diff --git a/.github/workflows/build-wheels.yml b/.github/workflows/build-wheels.yml index 6ac43670..9ab03a9a 100644 --- a/.github/workflows/build-wheels.yml +++ b/.github/workflows/build-wheels.yml @@ -32,6 +32,9 @@ jobs: - os: macos-latest target: aarch64-apple-darwin manylinux: auto + - os: windows-latest + target: x86_64-pc-windows-msvc + manylinux: auto runs-on: ${{ matrix.os }} steps: - uses: actions/checkout@v6 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 88519187..6db5a341 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -15,6 +15,30 @@ jobs: - run: cargo clippy --all-targets --locked -- -D warnings - run: cargo test --locked + # Fast native Windows gate. The pinned real-tool lifecycle has its own matrix + # below because it is slower and needs Node-installed external binaries. + windows-build: + runs-on: windows-latest + steps: + - uses: actions/checkout@v6 + - uses: dtolnay/rust-toolchain@stable + with: + components: rustfmt, clippy + - uses: Swatinem/rust-cache@v2.9.1 + - run: cargo fmt --all -- --check + - run: cargo clippy --all-targets --locked -- -D warnings + - run: cargo test --all-targets --locked + - run: cargo build --release --locked + - name: Smoke-test distributable Windows binary + shell: pwsh + run: | + New-Item -ItemType Directory -Force target/package-smoke | Out-Null + Copy-Item target/release/hcom.exe target/package-smoke/hcom-windows-x86_64.exe + $version = & target/package-smoke/hcom-windows-x86_64.exe --version + if ($LASTEXITCODE -ne 0 -or $version -notmatch '^hcom ') { + throw "Packaged binary smoke test failed: $version" + } + typecheck: runs-on: ubuntu-latest permissions: @@ -30,8 +54,8 @@ jobs: - run: bash ./scripts/typecheck.sh real-tool-tests: - name: Real tool (${{ matrix.tool }}) - runs-on: ubuntu-latest + name: Real tool (${{ matrix.tool }}, ${{ matrix.os }}) + runs-on: ${{ matrix.os }} timeout-minutes: 25 permissions: contents: read @@ -39,15 +63,33 @@ jobs: fail-fast: false matrix: include: - - tool: codex + - os: ubuntu-latest + tool: codex + package: "@openai/codex@0.141.0" + cache: codex-0.141.0 + test: real_tool_codex + - os: ubuntu-latest + tool: claude + package: "@anthropic-ai/claude-code@2.1.185" + cache: claude-2.1.185 + test: real_tool_claude + - os: ubuntu-latest + tool: relay + package: "@anthropic-ai/claude-code@2.1.185" + cache: relay-claude-2.1.185 + test: test_relay_roundtrip + - os: windows-latest + tool: codex package: "@openai/codex@0.141.0" cache: codex-0.141.0 test: real_tool_codex - - tool: claude + - os: windows-latest + tool: claude package: "@anthropic-ai/claude-code@2.1.185" cache: claude-2.1.185 test: real_tool_claude - - tool: relay + - os: windows-latest + tool: relay package: "@anthropic-ai/claude-code@2.1.185" cache: relay-claude-2.1.185 test: test_relay_roundtrip @@ -66,6 +108,23 @@ jobs: target/mock-tools target/npm-cache key: ${{ runner.os }}-mock-tools-${{ matrix.cache }} - - run: ./scripts/install-mock-tools.sh '${{ matrix.package }}' - - run: echo "$PWD/target/mock-tools/bin" >> "$GITHUB_PATH" - - run: cargo test --locked --test '${{ matrix.test }}' -- --ignored --nocapture --test-threads=1 + - name: Install pinned tool (Unix) + if: runner.os != 'Windows' + run: ./scripts/install-mock-tools.sh '${{ matrix.package }}' + - name: Add pinned tool to PATH (Unix) + if: runner.os != 'Windows' + run: echo "$PWD/target/mock-tools/bin" >> "$GITHUB_PATH" + - name: Install pinned tool (Windows) + if: runner.os == 'Windows' + shell: pwsh + run: ./scripts/install-mock-tools.ps1 '${{ matrix.package }}' + - name: Add pinned tool to PATH (Windows) + if: runner.os == 'Windows' + shell: pwsh + run: (Resolve-Path target/mock-tools).Path | Out-File -FilePath $env:GITHUB_PATH -Encoding utf8 -Append + - shell: bash + if: runner.os != 'Windows' + run: cargo test --locked --test '${{ matrix.test }}' -- --ignored --nocapture --test-threads=1 + - shell: pwsh + if: runner.os == 'Windows' + run: cargo test --locked --test '${{ matrix.test }}' -- --ignored --nocapture --test-threads=1 diff --git a/Cargo.lock b/Cargo.lock index 13299fc3..9ade3087 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -231,6 +231,12 @@ version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" +[[package]] +name = "cfg_aliases" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fd16c4719339c4530435d38e511904438d07cce7950afa3718a84ac36c10e89e" + [[package]] name = "cfg_aliases" version = "0.2.1" @@ -593,6 +599,12 @@ dependencies = [ "litrs", ] +[[package]] +name = "downcast-rs" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75b325c5dbd37f80359721ad39aca5a29fb04c89279657cffdda8736d0c0b9d2" + [[package]] name = "dunce" version = "1.0.5" @@ -874,6 +886,7 @@ dependencies = [ "libc", "lru", "nix 0.31.3", + "portable-pty", "rand 0.10.1", "ratatui", "regex", @@ -896,6 +909,7 @@ dependencies = [ "uuid", "vt100", "webpki-roots", + "windows-sys 0.60.2", ] [[package]] @@ -1190,6 +1204,18 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "nix" +version = "0.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab2156c4fce2f8df6c499cc1c763e4394b7482525bf2a9701c9d79d215f519e4" +dependencies = [ + "bitflags 2.11.1", + "cfg-if", + "cfg_aliases 0.1.1", + "libc", +] + [[package]] name = "nix" version = "0.29.0" @@ -1198,7 +1224,7 @@ checksum = "71e2746dc3a24dd78b3cfcb7be93368c6de9963d30f43a6a73998a9cf4b17b46" dependencies = [ "bitflags 2.11.1", "cfg-if", - "cfg_aliases", + "cfg_aliases 0.2.1", "libc", "memoffset", ] @@ -1211,7 +1237,7 @@ checksum = "cf20d2fde8ff38632c426f1165ed7436270b44f199fc55284c38276f9db47c3d" dependencies = [ "bitflags 2.11.1", "cfg-if", - "cfg_aliases", + "cfg_aliases 0.2.1", "libc", ] @@ -1452,6 +1478,27 @@ version = "1.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c33a9471896f1c69cecef8d20cbe2f7accd12527ce60845ff44c153bb2a21b49" +[[package]] +name = "portable-pty" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4a596a2b3d2752d94f51fac2d4a96737b8705dddd311a32b9af47211f08671e" +dependencies = [ + "anyhow", + "bitflags 1.3.2", + "downcast-rs", + "filedescriptor", + "lazy_static", + "libc", + "log", + "nix 0.28.0", + "serial2", + "shared_library", + "shell-words", + "winapi", + "winreg", +] + [[package]] name = "powerfmt" version = "0.2.0" @@ -1952,6 +1999,17 @@ dependencies = [ "serde_core", ] +[[package]] +name = "serial2" +version = "0.2.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9eb6ea5562eeaed6936b8b54e086aa0f88b9e5b1bef45beb038e2519fa1185b1" +dependencies = [ + "cfg-if", + "libc", + "windows-sys 0.61.2", +] + [[package]] name = "serial_test" version = "3.4.0" @@ -2000,6 +2058,16 @@ dependencies = [ "digest 0.11.3", ] +[[package]] +name = "shared_library" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a9e7e0f2bfae24d8a5b5a66c5b257a83c7412304311512a0c054cd5e619da11" +dependencies = [ + "lazy_static", + "libc", +] + [[package]] name = "shell-words" version = "1.1.1" @@ -2815,7 +2883,16 @@ version = "0.52.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" dependencies = [ - "windows-targets", + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-sys" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb" +dependencies = [ + "windows-targets 0.53.5", ] [[package]] @@ -2833,14 +2910,31 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" dependencies = [ - "windows_aarch64_gnullvm", - "windows_aarch64_msvc", - "windows_i686_gnu", - "windows_i686_gnullvm", - "windows_i686_msvc", - "windows_x86_64_gnu", - "windows_x86_64_gnullvm", - "windows_x86_64_msvc", + "windows_aarch64_gnullvm 0.52.6", + "windows_aarch64_msvc 0.52.6", + "windows_i686_gnu 0.52.6", + "windows_i686_gnullvm 0.52.6", + "windows_i686_msvc 0.52.6", + "windows_x86_64_gnu 0.52.6", + "windows_x86_64_gnullvm 0.52.6", + "windows_x86_64_msvc 0.52.6", +] + +[[package]] +name = "windows-targets" +version = "0.53.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3" +dependencies = [ + "windows-link", + "windows_aarch64_gnullvm 0.53.1", + "windows_aarch64_msvc 0.53.1", + "windows_i686_gnu 0.53.1", + "windows_i686_gnullvm 0.53.1", + "windows_i686_msvc 0.53.1", + "windows_x86_64_gnu 0.53.1", + "windows_x86_64_gnullvm 0.53.1", + "windows_x86_64_msvc 0.53.1", ] [[package]] @@ -2849,48 +2943,96 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53" + [[package]] name = "windows_aarch64_msvc" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" +[[package]] +name = "windows_aarch64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006" + [[package]] name = "windows_i686_gnu" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" +[[package]] +name = "windows_i686_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3" + [[package]] name = "windows_i686_gnullvm" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" +[[package]] +name = "windows_i686_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c" + [[package]] name = "windows_i686_msvc" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" +[[package]] +name = "windows_i686_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2" + [[package]] name = "windows_x86_64_gnu" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" +[[package]] +name = "windows_x86_64_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499" + [[package]] name = "windows_x86_64_gnullvm" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1" + [[package]] name = "windows_x86_64_msvc" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" +[[package]] +name = "windows_x86_64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" + [[package]] name = "winnow" version = "1.0.3" @@ -2900,6 +3042,15 @@ dependencies = [ "memchr", ] +[[package]] +name = "winreg" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "80d0f4e272c85def139476380b12f9ac60926689dd2e01d4923222f40580869d" +dependencies = [ + "winapi", +] + [[package]] name = "wit-bindgen" version = "0.51.0" diff --git a/Cargo.toml b/Cargo.toml index 50cc6bf9..20ff60dd 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -15,12 +15,10 @@ path = "src/main.rs" [dependencies] clap = { version = "4", features = ["derive"] } -nix = { version = "0.31.2", features = ["term", "signal", "poll", "process", "fs", "ioctl"] } vt100 = "0.16" serde = { version = "1", features = ["derive"] } serde_json = "1" anyhow = "1" -libc = "0.2.184" rusqlite = { version = "0.39.0", features = ["bundled"] } regex = "1" thiserror = "2" @@ -38,7 +36,6 @@ toml_edit = "0.25.10" tempfile = "3" rand = "0.10.0" glob = "0.3" -signal-hook = "0.4.4" uuid = { version = "1.23.0", features = ["v4"] } # Relay (MQTT) dependencies rumqttc = { version = "0.25.1", default-features = false, features = ["use-rustls"] } @@ -51,6 +48,28 @@ webpki-roots = "1.0.6" rustls-native-certs = "0.8.3" lru = "0.16" +# Unix-only: PTY, signals, fd polling, file locks, process groups. +[target.'cfg(unix)'.dependencies] +nix = { version = "0.31.2", features = ["term", "signal", "poll", "process", "fs", "ioctl"] } +libc = "0.2.184" +signal-hook = "0.4.4" + +# Windows-only: process/job-object control, winsock select, file locking. +[target.'cfg(windows)'.dependencies] +windows-sys = { version = "0.60", features = [ + "Win32_Foundation", + "Win32_Security", + "Win32_System_Threading", + "Win32_System_JobObjects", + "Win32_System_Diagnostics_ToolHelp", + "Win32_System_IO", + "Win32_System_Console", + "Win32_Storage_FileSystem", + "Win32_Networking_WinSock", +] } +# ConPTY-backed pseudoterminal for the Windows PTY wrapper. +portable-pty = "0.9" + [dev-dependencies] serial_test = "3" diff --git a/Justfile b/Justfile index a0b28136..b016cd2a 100644 --- a/Justfile +++ b/Justfile @@ -1,5 +1,7 @@ set shell := ["bash", "-eu", "-o", "pipefail", "-c"] +set windows-shell := ["powershell.exe", "-NoProfile", "-Command"] +windows-mock-bin := justfile_directory() + "/target/mock-tools" default-mock-prefix := if os() == "android" { home_directory() + "/.cache/hcom-mock-tools" } else { @@ -44,3 +46,36 @@ ci: mock-tools typecheck ulimit -Su "$(ulimit -Hu)" && TMPDIR="{{ci-tmp}}" PATH="{{mock-bin}}:$PATH" cargo test --locked --test real_tool_codex -- --ignored --nocapture --test-threads=1 ulimit -Su "$(ulimit -Hu)" && TMPDIR="{{ci-tmp}}" PATH="{{mock-bin}}:$PATH" cargo test --locked --test real_tool_claude -- --ignored --nocapture --test-threads=1 ulimit -Su "$(ulimit -Hu)" && TMPDIR="{{ci-tmp}}" PATH="{{mock-bin}}:$PATH" cargo test --locked --test test_relay_roundtrip -- --ignored --nocapture --test-threads=1 + +# Match the native Windows CI gate. The package smoke copy is deliberately +# renamed instead of using a second Cargo target directory. +[windows] +mock-tools-windows: + & "{{justfile_directory()}}/scripts/install-mock-tools.ps1" + +[windows] +real-tool-tests-windows: mock-tools-windows + $env:PATH = "{{windows-mock-bin}};" + $env:PATH; cargo test --locked --test real_tool_codex -- --ignored --nocapture --test-threads=1 + $env:PATH = "{{windows-mock-bin}};" + $env:PATH; cargo test --locked --test real_tool_claude -- --ignored --nocapture --test-threads=1 + $env:PATH = "{{windows-mock-bin}};" + $env:PATH; cargo test --locked --test test_relay_roundtrip -- --ignored --nocapture --test-threads=1 + +[windows] +ci-windows: + cargo fmt --all -- --check + cargo clippy --all-targets --locked -- -D warnings + cargo test --all-targets --locked + just package-smoke-windows + just real-tool-tests-windows + +[windows] +package-smoke-windows: + cargo build --release --locked + New-Item -ItemType Directory -Force target/package-smoke | Out-Null + # Move (not copy): real-tool-tests-windows runs next and every test-spawned + # hcom process sets HCOM_DEV_ROOT, which makes dev_root_binary() pick + # whichever of target/release or target/debug has the newer mtime. Leaving + # a freshly-built target/release/hcom.exe behind would make it win over the + # debug binary cargo test just built, so tests would silently re-exec into + # this release build instead of exercising their own binary. + Move-Item -Force target/release/hcom.exe target/package-smoke/hcom-windows-x86_64.exe + $version = & target/package-smoke/hcom-windows-x86_64.exe --version; if ($LASTEXITCODE -ne 0 -or $version -notmatch '^hcom ') { throw "Packaged binary smoke test failed: $version" }; Write-Output $version diff --git a/README.md b/README.md index b33def91..2da91ec5 100644 --- a/README.md +++ b/README.md @@ -29,6 +29,11 @@ brew install aannoo/hcom/hcom curl -fsSL https://github.com/aannoo/hcom/releases/latest/download/hcom-installer.sh | sh ``` +```powershell +# PowerShell installer for Windows +irm https://github.com/aannoo/hcom/releases/latest/download/hcom-installer.ps1 | iex +``` + ```bash # With PyPI uv tool install hcom # or: pip install hcom @@ -426,6 +431,9 @@ cargo build && cargo test hcom config dev_root $(pwd) hcom status just ci # run the CI gate locally + +# On native Windows (PowerShell) +just ci-windows ``` --- diff --git a/dist-workspace.toml b/dist-workspace.toml index 0ead4d81..73dd375e 100644 --- a/dist-workspace.toml +++ b/dist-workspace.toml @@ -8,9 +8,9 @@ cargo-dist-version = "0.31.0" # CI backends to support ci = "github" # Target platforms to build apps for (Rust target-triple syntax) -targets = ["aarch64-apple-darwin", "aarch64-linux-android", "aarch64-unknown-linux-gnu", "aarch64-unknown-linux-musl", "x86_64-apple-darwin", "x86_64-unknown-linux-gnu", "x86_64-unknown-linux-musl"] +targets = ["aarch64-apple-darwin", "aarch64-linux-android", "aarch64-unknown-linux-gnu", "aarch64-unknown-linux-musl", "x86_64-apple-darwin", "x86_64-pc-windows-msvc", "x86_64-unknown-linux-gnu", "x86_64-unknown-linux-musl"] # The installers to generate for each app -installers = ["shell", "homebrew"] +installers = ["shell", "powershell", "homebrew"] # Use .tar.gz on Unix so curl-installed users on minimal Linux systems # don't need a separate `xz` executable just to unpack releases. unix-archive = ".tar.gz" diff --git a/pyproject.toml b/pyproject.toml index b0bec9fa..44355163 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -16,6 +16,7 @@ classifiers = [ "Environment :: Console", "Intended Audience :: Developers", "Operating System :: MacOS", + "Operating System :: Microsoft :: Windows", "Operating System :: POSIX :: Linux", "Topic :: Communications :: Chat", "Topic :: Software Development", diff --git a/scripts/install-mock-tools.ps1 b/scripts/install-mock-tools.ps1 new file mode 100644 index 00000000..6bc0c1df --- /dev/null +++ b/scripts/install-mock-tools.ps1 @@ -0,0 +1,46 @@ +param( + [Parameter(ValueFromRemainingArguments = $true)] + [string[]] $Packages +) + +$ErrorActionPreference = "Stop" +$root = Split-Path -Parent $PSScriptRoot +$prefix = if ($env:HCOM_MOCK_TOOLS_PREFIX) { + $env:HCOM_MOCK_TOOLS_PREFIX +} else { + Join-Path $root "target/mock-tools" +} +$cache = if ($env:HCOM_MOCK_TOOLS_NPM_CACHE) { + $env:HCOM_MOCK_TOOLS_NPM_CACHE +} else { + Join-Path $root "target/npm-cache" +} + +if (-not $Packages -or $Packages.Count -eq 0) { + $Packages = @( + "@openai/codex@0.141.0", + "@anthropic-ai/claude-code@2.1.185" + ) +} + +New-Item -ItemType Directory -Force $prefix, $cache | Out-Null + +$npm = (Get-Command npm.cmd -ErrorAction Stop).Source +& $npm install ` + --global ` + --prefix $prefix ` + --cache $cache ` + --no-audit ` + --no-fund ` + --fetch-retries 5 ` + --fetch-retry-mintimeout 20000 ` + --fetch-retry-maxtimeout 120000 ` + --fetch-timeout 600000 ` + @Packages +if ($LASTEXITCODE -ne 0) { + throw "npm install failed with exit code $LASTEXITCODE" +} + +# npm's global executable directory is on Windows and /bin +# on Unix. Print it so callers can add the exact directory to PATH. +Write-Output $prefix diff --git a/skills/hcom-agent-messaging/SKILL.md b/skills/hcom-agent-messaging/SKILL.md index ad561edc..42cc5768 100644 --- a/skills/hcom-agent-messaging/SKILL.md +++ b/skills/hcom-agent-messaging/SKILL.md @@ -141,7 +141,7 @@ place scripts in `~/.hcom/scripts/` as `.sh` or `.py`. run with `hcom run - **always use `trap cleanup ERR INT TERM`** — orphan headless agents run indefinitely - **always use `hcom kill` for cleanup** (not `stop`) — kill also closes the terminal pane - **always forward `--name`** — hcom injects it, scripts must propagate it -- **always use `--go`** on launch/kill — without it, scripts hang on confirmation prompt +- **always use `--go`** on launch commands — without it, scripts hang on confirmation prompt (`hcom kill` never prompts, so `--go` is optional there) ### agent topologies diff --git a/skills/hcom-agent-messaging/references/gotchas.md b/skills/hcom-agent-messaging/references/gotchas.md index 524d3469..aaf46185 100644 --- a/skills/hcom-agent-messaging/references/gotchas.md +++ b/skills/hcom-agent-messaging/references/gotchas.md @@ -4,18 +4,17 @@ Common issues and fixes discovered during real testing. ## Script Hangs Forever -**Cause:** Missing `--go` flag on `hcom 1 claude`, `hcom kill`, or any command that normally prompts for confirmation. +**Cause:** Missing `--go` flag on launch commands such as `hcom 1 claude`. -**Fix:** Always include `--go` on every hcom launch and kill command in scripts. +**Fix:** Include `--go` on launch commands in scripts. `hcom kill` does not +prompt, so `--go` is optional for kill commands. ```bash # WRONG - hangs waiting for user confirmation hcom 1 claude --tag worker --headless --hcom-prompt "..." -hcom kill luna # RIGHT hcom 1 claude --tag worker --go --headless --hcom-prompt "..." -hcom kill luna --go ``` ## Agent Not Receiving Messages diff --git a/src/bootstrap.rs b/src/bootstrap.rs index 2dbc0544..627e4899 100644 --- a/src/bootstrap.rs +++ b/src/bootstrap.rs @@ -56,11 +56,11 @@ Routing rules: You MUST use `hcom --name {instance_name}` for all hcom commands: -- Message: send @name(s) [--intent request|inform|ack] [--reply-to ] [--thread ] -- "plain text" +- Message: send {target_name_s} [--intent request|inform|ack] [--reply-to ] [--thread ] -- 'plain text' Or (for code/md/backticks) instead of --: --file | --base64 | pipe/heredoc - Example: send @luna @nova --intent ack --reply-to 82 --name {instance_name} -- "ok" + Example: send {target_luna} {target_nova} --intent ack --reply-to 82 --name {instance_name} -- 'ok' - See who's active: list [-v] [--json] [--names] [--format '{{name}} {{status}}'] [name] -- Read another's conversation: transcript [name] [N-M] [--last N] [--full] | transcript search "text" [--all] +- Read another's conversation: transcript [name] [N-M] [--last N] [--full] | transcript search 'text' [--all] - View events: events [--last N] [--all] [--sql EXPR] [filters] Filters (same flag=OR, different=AND): --agent NAME | --type message|status|life | --status listening|active|blocked | --cmd PATTERN (contains, ^prefix, =exact) | --file PATH (*.py for glob, file.py for contains) Event-based notifications, watch agents, subscribe, react: events sub [filters] | --help @@ -81,7 +81,7 @@ If unsure about syntax, always run `hcom --help` FIRST. Do not guess. 1. Task via hcom → ack immediately, do work, report via hcom 2. No filler messages (greetings, thanks, congratulations). 3. Use --intent on sends: request (want reply), inform (dont need reply), ack (responding). -4. User says "the gemini/claude/codex agent" or unclear → run `hcom list` to resolve name +4. User says 'the gemini/claude/codex agent' or unclear → run `hcom list` to resolve name Agent names are 4-letter CVCV words. When user mentions one, they mean an agent. {active_instances} @@ -89,7 +89,7 @@ Agent names are 4-letter CVCV words. When user mentions one, they mean an agent. This is session context, not a task for immediate action."#; const TAG_NOTICE: &str = r#" -You are tagged "{tag}". Message your group: send @{tag}- -- msg"#; +You are tagged '{tag}'. Message your group: send {target_tag} -- msg"#; const RELAY_NOTICE: &str = r#" Remote agents have suffix (e.g., `luna:BOXE`). @luna = local only; @luna:BOXE = remote. Remote event IDs 42:BOXE. Remote launch needs --device BOXE and --dir passed in. Remote hcom events needs --remote-fetch --device BOXE. Remote events sub needs --device BOXE."#; @@ -161,7 +161,7 @@ Messages do NOT arrive automatically. LISTENING REQUIREMENT: - After sending hcom message expecting reply → `hcom listen --timeout 60 --name {instance_name}` - After receiving a task via hcom → do the work, report, then enter CONNECTED MODE -- User says "stay connected" → enter CONNECTED MODE +- User says 'stay connected' → enter CONNECTED MODE CONNECTED MODE: 1. Run exactly one foreground blocking command: @@ -192,7 +192,7 @@ You're participating in the hcom multi-agent network. - Your name: {subagent_name} - Your parent: {parent_name} - Use "--name {subagent_name}" for all hcom commands -- Announce to parent once: send @{parent_name} --intent inform -- "Connected as {subagent_name}" +- Announce to parent once: send {target_parent} --intent inform -- "Connected as {subagent_name}" Messages instantly auto-arrive via tags — end your turn to receive them. @@ -207,8 +207,8 @@ Response rules: hcom message → respond via hcom send Commands: - {hcom_cmd} send @name(s) [--intent request|inform|ack] [--reply-to ] [--thread ] -- <"message"> (or --stdin, --file , --base64 ) - Example: {hcom_cmd} send @luna @nova --intent ack --reply-to 82 --name {subagent_name} -- "ok" | Code/markdown: replace "ok" with --file + {hcom_cmd} send {target_name_s} [--intent request|inform|ack] [--reply-to ] [--thread ] -- <"message"> (or --stdin, --file , --base64 ) + Example: {hcom_cmd} send {target_luna} {target_nova} --intent ack --reply-to 82 --name {subagent_name} -- "ok" | Code/markdown: replace "ok" with --file {hcom_cmd} list --name {subagent_name} {hcom_cmd} events --name {subagent_name} {hcom_cmd} --help --name {subagent_name} @@ -279,6 +279,19 @@ fn launch_tool_names() -> String { .join("|") } +/// Render an hcom recipient token safely for the current platform's shell. +/// +/// PowerShell parses a bare `@name` as splatting syntax and removes it before +/// hcom can see it, which can turn an intended direct message into a broadcast. +fn recipient_token(name: &str) -> String { + let token = format!("@{name}"); + if cfg!(windows) { + format!("'{token}'") + } else { + token + } +} + /// Get combined list of bundled + user scripts. /// Returns empty string if none, or "Scripts: clone, debate, ...". fn get_scripts(hcom_dir: &std::path::Path) -> String { @@ -392,6 +405,10 @@ fn render_template(template: &str, ctx: &BootstrapContext) -> String { .replace("{active_instances}", &ctx.active_instances) .replace("{scripts}", &ctx.scripts) .replace("{launch_tools}", &ctx.launch_tools) + .replace("{target_name_s}", &recipient_token("name(s)")) + .replace("{target_luna}", &recipient_token("luna")) + .replace("{target_nova}", &recipient_token("nova")) + .replace("{target_tag}", &recipient_token(&format!("{}-", ctx.tag))) .replace("{{", "{") .replace("}}", "}") } @@ -499,13 +516,24 @@ pub fn get_bootstrap( // Rewrite hcom references if using alternate command if ctx.hcom_cmd != "hcom" { - let sentinel = "__HCOM_CMD__"; - result = result.replace(&ctx.hcom_cmd, sentinel); + let command_sentinel = "__HCOM_CMD__"; + let marker_sentinel = "__HCOM_IDENTITY_MARKER__"; + let open_tag_sentinel = "__HCOM_OPEN_TAG__"; + let close_tag_sentinel = "__HCOM_CLOSE_TAG__"; + result = result + .replace(&ctx.hcom_cmd, command_sentinel) + .replace("[hcom:", marker_sentinel) + .replace("", open_tag_sentinel) + .replace("", close_tag_sentinel); result = regex::Regex::new(r"\bhcom\b") .unwrap() .replace_all(&result, &ctx.hcom_cmd) .to_string(); - result = result.replace(sentinel, &ctx.hcom_cmd); + result = result + .replace(command_sentinel, &ctx.hcom_cmd) + .replace(marker_sentinel, "[hcom:") + .replace(open_tag_sentinel, "") + .replace(close_tag_sentinel, ""); } format!( @@ -521,6 +549,10 @@ pub fn get_subagent_bootstrap(subagent_name: &str, parent_name: &str) -> String let result = SUBAGENT_BOOTSTRAP .replace("{subagent_name}", subagent_name) .replace("{parent_name}", parent_name) + .replace("{target_parent}", &recipient_token(parent_name)) + .replace("{target_name_s}", &recipient_token("name(s)")) + .replace("{target_luna}", &recipient_token("luna")) + .replace("{target_nova}", &recipient_token("nova")) .replace("{hcom_cmd}", &hcom_cmd) .replace("{SENDER}", SENDER); @@ -717,8 +749,12 @@ mod tests { None, ); - assert!(result.contains("tagged \"p0c\"")); - assert!(result.contains("send @p0c-")); + assert!(result.contains("tagged 'p0c'")); + if cfg!(windows) { + assert!(result.contains("send '@p0c-'")); + } else { + assert!(result.contains("send @p0c-")); + } } #[test] @@ -792,6 +828,40 @@ mod tests { assert!(result.contains("--name luna_reviewer_1")); assert!(result.contains(SENDER)); assert!(result.contains("")); + if cfg!(windows) { + assert!(result.contains("send '@luna' --intent inform")); + assert!(result.contains("send '@name(s)'")); + } else { + assert!(result.contains("send @luna --intent inform")); + assert!(result.contains("send @name(s)")); + } + } + + #[test] + fn test_bootstrap_quotes_send_recipients_on_windows() { + let (tmp, db) = setup_test_db(); + let result = get_bootstrap( + &db, + tmp.path(), + "luna", + "claude", + false, + true, + "", + "team", + false, + None, + ); + + if cfg!(windows) { + assert!(result.contains("send '@name(s)'")); + assert!(result.contains("send '@luna' '@nova'")); + assert!(result.contains("send '@team-' -- msg")); + } else { + assert!(result.contains("send @name(s)")); + assert!(result.contains("send @luna @nova")); + assert!(result.contains("send @team- -- msg")); + } } #[test] @@ -1027,7 +1097,7 @@ mod tests { None, ); - assert!(result.contains("tagged \"team-a\"")); + assert!(result.contains("tagged 'team-a'")); assert!(!result.contains("team-b")); } diff --git a/src/commands/config.rs b/src/commands/config.rs index 0f2e6780..4191b9ee 100644 --- a/src/commands/config.rs +++ b/src/commands/config.rs @@ -328,7 +328,7 @@ fn validate_config_args(field_name: &str, value: &str) -> Result<(), String> { return Ok(()); } - let tokens = crate::tools::args_common::shell_split(value) + let tokens = crate::tools::args_common::shell_split(value, cfg!(windows)) .map_err(|e| format!("invalid {field_name} from config: {e}"))?; let errors = validate_tool_args(&tool, &tokens); if errors.is_empty() { @@ -1722,12 +1722,13 @@ pub fn terminal_help_text(show_current: bool) -> String { }; lines.push(format!("Current: default (auto-detect){suffix}")); } else { - let kind = - if crate::config::get_merged_preset(¤t).is_some_and(|p| p.close.is_some()) { - "managed" - } else { - "open only" - }; + let kind = if crate::config::get_merged_preset(¤t) + .is_some_and(|p| p.has_close(cfg!(windows))) + { + "managed" + } else { + "open only" + }; lines.push(format!("Current: {current} ({kind}) [{source}]")); } lines.push(String::new()); @@ -1766,7 +1767,8 @@ pub fn terminal_help_text(show_current: bool) -> String { lines.push(String::new()); lines.push("Other (opens window only):".to_string()); for (name, preset) in TERMINAL_PRESETS.iter() { - if all_managed.contains(name) || preset.close.is_some() { + let has_close = preset.close.default.is_some() || preset.close.windows.is_some(); + if all_managed.contains(name) || has_close { continue; } if !preset.platforms.is_empty() && !preset.platforms.contains(&platform) { @@ -1784,10 +1786,11 @@ pub fn terminal_help_text(show_current: bool) -> String { t.iter() .filter(|(name, _)| !TERMINAL_PRESETS.iter().any(|(n, _)| *n == name.as_str())) .map(|(name, val)| { - let has_close = val - .get("close") - .and_then(|v| v.as_str()) - .is_some_and(|s| !s.is_empty()); + // close may be a legacy string or an argv array. + let has_close = val.get("close").is_some_and(|v| { + v.as_str().is_some_and(|s| !s.is_empty()) + || v.as_array().is_some_and(|a| !a.is_empty()) + }); (name.clone(), has_close) }) .collect() @@ -1919,9 +1922,16 @@ fn config_terminal(argv: &[String], setup_mode: bool) -> i32 { println!("Terminal: {current} [{source}]"); } println!("\nAvailable presets:"); - for (name, _preset) in TERMINAL_PRESETS.iter() { + let platform = crate::shared::platform::platform_name(); + for (name, preset) in TERMINAL_PRESETS.iter() { let marker = if *name == current { " ← current" } else { "" }; - println!(" {}{}", name, marker); + let availability = + if preset.platforms.is_empty() || preset.platforms.contains(&platform) { + "" + } else { + " (unavailable on this platform)" + }; + println!(" {name}{availability}{marker}"); } // Include TOML-defined presets not in built-ins let toml_path = crate::paths::config_toml_path(); @@ -1959,11 +1969,29 @@ fn config_terminal(argv: &[String], setup_mode: bool) -> i32 { } } + if argv.len() > 1 || preset_name.contains("{script}") { + let command = argv.join(" "); + if !command.contains("{script}") { + eprintln!("Error: Custom terminal command must contain {{script}}"); + return 1; + } + return match config_set("HCOM_TERMINAL", &command) { + Ok(()) => { + println!("Terminal set to custom command"); + 0 + } + Err(e) => { + eprintln!("Error: {e}"); + 1 + } + }; + } + // Validate preset exists (built-in or user-defined in config.toml) - let valid = TERMINAL_PRESETS + let builtin = TERMINAL_PRESETS .iter() - .any(|(name, _)| *name == preset_name.as_str()) - || crate::config::is_user_defined_preset(preset_name); + .find(|(name, _)| *name == preset_name.as_str()); + let valid = builtin.is_some() || crate::config::is_user_defined_preset(preset_name); if !valid { let mut available: Vec<&str> = TERMINAL_PRESETS.iter().map(|(name, _)| *name).collect(); @@ -1978,6 +2006,14 @@ fn config_terminal(argv: &[String], setup_mode: bool) -> i32 { eprintln!("Available: {}", available.join(", ")); return 1; } + let platform = crate::shared::platform::platform_name(); + if let Some((_, preset)) = builtin + && !preset.platforms.is_empty() + && !preset.platforms.contains(&platform) + { + eprintln!("Error: Terminal preset '{preset_name}' is not available on {platform}"); + return 1; + } match config_set("HCOM_TERMINAL", preset_name) { Ok(()) => { @@ -2345,7 +2381,7 @@ mod tests { #[test] fn test_terminal_help_text_documents_new_placeholders() { - // If you add a placeholder to parse_terminal_command, document it. + // If you add a placeholder to substitute_open_argv, document it. let help = terminal_help_text(false); for placeholder in ["{instance_name}", "{tool}", "{cwd}", "{pane_title}"] { assert!( diff --git a/src/commands/daemon.rs b/src/commands/daemon.rs index 860094f5..46ede081 100644 --- a/src/commands/daemon.rs +++ b/src/commands/daemon.rs @@ -48,10 +48,8 @@ pub(crate) fn daemon_stop() -> i32 { } }; - unsafe { - libc::kill(pid as i32, libc::SIGTERM); - } - println!("Sent SIGTERM to daemon (PID {pid})"); + crate::sys::process::terminate(pid); + println!("Requested daemon shutdown (PID {pid})"); for _ in 0..50 { thread::sleep(Duration::from_millis(100)); @@ -62,16 +60,15 @@ pub(crate) fn daemon_stop() -> i32 { } } - println!("Daemon did not respond to SIGTERM, escalating to SIGKILL"); - let kill_ret = unsafe { libc::kill(pid as i32, libc::SIGKILL) }; - if kill_ret != 0 { + println!("Daemon did not exit in time, forcing termination"); + if !crate::sys::process::kill(pid) { eprintln!( - "SIGKILL failed (errno {}), PID file retained", + "Force-kill failed (errno {}), PID file retained", std::io::Error::last_os_error() ); return 1; } - println!("Daemon killed (SIGKILL)"); + println!("Daemon killed"); crate::relay::worker::remove_relay_pid_file(); 0 } diff --git a/src/commands/kill.rs b/src/commands/kill.rs index 771406fa..c4fa5a68 100644 --- a/src/commands/kill.rs +++ b/src/commands/kill.rs @@ -28,12 +28,50 @@ pub struct KillTrackedResult { pub pid: u32, pub kill_result: terminal::KillResult, pub pane_closed: bool, + pub pane_retry_command: Option, pub preset_name: String, pub pane_id: String, } const EPERM_RECHECK_DELAY: std::time::Duration = std::time::Duration::from_millis(50); +#[derive(Clone, Copy)] +enum PaneCleanupProcessState { + Terminated, + AlreadyDead, + NotTerminated, +} + +impl From for PaneCleanupProcessState { + fn from(result: terminal::KillResult) -> Self { + match result { + terminal::KillResult::Sent => Self::Terminated, + terminal::KillResult::AlreadyDead => Self::AlreadyDead, + terminal::KillResult::PermissionDenied => Self::NotTerminated, + } + } +} + +fn report_incomplete_pane_cleanup( + process_state: PaneCleanupProcessState, + retry_command: Option<&str>, +) -> bool { + if matches!(process_state, PaneCleanupProcessState::NotTerminated) { + return false; + } + let Some(command) = retry_command else { + return false; + }; + let process_message = match process_state { + PaneCleanupProcessState::Terminated => "Process terminated", + PaneCleanupProcessState::AlreadyDead => "Process was already terminated", + PaneCleanupProcessState::NotTerminated => unreachable!(), + }; + eprintln!("{process_message}, but pane remains. Retry this command with approval/escalation:"); + eprintln!("{command}"); + true +} + /// Resolve who initiated the kill fn resolve_initiator(db: &HcomDb, explicit_name: Option<&str>) -> String { if let Some(name) = explicit_name { @@ -102,7 +140,7 @@ pub fn kill_tracked_instance( .pid .ok_or_else(|| format!("No tracked PID for '{}'", name))? as u32; let is_headless = inst.background != 0; - let (result, pane_closed, preset_name, pane_id) = + let (result, pane_closed, pane_retry_command, preset_name, pane_id) = kill_instance(db, name, pid, &inst, is_headless); stop_instance(db, name, initiator, "killed"); @@ -111,6 +149,7 @@ pub fn kill_tracked_instance( pid, kill_result: result, pane_closed, + pane_retry_command, preset_name, pane_id, }) @@ -132,6 +171,7 @@ fn handle_remote_kill_response(name: &str, response: &serde_json::Value) -> Resu let pane_closed = result["pane_closed"].as_bool().unwrap_or(false); let preset_name = result["preset_name"].as_str().unwrap_or(""); let pane_id = result["pane_id"].as_str().unwrap_or(""); + let pane_retry_command = result["pane_retry_command"].as_str(); let pane_info = pane_info_str(pane_closed, preset_name, pane_id); if kill_result == "permission_denied" { @@ -146,7 +186,25 @@ fn handle_remote_kill_response(name: &str, response: &serde_json::Value) -> Resu for line in lines { println!("{line}"); } - Ok(0) + if pane_retry_command.is_some() + && let Some((_, device)) = crate::relay::control::split_device_suffix(name) + { + eprintln!("Run the pane-close retry command on remote device {device}."); + } + Ok( + if report_incomplete_pane_cleanup( + match kill_result { + "sent" => PaneCleanupProcessState::Terminated, + "already_dead" => PaneCleanupProcessState::AlreadyDead, + _ => PaneCleanupProcessState::NotTerminated, + }, + pane_retry_command, + ) { + 1 + } else { + 0 + }, + ) } fn render_remote_kill_feedback( @@ -246,9 +304,10 @@ fn pane_info_str(pane_closed: bool, preset_name: &str, pane_id: &str) -> String String::new() } } else if !preset_name.is_empty() - && crate::config::get_merged_preset(preset_name).is_some_and(|p| p.close.is_some()) + && let Some(preset) = crate::config::get_merged_preset(preset_name) + && preset.has_close(cfg!(windows)) { - if crate::terminal::is_zellij_preset(preset_name) { + if crate::terminal::is_zellij_merged(&preset) { return " (zellij pane close unconfirmed)".to_string(); } format!(" (pane close failed for {})", preset_name) @@ -262,6 +321,7 @@ fn kill_all(db: &HcomDb, hcom_dir: &std::path::Path, initiator: &str) -> Result< let instances = db.iter_instances_full()?; let mut killed = 0; let mut failed = 0; + let mut incomplete = 0; // Collect active PIDs for orphan filtering let mut active_pids = HashSet::new(); @@ -275,7 +335,7 @@ fn kill_all(db: &HcomDb, hcom_dir: &std::path::Path, initiator: &str) -> Result< if let Some(pid) = inst.pid { active_pids.insert(pid as u32); let is_headless = inst.background != 0; - let (result, pane_closed, preset_name, pane_id) = + let (result, pane_closed, pane_retry_command, preset_name, pane_id) = kill_instance(db, &inst.name, pid as u32, inst, is_headless); let pane_info = pane_info_str(pane_closed, &preset_name, &pane_id); match result { @@ -301,6 +361,8 @@ fn kill_all(db: &HcomDb, hcom_dir: &std::path::Path, initiator: &str) -> Result< failed += 1; } } + incomplete += + report_incomplete_pane_cleanup(result.into(), pane_retry_command.as_deref()) as i32; // Clean up instance stop_instance(db, &inst.name, initiator, "killed"); println!(" To resume: hcom r {}", inst.name); @@ -313,7 +375,7 @@ fn kill_all(db: &HcomDb, hcom_dir: &std::path::Path, initiator: &str) -> Result< // Kill orphans too let orphans = pidtrack::get_orphan_processes(hcom_dir, Some(&active_pids)); for orphan in &orphans { - let (result, pane_closed) = terminal::kill_process( + let (result, pane_closed, pane_retry_command) = terminal::kill_process( orphan.pid, &orphan.terminal_preset, &orphan.pane_id, @@ -349,18 +411,23 @@ fn kill_all(db: &HcomDb, hcom_dir: &std::path::Path, initiator: &str) -> Result< failed += 1; } } + incomplete += + report_incomplete_pane_cleanup(result.into(), pane_retry_command.as_deref()) as i32; pidtrack::remove_pid(hcom_dir, orphan.pid); } if killed == 0 && failed == 0 { println!("No processes with tracked PIDs found"); - } else if failed > 0 { - println!("Killed {}, {} failed", killed, failed); + } else if failed > 0 || incomplete > 0 { + println!( + "Killed {}, {} failed, {} with incomplete pane cleanup", + killed, failed, incomplete + ); } else { println!("Killed {}", killed); } - Ok(if failed > 0 { 1 } else { 0 }) + Ok(if failed > 0 || incomplete > 0 { 1 } else { 0 }) } /// Kill instances by tag. @@ -373,12 +440,13 @@ fn kill_by_tag(db: &HcomDb, hcom_dir: &std::path::Path, tag: &str, initiator: &s let mut killed = 0; let mut failed = 0; + let mut incomplete = 0; // Kill active instances with this tag for inst in &tagged { if let Some(pid) = inst.pid { let is_headless = inst.background != 0; - let (result, pane_closed, preset_name, pane_id) = + let (result, pane_closed, pane_retry_command, preset_name, pane_id) = kill_instance(db, &inst.name, pid as u32, inst, is_headless); let pane_info = pane_info_str(pane_closed, &preset_name, &pane_id); match result { @@ -404,6 +472,8 @@ fn kill_by_tag(db: &HcomDb, hcom_dir: &std::path::Path, tag: &str, initiator: &s failed += 1; } } + incomplete += + report_incomplete_pane_cleanup(result.into(), pane_retry_command.as_deref()) as i32; stop_instance(db, &inst.name, initiator, "killed"); } else { // No PID tracked — clean up DB entry @@ -421,7 +491,7 @@ fn kill_by_tag(db: &HcomDb, hcom_dir: &std::path::Path, tag: &str, initiator: &s let tagged_orphans: Vec<_> = orphans.iter().filter(|o| o.tag == tag).collect(); for orphan in &tagged_orphans { let names = orphan.names.join(", "); - let (result, pane_closed) = terminal::kill_process( + let (result, pane_closed, pane_retry_command) = terminal::kill_process( orphan.pid, &orphan.terminal_preset, &orphan.pane_id, @@ -451,6 +521,8 @@ fn kill_by_tag(db: &HcomDb, hcom_dir: &std::path::Path, tag: &str, initiator: &s failed += 1; } } + incomplete += + report_incomplete_pane_cleanup(result.into(), pane_retry_command.as_deref()) as i32; pidtrack::remove_pid(hcom_dir, orphan.pid); } @@ -460,7 +532,7 @@ fn kill_by_tag(db: &HcomDb, hcom_dir: &std::path::Path, tag: &str, initiator: &s } println!("Killed {} (tag:{})", killed, tag); - Ok(if failed > 0 { 1 } else { 0 }) + Ok(if failed > 0 || incomplete > 0 { 1 } else { 0 }) } /// Kill a single instance by name. @@ -485,7 +557,7 @@ fn kill_single( || o.process_id == target || target_pid == Some(o.pid) }) { - let (result, pane_closed) = terminal::kill_process( + let (result, pane_closed, pane_retry_command) = terminal::kill_process( orphan.pid, &orphan.terminal_preset, &orphan.pane_id, @@ -516,7 +588,14 @@ fn kill_single( } } pidtrack::remove_pid(hcom_dir, orphan.pid); - return Ok(0); + return Ok( + if report_incomplete_pane_cleanup(result.into(), pane_retry_command.as_deref()) + { + 1 + } else { + 0 + }, + ); } bail!("Agent '{}' not found", target); } @@ -552,17 +631,18 @@ fn kill_single( let pane_closed = kill_result.pane_closed; let preset_name = kill_result.preset_name; let pane_id = kill_result.pane_id; + let pane_retry_command = kill_result.pane_retry_command; let result = kill_result.kill_result; let pane_info = pane_info_str(pane_closed, &preset_name, &pane_id); - match result { + let exit = match result { terminal::KillResult::Sent => { println!( "Sent SIGTERM to process group {} for '{}'{}", pid, name, pane_info ); println!(" To resume: hcom r {}", name); - Ok(0) + 0 } terminal::KillResult::AlreadyDead => { println!( @@ -570,30 +650,38 @@ fn kill_single( pid, name, pane_info ); println!(" To resume: hcom r {}", name); - Ok(0) + 0 } terminal::KillResult::PermissionDenied => { eprintln!( "Permission denied to kill process group {} for '{}'", pid, name ); - Ok(1) + 1 } - } + }; + Ok( + if report_incomplete_pane_cleanup(result.into(), pane_retry_command.as_deref()) { + 1 + } else { + exit + }, + ) } /// Kill a process and close its terminal pane. -/// Returns (KillResult, pane_closed, preset_name, pane_id). +/// Returns (KillResult, pane_closed, pane_retry_command, preset_name, pane_id). fn kill_instance( _db: &HcomDb, name: &str, pid: u32, instance: &crate::db::InstanceRow, is_headless: bool, -) -> (terminal::KillResult, bool, String, String) { +) -> (terminal::KillResult, bool, Option, String, String) { // Headless instances have no terminal pane — skip pane close if is_headless { - let (result, pane_closed) = terminal::kill_process(pid, "", "", "", "", "", ""); + let (result, pane_closed, pane_retry_command) = + terminal::kill_process(pid, "", "", "", "", "", ""); let result = normalize_kill_result(name, pid, result, pane_closed); log_info( "kill", @@ -603,7 +691,13 @@ fn kill_instance( name, pid, result, pane_closed ), ); - return (result, pane_closed, String::new(), String::new()); + return ( + result, + pane_closed, + pane_retry_command, + String::new(), + String::new(), + ); } let ti = terminal::resolve_terminal_info( @@ -611,7 +705,7 @@ fn kill_instance( instance.launch_context.as_deref(), ); - let (result, pane_closed) = terminal::kill_process( + let (result, pane_closed, pane_retry_command) = terminal::kill_process( pid, &ti.preset_name, &ti.pane_id, @@ -634,6 +728,7 @@ fn kill_instance( ( result, pane_closed, + pane_retry_command, ti.preset_name.clone(), ti.pane_id.clone(), ) @@ -681,6 +776,22 @@ mod tests { assert_eq!(result, terminal::KillResult::AlreadyDead); } + #[test] + fn test_incomplete_cleanup_requires_terminated_process_and_retry_command() { + assert!(report_incomplete_pane_cleanup( + PaneCleanupProcessState::Terminated, + Some("wezterm cli kill-pane --pane-id 123") + )); + assert!(!report_incomplete_pane_cleanup( + PaneCleanupProcessState::NotTerminated, + Some("wezterm cli kill-pane --pane-id 123") + )); + assert!(!report_incomplete_pane_cleanup( + PaneCleanupProcessState::AlreadyDead, + None + )); + } + #[test] fn test_handle_remote_kill_response_permission_denied_returns_nonzero() { let result = handle_remote_kill_response( diff --git a/src/commands/launch.rs b/src/commands/launch.rs index c8c4e0ec..8773ebb3 100644 --- a/src/commands/launch.rs +++ b/src/commands/launch.rs @@ -183,6 +183,7 @@ pub fn run(argv: &[String], flags: &GlobalFlags) -> Result { bail!("--dir path does not exist or is not a directory: {}", dir); } path.canonicalize() + .map(|p| crate::shared::platform::child_process_path(&p)) .map(|p| p.to_string_lossy().to_string()) .unwrap_or_else(|_| dir.clone()) } else { @@ -500,7 +501,7 @@ fn append_config_args(config_args: &str, cli_args: &[String]) -> Vec { // Don't silently drop hand-edited config args on a parse error (e.g. an // unterminated quote) — surface it so the launch isn't quietly missing // flags the user configured. - crate::tools::args_common::shell_split(config_args).unwrap_or_else(|err| { + crate::tools::args_common::shell_split(config_args, cfg!(windows)).unwrap_or_else(|err| { eprintln!("hcom: ignoring malformed configured args ({err}): {config_args}"); Vec::new() }) diff --git a/src/commands/listen.rs b/src/commands/listen.rs index d40744ec..5249dc4a 100644 --- a/src/commands/listen.rs +++ b/src/commands/listen.rs @@ -108,6 +108,19 @@ fn build_prefix(intent: Option<&str>, thread: Option<&str>, event_id: Option Result { + let Some(name) = sql.strip_prefix("stopped:") else { + return Ok(sql.to_string()); + }; + if name.is_empty() { + return Err("stopped: preset requires an agent name"); + } + let escaped = name.replace('\'', "''"); + Ok(format!( + "type='life' AND instance='{escaped}' AND json_extract(data, '$.action')='stopped'" + )) +} + /// Main entry point for `hcom listen` command. /// /// Returns exit code (0 = success, 1 = error, 130 = interrupted). @@ -188,7 +201,13 @@ pub fn cmd_listen(db: &HcomDb, args: &ListenArgs, ctx: Option<&CommandContext>) } if let Some(ref sql) = args.sql { - sql_parts.push(format!("({sql})")); + match expand_sql_preset(sql) { + Ok(expanded) => sql_parts.push(format!("({expanded})")), + Err(error) => { + eprintln!("Error: {error}"); + return 1; + } + } } if sql_parts.is_empty() { @@ -208,10 +227,7 @@ pub fn cmd_listen(db: &HcomDb, args: &ListenArgs, ctx: Option<&CommandContext>) if let Some(ref filter) = combined_sql { // Setup SIGTERM handler for filter mode let shutdown = Arc::new(AtomicBool::new(false)); - { - let shutdown_flag = Arc::clone(&shutdown); - let _ = signal_hook::flag::register(signal_hook::consts::SIGTERM, shutdown_flag); - } + crate::sys::signal::register_term(&shutdown); return listen_with_filter( db, filter, @@ -251,10 +267,7 @@ pub fn cmd_listen(db: &HcomDb, args: &ListenArgs, ctx: Option<&CommandContext>) // Setup SIGTERM handler for clean shutdown let shutdown = Arc::new(AtomicBool::new(false)); - { - let shutdown_flag = Arc::clone(&shutdown); - let _ = signal_hook::flag::register(signal_hook::consts::SIGTERM, shutdown_flag); - } + crate::sys::signal::register_term(&shutdown); // Check if already disconnected if db @@ -676,3 +689,23 @@ fn filter_listen_loop( } } } + +#[cfg(test)] +mod tests { + use super::expand_sql_preset; + + #[test] + fn stopped_sql_preset_expands_and_escapes_name() { + let sql = expand_sql_preset("stopped:win'probe").unwrap(); + assert!(sql.contains("instance='win''probe'")); + assert!(sql.contains("json_extract(data, '$.action')='stopped'")); + } + + #[test] + fn stopped_sql_preset_requires_name() { + assert_eq!( + expand_sql_preset("stopped:").unwrap_err(), + "stopped: preset requires an agent name" + ); + } +} diff --git a/src/commands/relay.rs b/src/commands/relay.rs index 1c4398ef..eec21353 100644 --- a/src/commands/relay.rs +++ b/src/commands/relay.rs @@ -350,31 +350,9 @@ fn relay_push() -> i32 { 0 } -fn stop_relay_worker_quiet() { - let Some(pid) = crate::relay::worker::relay_worker_pid() else { - return; - }; - - let _ = crate::relay::worker::stop_relay_worker(); - for _ in 0..50 { - if !crate::pidtrack::is_alive(pid) { - crate::relay::worker::remove_relay_pid_file(); - return; - } - std::thread::sleep(std::time::Duration::from_millis(100)); - } - - // Last resort: a stale worker pinned to the old namespace must not survive - // a relay reset or shutdown. - unsafe { - libc::kill(pid as i32, libc::SIGKILL); - } - crate::relay::worker::remove_relay_pid_file(); -} - fn restart_relay_worker_for_config_change() -> bool { if crate::relay::worker::is_relay_worker_running() { - stop_relay_worker_quiet(); + crate::relay::worker::stop_relay_worker_blocking(); } crate::relay::worker::ensure_worker(false) } @@ -473,7 +451,7 @@ fn relay_off(db: &HcomDb, argv: &[String]) -> i32 { } } - stop_relay_worker_quiet(); + crate::relay::worker::stop_relay_worker_blocking(); println!("{FG_YELLOW}Relay: disabled{RESET}"); println!("\nRun 'hcom relay connect' to reconnect"); 0 diff --git a/src/commands/reset_ops.rs b/src/commands/reset_ops.rs index 7ece2faf..43fc740e 100644 --- a/src/commands/reset_ops.rs +++ b/src/commands/reset_ops.rs @@ -32,9 +32,7 @@ pub(crate) fn archive_and_clear_db() -> Result, String> { }; if !has_content { - let _ = fs::remove_file(&db_file); - let _ = fs::remove_file(&db_wal); - let _ = fs::remove_file(&db_shm); + remove_database_files(&db_file, &db_wal, &db_shm)?; return Ok(None); } @@ -54,13 +52,33 @@ pub(crate) fn archive_and_clear_db() -> Result, String> { let _ = fs::copy(&db_shm, session_archive.join("hcom.db-shm")); } - let _ = fs::remove_file(&db_file); - let _ = fs::remove_file(&db_wal); - let _ = fs::remove_file(&db_shm); + remove_database_files(&db_file, &db_wal, &db_shm)?; Ok(Some(session_archive.to_string_lossy().to_string())) } +fn remove_database_files( + db_file: &std::path::Path, + db_wal: &std::path::Path, + db_shm: &std::path::Path, +) -> Result<(), String> { + // Remove sidecars first and the primary DB last. If a sidecar is locked, + // leave the primary database intact rather than creating a partial reset. + for path in [db_wal, db_shm, db_file] { + match fs::remove_file(path) { + Ok(()) => {} + Err(err) if err.kind() == std::io::ErrorKind::NotFound => {} + Err(err) => { + return Err(format!( + "could not remove {}: {err}. Stop other hcom processes using this database and retry", + path.display() + )); + } + } + } + Ok(()) +} + /// Clean temp files (launch scripts, prompts, old logs). pub(crate) fn clean_temp_files() { let base = hcom_dir(); @@ -215,4 +233,17 @@ mod tests { assert!(ts.contains('-')); assert!(ts.contains('_')); } + + #[test] + fn remove_database_files_reports_failure_instead_of_claiming_reset() { + let dir = tempfile::tempdir().unwrap(); + let db_path = dir.path().join("hcom.db"); + let wal_path = dir.path().join("hcom.db-wal"); + let shm_path = dir.path().join("hcom.db-shm"); + std::fs::create_dir(&db_path).unwrap(); + + let err = remove_database_files(&db_path, &wal_path, &shm_path).unwrap_err(); + assert!(err.contains("could not remove")); + assert!(err.contains("Stop other hcom processes")); + } } diff --git a/src/commands/resume.rs b/src/commands/resume.rs index a852f3f8..ad2e3e31 100644 --- a/src/commands/resume.rs +++ b/src/commands/resume.rs @@ -399,6 +399,7 @@ fn prepare_resume_plan_from_source( bail!("--dir path does not exist or is not a directory: {}", dir); } path.canonicalize() + .map(|p| crate::shared::platform::child_process_path(&p)) .map(|p| p.to_string_lossy().to_string()) .unwrap_or_else(|_| dir.clone()) } else if fork && !is_adoption { @@ -1656,66 +1657,12 @@ fn is_opencode_session_id(s: &str) -> bool { s.starts_with("ses_") && s.len() >= 8 && s[4..].chars().all(|c| c.is_ascii_alphanumeric()) } -/// Locate opencode's data dir. Opencode itself follows XDG on every platform -/// (including macOS, unlike `dirs::data_dir`), so we check XDG-style paths -/// first and only fall back to the platform default when neither exists. -/// -/// Resolution order, returning the first that actually exists on disk: -/// 1. `$XDG_DATA_HOME/opencode` (if `XDG_DATA_HOME` is set) -/// 2. `~/.local/share/opencode` (macOS XDG-style + Linux) -/// 3. `dirs::data_dir().join("opencode")` (macOS Apple-style + Windows -/// `%LOCALAPPDATA%`) -/// -/// If none exist, falls through to the platform default path (even though -/// it's absent) so callers can surface a useful "searched here" message. -fn opencode_family_data_dir(tool: &str) -> Option { - let mut candidates: Vec = Vec::new(); - if let Ok(xdg) = std::env::var("XDG_DATA_HOME") - && !xdg.is_empty() - { - candidates.push(std::path::PathBuf::from(xdg).join(tool)); - } - if let Some(home) = dirs::home_dir() { - candidates.push(home.join(".local/share").join(tool)); - } - if let Some(data) = dirs::data_dir() { - candidates.push(data.join(tool)); - } - - for candidate in &candidates { - if candidate.is_dir() { - return Some(candidate.clone()); - } - } - candidates.into_iter().next_back() -} - fn opencode_data_dir() -> Option { - opencode_family_data_dir("opencode") + crate::runtime_env::opencode_family_data_dir("opencode") } fn opencode_family_db_path(tool: &str) -> Option { - let data_dir = opencode_family_data_dir(tool)?; - if tool == "kilo" { - if std::env::var("KILO_DB").as_deref() == Ok(":memory:") { - return None; - } - return Some( - std::env::var("KILO_DB") - .ok() - .filter(|value| !value.is_empty()) - .map(std::path::PathBuf::from) - .map(|path| { - if path.is_absolute() { - path - } else { - data_dir.join(path) - } - }) - .unwrap_or_else(|| data_dir.join("kilo.db")), - ); - } - Some(data_dir.join("opencode.db")) + crate::runtime_env::opencode_family_db_path(tool) } /// Query an OpenCode-family SQLite DB for a session's working directory. @@ -2467,6 +2414,10 @@ mod tests { ); } + // Unix-only: relies on redirecting the home dir via `isolated_test_env`'s + // $HOME, but on Windows `dirs::home_dir()` queries the OS profile folder + // directly and ignores it. + #[cfg(unix)] #[test] #[serial_test::serial] fn test_find_session_on_disk_prefers_omp_for_omp_paths() { @@ -2583,6 +2534,10 @@ mod tests { } } + // Unix-only: relies on redirecting the home dir via `isolated_test_env`'s + // $HOME, but on Windows `dirs::home_dir()` queries the OS profile folder + // directly and ignores it. + #[cfg(unix)] #[test] #[serial_test::serial] fn test_derive_omp_transcript_path_checks_named_profile() { diff --git a/src/commands/run.rs b/src/commands/run.rs index 57dfdb0f..35798aa0 100644 --- a/src/commands/run.rs +++ b/src/commands/run.rs @@ -14,6 +14,56 @@ use crate::paths::scripts_dir; use crate::scripts; use crate::shared::CommandContext; +#[cfg(windows)] +fn resolve_git_bash() -> Result { + let bash = crate::terminal::which_bin("bash").ok_or_else(|| BASH_MISSING_MSG.to_string())?; + match Command::new(&bash).arg("--version").output() { + Ok(output) if output.status.success() => Ok(bash), + Ok(_) => Err(format!( + "{BASH_MISSING_MSG}\nFound `{bash}`, but it is not a working Bash installation \ + (the Windows WSL launcher is not sufficient)." + )), + Err(err) => Err(format!( + "{BASH_MISSING_MSG}\nFound `{bash}`, but it could not be executed: {err}" + )), + } +} +#[cfg(windows)] +const BASH_MISSING_MSG: &str = "Git Bash required to run shell (.sh) workflow scripts — install it and ensure `bash` is on PATH."; + +fn python_command() -> Result { + if let Ok(program) = std::env::var("PYTHON") + && !program.trim().is_empty() + { + return Ok(Command::new(program)); + } + + #[cfg(windows)] + { + for (name, args) in [("python", &[][..]), ("py", &["-3"][..])] { + let Some(program) = crate::terminal::which_bin(name) else { + continue; + }; + let mut probe = Command::new(&program); + probe.args(args).arg("--version"); + if probe.output().is_ok_and(|output| output.status.success()) { + let mut command = Command::new(program); + command.args(args); + return Ok(command); + } + } + Err( + "Python 3 is required to run .py workflow scripts. Install Python, set `PYTHON`, \ + or ensure `python`/`py` is on PATH." + .to_string(), + ) + } + #[cfg(not(windows))] + { + Ok(Command::new("python3")) + } +} + #[derive(clap::Parser, Debug)] #[command(name = "run", about = "Run a bundled or user workflow script")] pub struct RunArgs { @@ -341,13 +391,28 @@ pub fn cmd_run(db: &HcomDb, args: &RunArgs, ctx: Option<&CommandContext>) -> i32 match &script.source { ScriptSource::User { path } => { let mut cmd = if path.extension().and_then(|e| e.to_str()) == Some("py") { - let python = std::env::var("PYTHON").unwrap_or_else(|_| "python3".into()); - let mut c = Command::new(&python); + let mut c = match python_command() { + Ok(command) => command, + Err(err) => { + eprintln!("{err}"); + return 1; + } + }; c.arg(path); c.args(&args); c } else { - let mut c = Command::new("bash"); + #[cfg(windows)] + let bash = match resolve_git_bash() { + Ok(bash) => bash, + Err(err) => { + eprintln!("{err}"); + return 1; + } + }; + #[cfg(not(windows))] + let bash = "bash"; + let mut c = Command::new(bash); c.arg(path); c.args(&args); c @@ -376,7 +441,17 @@ pub fn cmd_run(db: &HcomDb, args: &RunArgs, ctx: Option<&CommandContext>) -> i32 } }; - let mut cmd = Command::new("bash"); + #[cfg(windows)] + let bash = match resolve_git_bash() { + Ok(bash) => bash, + Err(err) => { + eprintln!("{err}"); + return 1; + } + }; + #[cfg(not(windows))] + let bash = "bash"; + let mut cmd = Command::new(bash); cmd.arg(tmp.path()); cmd.args(&args); @@ -408,7 +483,13 @@ const SCRIPT_GUIDE: &str = r#"# Creating Custom Scripts ## Location User scripts: ~/.hcom/scripts/ - File types: *.sh (bash), *.py (python3) + File types: *.sh (bash), *.py (Python 3) + +*.sh scripts (including the bundled confess/debate/fatcow workflows) run via +`bash`. On Windows, install Git Bash (already a common dependency for +Windows dev setups, including npm-installed AI CLIs) so `bash` is on PATH. +Python scripts use `PYTHON` when set; otherwise Windows tries `python`, then +the Python launcher (`py -3`). User scripts shadow bundled scripts with the same name. Scripts are discovered automatically — drop a file and run `hcom run `. diff --git a/src/commands/status.rs b/src/commands/status.rs index c259af54..d01d820b 100644 --- a/src/commands/status.rs +++ b/src/commands/status.rs @@ -29,11 +29,19 @@ fn path_exists_or_symlink(path: &Path) -> bool { path.exists() || std::fs::symlink_metadata(path).is_ok() } +/// Cross-platform PATH scan tolerant of dangling symlinks — status display +/// wants "is this installed" even for a symlink whose target is momentarily +/// missing (e.g. mid-upgrade via nvm/homebrew), unlike `which_bin`, whose +/// results get executed and so require the target to actually resolve. fn is_in_path(name: &str) -> bool { - std::env::var("PATH") - .unwrap_or_default() - .split(':') - .any(|dir| path_exists_or_symlink(&Path::new(dir).join(name))) + let Some(path_var) = std::env::var_os("PATH") else { + return false; + }; + std::env::split_paths(&path_var).any(|dir| { + crate::terminal::which_candidates(&dir, name) + .iter() + .any(|candidate| path_exists_or_symlink(candidate)) + }) } fn is_antigravity_installed() -> bool { @@ -233,7 +241,10 @@ pub fn cmd_status(db: &HcomDb, args: &StatusArgs, _ctx: Option<&CommandContext>) { true } else { - crate::config::is_known_terminal_preset_pub(&terminal_config) + let platform = crate::shared::platform::platform_name(); + (crate::config::is_known_terminal_preset_pub(&terminal_config) + && crate::config::terminal_preset_supported_on(&terminal_config, platform)) + || crate::config::is_user_defined_preset(&terminal_config) }; // Relay — use proper status from relay module @@ -367,7 +378,10 @@ pub fn cmd_status(db: &HcomDb, args: &StatusArgs, _ctx: Option<&CommandContext>) { println!("terminal: {terminal_config}"); } else { - let available = crate::config::is_known_terminal_preset_pub(&terminal_config); + let platform = crate::shared::platform::platform_name(); + let available = (crate::config::is_known_terminal_preset_pub(&terminal_config) + && crate::config::terminal_preset_supported_on(&terminal_config, platform)) + || crate::config::is_user_defined_preset(&terminal_config); let sym = if available { "✓" } else { "✗" }; println!("terminal: {terminal_config} {sym}"); } @@ -573,13 +587,6 @@ mod tests { assert_eq!(t.symbol(), "✗"); } - #[test] - fn test_is_in_path() { - // ls should be in PATH on any Unix system - assert!(is_in_path("ls")); - assert!(!is_in_path("definitely_not_a_real_binary_xyz123")); - } - #[cfg(unix)] #[test] fn test_path_exists_or_symlink_accepts_broken_symlink() { @@ -589,6 +596,29 @@ mod tests { assert!(path_exists_or_symlink(&link)); } + // Unix-only: relies on `:`-separated PATH and a real symlink. + #[cfg(unix)] + #[test] + #[serial] + fn test_is_in_path_tolerates_broken_symlink() { + let dir = tempfile::tempdir().unwrap(); + let link = dir.path().join("definitely_not_a_real_binary_xyz123"); + std::os::unix::fs::symlink(dir.path().join("missing-target"), &link).unwrap(); + + let original_path = std::env::var_os("PATH"); + unsafe { + std::env::set_var("PATH", dir.path()); + } + assert!(is_in_path("definitely_not_a_real_binary_xyz123")); + assert!(!is_in_path("also_not_a_real_binary_abc456")); + unsafe { + match &original_path { + Some(v) => std::env::set_var("PATH", v), + None => std::env::remove_var("PATH"), + } + } + } + #[test] fn test_tool_statuses_cover_released_specs_including_kimi() { let tools = get_tool_statuses(); @@ -649,6 +679,46 @@ mod tests { } } + // B-2: preset availability must match the validate/launch platform gate — + // a wrong-platform built-in is NOT available, and a user-defined preset IS. + #[test] + #[serial] + fn terminal_availability_matches_platform_gate() { + use crate::hooks::test_helpers::isolated_test_env; + + // Mirrors the `available` expression at both status sites. + let available = |name: &str| { + let platform = crate::shared::platform::platform_name(); + (crate::config::is_known_terminal_preset_pub(name) + && crate::config::terminal_preset_supported_on(name, platform)) + || crate::config::is_user_defined_preset(name) + }; + + let (_dir, hcom_dir, _home, _guard) = isolated_test_env(); + let platform = crate::shared::platform::platform_name(); + let builtin = match platform { + "Darwin" | "Linux" => "windows-terminal", + _ => "iterm", + }; + + // Wrong-platform built-in with no user override: not available. + assert!( + !available(builtin), + "{builtin} must show unavailable on {platform}" + ); + + // A user-defined preset of the same name: available. + std::fs::write( + hcom_dir.join("config.toml"), + format!("[terminal.presets.{builtin}]\nopen = \"{builtin} {{script}}\"\n"), + ) + .unwrap(); + assert!( + available(builtin), + "user-defined {builtin} must show available on {platform}" + ); + } + #[test] fn test_status_json_includes_dev_root_only_when_set() { let dir = tempfile::tempdir().unwrap(); diff --git a/src/commands/term.rs b/src/commands/term.rs index e0c7c759..28382f70 100644 --- a/src/commands/term.rs +++ b/src/commands/term.rs @@ -6,7 +6,7 @@ use std::io::{Read, Write}; use std::net::TcpStream; use std::path::PathBuf; -use std::time::Duration; +use std::time::{Duration, Instant}; use crate::db::HcomDb; @@ -89,7 +89,7 @@ pub fn inject_text_remote_result( } if enter { if !text.is_empty() { - std::thread::sleep(Duration::from_millis(100)); + wait_for_text_rendered(port, text); } inject_raw(port, b"\r")?; } @@ -102,6 +102,26 @@ pub fn inject_text_remote_result( Ok(label) } +/// Poll the screen until the injected text exclusively fills the input box +/// before returning, so Enter is sent once the TUI has actually rendered it +/// rather than after a guessed delay. Tools with no input-box parser (e.g. +/// the OpenCode-family plugin-delivery tools) always report `input_text: +/// null`, so this can never observe a match for them and simply falls +/// through once the deadline passes — same best-effort behavior as before. +const TEXT_RENDER_TIMEOUT: Duration = Duration::from_secs(2); + +fn wait_for_text_rendered(port: i32, text: &str) { + let deadline = Instant::now() + TEXT_RENDER_TIMEOUT; + while Instant::now() < deadline { + if let Some(screen) = query_screen(port) + && screen.get("input_text").and_then(|v| v.as_str()) == Some(text) + { + return; + } + std::thread::sleep(Duration::from_millis(30)); + } +} + /// Inject text into PTY via inject port (CLI wrapper). fn inject_text(db: &HcomDb, name: &str, text: &str, enter: bool) -> i32 { match inject_text_remote_result(db, name, text, enter) { diff --git a/src/commands/transcript.rs b/src/commands/transcript.rs index feea781e..8076f0b9 100644 --- a/src/commands/transcript.rs +++ b/src/commands/transcript.rs @@ -15,6 +15,28 @@ use crate::shared::CommandContext; use crate::tool::Tool; use crate::transcript::{self, Exchange, ReadOptions, format_exchanges, summarize_action}; +fn run_search_tool(program: &str, args: &[&str]) -> Result, String> { + match std::process::Command::new(program).args(args).output() { + Ok(output) if output.status.success() => Ok(Some(output)), + Ok(output) if output.status.code() == Some(1) => Ok(None), + Ok(output) => { + let detail = String::from_utf8_lossy(&output.stderr); + Err(format!( + "{program} failed{}", + if detail.trim().is_empty() { + format!(" with {}", output.status) + } else { + format!(": {}", detail.trim()) + } + )) + } + Err(err) if err.kind() == std::io::ErrorKind::NotFound => Err(format!( + "required search tool `{program}` was not found on PATH" + )), + Err(err) => Err(format!("could not run `{program}`: {err}")), + } +} + /// Parsed arguments for `hcom transcript`. #[derive(clap::Parser, Debug)] #[command(name = "transcript", about = "View and search transcripts")] @@ -345,7 +367,22 @@ fn cmd_transcript_search( .filter(|line| !line.is_empty()) .map(str::to_string) .collect(), - _ => Vec::new(), + Ok(out) if out.status.code() == Some(1) => Vec::new(), + Ok(out) => { + eprintln!( + "Error: ripgrep failed: {}", + String::from_utf8_lossy(&out.stderr).trim() + ); + return 1; + } + Err(err) if err.kind() == std::io::ErrorKind::NotFound => { + eprintln!("Error: transcript search --all requires `rg` (ripgrep) on PATH"); + return 1; + } + Err(err) => { + eprintln!("Error: could not run `rg`: {err}"); + return 1; + } } }; @@ -408,20 +445,26 @@ fn cmd_transcript_search( .unwrap_or_default(); let remaining = limit - results.len(); - let out = std::process::Command::new("rg") - .args([ + let max_count = remaining.to_string(); + let out = match run_search_tool( + "rg", + &[ "-n", "--max-count", - &remaining.to_string(), + &max_count, "--max-columns", "500", pattern, file_path, - ]) - .output(); - if let Ok(out) = out - && out.status.success() - { + ], + ) { + Ok(output) => output, + Err(err) => { + eprintln!("Error: {err}"); + return 1; + } + }; + if let Some(out) = out { let stdout = String::from_utf8_lossy(&out.stdout); let lines: Vec<&str> = stdout.lines().collect(); let match_count = lines.len(); @@ -580,26 +623,39 @@ fn cmd_transcript_search( // Use rg for line-level matches with context let remaining = limit - results.len(); - let output = std::process::Command::new("rg") - .args([ + let max_count = remaining.to_string(); + let output = match run_search_tool( + "rg", + &[ "-n", "--max-count", - &remaining.to_string(), + &max_count, "--max-columns", "500", pattern, path, - ]) - .output() - .or_else(|_| { - std::process::Command::new("grep") - .args(["-n", "-m", &remaining.to_string(), pattern, path]) - .output() - }); - - if let Ok(out) = output - && out.status.success() - { + ], + ) { + Ok(output) => Ok(output), + Err(rg_err) if rg_err.contains("was not found on PATH") => run_search_tool( + "grep", + &["-n", "-m", &max_count, pattern, path], + ) + .map_err(|grep_err| { + format!("transcript search requires `rg` or `grep` on PATH ({rg_err}; {grep_err})") + }), + Err(err) => Err(err), + }; + + let output = match output { + Ok(output) => output, + Err(err) => { + eprintln!("Error: {err}"); + return 1; + } + }; + + if let Some(out) = output { let stdout = String::from_utf8_lossy(&out.stdout); let lines: Vec<&str> = stdout.lines().collect(); let match_count = lines.len(); @@ -1824,4 +1880,11 @@ mod tests { fn test_transcript_rejects_bogus() { assert!(TranscriptArgs::try_parse_from(["transcript", "--bogus"]).is_err()); } + + #[test] + fn missing_search_tool_is_an_error_not_an_empty_result() { + let err = + run_search_tool("__hcom_definitely_missing_search_tool__", &["pattern"]).unwrap_err(); + assert!(err.contains("was not found on PATH")); + } } diff --git a/src/commands/update.rs b/src/commands/update.rs index b4abe5aa..a62c6eeb 100644 --- a/src/commands/update.rs +++ b/src/commands/update.rs @@ -73,9 +73,33 @@ pub fn cmd_update(_db: &HcomDb, args: &UpdateArgs, ctx: Option<&CommandContext>) } println!("Running: {}", info.cmd); - let status = std::process::Command::new("sh") - .args(["-c", info.cmd]) - .status(); + + let status = if cfg!(windows) { + if crate::update::is_powershell_installer_command(info.cmd) { + std::process::Command::new("powershell") + .args([ + "-NoProfile", + "-ExecutionPolicy", + "Bypass", + "-Command", + "irm https://github.com/aannoo/hcom/releases/latest/download/hcom-installer.ps1 | iex", + ]) + .status() + } else if crate::update::is_shell_pipe_command(info.cmd) { + Err(std::io::Error::other( + "POSIX shell update command selected on Windows", + )) + } else { + match crate::update::split_program_args(info.cmd) { + Some((program, args)) => std::process::Command::new(program).args(args).status(), + None => Err(std::io::Error::other("empty update command")), + } + } + } else { + std::process::Command::new("sh") + .args(["-c", info.cmd]) + .status() + }; match status { Ok(s) if s.success() => { diff --git a/src/config.rs b/src/config.rs index 3c556ce5..f112d979 100644 --- a/src/config.rs +++ b/src/config.rs @@ -335,20 +335,33 @@ impl HcomConfig { errors.insert("terminal".into(), "terminal cannot be empty".into()); } else if self.terminal != "default" && self.terminal != "print" && self.terminal != "here" { - // Check against built-in presets + user-defined TOML presets - let known = - is_known_terminal_preset(&self.terminal) || is_user_defined_preset(&self.terminal); - if !known { - // Not a known preset — must be a custom command with {script} - if !self.terminal.contains("{script}") { + let platform = crate::shared::platform::platform_name(); + if let Some(error) = user_defined_preset_error(&self.terminal) { + errors.insert( + "terminal".into(), + format!("invalid terminal preset '{}': {error}", self.terminal), + ); + } else if is_user_defined_preset(&self.terminal) { + // User TOML presets declare no platform and override any built-in + // of the same name — exempt from the built-in platform gate. + } else if is_known_terminal_preset(&self.terminal) { + if !terminal_preset_supported_on(&self.terminal, platform) { errors.insert( "terminal".into(), format!( - "terminal must be 'default', preset name, or custom command with {{script}}, got '{}'", - self.terminal + "terminal preset '{}' is not available on {}", + self.terminal, platform ), ); } + } else if !self.terminal.contains("{script}") { + errors.insert( + "terminal".into(), + format!( + "terminal must be 'default', preset name, or custom command with {{script}}, got '{}'", + self.terminal + ), + ); } } @@ -993,6 +1006,14 @@ fn is_known_terminal_preset(name: &str) -> bool { .any(|(p, _)| p.eq_ignore_ascii_case(name)) } +/// True if `name` is a built-in preset supported on `platform` +/// ("Darwin"/"Linux"/"Windows", see `crate::shared::platform::platform_name`). +pub fn terminal_preset_supported_on(name: &str, platform: &str) -> bool { + TERMINAL_PRESETS + .iter() + .any(|(p, preset)| p.eq_ignore_ascii_case(name) && preset.platforms.contains(&platform)) +} + /// Resolve old casing to canonical preset name (e.g., "WezTerm" → "wezterm"). /// Returns the canonical name if matched, otherwise returns the input unchanged. fn normalize_terminal_case(name: &str) -> String { @@ -1004,13 +1025,43 @@ fn normalize_terminal_case(name: &str) -> String { name.to_string() } +fn user_defined_preset_error(name: &str) -> Option { + let presets = load_toml_presets(&paths::config_toml_path())?; + let (_, value) = presets + .as_table()? + .iter() + .find(|(key, _)| key.eq_ignore_ascii_case(name))?; + let preset = match value.as_table() { + Some(preset) => preset, + None => return Some("expected a table".to_string()), + }; + for field in ["open", "close"] { + if let Some(value) = preset.get(field) + && let Err(error) = toml_val_to_argv(value) + { + return Some(format!("{field}: {error}")); + } + } + None +} + /// Check if a terminal name matches a user-defined preset in config.toml. pub fn is_user_defined_preset(name: &str) -> bool { let toml_path = paths::config_toml_path(); if let Some(presets_val) = load_toml_presets(&toml_path) && let Some(table) = presets_val.as_table() { - return table.keys().any(|k| k.eq_ignore_ascii_case(name)); + return table.iter().any(|(key, value)| { + key.eq_ignore_ascii_case(name) + && value.as_table().is_some_and(|preset| { + preset.get("open").map(toml_val_to_argv).transpose().is_ok() + && preset + .get("close") + .map(toml_val_to_argv) + .transpose() + .is_ok() + }) + }); } false } @@ -1036,28 +1087,30 @@ pub fn get_merged_preset(name: &str) -> Option { .map(|(_, v)| v) })? .as_table()?; - Some(TomlPresetFields { - binary: val - .get("binary") - .and_then(|v| v.as_str()) - .map(|s| s.to_string()), - app_name: val - .get("app_name") - .and_then(|v| v.as_str()) - .map(|s| s.to_string()), - open: val - .get("open") - .and_then(|v| v.as_str()) - .map(|s| s.to_string()), - close: val - .get("close") - .and_then(|v| v.as_str()) - .map(|s| s.to_string()), - pane_id_env: val - .get("pane_id_env") - .and_then(|v| v.as_str()) - .map(|s| s.to_string()), - }) + let open_result = val.get("open").map(toml_val_to_argv).transpose(); + let close_result = val.get("close").map(toml_val_to_argv).transpose(); + match (open_result, close_result) { + (Err(e), _) | (_, Err(e)) => { + eprintln!("Warning: skipping custom terminal preset {name:?}: {e}"); + None + } + (Ok(open), Ok(close)) => Some(TomlPresetFields { + binary: val + .get("binary") + .and_then(|v| v.as_str()) + .map(|s| s.to_string()), + app_name: val + .get("app_name") + .and_then(|v| v.as_str()) + .map(|s| s.to_string()), + open: open.flatten(), + close: close.flatten(), + pane_id_env: val + .get("pane_id_env") + .and_then(|v| v.as_str()) + .map(|s| s.to_string()), + }), + } }); let builtin = crate::shared::get_terminal_preset(name); @@ -1065,20 +1118,45 @@ pub fn get_merged_preset(name: &str) -> Option { match (&toml_preset, &builtin) { (None, None) => None, _ => { - let b_open = builtin.map(|b| b.open).unwrap_or(""); - let b_close = builtin.and_then(|b| b.close); + // Built-in argv templates, lowered to owned Vec per platform. + let argv_vec = |sel: Option| { + sel.map(|t| t.iter().map(|s| s.to_string()).collect::>()) + }; + let b_open = builtin.map(|b| b.open); + let b_close = builtin.map(|b| b.close); let b_binary = builtin.and_then(|b| b.binary); let b_app = builtin.and_then(|b| b.app_name); let b_pane_env = builtin.and_then(|b| b.pane_id_env); let t = toml_preset.as_ref(); + + // A TOML `open`/`close` override (string or array) replaces the + // built-in on BOTH platforms — TOML custom presets have no separate + // Windows slot, so the array form is the Windows escape hatch (it + // can carry literal Windows paths without shell mangling). + let toml_open = t.and_then(|t| t.open.clone()); + let toml_close = t.and_then(|t| t.close.clone()); + + let (open, open_windows) = match toml_open { + Some(o) => (o, None), + None => ( + argv_vec(b_open.and_then(|o| o.default)).unwrap_or_default(), + argv_vec(b_open.and_then(|o| o.windows)), + ), + }; + let (close, close_windows) = match toml_close { + Some(c) => (Some(c), None), + None => ( + argv_vec(b_close.and_then(|c| c.default)), + argv_vec(b_close.and_then(|c| c.windows)), + ), + }; + Some(MergedPreset { - open: t - .and_then(|t| t.open.clone()) - .unwrap_or_else(|| b_open.to_string()), - close: t - .and_then(|t| t.close.clone()) - .or_else(|| b_close.map(|s| s.to_string())), + open, + open_windows, + close, + close_windows, binary: t .and_then(|t| t.binary.clone()) .or_else(|| b_binary.map(|s| s.to_string())), @@ -1093,25 +1171,121 @@ pub fn get_merged_preset(name: &str) -> Option { } } +/// Convert a TOML preset `open`/`close` value into an argv vector. +/// +/// Accepts BOTH forms: +/// - Array: each element must be a string; non-string elements are an error. +/// Literal Windows paths like `C:\Users\x\s.ps1` survive intact — this is +/// the recommended escape hatch for custom presets. +/// - String (legacy): tokenized once via the double-quote-aware +/// `args_common::shell_split`. Backslashes are consumed by that tokenizer, so +/// the array form is preferred for Windows paths. A `\` in the string triggers +/// a warning so users can migrate to the array form. +/// +/// Returns `Ok(None)` for an empty string/array (treated as "unset"). +/// Returns `Err` on non-string array elements or invalid shell quoting — the +/// caller should treat this as a configuration error rather than falling back +/// to a built-in preset. +fn toml_val_to_argv(v: &toml::Value) -> Result>, String> { + match v { + toml::Value::Array(items) => { + if items.is_empty() { + return Ok(None); + } + let mut argv = Vec::with_capacity(items.len()); + for (i, e) in items.iter().enumerate() { + match e.as_str() { + Some(s) => argv.push(s.to_string()), + None => { + return Err(format!( + "element [{}] is not a string (got {}); use quoted strings", + i, + e.type_str() + )); + } + } + } + Ok(Some(argv)) + } + toml::Value::String(s) => { + if s.is_empty() { + return Ok(None); + } + if s.contains('\\') { + eprintln!( + "Warning: custom terminal preset command contains backslashes; \ + use the array form to avoid shell tokenization issues on Windows: {s:?}" + ); + } + match crate::tools::args_common::shell_split(s, cfg!(windows)) { + Ok(argv) if !argv.is_empty() => Ok(Some(argv)), + Ok(_) => Ok(None), + Err(e) => Err(format!("invalid quoting in preset command: {e}")), + } + } + _ => Err(format!( + "expected a string or array of strings, got {}", + v.type_str() + )), + } +} + /// Parsed TOML preset fields (all optional — overlay on built-in). struct TomlPresetFields { binary: Option, app_name: Option, - open: Option, - close: Option, + open: Option>, + close: Option>, pane_id_env: Option, } -/// Fully merged terminal preset (TOML + built-in). +/// Fully merged terminal preset (TOML + built-in), as argument vectors. #[derive(Debug, Clone)] pub struct MergedPreset { - pub open: String, - pub close: Option, + /// Default (Unix / fallback) open argv. + pub open: Vec, + /// Windows-specific open argv override (None ⇒ use `open`). + pub open_windows: Option>, + /// Default (Unix / fallback) close argv (None ⇒ no close API). + pub close: Option>, + /// Windows-specific close argv override (None ⇒ use `close`). + pub close_windows: Option>, pub binary: Option, pub app_name: Option, pub pane_id_env: Option, } +impl MergedPreset { + /// Open argv for the given platform (Windows falls back to the default). + pub fn open_argv(&self, is_windows: bool) -> Vec { + if is_windows { + self.open_windows + .clone() + .unwrap_or_else(|| self.open.clone()) + } else { + self.open.clone() + } + } + + /// Close argv for the given platform (Windows falls back to the default). + pub fn close_argv(&self, is_windows: bool) -> Option> { + if is_windows { + self.close_windows.clone().or_else(|| self.close.clone()) + } else { + self.close.clone() + } + } + + /// Whether a close API exists for the given platform (no clone). + pub fn has_close(&self, is_windows: bool) -> bool { + if is_windows { + self.close_windows.is_some() || self.close.is_some() + } else { + self.close.is_some() + } + } +} + fn is_falsy(s: &str) -> bool { matches!(s, "0" | "false" | "False" | "no" | "off" | "") } @@ -1340,6 +1514,9 @@ mod tests { } } + // Unix-only: asserts against $HOME and POSIX absolute paths; Windows + // resolves the base dir from USERPROFILE and treats "/x" as drive-relative. + #[cfg(unix)] #[test] #[serial] fn test_default_config_uses_home_hcom() { @@ -1354,6 +1531,7 @@ mod tests { }); } + #[cfg(unix)] #[test] #[serial] fn test_hcom_dir_overrides_home() { @@ -1450,6 +1628,7 @@ mod tests { }); } + #[cfg(unix)] #[test] #[serial] fn test_hcom_dir_absolute_stays_absolute() { @@ -1565,8 +1744,14 @@ mod tests { config.terminal = "KITTY".to_string(); let errors = config.collect_errors(); - assert!(!errors.contains_key("terminal")); - assert_eq!(config.terminal, "kitty"); + assert_eq!(config.terminal, "kitty"); // Normalized regardless of platform + // kitty is Darwin/Linux-only (DL); on Windows it's correctly rejected + // by the platform-availability check added for finding #17. + if crate::shared::platform::platform_name() == "Windows" { + assert!(errors.contains_key("terminal")); + } else { + assert!(!errors.contains_key("terminal")); + } } #[test] @@ -1584,6 +1769,9 @@ mod tests { #[test] fn test_terminal_known_presets_accepted() { + // Finding 17: presets are now validated against the host platform, so + // only assert presets that are actually supported here. + let platform = crate::shared::platform::platform_name(); let mut config = HcomConfig::default(); for preset in &[ "kitty", @@ -1593,14 +1781,30 @@ mod tests { "terminal.app", "iterm", ] { + if !terminal_preset_supported_on(preset, platform) { + continue; + } config.terminal = preset.to_string(); assert!( !config.collect_errors().contains_key("terminal"), - "preset '{preset}' should be valid" + "preset '{preset}' should be valid on {platform}" ); } } + #[test] + #[cfg(not(target_os = "windows"))] + fn wrong_platform_builtin_preset_is_rejected() { + // Finding 17: a built-in preset not available on the host platform + // (here, "wttab" is Windows-only) must be rejected at validation time, + // not just silently accepted and left to fail at launch. + let mut config = HcomConfig { + terminal: "wttab".to_string(), + ..HcomConfig::default() + }; + assert!(config.collect_errors().contains_key("terminal")); + } + #[test] fn test_set_field_full_auto_normalization() { let mut config = HcomConfig::default(); @@ -2024,6 +2228,155 @@ binary = "myterm" assert!(presets.as_table().unwrap().contains_key("myterm")); } + #[test] + fn test_toml_val_to_argv_array_preserves_windows_path() { + // Array form: elements collected verbatim, so a literal Windows path + // (backslashes, drive letter) survives without tokenization. + let v = toml::Value::Array(vec![ + toml::Value::String("myterm".into()), + toml::Value::String("-e".into()), + toml::Value::String(r"C:\Users\x\s.ps1".into()), + ]); + assert_eq!( + toml_val_to_argv(&v), + Ok(Some(vec![ + "myterm".to_string(), + "-e".to_string(), + r"C:\Users\x\s.ps1".to_string(), + ])) + ); + } + + #[test] + fn test_toml_val_to_argv_array_rejects_non_string_element() { + let v = toml::Value::Array(vec![ + toml::Value::String("myterm".into()), + toml::Value::Integer(42), + toml::Value::String("{script}".into()), + ]); + assert!(toml_val_to_argv(&v).is_err()); + } + + #[test] + fn test_toml_val_to_argv_string_tokenizes_legacy() { + let v = toml::Value::String("myterm -e bash {script}".into()); + assert_eq!( + toml_val_to_argv(&v), + Ok(Some(vec![ + "myterm".to_string(), + "-e".to_string(), + "bash".to_string(), + "{script}".to_string(), + ])) + ); + } + + #[test] + fn test_toml_val_to_argv_string_invalid_quoting_returns_err() { + let v = toml::Value::String(r#"kitty -- bash "unterminated"#.into()); + assert!(toml_val_to_argv(&v).is_err()); + } + + #[test] + fn test_toml_val_to_argv_empty_and_wrong_type() { + assert!(toml_val_to_argv(&toml::Value::Integer(3)).is_err()); + assert_eq!(toml_val_to_argv(&toml::Value::Array(vec![])), Ok(None)); + assert_eq!( + toml_val_to_argv(&toml::Value::String(String::new())), + Ok(None) + ); + } + + // B-1: a user-defined `[terminal.presets.]` override declares no + // platform and takes precedence over the built-in, so it must be accepted + // even on a platform where the built-in itself is unavailable. + #[test] + #[serial] + fn user_defined_override_exempt_from_builtin_platform_gate() { + let (_dir, hcom_dir, _home, _guard) = isolated_test_env(); + let platform = crate::shared::platform::platform_name(); + // A built-in preset NOT available on the current host platform. + let builtin = match platform { + // windows-terminal is Windows-only. + "Darwin" | "Linux" => "windows-terminal", + // iterm is Darwin-only. + _ => "iterm", + }; + + // Control: without any user override the wrong-platform built-in is + // rejected at validate time. + let mut cfg = HcomConfig { + terminal: builtin.to_string(), + ..Default::default() + }; + assert!( + cfg.collect_errors().contains_key("terminal"), + "built-in {builtin} should be rejected on {platform} without a user override" + ); + + // Define a user preset with the SAME name — it must now be accepted. + std::fs::write( + hcom_dir.join("config.toml"), + format!( + "[terminal.presets.{builtin}]\nopen = \"{builtin} -- powershell -File {{script}}\"\n" + ), + ) + .unwrap(); + let mut cfg = HcomConfig { + terminal: builtin.to_string(), + ..Default::default() + }; + assert!( + !cfg.collect_errors().contains_key("terminal"), + "user-defined override of {builtin} must be accepted on {platform}" + ); + } + + #[test] + #[serial] + fn malformed_user_override_does_not_bypass_builtin_platform_gate() { + let (_dir, hcom_dir, _home, _guard) = isolated_test_env(); + let builtin = match crate::shared::platform::platform_name() { + "Darwin" | "Linux" => "windows-terminal", + _ => "iterm", + }; + std::fs::write( + hcom_dir.join("config.toml"), + format!("[terminal.presets.{builtin}]\nopen = \"powershell \\\"unterminated\"\n"), + ) + .unwrap(); + + assert!(!is_user_defined_preset(builtin)); + let mut cfg = HcomConfig { + terminal: builtin.to_string(), + ..Default::default() + }; + assert!( + cfg.collect_errors().contains_key("terminal"), + "malformed override must not exempt {builtin} from the platform gate" + ); + } + + #[test] + #[serial] + fn malformed_user_override_rejected_for_supported_builtin() { + let (_dir, hcom_dir, _home, _guard) = isolated_test_env(); + let builtin = if cfg!(windows) { "cmd" } else { "tmux" }; + std::fs::write( + hcom_dir.join("config.toml"), + format!("[terminal.presets.{builtin}]\nclose = 42\n"), + ) + .unwrap(); + let mut cfg = HcomConfig { + terminal: builtin.to_string(), + ..Default::default() + }; + + let error = cfg.collect_errors().remove("terminal").unwrap(); + assert!(error.contains("invalid terminal preset")); + assert!(error.contains("close:")); + } + #[test] fn test_load_toml_presets_missing() { let dir = tempfile::tempdir().unwrap(); diff --git a/src/db/mod.rs b/src/db/mod.rs index d5ece072..d3589040 100644 --- a/src/db/mod.rs +++ b/src/db/mod.rs @@ -65,8 +65,7 @@ pub struct HcomDb { } fn get_inode(path: &std::path::Path) -> u64 { - use std::os::unix::fs::MetadataExt; - std::fs::metadata(path).map(|m| m.ino()).unwrap_or(0) + crate::sys::fs::file_id(path) } impl HcomDb { @@ -366,6 +365,18 @@ impl HcomDb { // recovery can re-register them into the fresh DB. self.snapshot_running_to_pidtrack(); + // Release our handle to the old DB file before archiving. Windows + // refuses to delete a file that still has an open handle; Unix + // unlinks an open file fine, so this is a no-op there. + // + // This only releases *our own* connection. If any other hcom + // process — another agent instance, a relay worker, a hook + // invocation — has the same DB file open at this moment, the + // `remove_file` inside `archive_db_at` below can still fail on + // Windows; see the doc comment there for why closing our own + // handle isn't sufficient in general. + self.conn = Connection::open_in_memory()?; + // Archive the old DB let archive_path = Self::archive_db_at(&self.db_path)?; if let Some(ref path) = archive_path { @@ -582,6 +593,28 @@ impl HcomDb { /// Archive current database at a given path. /// WAL checkpoint, copy to archive dir (sibling archive/ directory), delete original. + /// + /// Known, deliberately deferred limitation on Windows: the `remove_file` + /// below can fail even though the caller already released its own + /// connection (see `ensure_schema`). Windows only allows deleting a file + /// while other handles remain open if *every* one of those handles was + /// opened with `FILE_SHARE_DELETE` — and SQLite's Windows VFS (and thus + /// rusqlite's default `Connection::open`) does not request that flag. + /// Unix has no equivalent restriction; `unlink` on an open file always + /// succeeds there, which is why this asymmetry doesn't show up in the + /// Unix path at all. + /// + /// In practice this only bites when a schema-version mismatch forces an + /// archive-and-reset (rare) while some other hcom process — another agent + /// instance, a relay worker, a hook invocation — still has the same DB + /// file open anywhere on the machine. When that happens, this call + /// returns a real, un-recoverable-in-place `Err`; there is no retry that + /// helps within this function. A proper fix would need a different + /// strategy entirely — e.g. copying the live file's contents into a fresh + /// DB and resetting schema in place, rather than deleting the original — + /// so no cross-process handle-closing coordination is required. That is a + /// larger change than this narrow Windows-support pass and is deferred + /// given how rare schema mismatches are in practice. fn archive_db_at(db_path: &std::path::Path) -> Result> { if !db_path.exists() { return Ok(None); diff --git a/src/delivery.rs b/src/delivery.rs index 4d0e6cef..35a03afb 100644 --- a/src/delivery.rs +++ b/src/delivery.rs @@ -15,6 +15,12 @@ use crate::log::{log_error, log_info, log_warn}; use crate::notify::NotifyServer; use crate::shared::{ST_ACTIVE, ST_BLOCKED, ST_INACTIVE, ST_LISTENING}; +/// Whether the wrapped child exited because hcom killed it (vs. closed on its +/// own). Set by the PTY proxy (Unix) and read here during delivery cleanup to +/// choose the exit status context. Lives here rather than in `pty` so the +/// delivery loop compiles on platforms without the PTY wrapper. +pub static EXIT_WAS_KILLED: AtomicBool = AtomicBool::new(false); + /// Safely truncate a string to at most `max_chars` characters. /// Unlike byte slicing `&s[..n]`, this won't panic on multi-byte UTF-8. pub(crate) fn truncate_chars(s: &str, max_chars: usize) -> String { @@ -159,6 +165,7 @@ fn refresh_title_state(args: TitleRefresh<'_>) { /// whose chrome doesn't render OSC titles. Currently only herdr; add a /// `Backend` variant and a `resolve` arm to support another. mod host_label { + #[cfg(unix)] use std::time::Duration; use crate::db::HcomDb; @@ -167,6 +174,7 @@ mod host_label { /// Long enough to absorb a slow herdr server tick, short enough that a /// dead socket doesn't visibly stall the delivery loop. + #[cfg(unix)] const SOCKET_TIMEOUT: Duration = Duration::from_millis(200); /// Per-loop state: which backend (if any) we resolved at startup, and the @@ -786,6 +794,13 @@ fn launch_ready_observed( if config.block_on_approval && screen.approval { return false; } + // Copilot's SessionStart hook binds the real CLI session after startup and + // only after the initial prompt has completed. That binding is authoritative + // readiness evidence even when newer Copilot versions omit or redraw the + // historical "/ commands" footer before the screen scraper observes it. + if config.tool == "copilot" && db.has_session(name) { + return true; + } if config.launch_ready_on_plugin_bind { // Authoritative readiness for plugin-driven tools (OMP): the extension's // bind (a kind='plugin' notify endpoint) proves both TUI construction @@ -2007,7 +2022,7 @@ pub(crate) fn cleanup_deleted_instance(db: &mut HcomDb, current_name: &str) { } }; - let was_killed = crate::pty::EXIT_WAS_KILLED.load(std::sync::atomic::Ordering::Acquire); + let was_killed = EXIT_WAS_KILLED.load(std::sync::atomic::Ordering::Acquire); let (exit_context, exit_reason) = if was_killed { ("exit:killed", "killed") } else { @@ -2435,6 +2450,29 @@ mod tests { assert!(launch_ready_observed(&db, "vupo", &config, &state)); } + #[test] + fn copilot_session_binding_satisfies_launch_readiness() { + let (_dir, db) = open_ready_test_db(); + db.conn() + .execute( + "INSERT INTO instances (name, tool, session_id, created_at) + VALUES ('mira', 'copilot', 'copilot-session-1', 0)", + [], + ) + .unwrap(); + let mut screen = safe_screen(); + screen.ready = false; + screen.prompt_empty = false; + let state = make_state(screen, 500); + + assert!(launch_ready_observed( + &db, + "mira", + &ToolConfig::for_tool(crate::tool::Tool::Copilot), + &state + )); + } + #[test] fn gate_gemini_skips_prompt_empty_check() { // Gemini has require_prompt_empty=false diff --git a/src/hooks/antigravity.rs b/src/hooks/antigravity.rs index f5291b05..f6948c79 100644 --- a/src/hooks/antigravity.rs +++ b/src/hooks/antigravity.rs @@ -84,8 +84,18 @@ fn antigravity_hooks_path(gemini_dir: &Path) -> PathBuf { /// The fallback is delivered base64-encoded and piped through `base64 -d` so the /// JSON's quotes (and any apostrophes) survive the nested `sh -c '...'` pass — /// naive interpolation gets stripped or mis-tokenized by the inner shell. +/// fn hook_sh_cmd(hcom_cmd: &str, subcmd: &str, fallback_json: &str) -> String { let bin = hcom_cmd.split_whitespace().next().unwrap_or("hcom"); + if cfg!(windows) { + let invoke = format!("set \"ANTIGRAVITY_AGENT=1\" && {hcom_cmd} {subcmd}"); + if fallback_json.is_empty() { + return format!("where {bin} >nul 2>nul && ({invoke}) || exit /b 0"); + } + return format!( + "where {bin} >nul 2>nul && ({invoke}) || (echo {fallback_json} & exit /b 0)" + ); + } if fallback_json.is_empty() { format!( "sh -c 'command -v {bin} >/dev/null 2>&1 && ANTIGRAVITY_AGENT=1 exec {hcom_cmd} {subcmd} || exit 0'" @@ -1011,7 +1021,12 @@ mod tests { fn test_hook_sh_cmd_includes_subcmd_and_hcom() { let cmd = hook_sh_cmd("hcom gemini-beforeagent", "gemini-beforeagent", ""); assert!(cmd.contains("gemini-beforeagent")); - assert!(cmd.contains("command -v hcom")); + if cfg!(windows) { + assert!(cmd.contains("where hcom")); + assert!(!cmd.contains("sh -c")); + } else { + assert!(cmd.contains("command -v hcom")); + } assert!(cmd.contains("ANTIGRAVITY_AGENT=1")); assert!(cmd.contains("hcom gemini-beforeagent")); } @@ -1020,7 +1035,11 @@ mod tests { fn test_hook_sh_cmd_with_fallback_uses_base64_pipeline() { let cmd = hook_sh_cmd("hcom", "gemini-beforetool", "{\"decision\":\"allow\"}"); assert!(cmd.contains("gemini-beforetool")); - assert!(cmd.contains("base64 -d")); + if cfg!(windows) { + assert!(cmd.contains("echo {\"decision\":\"allow\"}")); + } else { + assert!(cmd.contains("base64 -d")); + } } #[test] @@ -1029,7 +1048,7 @@ mod tests { // no printf/echo when fallback is empty assert!(!cmd.contains("printf")); assert!(!cmd.contains("base64")); - assert!(cmd.contains("exit 0")); + assert!(cmd.contains(if cfg!(windows) { "exit /b 0" } else { "exit 0" })); } /// Actually execute the generated command with a missing binary and confirm @@ -1125,6 +1144,10 @@ mod tests { assert!(!remove_antigravity_hooks()); } + // Unix-only: redirects the home dir via $HOME, but on Windows + // `dirs::home_dir()` reads USERPROFILE and ignores it, so the home-based + // cleanup dir points outside the test's temp tree. + #[cfg(unix)] #[test] #[serial] fn test_remove_cleans_default_and_active_hcom_dir_local_paths() { diff --git a/src/hooks/claude.rs b/src/hooks/claude.rs index 8f132039..1cebccdb 100644 --- a/src/hooks/claude.rs +++ b/src/hooks/claude.rs @@ -2051,6 +2051,10 @@ pub fn load_claude_settings(settings_path: &Path) -> Option { /// after `brew uninstall hcom`), the hook exits 0 instead of emitting a "command /// not found" error inside the tool. fn build_hook_entry_command(cmd_suffix: &str) -> String { + // Claude runs hook commands through a POSIX shell on every platform + // (Git Bash on Windows), so the same command works everywhere. The + // `${HCOM:-hcom}` default plus the `command -v` guard make it silently + // exit 0 when hcom isn't on PATH. format!( "cmd=${{HCOM:-hcom}}; command -v \"${{cmd%% *}}\" >/dev/null 2>&1 && exec $cmd {} || exit 0", cmd_suffix @@ -2063,12 +2067,28 @@ fn format_claude_permission(prefix: &str, cmd: &str) -> String { format!("Bash({} {}{})", prefix, cmd, suffix) } +/// Format a single Claude permission pattern for the PowerShell tool: +/// `PowerShell(prefix cmd:*)`. +/// +/// Claude Code uses the Bash tool on Windows when Git for Windows is present, +/// and falls back to a separate PowerShell tool (same rule syntax as Bash) +/// otherwise, so both patterns are installed to cover either case. +fn format_claude_powershell_permission(prefix: &str, cmd: &str) -> String { + let suffix = if cmd.starts_with('-') { "" } else { ":*" }; + format!("PowerShell({} {}{})", prefix, cmd, suffix) +} + /// Build permission patterns for installation using detected prefix. fn build_claude_permissions() -> Vec { let prefix = crate::runtime_env::build_hcom_command(); SAFE_HCOM_COMMANDS .iter() .map(|cmd| format_claude_permission(&prefix, cmd)) + .chain( + SAFE_HCOM_COMMANDS + .iter() + .map(|cmd| format_claude_powershell_permission(&prefix, cmd)), + ) .collect() } @@ -2082,6 +2102,7 @@ fn build_all_claude_permission_patterns() -> Vec { for prefix in &["hcom", "uvx hcom"] { for cmd in SAFE_HCOM_COMMANDS.iter().chain(LEGACY_HCOM_COMMANDS.iter()) { patterns.push(format_claude_permission(prefix, cmd)); + patterns.push(format_claude_powershell_permission(prefix, cmd)); } } patterns @@ -2928,24 +2949,58 @@ mod tests { ); } + #[test] + fn test_format_claude_powershell_permission() { + assert_eq!( + format_claude_powershell_permission("hcom", "send"), + "PowerShell(hcom send:*)" + ); + assert_eq!( + format_claude_powershell_permission("hcom", "--help"), + "PowerShell(hcom --help)" + ); + assert_eq!( + format_claude_powershell_permission("uvx hcom", "list"), + "PowerShell(uvx hcom list:*)" + ); + } + #[test] fn test_build_claude_permissions() { let perms = build_claude_permissions(); assert!(!perms.is_empty()); - assert_eq!(perms.len(), SAFE_HCOM_COMMANDS.len()); - // All should start with "Bash(" + // Both Bash and PowerShell variants are installed for every safe command. + assert_eq!(perms.len(), SAFE_HCOM_COMMANDS.len() * 2); + assert_eq!( + perms.iter().filter(|p| p.starts_with("Bash(")).count(), + SAFE_HCOM_COMMANDS.len() + ); + assert_eq!( + perms + .iter() + .filter(|p| p.starts_with("PowerShell(")) + .count(), + SAFE_HCOM_COMMANDS.len() + ); + // All should start with "Bash(" or "PowerShell(" for p in &perms { - assert!(p.starts_with("Bash("), "bad permission: {}", p); + assert!( + p.starts_with("Bash(") || p.starts_with("PowerShell("), + "bad permission: {}", + p + ); } } #[test] fn test_build_all_claude_permission_patterns() { let patterns = build_all_claude_permission_patterns(); - // Should have both hcom and uvx hcom variants - let expected = (SAFE_HCOM_COMMANDS.len() + LEGACY_HCOM_COMMANDS.len()) * 2; + // Should have both hcom and uvx hcom variants, each with Bash and PowerShell rules + let expected = (SAFE_HCOM_COMMANDS.len() + LEGACY_HCOM_COMMANDS.len()) * 2 * 2; assert_eq!(patterns.len(), expected); assert!(patterns.iter().any(|p| p.contains("hcom send"))); + assert!(patterns.iter().any(|p| p == "PowerShell(hcom send:*)")); + assert!(patterns.iter().any(|p| p == "PowerShell(uvx hcom send:*)")); assert!(patterns.iter().any(|p| p.contains("uvx hcom send"))); // Legacy commands included for removal assert!(patterns.iter().any(|p| p.contains("hcom daemon"))); diff --git a/src/hooks/codex.rs b/src/hooks/codex.rs index b7e9a7ba..097f2293 100644 --- a/src/hooks/codex.rs +++ b/src/hooks/codex.rs @@ -6,7 +6,7 @@ use std::io::Write; use std::io::{BufRead, BufReader, Read}; use std::path::{Path, PathBuf}; #[cfg(not(test))] -use std::process::{Command, Stdio}; +use std::process::Stdio; use std::sync::OnceLock; #[cfg(not(test))] use std::sync::mpsc; @@ -943,7 +943,7 @@ fn fetch_codex_hcom_hook_entries() -> Result, String> { let cwd = std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")); // TODO: If Codex changes hooks/list discovery to depend on each launch // cwd, pass the target launch cwd through instead of using hcom's cwd. - let mut child = Command::new("codex") + let mut child = crate::terminal::executable_command("codex") .args(["app-server", "--listen", "stdio://"]) .stdin(Stdio::piped()) .stdout(Stdio::piped()) @@ -1110,7 +1110,7 @@ fn codex_cli_version_output_for_hook_trust() -> Result { static CACHE: OnceLock> = OnceLock::new(); CACHE .get_or_init(|| { - let output = Command::new("codex") + let output = crate::terminal::executable_command("codex") .arg("--version") .output() .map_err(|e| { @@ -1163,7 +1163,7 @@ fn detect_codex_hooks_feature_key() -> CodexHooksFeatureKey { } *CODEX_HOOKS_FEATURE_KEY_CACHE.get_or_init(|| { - let output = match std::process::Command::new("codex") + let output = match crate::terminal::executable_command("codex") .arg("--version") .output() { diff --git a/src/hooks/common.rs b/src/hooks/common.rs index 8f632cf1..fb521806 100644 --- a/src/hooks/common.rs +++ b/src/hooks/common.rs @@ -499,18 +499,8 @@ fn poll_loop( }; if let Some(server) = notify_server { - // Block on poll(2) instead of busy-looping with accept+sleep - use std::os::fd::AsRawFd; - let fd = server.as_raw_fd(); - let timeout_ms = wait_time.as_millis().min(i32::MAX as u128) as i32; - let mut pfd = libc::pollfd { - fd, - events: libc::POLLIN, - revents: 0, - }; - // SAFETY: valid pollfd, nfds=1, bounded timeout - let ret = unsafe { libc::poll(&mut pfd as *mut _, 1, timeout_ms) }; - if ret > 0 { + // Block until a wake-up connection arrives instead of busy-looping + if crate::sys::net::wait_readable(server, wait_time) { // Drain all pending connections if let Err(e) = server.set_nonblocking(true) { log::log_warn( @@ -545,18 +535,7 @@ fn poll_loop( /// POLLERR (broken pipe) and POLLNVAL (fd was closed/invalidated). /// fn check_stdin_closed() -> bool { - let mut pfd = libc::pollfd { - fd: 0, // stdin - events: libc::POLLIN, - revents: 0, - }; - // SAFETY: valid pollfd, nfds=1, timeout=0 - let ret = unsafe { libc::poll(&mut pfd as *mut _, 1, 0) }; - if ret < 0 { - return true; // poll error → assume closed - } - // Only POLLERR/POLLNVAL — NOT POLLHUP (normal pipe EOF) - (pfd.revents & (libc::POLLERR | libc::POLLNVAL)) != 0 + crate::sys::io::stdin_appears_broken() } /// Create TCP server socket for instant message wake notifications. @@ -980,36 +959,30 @@ fn stop_instance_inner( let pid = instance_data.pid; let is_headless = instance_data.background != 0; if let Some(pid_val) = pid { - let pid_i32 = pid_val as i32; + let pid_u32 = pid_val as u32; if is_headless { - // SIGTERM → wait up to 2s → SIGKILL - let term_ret = unsafe { libc::killpg(pid_i32, libc::SIGTERM) }; - if term_ret == 0 { + // Graceful-then-forceful group kill: terminate_group (Unix: SIGTERM; + // Windows: forceful process-tree kill) → poll up to 2s for exit → + // kill_group (Unix: SIGKILL; Windows: tree kill again). The poll also + // waits out Windows' asynchronous TerminateProcess. + use crate::sys::process::GroupSignal; + if crate::sys::process::terminate_group(pid_u32) == GroupSignal::Sent { let mut dead = false; for _ in 0..20 { std::thread::sleep(Duration::from_millis(100)); - let probe = unsafe { libc::kill(pid_i32, 0) }; - if probe != 0 { + if !crate::sys::process::is_alive(pid_u32) { dead = true; break; } } if !dead { - unsafe { libc::killpg(pid_i32, libc::SIGKILL) }; + crate::sys::process::kill_group(pid_u32); } } - // ESRCH/EPERM from initial killpg is fine — process already gone or foreign + // NotFound/PermissionDenied from initial signal is fine — process already gone or foreign } else { // Track surviving PTY processes in pidtrack - let alive = { - let probe = unsafe { libc::kill(pid_i32, 0) }; - if probe == 0 { - true - } else { - // EPERM = exists but foreign user — still track - std::io::Error::last_os_error().raw_os_error() == Some(libc::EPERM) - } - }; + let alive = crate::sys::process::is_alive(pid_u32); if alive { let hcom_dir = crate::paths::hcom_dir(); diff --git a/src/hooks/cursor.rs b/src/hooks/cursor.rs index 37081a82..54dfda15 100644 --- a/src/hooks/cursor.rs +++ b/src/hooks/cursor.rs @@ -849,6 +849,10 @@ mod tests { ); } + // Unix-only: relies on redirecting the home dir via $HOME, but on Windows + // `dirs::home_dir()` reads USERPROFILE and ignores the test's temp HOME, so + // the normal-vs-isolated mode check never sees the override. + #[cfg(unix)] #[test] #[serial] fn normal_mode_permissions_honor_cursor_config_dir() { @@ -942,6 +946,9 @@ mod tests { ); } + // Unix-only: same $HOME-redirection limitation as + // `normal_mode_permissions_honor_cursor_config_dir`. + #[cfg(unix)] #[test] #[serial] fn remove_cleans_default_and_isolated_paths() { diff --git a/src/hooks/gemini.rs b/src/hooks/gemini.rs index f3b23caf..84ffc10f 100644 --- a/src/hooks/gemini.rs +++ b/src/hooks/gemini.rs @@ -2,7 +2,6 @@ //! //! Lifecycle: SessionStart → BeforeAgent → [BeforeTool → AfterTool]* → AfterAgent → SessionEnd -use std::env; use std::io::Write as _; use std::path::{Path, PathBuf}; use std::time::Instant; @@ -37,15 +36,7 @@ pub fn derive_gemini_transcript_path(session_id: &str) -> Option { return None; } - let gemini_base = if let Ok(cli_home) = env::var("GEMINI_CLI_HOME") { - if !cli_home.is_empty() { - PathBuf::from(cli_home).join(".gemini") - } else { - PathBuf::from(env::var("HOME").ok()?).join(".gemini") - } - } else { - PathBuf::from(env::var("HOME").ok()?).join(".gemini") - }; + let gemini_base = crate::runtime_env::gemini_family_config_dir(); let gemini_tmp = gemini_base.join("tmp"); if !gemini_tmp.exists() { return None; @@ -947,6 +938,19 @@ pub fn get_gemini_version() -> Option<(u32, u32, u32)> { // Try parent (dist/index.js -> package.json at package root) package_json = real_path.parent()?.parent()?.join("package.json"); } + #[cfg(windows)] + if !package_json.exists() { + // npm on Windows installs a `gemini.cmd`/`.ps1` shim directly in the + // global bin dir (e.g. `%APPDATA%\npm\`) rather than a symlink into + // the package, so canonicalize() just resolves the shim itself. The + // package always lives in that same dir's `node_modules\@google\gemini-cli`. + package_json = real_path + .parent()? + .join("node_modules") + .join("@google") + .join("gemini-cli") + .join("package.json"); + } if !package_json.exists() { return None; } @@ -1354,6 +1358,31 @@ pub enum SetupError { }, } +/// Shell wrapper for a single hcom hook subcommand (`gemini-beforeagent`, etc.). +/// +/// Silently no-ops (exit 0) when hcom isn't on PATH. +/// +/// Gemini CLI's `getShellConfiguration()` (packages/core/src/utils/shell-utils.ts) +/// runs hook commands via PowerShell on Windows (`-NoProfile -NonInteractive -Command`), +/// never a POSIX shell, and via a POSIX shell (e.g. `bash -c`) elsewhere. The command +/// string built here is PowerShell-native on Windows and POSIX `sh -c` elsewhere to +/// match what actually executes it. +fn hook_command(hcom_cmd: &str, cmd_suffix: &str) -> String { + let bin = hcom_cmd.split_whitespace().next().unwrap_or("hcom"); + if cfg!(windows) { + // hcom_cmd/cmd_suffix are always fixed hcom invocations (never user input), + // so no quoting is needed here; if that ever changes, note that PowerShell + // still expands `$variables` and backticks in bare (unquoted) script text. + format!( + "if (Get-Command {bin} -ErrorAction SilentlyContinue) {{ {hcom_cmd} {cmd_suffix} }} else {{ exit 0 }}" + ) + } else { + format!( + "sh -c 'command -v {bin} >/dev/null 2>&1 && exec {hcom_cmd} {cmd_suffix} || exit 0'" + ) + } +} + /// Set up hcom hooks in Gemini settings.json. /// /// - Removes existing hcom hooks first (clean slate) @@ -1422,16 +1451,12 @@ pub fn try_setup_gemini_hooks(include_permissions: bool) -> Result<(), SetupErro if let Some(hooks) = settings.get_mut("hooks").and_then(|v| v.as_object_mut()) { for &(hook_type, matcher, cmd_suffix, timeout, description) in GEMINI_HOOK_CONFIGS { let hook_name = format!("hcom-{}", hook_type.to_lowercase()); - let bin = hcom_cmd.split_whitespace().next().unwrap_or("hcom"); - let hook_command = format!( - "sh -c 'command -v {bin} >/dev/null 2>&1 && exec {hcom_cmd} {cmd_suffix} || exit 0'" - ); let hook_entry = serde_json::json!({ "matcher": matcher, "hooks": [{ "name": hook_name, "type": "command", - "command": hook_command, + "command": hook_command(&hcom_cmd, cmd_suffix), "timeout": timeout, "description": description, }] @@ -1664,6 +1689,38 @@ fn remove_hooks_from_path(path: &Path) -> bool { mod tests { use super::*; + #[test] + fn test_hook_command_platform_specific() { + let cmd = hook_command("hcom", "gemini-beforeagent"); + if cfg!(windows) { + assert_eq!( + cmd, + "if (Get-Command hcom -ErrorAction SilentlyContinue) { hcom gemini-beforeagent } else { exit 0 }" + ); + } else { + assert_eq!( + cmd, + "sh -c 'command -v hcom >/dev/null 2>&1 && exec hcom gemini-beforeagent || exit 0'" + ); + } + } + + #[test] + fn test_hook_command_uses_first_word_as_bin_for_uvx() { + let cmd = hook_command("uvx hcom", "gemini-sessionend"); + if cfg!(windows) { + assert_eq!( + cmd, + "if (Get-Command uvx -ErrorAction SilentlyContinue) { uvx hcom gemini-sessionend } else { exit 0 }" + ); + } else { + assert_eq!( + cmd, + "sh -c 'command -v uvx >/dev/null 2>&1 && exec uvx hcom gemini-sessionend || exit 0'" + ); + } + } + #[test] fn test_matches_session_pattern() { assert!(matches_session_pattern( @@ -2114,10 +2171,7 @@ mod tests { } }; for &(hook_type, cmd_suffix) in expected { - let bin = hcom_cmd.split_whitespace().next().unwrap_or("hcom"); - let expected_full = format!( - "sh -c 'command -v {bin} >/dev/null 2>&1 && exec {hcom_cmd} {cmd_suffix} || exit 0'" - ); + let expected_full = hook_command(&hcom_cmd, cmd_suffix); let matchers = match hooks.get(hook_type).and_then(|v| v.as_array()) { Some(a) => a, None => { @@ -2218,10 +2272,7 @@ mod tests { ); assert_eq!(hook["timeout"].as_u64().unwrap(), expected_timeout as u64); let hcom = crate::runtime_env::build_hcom_command(); - let bin = hcom.split_whitespace().next().unwrap_or("hcom"); - let expected_command = format!( - "sh -c 'command -v {bin} >/dev/null 2>&1 && exec {hcom} {cmd_suffix} || exit 0'" - ); + let expected_command = hook_command(&hcom, cmd_suffix); assert_eq!( hook["command"].as_str().unwrap(), expected_command, diff --git a/src/hooks/kimi.rs b/src/hooks/kimi.rs index 94269656..bbc6480f 100644 --- a/src/hooks/kimi.rs +++ b/src/hooks/kimi.rs @@ -77,7 +77,7 @@ fn kimi_config_dir() -> PathBuf { { return PathBuf::from(dir); } - dirs::home_dir().unwrap_or_default().join(".kimi-code") + crate::runtime_env::tool_config_root().join(".kimi-code") } pub fn get_kimi_settings_path() -> PathBuf { @@ -887,6 +887,31 @@ pub fn dispatch_kimi_hook(hook_name: &str) -> i32 { #[cfg(test)] mod tests { use super::*; + use crate::hooks::test_helpers::isolated_test_env; + use serial_test::serial; + + #[test] + #[serial] + fn config_path_is_project_local_unless_explicitly_overridden() { + let (_dir, hcom_dir, _home, _guard) = isolated_test_env(); + unsafe { + std::env::remove_var("KIMI_CODE_HOME"); + } + assert_eq!( + get_kimi_settings_path(), + hcom_dir + .parent() + .unwrap() + .join(".kimi-code") + .join("config.toml") + ); + + let explicit = hcom_dir.join("explicit-kimi"); + unsafe { + std::env::set_var("KIMI_CODE_HOME", &explicit); + } + assert_eq!(get_kimi_settings_path(), explicit.join("config.toml")); + } fn rules(doc: &DocumentMut) -> &ArrayOfTables { match doc.get("permission").and_then(|p| p.get("rules")) { diff --git a/src/hooks/mod.rs b/src/hooks/mod.rs index 306d487e..cac9b4b6 100644 --- a/src/hooks/mod.rs +++ b/src/hooks/mod.rs @@ -46,6 +46,7 @@ pub mod test_helpers { saved_codex_home: Option, saved_gemini_cli_home: Option, saved_kilo_config_dir: Option, + saved_kimi_code_home: Option, saved_copilot_home: Option, saved_test_codex_cli_version: Option, saved_pi_coding_agent_dir: Option, @@ -76,6 +77,7 @@ pub mod test_helpers { saved_codex_home: std::env::var("CODEX_HOME").ok(), saved_gemini_cli_home: std::env::var("GEMINI_CLI_HOME").ok(), saved_kilo_config_dir: std::env::var("KILO_CONFIG_DIR").ok(), + saved_kimi_code_home: std::env::var("KIMI_CODE_HOME").ok(), saved_copilot_home: std::env::var("COPILOT_HOME").ok(), saved_test_codex_cli_version: std::env::var("HCOM_TEST_CODEX_CLI_VERSION").ok(), saved_pi_coding_agent_dir: std::env::var("PI_CODING_AGENT_DIR").ok(), @@ -124,6 +126,10 @@ pub mod test_helpers { Some(v) => std::env::set_var("KILO_CONFIG_DIR", v), None => std::env::remove_var("KILO_CONFIG_DIR"), } + match &self.saved_kimi_code_home { + Some(v) => std::env::set_var("KIMI_CODE_HOME", v), + None => std::env::remove_var("KIMI_CODE_HOME"), + } match &self.saved_copilot_home { Some(v) => std::env::set_var("COPILOT_HOME", v), None => std::env::remove_var("COPILOT_HOME"), diff --git a/src/hooks/opencode.rs b/src/hooks/opencode.rs index e70bdb14..32186807 100644 --- a/src/hooks/opencode.rs +++ b/src/hooks/opencode.rs @@ -118,35 +118,9 @@ fn instance_tool(db: &HcomDb, instance_name: &str) -> String { /// Both apps use XDG_DATA_HOME. Kilo additionally supports `KILO_DB`, which /// may be absolute or relative to Kilo's data directory. fn get_family_db_path(tool: &str) -> Option { - let xdg_data = std::env::var("XDG_DATA_HOME").unwrap_or_else(|_| { - let home = std::env::var("HOME").unwrap_or_default(); - format!("{}/.local/share", home) - }); - let data_dir = std::path::PathBuf::from(&xdg_data).join(tool); - let db_path = if tool == "kilo" { - if std::env::var("KILO_DB").as_deref() == Ok(":memory:") { - return None; - } - std::env::var("KILO_DB") - .ok() - .filter(|value| !value.is_empty()) - .map(std::path::PathBuf::from) - .map(|path| { - if path.is_absolute() { - path - } else { - data_dir.join(path) - } - }) - .unwrap_or_else(|| data_dir.join("kilo.db")) - } else { - data_dir.join("opencode.db") - }; - if db_path.exists() { - Some(db_path.to_string_lossy().to_string()) - } else { - None - } + crate::runtime_env::opencode_family_db_path(tool) + .filter(|p| p.exists()) + .map(|p| p.to_string_lossy().to_string()) } #[cfg(test)] @@ -578,17 +552,17 @@ pub const PLUGIN_SOURCE: &str = include_str!("../opencode_plugin/hcom.ts"); const PLUGIN_FILENAME: &str = "hcom.ts"; fn current_home_dir() -> std::path::PathBuf { - std::env::var("HOME") - .map(std::path::PathBuf::from) - .unwrap_or_else(|_| dirs::home_dir().unwrap_or_default()) + crate::runtime_env::user_home().unwrap_or_default() } -/// Resolve XDG_CONFIG_HOME with fallback to ~/.config. +/// Resolve the user config home (`XDG_CONFIG_HOME`, else `~/.config` on +/// Unix/macOS or `%APPDATA%` on Windows), falling back to `~/.config` if +/// unresolvable. fn xdg_config_home() -> String { - std::env::var("XDG_CONFIG_HOME").unwrap_or_else(|_| { - let home = std::env::var("HOME").unwrap_or_default(); - format!("{}/.config", home) - }) + crate::runtime_env::user_config_home() + .unwrap_or_else(|| current_home_dir().join(".config")) + .to_string_lossy() + .into_owned() } /// Get the canonical plugin install directory for an OpenCode-family app. diff --git a/src/instance_lifecycle.rs b/src/instance_lifecycle.rs index e54eff2e..da229a78 100644 --- a/src/instance_lifecycle.rs +++ b/src/instance_lifecycle.rs @@ -337,7 +337,7 @@ pub(crate) fn finalize_launch_failure_detail( 0 }; let process_state = data.pid.and_then(|pid| { - let alive = unsafe { libc::kill(pid as libc::pid_t, 0) == 0 }; + let alive = crate::sys::process::is_alive(pid as u32); alive.then(|| format!("process alive {age}s, never bound")) }); let mut detail = fallback_detail diff --git a/src/instance_names.rs b/src/instance_names.rs index 14020259..a6294cbe 100644 --- a/src/instance_names.rs +++ b/src/instance_names.rs @@ -332,10 +332,9 @@ pub(crate) fn reserve_generated_name(db: &HcomDb) -> Result { .truncate(false) .open(&lock_path)?; - // Acquire exclusive file lock - use nix::fcntl::{Flock, FlockArg}; - let flock = Flock::lock(lock_file, FlockArg::LockExclusive) - .map_err(|(_, e)| anyhow::anyhow!("flock failed: {}", e))?; + // Acquire exclusive file lock; released when `lock_file` drops at scope end. + crate::sys::fs::lock_exclusive(&lock_file) + .map_err(|e| anyhow::anyhow!("flock failed: {}", e))?; let result = (|| -> Result { let name = allocate_unreserved_name(db)?; @@ -361,8 +360,8 @@ pub(crate) fn reserve_generated_name(db: &HcomDb) -> Result { Ok(name) })(); - // Unlock (drop the flock guard) - let _file = Flock::unlock(flock); + // Lock released when `lock_file` is dropped at function scope end. + drop(lock_file); result } diff --git a/src/launcher.rs b/src/launcher.rs index 68acd516..45a94b6d 100644 --- a/src/launcher.rs +++ b/src/launcher.rs @@ -7,7 +7,6 @@ use std::collections::HashMap; use std::fs; use std::io::Write; -use std::os::unix::fs::{OpenOptionsExt, PermissionsExt}; use std::path::Path; use anyhow::{Result, bail}; @@ -677,11 +676,18 @@ fn ensure_hooks_installed(tool: &LaunchTool, include_permissions: bool) -> Resul } } -/// Build a command string for Claude (non-PTY mode). +/// Build a command string for Claude (non-PTY mode). Quoting matches the +/// shell that will run the resulting script: PowerShell on Windows, POSIX +/// shell elsewhere (see `launch_terminal`'s `create_powershell_script` / +/// `create_bash_script` split). fn build_claude_command(args: &[String]) -> String { let mut parts = vec!["claude".to_string()]; for arg in args { - parts.push(crate::tools::args_common::shell_quote(arg)); + if cfg!(windows) { + parts.push(terminal::ps_quote(arg)); + } else { + parts.push(crate::tools::args_common::shell_quote(arg)); + } } parts.join(" ") } @@ -689,6 +695,8 @@ fn build_claude_command(args: &[String]) -> String { /// Tool-specific extra environment variables for PTY mode. fn tool_extra_env(tool: &str) -> HashMap { let mut m = HashMap::new(); + // Claude is driven by the PTY wrapper (ConPTY on Windows, openpty on Unix), + // which handles injection; HCOM_PTY_MODE tells the Stop hook to defer to it. if tool == "claude" { m.insert("HCOM_PTY_MODE".to_string(), "1".to_string()); } @@ -716,6 +724,235 @@ fn background_runner_env( runner_env } +/// Non-HCOM ambient env to forward through the sidecar, with marker/identity/ +/// instance-state/terminal-color vars stripped. On Windows env names are +/// case-insensitive (and the paired PowerShell `Remove-Item Env:` folds case), +/// so `case_insensitive` folds case for the match; Unix keeps exact-case. +fn sidecar_ambient_env<'a>( + env: &HashMap, + strip_vars: impl Iterator, + case_insensitive: bool, +) -> HashMap { + let norm = |k: &str| { + if case_insensitive { + k.to_ascii_lowercase() + } else { + k.to_string() + } + }; + let strip: std::collections::HashSet = strip_vars.map(&norm).collect(); + env.iter() + .filter(|(k, _)| !k.starts_with("HCOM_") && !strip.contains(&norm(k))) + .map(|(k, v)| (k.clone(), v.clone())) + .collect() +} + +/// Windows runner: a PowerShell script that launches the tool through the hcom +/// ConPTY wrapper (`hcom pty `), mirroring the Unix bash runner. The +/// wrapper runs the delivery loop so idle agents can be woken. Mirrors the bash +/// runner's env scrubbing, HCOM env, secret sidecar, and PATH setup. +fn create_runner_script_windows( + tool: &str, + cwd: &str, + instance_name: &str, + env: &HashMap, + tool_args: &[String], +) -> Result { + let tool_spec = tool.parse::().map(|t| t.spec()).ok(); + let instance_state_env: &[&str] = tool_spec.map(|s| s.instance_state_env).unwrap_or(&[]); + + let launch_dir = paths::hcom_path(&[paths::LAUNCH_DIR]); + fs::create_dir_all(&launch_dir).ok(); + let script_file = launch_dir.join(format!( + "{}_{}_{}_{}.ps1", + tool, + instance_name, + std::process::id(), + rand::random::() % 9000 + 1000 + )); + + // Visible HCOM_* env, plus the managed-launch marker so hooks engage. + let mut hcom_env: HashMap = env + .iter() + .filter(|(k, _)| k.starts_with("HCOM_")) + .map(|(k, v)| (k.clone(), v.clone())) + .collect(); + hcom_env.insert("HCOM_LAUNCHED".to_string(), "1".to_string()); + let env_block = terminal::build_env_string(&hcom_env, "powershell"); + + // Non-HCOM ambient env (may carry secrets) goes through a private sidecar + // that is dot-sourced then deleted, matching the bash runner. + let ambient_env = sidecar_ambient_env( + env, + tool_marker_vars() + .iter() + .chain(HCOM_IDENTITY_VARS.iter()) + .chain(instance_state_env.iter()) + .chain(crate::terminal::TERMINAL_COLOR_VARS.iter()) + .copied(), + true, + ); + let sidecar_source = if ambient_env.is_empty() { + String::new() + } else { + let env_file = launch_dir.join(format!( + "{}_{}_{}_{}.ps1", + tool, + instance_name, + std::process::id(), + rand::random::() % 9000 + 1000 + )); + let mut file = crate::sys::fs::create_private_new(&env_file)?; + // Windows PowerShell 5.1 reads BOM-less files using the legacy ANSI + // code page, corrupting non-ASCII env values; a UTF-8 BOM forces it + // to read as UTF-8. + file.write_all(b"\xEF\xBB\xBF")?; + writeln!( + file, + "{}", + terminal::build_env_string(&ambient_env, "powershell") + )?; + let q = terminal::ps_quote(&env_file.to_string_lossy()); + format!("if (Test-Path {q}) {{ . {q}; Remove-Item -Force {q} }}") + }; + + // Scrub inherited tool markers / identity / instance-state vars. + let unset_names: Vec = tool_marker_vars() + .iter() + .chain(HCOM_IDENTITY_VARS.iter()) + .chain(instance_state_env.iter()) + .map(|v| format!("Env:{v}")) + .collect(); + let unset_line = if unset_names.is_empty() { + String::new() + } else { + format!( + "Remove-Item {} -ErrorAction SilentlyContinue", + unset_names.join(",") + ) + }; + + // Resolve binary directories for minimal PATH environments. + let mut path_dirs: Vec = Vec::new(); + if let Ok(dev_root) = std::env::var("HCOM_DEV_ROOT") + && let Some(bin) = crate::shared::dev_root_binary(Path::new(&dev_root)) + && let Some(dir) = bin.parent() + { + path_dirs.push(dir.to_string_lossy().into_owned()); + } + // Ensure the launched tool (and its hooks) can call back to *this* hcom by + // name — a dev binary may not be on the global PATH. + if let Ok(exe) = std::env::current_exe() + && let Some(dir) = exe.parent() + { + let d = dir.to_string_lossy().to_string(); + if !path_dirs.contains(&d) { + path_dirs.push(d); + } + } + let tool_bin = tool + .parse::() + .map(|t| t.spec().cli_binary) + .unwrap_or(tool); + for bin_name in &[tool_bin, "hcom", "python3", "node"] { + if let Some(bin_path) = terminal::which_bin(bin_name) + && let Some(dir) = Path::new(&bin_path).parent() + { + let d = dir.to_string_lossy().to_string(); + if !path_dirs.contains(&d) { + path_dirs.push(d); + } + } + } + let path_line = if path_dirs.is_empty() { + String::new() + } else { + format!( + "$env:PATH = {} + $env:PATH", + terminal::ps_quote(&format!("{};", path_dirs.join(";"))) + ) + }; + + // Run through the hcom PTY wrapper (ConPTY) so the tool is driven by the + // delivery loop — this is what wakes an idle agent on Windows. Mirrors the + // Unix runner's `hcom pty ` call. + // + // Tool args travel via a JSON sidecar file, not the command line: the + // PowerShell → native-exe argv boundary mangles embedded double quotes + // (powershell.exe passes them unescaped, so the child's command-line + // parser re-splits at quote/space boundaries). Codex args always contain + // quotes (`-c projects={ "path" = ... }`, developer_instructions), which + // made `hcom codex` fail with "unexpected argument" (#66). Only the + // sidecar path — generated by hcom, never quote-bearing — goes on the + // command line. `hcom pty` reads and deletes the file. + let hcom_bin = std::env::current_exe() + .map(|p| p.to_string_lossy().into_owned()) + .unwrap_or_else(|_| "hcom".to_string()); + let run_line = if tool_args.is_empty() { + format!("& {} pty {}", terminal::ps_quote(&hcom_bin), tool) + } else { + let args_file = launch_dir.join(format!( + "{}_{}_{}_{}.args.json", + tool, + instance_name, + std::process::id(), + rand::random::() % 9000 + 1000 + )); + let mut file = crate::sys::fs::create_private_new(&args_file)?; + file.write_all(serde_json::to_string(tool_args)?.as_bytes())?; + format!( + "& {} pty {} --hcom-args-file {}", + terminal::ps_quote(&hcom_bin), + tool, + terminal::ps_quote(&args_file.to_string_lossy()) + ) + }; + // `powershell -File` returns 0 unless the script exits with an explicit + // code, so surface the wrapper's real exit status (agent failures, PTY + // crashes, kill signals) instead of always reporting success. + let run_line = format!("{run_line}\nexit $LASTEXITCODE"); + + let display = tool + .chars() + .next() + .unwrap_or('?') + .to_uppercase() + .collect::() + + &tool[1..]; + let content = format!( + "# {display} hcom native runner ({instance_name})\n\ + Set-Location {cwd}\n\ + {unset_line}\n\ + {env_block}\n\ + {sidecar_source}\n\ + {path_line}\n\ + \n\ + {run_line}\n", + cwd = terminal::ps_quote(cwd), + ); + + // Windows PowerShell 5.1 reads BOM-less files using the legacy ANSI code + // page, corrupting non-ASCII usernames/paths/prompts/env values; a UTF-8 + // BOM forces it to read as UTF-8. + let mut bytes = Vec::with_capacity(content.len() + 3); + bytes.extend_from_slice(b"\xEF\xBB\xBF"); + bytes.extend_from_slice(content.as_bytes()); + fs::write(&script_file, &bytes)?; + + crate::log::log_info( + "pty", + "native.script", + &format!( + "script={} tool={} instance={} (windows ConPTY launch)", + script_file.display(), + tool, + instance_name + ), + ); + + Ok(script_file.to_string_lossy().to_string()) +} + /// Create a bash script that runs a tool via the hcom native PTY wrapper. /// /// The script sets up the environment and calls `hcom pty [args...]`. @@ -727,6 +964,9 @@ pub fn create_runner_script( tool_args: &[String], run_here: bool, ) -> Result { + if cfg!(windows) { + return create_runner_script_windows(tool, cwd, instance_name, env, tool_args); + } // Resolve the tool's IntegrationSpec for instance-state env stripping let tool_spec = tool.parse::().map(|t| t.spec()).ok(); let instance_state_env: &[&str] = tool_spec.map(|s| s.instance_state_env).unwrap_or(&[]); @@ -753,18 +993,16 @@ pub fn create_runner_script( .filter(|(k, _)| k.starts_with("HCOM_")) .map(|(k, v)| (k.clone(), v.clone())) .collect(); - let sidecar_strip: std::collections::HashSet<&str> = tool_marker_vars() - .iter() - .chain(HCOM_IDENTITY_VARS.iter()) - .chain(instance_state_env.iter()) - .chain(crate::terminal::TERMINAL_COLOR_VARS.iter()) - .copied() - .collect(); - let ambient_env: HashMap = env - .iter() - .filter(|(k, _)| !k.starts_with("HCOM_") && !sidecar_strip.contains(k.as_str())) - .map(|(k, v)| (k.clone(), v.clone())) - .collect(); + let ambient_env = sidecar_ambient_env( + env, + tool_marker_vars() + .iter() + .chain(HCOM_IDENTITY_VARS.iter()) + .chain(instance_state_env.iter()) + .chain(crate::terminal::TERMINAL_COLOR_VARS.iter()) + .copied(), + false, + ); let env_block = terminal::build_env_string(&hcom_env, "bash_export"); let sensitive_env_source = if ambient_env.is_empty() { String::new() @@ -776,11 +1014,7 @@ pub fn create_runner_script( std::process::id(), rand::random::() % 9000 + 1000 )); - let mut file = fs::OpenOptions::new() - .write(true) - .create_new(true) - .mode(0o600) - .open(&env_file)?; + let mut file = crate::sys::fs::create_private_new(&env_file)?; writeln!( file, "{}", @@ -865,7 +1099,7 @@ pub fn create_runner_script( ); fs::write(&script_file, &content)?; - fs::set_permissions(&script_file, fs::Permissions::from_mode(0o755))?; + crate::sys::fs::set_executable(&script_file)?; crate::log::log_info( "pty", @@ -882,6 +1116,22 @@ pub fn create_runner_script( Ok(script_file.to_string_lossy().to_string()) } +/// Build the command that runs a generated runner script in the launched +/// terminal: PowerShell on Windows, bash elsewhere. +fn runner_invocation_command(script_file: &str) -> String { + if cfg!(windows) { + format!( + "powershell -ExecutionPolicy Bypass -File {}", + crate::terminal::ps_quote(script_file) + ) + } else { + format!( + "bash {}", + crate::tools::args_common::shell_quote(script_file) + ) + } +} + /// Launch a tool via PTY wrapper in a terminal. #[allow(clippy::too_many_arguments)] pub fn launch_pty( @@ -913,10 +1163,7 @@ pub fn launch_pty( let script_file = create_runner_script(tool, cwd, instance_name, &runner_env, tool_args, run_here)?; - let command = format!( - "bash {}", - crate::tools::args_common::shell_quote(&script_file) - ); + let command = runner_invocation_command(&script_file); let terminal_env: HashMap = runner_env .iter() .filter(|(k, _)| k.starts_with("HCOM_")) @@ -1027,10 +1274,7 @@ fn launch_background_runner( runner_env.insert("HCOM_BACKGROUND".to_string(), log_filename); let script_file = create_runner_script(tool, cwd, instance_name, &runner_env, tool_args, false)?; - let command = format!( - "bash {}", - crate::tools::args_common::shell_quote(&script_file) - ); + let command = runner_invocation_command(&script_file); let terminal_env: HashMap = runner_env .iter() .filter(|(k, _)| k.starts_with("HCOM_")) @@ -1153,8 +1397,9 @@ fn resolve_explicit_name_conflict(db: &HcomDb, name: &str) -> Result<()> { /// - codex: adds `-c` with a TOML inline-table value that sets trust for the /// canonical CWD. Uses key `projects` (no dots in the key path) so codex's /// naive `.`-splitting never touches the path itself, making it robust to -/// dotted directory components. Paths containing a literal `"` or backslash -/// would break the TOML quoting — a Windows-only edge case, not handled here. +/// dotted directory components. The path is TOML-escaped (`\` and `"`) — +/// every Windows path carries backslashes, and unescaped they made codex +/// reject the value as "invalid type: string, expected a map". /// /// When `auto_trust` is false, returns immediately without modifying args. /// Idempotent: no-op if the relevant flag is already present. @@ -1179,12 +1424,26 @@ pub(crate) fn inject_workspace_trust_args( .any(|w| w[0] == "-c" && w[1].contains("trust_level")); if !already_set { let canonical_str = canonical_dir.to_string_lossy(); + // std::fs::canonicalize returns \\?\-prefixed verbatim paths on + // Windows; codex tracks projects by their ordinary absolute + // form, so strip the prefix or the trust key never matches. + let simplified = if let Some(unc) = canonical_str.strip_prefix(r"\\?\UNC\") { + format!(r"\\{unc}") + } else { + canonical_str + .strip_prefix(r"\\?\") + .unwrap_or(&canonical_str) + .to_string() + }; // codex -c: key="projects" (no dots → no split issue), value is a TOML // inline table with the quoted path as key. apply_single_override replaces // the projects table for this session only (file stays untouched). + // Escape backslashes before quotes: the quote escape introduces + // a backslash that must not itself be doubled. + let toml_escaped = crate::runtime_env::toml_escape_path(&simplified); let trust_override = format!( "projects={{ \"{}\" = {{ trust_level = \"trusted\" }} }}", - canonical_str + toml_escaped ); args.push("-c".to_string()); args.push(trust_override); @@ -2485,7 +2744,13 @@ mod tests { fn test_build_claude_command() { let args = vec!["--model".to_string(), "sonnet".to_string()]; let cmd = build_claude_command(&args); - assert_eq!(cmd, "claude --model sonnet"); + if cfg!(windows) { + // ps_quote() always quotes, unlike the POSIX shell_quote() used + // elsewhere, which leaves plain args unquoted. + assert_eq!(cmd, "claude '--model' 'sonnet'"); + } else { + assert_eq!(cmd, "claude --model sonnet"); + } } #[test] @@ -2735,6 +3000,9 @@ mod tests { ); } + // Unix-only: asserts the bash runner's `. 'sidecar'` sourcing + unset block; + // Windows generates a PowerShell runner with a different shape. + #[cfg(unix)] #[test] fn test_runner_script_strips_instance_state_vars() { let env = HashMap::from([ @@ -2775,6 +3043,99 @@ mod tests { std::fs::remove_file(env_file).ok(); } + // create_runner_script_windows() isn't cfg(windows)-gated (only its call + // site is, via a runtime cfg!(windows) check), so this runs on any host. + #[test] + fn test_runner_script_windows_has_bom_and_propagates_exit_code() { + let env = HashMap::from([("SOME_SECRET".to_string(), "sekrit".to_string())]); + + let script = create_runner_script_windows("gemini", "/tmp", "test-win", &env, &[]).unwrap(); + + let bytes = std::fs::read(&script).unwrap(); + assert_eq!( + &bytes[..3], + b"\xEF\xBB\xBF", + "PowerShell 5.1 misreads BOM-less files as the legacy ANSI code page" + ); + let content = String::from_utf8(bytes[3..].to_vec()).unwrap(); + assert!( + content.contains("exit $LASTEXITCODE"), + "runner must surface the wrapped process's real exit code, not always report success" + ); + + let sidecar = content + .split("Test-Path '") + .nth(1) + .and_then(|s| s.split('\'').next()) + .expect("ambient env should be sourced from a sidecar file"); + let sidecar_bytes = std::fs::read(sidecar).unwrap(); + assert_eq!( + &sidecar_bytes[..3], + b"\xEF\xBB\xBF", + "sidecar env file needs the same BOM as the runner script" + ); + assert!(String::from_utf8_lossy(&sidecar_bytes).contains("SOME_SECRET")); + + std::fs::remove_file(&script).ok(); + std::fs::remove_file(sidecar).ok(); + } + + // Tool args must travel via the JSON sidecar, never inline on the run + // line: powershell.exe passes embedded double quotes unescaped to native + // executables, so the child re-splits argv at quote boundaries (#66 — + // `hcom codex` failed on its quote-bearing `-c` values). + #[test] + fn test_runner_script_windows_passes_args_via_sidecar_file() { + let env = HashMap::new(); + let args = vec![ + "-c".to_string(), + r#"projects={ "C:\repo" = { trust_level = "trusted" } }"#.to_string(), + "-c".to_string(), + "developer_instructions=multi\nline \"quoted\" text".to_string(), + ]; + + let script = + create_runner_script_windows("codex", "/tmp", "test-args", &env, &args).unwrap(); + let content = std::fs::read_to_string(&script).unwrap(); + + let run_line = content + .lines() + .find(|l| l.contains(" pty codex")) + .expect("runner must invoke hcom pty"); + assert!(run_line.contains("--hcom-args-file")); + assert!(!run_line.contains("trust_level")); + assert!(!run_line.contains("developer_instructions")); + + let args_file = run_line + .split("--hcom-args-file '") + .nth(1) + .and_then(|s| s.split('\'').next()) + .expect("run line should quote the args file path"); + let json = std::fs::read_to_string(args_file).unwrap(); + let roundtrip: Vec = serde_json::from_str(&json).unwrap(); + assert_eq!( + roundtrip, args, + "args must survive the file round-trip exactly" + ); + + std::fs::remove_file(&script).ok(); + std::fs::remove_file(args_file).ok(); + } + + #[test] + fn test_runner_script_windows_no_args_skips_sidecar_file() { + let env = HashMap::new(); + let script = + create_runner_script_windows("gemini", "/tmp", "test-noargs", &env, &[]).unwrap(); + let content = std::fs::read_to_string(&script).unwrap(); + let run_line = content + .lines() + .find(|l| l.contains(" pty gemini")) + .expect("runner must invoke hcom pty"); + assert!(!run_line.contains("--hcom-args-file")); + std::fs::remove_file(&script).ok(); + } + #[test] #[serial] fn test_same_tool_nesting_strips_instance_state() { @@ -2974,6 +3335,38 @@ mod tests { assert!(val.contains("trust_level"), "trust_level missing: {val}"); } + #[test] + fn test_codex_windows_verbatim_path_stripped_and_toml_escaped() { + // std::fs::canonicalize yields \\?\-prefixed paths on Windows. The + // prefix must go (codex keys projects by the plain absolute form) and + // backslashes must be TOML-escaped or codex rejects the inline table. + let dir = std::path::Path::new(r"\\?\C:\Users\x\proj"); + let mut args: Vec = vec![]; + inject_workspace_trust_args(&LaunchTool::Codex, dir, &mut args, true); + let val = &args[1]; + assert!( + val.contains(r#""C:\\Users\\x\\proj""#), + "path must be prefix-stripped and backslash-escaped: {val}" + ); + assert!( + !val.contains(r"\\?\"), + "verbatim prefix must be stripped: {val}" + ); + } + + #[test] + fn test_codex_windows_verbatim_unc_path_keeps_server_form() { + let dir = std::path::Path::new(r"\\?\UNC\server\share\dir"); + let mut args: Vec = vec![]; + inject_workspace_trust_args(&LaunchTool::Codex, dir, &mut args, true); + let val = &args[1]; + // \\?\UNC\server\... → \\server\... → TOML-escaped \\\\server\\... + assert!( + val.contains(r#""\\\\server\\share\\dir""#), + "UNC path must keep its \\\\server form, escaped: {val}" + ); + } + #[test] fn test_non_trust_tools_unaffected() { let dir = std::path::Path::new("/some/workspace"); @@ -2991,4 +3384,19 @@ mod tests { ); } } + + #[test] + fn sidecar_ambient_env_folds_case_on_windows_only() { + let mut env = std::collections::HashMap::new(); + env.insert("no_color".to_string(), "1".to_string()); + env.insert("NO_COLOR".to_string(), "1".to_string()); + env.insert("MY_SECRET".to_string(), "x".to_string()); + env.insert("HCOM_X".to_string(), "y".to_string()); + let strip = ["NO_COLOR"]; + let win = sidecar_ambient_env(&env, strip.iter().copied(), true); + assert!(!win.contains_key("no_color") && !win.contains_key("NO_COLOR")); + assert!(win.contains_key("MY_SECRET") && !win.contains_key("HCOM_X")); + let unix = sidecar_ambient_env(&env, strip.iter().copied(), false); + assert!(!unix.contains_key("NO_COLOR") && unix.contains_key("no_color")); // Unix exact-case preserved + } } diff --git a/src/main.rs b/src/main.rs index 7fc31cab..24cb8899 100644 --- a/src/main.rs +++ b/src/main.rs @@ -31,6 +31,7 @@ mod runtime_env; pub mod scripts; pub mod shared; mod shell_env; +mod sys; pub mod terminal; mod tool; pub mod tools; @@ -67,6 +68,9 @@ fn main() -> Result<()> { } /// Run PTY wrapper mode. +/// +/// Uses Unix pseudo-terminals on Unix and ConPTY (via `portable-pty`) on +/// Windows; the proxy backend is selected inside `pty::Proxy`. pub fn run_pty(args: &[String]) -> Result<()> { if args.is_empty() || args[0] == "--help" || args[0] == "-h" { eprintln!("hcom pty - PTY wrapper for hcom"); @@ -102,7 +106,24 @@ pub fn run_pty(args: &[String]) -> Result<()> { } let tool_str = &args[0]; - let tool_args: Vec<&str> = args[1..].iter().map(|s| s.as_str()).collect(); + + // Windows runner scripts pass tool args via a JSON sidecar file instead of + // inline argv (see create_runner_script_windows): the PowerShell → + // native-exe boundary corrupts arguments with embedded double quotes. + let sidecar_args: Vec; + let tool_args: Vec<&str> = if args.get(1).map(String::as_str) == Some("--hcom-args-file") { + let Some(path) = args.get(2) else { + bail!("--hcom-args-file requires a path"); + }; + let content = std::fs::read_to_string(path) + .with_context(|| format!("Failed to read args file {path}"))?; + let _ = std::fs::remove_file(path); + sidecar_args = serde_json::from_str(&content) + .with_context(|| format!("Invalid JSON in args file {path}"))?; + sidecar_args.iter().map(|s| s.as_str()).collect() + } else { + args[1..].iter().map(|s| s.as_str()).collect() + }; // Keep arbitrary commands explicit so they cannot inherit a known tool's // delivery behavior merely because parsing failed. @@ -122,7 +143,14 @@ pub fn run_pty(args: &[String]) -> Result<()> { // On Termux, some wrapped tools need a launcher override instead of direct exec. let (command, extra_args): (String, Vec); - if let Some((launcher, prefix_args)) = + #[cfg(windows)] + let windows_launcher = terminal::resolve_windows_tool_launcher(tool_exe, &resolved); + #[cfg(not(windows))] + let windows_launcher: Option<(String, Vec)> = None; + if let Some((launcher, prefix_args)) = windows_launcher { + command = launcher; + extra_args = prefix_args; + } else if let Some((launcher, prefix_args)) = terminal::resolve_termux_tool_launcher(tool_exe, &resolved) { command = launcher; diff --git a/src/notify/server.rs b/src/notify/server.rs index a5c2fe45..53b93b9c 100644 --- a/src/notify/server.rs +++ b/src/notify/server.rs @@ -9,11 +9,8 @@ use anyhow::{Context, Result}; use std::net::TcpListener; -use std::os::fd::{AsRawFd, BorrowedFd}; use std::time::Duration; -use nix::poll::{PollFd, PollFlags, PollTimeout, poll}; - /// TCP notification server for wake-ups pub struct NotifyServer { listener: TcpListener, @@ -41,19 +38,12 @@ impl NotifyServer { /// /// Returns true if notified (connection received), false on timeout pub fn wait(&self, timeout: Duration) -> bool { - let timeout_ms = timeout.as_millis().min(i32::MAX as u128) as i32; - let poll_timeout = PollTimeout::try_from(timeout_ms).unwrap_or(PollTimeout::MAX); - - let fd = unsafe { BorrowedFd::borrow_raw(self.listener.as_raw_fd()) }; - let mut poll_fds = [PollFd::new(fd, PollFlags::POLLIN)]; - - match poll(&mut poll_fds, poll_timeout) { - Ok(n) if n > 0 => { - // Drain all pending notifications - self.drain(); - true - } - _ => false, + if crate::sys::net::wait_readable(&self.listener, timeout) { + // Drain all pending notifications + self.drain(); + true + } else { + false } } diff --git a/src/pidtrack.rs b/src/pidtrack.rs index 1e463655..8ab3f1e2 100644 --- a/src/pidtrack.rs +++ b/src/pidtrack.rs @@ -88,17 +88,9 @@ fn pidfile_path(hcom_dir: &Path) -> PathBuf { hcom_dir.join(PIDFILE_NAME) } -/// Check if a process is alive via `kill(pid, 0)`. -/// Handles EPERM (process exists but owned by another user). +/// Check if a process is alive. See [`crate::sys::process::is_alive`]. pub fn is_alive(pid: u32) -> bool { - // SAFETY: kill(pid, 0) is a no-op signal that just checks process existence. - let ret = unsafe { libc::kill(pid as i32, 0) }; - if ret == 0 { - return true; - } - // EPERM means process exists but is owned by another user - let err = std::io::Error::last_os_error(); - err.raw_os_error() == Some(libc::EPERM) + crate::sys::process::is_alive(pid) } /// Read raw pidfile data. diff --git a/src/pty/inject.rs b/src/pty/inject.rs index 878be516..14cdebe1 100644 --- a/src/pty/inject.rs +++ b/src/pty/inject.rs @@ -3,6 +3,7 @@ use anyhow::{Context, Result}; use std::io::{Read, Write}; use std::net::{TcpListener, TcpStream}; +#[cfg(unix)] use std::os::fd::AsRawFd; /// Magic prefix for query commands (not injection) @@ -67,16 +68,26 @@ impl InjectServer { self.port } - /// Get the listener raw file descriptor for polling + /// Get the listener raw file descriptor for polling (Unix poll loop only). + #[cfg(unix)] pub fn listener_raw_fd(&self) -> i32 { self.listener.as_raw_fd() } - /// Get raw file descriptors for active clients + /// Get raw file descriptors for active clients (Unix poll loop only). + #[cfg(unix)] pub fn client_raw_fds(&self) -> impl Iterator + '_ { self.clients.iter().map(|(stream, _)| stream.as_raw_fd()) } + /// Number of connected inject clients (portable; the Unix loop uses + /// `client_raw_fds().count()`, but tests and the Windows loop need this + /// without raw fds). + #[cfg(any(test, windows))] + pub fn client_count(&self) -> usize { + self.clients.len() + } + /// Accept a new connection. /// /// Returns `Ok(true)` if a connection was accepted, `Ok(false)` if the accept @@ -165,8 +176,9 @@ impl InjectServer { #[cfg(test)] mod tests { - use super::InjectServer; - use std::net::TcpStream; + use super::{InjectResult, InjectServer}; + use std::io::Write; + use std::net::{Shutdown, TcpStream}; use std::thread; use std::time::{Duration, Instant}; @@ -175,7 +187,7 @@ mod tests { let mut server = InjectServer::new().unwrap(); assert!(!server.accept().unwrap()); - assert_eq!(server.client_raw_fds().count(), 0); + assert_eq!(server.client_count(), 0); } #[test] @@ -195,6 +207,37 @@ mod tests { }; assert!(accepted); - assert_eq!(server.client_raw_fds().count(), 1); + assert_eq!(server.client_count(), 1); + } + + #[test] + fn completed_clients_can_be_drained_in_connection_order() { + let mut server = InjectServer::new().unwrap(); + let mut first = TcpStream::connect(("127.0.0.1", server.port())).unwrap(); + first.write_all(b"text").unwrap(); + first.shutdown(Shutdown::Write).unwrap(); + let mut second = TcpStream::connect(("127.0.0.1", server.port())).unwrap(); + second.write_all(b"\r").unwrap(); + second.shutdown(Shutdown::Write).unwrap(); + + let deadline = Instant::now() + Duration::from_secs(1); + while server.client_count() < 2 && Instant::now() < deadline { + let _ = server.accept(); + thread::sleep(Duration::from_millis(5)); + } + assert_eq!(server.client_count(), 2); + + let read_next = |server: &mut InjectServer| { + let deadline = Instant::now() + Duration::from_secs(1); + loop { + if let InjectResult::Inject(text) = server.read_client(0).unwrap() { + break text; + } + assert!(Instant::now() < deadline, "client did not complete"); + thread::sleep(Duration::from_millis(5)); + } + }; + assert_eq!(read_next(&mut server), "text"); + assert_eq!(read_next(&mut server), "\r"); } } diff --git a/src/pty/mod.rs b/src/pty/mod.rs index 03c4f32b..f3a8c16c 100644 --- a/src/pty/mod.rs +++ b/src/pty/mod.rs @@ -9,34 +9,55 @@ mod inject; pub mod screen; +#[cfg(any(unix, windows))] +mod shared; +#[cfg(unix)] mod terminal; +#[cfg(windows)] +mod win; +#[cfg(windows)] +pub use win::Proxy; + +#[cfg(unix)] use anyhow::{Context, Result, bail}; +#[cfg(unix)] use nix::errno::Errno; +#[cfg(unix)] use nix::fcntl::{FcntlArg, OFlag, fcntl}; +#[cfg(unix)] use nix::poll::{PollFd, PollFlags, PollTimeout, poll}; +#[cfg(unix)] use nix::pty::openpty; +#[cfg(unix)] use nix::sys::signal::{Signal, kill}; +#[cfg(unix)] use nix::unistd::{Pid, read, write}; +#[cfg(unix)] use std::io; +#[cfg(unix)] use std::os::fd::{AsFd, AsRawFd, BorrowedFd, OwnedFd}; +#[cfg(unix)] use std::os::unix::process::CommandExt; +#[cfg(unix)] use std::process::{Child, Command, ExitStatus}; +#[cfg(unix)] use std::sync::atomic::{AtomicBool, AtomicU16, Ordering}; -use std::sync::mpsc; +#[cfg(unix)] use std::sync::{Arc, RwLock}; -use std::time::{Duration, Instant}; +use std::time::Duration; +#[cfg(unix)] +use std::time::Instant; +#[cfg(unix)] use inject::InjectServer; +#[cfg(unix)] use screen::ScreenTracker; +#[cfg(unix)] use terminal::TerminalGuard; -use crate::config::Config; -use crate::db::HcomDb; -use crate::delivery::{DeliveryState, ScreenState, ToolConfig, run_delivery_loop}; -use crate::log::{log_error, log_info, log_warn}; -use crate::notify::NotifyServer; -use crate::shared::{ST_BLOCKED, ST_LISTENING, status_icon}; +#[cfg(unix)] +use crate::delivery::ScreenState; use crate::tool::Tool; /// Identity of the process wrapped by the PTY. @@ -58,21 +79,21 @@ impl PtyTarget { } } - fn known_tool(&self) -> Option { + pub(super) fn known_tool(&self) -> Option { match self { Self::Known(tool) => Some(*tool), Self::AdhocCommand(_) => None, } } - fn delivery_tool(&self) -> Tool { + pub(super) fn delivery_tool(&self) -> Tool { match self { Self::Known(tool) => *tool, Self::AdhocCommand(_) => Tool::Adhoc, } } - fn delivery_start_timeout(&self) -> Duration { + pub(super) fn delivery_start_timeout(&self) -> Duration { Duration::from_secs(self.delivery_tool().spec().pty.delivery_start_timeout_secs) } } @@ -80,6 +101,7 @@ impl PtyTarget { /// Tracks what type of incomplete escape sequence is pending on stdout. /// Used to defer title writes until the sequence completes across read boundaries. #[derive(Clone, Copy, PartialEq, Debug)] +#[cfg(unix)] enum PendingEscape { None, /// Incomplete CSI (ESC [) — complete when final byte (0x40-0x7E) appears @@ -111,6 +133,7 @@ enum PendingEscape { /// continuously-rendering TUI (e.g. pi during a turn) never yields a quiet /// iteration, which starved status-icon title updates entirely. #[inline] +#[cfg(unix)] fn title_write_safe(pending_utf8: u8, pending_escape: PendingEscape) -> bool { pending_utf8 == 0 && pending_escape == PendingEscape::None } @@ -120,6 +143,7 @@ fn title_write_safe(pending_utf8: u8, pending_escape: PendingEscape) -> bool { /// The prompt can briefly become undetectable while a TUI redraws, so treat /// non-empty -> None the same as non-empty -> empty. That preserves the /// cooldown across a Some("text") -> None -> Some("") transition. +#[cfg(any(unix, windows))] fn prompt_submit_observed( previous_input_text: Option<&str>, current_input_text: Option<&str>, @@ -144,6 +168,7 @@ fn prompt_submit_observed( /// Terminals emit these 3-byte events atomically, so a sequence split across /// reads is not tracked — it would pass through and cause at most one transient /// pause, self-corrected by the next focus event. +#[cfg(unix)] fn strip_focus_events(buf: &[u8]) -> Option> { if !buf.contains(&0x1b) { return None; @@ -180,6 +205,7 @@ fn strip_focus_events(buf: &[u8]) -> Option> { /// so those never appear in the filtered output. This function only sees /// ESC bytes that the filter passed through (non-title sequences). #[inline] +#[cfg(unix)] fn has_pending_escape(data: &[u8]) -> PendingEscape { if data.is_empty() { return PendingEscape::None; @@ -278,6 +304,7 @@ fn has_pending_escape(data: &[u8]) -> PendingEscape { /// - SingleShift: any single byte completes the shift /// - NfSeq: a final byte 0x30-0x7E #[inline] +#[cfg(unix)] fn resolve_pending_escape(pending: PendingEscape, data: &[u8]) -> PendingEscape { match pending { PendingEscape::None => PendingEscape::None, @@ -326,6 +353,7 @@ fn resolve_pending_escape(pending: PendingEscape, data: &[u8]) -> PendingEscape /// - 3-byte: 1110xxxx 10xxxxxx 10xxxxxx (starts 0xE0-0xEF) /// - 4-byte: 11110xxx 10xxxxxx 10xxxxxx 10xxxxxx (starts 0xF0-0xF7) #[inline] +#[cfg(unix)] fn pending_utf8_bytes(data: &[u8]) -> u8 { if data.is_empty() { return 0; @@ -400,6 +428,7 @@ fn pending_utf8_bytes(data: &[u8]) -> u8 { /// This filter only DISCARDS title bytes — real output passes through immediately. /// Max 3 prefix bytes (ESC, ], digit) held at buffer boundary for one poll cycle. #[derive(Clone, Copy, PartialEq)] +#[cfg(unix)] enum TitleFilterState { Pass, SawEsc, @@ -412,11 +441,13 @@ enum TitleFilterState { InTitleSawEsc, } +#[cfg(unix)] struct TitleOscFilter { state: TitleFilterState, discard_count: usize, } +#[cfg(unix)] impl TitleOscFilter { fn new() -> Self { Self { @@ -512,116 +543,40 @@ impl TitleOscFilter { } // Signal flags (set by signal handlers, checked in main loop) +#[cfg(unix)] static SIGWINCH_RECEIVED: AtomicBool = AtomicBool::new(false); +#[cfg(unix)] static SIGINT_RECEIVED: AtomicBool = AtomicBool::new(false); +#[cfg(unix)] static SIGTERM_RECEIVED: AtomicBool = AtomicBool::new(false); +#[cfg(unix)] static SIGHUP_RECEIVED: AtomicBool = AtomicBool::new(false); -// Exit reason flag (for cleanup to know context) -// false = normal exit (closed), true = signal exit (killed) -// Pub so delivery.rs can check it during cleanup -pub static EXIT_WAS_KILLED: AtomicBool = AtomicBool::new(false); +// Exit reason flag lives in `delivery` so the delivery loop compiles without +// the PTY wrapper; the proxy sets it here. +#[cfg(unix)] +use crate::delivery::EXIT_WAS_KILLED; +#[cfg(unix)] pub extern "C" fn handle_sigwinch(_: libc::c_int) { SIGWINCH_RECEIVED.store(true, Ordering::Release); } +#[cfg(unix)] pub extern "C" fn handle_sigint(_: libc::c_int) { SIGINT_RECEIVED.store(true, Ordering::Release); } +#[cfg(unix)] pub extern "C" fn handle_sigterm(_: libc::c_int) { SIGTERM_RECEIVED.store(true, Ordering::Release); } +#[cfg(unix)] extern "C" fn handle_sighup(_: libc::c_int) { SIGHUP_RECEIVED.store(true, Ordering::Release); } -/// Build minimal launch_context JSON from env vars available in the PTY process. -/// Captures process_id and late-bound terminal metadata needed by kill. -/// The start hook captures the full context (git_branch, tty, env snapshot) later. -fn build_early_launch_context() -> String { - use serde_json::{Map, Value}; - - let mut ctx = Map::new(); - - if let Ok(pid) = std::env::var("HCOM_PROCESS_ID") - && !pid.is_empty() - { - ctx.insert("process_id".into(), Value::String(pid)); - } - - // Kitty socket path for close-on-kill (needed when launching from outside kitty) - if let Ok(listen) = std::env::var("KITTY_LISTEN_ON") - && !listen.is_empty() - { - ctx.insert("kitty_listen_on".into(), Value::String(listen)); - } - - // Capture pane_id from terminal env vars for same-window launches. - let pane_id_vars: &[&str] = &[ - "WEZTERM_PANE", - "TMUX_PANE", - "KITTY_WINDOW_ID", - "ZELLIJ_PANE_ID", - ]; - for &var in pane_id_vars { - if let Ok(val) = std::env::var(var) - && !val.is_empty() - { - ctx.insert("pane_id".into(), Value::String(val)); - break; - } - } - - // Read terminal_id from temp file written by parent's launch stdout capture. - // This is the ID returned by `kitten @ launch` (or similar) and serves as - // fallback for pane_id when the terminal env var isn't available. - // - // Race condition: parent writes this file after `kitten @ launch` returns - // (~500ms after child starts), but we run within ~10-100ms of spawn. - // Retry with backoff only when pane_id not already captured from env vars - // (tmux/wezterm set env vars directly, no file needed). - if let Some(process_id) = ctx.get("process_id").and_then(|v| v.as_str()) { - let id_file = crate::paths::hcom_dir() - .join(".tmp") - .join("terminal_ids") - .join(process_id); - let needs_id = !ctx.contains_key("pane_id"); - let max_attempts: usize = if needs_id { 10 } else { 1 }; - let mut terminal_id_value = String::new(); - - for attempt in 0..max_attempts { - if let Ok(contents) = std::fs::read_to_string(&id_file) { - let trimmed = contents.trim().to_string(); - if !trimmed.is_empty() { - terminal_id_value = trimmed; - break; - } - } - if attempt + 1 < max_attempts { - std::thread::sleep(std::time::Duration::from_millis(100)); - } - } - - if !terminal_id_value.is_empty() { - ctx.insert( - "terminal_id".into(), - Value::String(terminal_id_value.clone()), - ); - if !ctx.contains_key("pane_id") { - ctx.insert("pane_id".into(), Value::String(terminal_id_value)); - } - } - // Don't delete the file here — capture_context in the SessionStart hook - // reads it to persist terminal_id into DB launch_context. If we delete - // early, the hook finds exists=false and terminal_id is lost from DB. - } - - Value::Object(ctx).to_string() -} - /// Configuration for the PTY proxy pub struct ProxyConfig { /// Pattern to detect when tool is ready (e.g., b"? for shortcuts") @@ -646,6 +601,7 @@ impl Default for ProxyConfig { } /// PTY proxy that manages the child process and I/O forwarding +#[cfg(unix)] pub struct Proxy { config: ProxyConfig, pty_master: OwnedFd, @@ -654,7 +610,6 @@ pub struct Proxy { screen: ScreenTracker, inject_server: InjectServer, last_user_input: Instant, - user_activity_cooldown_ms: u64, /// Shared delivery state (for delivery thread) delivery_state: Arc>, /// True while launch outcome is still Pending. Cleared by the delivery @@ -675,6 +630,7 @@ pub struct Proxy { current_status: Arc>, } +#[cfg(unix)] impl Proxy { /// Spawn a new PTY process pub fn spawn(command: &str, args: &[&str], config: ProxyConfig) -> Result { @@ -748,7 +704,7 @@ impl Proxy { // Capture minimal launch context early so kill can close the terminal pane. // The start hook may later overwrite with richer context (git_branch, tty, env). - let _ = db.store_launch_context(instance_name, &build_early_launch_context()); + let _ = db.store_launch_context(instance_name, &shared::build_early_launch_context()); } // Close slave in parent @@ -768,8 +724,6 @@ impl Proxy { // Start injection server (port is registered to DB by delivery thread) let inject_server = InjectServer::new()?; - let user_activity_cooldown_ms = 500; // 0.5s for all tools (dim detection enables this for Claude) - // Initialize shared state for terminal title (updated by delivery thread). // Query tag from DB to show full display name (tag-name) from the start. let initial_display_name = { @@ -796,7 +750,6 @@ impl Proxy { screen, inject_server, last_user_input: Instant::now(), - user_activity_cooldown_ms, delivery_state: Arc::new(RwLock::new(ScreenState::default())), launch_phase_active: Arc::new(AtomicBool::new(true)), running: Arc::new(AtomicBool::new(true)), @@ -933,7 +886,13 @@ impl Proxy { Ok(0) => { // Timeout - still update delivery state for time-based checks if ready_signaled { - self.update_delivery_state(); + shared::update_delivery_state( + &self.delivery_state, + &self.screen, + &self.config.target, + &self.launch_phase_active, + &|a| self.publish_approval(a), + ); } // Start delivery thread on timeout if startup_time exceeded // (child may produce no output after initial render, so the @@ -944,7 +903,27 @@ impl Proxy { self.inject_server.port(), "Starting delivery thread (poll timeout)", ); - self.start_delivery_thread()?; + match shared::start_delivery_thread( + self.config.instance_name.as_deref(), + self.running.clone(), + self.delivery_state.clone(), + self.launch_phase_active.clone(), + self.inject_server.port(), + self.config.target.clone(), + self.notify_port.clone(), + self.current_name.clone(), + self.current_status.clone(), + )? { + shared::DeliveryStart::Started(h) => { + self.delivery_handle = Some(h); + } + shared::DeliveryStart::Disabled => {} + shared::DeliveryStart::Pending(_h, _init_rx) => { + // Preserve the Unix behavior a delivery-init + // timeout has always had here: abort the session. + bail!("delivery start timed out"); + } + } delivery_started = true; } // Check runtime debug flag toggle @@ -961,7 +940,13 @@ impl Proxy { Err(Errno::EINTR) => { // Interrupted - still update delivery state if ready_signaled { - self.update_delivery_state(); + shared::update_delivery_state( + &self.delivery_state, + &self.screen, + &self.config.target, + &self.launch_phase_active, + &|a| self.publish_approval(a), + ); } continue; } @@ -1060,7 +1045,13 @@ impl Proxy { self.screen.process(raw); } if !raw_chunks.is_empty() { - self.update_delivery_state(); + shared::update_delivery_state( + &self.delivery_state, + &self.screen, + &self.config.target, + &self.launch_phase_active, + &|a| self.publish_approval(a), + ); if !ready_signaled && self.screen.is_ready() { ready_signaled = true; self.screen.dump_screen( @@ -1078,7 +1069,28 @@ impl Proxy { self.inject_server.port(), "Starting delivery thread", ); - self.start_delivery_thread()?; + match shared::start_delivery_thread( + self.config.instance_name.as_deref(), + self.running.clone(), + self.delivery_state.clone(), + self.launch_phase_active.clone(), + self.inject_server.port(), + self.config.target.clone(), + self.notify_port.clone(), + self.current_name.clone(), + self.current_status.clone(), + )? { + shared::DeliveryStart::Started(h) => { + self.delivery_handle = Some(h); + } + shared::DeliveryStart::Disabled => {} + shared::DeliveryStart::Pending(_h, _init_rx) => { + // Preserve the Unix behavior a + // delivery-init timeout has always had + // here: abort the session. + bail!("delivery start timed out"); + } + } delivery_started = true; } } @@ -1142,17 +1154,11 @@ impl Proxy { if !cursor_scrape { self.screen.clear_approval(); } - let mut approval_cleared = false; - if let Ok(mut state) = self.delivery_state.write() { - state.last_user_input = Instant::now(); - if !cursor_scrape { - approval_cleared = state.approval; - state.approval = false; - } - } - if approval_cleared { - self.publish_approval_status(false); - } + shared::note_user_keystroke( + &self.config.target, + &self.delivery_state, + &|a| self.publish_approval(a), + ); } // Copilot pauses stdin processing on terminal focus-out // (it enables DECSET 1004). Since hcom drives it via @@ -1204,7 +1210,13 @@ impl Proxy { // here — while the row is still blocked — instead of leaving // it to the scrape falling edge, which races (and loses to) // lifecycle hooks and drops the `pty:approval_cleared` event. - self.clear_injected_approval(); + if shared::clear_injected_approval_state( + &self.config.target, + &self.delivery_state, + &|a| self.publish_approval(a), + ) { + self.screen.clear_approval(); + } } inject::InjectResult::Query(client) => match client.command { inject::QueryCommand::Screen => { @@ -1247,9 +1259,8 @@ impl Proxy { }; if !name.is_empty() && (name != last_written_name || status != last_written_status) { - let icon = status_icon(&status); - let title = format!("{} {} [{}]", icon, name, self.config.target.name()); - let escape = format!("\x1b]1;{}\x07\x1b]2;{}\x07", title, title); + let escape = + shared::build_title_escape(&name, &status, self.config.target.name()); write_all(&stdout_fd, escape.as_bytes())?; last_written_name = name; last_written_status = status; @@ -1272,7 +1283,14 @@ impl Proxy { let exit_code = self.drain_and_wait_child()?; if !EXIT_WAS_KILLED.load(Ordering::Acquire) { - self.finalize_launch_failure_after_exit(startup_time.elapsed(), exit_code); + let tail = self.screen.visible_tail(8, 1000); + shared::finalize_launch_failure_after_exit( + self.config.instance_name.as_deref(), + tail.as_deref(), + &self.launch_phase_active, + startup_time.elapsed(), + exit_code, + ); } // Stop delivery after precise child-exit finalization. The shared @@ -1283,53 +1301,13 @@ impl Proxy { Ok(exit_code) } - fn finalize_launch_failure_after_exit(&mut self, elapsed: Duration, exit_code: i32) { - let Some(instance_name) = self.config.instance_name.as_deref() else { - return; - }; - - let Ok(db) = HcomDb::open() else { - return; - }; - let Ok(Some(instance)) = db.get_instance_full(instance_name) else { - return; - }; - - if instance.session_id.is_some() - || instance.status_context != "new" - || (instance.status != crate::shared::ST_INACTIVE && instance.status != "pending") - { - return; - } - - let elapsed_secs = elapsed.as_secs(); - let mut fallback = - format!("exited {elapsed_secs}s after spawn before binding (exit code {exit_code})"); - if let Some(tail) = self.screen.visible_tail(8, 1000) { - fallback.push_str("\nPTY output:\n"); - fallback.push_str(&tail); - } - let Some(detail) = crate::instance_lifecycle::finalize_launch_failure_detail( - &db, - &instance, - Some(&fallback), - ) else { - return; - }; - let _ = db.emit_launch_failed_event( - instance_name, - crate::shared::ST_INACTIVE, - "launch_failed", - "exited_before_bind", - &detail, + /// Publish an approval-status edge for this proxy's instance. + fn publish_approval(&self, waiting: bool) { + shared::publish_approval_status( + waiting, + self.config.instance_name.as_deref(), + &self.current_status, ); - self.launch_phase_active.store(false, Ordering::Release); - - if let Ok(process_id) = std::env::var("HCOM_PROCESS_ID") - && !process_id.is_empty() - { - let _ = db.delete_process_binding(&process_id); - } } fn forward_winsize(&mut self) -> Result<()> { @@ -1444,369 +1422,9 @@ impl Proxy { std::thread::sleep(Duration::from_millis(50)); } } - - /// Update shared delivery state from screen tracker - fn update_delivery_state(&self) { - let mut approval_changed = None; - if let Ok(mut state) = self.delivery_state.write() { - state.ready = self.screen.is_ready(); - // Cursor and Codex can briefly erase their approval surfaces during - // redraws (Codex does this on focus changes). Latch positive detection - // until output settles so a partial frame cannot clear blocked status. - let scrape_latched_tool = matches!( - self.config.target.known_tool(), - Some(Tool::Codex | Tool::Cursor) - ); - let scraped_approval = match self.config.target.known_tool() { - Some(Tool::Codex) => { - self.screen.is_waiting_approval() || self.screen.is_codex_approval_visible() - } - Some(Tool::Cursor) => self.screen.is_cursor_approval_visible(), - _ => false, - }; - state.approval_scrape_latched = crate::delivery::latch_scraped_approval( - state.approval_scrape_latched, - scraped_approval, - self.screen - .is_output_stable(crate::delivery::APPROVAL_SCRAPE_CLEAR_MS), - ); - let approval = (scrape_latched_tool && state.approval_scrape_latched) - || (self.config.target.name() == "antigravity" - && self.screen.is_antigravity_approval_visible()); - if approval != state.approval { - approval_changed = Some(approval); - } - state.approval = approval; - let input_text = self.screen.get_input_box_text(self.config.target.name()); - let new_prompt_empty = input_text.as_ref().is_some_and(|t| t.is_empty()); - // Stamp submit-edge cooldown when input transitions from a known - // non-empty value to empty or briefly undetected. Guards against - // the race where the delivery gate sees `prompt_empty + listening` - // in the gap before the tool's UserPromptSubmit hook flips status - // to active. Requiring a previously-known non-empty input avoids - // stamping on the initial false->true edge at startup. - if prompt_submit_observed(state.input_text.as_deref(), input_text.as_deref()) { - state.last_prompt_submit = Some(Instant::now()); - } - state.prompt_empty = new_prompt_empty; - state.input_text = input_text; - // visible_tail is only consumed by the launch-blocked heuristic; - // skip the screen walk + allocation once launch phase is over. - state.visible_tail = if self.launch_phase_active.load(Ordering::Acquire) { - self.screen.visible_tail(5, 500) - } else { - None - }; - state.last_output = self.screen.last_output_instant(); - state.cols = self.screen.cols(); - } - - if let Some(approval) = approval_changed { - self.publish_approval_status(approval); - } - } - - /// Clear a pending approval answered by an injected keystroke. - /// - /// Only acts when approval is currently showing, so routine message - /// injection (approval already false) is a no-op and never falsely stamps - /// user-active state. Cursor's approval is authoritative-by-prompt — it - /// clears only when the prompt leaves the screen — so it is excluded here, - /// matching the interactive stdin handler. - fn clear_injected_approval(&mut self) { - if self.config.target.name() == "cursor" { - return; - } - let approval_cleared = match self.delivery_state.write() { - Ok(mut state) if state.approval => { - state.approval = false; - true - } - _ => false, - }; - if approval_cleared { - self.screen.clear_approval(); - self.publish_approval_status(false); - } - } - - /// Publish PTY approval edges independently of the delivery queue. - /// - /// Approval is agent state: `hcom list` must report it even when no message - /// is pending. Clearing is guarded by the PTY-owned context so lifecycle - /// hooks that already moved the agent to active are never overwritten. - fn publish_approval_status(&self, approval: bool) { - let Ok(db) = HcomDb::open() else { - log_warn( - "native", - "pty.approval_status_open_failed", - "Failed to open database for PTY approval status", - ); - return; - }; - - let config = Config::get(); - let instance_name = config - .process_id - .as_deref() - .and_then(|process_id| db.get_process_binding(process_id).ok().flatten()) - .or_else(|| self.config.instance_name.clone()) - .or(config.instance_name); - let Some(instance_name) = instance_name.filter(|name| !name.is_empty()) else { - return; - }; - - let current = match db.get_instance_full(&instance_name) { - Ok(row) => row, - Err(error) => { - log_warn( - "native", - "pty.approval_status_failed", - &format!( - "Failed to read status for approval={} on {}: {}", - approval, instance_name, error - ), - ); - return; - } - }; - let already_blocked = current - .as_ref() - .is_some_and(|row| row.status == ST_BLOCKED && row.status_context == "pty:approval"); - - // Resolve the approval edge to publish: block on the rising edge, release - // on the falling edge, and stay silent when the row already matches. - let edge = if approval { - (!already_blocked).then_some((ST_BLOCKED, "pty:approval")) - } else { - already_blocked.then_some((ST_LISTENING, "pty:approval_cleared")) - }; - let Some((status, context)) = edge else { - // No transition to publish. Still reflect a standing block in the - // PTY-owned shared status so `hcom list` stays consistent. - if already_blocked && let Ok(mut shared_status) = self.current_status.write() { - *shared_status = ST_BLOCKED.to_string(); - } - return; - }; - - // Write the instance row, then log a paired status event. The bare - // `set_status` leaves `status_detail` (the gated-command preview) intact, - // while the explicit event keeps the block/release visible to the events - // table, `events sub`, and the TUI — mirroring how the sibling - // launch_blocked path pairs a row write with its own emitted event. - // Without the event, the row updates silently and event consumers never - // see the approval gate (Codex's only PTY-driven block path). - if let Err(error) = db.set_status(&instance_name, status, context) { - log_warn( - "native", - "pty.approval_status_failed", - &format!( - "Failed to publish approval={} for {}: {}", - approval, instance_name, error - ), - ); - return; - } - - let position = current.as_ref().map(|row| row.last_event_id).unwrap_or(0); - let detail = current - .as_ref() - .map(|row| row.status_detail.as_str()) - .unwrap_or(""); - let mut data = serde_json::json!({ - "status": status, - "context": context, - "position": position, - }); - if !detail.is_empty() { - data["detail"] = serde_json::json!(detail); - } - if let Err(error) = db.log_event("status", &instance_name, &data) { - log_warn( - "native", - "pty.approval_status_event_failed", - &format!( - "Failed to emit approval status event ({}) for {}: {}", - context, instance_name, error - ), - ); - } - - if let Ok(mut shared_status) = self.current_status.write() { - *shared_status = status.to_string(); - } - } - - /// Start the delivery thread (and transcript watcher for Codex) - /// - /// Returns Ok(()) if delivery thread initialized successfully (DB opened, notify server created). - /// Returns Err if initialization failed. - fn start_delivery_thread(&mut self) -> Result<()> { - let instance_name = match &self.config.instance_name { - Some(name) => name.clone(), - None => { - // Try to get from environment (fallback for testing without explicit config) - Config::get().instance_name.unwrap_or_default() - } - }; - - if instance_name.is_empty() { - // No instance name - skip delivery (hybrid mode or testing) - crate::log::log_warn( - "native", - "delivery.skip.no_instance_name", - "No instance name - delivery disabled. Set config.instance_name or HCOM_INSTANCE_NAME env var.", - ); - return Ok(()); - } - - // Create oneshot channel for init result - let (init_tx, init_rx) = mpsc::channel(); - - let running = self.running.clone(); - let delivery_state = self.delivery_state.clone(); - let launch_phase_active = self.launch_phase_active.clone(); - let inject_port = self.inject_server.port(); - let target = self.config.target.clone(); - let user_activity_cooldown_ms = self.user_activity_cooldown_ms; - let notify_port_shared = self.notify_port.clone(); - let shared_name = self.current_name.clone(); - let shared_status = self.current_status.clone(); - - // For Codex: spawn transcript watcher thread - if matches!(target.known_tool(), Some(Tool::Codex)) { - let watcher_running = self.running.clone(); - let watcher_name = instance_name.clone(); - std::thread::spawn(move || { - crate::hooks::codex_file_edits::run_transcript_watcher( - watcher_running, - watcher_name, - Duration::from_secs(5), - ); - }); - } - - let handle = std::thread::spawn(move || { - log_info( - "native", - "delivery.start", - &format!("Starting delivery thread for {}", instance_name), - ); - - // Initialize delivery components with dependency injection - let (mut db, notify) = match initialize_delivery_components( - &instance_name, - HcomDb::open, - NotifyServer::new, - ) { - Ok((db, notify)) => { - log_info( - "native", - "delivery.init.success", - &format!("Initialized delivery for {}", instance_name), - ); - // Store port for shutdown wakeup - notify_port_shared.store(notify.port(), Ordering::Release); - log_info( - "native", - "notify.registered", - &format!("Registered notify port {}", notify.port()), - ); - // Register inject port for screen queries - if let Err(e) = db.register_inject_port(&instance_name, inject_port) { - log_warn( - "native", - "inject.register_fail", - &format!("Failed to register inject port: {}", e), - ); - } - - // Signal successful initialization to parent - let _ = init_tx.send(Ok(())); - (db, notify) - } - Err(e) => { - log_error( - "native", - "delivery.init.fail", - &format!("Failed to initialize delivery: {}", e), - ); - let _ = init_tx.send(Err(e)); - return; - } - }; - - // Create delivery state wrapper - let state = DeliveryState { - screen: delivery_state, - launch_phase_active, - inject_port, - user_activity_cooldown_ms, - }; - - // Get tool config - let config = ToolConfig::for_tool(target.delivery_tool()); - - // Run delivery loop (pass shared state for main loop's OSC override) - run_delivery_loop( - running, - &mut db, - ¬ify, - &state, - &instance_name, - &config, - Some(shared_name), - Some(shared_status), - ); - - log_info( - "native", - "delivery.stop", - &format!("Delivery thread stopped for {}", instance_name), - ); - }); - - self.delivery_handle = Some(handle); - - // Wait for initialization result (with timeout to avoid blocking forever) - match init_rx.recv_timeout(Duration::from_secs(5)) { - Ok(Ok(())) => { - log_info( - "native", - "delivery.init.success", - "Delivery thread initialized successfully", - ); - Ok(()) - } - Ok(Err(e)) => { - log_error( - "native", - "delivery.init.fail", - &format!("Delivery thread init failed: {}", e), - ); - Err(e) - } - Err(mpsc::RecvTimeoutError::Timeout) => { - log_error( - "native", - "delivery.init.timeout", - "Delivery thread init timed out after 5s", - ); - bail!("Delivery thread initialization timed out") - } - Err(mpsc::RecvTimeoutError::Disconnected) => { - log_error( - "native", - "delivery.init.disconnect", - "Delivery thread init channel disconnected", - ); - bail!("Delivery thread initialization channel disconnected") - } - } - } } +#[cfg(unix)] impl Drop for Proxy { fn drop(&mut self) { use crate::log::log_info; @@ -1862,6 +1480,7 @@ impl Drop for Proxy { } } +#[cfg(unix)] fn exit_code_from_status(status: ExitStatus) -> i32 { use std::os::unix::process::ExitStatusExt; if let Some(code) = status.code() { @@ -1873,6 +1492,7 @@ fn exit_code_from_status(status: ExitStatus) -> i32 { } } +#[cfg(unix)] fn set_nonblocking(fd: &Fd) -> Result<()> { let flags = fcntl(fd.as_fd(), FcntlArg::F_GETFL).context("fcntl F_GETFL failed")?; let flags = OFlag::from_bits_truncate(flags); @@ -1881,6 +1501,7 @@ fn set_nonblocking(fd: &Fd) -> Result<()> { Ok(()) } +#[cfg(unix)] fn write_all(fd: &F, data: &[u8]) -> Result<()> { let mut written = 0; while written < data.len() { @@ -1897,6 +1518,7 @@ fn write_all(fd: &F, data: &[u8]) -> Result<()> { Ok(()) } +#[cfg(unix)] fn nix_read(fd: &F, buf: &mut [u8]) -> Result { read(fd.as_fd(), buf) } @@ -1904,15 +1526,17 @@ fn nix_read(fd: &F, buf: &mut [u8]) -> Result { /// Initialize delivery components with dependency injection for testing /// /// Returns (db, notify) on success, Err on failure +#[cfg(any(unix, windows))] fn initialize_delivery_components( instance_name: &str, db_factory: DbF, notify_factory: NotifyF, -) -> Result<(crate::db::HcomDb, crate::notify::NotifyServer)> +) -> anyhow::Result<(crate::db::HcomDb, crate::notify::NotifyServer)> where - DbF: FnOnce() -> Result, - NotifyF: FnOnce() -> Result, + DbF: FnOnce() -> anyhow::Result, + NotifyF: FnOnce() -> anyhow::Result, { + use anyhow::Context as _; // Open database let db = db_factory().context("Failed to open database")?; @@ -1926,7 +1550,7 @@ where Ok((db, notify)) } -#[cfg(test)] +#[cfg(all(test, unix))] mod tests { use super::{ PtyTarget, initialize_delivery_components, prompt_submit_observed, strip_focus_events, diff --git a/src/pty/screen.rs b/src/pty/screen.rs index be73895b..51dd230c 100644 --- a/src/pty/screen.rs +++ b/src/pty/screen.rs @@ -400,6 +400,7 @@ impl ScreenTracker { } /// Check if debug mode is enabled + #[cfg(unix)] pub fn debug_enabled(&self) -> bool { self.debug_enabled } @@ -545,14 +546,18 @@ impl ScreenTracker { let num_lines = lines.len(); // Search bottom-to-top to find the actual current input box, - // not stale output lines that happen to match the ❯ + ─ border pattern + // not stale output lines that happen to match the ❯ + ─ border pattern. + // `bypassPermissions` mode (require_ready_prompt=false) renders the + // same bordered box with a plain `>` instead of the styled `❯` — try + // the styled glyph first since it's the common case and less prone to + // matching unrelated output. for row_idx in (1..num_lines).rev() { let line = &lines[row_idx]; - // Find ❯ at start of line (Claude's prompt character) let trimmed = line.trim_start(); - if !trimmed.starts_with('❯') { + let Some(prompt_char) = ['❯', '>'].into_iter().find(|c| trimmed.starts_with(*c)) + else { continue; - } + }; // Check for borders above and below (input box frame) let line_above = &lines[row_idx - 1]; @@ -575,9 +580,9 @@ impl ScreenTracker { continue; } - // Extract text after ❯ (trim NBSP too - Claude uses \xa0 after prompt) - let prompt_pos = line.find('❯')?; - let after_prompt = &line[prompt_pos + '❯'.len_utf8()..]; + // Extract text after the prompt char (trim NBSP too - Claude uses \xa0 after prompt) + let prompt_pos = line.find(prompt_char)?; + let after_prompt = &line[prompt_pos + prompt_char.len_utf8()..]; let text = trim_with_nbsp(after_prompt); if text.is_empty() { @@ -586,7 +591,7 @@ impl ScreenTracker { // Dim text = placeholder, not real input let is_placeholder = self - .is_dim_after_prompt(row_idx as u16, "❯") + .is_dim_after_prompt(row_idx as u16, &prompt_char.to_string()) .unwrap_or(true); // Can't find prompt cell = treat as placeholder if is_placeholder { return Some(String::new()); @@ -1636,6 +1641,41 @@ mod tests { assert_eq!(t.get_claude_input_text(), Some(String::new())); } + #[test] + fn claude_bypass_permissions_ascii_prompt_with_empty_text() { + // `--permission-mode bypassPermissions` renders the same bordered box + // with a plain `>` instead of the styled `❯`. + let mut t = make_tracker(24, 80, "? for shortcuts"); + t.process("────────────────────\r\n".as_bytes()); + t.process("> \r\n".as_bytes()); + t.process("────────────────────\r\n".as_bytes()); + assert_eq!(t.get_claude_input_text(), Some(String::new())); + } + + #[test] + fn claude_bypass_permissions_ascii_prompt_with_non_dim_user_text() { + let mut t = make_tracker(24, 80, "? for shortcuts"); + t.process("────────────────────\r\n".as_bytes()); + t.process("> hello\r\n".as_bytes()); + t.process("────────────────────\r\n".as_bytes()); + assert_eq!(t.get_claude_input_text(), Some("hello".to_string())); + } + + #[test] + fn claude_bypass_permissions_ascii_prompt_with_dim_placeholder() { + let mut t = make_tracker(24, 80, "? for shortcuts"); + t.process("────────────────────\r\n".as_bytes()); + let mut data = Vec::new(); + data.extend_from_slice("> ".as_bytes()); + data.extend_from_slice(b"\x1b[2m"); // SGR dim on + data.extend_from_slice(b"placeholder text"); + data.extend_from_slice(b"\x1b[0m"); // SGR reset + data.extend_from_slice(b"\r\n"); + t.process(&data); + t.process("────────────────────\r\n".as_bytes()); + assert_eq!(t.get_claude_input_text(), Some(String::new())); + } + // ---- trim_with_nbsp ---- #[test] diff --git a/src/pty/shared.rs b/src/pty/shared.rs new file mode 100644 index 00000000..8c6be3ed --- /dev/null +++ b/src/pty/shared.rs @@ -0,0 +1,1359 @@ +//! Portable PTY orchestration shared by the Unix poll loop (`super`) and the +//! Windows ConPTY proxy (`super::win`). +//! +//! These were originally methods on the Unix `Proxy`. They were lifted to free +//! functions taking explicit parameters (every `self.X` became an argument) so +//! the Windows proxy can drive the exact same delivery-thread startup, approval +//! publishing, screen-state refresh, title escaping, and launch-failure +//! finalization. The bodies are byte-for-byte the Unix originals apart from the +//! `self.X` → parameter substitution; the Unix correctness rests on that. + +use std::sync::atomic::{AtomicBool, AtomicU16, Ordering}; +use std::sync::mpsc; +use std::sync::{Arc, RwLock}; +use std::thread::JoinHandle; +use std::time::{Duration, Instant}; + +use anyhow::Result; + +use crate::config::Config; +use crate::db::HcomDb; +use crate::delivery::{ + APPROVAL_SCRAPE_CLEAR_MS, DeliveryState, ScreenState, ToolConfig, latch_scraped_approval, + run_delivery_loop, +}; +use crate::log::{log_error, log_info, log_warn}; +use crate::notify::NotifyServer; +use crate::shared::{ST_BLOCKED, ST_LISTENING}; +use crate::tool::Tool; + +use super::PtyTarget; +use super::screen::ScreenTracker; + +/// User-activity cooldown applied uniformly across tools (0.5s). Dim detection +/// enables this for Claude. +pub(super) const USER_ACTIVITY_COOLDOWN_MS: u64 = 500; + +/// Update shared delivery state from screen tracker. +/// +/// `publish` is the caller's approval-status publisher (it owns the +/// `instance_name`/`current_status` plumbing); it is invoked on the approval +/// edge exactly as the Unix proxy invoked `self.publish_approval_status`. +pub(super) fn update_delivery_state( + screen_state: &Arc>, + screen: &ScreenTracker, + target: &PtyTarget, + launch_phase_active: &Arc, + publish: &dyn Fn(bool), +) { + let mut approval_changed = None; + if let Ok(mut state) = screen_state.write() { + state.ready = screen.is_ready(); + // Cursor and Codex can briefly erase their approval surfaces during + // redraws (Codex does this on focus changes). Latch positive detection + // until output settles so a partial frame cannot clear blocked status. + let scrape_latched_tool = matches!(target.known_tool(), Some(Tool::Codex | Tool::Cursor)); + let scraped_approval = match target.known_tool() { + Some(Tool::Codex) => screen.is_waiting_approval() || screen.is_codex_approval_visible(), + Some(Tool::Cursor) => screen.is_cursor_approval_visible(), + _ => false, + }; + state.approval_scrape_latched = latch_scraped_approval( + state.approval_scrape_latched, + scraped_approval, + screen.is_output_stable(APPROVAL_SCRAPE_CLEAR_MS), + ); + let approval = (scrape_latched_tool && state.approval_scrape_latched) + || (target.name() == "antigravity" && screen.is_antigravity_approval_visible()); + if approval != state.approval { + approval_changed = Some(approval); + } + state.approval = approval; + let input_text = screen.get_input_box_text(target.name()); + let new_prompt_empty = input_text.as_ref().is_some_and(|t| t.is_empty()); + // Stamp submit-edge cooldown when input transitions from a known + // non-empty value to empty or briefly undetected. Guards against + // the race where the delivery gate sees `prompt_empty + listening` + // in the gap before the tool's UserPromptSubmit hook flips status + // to active. Requiring a previously-known non-empty input avoids + // stamping on the initial false->true edge at startup. + if super::prompt_submit_observed(state.input_text.as_deref(), input_text.as_deref()) { + state.last_prompt_submit = Some(Instant::now()); + } + state.prompt_empty = new_prompt_empty; + state.input_text = input_text; + // visible_tail is only consumed by the launch-blocked heuristic; + // skip the screen walk + allocation once launch phase is over. + state.visible_tail = if launch_phase_active.load(Ordering::Acquire) { + screen.visible_tail(5, 500) + } else { + None + }; + state.last_output = screen.last_output_instant(); + state.cols = screen.cols(); + } + + if let Some(approval) = approval_changed { + publish(approval); + } +} + +/// Clear a pending approval answered by an injected keystroke. +/// +/// Only acts when approval is currently showing, so routine message +/// injection (approval already false) is a no-op and never falsely stamps +/// user-active state. Cursor's approval is authoritative-by-prompt — it +/// clears only when the prompt leaves the screen — so it is excluded here, +/// matching the interactive stdin handler. +/// +/// Returns `true` when the approval was cleared; the caller is then responsible +/// for clearing the tracker's approval (`screen.clear_approval()` on Unix, an +/// atomic request consumed by the reader thread on Windows). +pub(super) fn clear_injected_approval_state( + target: &PtyTarget, + screen_state: &Arc>, + publish: &dyn Fn(bool), +) -> bool { + if target.name() == "cursor" { + return false; + } + let approval_cleared = match screen_state.write() { + Ok(mut state) if state.approval => { + state.approval = false; + true + } + _ => false, + }; + if approval_cleared { + publish(false); + return true; + } + false +} + +/// Record a genuine user keystroke against the shared delivery state. +/// +/// Genuine keystrokes answering a title-detected approval clear it immediately. +/// Cursor's approval is screen-scraped and authoritative-by-prompt, so it clears +/// only when the prompt actually leaves the screen. +/// +/// Returns `true` only when a standing approval was actually cleared — matching +/// `clear_injected_approval_state`. The Unix caller ignores this and clears its +/// tracker inline on every non-cursor keystroke; the Windows caller uses it to +/// gate the tracker-clear atomic it consumes, so a keystroke with no approval +/// showing does not wipe the OSC scrape buffer (`output_buffer`) and lose an +/// approval edge that arrives in the same window. +pub(super) fn note_user_keystroke( + target: &PtyTarget, + screen_state: &Arc>, + publish: &dyn Fn(bool), +) -> bool { + let cursor_scrape = target.name() == "cursor"; + let mut approval_cleared = false; + if let Ok(mut state) = screen_state.write() { + state.last_user_input = Instant::now(); + if !cursor_scrape { + approval_cleared = state.approval; + state.approval = false; + } + } + if approval_cleared { + publish(false); + } + approval_cleared +} + +/// Publish PTY approval edges independently of the delivery queue. +/// +/// Approval is agent state: `hcom list` must report it even when no message +/// is pending. Clearing is guarded by the PTY-owned context so lifecycle +/// hooks that already moved the agent to active are never overwritten. +pub(super) fn publish_approval_status( + approval: bool, + instance_name_cfg: Option<&str>, + current_status: &Arc>, +) { + let Ok(db) = HcomDb::open() else { + log_warn( + "native", + "pty.approval_status_open_failed", + "Failed to open database for PTY approval status", + ); + return; + }; + + let config = Config::get(); + let instance_name = config + .process_id + .as_deref() + .and_then(|process_id| db.get_process_binding(process_id).ok().flatten()) + .or_else(|| instance_name_cfg.map(str::to_string)) + .or(config.instance_name); + let Some(instance_name) = instance_name.filter(|name| !name.is_empty()) else { + return; + }; + + let current = match db.get_instance_full(&instance_name) { + Ok(row) => row, + Err(error) => { + log_warn( + "native", + "pty.approval_status_failed", + &format!( + "Failed to read status for approval={} on {}: {}", + approval, instance_name, error + ), + ); + return; + } + }; + let already_blocked = current + .as_ref() + .is_some_and(|row| row.status == ST_BLOCKED && row.status_context == "pty:approval"); + + // Resolve the approval edge to publish: block on the rising edge, release + // on the falling edge, and stay silent when the row already matches. + let edge = if approval { + (!already_blocked).then_some((ST_BLOCKED, "pty:approval")) + } else { + already_blocked.then_some((ST_LISTENING, "pty:approval_cleared")) + }; + let Some((status, context)) = edge else { + // No transition to publish. Still reflect a standing block in the + // PTY-owned shared status so `hcom list` stays consistent. + if already_blocked && let Ok(mut shared_status) = current_status.write() { + *shared_status = ST_BLOCKED.to_string(); + } + return; + }; + + // Write the instance row, then log a paired status event. The bare + // `set_status` leaves `status_detail` (the gated-command preview) intact, + // while the explicit event keeps the block/release visible to the events + // table, `events sub`, and the TUI — mirroring how the sibling + // launch_blocked path pairs a row write with its own emitted event. + // Without the event, the row updates silently and event consumers never + // see the approval gate (Codex's only PTY-driven block path). + if let Err(error) = db.set_status(&instance_name, status, context) { + log_warn( + "native", + "pty.approval_status_failed", + &format!( + "Failed to publish approval={} for {}: {}", + approval, instance_name, error + ), + ); + return; + } + + let position = current.as_ref().map(|row| row.last_event_id).unwrap_or(0); + let detail = current + .as_ref() + .map(|row| row.status_detail.as_str()) + .unwrap_or(""); + let mut data = serde_json::json!({ + "status": status, + "context": context, + "position": position, + }); + if !detail.is_empty() { + data["detail"] = serde_json::json!(detail); + } + if let Err(error) = db.log_event("status", &instance_name, &data) { + log_warn( + "native", + "pty.approval_status_event_failed", + &format!( + "Failed to emit approval status event ({}) for {}: {}", + context, instance_name, error + ), + ); + } + + if let Ok(mut shared_status) = current_status.write() { + *shared_status = status.to_string(); + } +} + +/// Outcome of [`start_delivery_thread`]. +/// +/// `Result::Err` (distinct from every variant here) is an up-front init failure: +/// the spawned thread already returned, so the attempt is safely **retryable**. +pub(super) enum DeliveryStart { + /// Init succeeded (DB opened, notify server created). Join the handle at + /// shutdown. + Started(JoinHandle<()>), + /// No instance name (delivery disabled). No thread was spawned. + Disabled, + /// The thread was spawned but its init result was not observed within the + /// timeout (or the init channel disconnected). It is detached and may still + /// be initializing, so it MUST be joined at shutdown and MUST NOT be retried + /// — a retry would spawn a *second* delivery thread alongside it (there is no + /// singleton guard in `run_delivery_loop`) and double-deliver. + Pending(JoinHandle<()>, mpsc::Receiver>), +} + +/// Start the delivery thread (and transcript watcher for Codex). +/// +/// Returns [`DeliveryStart::Started`] when the delivery thread initialized +/// successfully (DB opened, notify server created), [`DeliveryStart::Disabled`] +/// when there is no instance name, and [`DeliveryStart::Pending`] when the thread +/// was spawned but its init result timed out / the channel disconnected (the +/// thread is detached and still live; non-retryable). Returns `Err` only on an +/// up-front init failure, where no thread is left running — that case is +/// retryable and the caller maps it to a launch failure. +#[allow(clippy::too_many_arguments)] +pub(super) fn start_delivery_thread( + instance_name_cfg: Option<&str>, + running: Arc, + delivery_state: Arc>, + launch_phase_active: Arc, + inject_port: u16, + target: PtyTarget, + notify_port: Arc, + current_name: Arc>, + current_status: Arc>, +) -> Result { + let instance_name = match instance_name_cfg { + Some(name) => name.to_string(), + None => { + // Try to get from environment (fallback for testing without explicit config) + Config::get().instance_name.unwrap_or_default() + } + }; + + if instance_name.is_empty() { + // No instance name - skip delivery (hybrid mode or testing) + crate::log::log_warn( + "native", + "delivery.skip.no_instance_name", + "No instance name - delivery disabled. Set config.instance_name or HCOM_INSTANCE_NAME env var.", + ); + return Ok(DeliveryStart::Disabled); + } + + // Create oneshot channel for init result + let (init_tx, init_rx) = mpsc::channel(); + + let handle = std::thread::spawn(move || { + log_info( + "native", + "delivery.start", + &format!("Starting delivery thread for {}", instance_name), + ); + + // Initialize delivery components with dependency injection + let (mut db, notify) = match super::initialize_delivery_components( + &instance_name, + HcomDb::open, + NotifyServer::new, + ) { + Ok((db, notify)) => { + log_info( + "native", + "delivery.init.success", + &format!("Initialized delivery for {}", instance_name), + ); + // Store port for shutdown wakeup + notify_port.store(notify.port(), Ordering::Release); + log_info( + "native", + "notify.registered", + &format!("Registered notify port {}", notify.port()), + ); + // Register inject port for screen queries + if let Err(e) = db.register_inject_port(&instance_name, inject_port) { + log_warn( + "native", + "inject.register_fail", + &format!("Failed to register inject port: {}", e), + ); + } + + // Signal successful initialization to parent + let _ = init_tx.send(Ok(())); + + // For Codex: spawn the transcript watcher only after delivery + // init has succeeded (#5). Spawning it before init meant a failed + // or timed-out init still left an orphan watcher running against a + // session that never came up. Init success is reached exactly once + // per live delivery thread, so the watcher starts exactly once. + if matches!(target.known_tool(), Some(Tool::Codex)) { + let watcher_running = running.clone(); + let watcher_name = instance_name.clone(); + std::thread::spawn(move || { + crate::hooks::codex_file_edits::run_transcript_watcher( + watcher_running, + watcher_name, + Duration::from_secs(5), + ); + }); + } + (db, notify) + } + Err(e) => { + log_error( + "native", + "delivery.init.fail", + &format!("Failed to initialize delivery: {}", e), + ); + let _ = init_tx.send(Err(e)); + return; + } + }; + + // Create delivery state wrapper + let state = DeliveryState { + screen: delivery_state, + launch_phase_active, + inject_port, + user_activity_cooldown_ms: USER_ACTIVITY_COOLDOWN_MS, + }; + + // Get tool config + let config = ToolConfig::for_tool(target.delivery_tool()); + + // Run delivery loop (pass shared state for main loop's OSC override) + run_delivery_loop( + running, + &mut db, + ¬ify, + &state, + &instance_name, + &config, + Some(current_name), + Some(current_status), + ); + + log_info( + "native", + "delivery.stop", + &format!("Delivery thread stopped for {}", instance_name), + ); + }); + + // Wait for initialization result (with timeout to avoid blocking forever) + match init_rx.recv_timeout(Duration::from_secs(5)) { + Ok(Ok(())) => { + log_info( + "native", + "delivery.init.success", + "Delivery thread initialized successfully", + ); + Ok(DeliveryStart::Started(handle)) + } + Ok(Err(e)) => { + log_error( + "native", + "delivery.init.fail", + &format!("Delivery thread init failed: {}", e), + ); + Err(e) + } + Err(mpsc::RecvTimeoutError::Timeout) => { + log_error( + "native", + "delivery.init.timeout", + "Delivery thread init timed out after 5s", + ); + // Detached thread is still running; keep the handle so shutdown can + // join it, and flag as non-retryable (Pending). + Ok(DeliveryStart::Pending(handle, init_rx)) + } + Err(mpsc::RecvTimeoutError::Disconnected) => { + log_error( + "native", + "delivery.init.disconnect", + "Delivery thread init channel disconnected", + ); + // Sender dropped without sending: thread returned/panicked, possibly + // after partial registration. Non-retryable like a timeout; keep the + // handle so shutdown can join it. + Ok(DeliveryStart::Pending(handle, init_rx)) + } + } +} + +/// Decide whether the delivery coordinator should start delivery this tick. +/// +/// Pure helper so the decision is host-testable (the coordinator itself needs a +/// live ConPTY). Start once the tool is ready, once the start-timeout has +/// elapsed, or when we are shutting down. +/// +/// The shutdown trigger only fires after the child has already exited — `run()` +/// flips `running` false only once the reader has hit EOF and been joined — so +/// there is no live child left to inject into and the delivery loop's `while +/// running` body runs zero iterations (its `register_notify_port` never runs). +/// This final start therefore exists so init-time registration (DB open, notify +/// server, inject-port registration) and the loop's post-loop cleanup get a +/// chance to run and settle the final DB status, not to hand an in-flight `hcom +/// deliver` a live consumer. +#[cfg_attr(not(windows), allow(dead_code))] +pub(super) fn should_start_delivery( + ready: bool, + elapsed: Duration, + timeout: Duration, + shutting_down: bool, +) -> bool { + ready || elapsed > timeout || shutting_down +} + +/// Leading-edge throttle for the `hcom term` screen snapshot the reader renders. +/// +/// Rendering `get_screen_dump` costs ~150µs on a wide screen, so refreshing it on +/// every ConPTY chunk would burn measurable CPU (and steal reader time from +/// draining the pipe) under heavy output. Cap it at one render per `throttle`; +/// any chunk skipped here is marked dirty and picked up by a single trailing-edge +/// refresh once output goes quiet (see the reader loop's debounce). Pure so the +/// decision is host-testable. +#[cfg_attr(not(windows), allow(dead_code))] +pub(super) fn should_refresh_snapshot(since_last_snapshot: Duration, throttle: Duration) -> bool { + since_last_snapshot >= throttle +} + +/// Finalize a launch failure once the child has exited before binding. +/// +/// `tail` is the screen's visible tail (the Unix caller passes +/// `self.screen.visible_tail(8, 1000)`); kept as a parameter so the function +/// stays free of any tracker reference. +pub(super) fn finalize_launch_failure_after_exit( + instance_name_cfg: Option<&str>, + tail: Option<&str>, + launch_phase_active: &Arc, + elapsed: Duration, + exit_code: i32, +) { + let Some(instance_name) = instance_name_cfg else { + return; + }; + + let Ok(db) = HcomDb::open() else { + return; + }; + let Ok(Some(instance)) = db.get_instance_full(instance_name) else { + return; + }; + + if instance.session_id.is_some() + || instance.status_context != "new" + || (instance.status != crate::shared::ST_INACTIVE && instance.status != "pending") + { + return; + } + + let elapsed_secs = elapsed.as_secs(); + let mut fallback = + format!("exited {elapsed_secs}s after spawn before binding (exit code {exit_code})"); + if let Some(tail) = tail { + fallback.push_str("\nPTY output:\n"); + fallback.push_str(tail); + } + let Some(detail) = + crate::instance_lifecycle::finalize_launch_failure_detail(&db, &instance, Some(&fallback)) + else { + return; + }; + let _ = db.emit_launch_failed_event( + instance_name, + crate::shared::ST_INACTIVE, + "launch_failed", + "exited_before_bind", + &detail, + ); + launch_phase_active.store(false, Ordering::Release); + + if let Ok(process_id) = std::env::var("HCOM_PROCESS_ID") + && !process_id.is_empty() + { + let _ = db.delete_process_binding(&process_id); + } +} + +/// Build the OSC 1/2 title-set escape for `name`/`status` under `tool_name`. +pub(super) fn build_title_escape(name: &str, status: &str, tool_name: &str) -> String { + let title = crate::shared::format_pane_title(status, name, tool_name); + format!("\x1b]1;{}\x07\x1b]2;{}\x07", title, title) +} + +/// Build minimal launch_context JSON from env vars available in the PTY process. +/// Captures process_id and late-bound terminal metadata needed by kill. +/// The start hook captures the full context (git_branch, tty, env snapshot) later. +/// +/// Portable: every operation here (`env::var`, `fs::read_to_string`, `thread::sleep`) +/// works identically on Windows. `TMUX_PANE`/`ZELLIJ_PANE_ID`/`KITTY_WINDOW_ID` simply +/// won't be set there — kitty and the multiplexers have no native Windows build — so +/// those fields degrade to absent, same as on Unix outside of tmux/zellij/kitty. +/// `WEZTERM_PANE` is meaningful on Windows too, since WezTerm is natively cross-platform. +pub(super) fn build_early_launch_context() -> String { + use serde_json::{Map, Value}; + + let mut ctx = Map::new(); + + if let Ok(pid) = std::env::var("HCOM_PROCESS_ID") + && !pid.is_empty() + { + ctx.insert("process_id".into(), Value::String(pid)); + } + + // Kitty socket path for close-on-kill (needed when launching from outside kitty) + if let Ok(listen) = std::env::var("KITTY_LISTEN_ON") + && !listen.is_empty() + { + ctx.insert("kitty_listen_on".into(), Value::String(listen)); + } + + // Capture pane_id from terminal env vars for same-window launches. + let pane_id_vars: &[&str] = &[ + "WEZTERM_PANE", + "TMUX_PANE", + "KITTY_WINDOW_ID", + "ZELLIJ_PANE_ID", + ]; + for &var in pane_id_vars { + if let Ok(val) = std::env::var(var) + && !val.is_empty() + { + ctx.insert("pane_id".into(), Value::String(val)); + break; + } + } + + // Read terminal_id from temp file written by parent's launch stdout capture. + // This is the ID returned by `kitten @ launch` (or similar) and serves as + // fallback for pane_id when the terminal env var isn't available. + // + // Race condition: parent writes this file after `kitten @ launch` returns + // (~500ms after child starts), but we run within ~10-100ms of spawn. + // Retry with backoff only when pane_id not already captured from env vars + // (tmux/wezterm set env vars directly, no file needed). + if let Some(process_id) = ctx.get("process_id").and_then(|v| v.as_str()) { + let id_file = crate::paths::hcom_dir() + .join(".tmp") + .join("terminal_ids") + .join(process_id); + let needs_id = !ctx.contains_key("pane_id"); + let max_attempts: usize = if needs_id { 10 } else { 1 }; + let mut terminal_id_value = String::new(); + + for attempt in 0..max_attempts { + if let Ok(contents) = std::fs::read_to_string(&id_file) { + let trimmed = contents.trim().to_string(); + if !trimmed.is_empty() { + terminal_id_value = trimmed; + break; + } + } + if attempt + 1 < max_attempts { + std::thread::sleep(std::time::Duration::from_millis(100)); + } + } + + if !terminal_id_value.is_empty() { + ctx.insert( + "terminal_id".into(), + Value::String(terminal_id_value.clone()), + ); + if !ctx.contains_key("pane_id") { + ctx.insert("pane_id".into(), Value::String(terminal_id_value)); + } + } + // Don't delete the file here — capture_context in the SessionStart hook + // reads it to persist terminal_id into DB launch_context. If we delete + // early, the hook finds exists=false and terminal_id is lost from DB. + } + + Value::Object(ctx).to_string() +} + +/// True when `seq` is the terminal's cursor-position query (`ESC[6n`, a DSR +/// with parameter 6). In headless mode there is no outer terminal to answer it, +/// so the reader must reply on the child's behalf or startup hangs (#1). +/// +/// Deliberately narrow: only the bare `ESC[6n` query matches. A CPR *reply* +/// (`ESC[;R`), a private DSR (`ESC[?6n`), and a parameterless `ESC[n` +/// must not match — we only synthesize a reply to the child's own query. +#[cfg_attr(not(windows), allow(dead_code))] +pub(super) fn csi_is_dsr_cpr(seq: &[u8]) -> bool { + seq == b"\x1b[6n" +} + +/// Rebuild a DEC private mode-set (`ESC[? … h|l`), dropping only the Win32-input +/// (`9001`) and focus-reporting (`1004`) parameters and keeping every other mode +/// (#15). +/// +/// The previous whole-prefix match (`starts_with(b"\x1b[?9001")`) dropped or kept +/// the entire CSI, so `ESC[?9001;25h` lost mode 25 and `ESC[?25;9001h` leaked +/// 9001 to the outer terminal. Filtering per parameter fixes both. Returns an +/// empty `Vec` when every parameter was dropped (emit nothing). +#[cfg_attr(not(windows), allow(dead_code))] +pub(super) fn filter_dec_private_modes(seq: &[u8]) -> Vec { + let final_byte = *seq.last().unwrap(); + let params = &seq[3..seq.len() - 1]; + let kept: Vec<&[u8]> = params + .split(|&b| b == b';') + .filter(|p| *p != b"9001" && *p != b"1004") + .collect(); + if kept.is_empty() { + return Vec::new(); + } + let mut out = Vec::with_capacity(seq.len()); + out.extend_from_slice(b"\x1b[?"); + for (i, p) in kept.iter().enumerate() { + if i > 0 { + out.push(b';'); + } + out.extend_from_slice(p); + } + out.push(final_byte); + out +} + +/// Rewrites the child's DEC private-mode **sets** so the *outer* terminal is +/// never switched into Win32 input mode (`?9001`) or focus reporting (`?1004`), +/// and notices the child's cursor-position query (`ESC[6n`) so a headless reader +/// can answer it. +/// +/// A ConPTY wrapper sits between the child and the real terminal. If the child's +/// `ESC[?9001h` reaches the outer terminal, that terminal starts encoding its +/// input — including its automatic `ESC[6n` (cursor position) reply — as Win32 +/// input records. The child, which only understands a plain `ESC[15;1R`, then +/// waits forever for a reply it can parse. Stripping those mode-sets keeps the +/// outer terminal in normal VT mode so the DSR reply round-trips correctly. +/// +/// The parser is stateful so sequences split across reads are handled, and every +/// other byte (including all other escape sequences) passes through unchanged. +/// DSR queries are still passed through: in interactive mode the real terminal +/// must see them to answer; the headless reply is gated separately in win.rs. +/// +/// Lives here (rather than in `win.rs`) so its correctness-critical parsing runs +/// under the host test gate; `win.rs` owns only the headless DSR-reply write. +#[cfg_attr(not(windows), allow(dead_code))] +#[derive(Default)] +pub(super) struct OutputModeFilter { + state: FilterState, + buf: Vec, + dsr_seen: bool, + pending_utf8: u8, +} + +#[derive(Default, PartialEq)] +enum FilterState { + #[default] + Ground, + Esc, + Csi, + OscStart, + OscDigit(u8), + StringSeq { + strip: bool, + saw_esc: bool, + discarded: usize, + }, + SingleShift, + Nf, +} + +#[cfg_attr(not(windows), allow(dead_code))] +impl OutputModeFilter { + pub(super) fn filter(&mut self, input: &[u8], out: &mut Vec) { + let output_start = out.len(); + for &b in input { + match self.state { + FilterState::Ground => { + if b == 0x1b { + self.buf.clear(); + self.buf.push(b); + self.state = FilterState::Esc; + } else { + out.push(b); + } + } + FilterState::Esc => { + self.buf.push(b); + match b { + b'[' => self.state = FilterState::Csi, + b']' => self.state = FilterState::OscStart, + b'P' | b'^' | b'_' | b'X' => { + out.extend_from_slice(&self.buf); + self.buf.clear(); + self.state = FilterState::StringSeq { + strip: false, + saw_esc: false, + discarded: 0, + }; + } + b'N' | b'O' => { + out.extend_from_slice(&self.buf); + self.buf.clear(); + self.state = FilterState::SingleShift; + } + 0x20..=0x2f => { + out.extend_from_slice(&self.buf); + self.buf.clear(); + self.state = FilterState::Nf; + } + _ => { + // A complete two-byte escape: pass through untouched. + out.extend_from_slice(&self.buf); + self.buf.clear(); + self.state = FilterState::Ground; + } + } + } + FilterState::Csi => { + self.buf.push(b); + if (0x40..=0x7e).contains(&b) { + // Completed CSI. Notice the child's cursor-position query + // (still pass it through — interactive needs the real + // terminal to answer), and filter DEC private mode-sets + // per-parameter. + if csi_is_dsr_cpr(&self.buf) { + self.dsr_seen = true; + } + if self.buf.starts_with(b"\x1b[?") && matches!(b, b'h' | b'l') { + out.extend_from_slice(&filter_dec_private_modes(&self.buf)); + } else { + out.extend_from_slice(&self.buf); + } + self.buf.clear(); + self.state = FilterState::Ground; + } else if self.buf.len() > 32 { + // Malformed/overlong — give up filtering, emit as-is. + // Combined mode-sets are short, so this never trips on a + // legitimate `?9001`/`?1004` sequence. + out.extend_from_slice(&self.buf); + self.buf.clear(); + self.state = FilterState::Ground; + } + } + FilterState::OscStart => { + self.buf.push(b); + if matches!(b, b'0' | b'1' | b'2') { + self.state = FilterState::OscDigit(b); + } else { + // Not a title OSC. Emit its prefix immediately and keep + // tracking until BEL/ST so title writes cannot split it. + out.extend_from_slice(&self.buf); + self.buf.clear(); + self.state = FilterState::StringSeq { + strip: false, + saw_esc: b == 0x1b, + discarded: 0, + }; + if b == 0x07 { + self.state = FilterState::Ground; + } + } + } + FilterState::OscDigit(_digit) => { + self.buf.push(b); + if b == b';' { + // Confirmed OSC 0/1/2: discard the complete title, + // including a terminator that may arrive in a later read. + self.buf.clear(); + self.state = FilterState::StringSeq { + strip: true, + saw_esc: false, + discarded: 0, + }; + } else { + // Multi-digit/non-title OSC. Preserve it while tracking + // its boundary for safe insertion of hcom's title. + out.extend_from_slice(&self.buf); + self.buf.clear(); + self.state = FilterState::StringSeq { + strip: false, + saw_esc: b == 0x1b, + discarded: 0, + }; + if b == 0x07 { + self.state = FilterState::Ground; + } + } + } + FilterState::StringSeq { + strip, + saw_esc, + discarded, + } => { + if !strip { + out.push(b); + } + if b == 0x07 || (saw_esc && b == b'\\') { + self.state = FilterState::Ground; + } else if strip && discarded >= 256 { + // Match the Unix title filter's fail-open bound: a + // malformed title must not swallow unbounded real output. + self.state = FilterState::Ground; + } else { + self.state = FilterState::StringSeq { + strip, + saw_esc: b == 0x1b, + discarded: discarded + usize::from(strip), + }; + } + } + FilterState::SingleShift => { + out.push(b); + self.state = FilterState::Ground; + } + FilterState::Nf => { + out.push(b); + if (0x30..=0x7e).contains(&b) { + self.state = FilterState::Ground; + } + } + } + } + // If this read contained only a stripped title OSC, retain the previous + // UTF-8 state: no real output arrived to complete the pending character. + if out.len() > output_start { + self.pending_utf8 = advance_pending_utf8(self.pending_utf8, &out[output_start..]); + } + } + + /// One-shot: returns `true` once after a cursor-position query (`ESC[6n`) + /// was seen, then resets. The reader uses it to reply on the child's behalf + /// when running headless. + pub(super) fn take_dsr(&mut self) -> bool { + std::mem::take(&mut self.dsr_seen) + } + + /// True when an hcom title OSC can be appended without splitting a control + /// sequence or a multi-byte UTF-8 character. + pub(super) fn title_write_safe(&self) -> bool { + self.state == FilterState::Ground && self.pending_utf8 == 0 + } +} + +/// Advance the number of expected UTF-8 continuation bytes across arbitrary +/// read boundaries. Invalid input resets the guard and is otherwise untouched. +fn advance_pending_utf8(mut pending: u8, data: &[u8]) -> u8 { + for &byte in data { + if pending > 0 && byte & 0xc0 == 0x80 { + pending -= 1; + continue; + } + pending = if byte & 0xf8 == 0xf0 { + 3 + } else if byte & 0xf0 == 0xe0 { + 2 + } else if byte & 0xe0 == 0xc0 { + 1 + } else { + 0 + }; + } + pending +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::shared::status_icon; + + #[test] + fn should_start_delivery_on_ready() { + // Ready alone starts delivery even well before the timeout. + assert!(should_start_delivery( + true, + Duration::from_millis(1), + Duration::from_secs(10), + false, + )); + } + + #[test] + fn should_start_delivery_on_timeout() { + // #8 regression: not ready, but elapsed exceeded the timeout → start. + assert!(should_start_delivery( + false, + Duration::from_secs(11), + Duration::from_secs(10), + false, + )); + } + + #[test] + fn should_start_delivery_on_shutdown() { + // Shutting down forces a final start (child already exited) so init-time + // registration and post-loop cleanup run, even if never ready and still + // inside the timeout. + assert!(should_start_delivery( + false, + Duration::from_millis(1), + Duration::from_secs(10), + true, + )); + } + + #[test] + fn should_not_start_delivery_before_ready_or_timeout() { + // #8 regression: not ready and still inside the timeout window while + // running → must NOT start yet (the old reader could start too early). + assert!(!should_start_delivery( + false, + Duration::from_secs(1), + Duration::from_secs(10), + false, + )); + } + + #[test] + fn should_refresh_snapshot_only_after_throttle_elapsed() { + let throttle = Duration::from_millis(100); + // Fresh chunk right after a refresh: defer (mark dirty), don't re-render. + assert!(!should_refresh_snapshot(Duration::from_millis(0), throttle)); + assert!(!should_refresh_snapshot( + Duration::from_millis(99), + throttle + )); + // At/after the throttle window: render now (leading edge). + assert!(should_refresh_snapshot( + Duration::from_millis(100), + throttle + )); + assert!(should_refresh_snapshot( + Duration::from_millis(250), + throttle + )); + } + + #[test] + fn csi_is_dsr_cpr_matches_only_the_cursor_position_query() { + assert!(csi_is_dsr_cpr(b"\x1b[6n")); + // A CPR reply, a private DSR, and a bare DSR must not match. + assert!(!csi_is_dsr_cpr(b"\x1b[6;1R")); + assert!(!csi_is_dsr_cpr(b"\x1b[?6n")); + assert!(!csi_is_dsr_cpr(b"\x1b[n")); + } + + fn filter_modes(chunks: &[&[u8]]) -> Vec { + let mut f = OutputModeFilter::default(); + let mut out = Vec::new(); + for c in chunks { + f.filter(c, &mut out); + } + out + } + + #[test] + fn filter_dec_private_modes_drops_only_targeted_params() { + // Whole-prefix regression (#15): a targeted param anywhere in the list + // must be dropped without losing the others, in either order. + assert_eq!(filter_dec_private_modes(b"\x1b[?9001;25h"), b"\x1b[?25h"); + assert_eq!(filter_dec_private_modes(b"\x1b[?25;9001h"), b"\x1b[?25h"); + assert_eq!( + filter_dec_private_modes(b"\x1b[?1004;2004h"), + b"\x1b[?2004h" + ); + assert_eq!(filter_dec_private_modes(b"\x1b[?9001;1004h"), b""); + assert_eq!( + filter_dec_private_modes(b"\x1b[?9001;1004;25h"), + b"\x1b[?25h" + ); + assert_eq!(filter_dec_private_modes(b"\x1b[?25;9001l"), b"\x1b[?25l"); + } + + #[test] + fn output_mode_filter_drops_win32_and_focus_mode_sets() { + // ESC[?9001h ESC[?1004h "hi" ESC[6n — mode-sets dropped, DSR passes. + let input = b"\x1b[?9001h\x1b[?1004h hi \x1b[6n"; + assert_eq!(filter_modes(&[input]), b" hi \x1b[6n"); + } + + #[test] + fn output_mode_filter_passes_other_sequences_and_text() { + let input = b"\x1b[31mred\x1b[0m\x1b[2J plain"; + assert_eq!(filter_modes(&[input]), input); + } + + #[test] + fn output_mode_filter_keeps_other_modes_in_a_combined_set() { + // #15: mixed sets keep non-targeted modes and drop targeted ones, + // regardless of parameter order. + assert_eq!(filter_modes(&[b"\x1b[?9001;25h"]), b"\x1b[?25h"); + assert_eq!(filter_modes(&[b"\x1b[?25;9001h"]), b"\x1b[?25h"); + assert_eq!(filter_modes(&[b"\x1b[4h\x1b[?25h"]), b"\x1b[4h\x1b[?25h"); + } + + #[test] + fn output_mode_filter_handles_sequence_split_across_reads() { + // A combined set split mid-sequence still filters per-parameter. + assert_eq!(filter_modes(&[b"\x1b[?25;90", b"01h"]), b"\x1b[?25h"); + // The pure Win32-input set split mid-sequence is still fully dropped. + assert_eq!(filter_modes(&[b"\x1b[?90", b"01h", b"X"]), b"X"); + } + + #[test] + fn output_mode_filter_drops_mode_reset_too() { + assert_eq!(filter_modes(&[b"\x1b[?9001l\x1b[?1004lY"]), b"Y"); + } + + #[test] + fn output_mode_filter_take_dsr_is_one_shot() { + let mut f = OutputModeFilter::default(); + let mut out = Vec::new(); + f.filter(b"\x1b[6n", &mut out); + // DSR passes through to the outer terminal... + assert_eq!(out, b"\x1b[6n"); + // ...and is latched exactly once. + assert!(f.take_dsr()); + assert!(!f.take_dsr()); + } + + #[test] + fn output_mode_filter_strips_title_osc_split_across_reads() { + assert_eq!( + filter_modes(&[b"before\x1b]2;Clau", b"de Code\x07after"]), + b"beforeafter" + ); + assert_eq!( + filter_modes(&[b"\x1b", b"]1", b";icon\x1b", b"\\text"]), + b"text" + ); + } + + #[test] + fn output_mode_filter_preserves_non_title_osc_and_tracks_its_boundary() { + let chunks: &[&[u8]] = &[b"\x1b]8;;https://exam", b"ple.test\x1b\\link"]; + assert_eq!(filter_modes(chunks), chunks.concat()); + + let mut f = OutputModeFilter::default(); + let mut out = Vec::new(); + f.filter(chunks[0], &mut out); + assert!(!f.title_write_safe()); + f.filter(chunks[1], &mut out); + assert!(f.title_write_safe()); + } + + #[test] + fn output_mode_filter_defers_title_across_split_utf8() { + let mut f = OutputModeFilter::default(); + let mut out = Vec::new(); + f.filter(&[0xe2], &mut out); + assert!(!f.title_write_safe()); + f.filter(&[0x94], &mut out); + assert!(!f.title_write_safe()); + f.filter(&[0x80], &mut out); + assert!(f.title_write_safe()); + assert_eq!(out, "─".as_bytes()); + } + + #[test] + fn output_mode_filter_title_only_read_preserves_pending_utf8() { + let mut f = OutputModeFilter::default(); + let mut out = Vec::new(); + f.filter(&[0xe2, 0x94], &mut out); + assert!(!f.title_write_safe()); + f.filter(b"\x1b]2;Claude Code\x07", &mut out); + assert!(!f.title_write_safe()); + f.filter(&[0x80], &mut out); + assert!(f.title_write_safe()); + } + + #[test] + fn output_mode_filter_tracks_other_split_escape_boundaries() { + let mut f = OutputModeFilter::default(); + let mut out = Vec::new(); + + f.filter(b"\x1bPpayload", &mut out); + assert!(!f.title_write_safe()); + f.filter(b"\x1b\\", &mut out); + assert!(f.title_write_safe()); + + f.filter(b"\x1bN", &mut out); + assert!(!f.title_write_safe()); + f.filter(b"x", &mut out); + assert!(f.title_write_safe()); + + f.filter(b"\x1b(", &mut out); + assert!(!f.title_write_safe()); + f.filter(b"B", &mut out); + assert!(f.title_write_safe()); + + assert_eq!(out, b"\x1bPpayload\x1b\\\x1bNx\x1b(B"); + } + + #[test] + fn build_title_escape_formats_osc_1_and_2() { + // listening icon is the green dot; assert exact OSC framing. + let esc = build_title_escape("alpha", "listening", "claude"); + let icon = status_icon("listening"); + let title = format!("{} alpha [claude]", icon); + assert_eq!(esc, format!("\x1b]1;{}\x07\x1b]2;{}\x07", title, title)); + assert!(esc.starts_with("\x1b]1;")); + assert!(esc.contains("\x07\x1b]2;")); + assert!(esc.ends_with('\x07')); + } + + #[test] + fn build_title_escape_uses_status_icon() { + // Different statuses must change the embedded icon. + let listening = build_title_escape("a", "listening", "claude"); + let blocked = build_title_escape("a", "blocked", "claude"); + assert_ne!(listening, blocked); + } + + #[test] + fn note_user_keystroke_cursor_is_noop_and_returns_false() { + let target = PtyTarget::AdhocCommand("cursor".to_string()); + let state = Arc::new(RwLock::new(ScreenState { + approval: true, + ..ScreenState::default() + })); + let calls = std::cell::Cell::new(0); + let publish = |_a: bool| calls.set(calls.get() + 1); + // cursor name: must not clear approval, must not publish, returns false. + let cleared = note_user_keystroke(&target, &state, &publish); + assert!(!cleared); + assert!(state.read().unwrap().approval, "cursor approval untouched"); + assert_eq!(calls.get(), 0, "cursor keystroke must not publish"); + } + + #[test] + fn note_user_keystroke_clears_approval_for_non_cursor() { + let target = PtyTarget::Known(Tool::Claude); + let state = Arc::new(RwLock::new(ScreenState { + approval: true, + ..ScreenState::default() + })); + let calls = std::cell::Cell::new(0); + let publish = |a: bool| { + assert!(!a, "keystroke publishes the cleared (false) edge"); + calls.set(calls.get() + 1); + }; + let cleared = note_user_keystroke(&target, &state, &publish); + assert!(cleared, "a standing approval was cleared"); + assert!(!state.read().unwrap().approval, "approval cleared"); + assert_eq!(calls.get(), 1, "cleared edge published once"); + } + + #[test] + fn note_user_keystroke_no_publish_when_not_blocked() { + let target = PtyTarget::Known(Tool::Claude); + let state = Arc::new(RwLock::new(ScreenState::default())); // approval=false + let calls = std::cell::Cell::new(0); + let publish = |_a: bool| calls.set(calls.get() + 1); + // No approval was showing, so nothing is cleared and the Windows caller + // must not request a tracker-clear (which would wipe the scrape buffer). + let cleared = note_user_keystroke(&target, &state, &publish); + assert!( + !cleared, + "no standing approval means no tracker clear requested" + ); + assert_eq!(calls.get(), 0, "no edge to publish when already clear"); + } + + #[test] + fn clear_injected_approval_state_cursor_returns_false() { + let target = PtyTarget::AdhocCommand("cursor".to_string()); + let state = Arc::new(RwLock::new(ScreenState { + approval: true, + ..ScreenState::default() + })); + let publish = |_a: bool| panic!("cursor must not publish"); + assert!(!clear_injected_approval_state(&target, &state, &publish)); + assert!(state.read().unwrap().approval, "cursor approval untouched"); + } + + #[test] + fn clear_injected_approval_state_clears_when_blocked() { + let target = PtyTarget::Known(Tool::Claude); + let state = Arc::new(RwLock::new(ScreenState { + approval: true, + ..ScreenState::default() + })); + let calls = std::cell::Cell::new(0); + let publish = |a: bool| { + assert!(!a); + calls.set(calls.get() + 1); + }; + assert!(clear_injected_approval_state(&target, &state, &publish)); + assert!(!state.read().unwrap().approval); + assert_eq!(calls.get(), 1); + } + + #[test] + fn clear_injected_approval_state_noop_when_not_blocked() { + let target = PtyTarget::Known(Tool::Claude); + let state = Arc::new(RwLock::new(ScreenState::default())); + let publish = |_a: bool| panic!("must not publish when nothing to clear"); + assert!(!clear_injected_approval_state(&target, &state, &publish)); + } + + // build_early_launch_context is portable (env::var/fs::read_to_string/thread::sleep + // all work identically on Windows), so these run on every platform rather than + // being Unix-only. Each test clears the env vars it touches before asserting so a + // panic can't leak state into later tests; #[serial] additionally prevents these + // from interleaving with each other. + use serial_test::serial; + + fn clear_launch_context_env() { + // SAFETY: tests are #[serial]. + unsafe { + std::env::remove_var("HCOM_PROCESS_ID"); + std::env::remove_var("KITTY_LISTEN_ON"); + std::env::remove_var("WEZTERM_PANE"); + std::env::remove_var("TMUX_PANE"); + std::env::remove_var("KITTY_WINDOW_ID"); + std::env::remove_var("ZELLIJ_PANE_ID"); + } + } + + #[test] + #[serial] + fn build_early_launch_context_empty_when_no_env_vars_set() { + clear_launch_context_env(); + let json = build_early_launch_context(); + clear_launch_context_env(); + assert_eq!(json, "{}"); + } + + #[test] + #[serial] + fn build_early_launch_context_captures_kitty_listen_on() { + clear_launch_context_env(); + // SAFETY: test is #[serial]. + unsafe { + std::env::set_var("KITTY_LISTEN_ON", "unix:/tmp/kitty.sock"); + } + let json = build_early_launch_context(); + clear_launch_context_env(); + let parsed: serde_json::Value = serde_json::from_str(&json).unwrap(); + assert_eq!(parsed["kitty_listen_on"], "unix:/tmp/kitty.sock"); + assert!(parsed.get("pane_id").is_none()); + } + + #[test] + #[serial] + fn build_early_launch_context_prefers_first_pane_id_var_in_priority_order() { + clear_launch_context_env(); + // SAFETY: test is #[serial]. + unsafe { + std::env::set_var("WEZTERM_PANE", "wezterm-pane"); + std::env::set_var("TMUX_PANE", "tmux-pane"); + } + let json = build_early_launch_context(); + clear_launch_context_env(); + let parsed: serde_json::Value = serde_json::from_str(&json).unwrap(); + assert_eq!(parsed["pane_id"], "wezterm-pane"); + } + + #[test] + #[serial] + fn build_early_launch_context_ignores_multiplexer_only_vars_when_absent() { + // TMUX_PANE/ZELLIJ_PANE_ID never being set on Windows must degrade to + // simply absent fields, not an error — same as on Unix outside a + // multiplexer. This is the "no platform branching needed" behavior. + clear_launch_context_env(); + // SAFETY: test is #[serial]. + unsafe { + std::env::set_var("KITTY_WINDOW_ID", "kitty-window-1"); + } + let json = build_early_launch_context(); + clear_launch_context_env(); + let parsed: serde_json::Value = serde_json::from_str(&json).unwrap(); + assert_eq!(parsed["pane_id"], "kitty-window-1"); + } +} diff --git a/src/pty/win.rs b/src/pty/win.rs new file mode 100644 index 00000000..77472272 --- /dev/null +++ b/src/pty/win.rs @@ -0,0 +1,1204 @@ +//! Windows ConPTY proxy — the Windows-native equivalent of the Unix PTY wrapper. +//! +//! The Unix proxy (`super`) is built on `openpty` + `nix::poll`. Windows has no +//! such primitives, so this spawns the tool under a **ConPTY** (via +//! `portable-pty`) and drives it with blocking IO threads instead of a poll +//! loop. The upper layers are reused unchanged: [`ScreenTracker`] for vt100 +//! screen tracking, [`InjectServer`] for TCP text injection, and +//! [`run_delivery_loop`] for notify-driven message delivery. This is what lets +//! an **idle** agent be woken on Windows (the M1 limitation): the delivery loop +//! injects `` text into the ConPTY input when a message arrives. + +use anyhow::{Context, Result}; +use std::io::{IsTerminal, Read, Write}; +use std::sync::atomic::{AtomicBool, AtomicU16, Ordering}; +use std::sync::mpsc; +use std::sync::{Arc, Mutex, RwLock}; +use std::thread::{self, JoinHandle}; +use std::time::{Duration, Instant}; + +use portable_pty::{CommandBuilder, MasterPty, PtySize, native_pty_system}; + +use super::ProxyConfig; +use super::inject::{InjectResult, InjectServer, QueryCommand}; +use super::screen::ScreenTracker; +use super::shared; + +use crate::db::HcomDb; +use crate::delivery::{EXIT_WAS_KILLED, ScreenState}; +use crate::log::log_error; + +/// True if `path` is a `.cmd`/`.bat` script (case-insensitive), which +/// `CreateProcessW` cannot execute directly — only `cmd.exe /c` can run those. +/// Covers npm-installed tool shims, which `terminal::which_bin` resolves to +/// `.cmd` via PATHEXT. +fn is_cmd_script(path: &str) -> bool { + std::path::Path::new(path) + .extension() + .and_then(|e| e.to_str()) + .is_some_and(|e| e.eq_ignore_ascii_case("cmd") || e.eq_ignore_ascii_case("bat")) +} + +/// Windows ConPTY-backed PTY proxy. +pub struct Proxy { + config: ProxyConfig, + child: Box, + /// ConPTY master, shared so the resize-watcher (calls `resize`) and the + /// reader-spawn (calls `try_clone_reader`) can both lock it. `MasterPty` is + /// `Send` but not `Clone`, so a `Mutex` is the only way to share it. + master: Arc>>, + writer: Arc>>, + screen_state: Arc>, + launch_phase_active: Arc, + running: Arc, + notify_port: Arc, + current_name: Arc>, + current_status: Arc>, + rows: u16, + cols: u16, + /// Delivery thread handle. Wrapped in `Arc>>` because the + /// delivery coordinator thread starts delivery (ready-or-timeout gated) and + /// stores the handle, while `run()`/`Drop` take it to join. + delivery_handle: Arc>>>, + /// Latched by the reader thread once the tool's ready pattern is observed; + /// read by the delivery coordinator to start delivery early. A latch never + /// regresses, unlike reading the (non-latched) screen-state ready flag. + ready_signaled: Arc, + /// Eagerly-maintained screen dump for `hcom term` screen queries. The reader + /// owns the `ScreenTracker` and refreshes this (throttled) each chunk; the + /// inject thread serves it directly so an idle agent — whose reader is + /// blocked in `read()` — still answers immediately (#4). + screen_snapshot: Arc>, + /// Set by the stdin/inject threads when a genuine keystroke (or injected + /// answer) should clear a pending approval; consumed by the reader thread, + /// which owns the `ScreenTracker` and calls `clear_approval()`. + approval_clear_requested: Arc, + /// Pending terminal resize `(rows, cols)` detected by the resize-watcher; + /// applied to the `ScreenTracker` by the reader thread before `process`. + pending_resize: Arc>>, + /// Visible tail captured by the reader thread on EOF, read by `run()` to + /// build the launch-failure diagnostic. + last_tail: Arc>>, + /// Set when delivery initialization fails; `run()` maps it to a nonzero exit. + launch_failed: Arc, + /// Job object the child is assigned to (`KILL_ON_JOB_CLOSE`). Reaps the + /// child's whole tree even if this proxy dies abnormally and `Drop` never + /// runs. `None` if the child couldn't be assigned (falls back to the + /// snapshot-based kill in `Drop`). + _job: Option, +} + +impl Proxy { + /// Spawn `command` under a ConPTY and prepare the proxy. + pub fn spawn(command: &str, args: &[&str], config: ProxyConfig) -> Result { + let (cols, rows) = crossterm::terminal::size().unwrap_or((80, 24)); + + let pty_system = native_pty_system(); + let pair = pty_system + .openpty(PtySize { + rows, + cols, + pixel_width: 0, + pixel_height: 0, + }) + .context("openpty (ConPTY) failed")?; + + // npm-installed tool shims (e.g. `gemini.cmd`, `codex.cmd`) resolve to + // `.cmd`/`.bat` files. CreateProcessW cannot execute those directly — + // only `cmd.exe /c` knows how to run a batch file — so route through + // the shell in that case. Other extensions (`.exe`, extension-less) + // spawn directly, exactly as before. + let mut cmd = if is_cmd_script(command) { + let mut c = CommandBuilder::new("cmd.exe"); + c.args(["/c", command]); + c.args(args); + c + } else { + let mut c = CommandBuilder::new(command); + c.args(args); + c + }; + for (k, v) in &config.env_vars { + cmd.env(k, v); + } + // Pin the ConPTY child's working directory. The Unix runner `.sh` does + // `cd {cwd}` then `exec hcom`, so the openpty child inherits the launch + // dir; the Windows runner `.ps1` uses `Set-Location` then invokes + // `hcom.exe` as a *child*. `Set-Location` only moves the PowerShell + // host's cwd — the spawned hcom (and the ConPTY child) do not reliably + // inherit it, and `CommandBuilder` defaults the child to the process + // default (the user's home) when no cwd is set. That launched Claude + // outside the repo, so its file index fell back to a full-home ripgrep + // scan (~11s), freezing input and swallowing ESC-ESC. hcom's own cwd is + // already the launch dir, so pinning to it keeps Claude in-repo. + if let Ok(cwd) = std::env::current_dir() { + cmd.cwd(crate::shared::platform::child_process_path(&cwd)); + } + + let child = pair + .slave + .spawn_command(cmd) + .context("ConPTY spawn failed")?; + // The parent does not need the slave handle once the child holds it. + drop(pair.slave); + + let writer = pair.master.take_writer().context("take_writer failed")?; + + // Persist PID so `hcom kill` can target the agent. + if let Some(ref instance_name) = config.instance_name + && let Ok(db) = HcomDb::open() + && let Some(pid) = child.process_id() + { + let _ = db.update_instance_pid(instance_name, pid); + + // Capture minimal launch context early so kill can close the terminal pane. + // The start hook may later overwrite with richer context (git_branch, tty, env). + let _ = db.store_launch_context(instance_name, &shared::build_early_launch_context()); + } + + // Tie the child to a kill-on-close job so its whole tree is reaped if we + // die abnormally (the explicit snapshot-kill in Drop covers clean exit). + let job = child.process_id().and_then(job::KillOnDropJob::assign); + + let initial_name = config.instance_name.clone().unwrap_or_default(); + + Ok(Self { + config, + child, + master: Arc::new(Mutex::new(pair.master)), + writer: Arc::new(Mutex::new(writer)), + screen_state: Arc::new(RwLock::new(ScreenState::default())), + launch_phase_active: Arc::new(AtomicBool::new(true)), + running: Arc::new(AtomicBool::new(true)), + notify_port: Arc::new(AtomicU16::new(0)), + current_name: Arc::new(RwLock::new(initial_name)), + current_status: Arc::new(RwLock::new(String::new())), + rows, + cols, + delivery_handle: Arc::new(Mutex::new(None)), + ready_signaled: Arc::new(AtomicBool::new(false)), + screen_snapshot: Arc::new(RwLock::new(String::new())), + approval_clear_requested: Arc::new(AtomicBool::new(false)), + pending_resize: Arc::new(RwLock::new(None)), + last_tail: Arc::new(RwLock::new(None)), + launch_failed: Arc::new(AtomicBool::new(false)), + _job: job, + }) + } + + /// Run the proxy until the child exits, returning its exit code. + pub fn run(&mut self) -> Result { + // Put our console into raw + VT passthrough so the tool's TUI renders + // and keystrokes flow through unbuffered. Restored on drop. + let _console = console::RawConsoleGuard::enable(); + + let startup_time = Instant::now(); + + let inject_server = InjectServer::new()?; + let inject_port = inject_server.port(); + + // Spawn the reader first: it can fail (ConPTY reader clone), and a + // failure here must tear nothing half-up (#23), so it bails before any + // other thread exists. The already-spawned child is reaped by Drop. Keep + // its handle: run() joins it below so the EOF-captured launch-failure + // tail is available. + let reader_handle = self.spawn_reader_thread(inject_port)?; + // A dedicated coordinator owns delivery-thread startup (ready-or-timeout + // gated); the reader can't, since it blocks in read() and cannot run a + // timer while the child is idle. + let coordinator_handle = self.spawn_delivery_coordinator(inject_port); + self.spawn_stdin_thread(); + self.spawn_inject_thread(inject_server); + self.spawn_resize_watcher(); + + // Poll rather than blocking solely in child.wait(): a definitive + // delivery-init failure must terminate a long-lived child immediately. + let exit_code = loop { + if self.launch_failed.load(Ordering::Acquire) { + if let Some(pid) = self.child.process_id() { + let _ = crate::sys::process::kill_group(pid); + } else { + let _ = self.child.kill(); + } + break wait_child_blocking(self.child.as_mut()); + } + match self.child.try_wait() { + Ok(Some(status)) => break status.exit_code() as i32, + Ok(None) => thread::sleep(Duration::from_millis(50)), + Err(error) => { + log_error("native", "win.wait", &format!("child wait failed: {error}")); + break 1; + } + } + }; + + // Commit the exit reason before setting running=false. The delivery + // thread reads EXIT_WAS_KILLED in cleanup; if we set running=false first + // (or allow the reader thread to do so), the delivery loop can enter + // cleanup before this store — recording exit:closed for a kill. + // Exit code 130 is the sentinel written by terminate_win() for an + // externally-issued `hcom kill`. + EXIT_WAS_KILLED.store(exit_code == 130, Ordering::Release); + + // Join the reader BEFORE reading last_tail (and before running=false, so + // the ordering below still matches the Unix proxy). The reader breaks + // its loop on PTY EOF (Ok(0)), not on `running`, so the child having + // exited is enough for it to wind down — no stop signal is needed first. + // It writes last_tail only at that EOF, and on Windows the ConPTY pipe + // can signal EOF after `child.wait()` already returned; without the join + // we could read last_tail while it is still None and emit a launch + // failure with an empty PTY tail. Joining first closes that race. + // + // Bounded join: the ConPTY pipe only reaches EOF once *every* process + // holding the slave handle exits. If the child spawned a grandchild that + // inherited the handle and outlives it, `reader.read()` never returns and + // an unbounded join would hang run() forever — running=false and the + // delivery join below would never run. Time-box the wait; on timeout we + // proceed (losing only the launch-failure tail) and let Drop kill the + // whole tree, which closes the pipe and lets the orphaned reader wind + // down. The normal EOF lag is milliseconds, so this only trips on a + // genuinely stuck grandchild. + join_with_timeout(reader_handle, Duration::from_secs(2)); + + // Record a precise launch-failure (exited-before-bind) BEFORE flipping + // running=false, mirroring the Unix proxy: finalize records the real + // evidence first, and the shared launch_phase flag then suppresses a + // duplicate generic failure from delivery cleanup. Skipped on a kill so + // a manual `hcom kill` is never recorded as a launch failure. + if !EXIT_WAS_KILLED.load(Ordering::Acquire) { + let tail = self.last_tail.read().ok().and_then(|g| g.clone()); + shared::finalize_launch_failure_after_exit( + self.config.instance_name.as_deref(), + tail.as_deref(), + &self.launch_phase_active, + startup_time.elapsed(), + exit_code, + ); + } + + // Signal threads to stop and wake the delivery loop's notify select. + self.running.store(false, Ordering::Release); + let port = self.notify_port.load(Ordering::Acquire); + if port != 0 { + let _ = std::net::TcpStream::connect(("127.0.0.1", port)); + } + + // Join the coordinator BEFORE taking `delivery_handle`. The coordinator + // is the sole writer of that handle; joining it establishes the + // happens-before that makes its store visible here. Its shutdown-time + // final start attempt can wait up to the 5s init recv_timeout, so bound + // the join at 6s (common case: coordinator already returned → instant). + join_with_timeout(coordinator_handle, Duration::from_secs(6)); + + // Recover the guard even if the mutex was poisoned: a panic elsewhere + // must not strand the delivery thread unjoined. The handle is the only + // thing behind this lock, so the (possibly stale) inner value is safe to + // take. + let handle = self + .delivery_handle + .lock() + .unwrap_or_else(|e| e.into_inner()) + .take(); + if let Some(handle) = handle { + let _ = handle.join(); + } + + // If delivery initialization failed, surface it as a nonzero exit. + if self.launch_failed.load(Ordering::Acquire) { + anyhow::bail!("delivery initialization failed"); + } + + Ok(exit_code) + } + + /// Own the delivery-thread startup decision. Polls every 50ms and starts + /// delivery exactly once when the tool is ready, the start-timeout elapses, + /// or shutdown begins. The reader thread cannot own this: it blocks in + /// `read()` and so cannot run a timer while the child is idle (#8). + /// + /// Stores the delivery-thread handle (through a poisoned mutex too) so it is + /// never dropped where `run()` can't join it. On [`shared::DeliveryStart:: + /// Pending`] it keeps the handle and stops retrying — a retry would spawn a + /// second delivery thread and double-deliver (#6). A transient up-front + /// `Err` sets `launch_failed` and retries on the next tick. + fn spawn_delivery_coordinator(&self, inject_port: u16) -> JoinHandle<()> { + let running = self.running.clone(); + let ready_signaled = self.ready_signaled.clone(); + let screen_state = self.screen_state.clone(); + let launch_phase = self.launch_phase_active.clone(); + let target = self.config.target.clone(); + let instance = self.config.instance_name.clone(); + let current_name = self.current_name.clone(); + let current_status = self.current_status.clone(); + let notify_port = self.notify_port.clone(); + let delivery_handle = self.delivery_handle.clone(); + let launch_failed = self.launch_failed.clone(); + let timeout = self.config.target.delivery_start_timeout(); + thread::spawn(move || { + let startup = Instant::now(); + loop { + let shutting_down = !running.load(Ordering::Acquire); + let should_start = shared::should_start_delivery( + ready_signaled.load(Ordering::Acquire), + startup.elapsed(), + timeout, + shutting_down, + ); + if should_start { + match shared::start_delivery_thread( + instance.as_deref(), + running.clone(), + screen_state.clone(), + launch_phase.clone(), + inject_port, + target.clone(), + notify_port.clone(), + current_name.clone(), + current_status.clone(), + ) { + Ok(shared::DeliveryStart::Started(h)) => { + *delivery_handle.lock().unwrap_or_else(|e| e.into_inner()) = Some(h); + // Clear any launch_failed from an earlier failed + // attempt so a transient error doesn't poison the + // exit code once a retry wins. + launch_failed.store(false, Ordering::Release); + return; + } + Ok(shared::DeliveryStart::Pending(h, init_rx)) => { + // Keep the handle and wait (bounded) for the + // timed-out initializer's eventual result. A + // definitive failure wakes run(), which kills and + // reaps the ConPTY child instead of waiting for its + // session to end naturally. Bounded rather than a + // plain recv() so a permanently stuck initializer + // still resolves this thread instead of blocking it + // forever. + *delivery_handle.lock().unwrap_or_else(|e| e.into_inner()) = Some(h); + if !matches!(init_rx.recv_timeout(Duration::from_secs(30)), Ok(Ok(()))) + { + launch_failed.store(true, Ordering::Release); + running.store(false, Ordering::Release); + } + return; + } + Ok(shared::DeliveryStart::Disabled) => return, + Err(_transient) => { + launch_failed.store(true, Ordering::Release); + // A shutdown tick gets exactly one last attempt; a + // running tick falls through to retry. + if shutting_down { + return; + } + } + } + } + if shutting_down { + return; + } + thread::sleep(Duration::from_millis(50)); + } + }) + } + + /// Poll the outer terminal size and forward changes to the ConPTY and the + /// screen tracker. Windows has no SIGWINCH, so this ~200ms poll is the + /// Windows counterpart to the Unix proxy's `forward_winsize`. + fn spawn_resize_watcher(&self) { + let running = self.running.clone(); + let master = self.master.clone(); + let pending_resize = self.pending_resize.clone(); + let (mut last_cols, mut last_rows) = (self.cols, self.rows); + thread::spawn(move || { + while running.load(Ordering::Acquire) { + if let Ok((cols, rows)) = crossterm::terminal::size() + && (cols, rows) != (last_cols, last_rows) + { + last_cols = cols; + last_rows = rows; + if let Ok(master) = master.lock() { + let _ = master.resize(PtySize { + rows, + cols, + pixel_width: 0, + pixel_height: 0, + }); + } + // Hand the new size to the reader thread, which owns the + // ScreenTracker and applies it before the next `process`. + if let Ok(mut g) = pending_resize.write() { + *g = Some((rows, cols)); + } + } + thread::sleep(Duration::from_millis(200)); + } + }); + } + + /// PTY output → our stdout, feeding the screen tracker and the shared + /// screen state the delivery loop reads. portable-pty's reader blocks, so a + /// dedicated thread replaces the Unix poll loop. + /// + /// This thread also owns the Windows equivalents of the Unix poll loop's + /// per-iteration work: refreshing the shared delivery state (via + /// `shared::update_delivery_state`), latching the ready signal for the + /// delivery coordinator, consuming approval-clear requests from the + /// stdin/inject threads, applying pending resizes, refreshing the screen + /// snapshot for `hcom term` queries, answering the child's cursor-position + /// query when headless, and emitting title OSC updates on status/name + /// changes. + /// + /// Returns the thread's `JoinHandle` so `run()` can join it after the child + /// exits and before reading `last_tail` — the reader writes `last_tail` only + /// at PTY EOF (`Ok(0)`), which on Windows can lag the child's exit, so the + /// join is what guarantees the launch-failure tail is populated. + /// + /// Returns `Err` if the ConPTY reader can't be cloned (or the master mutex is + /// poisoned): without a reader there is no screen tracking, no delivery + /// coordination, and no launch-failure tail, so a silently degraded session + /// is worse than a loud failure (#23). `run()` calls this with `?` before + /// spawning any other thread, so an early bail tears nothing half-up. + fn spawn_reader_thread(&self, inject_port: u16) -> Result> { + let reader = match self.master.lock() { + Ok(master) => master + .try_clone_reader() + .context("ConPTY try_clone_reader failed; cannot track screen")?, + Err(_) => anyhow::bail!("ConPTY master mutex poisoned; cannot spawn reader"), + }; + let screen_state = self.screen_state.clone(); + let launch_phase = self.launch_phase_active.clone(); + let target = self.config.target.clone(); + let ready_pattern = self.config.ready_pattern.clone(); + let instance = self.config.instance_name.clone(); + let current_name = self.current_name.clone(); + let current_status = self.current_status.clone(); + let approval_clear_requested = self.approval_clear_requested.clone(); + let pending_resize = self.pending_resize.clone(); + let last_tail = self.last_tail.clone(); + let ready_signaled = self.ready_signaled.clone(); + let screen_snapshot = self.screen_snapshot.clone(); + let writer = self.writer.clone(); + let (rows, cols) = (self.rows, self.cols); + + // Producer: owns the ConPTY reader and blocks in read(), forwarding raw + // chunks over a channel. This exists so the consumer loop below can wait + // with a bounded timeout (`recv_timeout`) instead of blocking in read() + // forever — that bounded wait is what lets it render a trailing `hcom + // term` snapshot ~120ms after an idle agent's output stops (#4); a plain + // blocking read() could not, since it never returns while the child is + // idle. Detached like the old single reader was: on the orphaned- + // grandchild EOF-lag case (see run()) it may stay blocked in read(), but + // it holds no lock and process::exit reaps it. + // Bounded so the producer blocks (SyncSender::send) when the consumer + // lags, restoring the child backpressure the pre-split single-thread + // reader had implicitly (read + stdout write on one thread). Unbounded + // here would let the producer drain the ConPTY into RAM without limit + // under sustained output or a stalled headless stdout. ~256 * 8KiB + // chunks (~2MiB) absorbs normal bursts; disconnect/recv_timeout + // semantics are identical to an unbounded channel. + let (tx, rx) = mpsc::sync_channel::>(256); + thread::spawn(move || { + let mut reader = reader; + let mut buf = [0u8; 8192]; + loop { + match reader.read(&mut buf) { + Ok(0) => break, // EOF: child exited / PTY closed + Ok(n) => { + if tx.send(buf[..n].to_vec()).is_err() { + break; // consumer gone + } + } + Err(_) => break, + } + } + // Dropping `tx` here signals EOF/read-error to the consumer, which + // then captures the launch-failure tail and does a final snapshot. + }); + + Ok(thread::spawn(move || { + let mut screen = + ScreenTracker::new_with_instance(rows, cols, &ready_pattern, instance.as_deref()); + let mut stdout = std::io::stdout(); + let mut filter = shared::OutputModeFilter::default(); + let mut scratch: Vec = Vec::with_capacity(8192); + + // In interactive mode stdout is a real console, so the outer terminal + // answers the child's cursor-position query itself — we must not also + // answer. Headless (piped/no console) there is no terminal to answer, + // so the reader replies on the child's behalf or startup hangs (#1). + let headless = !std::io::stdout().is_terminal(); + let mut last_name = String::new(); + let mut last_status = String::new(); + + // `hcom term` snapshot refresh (see should_refresh_snapshot). Under + // sustained output we render at most once per SNAPSHOT_THROTTLE; + // chunks skipped by that throttle set `dirty`. When output then goes + // quiet, recv_timeout fires after SNAPSHOT_DEBOUNCE and we render one + // final dump, so an idle agent's last frame is current within ~120ms + // (#4). At most one extra dump per quiet period, zero under sustained + // output. + const SNAPSHOT_THROTTLE: Duration = Duration::from_millis(100); + const SNAPSHOT_DEBOUNCE: Duration = Duration::from_millis(120); + let mut last_snapshot = Instant::now(); + let mut dirty = false; + let refresh = |screen: &ScreenTracker| { + if let Ok(mut s) = screen_snapshot.write() { + *s = screen.get_screen_dump(target.name(), inject_port); + } + }; + let publish = + |a: bool| shared::publish_approval_status(a, instance.as_deref(), ¤t_status); + + loop { + match rx.recv_timeout(SNAPSHOT_DEBOUNCE) { + Err(mpsc::RecvTimeoutError::Timeout) => { + // Output has been quiet for SNAPSHOT_DEBOUNCE. If a frame + // was deferred by the throttle, render it now so an idle + // agent's final frame is current for `hcom term`. + if dirty { + refresh(&screen); + last_snapshot = Instant::now(); + dirty = false; + } + // Mirror the Unix poll loop's idle-path hooks (src/pty/mod.rs): + // without this, `update_delivery_state` on Windows only ever + // runs from the `Ok(data)` branch above, so once output goes + // quiet it never re-evaluates. A transient misread on the last + // chunk before quiescence — e.g. a mid-redraw frame where the + // gate sees leftover non-dim text on the prompt row — then + // latches forever instead of self-correcting within one poll, + // stalling delivery indefinitely until new output arrives. + if ready_signaled.load(Ordering::Acquire) { + shared::update_delivery_state( + &screen_state, + &screen, + &target, + &launch_phase, + &publish, + ); + } + screen.check_debug_flag(); + screen.check_periodic_dump( + target.name(), + inject_port, + "Periodic dump (win reader loop)", + ); + continue; + } + Err(mpsc::RecvTimeoutError::Disconnected) => { + // EOF / read error: capture the visible tail so run() can + // build the launch-failure diagnostic before the screen is + // gone, and render the final frame for a late `hcom term`. + if let Ok(mut g) = last_tail.write() { + *g = screen.visible_tail(8, 1000); + } + refresh(&screen); + break; // child exited / PTY closed + } + Ok(data) => { + let data = data.as_slice(); + // A genuine keystroke / injected answer flagged a pending + // approval for clearing; the reader owns the tracker. + if approval_clear_requested.swap(false, Ordering::AcqRel) { + screen.clear_approval(); + } + // Apply a pending terminal resize before processing this + // frame so the screen model matches the new geometry. + if let Some((r, c)) = pending_resize.write().ok().and_then(|mut g| g.take()) + { + screen.resize(r, c); + } + + // Strip the child's Win32-input/focus mode-set sequences + // before they reach the *outer* terminal (see + // OutputModeFilter); otherwise the outer terminal answers + // the child's DSR query in Win32 input-record encoding, + // which the child can't parse, and startup hangs. + scratch.clear(); + filter.filter(data, &mut scratch); + let _ = stdout.write_all(&scratch); + let _ = stdout.flush(); + + // Headless: no outer terminal saw the DSR query, so answer + // it here (a canned cursor-at-1;1 report) to unblock the + // child's console initialization. Interactive: the real + // terminal already answered, so we never synthesize a + // reply — the latch is simply left set and unread. + if headless + && filter.take_dsr() + && let Ok(mut w) = writer.lock() + { + let _ = w.write_all(b"\x1b[1;1R"); + let _ = w.flush(); + } + + screen.process(data); + + // Refresh the `hcom term` snapshot, throttled to ≤10Hz so + // heavy output doesn't spend the reader in screen dumps + // (~150µs each). A chunk skipped here is marked dirty and + // captured by the trailing-edge Timeout branch above once + // output stops. + if shared::should_refresh_snapshot( + last_snapshot.elapsed(), + SNAPSHOT_THROTTLE, + ) { + refresh(&screen); + last_snapshot = Instant::now(); + dirty = false; + } else { + dirty = true; + } + + shared::update_delivery_state( + &screen_state, + &screen, + &target, + &launch_phase, + &publish, + ); + + // Latch the ready signal for the delivery coordinator. A + // latch never regresses (unlike re-reading a screen flag + // that can flicker during redraws). + if !ready_signaled.load(Ordering::Acquire) && screen.is_ready() { + ready_signaled.store(true, Ordering::Release); + } + + // Title OSC update. The output filter tracks complete + // CSI/OSC/DCS/UTF-8 boundaries, so this cannot split the + // child's byte stream. Never emit terminal metadata into + // redirected/headless stdout. + // + // Compare under the read guards against the last-written + // values and only build/clone when something actually + // changed. This runs on every at-ground chunk (frequent + // under heavy output) and name/status rarely change, so + // the common path holds the two read locks briefly but + // allocates nothing. + if !headless + && filter.title_write_safe() + && let (Ok(name), Ok(status)) = + (current_name.read(), current_status.read()) + && !name.is_empty() + && (*name != last_name || *status != last_status) + { + let esc = shared::build_title_escape(&name, &status, target.name()); + let _ = stdout.write_all(esc.as_bytes()); + let _ = stdout.flush(); + last_name = name.clone(); + last_status = status.clone(); + } + } + } + } + // Do NOT store running=false here. Letting run() be the sole writer + // ensures EXIT_WAS_KILLED is committed before the delivery thread + // sees running=false and enters cleanup. If the reader set it first, + // the delivery loop could read EXIT_WAS_KILLED=false and record + // exit:closed even when the child was killed via `hcom kill`. + })) + } + + /// Our stdin → PTY input. Intentionally detached and never joined. + /// + /// The `running` check at the loop top only catches shutdown *between* + /// reads; a `stdin.read()` already blocked when the child exits cannot be + /// interrupted and outlives the child. This does not leak: `main` calls + /// `std::process::exit` immediately after `run` returns (and `Proxy::drop`), + /// which terminates the process and reaps this thread even mid-read. The + /// thread holds no lock across the blocking read, so it cannot wedge + /// cleanup. If `Proxy` ever gains a caller that keeps running after `run` + /// returns, this read would need an explicit interrupt (e.g. + /// `CancelSynchronousIo`). + fn spawn_stdin_thread(&self) { + let writer = self.writer.clone(); + let running = self.running.clone(); + let target = self.config.target.clone(); + let screen_state = self.screen_state.clone(); + let current_status = self.current_status.clone(); + let instance = self.config.instance_name.clone(); + let approval_clear_requested = self.approval_clear_requested.clone(); + thread::spawn(move || { + let mut stdin = std::io::stdin(); + let mut buf = [0u8; 4096]; + loop { + if !running.load(Ordering::Acquire) { + break; + } + match stdin.read(&mut buf) { + Ok(0) => break, + Ok(n) => { + if let Ok(mut w) = writer.lock() { + let _ = w.write_all(&buf[..n]); + let _ = w.flush(); + } + if n > 0 { + // A genuine keystroke answering a title-detected + // approval clears it immediately. Record the cleared + // edge against shared state; the reader thread owns + // the tracker, so request a tracker-clear via the + // atomic it consumes — but ONLY when an approval was + // actually standing. `clear_approval()` wipes the OSC + // scrape buffer, so requesting it on every keystroke + // would let a routine keypress race out an approval + // edge arriving in the same window. + let publish = |a: bool| { + shared::publish_approval_status( + a, + instance.as_deref(), + ¤t_status, + ) + }; + if shared::note_user_keystroke(&target, &screen_state, &publish) { + approval_clear_requested.store(true, Ordering::Release); + } + } + } + Err(_) => break, + } + } + }); + } + + /// InjectServer → PTY input. Polls for inject connections (the delivery loop + /// and `hcom term inject` connect here) and writes the text to the ConPTY. + fn spawn_inject_thread(&self, mut inject_server: InjectServer) { + let writer = self.writer.clone(); + let running = self.running.clone(); + let target = self.config.target.clone(); + let screen_state = self.screen_state.clone(); + let current_status = self.current_status.clone(); + let instance = self.config.instance_name.clone(); + let approval_clear_requested = self.approval_clear_requested.clone(); + let screen_snapshot = self.screen_snapshot.clone(); + // A client stuck Pending past this long (connected but never sending + // EOF/erroring — e.g. a killed-without-cleanup peer) stops blocking + // later-queued clients such as independent `hcom term` screen queries. + const STALL_TIMEOUT: Duration = Duration::from_secs(2); + thread::spawn(move || { + let mut stalled_since: Option = None; + while running.load(Ordering::Acquire) { + // Drain the accept queue. + while matches!(inject_server.accept(), Ok(true)) {} + // Preserve connection order. `hcom term inject --enter` sends + // text and Enter on two consecutive TCP connections; processing + // newest-first delivers Enter before the text and leaves the + // prompt filled but unsubmitted. Completed clients remove + // themselves, so keep the same index after completion. + let mut index = 0; + while index < inject_server.client_count() { + let completed = match inject_server.read_client(index) { + Ok(InjectResult::Inject(text)) => { + if let Ok(mut w) = writer.lock() { + let _ = w.write_all(text.as_bytes()); + let _ = w.flush(); + } + // An injected answer reaches the PTY directly and + // bypasses the stdin handler. Publish the cleared + // edge synchronously (while the row is still blocked) + // and request a tracker-clear from the reader thread. + let publish = |a: bool| { + shared::publish_approval_status( + a, + instance.as_deref(), + ¤t_status, + ) + }; + if shared::clear_injected_approval_state( + &target, + &screen_state, + &publish, + ) { + approval_clear_requested.store(true, Ordering::Release); + } + true + } + // Screen queries (`hcom term`) are served from the + // eagerly-maintained snapshot the reader refreshes, so an + // idle agent (reader blocked in read()) still answers + // immediately rather than hanging or returning "" (#4). + Ok(InjectResult::Query(q)) => { + match q.command { + QueryCommand::Screen => { + let dump = screen_snapshot + .read() + .map(|s| s.clone()) + .unwrap_or_default(); + q.respond(&dump); + } + QueryCommand::Unknown => q.respond("error: unknown command\n"), + } + true + } + Ok(InjectResult::Pending) => false, + Err(_) => true, + }; + if completed { + stalled_since = None; + // A completed client at `index` was removed, shifting + // the next client into this slot; retry it here. + continue; + } + if index == 0 { + let stalled = stalled_since.get_or_insert_with(Instant::now); + if stalled.elapsed() < STALL_TIMEOUT { + // Strict FIFO: do not let a later, already-complete + // Enter frame overtake an earlier text frame that + // has not exposed EOF yet. + break; + } + // Client 0 has been stuck long enough that it's + // unlikely to ever complete; stop enforcing strict + // order so later, independent clients aren't starved. + } + index += 1; + } + thread::sleep(Duration::from_millis(10)); + } + }); + } +} + +/// Block until `child` exits, returning its exit code. +/// +/// This wait spans the entire interactive session, so it is deliberately +/// unbounded: from here a healthy child running for hours is indistinguishable +/// from a hung one, and any watchdog timeout at this spot force-kills every +/// session once the timer elapses (a 5s escalation here used to kill Claude a +/// few seconds after launch). The Unix proxy's 5s SIGKILL escalation is not an +/// analogue for this wait — it lives in `drain_and_wait_child`, which runs +/// only after the PTY signals EOF/HUP (the child is already tearing down) and +/// exists to break the full-PTY-buffer write deadlock; that deadlock cannot +/// happen here because the reader thread keeps draining the ConPTY pipe +/// concurrently. A genuinely stuck child is still covered: `hcom kill` +/// terminates the tree directly (releasing this wait), and Drop's kill_group +/// plus the job object's kill-on-close reap anything left. +/// +/// Never returns an `Err` (unlike a bare `child.wait()?`): a wait failure is +/// logged and treated as an unknown exit code, since propagating it via `?` +/// would skip run()'s cleanup (stop signal, notify wake, delivery-thread +/// join) — that cleanup must run regardless of whether waiting on the child +/// itself succeeded. +fn wait_child_blocking(child: &mut (dyn portable_pty::Child + Send + Sync)) -> i32 { + match child.wait() { + Ok(status) => status.exit_code() as i32, + Err(e) => { + log_error("native", "win.wait", &format!("child wait failed: {e}")); + 1 + } + } +} + +/// Join `handle`, but give up after `timeout` and return regardless. +/// +/// `JoinHandle::join` has no timeout, so we hand the handle to a short-lived +/// joiner thread and wait on a channel. On timeout the joiner thread is left +/// running (detached) — it owns `handle` and completes on its own once the +/// reader finally exits (e.g. after Drop kills the process tree and the ConPTY +/// pipe closes). Dropping the receiver here does not abort it. The joiner holds +/// no lock, so a lingering one cannot wedge the rest of shutdown. +fn join_with_timeout(handle: JoinHandle<()>, timeout: Duration) { + let (tx, rx) = std::sync::mpsc::channel(); + thread::spawn(move || { + let _ = handle.join(); + let _ = tx.send(()); + }); + let _ = rx.recv_timeout(timeout); +} + +impl Drop for Proxy { + fn drop(&mut self) { + self.running.store(false, Ordering::Release); + // Reap the child and any descendants it spawned (race-free snapshot + // walk). The `_job` field's kill-on-close is the backstop for the case + // where Drop never runs. + // + // `child.process_id()` keeps returning `Some` even after the child has + // already exited normally — it's a fresh `GetProcessId()` on our still- + // open handle, not a liveness check — so a bare `is_some()` can't tell + // "run() exited early and this child still needs killing" apart from + // "run() already waited on and reaped this child." Check `try_wait()` + // first (side-effect-free, safe to call again after run()'s own wait): + // today Drop only ever runs after run()'s wait/cleanup has completed, + // so this has no observable effect, but it guards against a future + // reordering marking a normal exit as `exit:killed`. + if !matches!(self.child.try_wait(), Ok(Some(_))) { + if let Some(pid) = self.child.process_id() { + // Mark as killed so the delivery thread records exit:killed if + // it is still running. Do not also call child.kill() below: it + // would send a second, competing TerminateProcess to the same + // PID and can overwrite the hcom-kill sentinel exit code (130) + // that kill_group's terminate_win already set — the same race + // fixed in kill_child_group (sys/process.rs). + EXIT_WAS_KILLED.store(true, Ordering::Release); + let _ = crate::sys::process::kill_group(pid); + } else { + let _ = self.child.kill(); + } + } + if let Some(ref instance_name) = self.config.instance_name + && let Ok(db) = HcomDb::open() + { + let _ = db.delete_notify_endpoint(instance_name, "inject"); + } + } +} + +#[cfg(test)] +mod tests { + use super::{is_cmd_script, wait_child_blocking}; + use portable_pty::{CommandBuilder, PtySize, native_pty_system}; + use std::io::{Read, Write}; + use std::time::{Duration, Instant}; + + /// Spawn `argv` under a ConPTY. Returns the master too: dropping it closes + /// the ConPTY and kills the child, so tests must keep it alive while + /// waiting. + /// + /// A service thread plays the role of the outer terminal, which this + /// headless test doesn't have — without it the child hangs before ever + /// exiting (even `cmd /c exit 42`; both tests once ran 60s+ in CI): + /// - It drains the master output so the ConPTY output pipe never fills. + /// - It answers conhost's cursor-position query. portable-pty creates the + /// ConPTY with `PSEUDOCONSOLE_INHERIT_CURSOR`, so conhost emits `ESC[6n` + /// and blocks console initialization — and with it every console API + /// call the child makes — until an `ESC[;R` report arrives on the + /// input pipe. In production the real terminal answers automatically. + fn spawn_in_conpty( + argv: &[&str], + ) -> ( + Box, + Box, + ) { + let pair = native_pty_system() + .openpty(PtySize { + rows: 24, + cols: 80, + pixel_width: 0, + pixel_height: 0, + }) + .expect("openpty (ConPTY) failed"); + let mut cmd = CommandBuilder::new(argv[0]); + cmd.args(&argv[1..]); + let child = pair.slave.spawn_command(cmd).expect("spawn failed"); + drop(pair.slave); + let mut reader = pair + .master + .try_clone_reader() + .expect("try_clone_reader failed"); + let mut writer = pair.master.take_writer().expect("take_writer failed"); + std::thread::spawn(move || { + let mut sink = [0u8; 8192]; + let mut replied = false; + loop { + match reader.read(&mut sink) { + Ok(n) if n > 0 => { + if !replied && sink[..n].windows(4).any(|w| w == b"\x1b[6n") { + let _ = writer.write_all(b"\x1b[1;1R"); + let _ = writer.flush(); + replied = true; + } + } + _ => break, + } + } + }); + (pair.master, child) + } + + /// Run `wait_child_blocking` on a helper thread with a hard deadline, so a + /// hung child fails the test in bounded time instead of stalling CI until + /// the job timeout. + fn wait_with_deadline( + mut child: Box, + deadline: Duration, + ) -> i32 { + let (tx, rx) = std::sync::mpsc::channel(); + std::thread::spawn(move || { + let _ = tx.send(wait_child_blocking(child.as_mut())); + }); + rx.recv_timeout(deadline) + .expect("child did not exit within the test deadline") + } + + #[test] + fn wait_child_blocking_returns_the_child_exit_code() { + let (_master, child) = spawn_in_conpty(&["cmd.exe", "/c", "exit 42"]); + assert_eq!(wait_with_deadline(child, Duration::from_secs(30)), 42); + } + + #[test] + fn wait_child_blocking_does_not_kill_a_healthy_long_running_child() { + // Regression: a 5s watchdog in run()'s child wait used to kill_group + // every session a few seconds after launch, forcing the kill sentinel + // (130) as the exit code. A child that outlives that window must still + // exit on its own, with its own code. + let (_master, child) = spawn_in_conpty(&["ping", "-n", "7", "127.0.0.1"]); + let start = Instant::now(); + let code = wait_with_deadline(child, Duration::from_secs(60)); + let elapsed = start.elapsed(); + assert_eq!(code, 0, "child should exit on its own, not be killed"); + assert!( + elapsed >= Duration::from_secs(5), + "child exited after {elapsed:?}; expected it to outlive the former 5s watchdog window" + ); + } + + #[test] + fn is_cmd_script_matches_cmd_and_bat_case_insensitively() { + assert!(is_cmd_script("gemini.cmd")); + assert!(is_cmd_script(r"C:\Users\me\AppData\Roaming\npm\codex.CMD")); + assert!(is_cmd_script("run.bat")); + assert!(is_cmd_script("RUN.BAT")); + } + + #[test] + fn is_cmd_script_rejects_other_extensions() { + assert!(!is_cmd_script("claude.exe")); + assert!(!is_cmd_script("gemini")); + assert!(!is_cmd_script("script.ps1")); + assert!(!is_cmd_script("noext.")); + } + + // OutputModeFilter and its tests now live in `super::shared` so they run on + // the (non-Windows) host gate rather than being cross-compiled only. +} + +/// A job object whose assigned processes are killed when the handle closes. +mod job { + use windows_sys::Win32::Foundation::CloseHandle; + use windows_sys::Win32::System::JobObjects::{ + AssignProcessToJobObject, CreateJobObjectW, JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE, + JOBOBJECT_EXTENDED_LIMIT_INFORMATION, JobObjectExtendedLimitInformation, + SetInformationJobObject, + }; + use windows_sys::Win32::System::Threading::{ + OpenProcess, PROCESS_SET_QUOTA, PROCESS_TERMINATE, + }; + + pub struct KillOnDropJob { + /// HANDLE stored as `isize` (matches the console module) so the field + /// stays `Send` and doesn't infect the proxy with a raw pointer. + handle: isize, + } + + impl KillOnDropJob { + /// Create a `KILL_ON_JOB_CLOSE` job and assign `pid` to it. Returns + /// `None` (caller falls back to an explicit kill) if any step fails — + /// e.g. the process already exited or assignment is refused. + pub fn assign(pid: u32) -> Option { + // SAFETY: each handle is closed on every failure path; the limit + // struct is zero-initialized before its one field is set. + unsafe { + let handle = CreateJobObjectW(std::ptr::null(), std::ptr::null()); + if handle.is_null() { + return None; + } + let mut info: JOBOBJECT_EXTENDED_LIMIT_INFORMATION = std::mem::zeroed(); + info.BasicLimitInformation.LimitFlags = JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE; + let set = SetInformationJobObject( + handle, + JobObjectExtendedLimitInformation, + &info as *const _ as *const _, + std::mem::size_of::() as u32, + ); + if set == 0 { + CloseHandle(handle); + return None; + } + let proc = OpenProcess(PROCESS_SET_QUOTA | PROCESS_TERMINATE, 0, pid); + if proc.is_null() { + CloseHandle(handle); + return None; + } + let assigned = AssignProcessToJobObject(handle, proc); + CloseHandle(proc); + if assigned == 0 { + CloseHandle(handle); + return None; + } + Some(KillOnDropJob { + handle: handle as isize, + }) + } + } + } + + impl Drop for KillOnDropJob { + fn drop(&mut self) { + // Closing the last handle to a KILL_ON_JOB_CLOSE job terminates + // every process still assigned to it. + // SAFETY: handle came from CreateJobObjectW and is closed once. + unsafe { + CloseHandle(self.handle as _); + } + } + } +} + +/// Windows console raw-mode + VT passthrough, restored on drop. +mod console { + use windows_sys::Win32::System::Console::{ + CONSOLE_MODE, ENABLE_ECHO_INPUT, ENABLE_LINE_INPUT, ENABLE_PROCESSED_INPUT, + ENABLE_VIRTUAL_TERMINAL_INPUT, ENABLE_VIRTUAL_TERMINAL_PROCESSING, GetConsoleMode, + GetStdHandle, STD_INPUT_HANDLE, STD_OUTPUT_HANDLE, SetConsoleMode, + }; + + pub struct RawConsoleGuard { + stdin_handle: isize, + stdout_handle: isize, + prev_in: CONSOLE_MODE, + prev_out: CONSOLE_MODE, + restore: bool, + } + + impl RawConsoleGuard { + /// Best-effort: disable line input/echo on stdin, enable VT input, and + /// enable VT processing on stdout so the child's escape sequences render. + /// If the handles aren't consoles (piped), this is a no-op. + pub fn enable() -> Self { + // SAFETY: GetStdHandle returns process-owned console handles; the + // mode getters/setters only touch those handles. + unsafe { + let stdin_handle = GetStdHandle(STD_INPUT_HANDLE) as isize; + let stdout_handle = GetStdHandle(STD_OUTPUT_HANDLE) as isize; + let mut prev_in: CONSOLE_MODE = 0; + let mut prev_out: CONSOLE_MODE = 0; + let ok_in = GetConsoleMode(stdin_handle as _, &mut prev_in) != 0; + let ok_out = GetConsoleMode(stdout_handle as _, &mut prev_out) != 0; + if ok_in { + let raw_in = (prev_in + & !(ENABLE_LINE_INPUT | ENABLE_ECHO_INPUT | ENABLE_PROCESSED_INPUT)) + | ENABLE_VIRTUAL_TERMINAL_INPUT; + SetConsoleMode(stdin_handle as _, raw_in); + } + if ok_out { + SetConsoleMode( + stdout_handle as _, + prev_out | ENABLE_VIRTUAL_TERMINAL_PROCESSING, + ); + } + RawConsoleGuard { + stdin_handle, + stdout_handle, + prev_in, + prev_out, + restore: ok_in || ok_out, + } + } + } + } + + impl Drop for RawConsoleGuard { + fn drop(&mut self) { + if !self.restore { + return; + } + // SAFETY: restoring the previously-read modes on the same handles. + unsafe { + SetConsoleMode(self.stdin_handle as _, self.prev_in); + SetConsoleMode(self.stdout_handle as _, self.prev_out); + } + } + } +} diff --git a/src/relay/control.rs b/src/relay/control.rs index 1c8b61e4..025e63de 100644 --- a/src/relay/control.rs +++ b/src/relay/control.rs @@ -818,6 +818,7 @@ fn handle_remote_kill( crate::terminal::KillResult::PermissionDenied => "permission_denied", }, "pane_closed": result.pane_closed, + "pane_retry_command": result.pane_retry_command, "preset_name": result.preset_name, "pane_id": result.pane_id, "ok": !matches!(result.kill_result, crate::terminal::KillResult::PermissionDenied), @@ -936,7 +937,10 @@ fn handle_remote_relay_off( if super::worker::is_relay_worker_running() { std::thread::spawn(|| { std::thread::sleep(Duration::from_millis(100)); - let _ = super::worker::stop_relay_worker(); + // Blocking + force-kill fallback: the graceful request is + // best-effort and may not reach a consoleless worker on Windows, + // and the watchdog won't auto-exit while local instances remain. + super::worker::stop_relay_worker_blocking(); }); } Ok(json!({ diff --git a/src/relay/mod.rs b/src/relay/mod.rs index 61d1b01c..752aa5ec 100644 --- a/src/relay/mod.rs +++ b/src/relay/mod.rs @@ -179,8 +179,8 @@ fn read_or_create_device_uuid_at(path: &std::path::Path) -> Option { .open(&lock_path) .ok()?; - use nix::fcntl::{Flock, FlockArg}; - let _flock = Flock::lock(lock_file, FlockArg::LockExclusive).ok()?; + // Held until `lock_file` drops at function scope end. + crate::sys::fs::lock_exclusive(&lock_file).ok()?; // Re-check under lock — a concurrent caller may have written by now. if let Some(uuid) = read_nonempty(path) { diff --git a/src/relay/worker.rs b/src/relay/worker.rs index 8188cf66..1f1ea3d0 100644 --- a/src/relay/worker.rs +++ b/src/relay/worker.rs @@ -7,7 +7,6 @@ //! before spawning a new relay-worker process. use std::net::TcpListener; -use std::os::fd::AsRawFd; use std::path::PathBuf; use std::process::{Command, Stdio}; use std::sync::Arc; @@ -153,25 +152,11 @@ pub fn run() -> i32 { // CLI callers (hcom send, hooks) connect to trigger immediate push. let notify_port = setup_notify_listener(&cmd_tx); - // Install signal handlers via signal-hook (sets AtomicBool on SIGTERM/SIGINT). + // Install shutdown-signal handlers (set AtomicBool on terminate/interrupt). // The watchdog thread checks this flag — no separate signal-polling thread needed. let shutdown = Arc::new(AtomicBool::new(false)); - if let Err(e) = signal_hook::flag::register(signal_hook::consts::SIGTERM, Arc::clone(&shutdown)) - { - log::log_error( - "relay", - "signal.register.sigterm", - &format!("Failed to register SIGTERM handler: {}", e), - ); - } - if let Err(e) = signal_hook::flag::register(signal_hook::consts::SIGINT, Arc::clone(&shutdown)) - { - log::log_error( - "relay", - "signal.register.sigint", - &format!("Failed to register SIGINT handler: {}", e), - ); - } + crate::sys::signal::register_term(&shutdown); + crate::sys::signal::register_int(&shutdown); // Spawn auto-exit watchdog thread (also monitors shutdown flag) let cmd_tx_watchdog = cmd_tx; @@ -339,16 +324,9 @@ fn do_spawn() -> bool { } }; - loop { - let lock_ret = unsafe { libc::flock(lock_file.as_raw_fd(), libc::LOCK_EX) }; - if lock_ret == 0 { - break; - } - let err = std::io::Error::last_os_error(); - if err.kind() != std::io::ErrorKind::Interrupted { - log::log_warn("relay", "relay_worker.spawn_lock_err", &format!("{err}")); - return false; - } + if let Err(err) = crate::sys::fs::lock_exclusive(&lock_file) { + log::log_warn("relay", "relay_worker.spawn_lock_err", &format!("{err}")); + return false; } if is_relay_worker_running() { @@ -380,19 +358,11 @@ fn do_spawn() -> bool { .stdout(Stdio::null()) .stderr(Stdio::null()); - // Detach into own session so it survives parent terminal close (no SIGHUP) - #[cfg(unix)] - { - use std::os::unix::process::CommandExt; - unsafe { - cmd.pre_exec(|| { - libc::setsid(); - Ok(()) - }); - } - } - - match cmd.spawn() { + // Detach into its own session so it survives parent terminal close (no + // SIGHUP) and, on Windows, doesn't inherit the parent's stdio handles + // (which would otherwise keep any caller piping hcom's output from ever + // observing EOF). + match crate::sys::process::spawn_detached(&mut cmd) { Ok(child) => { write_pid_file_for(child.id()); log::log_info( @@ -529,17 +499,45 @@ pub fn remove_relay_pid_file() { /// Stop a running relay-worker by sending SIGTERM to the PID from PID file. pub fn stop_relay_worker() -> bool { - if let Some(pid) = read_pid_file() { - // SAFETY: Sending SIGTERM to a known PID. - let ret = unsafe { libc::kill(pid as i32, libc::SIGTERM) }; - if ret == 0 { - log::log_info("relay", "relay_worker.stopped", &format!("pid={}", pid)); - return true; - } + if let Some(pid) = read_pid_file() + && crate::sys::process::terminate(pid) + { + log::log_info("relay", "relay_worker.stopped", &format!("pid={}", pid)); + return true; } false } +/// Stop the relay worker and block until it exits, escalating to a force-kill +/// if the graceful request does not take effect within ~5s. Removes the PID +/// file once the worker is gone. +/// +/// Unlike [`stop_relay_worker`], this *guarantees* termination. Callers with no +/// other backstop must use this: [`terminate`](crate::sys::process::terminate) +/// is best-effort and on Windows may not be delivered when the worker shares no +/// console with the caller, so a bare `stop_relay_worker` could leave the worker +/// running (the auto-exit watchdog only winds down when no local instances +/// remain). +pub fn stop_relay_worker_blocking() { + let Some(pid) = read_pid_file() else { + return; + }; + + let _ = stop_relay_worker(); + for _ in 0..50 { + if !crate::pidtrack::is_alive(pid) { + remove_pid_file(); + return; + } + std::thread::sleep(Duration::from_millis(100)); + } + + // Graceful request did not take effect in time; force-kill so a stale + // worker cannot survive a relay reset/off. + crate::sys::process::kill(pid); + remove_pid_file(); +} + #[cfg(test)] mod tests { use super::*; diff --git a/src/router.rs b/src/router.rs index f609f125..f042afa3 100644 --- a/src/router.rs +++ b/src/router.rs @@ -3,7 +3,6 @@ //! All hooks and commands are handled natively in Rust. use std::env; -use std::os::unix::process::CommandExt; use std::path::{Path, PathBuf}; use std::process::Command; @@ -398,8 +397,10 @@ pub fn maybe_reexec_dev_root() { // Re-exec: replace this process with the dev root's binary let args: Vec = env::args().collect(); - let err = Command::new(&target_binary).args(&args[1..]).exec(); - // exec() only returns on error + let mut cmd = Command::new(&target_binary); + cmd.args(&args[1..]); + let err = crate::sys::process::exec_replace(cmd); + // exec_replace only returns on error log_error( "router", "dev_root_reexec_failed", diff --git a/src/runtime_env.rs b/src/runtime_env.rs index 19cd9637..c815a4c5 100644 --- a/src/runtime_env.rs +++ b/src/runtime_env.rs @@ -3,6 +3,13 @@ /// Cached hcom invocation prefix (computed once per process lifetime). static HCOM_PREFIX: std::sync::LazyLock> = std::sync::LazyLock::new(|| { if std::env::var("HCOM_DEV_ROOT").is_ok() { + #[cfg(windows)] + if let Ok(exe) = std::env::current_exe() + && let Ok(resolved) = exe.canonicalize() + { + let resolved = crate::shared::platform::child_process_path(&resolved); + return vec![resolved.to_string_lossy().replace('\\', "/")]; + } return vec!["hcom".into()]; } @@ -49,6 +56,97 @@ pub(crate) fn gemini_family_config_dir() -> std::path::PathBuf { tool_config_root().join(".gemini") } +/// User home directory, honoring an explicit `HOME` override before falling back +/// to the platform default (`dirs::home_dir()` resolves `%USERPROFILE%` on Windows). +pub(crate) fn user_home() -> Option { + if let Ok(home) = std::env::var("HOME") + && !home.is_empty() + { + return Some(std::path::PathBuf::from(home)); + } + dirs::home_dir() +} + +/// Cross-platform user config base directory. +/// +/// Resolution order: +/// 1. `$XDG_CONFIG_HOME` (explicit override, all platforms) +/// 2. `$HOME/.config` — on every platform, including Windows and macOS +/// +/// OpenCode and Kilo resolve their config directory via the `xdg-basedir` npm +/// package, which has no Windows- or macOS-specific branch at all: it always +/// resolves to `~/.config` (falling back to `$XDG_CONFIG_HOME` when set) on +/// every OS. There is no `%APPDATA%` or `~/Library/Application Support` +/// involved, so hcom must not special-case Windows here either. +pub(crate) fn user_config_home() -> Option { + if let Ok(xdg) = std::env::var("XDG_CONFIG_HOME") + && !xdg.is_empty() + { + return Some(std::path::PathBuf::from(xdg)); + } + user_home().map(|h| h.join(".config")) +} + +/// Cross-platform data directory for an OpenCode-family tool (`opencode`/`kilo`), +/// i.e. where the tool keeps its SQLite session DB. +/// +/// Resolution order: +/// 1. `$XDG_DATA_HOME/` (explicit override, all platforms) — trusted +/// unconditionally, with no existence probe: an explicit override always +/// wins, even if the directory hasn't been created yet. +/// 2. `~/.local/share/` — on every platform, including Windows and macOS +/// +/// Like [`user_config_home`], this follows OpenCode/Kilo's use of the +/// `xdg-basedir` npm package, which always resolves to `~/.local/share` +/// (falling back to `$XDG_DATA_HOME` when set) regardless of OS. There is no +/// `%APPDATA%` or Apple-style data dir involved, so `dirs::data_dir()` is +/// never a correct candidate for this tool family on any platform. +/// +/// This is the single source of truth for opencode/kilo data-dir resolution, +/// shared by the hook dispatcher, the transcript search, and `resume`. +pub(crate) fn opencode_family_data_dir(tool: &str) -> Option { + if let Ok(xdg) = std::env::var("XDG_DATA_HOME") + && !xdg.is_empty() + { + return Some(std::path::PathBuf::from(xdg).join(tool)); + } + user_home().map(|h| h.join(".local/share").join(tool)) +} + +/// Resolve the SQLite database path for an OpenCode-family tool (`opencode`/`kilo`). +/// +/// Builds on [`opencode_family_data_dir`]. For `kilo`, honors `KILO_DB`: `:memory:` +/// means no on-disk DB (returns `None`), an absolute path is used as-is, and a +/// relative path is joined onto the data dir; if `KILO_DB` is unset, defaults to +/// `/kilo.db`. Any other tool resolves to `/opencode.db`. +/// +/// This performs path construction only — it does not check whether the +/// resulting path exists on disk. Callers that need "exists" semantics should +/// apply their own `.exists()` check. +pub(crate) fn opencode_family_db_path(tool: &str) -> Option { + let data_dir = opencode_family_data_dir(tool)?; + if tool == "kilo" { + if std::env::var("KILO_DB").as_deref() == Ok(":memory:") { + return None; + } + return Some( + std::env::var("KILO_DB") + .ok() + .filter(|value| !value.is_empty()) + .map(std::path::PathBuf::from) + .map(|path| { + if path.is_absolute() { + path + } else { + data_dir.join(path) + } + }) + .unwrap_or_else(|| data_dir.join("kilo.db")), + ); + } + Some(data_dir.join("opencode.db")) +} + /// Set terminal title via escape codes written to /dev/tty. pub(crate) fn set_terminal_title(instance_name: &str) { let title = format!("hcom: {}", instance_name); @@ -58,7 +156,26 @@ pub(crate) fn set_terminal_title(instance_name: &str) { } } +/// Escape a filesystem path for embedding in a TOML basic (double-quoted) string. +/// Backslashes are doubled first, then quotes — order matters: the quote escape +/// itself introduces a backslash that must not be re-doubled. +pub(crate) fn toml_escape_path(path: &str) -> String { + path.replace('\\', r"\\").replace('"', "\\\"") +} + #[cfg(test)] +mod escape_tests { + #[test] + fn toml_escape_path_doubles_backslashes_then_quotes() { + assert_eq!(super::toml_escape_path(r"C:\foo\bar"), r"C:\\foo\\bar"); + assert_eq!(super::toml_escape_path(r#"a"b"#), r#"a\"b"#); + assert_eq!(super::toml_escape_path(r#"C:\a"b"#), r#"C:\\a\"b"#); + } +} + +// Unix-only: these assert $HOME resolution and POSIX path canonicalization +// (Windows resolves USERPROFILE and prefixes canonical paths with \\?\). +#[cfg(all(test, unix))] mod tests { use crate::hooks::test_helpers::EnvGuard; use serial_test::serial; @@ -104,4 +221,182 @@ mod tests { std::env::set_current_dir(prev_cwd).unwrap(); assert_eq!(root, expected); } + + #[test] + #[serial] + fn user_home_prefers_home_env_when_set() { + let _guard = EnvGuard::new(); + let temp = tempfile::tempdir().unwrap(); + let home = temp.path().join("home"); + std::fs::create_dir_all(&home).unwrap(); + + unsafe { + std::env::set_var("HOME", &home); + } + + assert_eq!(super::user_home(), Some(home)); + } + + #[test] + #[serial] + fn user_home_falls_through_when_home_env_empty() { + let _guard = EnvGuard::new(); + + unsafe { + std::env::set_var("HOME", ""); + } + + // Empty HOME is treated as unset, so resolution falls through to + // dirs::home_dir() rather than returning Some(PathBuf::from("")). + assert_eq!(super::user_home(), dirs::home_dir()); + } + + #[test] + #[serial] + fn user_config_home_prefers_xdg_config_home_when_set() { + let _guard = EnvGuard::new(); + let temp = tempfile::tempdir().unwrap(); + let xdg = temp.path().join("xdg-config"); + std::fs::create_dir_all(&xdg).unwrap(); + + unsafe { + std::env::set_var("XDG_CONFIG_HOME", &xdg); + } + + assert_eq!(super::user_config_home(), Some(xdg)); + } + + #[test] + #[serial] + fn user_config_home_empty_xdg_falls_back_to_dot_config() { + let _guard = EnvGuard::new(); + let temp = tempfile::tempdir().unwrap(); + let home = temp.path().join("home"); + std::fs::create_dir_all(&home).unwrap(); + + unsafe { + std::env::set_var("HOME", &home); + std::env::set_var("XDG_CONFIG_HOME", ""); + } + + // Empty XDG_CONFIG_HOME is treated as unset, so resolution falls + // back to $HOME/.config (the unix branch, given this test's cfg gate). + assert_eq!(super::user_config_home(), Some(home.join(".config"))); + } + + /// RAII guard for XDG_DATA_HOME, which crate::hooks::test_helpers::EnvGuard + /// does not track. + struct XdgDataHomeGuard(Option); + + impl XdgDataHomeGuard { + fn set(value: &str) -> Self { + let saved = std::env::var("XDG_DATA_HOME").ok(); + unsafe { + std::env::set_var("XDG_DATA_HOME", value); + } + Self(saved) + } + } + + impl Drop for XdgDataHomeGuard { + fn drop(&mut self) { + unsafe { + match &self.0 { + Some(v) => std::env::set_var("XDG_DATA_HOME", v), + None => std::env::remove_var("XDG_DATA_HOME"), + } + } + } + } + + #[test] + #[serial] + fn opencode_family_data_dir_prefers_xdg_data_home_when_it_exists() { + let _guard = EnvGuard::new(); + let temp = tempfile::tempdir().unwrap(); + let home = temp.path().join("home"); + let xdg_data = temp.path().join("xdg-data"); + let xdg_tool_dir = xdg_data.join("opencode"); + let local_share_tool_dir = home.join(".local/share/opencode"); + std::fs::create_dir_all(&xdg_tool_dir).unwrap(); + std::fs::create_dir_all(&local_share_tool_dir).unwrap(); + + unsafe { + std::env::set_var("HOME", &home); + } + let _xdg_guard = XdgDataHomeGuard::set(xdg_data.to_str().unwrap()); + + // XDG_DATA_HOME wins even though ~/.local/share/opencode also exists. + assert_eq!( + super::opencode_family_data_dir("opencode"), + Some(xdg_tool_dir) + ); + } + + #[test] + #[serial] + fn opencode_family_data_dir_trusts_nonexistent_xdg_candidate() { + let _guard = EnvGuard::new(); + let temp = tempfile::tempdir().unwrap(); + let home = temp.path().join("home"); + let local_share_tool_dir = home.join(".local/share/opencode"); + std::fs::create_dir_all(&local_share_tool_dir).unwrap(); + + unsafe { + std::env::set_var("HOME", &home); + } + // XDG_DATA_HOME is explicitly set but its opencode subdir does not + // exist on disk yet. An explicit override must win unconditionally + // rather than falling back to a stale ~/.local/share/opencode. + let xdg_data_missing = temp.path().join("xdg-data-missing"); + let _xdg_guard = XdgDataHomeGuard::set(xdg_data_missing.to_str().unwrap()); + + assert_eq!( + super::opencode_family_data_dir("opencode"), + Some(xdg_data_missing.join("opencode")) + ); + } + + #[test] + #[serial] + fn opencode_family_data_dir_empty_xdg_falls_back_to_local_share() { + let _guard = EnvGuard::new(); + let temp = tempfile::tempdir().unwrap(); + let home = temp.path().join("home"); + let local_share_tool_dir = home.join(".local/share/opencode"); + std::fs::create_dir_all(&local_share_tool_dir).unwrap(); + + unsafe { + std::env::set_var("HOME", &home); + } + let _xdg_guard = XdgDataHomeGuard::set(""); + + // Empty XDG_DATA_HOME is treated as unset (no candidate added for it). + assert_eq!( + super::opencode_family_data_dir("opencode"), + Some(local_share_tool_dir) + ); + } + + #[test] + #[serial] + fn opencode_family_data_dir_returns_local_share_even_when_it_does_not_exist() { + let _guard = EnvGuard::new(); + let temp = tempfile::tempdir().unwrap(); + let home = temp.path().join("home"); + std::fs::create_dir_all(&home).unwrap(); + + unsafe { + std::env::set_var("HOME", &home); + } + let _xdg_guard = XdgDataHomeGuard::set(""); + + // ~/.local/share/opencode doesn't exist under this isolated HOME, but + // it's still the only non-override candidate (OpenCode/Kilo never use + // dirs::data_dir() on any platform), so it's returned unconditionally. + assert_eq!( + super::opencode_family_data_dir("opencode"), + Some(home.join(".local/share/opencode")) + ); + } } diff --git a/src/shared/platform.rs b/src/shared/platform.rs index 9ea24553..b6648698 100644 --- a/src/shared/platform.rs +++ b/src/shared/platform.rs @@ -4,6 +4,22 @@ use std::path::{Path, PathBuf}; use std::sync::LazyLock; +/// Convert Windows verbatim paths returned by `canonicalize` back to forms +/// accepted as a working directory by `cmd.exe` and `.cmd` shims. +pub fn child_process_path(path: &Path) -> PathBuf { + #[cfg(windows)] + { + let value = path.to_string_lossy(); + if let Some(unc) = value.strip_prefix(r"\\?\UNC\") { + return PathBuf::from(format!(r"\\{unc}")); + } + if let Some(drive_path) = value.strip_prefix(r"\\?\") { + return PathBuf::from(drive_path); + } + } + path.to_path_buf() +} + /// Cached WSL detection result. static IS_WSL: LazyLock = LazyLock::new(detect_wsl); @@ -78,6 +94,24 @@ pub fn shorten_path(path: &str) -> String { path.to_string() } +#[cfg(all(test, windows))] +mod child_process_path_tests { + use super::child_process_path; + use std::path::Path; + + #[test] + fn child_process_path_removes_windows_verbatim_prefixes() { + assert_eq!( + child_process_path(Path::new(r"\\?\C:\work\repo")), + Path::new(r"C:\work\repo") + ); + assert_eq!( + child_process_path(Path::new(r"\\?\UNC\server\share\repo")), + Path::new(r"\\server\share\repo") + ); + } +} + /// Shorten path and truncate to max_width, keeping the trailing portion visible. /// /// Example: "/very/long/path/to/some/project" with max 30 → ".../path/to/some/project" @@ -179,8 +213,9 @@ fn cargo_target_dir(dev_root: &Path) -> PathBuf { /// neither binary exists. pub fn dev_root_binary(dev_root: &Path) -> Option { let target_dir = cargo_target_dir(dev_root); - let release = target_dir.join("release/hcom"); - let debug = target_dir.join("debug/hcom"); + let binary = format!("hcom{}", std::env::consts::EXE_SUFFIX); + let release = target_dir.join("release").join(&binary); + let debug = target_dir.join("debug").join(&binary); let mtime = |p: &Path| std::fs::metadata(p).ok().and_then(|m| m.modified().ok()); @@ -278,7 +313,10 @@ mod tests { fn test_dev_root_binary_uses_default_target_dir() { let _target_guard = CargoTargetDirGuard::new(); let dir = TempDir::new().unwrap(); - let release = dir.path().join("target/release/hcom"); + let release = dir + .path() + .join("target/release") + .join(format!("hcom{}", std::env::consts::EXE_SUFFIX)); touch_binary(&release); unsafe { @@ -294,7 +332,9 @@ mod tests { let _target_guard = CargoTargetDirGuard::new(); let dir = TempDir::new().unwrap(); let custom_target = dir.path().join(".cargo-target"); - let debug = custom_target.join("debug/hcom"); + let debug = custom_target + .join("debug") + .join(format!("hcom{}", std::env::consts::EXE_SUFFIX)); touch_binary(&debug); unsafe { @@ -311,7 +351,9 @@ mod tests { let dir = TempDir::new().unwrap(); let cargo_dir = dir.path().join(".cargo"); let custom_target = dir.path().join(".hcom-build"); - let release = custom_target.join("release/hcom"); + let release = custom_target + .join("release") + .join(format!("hcom{}", std::env::consts::EXE_SUFFIX)); touch_binary(&release); std::fs::create_dir_all(&cargo_dir).unwrap(); std::fs::write( diff --git a/src/shared/terminal_presets.rs b/src/shared/terminal_presets.rs index ca576466..22753b93 100644 --- a/src/shared/terminal_presets.rs +++ b/src/shared/terminal_presets.rs @@ -2,28 +2,83 @@ use std::sync::LazyLock; +/// An argument-vector template: `argv[0]` is the executable, the rest are +/// argument templates (one element per argument; no shell splitting). Each +/// element may contain placeholders like `{script}` or `{pane_id}` that are +/// substituted per-element at launch time. +pub type ArgvTemplate = &'static [&'static str]; + +/// A per-platform argv template. `default` covers Unix (Darwin + Linux) and any +/// platform without a specific override; `windows` is a Windows-only override +/// (e.g. launching the generated `.ps1` directly via PowerShell). When both are +/// `None`, the API (open or close) is unavailable. +#[derive(Debug, Clone, Copy)] +pub struct PlatformArgv { + pub default: Option, + pub windows: Option, +} + +impl PlatformArgv { + /// Select the argv template for the given platform. Windows falls back to + /// `default` when no Windows-specific override is present. + pub const fn select(&self, is_windows: bool) -> Option { + if is_windows { + if self.windows.is_some() { + self.windows + } else { + self.default + } + } else { + self.default + } + } +} + /// Terminal preset configuration. -#[derive(Debug, Clone)] +#[derive(Debug, Clone, Copy)] pub struct TerminalPreset { /// Binary to check for availability (None = check app bundle). pub binary: Option<&'static str>, /// App name for macOS bundle detection (e.g., "kitty", "WezTerm"). pub app_name: Option<&'static str>, - /// Command template with {script} placeholder. - pub open: &'static str, - /// Close command template with {pane_id} placeholder (None = no close API). - pub close: Option<&'static str>, + /// Open command argv template (per-platform), with a `{script}` placeholder. + pub open: PlatformArgv, + /// Close command argv template (per-platform), with a `{pane_id}` placeholder + /// (both slots None = no close API). + pub close: PlatformArgv, /// Env var that contains the pane ID. pub pane_id_env: Option<&'static str>, /// Supported platforms. pub platforms: &'static [&'static str], } +/// An argv template available on all platforms (Windows reuses the default). +const fn argv(default: ArgvTemplate) -> PlatformArgv { + PlatformArgv { + default: Some(default), + windows: None, + } +} + +/// An argv template with a distinct Windows variant. +const fn argv_win(default: ArgvTemplate, windows: ArgvTemplate) -> PlatformArgv { + PlatformArgv { + default: Some(default), + windows: Some(windows), + } +} + +/// No open/close API on any platform. +const NONE_ARGV: PlatformArgv = PlatformArgv { + default: None, + windows: None, +}; + const fn p( binary: Option<&'static str>, app_name: Option<&'static str>, - open: &'static str, - close: Option<&'static str>, + open: PlatformArgv, + close: PlatformArgv, pane_id_env: Option<&'static str>, platforms: &'static [&'static str], ) -> TerminalPreset { @@ -48,8 +103,8 @@ pub static TERMINAL_PRESETS: LazyLock> = Laz p( None, None, - "open -a Terminal {script}", - None, + argv(&["open", "-a", "Terminal", "{script}"]), + NONE_ARGV, None, &["Darwin"], ), @@ -59,8 +114,8 @@ pub static TERMINAL_PRESETS: LazyLock> = Laz p( None, None, - "open -a iTerm {script}", - None, + argv(&["open", "-a", "iTerm", "{script}"]), + NONE_ARGV, None, &["Darwin"], ), @@ -70,8 +125,16 @@ pub static TERMINAL_PRESETS: LazyLock> = Laz p( None, None, - "open -na Ghostty.app --args -e bash {script}", - None, + argv(&[ + "open", + "-na", + "Ghostty.app", + "--args", + "-e", + "bash", + "{script}", + ]), + NONE_ARGV, None, &["Darwin"], ), @@ -81,8 +144,10 @@ pub static TERMINAL_PRESETS: LazyLock> = Laz p( Some("cmux"), Some("cmux"), - "cmux new-workspace --command 'bash {script}'", - Some("cmux close-workspace --workspace {pane_id}"), + // `bash {script}` is a single argv element on purpose — cmux + // re-parses its `--command` value internally. + argv(&["cmux", "new-workspace", "--command", "bash {script}"]), + argv(&["cmux", "close-workspace", "--workspace", "{pane_id}"]), Some("CMUX_WORKSPACE_ID"), &["Darwin"], ), @@ -93,8 +158,8 @@ pub static TERMINAL_PRESETS: LazyLock> = Laz p( Some("kitty"), Some("kitty"), - "kitty --env HCOM_PROCESS_ID={process_id} {script}", - Some("kitten @ close-window --match id:{pane_id}"), + argv(&["kitty", "--env", "HCOM_PROCESS_ID={process_id}", "{script}"]), + argv(&["kitten", "@", "close-window", "--match", "id:{pane_id}"]), None, DL, ), @@ -104,8 +169,8 @@ pub static TERMINAL_PRESETS: LazyLock> = Laz p( Some("kitty"), Some("kitty"), - "kitty --env HCOM_PROCESS_ID={process_id} {script}", - Some("kitten @ close-window --match id:{pane_id}"), + argv(&["kitty", "--env", "HCOM_PROCESS_ID={process_id}", "{script}"]), + argv(&["kitten", "@", "close-window", "--match", "id:{pane_id}"]), None, DL, ), @@ -115,8 +180,21 @@ pub static TERMINAL_PRESETS: LazyLock> = Laz p( Some("wezterm"), Some("WezTerm"), - "wezterm start -- bash {script}", - Some("wezterm cli kill-pane --pane-id {pane_id}"), + argv_win( + &["wezterm", "start", "--", "bash", "{script}"], + &[ + "wezterm", + "start", + "--", + "powershell", + "-ExecutionPolicy", + "Bypass", + "-NoExit", + "-File", + "{script}", + ], + ), + argv(&["wezterm", "cli", "kill-pane", "--pane-id", "{pane_id}"]), Some("WEZTERM_PANE"), DLW, ), @@ -126,8 +204,21 @@ pub static TERMINAL_PRESETS: LazyLock> = Laz p( Some("wezterm"), Some("WezTerm"), - "wezterm start -- bash {script}", - Some("wezterm cli kill-pane --pane-id {pane_id}"), + argv_win( + &["wezterm", "start", "--", "bash", "{script}"], + &[ + "wezterm", + "start", + "--", + "powershell", + "-ExecutionPolicy", + "Bypass", + "-NoExit", + "-File", + "{script}", + ], + ), + argv(&["wezterm", "cli", "kill-pane", "--pane-id", "{pane_id}"]), Some("WEZTERM_PANE"), DLW, ), @@ -137,8 +228,20 @@ pub static TERMINAL_PRESETS: LazyLock> = Laz p( Some("alacritty"), Some("Alacritty"), - "alacritty -e bash {script}", - None, + argv_win( + &["alacritty", "-e", "bash", "{script}"], + &[ + "alacritty", + "-e", + "powershell", + "-ExecutionPolicy", + "Bypass", + "-NoExit", + "-File", + "{script}", + ], + ), + NONE_ARGV, None, DLW, ), @@ -148,8 +251,8 @@ pub static TERMINAL_PRESETS: LazyLock> = Laz p( None, Some("Warp"), - "open warp://launch/hcom-{process_id}", - None, + argv(&["open", "warp://launch/hcom-{process_id}"]), + NONE_ARGV, None, &["Darwin"], ), @@ -157,15 +260,22 @@ pub static TERMINAL_PRESETS: LazyLock> = Laz // Tab utilities ( "ttab", - p(Some("ttab"), None, "ttab {script}", None, None, &["Darwin"]), + p( + Some("ttab"), + None, + argv(&["ttab", "{script}"]), + NONE_ARGV, + None, + &["Darwin"], + ), ), ( "wttab", p( Some("wttab"), None, - "wttab {script}", - None, + argv(&["wttab", "{script}"]), + NONE_ARGV, None, &["Windows"], ), @@ -176,8 +286,8 @@ pub static TERMINAL_PRESETS: LazyLock> = Laz p( Some("gnome-terminal"), None, - "gnome-terminal --window -- bash {script}", - None, + argv(&["gnome-terminal", "--window", "--", "bash", "{script}"]), + NONE_ARGV, None, &["Linux"], ), @@ -187,8 +297,8 @@ pub static TERMINAL_PRESETS: LazyLock> = Laz p( Some("konsole"), None, - "konsole -e bash {script}", - None, + argv(&["konsole", "-e", "bash", "{script}"]), + NONE_ARGV, None, &["Linux"], ), @@ -198,8 +308,8 @@ pub static TERMINAL_PRESETS: LazyLock> = Laz p( Some("xterm"), None, - "xterm -e bash {script}", - None, + argv(&["xterm", "-e", "bash", "{script}"]), + NONE_ARGV, None, &["Linux"], ), @@ -209,8 +319,8 @@ pub static TERMINAL_PRESETS: LazyLock> = Laz p( Some("tilix"), None, - "tilix -e bash {script}", - None, + argv(&["tilix", "-e", "bash", "{script}"]), + NONE_ARGV, None, &["Linux"], ), @@ -220,8 +330,8 @@ pub static TERMINAL_PRESETS: LazyLock> = Laz p( Some("terminator"), None, - "terminator -x bash {script}", - None, + argv(&["terminator", "-x", "bash", "{script}"]), + NONE_ARGV, None, &["Linux"], ), @@ -231,10 +341,24 @@ pub static TERMINAL_PRESETS: LazyLock> = Laz p( Some("zellij"), None, - "zellij action new-pane -- bash {script}", - Some("zellij action close-pane --pane-id {pane_id}"), + argv_win( + &["zellij", "action", "new-pane", "--", "bash", "{script}"], + &[ + "zellij", + "action", + "new-pane", + "--", + "powershell", + "-ExecutionPolicy", + "Bypass", + "-NoExit", + "-File", + "{script}", + ], + ), + argv(&["zellij", "action", "close-pane", "--pane-id", "{pane_id}"]), Some("ZELLIJ_PANE_ID"), - DL, + DLW, ), ), ( @@ -242,8 +366,8 @@ pub static TERMINAL_PRESETS: LazyLock> = Laz p( Some("wsh"), None, - "wsh run -- bash {script}", - Some("wsh deleteblock -b {pane_id}"), + argv(&["wsh", "run", "--", "bash", "{script}"]), + argv(&["wsh", "deleteblock", "-b", "{pane_id}"]), Some("WAVETERM_BLOCKID"), DL, ), @@ -254,8 +378,17 @@ pub static TERMINAL_PRESETS: LazyLock> = Laz p( Some("wt"), None, - "wt -- bash {script}", - None, + argv(&[ + "wt", + "--", + "powershell", + "-ExecutionPolicy", + "Bypass", + "-NoExit", + "-File", + "{script}", + ]), + NONE_ARGV, None, &["Windows"], ), @@ -265,8 +398,18 @@ pub static TERMINAL_PRESETS: LazyLock> = Laz p( Some("mintty"), None, - "mintty bash {script}", - None, + // Run the generated PowerShell script directly — never hand a + // `.ps1` to bash (the old `mintty bash {script}` bug). + argv(&[ + "mintty", + "powershell", + "-ExecutionPolicy", + "Bypass", + "-NoExit", + "-File", + "{script}", + ]), + NONE_ARGV, None, &["Windows"], ), @@ -277,8 +420,8 @@ pub static TERMINAL_PRESETS: LazyLock> = Laz p( Some("tmux"), None, - "tmux new-session -d bash {script}", - Some("tmux kill-pane -t {pane_id}"), + argv(&["tmux", "new-session", "-d", "bash", "{script}"]), + argv(&["tmux", "kill-pane", "-t", "{pane_id}"]), Some("TMUX_PANE"), DL, ), @@ -288,8 +431,8 @@ pub static TERMINAL_PRESETS: LazyLock> = Laz p( Some("tmux"), None, - "tmux split-window -h {script}", - Some("tmux kill-pane -t {pane_id}"), + argv(&["tmux", "split-window", "-h", "{script}"]), + argv(&["tmux", "kill-pane", "-t", "{pane_id}"]), Some("TMUX_PANE"), DL, ), @@ -299,8 +442,22 @@ pub static TERMINAL_PRESETS: LazyLock> = Laz p( Some("wezterm"), Some("WezTerm"), - "wezterm cli spawn -- bash {script}", - Some("wezterm cli kill-pane --pane-id {pane_id}"), + argv_win( + &["wezterm", "cli", "spawn", "--", "bash", "{script}"], + &[ + "wezterm", + "cli", + "spawn", + "--", + "powershell", + "-ExecutionPolicy", + "Bypass", + "-NoExit", + "-File", + "{script}", + ], + ), + argv(&["wezterm", "cli", "kill-pane", "--pane-id", "{pane_id}"]), Some("WEZTERM_PANE"), DLW, ), @@ -310,8 +467,33 @@ pub static TERMINAL_PRESETS: LazyLock> = Laz p( Some("wezterm"), Some("WezTerm"), - "wezterm cli split-pane --top-level --right -- bash {script}", - Some("wezterm cli kill-pane --pane-id {pane_id}"), + argv_win( + &[ + "wezterm", + "cli", + "split-pane", + "--top-level", + "--right", + "--", + "bash", + "{script}", + ], + &[ + "wezterm", + "cli", + "split-pane", + "--top-level", + "--right", + "--", + "powershell", + "-ExecutionPolicy", + "Bypass", + "-NoExit", + "-File", + "{script}", + ], + ), + argv(&["wezterm", "cli", "kill-pane", "--pane-id", "{pane_id}"]), Some("WEZTERM_PANE"), DLW, ), @@ -321,8 +503,18 @@ pub static TERMINAL_PRESETS: LazyLock> = Laz p( Some("kitten"), Some("kitty"), - "kitten @ launch --type=tab --env HCOM_PROCESS_ID={process_id} -- bash {script}", - Some("kitten @ close-window --match id:{pane_id}"), + argv(&[ + "kitten", + "@", + "launch", + "--type=tab", + "--env", + "HCOM_PROCESS_ID={process_id}", + "--", + "bash", + "{script}", + ]), + argv(&["kitten", "@", "close-window", "--match", "id:{pane_id}"]), None, DL, ), @@ -332,8 +524,18 @@ pub static TERMINAL_PRESETS: LazyLock> = Laz p( Some("kitten"), Some("kitty"), - "kitten @ launch --type=window --env HCOM_PROCESS_ID={process_id} -- bash {script}", - Some("kitten @ close-window --match id:{pane_id}"), + argv(&[ + "kitten", + "@", + "launch", + "--type=window", + "--env", + "HCOM_PROCESS_ID={process_id}", + "--", + "bash", + "{script}", + ]), + argv(&["kitten", "@", "close-window", "--match", "id:{pane_id}"]), None, DL, ), @@ -348,8 +550,19 @@ pub static TERMINAL_PRESETS: LazyLock> = Laz p( Some("herdr"), None, - "herdr agent start {instance_name} --cwd {cwd} --no-focus -- bash {script}", - Some("herdr pane close {pane_id}"), + argv(&[ + "herdr", + "agent", + "start", + "{instance_name}", + "--cwd", + "{cwd}", + "--no-focus", + "--", + "bash", + "{script}", + ]), + argv(&["herdr", "pane", "close", "{pane_id}"]), Some("HERDR_PANE_ID"), DL, ), @@ -357,6 +570,24 @@ pub static TERMINAL_PRESETS: LazyLock> = Laz ] }); +#[cfg(test)] +mod windows_zellij_tests { + use super::TERMINAL_PRESETS; + + #[test] + fn zellij_has_a_windows_powershell_open_command() { + let preset = TERMINAL_PRESETS + .iter() + .find(|(name, _)| *name == "zellij") + .map(|(_, preset)| preset) + .unwrap(); + assert!(preset.platforms.contains(&"Windows")); + let argv = preset.open.select(true).unwrap(); + assert!(argv.contains(&"powershell")); + assert!(argv.contains(&"{script}")); + } +} + /// Look up a terminal preset by name (case-sensitive). pub fn get_terminal_preset(name: &str) -> Option<&TerminalPreset> { TERMINAL_PRESETS @@ -402,7 +633,7 @@ mod tests { fn test_terminal_preset_lookup() { let preset = get_terminal_preset("kitty").unwrap(); assert_eq!(preset.binary, Some("kitty")); - assert!(preset.close.is_some()); + assert!(preset.close.select(false).is_some()); assert!(get_terminal_preset("nonexistent").is_none()); } @@ -411,8 +642,41 @@ mod tests { fn test_kitty_tab_close_matches_window_id() { let preset = get_terminal_preset("kitty-tab").unwrap(); assert_eq!( - preset.close, - Some("kitten @ close-window --match id:{pane_id}") + preset.close.select(false), + Some(&["kitten", "@", "close-window", "--match", "id:{pane_id}"] as ArgvTemplate) + ); + } + + #[test] + fn test_platform_argv_select_falls_back_to_default_when_no_windows() { + let pa = argv(&["foo", "bar"]); + assert_eq!(pa.select(false), Some(&["foo", "bar"] as ArgvTemplate)); + assert_eq!(pa.select(true), Some(&["foo", "bar"] as ArgvTemplate)); + } + + #[test] + fn test_wezterm_open_selects_powershell_variant_on_windows() { + let preset = get_terminal_preset("wezterm").unwrap(); + let unix = preset.open.select(false).unwrap(); + let win = preset.open.select(true).unwrap(); + // Unix runs bash; Windows runs the .ps1 via PowerShell. + assert!(unix.contains(&"bash")); + assert!(!unix.contains(&"powershell")); + assert!(win.contains(&"powershell")); + assert!(!win.contains(&"bash")); + assert!(win.contains(&"-File")); + } + + #[test] + fn test_mintty_open_contains_no_bash() { + let preset = get_terminal_preset("mintty").unwrap(); + // mintty is Windows-only; the selected argv must avoid bash. + let win = preset.open.select(true).unwrap(); + assert!( + !win.contains(&"bash"), + "mintty open must not hand a .ps1 to bash" ); + assert!(win.contains(&"powershell")); + assert_eq!(win.first(), Some(&"mintty")); } } diff --git a/src/shell_env.rs b/src/shell_env.rs index bf137fdf..90e348b0 100644 --- a/src/shell_env.rs +++ b/src/shell_env.rs @@ -7,7 +7,6 @@ use std::collections::HashMap; use std::fs; use std::io::Read; -use std::os::unix::fs::PermissionsExt; use std::path::{Path, PathBuf}; use std::process::{Command, Stdio}; use std::time::{Duration, SystemTime, UNIX_EPOCH}; @@ -53,6 +52,13 @@ pub fn resolved_shell_env() -> Option> { } fn resolve_shell_env_uncached() -> Option> { + // Windows has no login-shell PATH-stripping problem to work around (unlike + // macOS GUI-launched apps), and `shell_path()`'s non-macOS fallback + // (`/bin/bash`) never exists there — skip straight to the `None` fail-open + // instead of spawning a command that's guaranteed to fail. + if cfg!(windows) { + return None; + } let shell = shell_path()?; if unsupported_shell(&shell) { return None; @@ -115,14 +121,14 @@ fn timed_shell_output_with_timeout( } Ok(None) => { if start.elapsed() >= timeout { - kill_shell_process_group(&mut child); + crate::sys::process::kill_child_group(&mut child); let _ = child.wait(); return None; } std::thread::sleep(Duration::from_millis(25)); } Err(_) => { - kill_shell_process_group(&mut child); + crate::sys::process::kill_child_group(&mut child); let _ = child.wait(); return None; } @@ -142,39 +148,11 @@ fn shell_command(shell: &Path, cmd: &str, marker: &str) -> Command { .stdout(Stdio::piped()) .stderr(Stdio::null()); - #[cfg(unix)] - unsafe { - use std::os::unix::process::CommandExt; - command.pre_exec(|| { - if libc::setsid() == -1 { - Err(std::io::Error::last_os_error()) - } else { - Ok(()) - } - }); - } + crate::sys::process::detach_session(&mut command); command } -fn kill_shell_process_group(child: &mut std::process::Child) { - #[cfg(unix)] - { - use nix::sys::signal::{Signal, killpg}; - use nix::unistd::Pid; - - let Ok(raw_pid) = i32::try_from(child.id()) else { - let _ = child.kill(); - return; - }; - if killpg(Pid::from_raw(raw_pid), Signal::SIGKILL).is_ok() { - return; - } - } - - let _ = child.kill(); -} - #[derive(Debug, Clone, Serialize, Deserialize)] struct ShellEnvCache { #[serde(default)] @@ -196,9 +174,9 @@ fn write_cache(path: &Path, entry: &ShellEnvCache) -> std::io::Result<()> { let content = serde_json::to_vec(entry).map_err(std::io::Error::other)?; let tmp = tempfile::NamedTempFile::new_in(path.parent().unwrap_or_else(|| Path::new(".")))?; fs::write(tmp.path(), content)?; - fs::set_permissions(tmp.path(), fs::Permissions::from_mode(0o600))?; + crate::sys::fs::set_private(tmp.path())?; tmp.persist(path).map_err(std::io::Error::other)?; - fs::set_permissions(path, fs::Permissions::from_mode(0o600))?; + crate::sys::fs::set_private(path)?; Ok(()) } @@ -360,8 +338,10 @@ mod tests { assert!(!cache_is_fresh(&entry, 10, 101 + CACHE_TTL.as_secs())); } + #[cfg(unix)] #[test] fn cache_write_uses_private_permissions() { + use std::os::unix::fs::PermissionsExt; let dir = tempfile::tempdir().unwrap(); let path = dir.path().join("shell_env.json"); let entry = ShellEnvCache { @@ -399,6 +379,9 @@ mod tests { assert_ne!(child_session, current_session); } + // Unix-only: drives a POSIX shell (`env -0`, sh loops) that isn't resolved + // on Windows. + #[cfg(unix)] #[test] fn resolver_discards_stderr_without_breaking_env_resolution() { let shell = test_shell_path(); @@ -448,6 +431,7 @@ mod tests { assert!(wait_for_process_exit(shell_pid)); } + #[cfg(unix)] fn test_shell_path() -> PathBuf { ["/bin/sh", "/usr/bin/sh"] .into_iter() diff --git a/src/sys/fs.rs b/src/sys/fs.rs new file mode 100644 index 00000000..8cd78c7b --- /dev/null +++ b/src/sys/fs.rs @@ -0,0 +1,160 @@ +//! Filesystem primitives that differ across platforms: Unix permission bits and +//! stable on-disk file identity. + +use std::fs::File; +use std::io; +use std::path::Path; + +/// Create a new file restricted to the owner (`0o600` on Unix), failing if it +/// already exists. On Windows the file is created with default ACLs (no Unix +/// mode); profile-local files are already private. This is a no-op gap if +/// `HCOM_DIR`/`HOME` is redirected to a location shared with other accounts — +/// secrets written there won't be owner-restricted on Windows. Real ACL +/// restriction (`SetNamedSecurityInfo`) is deferred to a later batch; this is +/// a deliberate, tracked gap, not an oversight. +pub fn create_private_new(path: &Path) -> io::Result { + let mut opts = std::fs::OpenOptions::new(); + opts.write(true).create_new(true); + #[cfg(unix)] + { + use std::os::unix::fs::OpenOptionsExt; + opts.mode(0o600); + } + opts.open(path) +} + +/// Acquire a blocking exclusive lock on an open file. The lock is released when +/// the file handle is closed (dropped). +/// +/// Unix: `flock(LOCK_EX)`, retrying on `EINTR`. Windows: `LockFileEx` with +/// `LOCKFILE_EXCLUSIVE_LOCK` over the whole file. +pub fn lock_exclusive(file: &File) -> io::Result<()> { + #[cfg(unix)] + { + use std::os::unix::io::AsRawFd; + loop { + // SAFETY: flock on a valid fd; return value is checked. + let ret = unsafe { libc::flock(file.as_raw_fd(), libc::LOCK_EX) }; + if ret == 0 { + return Ok(()); + } + let err = io::Error::last_os_error(); + if err.kind() != io::ErrorKind::Interrupted { + return Err(err); + } + } + } + #[cfg(windows)] + { + use std::os::windows::io::AsRawHandle; + use windows_sys::Win32::Foundation::HANDLE; + use windows_sys::Win32::Storage::FileSystem::{LOCKFILE_EXCLUSIVE_LOCK, LockFileEx}; + use windows_sys::Win32::System::IO::OVERLAPPED; + + let mut overlapped: OVERLAPPED = unsafe { std::mem::zeroed() }; + // SAFETY: valid handle for the file's lifetime; whole-file range. + let ok = unsafe { + LockFileEx( + file.as_raw_handle() as HANDLE, + LOCKFILE_EXCLUSIVE_LOCK, + 0, + u32::MAX, + u32::MAX, + &mut overlapped, + ) + }; + if ok == 0 { + return Err(io::Error::last_os_error()); + } + Ok(()) + } +} + +/// Whether `path` is a Unix-domain socket. Always false on Windows, which has +/// no filesystem socket node type. +pub fn is_socket(path: &Path) -> bool { + #[cfg(unix)] + { + use std::os::unix::fs::FileTypeExt; + std::fs::metadata(path) + .map(|m| m.file_type().is_socket()) + .unwrap_or(false) + } + #[cfg(not(unix))] + { + let _ = path; + false + } +} + +/// Restrict a file to owner-only read/write (`0o600` on Unix). +/// +/// No-op on Windows, where Unix mode bits do not apply and files created under +/// the user's profile are already private by default. Same caveat as +/// `create_private_new`: a shared `HCOM_DIR`/`HOME` location isn't actually +/// locked down on Windows until real ACL restriction is implemented. +pub fn set_private(path: &Path) -> io::Result<()> { + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o600)) + } + #[cfg(not(unix))] + { + let _ = path; + Ok(()) + } +} + +/// Mark a file as executable (`0o755` on Unix). +/// +/// No-op on Windows, where executability is determined by file extension rather +/// than a mode bit. +pub fn set_executable(path: &Path) -> io::Result<()> { + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o755)) + } + #[cfg(not(unix))] + { + let _ = path; + Ok(()) + } +} + +/// Stable identity of a file on disk, used to detect replacement (atomic +/// rename/swap) of a path that keeps the same name. +/// +/// Unix: the inode number. Windows: the `nFileIndex` from +/// `GetFileInformationByHandle`. Returns 0 when the file cannot be inspected. +pub fn file_id(path: &Path) -> u64 { + #[cfg(unix)] + { + use std::os::unix::fs::MetadataExt; + std::fs::metadata(path).map(|m| m.ino()).unwrap_or(0) + } + #[cfg(windows)] + { + file_id_win(path).unwrap_or(0) + } +} + +#[cfg(windows)] +fn file_id_win(path: &Path) -> Option { + use std::os::windows::io::AsRawHandle; + use windows_sys::Win32::Foundation::HANDLE; + use windows_sys::Win32::Storage::FileSystem::{ + BY_HANDLE_FILE_INFORMATION, GetFileInformationByHandle, + }; + + let file = std::fs::File::open(path).ok()?; + let mut info: BY_HANDLE_FILE_INFORMATION = unsafe { std::mem::zeroed() }; + // SAFETY: `file` owns a valid handle for the duration of the call and + // `info` is a properly sized output buffer. + let ok = unsafe { GetFileInformationByHandle(file.as_raw_handle() as HANDLE, &mut info) }; + if ok == 0 { + return None; + } + Some(((info.nFileIndexHigh as u64) << 32) | info.nFileIndexLow as u64) +} diff --git a/src/sys/io.rs b/src/sys/io.rs new file mode 100644 index 00000000..dbf49a0d --- /dev/null +++ b/src/sys/io.rs @@ -0,0 +1,29 @@ +//! Low-level stdio inspection used for orphan detection. + +/// Whether this process's stdin appears broken/invalid (a heuristic for +/// detecting that the launching parent is gone). +/// +/// Unix: a non-blocking `poll` of fd 0 that reports `POLLERR`/`POLLNVAL` (but +/// *not* `POLLHUP`, which is the normal end of a piped payload). Windows: always +/// false — pipe close semantics differ and orphan detection there relies on +/// other signals. +pub fn stdin_appears_broken() -> bool { + #[cfg(unix)] + { + let mut pfd = libc::pollfd { + fd: 0, + events: libc::POLLIN, + revents: 0, + }; + // SAFETY: single valid pollfd, nfds=1, timeout=0 (non-blocking). + let ret = unsafe { libc::poll(&mut pfd as *mut _, 1, 0) }; + if ret < 0 { + return true; + } + (pfd.revents & (libc::POLLERR | libc::POLLNVAL)) != 0 + } + #[cfg(not(unix))] + { + false + } +} diff --git a/src/sys/mod.rs b/src/sys/mod.rs new file mode 100644 index 00000000..f34a1438 --- /dev/null +++ b/src/sys/mod.rs @@ -0,0 +1,14 @@ +//! Platform-abstraction layer. +//! +//! Every OS-specific primitive the rest of hcom needs lives behind this module +//! so call sites never touch `nix`, `libc`, `std::os::unix`, or Windows APIs +//! directly. Each capability is a thin, platform-neutral function API; the +//! per-OS implementation is selected inline with `#[cfg]`. This turns the +//! historically scattered `#[cfg]` blocks into a single, auditable boundary and +//! keeps Unix and Windows behavior side by side. + +pub mod fs; +pub mod io; +pub mod net; +pub mod process; +pub mod signal; diff --git a/src/sys/net.rs b/src/sys/net.rs new file mode 100644 index 00000000..7e53d7c9 --- /dev/null +++ b/src/sys/net.rs @@ -0,0 +1,41 @@ +//! Socket readiness waiting, used by the notify servers to block until a +//! wake-up connection arrives instead of busy-polling. + +use std::net::TcpListener; +use std::time::Duration; + +/// Block until `listener` has an incoming connection ready to accept, or +/// `timeout` elapses. Returns true if readable, false on timeout. +/// +/// Unix: `poll(POLLIN)`. Windows: `select` over the socket. +pub fn wait_readable(listener: &TcpListener, timeout: Duration) -> bool { + #[cfg(unix)] + { + use std::os::unix::io::AsRawFd; + let timeout_ms = timeout.as_millis().min(i32::MAX as u128) as i32; + let mut pfd = libc::pollfd { + fd: listener.as_raw_fd(), + events: libc::POLLIN, + revents: 0, + }; + // SAFETY: single valid pollfd, nfds=1, bounded timeout. + let ret = unsafe { libc::poll(&mut pfd as *mut _, 1, timeout_ms) }; + ret > 0 + } + #[cfg(windows)] + { + use std::os::windows::io::AsRawSocket; + use windows_sys::Win32::Networking::WinSock::{FD_SET, SOCKET, TIMEVAL, select}; + + let mut set: FD_SET = unsafe { std::mem::zeroed() }; + set.fd_count = 1; + set.fd_array[0] = listener.as_raw_socket() as SOCKET; + let tv = TIMEVAL { + tv_sec: timeout.as_secs().min(i32::MAX as u64) as i32, + tv_usec: timeout.subsec_micros() as i32, + }; + // SAFETY: read set holds one valid socket; nfds is ignored on Windows. + let ret = unsafe { select(0, &mut set, std::ptr::null_mut(), std::ptr::null_mut(), &tv) }; + ret > 0 + } +} diff --git a/src/sys/process.rs b/src/sys/process.rs new file mode 100644 index 00000000..863c957e --- /dev/null +++ b/src/sys/process.rs @@ -0,0 +1,596 @@ +//! Process-control primitives: liveness, termination, and session/group setup. +//! +//! Unix uses `libc`/`nix` signals and `setsid`; Windows uses the Win32 process +//! and job-object APIs. See the module-level docs in [`crate::sys`]. + +use std::process::Command; + +/// Whether a process with the given PID is currently alive. +/// +/// Unix: `kill(pid, 0)`, treating `EPERM` (the process exists but is owned by +/// another user) as alive. Windows: `OpenProcess` followed by +/// `GetExitCodeProcess` — a live handle alone isn't proof of life, since the +/// process object stays valid as long as any handle (ours, or a job object's) +/// is open even after the process has exited. A failure to open with +/// `ERROR_ACCESS_DENIED` also means the process exists. +pub fn is_alive(pid: u32) -> bool { + #[cfg(unix)] + { + // SAFETY: kill(pid, 0) sends no signal; it only checks for existence. + let ret = unsafe { libc::kill(pid as i32, 0) }; + if ret == 0 { + return true; + } + std::io::Error::last_os_error().raw_os_error() == Some(libc::EPERM) + } + #[cfg(windows)] + { + use windows_sys::Win32::Foundation::{ + CloseHandle, ERROR_ACCESS_DENIED, GetLastError, STILL_ACTIVE, + }; + use windows_sys::Win32::System::Threading::{ + GetExitCodeProcess, OpenProcess, PROCESS_QUERY_LIMITED_INFORMATION, + }; + // SAFETY: query-only access mask; the handle is closed before returning. + unsafe { + let handle = OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, 0, pid); + if !handle.is_null() { + let mut exit_code = 0u32; + let ok = GetExitCodeProcess(handle, &mut exit_code) != 0; + CloseHandle(handle); + return ok && exit_code == STILL_ACTIVE as u32; + } + GetLastError() == ERROR_ACCESS_DENIED + } + } +} + +/// Forcefully terminate a process by PID. Best-effort; returns whether the +/// request was delivered. +/// +/// Unix: `SIGKILL`. Windows: `TerminateProcess`. +pub fn kill(pid: u32) -> bool { + #[cfg(unix)] + { + // SAFETY: standard kill(2) with a valid signal number. + unsafe { libc::kill(pid as i32, libc::SIGKILL) == 0 } + } + #[cfg(windows)] + { + terminate_win(pid) + } +} + +/// Request graceful termination of a process by PID. Best-effort; returns +/// whether the request was delivered. Like Unix `SIGTERM`, this only *asks* the +/// process to exit — it does not force it. Callers that need a guarantee must +/// wait and then escalate to [`kill`]. +/// +/// Unix: `SIGTERM`. Windows: `CTRL_BREAK_EVENT` to the target's process group +/// (see [`request_shutdown_win`]), which mirrors the SIGTERM-sets-a-flag model. +pub fn terminate(pid: u32) -> bool { + #[cfg(unix)] + { + // SAFETY: standard kill(2) with a valid signal number. + unsafe { libc::kill(pid as i32, libc::SIGTERM) == 0 } + } + #[cfg(windows)] + { + request_shutdown_win(pid) + } +} + +/// Outcome of signalling a process group. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum GroupSignal { + /// The signal was delivered. + Sent, + /// No such process/group exists (already gone). + NotFound, + /// The caller lacks permission to signal the group. + #[cfg(unix)] + PermissionDenied, + /// Any other failure. + #[cfg(unix)] + Other, +} + +/// Send a graceful termination request to a process group by PID. +/// +/// Unix: `killpg(SIGTERM)`. Windows has no process-group signal and no graceful +/// per-tree signal for an unrelated process, so this maps to a forceful +/// process-tree termination ([`kill_tree_win`]) — the same as [`kill_group`]. +pub fn terminate_group(pid: u32) -> GroupSignal { + #[cfg(unix)] + { + signal_group_unix(pid, libc::SIGTERM) + } + #[cfg(windows)] + { + kill_tree_win(pid) + } +} + +/// Forcefully kill a process group by PID. +/// +/// Unix: `killpg(SIGKILL)`. Windows has no process groups in the POSIX sense, so +/// this terminates the target PID **and all of its descendants** via a process +/// snapshot ([`kill_tree_win`]). This matters because hcom records the PID of +/// the launcher (e.g. the background `powershell` host), and the real agent runs +/// as its child; killing only the recorded PID would orphan the agent. +pub fn kill_group(pid: u32) -> GroupSignal { + #[cfg(unix)] + { + signal_group_unix(pid, libc::SIGKILL) + } + #[cfg(windows)] + { + kill_tree_win(pid) + } +} + +#[cfg(unix)] +fn signal_group_unix(pid: u32, sig: libc::c_int) -> GroupSignal { + // SAFETY: killpg with a valid signal number; return value is checked. + let ret = unsafe { libc::killpg(pid as i32, sig) }; + if ret == 0 { + return GroupSignal::Sent; + } + match std::io::Error::last_os_error().raw_os_error() { + Some(libc::ESRCH) => GroupSignal::NotFound, + Some(libc::EPERM) => GroupSignal::PermissionDenied, + _ => GroupSignal::Other, + } +} + +/// Replace the current process with the given command. +/// +/// Unix uses `exec()` and only returns (an error) on failure. Windows has no +/// `exec`, so it spawns the command, waits, and exits with the child's status +/// code — likewise not returning on success. +pub fn exec_replace(mut cmd: Command) -> std::io::Error { + #[cfg(unix)] + { + use std::os::unix::process::CommandExt; + cmd.exec() + } + #[cfg(windows)] + { + match cmd.status() { + Ok(status) => std::process::exit(status.code().unwrap_or(1)), + Err(e) => e, + } + } +} + +/// Kill a child process together with its process group. +/// +/// Unix: `killpg(SIGKILL)` on the child's group (set up via [`detach_session`]), +/// falling back to `Child::kill` if the group signal fails. Windows: terminates +/// the child's whole process tree ([`kill_tree_win`]). +pub fn kill_child_group(child: &mut std::process::Child) { + #[cfg(unix)] + { + use nix::sys::signal::{Signal, killpg}; + use nix::unistd::Pid; + + if let Ok(raw_pid) = i32::try_from(child.id()) + && killpg(Pid::from_raw(raw_pid), Signal::SIGKILL).is_ok() + { + return; + } + } + + #[cfg(windows)] + { + // kill_tree_win_checked terminates `child` itself (with the hcom-kill + // sentinel exit code 130, via terminate_win) and its whole descendant + // tree. If root's own termination is confirmed, return rather than + // falling through to child.kill() below: TerminateProcess only + // requests termination asynchronously, so a second call racing the + // first can overwrite the sentinel exit code with a different one, + // corrupting the EXIT_WAS_KILLED check that reads it back. + // + // If root's termination was NOT confirmed, it may just be that the + // fresh-by-PID OpenProcess inside terminate_win failed for a reason + // other than "already gone" (a handle-table limit, or AV/EDR hooking + // around freshly-opened handles, for instance) — fall through to + // child.kill() as a retry via the handle Command already has open, + // which a failing OpenProcess-by-PID wouldn't affect. + let (_, root_terminated) = kill_tree_win_checked(child.id()); + if root_terminated { + return; + } + } + + let _ = child.kill(); +} + +/// Put a not-yet-spawned [`Command`] into its own session / process group, so +/// the resulting child can be signalled as a group and is detached from the +/// parent's controlling terminal. +/// +/// Unix: `setsid()` via a `pre_exec` hook. Windows: `CREATE_NEW_PROCESS_GROUP` +/// combined with `CREATE_NO_WINDOW`, so the child neither shares the parent's +/// console nor dies when that console closes. +pub fn detach_session(command: &mut Command) { + #[cfg(unix)] + { + use std::os::unix::process::CommandExt; + // SAFETY: setsid() runs in the child between fork and exec and is + // async-signal-safe. + unsafe { + command.pre_exec(|| { + if libc::setsid() == -1 { + Err(std::io::Error::last_os_error()) + } else { + Ok(()) + } + }); + } + } + #[cfg(windows)] + { + use std::os::windows::process::CommandExt; + const CREATE_NEW_PROCESS_GROUP: u32 = 0x0000_0200; + const CREATE_NO_WINDOW: u32 = 0x0800_0000; + command.creation_flags(CREATE_NEW_PROCESS_GROUP | CREATE_NO_WINDOW); + } +} + +/// Best-effort graceful shutdown request on Windows, the analogue of Unix +/// `SIGTERM`: it asks the target to exit but does not force it. +/// +/// Sends `CTRL_BREAK_EVENT` to the target's process group. Processes spawned via +/// [`detach_session`] use `CREATE_NEW_PROCESS_GROUP`, so their process-group id +/// equals their PID, and a process that has registered a handler (see +/// `sys::signal::register_term`) sets its shutdown flag in response. +/// +/// Delivery only reaches a process that shares the caller's console. A relay +/// worker spawned from a different console (the common case) does not receive +/// it, and this returns `false`; the caller's wait + [`kill`] fallback then +/// terminates it. This matches Unix, where `SIGTERM` likewise only *requests* +/// shutdown and callers escalate to `SIGKILL`. +#[cfg(windows)] +fn request_shutdown_win(pid: u32) -> bool { + use windows_sys::Win32::System::Console::{CTRL_BREAK_EVENT, GenerateConsoleCtrlEvent}; + // SAFETY: plain FFI call, no pointers; the process-group id is the target + // PID (valid because detach_session uses CREATE_NEW_PROCESS_GROUP). + unsafe { GenerateConsoleCtrlEvent(CTRL_BREAK_EVENT, pid) != 0 } +} + +#[cfg(windows)] +fn terminate_win(pid: u32) -> bool { + use windows_sys::Win32::Foundation::CloseHandle; + use windows_sys::Win32::System::Threading::{OpenProcess, PROCESS_TERMINATE, TerminateProcess}; + // SAFETY: opens a terminate-only handle, closes it before returning. + unsafe { + let handle = OpenProcess(PROCESS_TERMINATE, 0, pid); + if handle.is_null() { + return false; + } + // Exit code 130 (128 + SIGINT) is the hcom sentinel for "externally + // killed via hcom kill". The pty proxy reads this back from child.wait() + // to set EXIT_WAS_KILLED before the delivery thread records exit status. + let ok = TerminateProcess(handle, 130) != 0; + CloseHandle(handle); + ok + } +} + +/// Snapshot every live process's pid -> parent_pid link via +/// `CreateToolhelp32Snapshot`, retrying once on transient failure. +/// +/// Returns `None` if the snapshot could not be taken even after the retry. +#[cfg(windows)] +fn snapshot_parents() -> Option> { + use std::collections::HashMap; + use windows_sys::Win32::Foundation::{CloseHandle, INVALID_HANDLE_VALUE}; + use windows_sys::Win32::System::Diagnostics::ToolHelp::{ + CreateToolhelp32Snapshot, PROCESSENTRY32W, Process32FirstW, Process32NextW, + TH32CS_SNAPPROCESS, + }; + + let mut parents: HashMap = HashMap::new(); + // SAFETY: snapshot handle is closed before returning; the PROCESSENTRY32W is + // fully initialized (dwSize set) before the enumeration calls. + unsafe { + let mut snapshot = CreateToolhelp32Snapshot(TH32CS_SNAPPROCESS, 0); + if snapshot == INVALID_HANDLE_VALUE { + // Snapshot can fail transiently under high load; retry once. + snapshot = CreateToolhelp32Snapshot(TH32CS_SNAPPROCESS, 0); + } + if snapshot == INVALID_HANDLE_VALUE { + return None; + } + let mut entry: PROCESSENTRY32W = std::mem::zeroed(); + entry.dwSize = std::mem::size_of::() as u32; + if Process32FirstW(snapshot, &mut entry) != 0 { + loop { + parents.insert(entry.th32ProcessID, entry.th32ParentProcessID); + if Process32NextW(snapshot, &mut entry) == 0 { + break; + } + } + } + CloseHandle(snapshot); + } + Some(parents) +} + +/// Spawn a detached child without leaking the caller's captured stdio handles. +/// +/// On Windows, `Command` must enable handle inheritance for explicitly supplied +/// child stdio (for example a background log file). Without an explicit handle +/// list, that can also inherit the hcom CLI's own stdout/stderr pipes. A caller +/// using `Command::output()` then waits forever for EOF after hcom exits because +/// the long-lived agent still owns duplicate pipe handles. +pub fn spawn_detached(command: &mut Command) -> std::io::Result { + detach_session(command); + + #[cfg(not(windows))] + { + command.spawn() + } + + #[cfg(windows)] + { + use std::sync::{Mutex, OnceLock}; + use windows_sys::Win32::Foundation::{ + GetHandleInformation, HANDLE_FLAG_INHERIT, SetHandleInformation, + }; + use windows_sys::Win32::System::Console::{ + GetStdHandle, STD_ERROR_HANDLE, STD_INPUT_HANDLE, STD_OUTPUT_HANDLE, + }; + + static SPAWN_LOCK: OnceLock> = OnceLock::new(); + let _guard = SPAWN_LOCK + .get_or_init(|| Mutex::new(())) + .lock() + .unwrap_or_else(|error| error.into_inner()); + + let mut restored = Vec::new(); + for kind in [STD_INPUT_HANDLE, STD_OUTPUT_HANDLE, STD_ERROR_HANDLE] { + // SAFETY: GetStdHandle returns a borrowed process handle. We only + // query/change its inheritance flag and restore it before returning. + unsafe { + let handle = GetStdHandle(kind); + if handle.is_null() { + continue; + } + let mut flags = 0; + if GetHandleInformation(handle, &mut flags) != 0 + && flags & HANDLE_FLAG_INHERIT != 0 + && SetHandleInformation(handle, HANDLE_FLAG_INHERIT, 0) != 0 + { + restored.push(handle); + } + } + } + + let child = command.spawn(); + for handle in restored { + // SAFETY: these are the same live borrowed standard handles whose + // inheritance flag was cleared above. + unsafe { + SetHandleInformation(handle, HANDLE_FLAG_INHERIT, HANDLE_FLAG_INHERIT); + } + } + child + } +} + +/// Number of extra re-scan rounds run after the initial kill pass, to catch +/// descendants spawned after the first snapshot (see [`kill_tree_win_checked`]). +#[cfg(windows)] +const RESCAN_ROUNDS: u32 = 2; + +/// Delay between re-scan rounds, giving the OS a moment to start tearing down +/// what was just killed before the next snapshot is taken. +#[cfg(windows)] +const RESCAN_DELAY: std::time::Duration = std::time::Duration::from_millis(75); + +/// Terminate `root` and all of its descendants. +/// +/// Windows has no process groups, so the only general way to "kill the agent and +/// its children" by PID from an unrelated process is to walk the parent/child +/// links in a process snapshot. The full descendant set is collected from a +/// single snapshot *before* any termination, so killing a parent can't strand a +/// child behind a now-stale parent PID (Windows does not reparent orphans). +/// +/// Returns `Sent` if the root was present (and termination was attempted), +/// `NotFound` if no live process had the root PID. Like `killpg`, individual +/// termination failures are best-effort and don't change the result. +/// +/// Caveat: PID reuse can make a parent link stale; this shares the same +/// theoretical race as `taskkill /T`, which is the accepted Windows approach. +#[cfg(windows)] +fn kill_tree_win(root: u32) -> GroupSignal { + kill_tree_win_checked(root).0 +} + +/// Same as [`kill_tree_win`], but also reports whether `root`'s own +/// `terminate_win` call specifically succeeded (as opposed to just "root was +/// found in the snapshot"). `kill_tree_win`'s `Sent` doesn't distinguish +/// these — every descendant's individual termination result is discarded — +/// so [`kill_child_group`] uses this directly to decide whether a retry via +/// its own already-open `Child` handle is worthwhile. +/// +/// Re-scan for stragglers: a single snapshot is inherently a point-in-time +/// view, so a process spawned by any tree member *after* the snapshot but +/// *before* that member is terminated is invisible to the first pass and would +/// otherwise survive. Unix's `killpg` doesn't have this problem for +/// already-existing group members — the kernel delivers the signal to the +/// whole group atomically — but Windows has no equivalent primitive, so after +/// the first kill pass this takes a couple of follow-up snapshots, each time +/// looking for new processes parented by *any* previously-known tree member +/// (not just `root`, since mid-tree descendants can keep spawning children of +/// their own even after `root` is gone) and killing those too. This narrows +/// the race window considerably but, being fundamentally snapshot-based, does +/// not eliminate it: a straggler spawned after the very last re-scan's +/// snapshot can still escape. +#[cfg(windows)] +fn kill_tree_win_checked(root: u32) -> (GroupSignal, bool) { + let Some(parents) = snapshot_parents() else { + // Still can't enumerate descendants; kill only the root. Any + // surviving descendants will be reaped when the job object + // closes. This still reports `Sent` (not a distinct "partial" + // result) — `GroupSignal`/`KillResult` are a flat success/ + // not-found/permission-denied/other set consumed by ~15 call + // sites across the CLI kill-reporting path and the relay JSON + // protocol (see commands/kill.rs, relay/control.rs); threading a + // new "partial" variant through all of them is a larger, separate + // change. Logging at least makes this rare degraded path visible + // instead of leaving zero trace anywhere. + crate::log::log_error( + "native", + "win.kill_tree", + &format!( + "CreateToolhelp32Snapshot failed twice; killing root pid {root} only, \ + descendants may be left running until the job object reaps them" + ), + ); + let ok = terminate_win(root); + return ( + if ok { + GroupSignal::Sent + } else { + GroupSignal::NotFound + }, + ok, + ); + }; + + if !parents.contains_key(&root) { + return (GroupSignal::NotFound, false); + } + + // Collect root + all descendants (BFS over the parent links). + let mut tree = vec![root]; + let mut i = 0; + while i < tree.len() { + let current = tree[i]; + for (&pid, &ppid) in &parents { + if ppid == current && !tree.contains(&pid) { + tree.push(pid); + } + } + i += 1; + } + + // Terminate children before parents (deepest first) so a parent can't spawn + // a new child after we've passed it. + let mut root_terminated = false; + for &pid in tree.iter().rev() { + let ok = terminate_win(pid); + if pid == root { + root_terminated = ok; + } + } + + // Re-scan for stragglers spawned between the snapshot and their parent's + // termination (see doc comment above). + // + // This widens (but does not introduce in kind) the PID-reuse race already + // called out on `kill_tree_win_checked`: each extra round is another + // `terminate_win` -> sleep -> re-snapshot cycle, so there's more elapsed + // time in which a PID this function just terminated could be recycled by + // an unrelated process that happens to be parented under another PID + // still in `tree`. `KillOnDropJob` (see `pty::win::job`) remains as a + // backstop that reaps the real tree regardless of this race, so this is + // treated as an acceptable, documented widening rather than something to + // engineer away here — doing so would need per-candidate parent-identity + // confirmation (e.g. `GetProcessTimes` creation-time checks) for a + // vanishingly unlikely window (tens of milliseconds). + // + // Always run all RESCAN_ROUNDS — + // round N finding nothing new doesn't mean a straggler can't still appear + // before round N+1's snapshot, so stopping early would defeat the point of + // having more than one round. Within a round, expand `tree` to a fixed + // point (mirroring the initial BFS above) rather than a single pass, so a + // straggler and *its own* child that both first appear in the same + // snapshot are both caught in that round regardless of HashMap iteration + // order. + for _ in 0..RESCAN_ROUNDS { + std::thread::sleep(RESCAN_DELAY); + let Some(parents) = snapshot_parents() else { + break; + }; + let before = tree.len(); + loop { + let start_len = tree.len(); + for (&pid, &ppid) in &parents { + if tree.contains(&ppid) && !tree.contains(&pid) { + tree.push(pid); + } + } + if tree.len() == start_len { + break; + } + } + // Kill only the newly discovered stragglers (deepest-first isn't + // load-bearing here since these are freshly discovered leaves relative + // to the known tree, but iterating in reverse discovery order costs + // nothing and keeps the same "children before parents" spirit). + for &pid in tree[before..].iter().rev() { + terminate_win(pid); + } + } + + (GroupSignal::Sent, root_terminated) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_is_alive_current_process() { + assert!(is_alive(std::process::id())); + } + + #[test] + fn test_is_alive_dead_process() { + assert!(!is_alive(99_999_999)); + } + + // Reproduces the bug fixed above: the process object stays valid (and + // OpenProcess succeeds) as long as any handle is open, even after the + // process has exited. Holding `child` past `wait()` keeps its handle + // open while we probe the same PID with our own OpenProcess call. + #[cfg(windows)] + #[test] + fn test_is_alive_false_for_exited_process_with_handle_still_open() { + let mut child = std::process::Command::new("cmd") + .args(["/C", "exit 0"]) + .spawn() + .unwrap(); + let pid = child.id(); + child.wait().unwrap(); + assert!( + !is_alive(pid), + "an open handle to an already-exited process must not count as alive" + ); + drop(child); + } + + // Reproduces the bug fixed above: kill_tree_win's terminate_win writes the + // hcom-kill sentinel exit code 130. A trailing child.kill() (a second, + // competing TerminateProcess on the same PID) could overwrite it before + // the OS settles on a final exit code. + #[cfg(windows)] + #[test] + fn test_kill_child_group_preserves_sentinel_exit_code() { + let mut child = std::process::Command::new("cmd") + .args(["/C", "timeout /T 30"]) + .spawn() + .unwrap(); + kill_child_group(&mut child); + let status = child.wait().unwrap(); + assert_eq!( + status.code(), + Some(130), + "kill_child_group must not let a second kill overwrite the hcom-kill sentinel" + ); + } +} diff --git a/src/sys/signal.rs b/src/sys/signal.rs new file mode 100644 index 00000000..bbf401c1 --- /dev/null +++ b/src/sys/signal.rs @@ -0,0 +1,111 @@ +//! Shutdown-signal registration: set an `AtomicBool` when the process is asked +//! to terminate, so long-running loops can exit cleanly. + +use std::sync::Arc; +use std::sync::atomic::AtomicBool; + +/// Register `flag` to be set on a termination request (Unix `SIGTERM`; Windows +/// console close/break/shutdown events). +pub fn register_term(flag: &Arc) { + #[cfg(unix)] + { + let _ = signal_hook::flag::register(signal_hook::consts::SIGTERM, Arc::clone(flag)); + } + #[cfg(windows)] + { + win::register(win::Kind::Term, Arc::clone(flag)); + } +} + +/// Register `flag` to be set on an interrupt request (Unix `SIGINT`; Windows +/// Ctrl-C). +pub fn register_int(flag: &Arc) { + #[cfg(unix)] + { + let _ = signal_hook::flag::register(signal_hook::consts::SIGINT, Arc::clone(flag)); + } + #[cfg(windows)] + { + win::register(win::Kind::Int, Arc::clone(flag)); + } +} + +#[cfg(windows)] +mod win { + use std::sync::atomic::{AtomicBool, Ordering}; + use std::sync::{Arc, Mutex, OnceLock}; + use windows_sys::Win32::System::Console::{ + CTRL_BREAK_EVENT, CTRL_C_EVENT, CTRL_CLOSE_EVENT, CTRL_LOGOFF_EVENT, CTRL_SHUTDOWN_EVENT, + SetConsoleCtrlHandler, + }; + + // Win32 BOOL is a plain i32; TRUE = 1, FALSE = 0. + const TRUE: i32 = 1; + const FALSE: i32 = 0; + + #[derive(Clone, Copy)] + pub enum Kind { + Int, + Term, + } + + static INT_FLAGS: OnceLock>>> = OnceLock::new(); + static TERM_FLAGS: OnceLock>>> = OnceLock::new(); + static INSTALLED: AtomicBool = AtomicBool::new(false); + + fn int_flags() -> &'static Mutex>> { + INT_FLAGS.get_or_init(|| Mutex::new(Vec::new())) + } + + fn term_flags() -> &'static Mutex>> { + TERM_FLAGS.get_or_init(|| Mutex::new(Vec::new())) + } + + fn set_all(flags: &Mutex>>) -> bool { + if let Ok(list) = flags.lock() { + if list.is_empty() { + return false; + } + for f in list.iter() { + f.store(true, Ordering::SeqCst); + } + return true; + } + false + } + + unsafe extern "system" fn handler(ctrl_type: u32) -> i32 { + let handled = match ctrl_type { + CTRL_C_EVENT => set_all(int_flags()), + CTRL_BREAK_EVENT | CTRL_CLOSE_EVENT | CTRL_LOGOFF_EVENT | CTRL_SHUTDOWN_EVENT => { + set_all(term_flags()) + } + _ => false, + }; + if handled { TRUE } else { FALSE } + } + + pub fn register(kind: Kind, flag: Arc) { + if !INSTALLED.swap(true, Ordering::SeqCst) { + // SAFETY: installs a process-wide console control handler once. + let ok = unsafe { SetConsoleCtrlHandler(Some(handler), TRUE) }; + if ok == FALSE { + crate::log::log_error( + "signal", + "console_ctrl_handler.install_failed", + &format!( + "SetConsoleCtrlHandler failed: {}", + std::io::Error::last_os_error() + ), + ); + } + } + let flags = match kind { + Kind::Int => int_flags(), + Kind::Term => term_flags(), + }; + if let Ok(mut list) = flags.lock() { + list.push(flag); + } + } +} diff --git a/src/terminal.rs b/src/terminal.rs index 8389d236..ed037ca9 100644 --- a/src/terminal.rs +++ b/src/terminal.rs @@ -10,16 +10,16 @@ use std::collections::HashMap; use std::fs; use std::io::Write; -use std::os::unix::fs::PermissionsExt; use std::path::{Path, PathBuf}; use std::process::Command; +use std::time::{Duration, Instant}; use anyhow::{Context, Result, anyhow, bail}; use crate::paths; use crate::shared::constants::HCOM_IDENTITY_VARS; use crate::shared::platform; -use crate::shared::terminal_presets::TERMINAL_ENV_MAP; +use crate::shared::terminal_presets::{ArgvTemplate, TERMINAL_ENV_MAP}; use crate::shared::tool_detection::tool_marker_vars; /// Result of kill_process(). @@ -30,6 +30,38 @@ pub enum KillResult { PermissionDenied, } +const TERMINAL_CLOSE_TIMEOUT: Duration = Duration::from_secs(5); + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct PaneCloseResult { + pub closed: bool, + pub retry_command: Option, +} + +fn format_close_command(argv: &[String]) -> String { + argv.iter() + .map(|arg| { + #[cfg(windows)] + { + if !arg.is_empty() + && arg + .chars() + .all(|c| c.is_ascii_alphanumeric() || "/_-.=:,@\\".contains(c)) + { + arg.clone() + } else { + ps_quote(arg) + } + } + #[cfg(not(windows))] + { + crate::tools::args_common::shell_quote(arg) + } + }) + .collect::>() + .join(" ") +} + /// Terminal info resolved for an instance. #[derive(Debug, Clone, Default)] pub struct TerminalInfo { @@ -54,15 +86,37 @@ pub enum LaunchResult { /// macOS app bundle fallback commands for cross-platform terminals. /// Used when CLI binary isn't in PATH but .app bundle is installed. -const MACOS_APP_FALLBACKS: &[(&str, &str)] = &[ - ("kitty-window", "open -n -a kitty.app --args {script}"), +const MACOS_APP_FALLBACKS: &[(&str, ArgvTemplate)] = &[ + ( + "kitty-window", + &["open", "-n", "-a", "kitty.app", "--args", "{script}"], + ), ( "wezterm-window", - "open -n -a WezTerm.app --args start -- bash {script}", + &[ + "open", + "-n", + "-a", + "WezTerm.app", + "--args", + "start", + "--", + "bash", + "{script}", + ], ), ( "alacritty", - "open -n -a Alacritty.app --args -e bash {script}", + &[ + "open", + "-n", + "-a", + "Alacritty.app", + "--args", + "-e", + "bash", + "{script}", + ], ), ]; @@ -180,28 +234,30 @@ fn find_macos_app(name: &str) -> Option { None } -/// Replace `open -a ` app names with absolute `.app` bundle paths. +/// Replace `open -a ` app names with absolute `.app` bundle paths, in place +/// on an argv vector. /// /// This is only safe for app-launch commands where `open` passes argv via /// `--args`. Plain file-open forms like `open -a Terminal {script}` must keep /// `-a`, otherwise `open` treats the app bundle and script as regular paths and -/// falls back to file association for the script. -fn rewrite_open_command_with_app_path(template: &str, app_path: &Path) -> Result { - let mut parts = shell_split(template)?; - for idx in 0..parts.len().saturating_sub(1) { - let flag = &parts[idx]; +/// falls back to file association for the script. No-ops if no `--args` tail is +/// present or no app flag is found. +fn rewrite_open_argv_with_app_path(argv: &mut Vec, app_path: &Path) { + for idx in 0..argv.len().saturating_sub(1) { + let flag = &argv[idx]; let takes_app_arg = flag == "-a" || (flag.starts_with('-') && !flag.starts_with("--") && flag.chars().skip(1).any(|c| c == 'a')); if takes_app_arg { - let has_args_tail = parts.iter().skip(idx + 2).any(|part| part == "--args"); + let has_args_tail = argv.iter().skip(idx + 2).any(|part| part == "--args"); if !has_args_tail { - return Ok(template.to_string()); + return; } + let app_path_str = app_path.to_string_lossy().to_string(); if flag == "-a" { - parts.remove(idx); - parts[idx] = app_path.to_string_lossy().to_string(); + argv.remove(idx); + argv[idx] = app_path_str; } else { let mut rewritten_flag = String::from("-"); for ch in flag.chars().skip(1) { @@ -210,31 +266,25 @@ fn rewrite_open_command_with_app_path(template: &str, app_path: &Path) -> Result } } if rewritten_flag == "-" { - parts.remove(idx); - parts[idx] = app_path.to_string_lossy().to_string(); + argv.remove(idx); + argv[idx] = app_path_str; } else { - parts[idx] = rewritten_flag; - parts[idx + 1] = app_path.to_string_lossy().to_string(); + argv[idx] = rewritten_flag; + argv[idx + 1] = app_path_str; } } - return Ok(parts - .iter() - .map(|p| shell_quote(p)) - .collect::>() - .join(" ")); + return; } } - Ok(template.to_string()) } -fn rewrite_macos_open_app_command(template: &str, app_name: &str) -> String { +fn rewrite_macos_open_app_argv(argv: &mut Vec, app_name: &str) { if !cfg!(target_os = "macos") { - return template.to_string(); + return; + } + if let Some(app_path) = find_macos_app(app_name) { + rewrite_open_argv_with_app_path(argv, &app_path); } - let Some(app_path) = find_macos_app(app_name) else { - return template.to_string(); - }; - rewrite_open_command_with_app_path(template, &app_path).unwrap_or_else(|_| template.to_string()) } fn should_use_command_extension(background: bool, terminal_mode: &str) -> bool { @@ -279,13 +329,8 @@ pub fn find_kitty_socket() -> String { candidates.sort_by(|a, b| b.cmp(a)); // Reverse sort (newest first) for sock_path in &candidates { - // Check if it's a socket - if let Ok(meta) = fs::metadata(sock_path) { - use std::os::unix::fs::FileTypeExt; - if !meta.file_type().is_socket() { - continue; - } - } else { + // Skip anything that isn't a Unix-domain socket + if !crate::sys::fs::is_socket(sock_path) { continue; } @@ -398,13 +443,43 @@ pub fn wezterm_reachable() -> bool { .unwrap_or(false) } +/// Candidate file names to probe for `name` in a PATH directory. On Windows an +/// extension-less name is expanded with PATHEXT (`.exe`, `.cmd`, …); elsewhere +/// the name is used verbatim. +pub(crate) fn which_candidates(dir: &Path, name: &str) -> Vec { + #[cfg(windows)] + { + if Path::new(name).extension().is_some() { + return vec![dir.join(name)]; + } + let exts = std::env::var("PATHEXT").unwrap_or_else(|_| ".COM;.EXE;.BAT;.CMD".to_string()); + let mut out: Vec = exts + .split(';') + .filter(|e| !e.is_empty()) + .map(|ext| dir.join(format!("{name}{ext}"))) + .collect(); + out.push(dir.join(name)); + out + } + #[cfg(not(windows))] + { + vec![dir.join(name)] + } +} + /// Simple `which` implementation — find binary in PATH. pub fn which_bin(name: &str) -> Option { - let path_var = std::env::var("PATH").ok()?; - for dir in path_var.split(':') { - let candidate = Path::new(dir).join(name); - if candidate.exists() && candidate.is_file() { - return Some(candidate.to_string_lossy().to_string()); + // `split_paths` uses the platform separator (`;` on Windows, `:` elsewhere), + // which also avoids splitting Windows drive letters like `C:`. PATH being + // entirely unset (rather than merely lacking `name`) still falls through + // to the well-known-location fallbacks below. + if let Some(path_var) = std::env::var_os("PATH") { + for dir in std::env::split_paths(&path_var) { + for candidate in which_candidates(&dir, name) { + if candidate.is_file() { + return Some(candidate.to_string_lossy().to_string()); + } + } } } @@ -429,9 +504,70 @@ pub fn which_bin(name: &str) -> Option { } } + #[cfg(windows)] + if name.eq_ignore_ascii_case("agy") + && let Some(local_app_data) = std::env::var_os("LOCALAPPDATA") + { + let fallback = Path::new(&local_app_data) + .join("agy") + .join("bin") + .join("agy.exe"); + if fallback.is_file() { + return Some(fallback.to_string_lossy().into_owned()); + } + } + None } +/// Build a command for a PATH-resolved executable, including Windows npm +/// `.cmd`/`.bat` shims that CreateProcess cannot execute directly. +pub fn executable_command(name: &str) -> Command { + let resolved = which_bin(name).unwrap_or_else(|| name.to_string()); + #[cfg(windows)] + if Path::new(&resolved) + .extension() + .and_then(|ext| ext.to_str()) + .is_some_and(|ext| ext.eq_ignore_ascii_case("cmd") || ext.eq_ignore_ascii_case("bat")) + { + let mut command = Command::new("cmd.exe"); + command.args(["/d", "/c"]).arg(resolved); + return command; + } + Command::new(resolved) +} + +/// Bypass npm's Windows `codex.cmd` shim when launching the interactive tool. +/// +/// The shim expands `%*` through cmd.exe, which corrupts quote-bearing config +/// values such as the multiline `developer_instructions` bootstrap. Invoking +/// the package's Node entrypoint directly preserves Rust's argv boundaries. +#[cfg(windows)] +pub fn resolve_windows_tool_launcher(tool: &str, resolved: &str) -> Option<(String, Vec)> { + if tool != "codex" + || !Path::new(resolved) + .extension() + .and_then(|ext| ext.to_str()) + .is_some_and(|ext| ext.eq_ignore_ascii_case("cmd")) + { + return None; + } + let prefix = Path::new(resolved).parent()?; + let entrypoint = prefix.join("node_modules/@openai/codex/bin/codex.js"); + if !entrypoint.is_file() { + return None; + } + let node = which_bin("node")?; + Some((node, vec![entrypoint.to_string_lossy().into_owned()])) +} + +/// Resolve the `bash` command to run on Unix, preferring a `PATH` match and +/// falling back to `/bin/bash`. Shared by the background and run-here launch +/// paths so they can't drift on which `bash` gets invoked. +fn resolve_bash_command() -> String { + which_bin("bash").unwrap_or_else(|| "/bin/bash".to_string()) +} + /// Check if a file has a node shebang (#!/usr/bin/env node or similar). /// Used on Termux to detect npm-installed tools that need `node ` rewrite. pub fn has_node_shebang(path: &str) -> bool { @@ -554,13 +690,94 @@ fn resolve_binary_path(binary: &str, app_name: Option<&str>, preset_name: &str) } } -/// Resolve preset name to command template string. +/// True if `tok` names a bash-family interpreter — its file stem is `bash` +/// (case-insensitive), covering `bash`, `bash.exe`, `/bin/bash`, and +/// `C:\...\bash.exe`. +#[cfg(any(windows, test))] +fn is_bash_interp(tok: &str) -> bool { + std::path::Path::new(tok) + .file_stem() + .and_then(|s| s.to_str()) + .is_some_and(|stem| stem.eq_ignore_ascii_case("bash")) +} + +/// Detect an unrunnable non-adjacent bash-family `{script}` custom template: a +/// bash-family interpreter token (`bash`, `bash.exe`, `/bin/bash`, …) with a +/// LATER (non-adjacent) `{script}`, so the generated `.ps1` would be handed to +/// bash and can't be rewritten by a simple splice — e.g. `bash -c {script}`, +/// `bash -x {script}`, `bash.exe -i {script}`. Returns the offending +/// interpreter token. Adjacent ` {script}` is handled by +/// `shellify_bash_script_pair` and is NOT flagged. +#[cfg(any(windows, test))] +fn unsupported_bash_script_interp(argv: &[String]) -> Option { + let interp = argv.iter().position(|a| is_bash_interp(a))?; + let script = argv.iter().position(|a| a.contains("{script}"))?; + if script <= interp + 1 { + return None; + } + Some(argv[interp].clone()) +} + +/// Rewrite an adjacent bash-family `{script}` executor pair to PowerShell in a +/// custom (non-preset) command argv, so the generated `.ps1` runs on Windows +/// without requiring Git Bash on PATH. Matches an adjacent `` + +/// `{script}` pair anywhere in the argv — not just at argv[0] — where +/// `` is any bash-family token (`bash`, `bash.exe`, `/bin/bash`, …). +/// A bash-family token with a NON-adjacent `{script}` (e.g. `bash -c {script}`) +/// can't be rewritten by a simple splice — it is rejected with a clear error +/// instead of silently launching a broken command (see +/// `unsupported_bash_script_interp`). +/// +/// Off Windows this is a passthrough (the generated script is a bash script). +#[cfg(not(windows))] +fn windows_shellify_custom_argv(argv: Vec) -> Result> { + Ok(argv) +} + +#[cfg(windows)] +fn windows_shellify_custom_argv(argv: Vec) -> Result> { + if let Some(interp) = unsupported_bash_script_interp(&argv) { + bail!( + "custom terminal command runs `{interp}` with a non-adjacent `{{script}}` and \ + cannot run the generated PowerShell script on Windows; use `{interp} {{script}}` \ + (adjacent, no flags) or a native command" + ); + } + Ok(shellify_bash_script_pair(argv)) +} + +/// Replace an adjacent bash-family + `{script}` pair with the PowerShell `.ps1` +/// launcher. Platform-agnostic so it can be unit-tested on any host; the +/// `windows_shellify_custom_argv` wrapper only applies it on Windows. +#[cfg(any(windows, test))] +fn shellify_bash_script_pair(mut argv: Vec) -> Vec { + if let Some(i) = argv + .windows(2) + .position(|w| is_bash_interp(&w[0]) && w[1] == "{script}") + { + argv.splice( + i..i + 1, + [ + "powershell".to_string(), + "-ExecutionPolicy".to_string(), + "Bypass".to_string(), + "-NoExit".to_string(), + "-File".to_string(), + ], + ); + } + argv +} + +/// Resolve preset name to an open-command argv template (placeholders intact). /// /// On macOS, if CLI binary isn't in PATH but .app bundle exists, -/// uses a hardcoded fallback or substitutes the full binary path. -pub fn resolve_terminal_preset(preset_name: &str) -> Option { +/// uses a hardcoded fallback or substitutes the full binary path. The returned +/// argv still contains placeholders like `{script}`; substitute via +/// `substitute_open_argv`. +pub fn resolve_terminal_open_argv(preset_name: &str) -> Option> { let merged = crate::config::get_merged_preset(preset_name)?; - let mut open_cmd = merged.open; + let mut open_argv = merged.open_argv(cfg!(windows)); let app_name = merged.app_name.as_deref().unwrap_or(preset_name); if let Some(ref binary) = merged.binary @@ -570,18 +787,21 @@ pub fn resolve_terminal_preset(preset_name: &str) -> Option { // New-window presets have hardcoded fallbacks using `open -a` for &(name, fallback) in MACOS_APP_FALLBACKS { if name == preset_name && find_macos_app(app_name).is_some() { - return Some(rewrite_macos_open_app_command(fallback, app_name)); + let mut argv: Vec = fallback.iter().map(|s| s.to_string()).collect(); + rewrite_macos_open_app_argv(&mut argv, app_name); + return Some(argv); } } - // Tab/split presets: substitute leading binary with full path + // Tab/split presets: substitute leading binary element with full path if let Some(full_path) = resolve_binary_path(binary, Some(app_name), preset_name) - && open_cmd.starts_with(binary.as_str()) + && open_argv.first().map(String::as_str) == Some(binary.as_str()) { - open_cmd = format!("{}{}", full_path, &open_cmd[binary.len()..]); + open_argv[0] = full_path; } } - Some(rewrite_macos_open_app_command(&open_cmd, app_name)) + rewrite_macos_open_app_argv(&mut open_argv, app_name); + Some(open_argv) } /// Get terminal presets for current platform with availability status. @@ -654,6 +874,12 @@ pub fn build_env_string(env_vars: &HashMap, format_type: &str) - .map(|(k, v)| format!("export {}={};", k, shell_quote(v))) .collect::>() .join(" ") + } else if format_type == "powershell" { + valid + .iter() + .map(|(k, v)| format!("$env:{} = {}", k, ps_quote(v))) + .collect::>() + .join("\n") } else { valid .iter() @@ -678,19 +904,10 @@ fn shell_quote(s: &str) -> String { format!("'{}'", s.replace('\'', "'\\''")) } -/// Create a bash script for terminal launch. -/// -/// Scripts provide uniform execution across all platforms/terminals. -pub fn create_bash_script( - script_file: &Path, - env: &HashMap, - cwd: Option<&str>, - command_str: &str, - background: bool, - tool_name: Option<&str>, - opens_new_window: bool, -) -> Result<()> { - let tool_name = tool_name.unwrap_or_else(|| { +/// Resolve a human-readable tool name for the launch script title/banner, +/// detecting from the command when not explicitly provided. +fn launch_display_name<'a>(command_str: &str, tool_name: Option<&'a str>) -> &'a str { + tool_name.unwrap_or_else(|| { let cmd_lower = command_str.to_lowercase(); if cmd_lower.contains("opencode") { "OpenCode" @@ -707,7 +924,22 @@ pub fn create_bash_script( } else { "hcom" } - }); + }) +} + +/// Create a bash script for terminal launch. +/// +/// Scripts provide uniform execution across all platforms/terminals. +pub fn create_bash_script( + script_file: &Path, + env: &HashMap, + cwd: Option<&str>, + command_str: &str, + background: bool, + tool_name: Option<&str>, + opens_new_window: bool, +) -> Result<()> { + let tool_name = launch_display_name(command_str, tool_name); let mut f = fs::File::create(script_file).context("Failed to create script file")?; @@ -803,7 +1035,163 @@ pub fn create_bash_script( } // Make executable - fs::set_permissions(script_file, fs::Permissions::from_mode(0o755))?; + crate::sys::fs::set_executable(script_file)?; + + Ok(()) +} + +/// Quote a string as a PowerShell single-quoted literal (embedded `'` doubled). +pub fn ps_quote(s: &str) -> String { + format!("'{}'", s.replace('\'', "''")) +} + +/// Build sorted `$env:K = 'V'` assignments, applying the same key validation +/// as `build_env_string` so only well-formed names are emitted. +fn ps_env_assignments(env_vars: &HashMap) -> Vec { + let mut valid: Vec<(&String, &String)> = env_vars + .iter() + .filter(|(k, _)| { + k.chars() + .next() + .is_some_and(|c| c.is_ascii_alphabetic() || c == '_') + && k.chars().all(|c| c.is_ascii_alphanumeric() || c == '_') + }) + .collect(); + valid.sort_by_key(|(k, _)| k.to_string()); + valid + .iter() + .map(|(k, v)| format!("$env:{} = {}", k, ps_quote(v))) + .collect() +} + +/// Create a PowerShell launch script — the Windows-native equivalent of +/// `create_bash_script`. Emits a `.ps1` that sets the window title, scrubs +/// inherited tool/identity vars, prepends discovered tool directories to PATH, +/// assigns the per-launch env, changes directory, then runs the tool command +/// via the call operator. The window-open vs. run-once cleanup mirrors the +/// bash version; the window is kept alive by launching with `powershell -NoExit`. +pub fn create_powershell_script( + script_file: &Path, + env: &HashMap, + cwd: Option<&str>, + command_str: &str, + background: bool, + tool_name: Option<&str>, + opens_new_window: bool, +) -> Result<()> { + let tool_name = launch_display_name(command_str, tool_name); + + let mut f = fs::File::create(script_file).context("Failed to create script file")?; + // Windows PowerShell 5.1 decodes BOM-less scripts using the active ANSI + // code page. Force UTF-8 so non-ASCII paths, prompts, and environment + // values survive on stock Windows PowerShell. + f.write_all(&[0xEF, 0xBB, 0xBF])?; + + writeln!( + f, + "$Host.UI.RawUI.WindowTitle = \"hcom: starting {}...\"", + tool_name + )?; + writeln!(f, "Write-Host \"Starting {}...\"", tool_name)?; + + // Scrub inherited tool markers and identity vars so the child can't inherit + // them (PowerShell ignores Env: entries that don't exist). + let scrub: Vec = tool_marker_vars() + .iter() + .chain(HCOM_IDENTITY_VARS.iter()) + .map(|v| format!("Env:{v}")) + .collect(); + writeln!( + f, + "Remove-Item {} -ErrorAction SilentlyContinue", + scrub.join(",") + )?; + + // Discover paths for minimal environments. + let mut paths_to_add: Vec = Vec::new(); + + fn add_path(paths: &mut Vec, binary_path: Option) { + if let Some(bp) = binary_path + && let Some(dir) = Path::new(&bp).parent() + { + let dir_str = dir.to_string_lossy().to_string(); + if !paths.contains(&dir_str) { + paths.push(dir_str); + } + } + } + + add_path(&mut paths_to_add, which_bin("hcom")); + add_path(&mut paths_to_add, which_bin("python3")); + let cmd_stripped = command_str.trim_start(); + let tool_cmd = cmd_stripped.split_whitespace().next().unwrap_or(""); + add_path(&mut paths_to_add, which_bin(tool_cmd)); + if tool_cmd == "claude" { + add_path(&mut paths_to_add, which_bin("node")); + } + + if !paths_to_add.is_empty() { + // Windows PATH is `;`-separated. + let prefix = format!("{};", paths_to_add.join(";")); + writeln!(f, "$env:PATH = {} + $env:PATH", ps_quote(&prefix))?; + } + + for line in ps_env_assignments(env) { + writeln!(f, "{line}")?; + } + + if let Some(dir) = cwd { + writeln!(f, "Set-Location {}", ps_quote(dir))?; + } + + // Resolve the tool to a full path and invoke it through the call operator so + // a quoted path runs as a command. If the tool isn't found, fall through to + // the bare command name (resolved via the PATH we just prepended). + let mut final_command = command_str.to_string(); + if !tool_cmd.is_empty() + && let Some(tool_path) = which_bin(tool_cmd) + { + let replaced = final_command.replacen( + &format!("{tool_cmd} "), + &format!("& {} ", ps_quote(&tool_path)), + 1, + ); + final_command = if replaced != final_command { + replaced + } else { + // No arguments: the command is exactly the tool name (no trailing + // space to match), so replace the bare name directly. + final_command.replacen(tool_cmd, &format!("& {}", ps_quote(&tool_path)), 1) + }; + } + + writeln!(f, "{final_command}")?; + + if opens_new_window { + // Clear hcom state from the interactive shell left open after the tool + // exits (window persists via `powershell -NoExit`). + let mut leftover_vars: Vec<&str> = HCOM_IDENTITY_VARS.to_vec(); + leftover_vars.extend(["HCOM_TAG", "HCOM_CODEX_SANDBOX_MODE"]); + let leftover: Vec = leftover_vars.iter().map(|v| format!("Env:{v}")).collect(); + writeln!( + f, + "Remove-Item {} -ErrorAction SilentlyContinue", + leftover.join(",") + )?; + writeln!( + f, + "Remove-Item -Force -ErrorAction SilentlyContinue {}", + ps_quote(&script_file.to_string_lossy()) + )?; + } else if !background { + writeln!(f, "$hcom_status = $LASTEXITCODE")?; + writeln!( + f, + "Remove-Item -Force -ErrorAction SilentlyContinue {}", + ps_quote(&script_file.to_string_lossy()) + )?; + writeln!(f, "exit $hcom_status")?; + } Ok(()) } @@ -839,7 +1227,7 @@ where /// Inputs to terminal command template substitution. /// /// All fields are borrowed and may be empty; unknown placeholders are left -/// in place by `parse_terminal_command` (no substitution panics). +/// in place by `substitute_open_argv` (no substitution panics). #[derive(Default, Clone, Copy)] pub(crate) struct TerminalCommandContext<'a> { pub script: &'a str, @@ -852,9 +1240,18 @@ pub(crate) struct TerminalCommandContext<'a> { pub pane_title: Option<&'a str>, } -/// Parse terminal command template safely to prevent shell injection. -fn parse_terminal_command(template: &str, ctx: TerminalCommandContext<'_>) -> Result> { - if !template.contains("{script}") { +/// Substitute placeholders into an open-command argv template, per element. +/// +/// Each element of `template` is one argument (no shell splitting). Placeholders +/// are replaced inside each element with `String::replace`, so a Windows path +/// like `C:\Users\x\s.ps1` substituted into the `{script}` element survives +/// intact (no backslash mangling, no re-quoting). Requires at least one element +/// to contain `{script}`. +fn substitute_open_argv( + template: &[String], + ctx: TerminalCommandContext<'_>, +) -> Result> { + if !template.iter().any(|p| p.contains("{script}")) { bail!( "Custom terminal command must include {{script}} placeholder\n\ Example: open -n -a kitty.app --args bash \"{{script}}\"" @@ -866,83 +1263,85 @@ fn parse_terminal_command(template: &str, ctx: TerminalCommandContext<'_>) -> Re .filter(|s| !s.is_empty()) .unwrap_or(ctx.instance_name); - let mut replaced = Vec::new(); - let mut placeholder_found = false; - for mut part in shell_split(template)? { - for (placeholder, value) in [ - ("{process_id}", ctx.process_id), - ("{cwd}", ctx.cwd), - ("{instance_name}", ctx.instance_name), - ("{tool}", ctx.tool), - ("{pane_title}", pane_title), - ] { - if part.contains(placeholder) { - part = part.replace(placeholder, value); + let replaced: Vec = template + .iter() + .map(|part| { + let mut part = part.clone(); + for (placeholder, value) in [ + ("{process_id}", ctx.process_id), + ("{cwd}", ctx.cwd), + ("{instance_name}", ctx.instance_name), + ("{tool}", ctx.tool), + ("{pane_title}", pane_title), + ("{script}", ctx.script), + ] { + if part.contains(placeholder) { + part = part.replace(placeholder, value); + } } - } - if part.contains("{script}") { - part = part.replace("{script}", ctx.script); - placeholder_found = true; - } - replaced.push(part); - } - - if !placeholder_found { - bail!("{{script}} placeholder not found after parsing"); - } + part + }) + .collect(); Ok(replaced) } -/// Shell-split a string. -fn shell_split(s: &str) -> Result> { - let mut tokens = Vec::new(); - let mut current = String::new(); - let mut in_single = false; - let mut in_double = false; - let mut escape_next = false; +/// Substitute placeholders into a close-command argv template, per element. +/// +/// Mirrors the open path but for close placeholders. Returns `None` (caller +/// treats as "skip the close") when a required placeholder appears in the +/// template but the corresponding value is empty — preserving the previous +/// `close_terminal_pane` skip semantics. `effective_pane_id` is the resolved +/// pane id (caller falls back from `pane_id` to `terminal_id`). +fn substitute_close_argv( + template: &[String], + pid: u32, + effective_pane_id: &str, + process_id: &str, + terminal_id: &str, +) -> Option> { + let needs = |ph: &str| template.iter().any(|p| p.contains(ph)); - for ch in s.chars() { - if escape_next { - current.push(ch); - escape_next = false; - continue; - } - if ch == '\\' && !in_single { - escape_next = true; - continue; - } - if ch == '\'' && !in_double { - in_single = !in_single; - continue; - } - if ch == '"' && !in_single { - in_double = !in_double; - continue; - } - if ch.is_whitespace() && !in_single && !in_double { - if !current.is_empty() { - tokens.push(std::mem::take(&mut current)); - } - continue; - } - current.push(ch); + if needs("{pane_id}") && effective_pane_id.is_empty() { + return None; } - - if in_single || in_double { - bail!("Unmatched quote in command"); + if needs("{process_id}") && process_id.is_empty() { + return None; } - - if !current.is_empty() { - tokens.push(current); + if needs("{id}") && terminal_id.is_empty() { + return None; } - Ok(tokens) + let pid_str = pid.to_string(); + let argv: Vec = template + .iter() + .map(|part| { + let mut part = part.clone(); + for (placeholder, value) in [ + ("{pid}", pid_str.as_str()), + ("{pane_id}", effective_pane_id), + ("{process_id}", process_id), + ("{id}", terminal_id), + ] { + if part.contains(placeholder) { + part = part.replace(placeholder, value); + } + } + part + }) + .collect(); + + Some(argv) } -/// Get macOS Terminal.app launch command. -fn get_macos_terminal_command() -> String { - rewrite_macos_open_app_command("open -a Terminal {script}", "Terminal") +/// Get macOS Terminal.app launch argv ({script} substituted by the caller). +fn get_macos_terminal_argv() -> Vec { + let mut argv: Vec = ["open", "-a", "Terminal", "{script}"] + .iter() + .map(|s| s.to_string()) + .collect(); + rewrite_macos_open_app_argv(&mut argv, "Terminal"); + argv } /// Escape a string for use inside a YAML double-quoted scalar. @@ -1059,6 +1458,16 @@ fn write_warp_launch_config_at( Ok(yaml_path) } +/// Human-readable name for the Windows default-terminal fallback, mirroring +/// `windows_default_terminal_template`'s `has_wt` branch. +fn windows_default_terminal_display_name(has_wt: bool) -> &'static str { + if has_wt { + "Windows Terminal" + } else { + "cmd.exe" + } +} + /// Return a human-readable name for the platform's built-in fallback terminal /// (used when `terminal = "default"` and no terminal is detected from env). pub fn get_default_fallback_terminal_name() -> &'static str { @@ -1084,6 +1493,7 @@ pub fn get_default_fallback_terminal_name() -> &'static str { "none" } } + "Windows" => windows_default_terminal_display_name(which_bin("wt").is_some()), _ => "unknown", } } @@ -1126,6 +1536,48 @@ fn get_linux_terminal_argv() -> Option> { None } +/// Default Windows terminal launch argv template ({script} substituted by +/// the caller). Host-testable: takes `has_wt` explicitly instead of probing. +/// +/// Prefers Windows Terminal, which parses everything after `--` as a literal +/// argv so a script path with spaces stays a single argument. Without it, opens +/// a fresh console via cmd's `start` (the empty arg is start's window-title +/// slot; `{script}` is bare — `Command`'s Windows argv quoting adds quotes +/// only when needed). Either way the shell runs the generated `.ps1` with the +/// execution policy bypassed and stays open (`-NoExit`) for the new window. +fn windows_default_terminal_template(has_wt: bool) -> Vec { + let to_vec = |a: &[&str]| a.iter().map(|s| s.to_string()).collect::>(); + if has_wt { + return to_vec(&[ + "wt", + "--", + "powershell", + "-ExecutionPolicy", + "Bypass", + "-NoExit", + "-File", + "{script}", + ]); + } + to_vec(&[ + "cmd", + "/c", + "start", + "", + "powershell", + "-ExecutionPolicy", + "Bypass", + "-NoExit", + "-File", + "{script}", + ]) +} + +/// Default Windows terminal launch argv ({script} substituted by the caller). +fn get_windows_terminal_argv() -> Vec { + windows_default_terminal_template(which_bin("wt").is_some()) +} + /// Spawn terminal process, detached when inside AI tools. /// /// Returns (success, stdout_first_line) — stdout captured for {id} in close commands. @@ -1193,15 +1645,16 @@ pub fn is_zellij_preset(preset_name: &str) -> bool { if preset_name == "zellij" { return true; } + crate::config::get_merged_preset(preset_name).is_some_and(|p| is_zellij_merged(&p)) +} - crate::config::get_merged_preset(preset_name).is_some_and(|preset| { - preset.binary.as_deref() == Some("zellij") - || preset.open.starts_with("zellij ") - || preset - .close - .as_deref() - .is_some_and(|close| close.starts_with("zellij ")) - }) +pub fn is_zellij_merged(preset: &crate::config::MergedPreset) -> bool { + let is_zellij_argv0 = |argv: &[String]| argv.first().map(String::as_str) == Some("zellij"); + preset.binary.as_deref() == Some("zellij") + || is_zellij_argv0(&preset.open) + || preset.open_windows.as_deref().is_some_and(is_zellij_argv0) + || preset.close.as_deref().is_some_and(is_zellij_argv0) + || preset.close_windows.as_deref().is_some_and(is_zellij_argv0) } fn validate_terminal_launch_output( @@ -1243,6 +1696,36 @@ fn spawn_terminal_process(argv: &[String], inside_ai_tool: bool) -> Result<(bool let launcher_env = get_launcher_env(); let env_vec: Vec<(String, String)> = launcher_env.into_iter().collect(); + #[cfg(windows)] + if argv.first().is_some_and(|arg| { + Path::new(arg) + .file_stem() + .is_some_and(|stem| stem.eq_ignore_ascii_case("wezterm")) + }) && argv.get(1).is_some_and(|arg| arg == "start") + { + use std::os::windows::process::CommandExt; + + const DETACHED_PROCESS: u32 = 0x0000_0008; + const CREATE_NEW_PROCESS_GROUP: u32 = 0x0000_0200; + Command::new(&argv[0]) + .args(&argv[1..]) + .env_clear() + .envs(env_vec.iter().map(|(k, v)| (k.as_str(), v.as_str()))) + .stdin(std::process::Stdio::null()) + .stdout(std::process::Stdio::null()) + .stderr(std::process::Stdio::null()) + .creation_flags(DETACHED_PROCESS | CREATE_NEW_PROCESS_GROUP) + .spawn() + .map_err(|err| { + anyhow!(maybe_append_ai_tool_launch_hint( + format!("Failed to spawn terminal process: {err}"), + argv, + inside_ai_tool, + )) + })?; + return Ok((true, String::new())); + } + if inside_ai_tool { // Fully detach: don't let AI tool's PTY capture our output let launch_dir = paths::hcom_path(&[paths::LAUNCH_DIR]); @@ -1391,7 +1874,9 @@ pub fn launch_terminal( // Determine script extension after terminal mode resolution so explicit // Terminal.app uses the macOS `.command` launcher just like auto-detect. - let extension = if should_use_command_extension(background, &terminal_mode) { + let extension = if cfg!(windows) { + ".ps1" + } else if should_use_command_extension(background, &terminal_mode) { ".command" } else { ".sh" @@ -1411,16 +1896,28 @@ pub fn launch_terminal( fs::create_dir_all(parent).ok(); } - // Create script - create_bash_script( - &script_file, - &final_env, - cwd, - command, - background, - None, - opens_new_window, - )?; + // Create script. Windows uses a native PowerShell script; Unix uses bash. + if cfg!(windows) { + create_powershell_script( + &script_file, + &final_env, + cwd, + command, + background, + None, + opens_new_window, + )?; + } else { + create_bash_script( + &script_file, + &final_env, + cwd, + command, + background, + None, + opens_new_window, + )?; + } // Background mode if background { @@ -1431,25 +1928,23 @@ pub fn launch_terminal( let log_handle = fs::File::create(&log_file).context("Failed to create log file")?; - let mut cmd = Command::new("bash"); + let mut cmd = if cfg!(windows) { + let mut c = Command::new("powershell"); + c.args(["-ExecutionPolicy", "Bypass", "-File"]); + c + } else { + Command::new(resolve_bash_command()) + }; cmd.arg(&script_file) .stdin(std::process::Stdio::null()) .stdout(log_handle.try_clone()?) .stderr(log_handle); - // Detach child into its own session so it survives parent exit (no SIGHUP) - #[cfg(unix)] - { - use std::os::unix::process::CommandExt; - unsafe { - cmd.pre_exec(|| { - libc::setsid(); - Ok(()) - }); - } - } - - let child = cmd.spawn().context("Failed to launch background process")?; + // Detach child into its own session so it survives parent exit (no + // SIGHUP), without leaking a captured caller's stdout/stderr handles + // into the long-lived runner on Windows. + let child = crate::sys::process::spawn_detached(&mut cmd) + .context("Failed to launch background process")?; // Brief health check std::thread::sleep(std::time::Duration::from_millis(200)); @@ -1477,32 +1972,39 @@ pub fn launch_terminal( if let Some(dir) = cwd { std::env::set_current_dir(dir).ok(); } - // Use execve to replace this process entirely - use std::ffi::CString; - let bash_path = which_bin("bash").unwrap_or_else(|| "/bin/bash".to_string()); - let bash = CString::new(bash_path).unwrap(); - let arg0 = CString::new("bash").unwrap(); - let arg1 = CString::new(script_file.to_string_lossy().as_ref()).unwrap(); - let argv_ptrs: Vec<*const libc::c_char> = - vec![arg0.as_ptr(), arg1.as_ptr(), std::ptr::null()]; - let env_cstrings: Vec = full_env - .iter() - .filter_map(|(k, v)| CString::new(format!("{}={}", k, v)).ok()) - .collect(); - let mut env_ptrs: Vec<*const libc::c_char> = - env_cstrings.iter().map(|c| c.as_ptr()).collect(); - env_ptrs.push(std::ptr::null()); - // execve replaces process; never returns on success - unsafe { - libc::execve(bash.as_ptr(), argv_ptrs.as_ptr(), env_ptrs.as_ptr()); - } - bail!("execve failed: {}", std::io::Error::last_os_error()); + // Replace this process entirely with the script's shell. + let mut cmd = if cfg!(windows) { + let mut c = Command::new("powershell"); + c.args(["-ExecutionPolicy", "Bypass", "-File"]); + c + } else { + Command::new(resolve_bash_command()) + }; + cmd.arg(script_file).env_clear().envs(&full_env); + let err = crate::sys::process::exec_replace(cmd); + bail!("exec failed: {}", err); } // New window / custom command mode - let custom_cmd: Option = if terminal_mode == "default" { + let custom_cmd: Option> = if terminal_mode == "default" { None } else if crate::config::get_merged_preset(&terminal_mode).is_some() { + // Built-in presets not available on this platform are rejected here too + // (not just at config-validation time) so HCOM_TERMINAL can't bypass the + // check. User-defined TOML presets declare no platform and are exempt. + if crate::config::is_known_terminal_preset_pub(&terminal_mode) + && !crate::config::is_user_defined_preset(&terminal_mode) + && !crate::config::terminal_preset_supported_on( + &terminal_mode, + platform::platform_name(), + ) + { + bail!( + "terminal preset '{}' is not available on {}", + terminal_mode, + platform::platform_name() + ); + } // Known preset — check kitty remote control requirements if terminal_mode == "kitty-tab" || terminal_mode == "kitty-split" { let listen_on = std::env::var("KITTY_LISTEN_ON") @@ -1520,26 +2022,45 @@ pub fn launch_terminal( ); } } - let mut cmd = resolve_terminal_preset(&terminal_mode).unwrap_or_default(); - // Inject --to for kitty commands launched outside kitty - if !kitty_socket.is_empty() && cmd.contains("kitten @") && !cmd.contains("--to") { - cmd = cmd.replace( - "kitten @", - &format!("kitten @ --to {}", shell_quote(&kitty_socket)), - ); + let mut argv = resolve_terminal_open_argv(&terminal_mode).unwrap_or_default(); + // Inject `--to ` (after the `@`) for kitten commands launched + // outside kitty. Splice as separate argv elements — no shell quoting. + if !kitty_socket.is_empty() + && !kitty_socket.starts_with("fd:") + && argv.first().map(String::as_str) == Some("kitten") + && argv.get(1).map(String::as_str) == Some("@") + && !argv.iter().any(|a| a == "--to") + { + argv.splice(2..2, ["--to".to_string(), kitty_socket.clone()]); } - // Target launcher's tab for splits + // Target launcher's tab for splits: insert `--match window_id:` + // before the `--` separator. if (terminal_mode == "kitty-tab" || terminal_mode == "kitty-split") && let Ok(wid) = std::env::var("KITTY_WINDOW_ID") && !wid.is_empty() - && cmd.contains(" -- ") + && let Some(sep) = argv.iter().position(|a| a == "--") { - cmd = cmd.replacen(" -- ", &format!(" --match window_id:{} -- ", wid), 1); + argv.splice( + sep..sep, + ["--match".to_string(), format!("window_id:{wid}")], + ); } - Some(cmd) + Some(argv) } else { - // Custom command template - Some(terminal_mode.clone()) + // Custom command template string (HCOM_TERMINAL / config custom command). + // Tokenize once via the double-quote-aware splitter; the array-form TOML + // preset path never reaches here (those are known presets). + // + // `shell_split` itself treats an unquoted `\` as a literal character on + // Windows (instead of a POSIX escape), so Windows paths like + // `C:\Tools\term.exe` supplied via HCOM_TERMINAL survive intact without + // any pre-processing here. + let argv = match crate::tools::args_common::shell_split(&terminal_mode, cfg!(windows)) { + Ok(argv) if !argv.is_empty() => argv, + Ok(_) => bail!("custom terminal command is empty"), + Err(e) => bail!("invalid quoting in custom terminal command: {e}"), + }; + Some(windows_shellify_custom_argv(argv)?) }; let script_str = script_file.to_string_lossy().to_string(); @@ -1592,7 +2113,9 @@ pub fn launch_terminal( .map(|s| s.as_str()) .filter(|s| !s.is_empty()), }; - let final_argv = parse_terminal_command(&cmd_template, ctx)?; + // The Windows `.ps1`-via-PowerShell variant is already selected by the + // preset's `open_argv(cfg!(windows))`; no text rewrite needed. + let final_argv = substitute_open_argv(&cmd_template, ctx)?; let (success, captured_id) = spawn_terminal_process(&final_argv, inside_ai_tool)?; write_terminal_id(env, &captured_id); if success { @@ -1635,8 +2158,8 @@ pub fn launch_terminal( } let argv = match platform::platform_name() { - "Darwin" => parse_terminal_command( - &get_macos_terminal_command(), + "Darwin" => substitute_open_argv( + &get_macos_terminal_argv(), TerminalCommandContext { script: &script_str, process_id: env.get("HCOM_PROCESS_ID").map(|s| s.as_str()).unwrap_or(""), @@ -1651,12 +2174,14 @@ pub fn launch_terminal( )?, "Linux" => get_linux_terminal_argv() .ok_or_else(|| anyhow::anyhow!("No supported terminal emulator found"))?, + "Windows" => get_windows_terminal_argv(), other => bail!("Unsupported platform: {}", other), }; let final_argv: Vec = if platform::platform_name() == "Darwin" { argv } else { + // Linux/Windows defaults carry only `{script}` placeholders. argv.iter() .map(|a| a.replace("{script}", &script_str)) .collect() @@ -1702,19 +2227,21 @@ pub fn close_terminal_pane( kitty_listen_on: &str, terminal_id: &str, zellij_session_name: &str, -) -> bool { +) -> PaneCloseResult { + let failed_without_command = || PaneCloseResult { + closed: false, + retry_command: None, + }; let merged = match crate::config::get_merged_preset(preset_name) { Some(p) => p, - None => return false, + None => return failed_without_command(), }; - let close_template = match merged.close { - Some(ref c) => c.clone(), - None => return false, + let close_template = match merged.close_argv(cfg!(windows)) { + Some(c) => c, + None => return failed_without_command(), }; - let mut close_cmd = close_template; - // Determine effective pane_id (fall back to terminal_id) let effective_pane_id = if pane_id.is_empty() && !terminal_id.is_empty() { terminal_id @@ -1722,93 +2249,136 @@ pub fn close_terminal_pane( pane_id }; - // Skip if command needs a placeholder we don't have - if close_cmd.contains("{pane_id}") && effective_pane_id.is_empty() { - return false; - } - if close_cmd.contains("{process_id}") && process_id.is_empty() { - return false; - } - if close_cmd.contains("{id}") && terminal_id.is_empty() { - return false; - } - - close_cmd = close_cmd.replace("{pid}", &pid.to_string()); - close_cmd = close_cmd.replace("{pane_id}", effective_pane_id); - close_cmd = close_cmd.replace("{process_id}", process_id); - close_cmd = close_cmd.replace("{id}", terminal_id); + // Substitute close placeholders per-element. Returns None when a required + // placeholder is present but its value is empty (skip the close). + let mut argv = match substitute_close_argv( + &close_template, + pid, + effective_pane_id, + process_id, + terminal_id, + ) { + Some(a) => a, + None => return failed_without_command(), + }; - let is_zellij = is_zellij_preset(preset_name); + let is_zellij = is_zellij_merged(&merged); let zellij_before_close = if is_zellij { match zellij_terminal_pane_exists(zellij_session_name, effective_pane_id) { Some(true) => Some(true), - Some(false) => return false, + Some(false) => return failed_without_command(), None => None, } } else { None }; - if is_zellij && !zellij_session_name.is_empty() && close_cmd.starts_with("zellij action ") { - close_cmd = format!( - "zellij --session {}{}", - shell_quote(zellij_session_name), - &close_cmd["zellij".len()..] + // Splice `--session ` right after `zellij` for `zellij action ...`. + if is_zellij + && !zellij_session_name.is_empty() + && argv.first().map(String::as_str) == Some("zellij") + && argv.get(1).map(String::as_str) == Some("action") + { + argv.splice( + 1..1, + ["--session".to_string(), zellij_session_name.to_string()], ); } - // Resolve binary path via app bundle fallback + // Inject `--to ` (after the `@`) for kitten commands when we have + // the socket path. Must run before the binary-path rewrite below, because + // that rewrite replaces argv[0] with an absolute path and the "kitten" + // string check would no longer match. + if argv.first().map(String::as_str) == Some("kitten") + && argv.get(1).map(String::as_str) == Some("@") + && !kitty_listen_on.is_empty() + && !argv.iter().any(|a| a == "--to") + && !kitty_listen_on.starts_with("fd:") + { + argv.splice(2..2, ["--to".to_string(), kitty_listen_on.to_string()]); + } + + // Resolve binary path via app bundle fallback (replace argv[0]). if let Some(ref binary) = merged.binary { let app_name = merged.app_name.as_deref().unwrap_or(preset_name); if let Some(full_path) = resolve_binary_path(binary, Some(app_name), preset_name) - && close_cmd.starts_with(binary.as_str()) + && argv.first().map(String::as_str) == Some(binary.as_str()) { - close_cmd = format!("{}{}", full_path, &close_cmd[binary.len()..]); + argv[0] = full_path; } } - if close_cmd.starts_with("kitten ") + if argv.first().map(String::as_str) == Some("kitten") && let Some(full_path) = find_kitten_binary() { - close_cmd = format!( - "{}{}", - shell_quote(&full_path), - &close_cmd["kitten".len()..] - ); + argv[0] = full_path; } - // Inject --to for kitten commands when we have the socket path - if close_cmd.contains("kitten @") - && !kitty_listen_on.is_empty() - && !close_cmd.contains("--to") - && !kitty_listen_on.starts_with("fd:") - { - close_cmd = close_cmd.replace( - "kitten @", - &format!("kitten @ --to {}", shell_quote(kitty_listen_on)), - ); + if argv.is_empty() { + return failed_without_command(); } + let retry_command = format_close_command(&argv); + let failed = || PaneCloseResult { + closed: false, + retry_command: Some(retry_command.clone()), + }; - let output = Command::new("sh") - .args(["-c", &close_cmd]) + // Run the close command directly (no shell) so it works on Windows too. + let mut child = match Command::new(&argv[0]) + .args(&argv[1..]) .stdin(std::process::Stdio::null()) .stdout(std::process::Stdio::null()) - .stderr(std::process::Stdio::piped()) - .output(); + .stderr(std::process::Stdio::inherit()) + .spawn() + { + Ok(child) => child, + Err(err) => { + eprintln!("Failed to close {preset_name} pane: {err}"); + return failed(); + } + }; - let Ok(output) = output else { - return false; + let started = Instant::now(); + let status = loop { + match child.try_wait() { + Ok(Some(status)) => break status, + Ok(None) if started.elapsed() < TERMINAL_CLOSE_TIMEOUT => { + std::thread::sleep(Duration::from_millis(25)); + } + Ok(None) => { + let _ = child.kill(); + eprintln!( + "Timed out after {}s closing {preset_name} pane {effective_pane_id}", + TERMINAL_CLOSE_TIMEOUT.as_secs() + ); + return failed(); + } + Err(err) => { + let _ = child.kill(); + eprintln!("Failed waiting for {preset_name} pane close: {err}"); + return failed(); + } + } }; - if !output.status.success() { - return false; + + if !status.success() { + eprintln!("Failed to close {preset_name} pane {effective_pane_id}: {status}"); + return failed(); } if is_zellij { - return zellij_before_close == Some(true) + let closed = zellij_before_close == Some(true) && zellij_terminal_pane_exists(zellij_session_name, effective_pane_id) == Some(false); + return PaneCloseResult { + closed, + retry_command: (!closed).then_some(retry_command), + }; } - true + PaneCloseResult { + closed: true, + retry_command: None, + } } fn zellij_terminal_pane_exists(session_name: &str, pane_id: &str) -> Option { @@ -1848,8 +2418,8 @@ pub fn kill_process( kitty_listen_on: &str, terminal_id: &str, zellij_session_name: &str, -) -> (KillResult, bool) { - let pane_closed = if !preset_name.is_empty() { +) -> (KillResult, bool, Option) { + let pane_close = if !preset_name.is_empty() { close_terminal_pane( pid, preset_name, @@ -1860,22 +2430,24 @@ pub fn kill_process( zellij_session_name, ) } else { - false + PaneCloseResult { + closed: false, + retry_command: None, + } }; // SIGTERM the process group - let result = unsafe { libc::killpg(pid as i32, libc::SIGTERM) }; - let kill_result = if result == 0 { - KillResult::Sent - } else { - match std::io::Error::last_os_error().raw_os_error() { - Some(libc::ESRCH) => KillResult::AlreadyDead, - Some(libc::EPERM) => KillResult::PermissionDenied, - _ => KillResult::AlreadyDead, - } + use crate::sys::process::GroupSignal; + let kill_result = match crate::sys::process::terminate_group(pid) { + GroupSignal::Sent => KillResult::Sent, + #[cfg(unix)] + GroupSignal::PermissionDenied => KillResult::PermissionDenied, + GroupSignal::NotFound => KillResult::AlreadyDead, + #[cfg(unix)] + GroupSignal::Other => KillResult::AlreadyDead, }; - (kill_result, pane_closed) + (kill_result, pane_close.closed, pane_close.retry_command) } /// Resolve terminal info from the canonical preset fields plus launch_context metadata. @@ -1965,8 +2537,68 @@ fn zellij_pane_id_from_terminal_id(terminal_id: &str) -> Option { mod tests { use super::*; use serial_test::serial; + #[cfg(unix)] use std::os::unix::process::ExitStatusExt; + fn shellify(argv: &[&str]) -> Vec { + shellify_bash_script_pair(argv.iter().map(|s| s.to_string()).collect()) + } + + const PS: &[&str] = &[ + "powershell", + "-ExecutionPolicy", + "Bypass", + "-NoExit", + "-File", + "{script}", + ]; + + #[test] + fn shellify_rewrites_leading_bash_script() { + assert_eq!(shellify(&["bash", "{script}"]), PS); + } + + #[test] + fn shellify_rewrites_non_leading_bash_script() { + // Finding 12: bash is argv[2], not argv[0]; must still be rewritten. + let mut expected = vec!["myterm".to_string(), "--".to_string()]; + expected.extend(PS.iter().map(|s| s.to_string())); + assert_eq!(shellify(&["myterm", "--", "bash", "{script}"]), expected); + // `gnome-terminal -- bash {script}` is adjacent → rewritten, not bailed. + let mut expected = vec!["gnome-terminal".to_string(), "--".to_string()]; + expected.extend(PS.iter().map(|s| s.to_string())); + assert_eq!( + shellify(&["gnome-terminal", "--", "bash", "{script}"]), + expected + ); + } + + #[test] + fn shellify_rewrites_bash_family_interpreters() { + // B-3+B-4: any bash-family token (bash.exe, /bin/bash) adjacent to + // {script} is rewritten, not just the exact `bash`. + assert_eq!(shellify(&["bash.exe", "{script}"]), PS); + assert_eq!(shellify(&["/bin/bash", "{script}"]), PS); + } + + #[test] + fn shellify_leaves_bash_with_flags_alone() { + // Finding 15: `bash -c {script}` has no adjacent `{script}`, so the + // pair never matches and the argv is left intact (no broken splice). + assert_eq!( + shellify(&["bash", "-c", "{script}"]), + vec!["bash", "-c", "{script}"] + ); + } + + #[test] + fn shellify_leaves_non_bash_alone() { + assert_eq!( + shellify(&["myterm", "-e", "{script}"]), + vec!["myterm", "-e", "{script}"] + ); + } + struct EnvGuard(Vec<(&'static str, Option)>); impl EnvGuard { @@ -2015,6 +2647,7 @@ mod tests { } #[test] + #[cfg(unix)] fn test_termux_dispatch_rejects_nonzero_exit_status() { let status = std::process::ExitStatus::from_raw(1 << 8); let err = validate_termux_dispatch_status(status) @@ -2043,27 +2676,9 @@ mod tests { assert_eq!(shell_quote("it's"), "'it'\\''s'"); } - #[test] - fn test_shell_split_basic() { - let parts = shell_split("foo bar baz").unwrap(); - assert_eq!(parts, vec!["foo", "bar", "baz"]); - } - - #[test] - fn test_shell_split_quoted() { - let parts = shell_split("foo 'bar baz' qux").unwrap(); - assert_eq!(parts, vec!["foo", "bar baz", "qux"]); - } - - #[test] - fn test_shell_split_double_quoted() { - let parts = shell_split(r#"foo "bar baz" qux"#).unwrap(); - assert_eq!(parts, vec!["foo", "bar baz", "qux"]); - } - - #[test] - fn test_shell_split_unmatched_quote() { - assert!(shell_split("foo 'bar").is_err()); + /// Build a `Vec` argv from `&str` literals (test helper). + fn argv(parts: &[&str]) -> Vec { + parts.iter().map(|s| s.to_string()).collect() } #[test] @@ -2120,6 +2735,7 @@ mod tests { } #[test] + #[cfg(unix)] fn test_zellij_session_ambiguity_stderr_fails_launch_even_with_exit_zero() { let output = std::process::Output { status: std::process::ExitStatus::from_raw(0), @@ -2180,6 +2796,8 @@ mod tests { assert_eq!(dir, Path::new("/h/.warp/launch_configurations")); } + // Unix-only: Warp is a macOS terminal and the assertion pins POSIX paths. + #[cfg(unix)] #[test] fn test_write_warp_launch_config_writes_to_stable_dir() { let tmp = tempfile::tempdir().unwrap(); @@ -2197,6 +2815,117 @@ mod tests { assert!(content.contains("cwd: \"/some/dir\"")); } + #[test] + fn test_ps_quote_doubles_single_quotes() { + assert_eq!(ps_quote("plain"), "'plain'"); + assert_eq!(ps_quote("it's"), "'it''s'"); + assert_eq!(ps_quote(""), "''"); + } + + #[test] + fn test_ps_env_assignments_sorted_and_validated() { + let mut env = HashMap::new(); + env.insert("ZED".to_string(), "z".to_string()); + env.insert("ABE".to_string(), "a'b".to_string()); + env.insert("1bad".to_string(), "skip".to_string()); // invalid name dropped + let lines = ps_env_assignments(&env); + assert_eq!( + lines, + vec![ + "$env:ABE = 'a''b'".to_string(), + "$env:ZED = 'z'".to_string(), + ] + ); + } + + #[test] + fn test_create_powershell_script_window_mode() { + let tmp = tempfile::tempdir().unwrap(); + let script = tmp.path().join("launch.ps1"); + let mut env = HashMap::new(); + env.insert("HCOM_TOOL".to_string(), "claude".to_string()); + create_powershell_script( + &script, + &env, + Some("/work/dir"), + "claude --foo", + false, // background + None, + true, // opens_new_window + ) + .unwrap(); + assert!( + std::fs::read(&script) + .unwrap() + .starts_with(&[0xEF, 0xBB, 0xBF]) + ); + let content = std::fs::read_to_string(&script).unwrap(); + assert!(content.contains("$Host.UI.RawUI.WindowTitle = \"hcom: starting Claude Code...\"")); + assert!(content.contains("Write-Host \"Starting Claude Code...\"")); + assert!(content.contains("Remove-Item Env:")); + assert!(content.contains("$env:HCOM_TOOL = 'claude'")); + assert!(content.contains("Set-Location '/work/dir'")); + // The command args survive whether or not the tool resolved to a full + // path (bare `claude --foo` or call-operator `& '' --foo`). + assert!(content.contains("--foo")); + // Window mode self-deletes but does not `exit` (window persists via -NoExit). + assert!(content.contains("Remove-Item -Force -ErrorAction SilentlyContinue")); + assert!(!content.contains("exit $hcom_status")); + } + + #[test] + fn test_create_powershell_script_run_once_exits() { + let tmp = tempfile::tempdir().unwrap(); + let script = tmp.path().join("launch.ps1"); + let env = HashMap::new(); + create_powershell_script( + &script, &env, None, "codex", false, // background + None, false, // run-once (not a new window) + ) + .unwrap(); + let content = std::fs::read_to_string(&script).unwrap(); + assert!(content.contains("$hcom_status = $LASTEXITCODE")); + assert!(content.contains("exit $hcom_status")); + assert!(!content.contains("Set-Location")); + } + + #[test] + fn test_build_env_string_powershell_format() { + let mut env = HashMap::new(); + env.insert("HCOM_A".to_string(), "x".to_string()); + env.insert("HCOM_B".to_string(), "y'z".to_string()); + let out = build_env_string(&env, "powershell"); + assert_eq!(out, "$env:HCOM_A = 'x'\n$env:HCOM_B = 'y''z'"); + } + + #[test] + fn test_wezterm_open_argv_selects_powershell_on_windows() { + // The PowerShell variant is now selected by the preset's PlatformArgv, + // not a text rewrite. Confirm the merged preset surfaces it. + let merged = crate::config::get_merged_preset("wezterm").unwrap(); + let win = merged.open_argv(true); + assert!(win.iter().any(|a| a == "powershell")); + assert!(win.iter().any(|a| a == "-File")); + assert!(!win.iter().any(|a| a == "bash")); + let unix = merged.open_argv(false); + assert!(unix.iter().any(|a| a == "bash")); + } + + #[test] + fn test_mintty_open_argv_has_no_bash() { + let merged = crate::config::get_merged_preset("mintty").unwrap(); + let argv = merged.open_argv(true); + assert_eq!(argv.first().map(String::as_str), Some("mintty")); + assert!( + !argv.iter().any(|a| a == "bash"), + "mintty must not hand a .ps1 to bash" + ); + assert!(argv.iter().any(|a| a == "powershell")); + } + + // Unix-only: "/abs/path" isn't absolute on Windows (no drive), so it would + // be rewritten to the current dir. + #[cfg(unix)] #[test] fn test_resolve_warp_cwd_keeps_absolute() { let home = Path::new("/h"); @@ -2253,7 +2982,8 @@ mod tests { let preset = crate::shared::terminal_presets::get_terminal_preset("warp").unwrap(); assert_eq!(preset.app_name, Some("Warp")); assert_eq!(preset.binary, None); - assert!(preset.open.contains("warp://launch/hcom-{process_id}")); + let open = preset.open.select(false).unwrap(); + assert!(open.contains(&"warp://launch/hcom-{process_id}")); assert_eq!(preset.platforms, &["Darwin"]); } @@ -2361,46 +3091,102 @@ mod tests { } #[test] - fn test_parse_terminal_command_basic() { - let argv = - parse_terminal_command("open -a Terminal {script}", ctx_with_script("/tmp/test.sh")) - .unwrap(); - assert_eq!(argv, vec!["open", "-a", "Terminal", "/tmp/test.sh"]); + fn test_substitute_open_argv_basic() { + let out = substitute_open_argv( + &argv(&["open", "-a", "Terminal", "{script}"]), + ctx_with_script("/tmp/test.sh"), + ) + .unwrap(); + assert_eq!(out, vec!["open", "-a", "Terminal", "/tmp/test.sh"]); } #[test] - fn test_rewrite_open_command_with_app_path() { - let rewritten = rewrite_open_command_with_app_path( - "open -a Terminal {script}", - Path::new("/System/Applications/Utilities/Terminal.app"), + fn test_substitute_open_argv_preserves_windows_path() { + // A backslashed Windows .ps1 path substituted into a single argv element + // must survive byte-for-byte (no shell splitting, no escaping). + let out = substitute_open_argv( + &argv(&["wt", "--", "powershell", "-File", "{script}"]), + ctx_with_script(r"C:\Users\x\s.ps1"), ) .unwrap(); - assert_eq!(rewritten, "open -a Terminal {script}"); + assert_eq!( + out, + vec!["wt", "--", "powershell", "-File", r"C:\Users\x\s.ps1"] + ); } #[test] - fn test_rewrite_open_command_with_combined_flag() { - let rewritten = rewrite_open_command_with_app_path( - "open -na Ghostty.app --args -e bash {script}", - Path::new("/Applications/Ghostty.app"), + fn test_substitute_open_argv_process_id_element() { + // `HCOM_PROCESS_ID={process_id}` is one element; the placeholder is + // replaced inside it without needing quoting. + let out = substitute_open_argv( + &argv(&["kitty", "--env", "HCOM_PROCESS_ID={process_id}", "{script}"]), + TerminalCommandContext { + script: "/tmp/test.sh", + process_id: "abc-123", + ..TerminalCommandContext::default() + }, ) .unwrap(); assert_eq!( - rewritten, - "open -n /Applications/Ghostty.app --args -e bash '{script}'" + out, + vec!["kitty", "--env", "HCOM_PROCESS_ID=abc-123", "/tmp/test.sh"] ); } #[test] - fn test_rewrite_open_command_with_explicit_args() { - let rewritten = rewrite_open_command_with_app_path( - "open -a Terminal --args bash {script}", + fn test_rewrite_open_argv_with_app_path_keeps_plain_open_a() { + // No `--args` tail ⇒ leave `-a Terminal` intact (file-open form). + let mut v = argv(&["open", "-a", "Terminal", "{script}"]); + rewrite_open_argv_with_app_path( + &mut v, Path::new("/System/Applications/Utilities/Terminal.app"), - ) - .unwrap(); + ); + assert_eq!(v, vec!["open", "-a", "Terminal", "{script}"]); + } + + #[test] + fn test_rewrite_open_argv_with_combined_flag() { + let mut v = argv(&[ + "open", + "-na", + "Ghostty.app", + "--args", + "-e", + "bash", + "{script}", + ]); + rewrite_open_argv_with_app_path(&mut v, Path::new("/Applications/Ghostty.app")); assert_eq!( - rewritten, - "open /System/Applications/Utilities/Terminal.app --args bash '{script}'" + v, + vec![ + "open", + "-n", + "/Applications/Ghostty.app", + "--args", + "-e", + "bash", + "{script}" + ] + ); + } + + #[test] + fn test_rewrite_open_argv_with_explicit_args() { + let mut v = argv(&["open", "-a", "Terminal", "--args", "bash", "{script}"]); + rewrite_open_argv_with_app_path( + &mut v, + Path::new("/System/Applications/Utilities/Terminal.app"), + ); + assert_eq!( + v, + vec![ + "open", + "/System/Applications/Utilities/Terminal.app", + "--args", + "bash", + "{script}" + ] ); } @@ -2446,16 +3232,20 @@ mod tests { } #[test] - fn test_parse_terminal_command_missing_placeholder() { + fn test_substitute_open_argv_missing_placeholder() { assert!( - parse_terminal_command("open -a Terminal", ctx_with_script("/tmp/test.sh")).is_err() + substitute_open_argv( + &argv(&["open", "-a", "Terminal"]), + ctx_with_script("/tmp/test.sh") + ) + .is_err() ); } #[test] - fn test_parse_terminal_command_with_process_id() { - let argv = parse_terminal_command( - "tmux split -t {process_id} -- {script}", + fn test_substitute_open_argv_with_process_id() { + let out = substitute_open_argv( + &argv(&["tmux", "split", "-t", "{process_id}", "--", "{script}"]), TerminalCommandContext { script: "/tmp/test.sh", process_id: "abc-123", @@ -2464,15 +3254,15 @@ mod tests { ) .unwrap(); assert_eq!( - argv, + out, vec!["tmux", "split", "-t", "abc-123", "--", "/tmp/test.sh"] ); } #[test] fn test_waveterm_preset_uses_run_separator() { - let cmd = resolve_terminal_preset("waveterm").unwrap(); - let argv = parse_terminal_command( + let cmd = resolve_terminal_open_argv("waveterm").unwrap(); + let out = substitute_open_argv( &cmd, TerminalCommandContext { script: "/tmp/test.sh", @@ -2481,7 +3271,7 @@ mod tests { }, ) .unwrap(); - assert_eq!(argv, vec!["wsh", "run", "--", "bash", "/tmp/test.sh"]); + assert_eq!(out, vec!["wsh", "run", "--", "bash", "/tmp/test.sh"]); } #[test] @@ -2519,26 +3309,39 @@ mod tests { // (`◉ luna [claude]`) is pushed separately via `pane.rename` from the // delivery loop, not baked into the agent name. let preset = crate::shared::terminal_presets::get_terminal_preset("herdr").unwrap(); - assert!(preset.open.contains("{script}")); - assert!(preset.open.contains("{instance_name}")); + let open = preset.open.select(false).unwrap(); + assert!(open.contains(&"{script}")); + assert!(open.contains(&"{instance_name}")); assert!( - !preset.open.contains("{pane_title}"), + !open.contains(&"{pane_title}"), "herdr preset must not use {{pane_title}} as agent name" ); - assert!(preset.open.contains("{cwd}")); - assert!(!preset.open.contains("{process_id}")); + assert!(open.contains(&"{cwd}")); + assert!(!open.contains(&"{process_id}")); assert_eq!(preset.binary, Some("herdr")); // Close must use {pane_id} (stable raw `p_N`), not {id} (public // `-` which herdr renumbers when sibling panes close — a kill // batch addressing the public id can land on the wrong pane). - assert!(preset.close.unwrap().contains("{pane_id}")); + let close = preset.close.select(false).unwrap(); + assert!(close.contains(&"{pane_id}")); } #[test] - fn test_parse_herdr_terminal_command_uses_pane_title() { - let cmd = "herdr agent start {pane_title} --cwd {cwd} --no-focus -- bash {script}"; - let argv = parse_terminal_command( - cmd, + fn test_substitute_herdr_open_argv_uses_pane_title() { + let template = argv(&[ + "herdr", + "agent", + "start", + "{pane_title}", + "--cwd", + "{cwd}", + "--no-focus", + "--", + "bash", + "{script}", + ]); + let out = substitute_open_argv( + &template, TerminalCommandContext { script: "/tmp/test.sh", process_id: "abc-123", @@ -2550,7 +3353,7 @@ mod tests { ) .unwrap(); assert_eq!( - argv, + out, vec![ "herdr", "agent", @@ -2567,9 +3370,17 @@ mod tests { } #[test] - fn test_parse_terminal_command_pane_title_falls_back_to_instance_name() { - let argv = parse_terminal_command( - "herdr agent start {pane_title} -- bash {script}", + fn test_substitute_open_argv_pane_title_falls_back_to_instance_name() { + let out = substitute_open_argv( + &argv(&[ + "herdr", + "agent", + "start", + "{pane_title}", + "--", + "bash", + "{script}", + ]), TerminalCommandContext { script: "/tmp/test.sh", instance_name: "abc-123", @@ -2579,7 +3390,7 @@ mod tests { ) .unwrap(); assert_eq!( - argv, + out, vec![ "herdr", "agent", @@ -2593,9 +3404,9 @@ mod tests { } #[test] - fn test_parse_terminal_command_cwd_placeholder() { - let argv = parse_terminal_command( - "myterm --dir {cwd} -- bash {script}", + fn test_substitute_open_argv_cwd_placeholder() { + let out = substitute_open_argv( + &argv(&["myterm", "--dir", "{cwd}", "--", "bash", "{script}"]), TerminalCommandContext { script: "/tmp/test.sh", cwd: "/home/user", @@ -2604,7 +3415,7 @@ mod tests { ) .unwrap(); assert_eq!( - argv, + out, vec![ "myterm", "--dir", @@ -2617,12 +3428,136 @@ mod tests { } #[test] - fn test_parse_terminal_command_empty_cwd() { + fn test_substitute_open_argv_empty_cwd() { // Templates without {cwd} should work with empty cwd - let argv = - parse_terminal_command("open -a Terminal {script}", ctx_with_script("/tmp/test.sh")) - .unwrap(); - assert_eq!(argv, vec!["open", "-a", "Terminal", "/tmp/test.sh"]); + let out = substitute_open_argv( + &argv(&["open", "-a", "Terminal", "{script}"]), + ctx_with_script("/tmp/test.sh"), + ) + .unwrap(); + assert_eq!(out, vec!["open", "-a", "Terminal", "/tmp/test.sh"]); + } + + #[test] + fn test_substitute_close_argv_skips_when_pane_id_missing() { + // Required {pane_id} placeholder but empty value ⇒ None (skip close). + assert!( + substitute_close_argv( + &argv(&["wezterm", "cli", "kill-pane", "--pane-id", "{pane_id}"]), + 42, + "", + "proc-1", + "", + ) + .is_none() + ); + } + + #[test] + fn test_substitute_close_argv_substitutes_pane_id() { + let out = substitute_close_argv( + &argv(&["wezterm", "cli", "kill-pane", "--pane-id", "{pane_id}"]), + 42, + "pane-7", + "proc-1", + "", + ) + .unwrap(); + assert_eq!( + out, + vec!["wezterm", "cli", "kill-pane", "--pane-id", "pane-7"] + ); + } + + #[test] + fn test_format_close_command_preserves_all_arguments() { + let command = format_close_command(&[ + "wezterm".to_string(), + "cli".to_string(), + "kill-pane".to_string(), + "--pane-id".to_string(), + "123".to_string(), + ]); + assert_eq!(command, "wezterm cli kill-pane --pane-id 123"); + } + + #[cfg(windows)] + #[test] + fn test_format_close_command_quotes_powershell_arguments() { + let command = format_close_command(&[ + r"C:\Program Files\kitty\kitten.exe".to_string(), + "@".to_string(), + "--to".to_string(), + r"unix:C:\Users\O'Brien\kitty.sock".to_string(), + ]); + assert_eq!( + command, + r#"'C:\Program Files\kitty\kitten.exe' @ --to 'unix:C:\Users\O''Brien\kitty.sock'"# + ); + } + + #[cfg(not(windows))] + #[test] + fn test_format_close_command_quotes_posix_arguments() { + let command = format_close_command(&[ + "kitten".to_string(), + "@".to_string(), + "--to".to_string(), + "/tmp/O'Brien kitty.sock".to_string(), + ]); + assert_eq!(command, "kitten '@' --to '/tmp/O'\\''Brien kitty.sock'"); + } + + #[test] + fn test_zellij_close_argv_session_splice() { + // Reproduce the close_terminal_pane splice: --session after zellij. + let mut a = substitute_close_argv( + &argv(&["zellij", "action", "close-pane", "--pane-id", "{pane_id}"]), + 0, + "6", + "", + "", + ) + .unwrap(); + a.splice(1..1, ["--session".to_string(), "wise-kangaroo".to_string()]); + assert_eq!( + a, + vec![ + "zellij", + "--session", + "wise-kangaroo", + "action", + "close-pane", + "--pane-id", + "6" + ] + ); + } + + #[test] + fn test_kitten_close_argv_to_splice() { + // Reproduce the close_terminal_pane splice: --to after `@`. + let mut a = substitute_close_argv( + &argv(&["kitten", "@", "close-window", "--match", "id:{pane_id}"]), + 0, + "13", + "", + "", + ) + .unwrap(); + a.splice(2..2, ["--to".to_string(), "unix:/tmp/kitty".to_string()]); + assert_eq!( + a, + vec![ + "kitten", + "@", + "--to", + "unix:/tmp/kitty", + "close-window", + "--match", + "id:13" + ] + ); } #[test] @@ -2656,6 +3591,27 @@ mod tests { assert!(!has_node_shebang(path.to_str().unwrap())); } + #[cfg(windows)] + #[test] + fn windows_codex_npm_launcher_bypasses_cmd_shim() { + let temp = tempfile::tempdir().unwrap(); + let shim = temp.path().join("codex.cmd"); + let entrypoint = temp.path().join("node_modules/@openai/codex/bin/codex.js"); + std::fs::create_dir_all(entrypoint.parent().unwrap()).unwrap(); + std::fs::write(&shim, "@echo off\r\n").unwrap(); + std::fs::write(&entrypoint, "").unwrap(); + + let (launcher, args) = + resolve_windows_tool_launcher("codex", shim.to_str().unwrap()).unwrap(); + assert!( + Path::new(&launcher) + .file_stem() + .is_some_and(|stem| stem.eq_ignore_ascii_case("node")) + ); + assert_eq!(args, vec![entrypoint.to_string_lossy().into_owned()]); + assert!(resolve_windows_tool_launcher("claude", shim.to_str().unwrap()).is_none()); + } + #[test] fn test_has_node_shebang_with_elf_binary() { let dir = tempfile::tempdir().unwrap(); @@ -2698,4 +3654,89 @@ mod tests { assert!(resolved.is_none()); } } + + // Finding 22: the no-`wt` `cmd /c start` branch used to bake literal `"` + // quotes around `{script}`, which collided with `Command`'s own Windows + // argv quoting on spaced paths. `{script}` is now bare. + #[test] + fn windows_cmd_fallback_leaves_spaced_script_unquoted() { + let tmpl = windows_default_terminal_template(false); + let script = r"C:\Users\a b\hcom\s.ps1"; + let out: Vec = tmpl.iter().map(|a| a.replace("{script}", script)).collect(); + assert_eq!(out.last().unwrap(), script); + assert!(!out.last().unwrap().contains('"')); + assert_eq!(out[3], ""); + } + + // Finding 19: `hcom status`'s default-terminal display name must track the + // same has_wt branch as the launch planner, instead of falling through to + // "unknown" on Windows. + #[test] + fn windows_status_name_tracks_launch_planner() { + assert_eq!(windows_default_terminal_template(true)[0], "wt"); + assert_eq!( + windows_default_terminal_display_name(true), + "Windows Terminal" + ); + assert_eq!(windows_default_terminal_template(false)[0], "cmd"); + assert_eq!(windows_default_terminal_display_name(false), "cmd.exe"); + } + + // B-3+B-4: any bash-family interpreter with a NON-adjacent `{script}` (any + // flag, or none) can't be rewritten by `shellify_bash_script_pair`, so on + // Windows it must be rejected instead of silently handing a `.ps1` to bash. + // Adjacent ` {script}` is rewritten, not flagged. + #[test] + fn detects_unsupported_bash_c_script() { + let v = |a: &[&str]| a.iter().map(|s| s.to_string()).collect::>(); + // Non-adjacent {script}, regardless of flag → flagged (returns interp). + assert_eq!( + unsupported_bash_script_interp(&v(&["bash", "-c", "{script}"])).as_deref(), + Some("bash") + ); + assert!(unsupported_bash_script_interp(&v(&["bash", "-x", "{script}"])).is_some()); + assert!(unsupported_bash_script_interp(&v(&["bash", "-i", "{script}"])).is_some()); + assert!(unsupported_bash_script_interp(&v(&["bash", "-lc", "{script}"])).is_some()); + assert_eq!( + unsupported_bash_script_interp(&v(&["bash.exe", "-c", "{script}"])).as_deref(), + Some("bash.exe") + ); + assert!(unsupported_bash_script_interp(&v(&["/bin/bash", "-c", "{script}"])).is_some()); + // Adjacent bash-family + {script} → rewritten by shellify, not flagged. + assert!(unsupported_bash_script_interp(&v(&["bash", "{script}"])).is_none()); + assert!(unsupported_bash_script_interp(&v(&["bash.exe", "{script}"])).is_none()); + assert!(unsupported_bash_script_interp(&v(&["/bin/bash", "{script}"])).is_none()); + assert!( + unsupported_bash_script_interp(&v(&["gnome-terminal", "--", "bash", "{script}"])) + .is_none() + ); + // Non-bash command → untouched. + assert!(unsupported_bash_script_interp(&v(&["mypowershell", "{script}"])).is_none()); + } + + // Finding 25: background and run-here launches must resolve `bash` the + // same way (PATH match, falling back to `/bin/bash`) so they can't drift. + #[cfg(unix)] + #[test] + fn resolve_bash_command_prefers_path_then_fallback() { + let r = resolve_bash_command(); + assert!(r.ends_with("bash")); + match which_bin("bash") { + Some(p) => assert_eq!(r, p), + None => assert_eq!(r, "/bin/bash"), + } + } + + // Finding 17: built-in preset platform capability, checked against the + // real `TERMINAL_PRESETS` table (see src/shared/terminal_presets.rs). + #[test] + fn terminal_preset_platform_capability() { + use crate::config::terminal_preset_supported_on; + assert!(terminal_preset_supported_on("iterm", "Darwin")); + assert!(!terminal_preset_supported_on("iterm", "Windows")); + assert!(terminal_preset_supported_on("wttab", "Windows")); + assert!(!terminal_preset_supported_on("wttab", "Darwin")); + assert!(terminal_preset_supported_on("wezterm", "Windows")); + assert!(!terminal_preset_supported_on("nope", "Darwin")); + } } diff --git a/src/tools/args_common.rs b/src/tools/args_common.rs index 6b23f9b2..8b4d42c6 100644 --- a/src/tools/args_common.rs +++ b/src/tools/args_common.rs @@ -15,7 +15,15 @@ pub fn shell_quote(s: &str) -> String { } /// Split a configured argument string while preserving quoted values. -pub fn shell_split(s: &str) -> Result, String> { +/// +/// Outside quotes, `\` is a POSIX escape character (it consumes the next +/// character verbatim) unless `is_windows` is true, in which case it's +/// treated as a literal character instead — otherwise unquoted Windows paths +/// like `C:\Tools\term.exe` get mangled. This only affects the *unquoted* +/// branch: escapes inside quotes (`\"`, `\\`, `\$`, `` \` ``) are unchanged +/// regardless of `is_windows`, so quoted strings behave identically +/// everywhere. +pub fn shell_split(s: &str, is_windows: bool) -> Result, String> { let mut tokens = Vec::new(); let mut current = String::new(); let mut chars = s.chars().peekable(); @@ -48,7 +56,9 @@ pub fn shell_split(s: &str) -> Result, String> { } else if ch == '"' { in_double = true; } else if ch == '\\' { - if let Some(next) = chars.next() { + if is_windows { + current.push(ch); + } else if let Some(next) = chars.next() { current.push(next); } } else if ch.is_whitespace() { @@ -83,14 +93,76 @@ mod tests { #[test] fn shell_split_preserves_quoted_values() { + // Platform-independent: assert equal output for both `is_windows` values. + for is_windows in [true, false] { + assert_eq!( + shell_split("--model opus --verbose", is_windows).unwrap(), + vec!["--model", "opus", "--verbose"] + ); + assert_eq!( + shell_split("'hello world' --flag", is_windows).unwrap(), + vec!["hello world", "--flag"] + ); + assert!(shell_split("'unterminated", is_windows).is_err()); + } + } + + #[test] + fn shell_split_unquoted_backslash_is_platform_aware() { + // Literal outside quotes on Windows, so real paths survive intact. + assert_eq!( + shell_split(r"C:\Tools\term.exe --flag", true).unwrap(), + vec![r"C:\Tools\term.exe", "--flag"] + ); + // POSIX escape: each `\` consumes the following character. + assert_eq!( + shell_split(r"C:\Tools\term.exe --flag", false).unwrap(), + vec!["C:Toolsterm.exe", "--flag"] + ); + } + + #[test] + fn shell_split_quoted_escapes_are_platform_independent() { + // Regression guard: a prior fix pre-doubled every backslash in the + // whole input string before calling shell_split on Windows, which + // corrupted quoted escapes like `\"` (they became `\\"`, closing the + // quote early). The platform branch above only touches the *unquoted* + // backslash case, so quoted escapes must behave identically everywhere. + for is_windows in [true, false] { + assert_eq!( + shell_split(r#"myterm -e "say \"hi\"""#, is_windows).unwrap(), + vec!["myterm", "-e", "say \"hi\""] + ); + } + } + + #[test] + fn shell_split_quoted_windows_path_unaffected_by_platform() { + // The quoted-escape branch is untouched by the Windows change, so a + // quoted Windows path was never broken and stays that way. + for is_windows in [true, false] { + assert_eq!( + shell_split(r#""C:\Tools\term.exe" --flag"#, is_windows).unwrap(), + vec![r"C:\Tools\term.exe", "--flag"] + ); + } + } + + #[test] + fn shell_split_double_backslash_no_longer_collapses_on_windows() { + // A pre-existing (undocumented, code-comment-only) workaround relied + // on shell_split's escape-collapse to turn a doubled backslash into a + // single one on Windows. That collapse no longer happens outside + // quotes on Windows (see above) — low-impact, since Windows path APIs + // tolerate redundant separators, and the common single-backslash case + // now works directly without any workaround. assert_eq!( - shell_split("--model opus --verbose").unwrap(), - vec!["--model", "opus", "--verbose"] + shell_split(r"C:\\Tools\\term.exe", true).unwrap(), + vec![r"C:\\Tools\\term.exe"] ); assert_eq!( - shell_split("'hello world' --flag").unwrap(), - vec!["hello world", "--flag"] + shell_split(r"C:\\Tools\\term.exe", false).unwrap(), + vec![r"C:\Tools\term.exe"] ); - assert!(shell_split("'unterminated").is_err()); } } diff --git a/src/tools/codex_preprocessing.rs b/src/tools/codex_preprocessing.rs index 2ad56082..75ee899e 100644 --- a/src/tools/codex_preprocessing.rs +++ b/src/tools/codex_preprocessing.rs @@ -82,13 +82,18 @@ fn has_explicit_sandbox_or_approval(tokens: &[String]) -> bool { }) } -/// Ensure --add-dir ~/.hcom is present so hcom can write to its DB. +/// Ensure ~/.hcom is a writable sandbox root so hcom can write to its DB. /// -/// Codex's --add-dir flag is IGNORED in read-only sandbox mode, but required -/// for workspace-write mode to allow hcom DB writes. +/// Injected as `-c sandbox_workspace_write.writable_roots=[...]` rather than +/// `--add-dir`: codex's TUI gates the flag on its effective-permissions +/// preset, and a trusted project (hcom's auto-trust injection) or a missing +/// explicit `-a` resolves to a preset that rejects extra writable roots +/// outright ("Ignoring --add-dir ... Switch to workspace-write"). The config +/// override bypasses that gate; like --add-dir, it is inert outside +/// workspace-write mode. /// -/// If no sandbox flags are present (mode="none"), skip adding --add-dir -/// since user is using codex's own folder settings. +/// If no sandbox flags are present (mode="none"), skip the injection since +/// user is using codex's own folder settings. pub fn ensure_hcom_writable(tokens: &[String]) -> Vec { let has_sandbox = tokens.iter().any(|token| { matches!( @@ -108,6 +113,11 @@ pub fn ensure_hcom_writable(tokens: &[String]) -> Vec { let hcom_dir = paths::hcom_dir().to_string_lossy().to_string(); for (i, token) in tokens.iter().enumerate() { + // A user-supplied roots override owns the whole list — don't clobber. + if token.contains("sandbox_workspace_write.writable_roots") { + return tokens.to_vec(); + } + // Respect an explicit --add-dir for the hcom dir. if token == "--add-dir" && i + 1 < tokens.len() && tokens[i + 1] == hcom_dir { return tokens.to_vec(); } @@ -119,8 +129,14 @@ pub fn ensure_hcom_writable(tokens: &[String]) -> Vec { } } + // TOML basic-string escaping (backslashes first, then quotes) — every + // Windows path carries backslashes. + let toml_escaped = crate::runtime_env::toml_escape_path(&hcom_dir); let mut result = tokens.to_vec(); - result.extend(["--add-dir".to_string(), hcom_dir]); + result.extend([ + "-c".to_string(), + format!("sandbox_workspace_write.writable_roots=[\"{toml_escaped}\"]"), + ]); result } @@ -145,7 +161,7 @@ fn codex_supports_bypass_hook_trust() -> bool { static CACHE: OnceLock = OnceLock::new(); *CACHE.get_or_init(|| { - let output = match std::process::Command::new("codex") + let output = match crate::terminal::executable_command("codex") .arg("--version") .output() { @@ -314,9 +330,14 @@ pub fn add_codex_developer_instructions( bootstrap_text.to_string() }; + // `-c` values are TOML expressions. A raw multiline string happened to be + // accepted by older Codex builds but is ignored by current builds, + // silently dropping the hcom identity bootstrap. Serialize a real TOML + // string so quotes, backslashes, and newlines survive on every platform. + let encoded = toml::Value::String(combined).to_string(); remaining.extend([ "-c".to_string(), - format!("developer_instructions={combined}"), + format!("developer_instructions={encoded}"), ]); remaining } @@ -361,7 +382,7 @@ pub fn strip_codex_developer_instructions(codex_args: &[String]) -> Vec /// 1. Strip stale developer_instructions (resume/fork only — they carry old identity) /// 2. Sandbox flags based on mode /// 3. Runtime hook-trust bypass for Codex versions that require unmanaged hook trust -/// 4. --add-dir ~/.hcom for hcom DB writes +/// 4. writable_roots config override for ~/.hcom DB writes /// 5. Bootstrap injection via developer_instructions pub fn preprocess_codex_args( codex_args: &[String], @@ -397,11 +418,13 @@ pub fn preprocess_codex_args( // Warn if mode is "none" if sandbox_mode == "none" { - eprintln!("[hcom] Warning: Sandbox mode is 'none' - --add-dir ~/.hcom disabled."); + eprintln!( + "[hcom] Warning: Sandbox mode is 'none' - ~/.hcom writable-root injection disabled." + ); eprintln!("[hcom] hcom commands may fail unless HCOM_DIR is within workspace."); } - // 4. Ensure --add-dir ~/.hcom is present (skips if mode="none") + // 4. Ensure ~/.hcom is a writable sandbox root (skips if mode="none") args = ensure_hcom_writable(&args); // 5. Add bootstrap to developer_instructions @@ -419,6 +442,12 @@ mod tests { items.iter().map(|i| i.to_string()).collect() } + fn has_writable_roots(result: &[String]) -> bool { + result + .iter() + .any(|t| t.contains("sandbox_workspace_write.writable_roots")) + } + fn write_trusted_hcom_codex_hooks(codex_home: &std::path::Path) { let hooks_path = codex_home.join("hooks.json"); std::fs::create_dir_all(codex_home).unwrap(); @@ -533,15 +562,35 @@ mod tests { #[test] #[serial] - fn test_ensure_hcom_writable_adds_dir() { + fn test_ensure_hcom_writable_adds_writable_root() { init_config(); // --full-auto is still recognized as a sandbox-active marker for // back-compat with user-provided args, even though hcom no longer emits it. let tokens = s(&["--full-auto"]); let result = ensure_hcom_writable(&tokens); assert_eq!(result[0], "--full-auto"); - assert_eq!(result[result.len() - 2], "--add-dir"); - assert!(result.len() > 2); + assert_eq!(result[result.len() - 2], "-c"); + assert!( + result[result.len() - 1].starts_with("sandbox_workspace_write.writable_roots=[\""), + "writable_roots override missing: {:?}", + result + ); + } + + #[test] + #[serial] + fn test_ensure_hcom_writable_toml_escapes_backslashes() { + init_config(); + let tokens = s(&["--sandbox", "workspace-write"]); + let result = ensure_hcom_writable(&tokens); + let root = result.last().unwrap(); + // The raw hcom dir path must not leak unescaped backslashes into the + // TOML string — codex would reject the value as an invalid escape. + let hcom_dir = paths::hcom_dir().to_string_lossy().to_string(); + if hcom_dir.contains('\\') { + assert!(root.contains(r"\\"), "backslashes must be escaped: {root}"); + assert!(!root.contains(&format!("[\"{hcom_dir}\"]"))); + } } #[test] @@ -551,7 +600,11 @@ mod tests { let tokens = s(&["--yolo"]); let result = ensure_hcom_writable(&tokens); assert_eq!(result[0], "--yolo"); - assert_eq!(result[result.len() - 2], "--add-dir"); + assert!( + result[result.len() - 1].contains("writable_roots"), + "writable_roots override missing: {:?}", + result + ); assert!(result.contains(&"--yolo".to_string())); } @@ -565,13 +618,26 @@ mod tests { #[test] #[serial] - fn test_ensure_hcom_writable_no_duplicate() { + fn test_ensure_hcom_writable_respects_explicit_add_dir() { init_config(); let hcom_dir = paths::hcom_dir().to_string_lossy().to_string(); let tokens = vec!["--full-auto".to_string(), "--add-dir".to_string(), hcom_dir]; let result = ensure_hcom_writable(&tokens); - let add_dir_count = result.iter().filter(|t| *t == "--add-dir").count(); - assert_eq!(add_dir_count, 1); + assert_eq!(result, tokens, "explicit --add-dir must suppress injection"); + } + + #[test] + #[serial] + fn test_ensure_hcom_writable_respects_user_writable_roots() { + init_config(); + let tokens = s(&[ + "--sandbox", + "workspace-write", + "-c", + r#"sandbox_workspace_write.writable_roots=["/my/dir"]"#, + ]); + let result = ensure_hcom_writable(&tokens); + assert_eq!(result, tokens, "user roots override must not be clobbered"); } #[test] @@ -749,7 +815,7 @@ mod tests { let result = add_codex_developer_instructions(&args, "BOOTSTRAP"); assert_eq!( result, - s(&["-m", "o3", "-c", "developer_instructions=BOOTSTRAP"]) + s(&["-m", "o3", "-c", "developer_instructions=\"BOOTSTRAP\""]) ); } @@ -759,7 +825,7 @@ mod tests { let result = add_codex_developer_instructions(&args, "BOOTSTRAP"); assert_eq!(result[0], "resume"); assert_eq!(result[1], "-c"); - assert_eq!(result[2], "developer_instructions=BOOTSTRAP"); + assert_eq!(result[2], "developer_instructions=\"BOOTSTRAP\""); } #[test] @@ -771,7 +837,7 @@ mod tests { assert_eq!(result[2], "--model"); assert_eq!(result[3], "gpt-5"); assert_eq!(result[4], "-c"); - assert_eq!(result[5], "developer_instructions=BOOTSTRAP"); + assert_eq!(result[5], "developer_instructions=\"BOOTSTRAP\""); } #[test] @@ -846,7 +912,7 @@ mod tests { let result = preprocess_codex_args(&args, "BOOTSTRAP", "workspace"); assert!(result.contains(&"--sandbox".to_string())); assert!(result.contains(&"workspace-write".to_string())); - assert!(result.contains(&"--add-dir".to_string())); + assert!(has_writable_roots(&result)); assert!(result.contains(&BYPASS_HOOK_TRUST_FLAG.to_string())); assert!(result.iter().any(|t| t.contains("developer_instructions="))); } @@ -876,7 +942,7 @@ mod tests { assert_eq!(result[sandbox_position + 1], "read-only"); assert_eq!(result.iter().filter(|t| *t == "--sandbox").count(), 1); assert!(!result.contains(&"workspace-write".to_string())); - assert!(result.contains(&"--add-dir".to_string())); + assert!(has_writable_roots(&result)); assert!(!result.contains(&"sandbox_workspace_write.network_access=true".to_string())); } @@ -891,7 +957,7 @@ mod tests { assert!(!result.contains(&"--sandbox".to_string())); assert!(!result.contains(&"workspace-write".to_string())); assert!(!result.contains(&"sandbox_workspace_write.network_access=true".to_string())); - assert!(result.contains(&"--add-dir".to_string())); + assert!(has_writable_roots(&result)); } #[test] @@ -906,7 +972,7 @@ mod tests { assert!(!result.contains(&"untrusted".to_string())); assert!(!result.contains(&"--sandbox".to_string())); assert!(!result.contains(&"sandbox_workspace_write.network_access=true".to_string())); - assert!(!result.contains(&"--add-dir".to_string())); + assert!(!has_writable_roots(&result)); } #[test] @@ -926,7 +992,7 @@ mod tests { assert!(!result.contains(&"--sandbox".to_string())); assert!(!result.contains(&"-a".to_string())); assert!(!result.contains(&"sandbox_workspace_write.network_access=true".to_string())); - assert!(result.contains(&"--add-dir".to_string())); + assert!(has_writable_roots(&result)); } #[test] @@ -948,7 +1014,7 @@ mod tests { let args = s(&["-m", "o3"]); let result = preprocess_codex_args(&args, "BOOTSTRAP", "none"); assert!(!result.contains(&"--sandbox".to_string())); - assert!(!result.contains(&"--add-dir".to_string())); + assert!(!has_writable_roots(&result)); assert!(result.iter().any(|t| t.contains("developer_instructions="))); } diff --git a/src/tools/cursor_preprocessing.rs b/src/tools/cursor_preprocessing.rs index 77e367e9..9da00589 100644 --- a/src/tools/cursor_preprocessing.rs +++ b/src/tools/cursor_preprocessing.rs @@ -140,6 +140,9 @@ pub(crate) fn ensure_cursor_workspace_trusted(workspace: &Path) -> anyhow::Resul mod tests { use super::*; + // Unix-only: slugifies POSIX absolute paths; a Windows path (drive letter, + // backslashes) produces a different, platform-specific slug. + #[cfg(unix)] #[test] fn cursor_project_slug_matches_cursor_layout() { assert_eq!( diff --git a/src/transcript/mod.rs b/src/transcript/mod.rs index 89a41fcb..460ae423 100644 --- a/src/transcript/mod.rs +++ b/src/transcript/mod.rs @@ -733,6 +733,10 @@ mod tests { } } + // Unix-only: PI_CODING_AGENT_DIR is set to a Unix-style absolute path + // (`/tmp/...`), which on Windows has no drive letter and resolves against + // the current drive, so it never matches the expected `PathBuf`. + #[cfg(unix)] #[test] #[serial_test::serial] fn omp_disk_roots_include_pi_coding_agent_dir_sessions() { @@ -786,6 +790,10 @@ mod tests { } } + // Unix-only: relies on redirecting the home dir via `isolated_test_env`'s + // $HOME, but on Windows `dirs::home_dir()` queries the OS profile folder + // directly and ignores it. + #[cfg(unix)] #[test] #[serial_test::serial] fn omp_disk_roots_ignore_uninitialized_xdg_root() { @@ -805,6 +813,10 @@ mod tests { ); } + // Unix-only: relies on redirecting the home dir via `isolated_test_env`'s + // $HOME, but on Windows `dirs::home_dir()` queries the OS profile folder + // directly and ignores it. + #[cfg(unix)] #[test] #[serial_test::serial] fn omp_disk_roots_honor_pi_config_dir_and_default_profile_sentinels() { @@ -827,6 +839,10 @@ mod tests { assert_eq!(omp_profile_from_env(), None); } + // Unix-only: relies on redirecting the home dir via `isolated_test_env`'s + // $HOME, but on Windows `dirs::home_dir()` queries the OS profile folder + // directly and ignores it. + #[cfg(unix)] #[test] #[serial_test::serial] fn omp_agent_override_disables_xdg_and_is_ignored_by_named_profiles() { diff --git a/src/transcript/opencode.rs b/src/transcript/opencode.rs index 04fd6bb6..e379d615 100644 --- a/src/transcript/opencode.rs +++ b/src/transcript/opencode.rs @@ -20,31 +20,7 @@ pub(crate) struct TranscriptSearchMatch { } fn get_family_db_path(tool: &str) -> Option { - let xdg_data = std::env::var("XDG_DATA_HOME").unwrap_or_else(|_| { - let home = std::env::var("HOME").unwrap_or_default(); - format!("{home}/.local/share") - }); - let data_dir = PathBuf::from(xdg_data).join(tool); - let db_path = if tool == "kilo" { - if std::env::var("KILO_DB").as_deref() == Ok(":memory:") { - return None; - } - std::env::var("KILO_DB") - .ok() - .filter(|value| !value.is_empty()) - .map(PathBuf::from) - .map(|path| { - if path.is_absolute() { - path - } else { - data_dir.join(path) - } - }) - .unwrap_or_else(|| data_dir.join("kilo.db")) - } else { - data_dir.join("opencode.db") - }; - db_path.exists().then_some(db_path) + crate::runtime_env::opencode_family_db_path(tool).filter(|p| p.exists()) } pub(crate) fn get_opencode_db_path() -> Option { diff --git a/src/update.rs b/src/update.rs index 089880a6..15b93113 100644 --- a/src/update.rs +++ b/src/update.rs @@ -7,6 +7,9 @@ use std::path::{Path, PathBuf}; use std::time::{Duration, SystemTime}; const CHECK_INTERVAL: Duration = Duration::from_secs(86400); // 24 hours +const UNIX_INSTALL_CMD: &str = + "curl -fsSL https://github.com/aannoo/hcom/releases/latest/download/hcom-installer.sh | sh"; +const WINDOWS_INSTALL_CMD: &str = "powershell -NoProfile -ExecutionPolicy Bypass -Command \"irm https://github.com/aannoo/hcom/releases/latest/download/hcom-installer.ps1 | iex\""; pub(crate) fn flag_path() -> PathBuf { hcom_path(&[FLAGS_DIR, "update_check"]) @@ -28,7 +31,15 @@ fn parse_version(v: &str) -> Option<(u32, u32, u32)> { /// Spawn a detached background process to fetch latest version and write the cache file. /// Returns immediately — result shows up on next command. +/// +/// No-op on Windows: the script below is POSIX (`sh -c`, `awk`, `git`/`curl` +/// piping), and there's no `sh` to run it. Porting this to PowerShell is +/// disproportionate for a fire-and-forget cache refresh (errors are already +/// silently swallowed), so Windows just skips the doomed spawn attempt. fn spawn_background_check(flag: &Path, current: &str) { + if cfg!(windows) { + return; + } let flag_str = flag.to_string_lossy().to_string(); let current = current.to_string(); @@ -159,18 +170,47 @@ pub fn fetch_update_info() -> anyhow::Result { }) } +/// Whether `cmd` needs POSIX shell semantics to run (currently: only the +/// curl-installer fallback, which is a pipe to `sh`). All other commands +/// `get_update_cmd()` returns (`pip install -U hcom`, `uv tool upgrade hcom`, +/// `brew upgrade hcom`) are a plain program + args and need no shell at all. +/// +/// Platform-independent so it's testable on any host; `cmd_update` uses this +/// on Windows (which has no `sh`) to decide whether to refuse instead of +/// attempting a doomed spawn. +pub(crate) fn is_shell_pipe_command(cmd: &str) -> bool { + cmd.starts_with("curl ") +} + +pub(crate) fn is_powershell_installer_command(cmd: &str) -> bool { + cmd == WINDOWS_INSTALL_CMD +} + +/// Split a plain `program arg1 arg2 ...` command string into program + args. +/// Only meant for the shell-free update commands `get_update_cmd()` returns +/// (no quoting to worry about); not a general shell parser. +pub(crate) fn split_program_args(cmd: &str) -> Option<(&str, Vec<&str>)> { + let mut parts = cmd.split_whitespace(); + let program = parts.next()?; + Some((program, parts.collect())) +} + /// Detect install method and return appropriate update command. fn get_update_cmd() -> &'static str { let exe = match std::env::current_exe() { Ok(p) => p, - Err(_) => { - return "curl -fsSL https://github.com/aannoo/hcom/releases/latest/download/hcom-installer.sh | sh"; - } + Err(_) => return platform_installer_cmd(), }; + get_update_cmd_for_exe(&exe) +} +fn get_update_cmd_for_exe(exe: &Path) -> &'static str { // Resolve symlinks (e.g. Homebrew Cellar, uv shims). - let resolved = std::fs::canonicalize(&exe).unwrap_or(exe); - let path_str = resolved.to_string_lossy(); + let resolved = std::fs::canonicalize(exe).unwrap_or_else(|_| exe.to_path_buf()); + // Normalizing separators also makes install detection testable and handles + // native Windows paths without duplicating every path pattern. + let path_str = resolved.to_string_lossy().replace('\\', "/"); + let path_lower = path_str.to_ascii_lowercase(); // Homebrew install (Cellar path on both Apple Silicon and Intel) if path_str.contains("/Cellar/") { @@ -178,14 +218,15 @@ fn get_update_cmd() -> &'static str { } // uv tool install - if path_str.contains("/uv/") || path_str.contains("/.local/share/uv/") { + if path_lower.contains("/uv/") || path_lower.contains("/.local/share/uv/") { return "uv tool upgrade hcom"; } // pip install inside a venv or directly in site-packages/dist-packages - if path_str.contains("/site-packages/") - || path_str.contains("/dist-packages/") - || path_str.contains("/venv/") + if path_lower.contains("/site-packages/") + || path_lower.contains("/dist-packages/") + || path_lower.contains("/venv/") + || path_lower.contains("/.venv/") { return "pip install -U hcom"; } @@ -196,10 +237,25 @@ fn get_update_cmd() -> &'static str { return "pip install -U hcom"; } - // Default: curl installer - "curl -fsSL https://github.com/aannoo/hcom/releases/latest/download/hcom-installer.sh | sh" + platform_installer_cmd() } +fn platform_installer_cmd() -> &'static str { + if cfg!(windows) { + WINDOWS_INSTALL_CMD + } else { + UNIX_INSTALL_CMD + } +} + +// NOTE: only checks the Unix `~/.local/bin` + `~/.local/lib/pythonX.Y/site-packages` +// layout. A Windows `pip install --user hcom` uses a different layout (e.g. +// under `%APPDATA%\Python`), which this doesn't detect — such an install +// would fall through to the curl-installer default on Windows. Not adding a +// Windows branch here without being able to verify the actual path layout on +// a real Windows/Python install; this is a known, low-risk gap (worst case: +// `hcom update` on Windows suggests the wrong command for this one install +// method, users can still update manually). fn is_user_site_pip_install(exe: &Path) -> bool { let home = match std::env::var_os("HOME") { Some(home) => PathBuf::from(home), @@ -303,6 +359,33 @@ mod tests { assert_eq!(parse_version("1.2"), None); } + #[test] + fn test_is_shell_pipe_command() { + assert!(is_shell_pipe_command( + "curl -fsSL https://example.com/install.sh | sh" + )); + assert!(!is_shell_pipe_command("pip install -U hcom")); + assert!(!is_shell_pipe_command("uv tool upgrade hcom")); + assert!(!is_shell_pipe_command("brew upgrade hcom")); + assert!(!is_shell_pipe_command(WINDOWS_INSTALL_CMD)); + assert!(is_powershell_installer_command(WINDOWS_INSTALL_CMD)); + assert!(!is_powershell_installer_command("pip install -U hcom")); + } + + #[test] + fn test_split_program_args() { + assert_eq!( + split_program_args("pip install -U hcom"), + Some(("pip", vec!["install", "-U", "hcom"])) + ); + assert_eq!( + split_program_args("uv tool upgrade hcom"), + Some(("uv", vec!["tool", "upgrade", "hcom"])) + ); + assert_eq!(split_program_args(""), None); + assert_eq!(split_program_args(" "), None); + } + #[test] fn test_version_comparison() { assert!(parse_version("0.8.0") > parse_version("0.7.0")); @@ -312,10 +395,30 @@ mod tests { #[test] fn test_get_update_cmd_default() { - // Test binary path won't match any known install method, so we expect - // the curl installer fallback. + // Test binary path won't match any known install method. let cmd = get_update_cmd(); - assert!(cmd.contains("curl"), "expected curl fallback, got: {cmd}"); + if cfg!(windows) { + assert!( + cmd.contains("hcom-installer.ps1"), + "expected PowerShell fallback, got: {cmd}" + ); + } else { + assert!(cmd.contains("curl"), "expected curl fallback, got: {cmd}"); + } + } + + #[test] + fn test_windows_style_install_paths_are_detected() { + assert_eq!( + get_update_cmd_for_exe(Path::new( + r"C:\Users\me\AppData\Local\uv\tools\hcom\Scripts\hcom.exe" + )), + "uv tool upgrade hcom" + ); + assert_eq!( + get_update_cmd_for_exe(Path::new(r"C:\Users\me\project\.venv\Scripts\hcom.exe")), + "pip install -U hcom" + ); } #[test] diff --git a/tests/cli_smoke.rs b/tests/cli_smoke.rs index a1899442..15facba2 100644 --- a/tests/cli_smoke.rs +++ b/tests/cli_smoke.rs @@ -3,6 +3,7 @@ mod support; +#[cfg(unix)] use std::os::unix::process::CommandExt; use std::process::Command; use std::time::{Duration, Instant}; @@ -10,11 +11,17 @@ use support::{Hcom, parse_hcom_marker}; #[test] fn fixture_drop_terminates_registered_process_group() { + #[cfg(unix)] let mut child = Command::new("sh") .args(["-c", "sleep 60"]) .process_group(0) .spawn() .expect("spawn cleanup test process group"); + #[cfg(windows)] + let mut child = Command::new("powershell") + .args(["-NoProfile", "-Command", "Start-Sleep -Seconds 60"]) + .spawn() + .expect("spawn cleanup test process group"); let pid = i64::from(child.id()); let h = Hcom::new(); @@ -33,15 +40,13 @@ fn fixture_drop_terminates_registered_process_group() { ); let deadline = Instant::now() + Duration::from_secs(7); - while Instant::now() < deadline { - let rc = unsafe { nix::libc::kill(-(pid as i32), 0) }; - if rc != 0 && std::io::Error::last_os_error().raw_os_error() == Some(nix::libc::ESRCH) { - break; - } + while Instant::now() < deadline && support::process_group_alive(pid) { std::thread::sleep(Duration::from_millis(50)); } - let rc = unsafe { nix::libc::kill(-(pid as i32), 0) }; - assert_ne!(rc, 0, "fixture drop left process group {pid} alive"); + assert!( + !support::process_group_alive(pid), + "fixture drop left process group {pid} alive" + ); } #[test] diff --git a/tests/real_tool_claude.rs b/tests/real_tool_claude.rs index bf5db053..cfddf707 100644 --- a/tests/real_tool_claude.rs +++ b/tests/real_tool_claude.rs @@ -53,16 +53,18 @@ fn real_claude_approval_gate_blocks_pending_message_then_clears_on_approval() { let gated_token = format!("HCOM_CLAUDE_GATED_{suffix}"); let held_token = format!("HCOM_CLAUDE_HELD_{suffix}"); let sender_process_id = format!("hcom-claude-approver-{suffix}"); - let sender = h.start_with_process_id(&sender_process_id); + let sender = h.start_listening_with_process_id(&sender_process_id); let approval_result = h.workspace.join("approval-result.txt"); let approval_result_text = approval_result .to_str() .expect("UTF-8 approval result path") - .to_string(); + .replace('\\', "/"); // Append (not overwrite) so a duplicate execution is detectable as a second // line rather than an idempotent rewrite. - let gated_cmd = format!("echo {gated_token} >> {approval_result_text}"); + let gated_cmd = format!( + "node -e \"require('fs').appendFileSync('{approval_result_text}', '{gated_token}\\\\n')\"" + ); const GATED_TOOL: &str = "toolu_claude_gated"; // Two scripted turns, classified by the NEWEST turn (Claude resends full diff --git a/tests/real_tool_codex.rs b/tests/real_tool_codex.rs index 09e1a4b7..9436906a 100644 --- a/tests/real_tool_codex.rs +++ b/tests/real_tool_codex.rs @@ -22,7 +22,7 @@ use serial_test::serial; use std::fs; use std::time::Duration; use support::codex_mock::{ - CodexCase, MockResponses, Reply, completed, created, function_call, message, sse, + CodexCase, MockResponses, Reply, completed, created, message, shell_call, sse, }; use support::real_tool::{inject_prompt_until, require_pinned}; use support::{Hcom, parse_launch_names, unique_suffix}; @@ -63,14 +63,24 @@ fn real_codex_approval_gate_blocks_pending_message_then_clears_on_approval() { let gated_token = format!("HCOM_CODEX_GATED_{suffix}"); let message_token = format!("HCOM_CODEX_HELD_{suffix}"); let sender_process_id = format!("hcom-codex-approver-{suffix}"); - let sender = h.start_with_process_id(&sender_process_id); + let sender = h.start_listening_with_process_id(&sender_process_id); let approval_result = h.workspace.join("approval-result.txt"); let approval_result_text = approval_result .to_str() .expect("UTF-8 approval result path") .to_string(); - let gated_cmd = format!("echo {gated_token} > {approval_result_text}"); + let gated_cmd = if cfg!(windows) { + format!( + "node -e \"require('fs').writeFileSync('{}', '{}')\"", + approval_result_text + .replace('\\', "\\\\") + .replace('\'', "\\'"), + gated_token.replace('\\', "\\\\").replace('\'', "\\'") + ) + } else { + format!("echo {gated_token} > {approval_result_text}") + }; // Two scripted turns: the first requests a gated shell command; the second // (released only after Codex runs the approved command and POSTs its @@ -87,11 +97,7 @@ fn real_codex_approval_gate_blocks_pending_message_then_clears_on_approval() { } else if body.contains(&scenario_token) { Reply::Sse(sse(&[ created("RESP_A1"), - function_call( - "CALLG", - "exec_command", - &serde_json::json!({ "cmd": scenario_gated }).to_string(), - ), + shell_call("CALLG", &scenario_gated), completed("RESP_A1"), ])) } else { @@ -140,7 +146,7 @@ fn real_codex_approval_gate_blocks_pending_message_then_clears_on_approval() { h.eventually( "Codex process-bound launch", - Duration::from_secs(40), + Duration::from_secs(90), || { let Some(instance) = h.instance_json(&name)? else { return Ok(None); @@ -156,7 +162,7 @@ fn real_codex_approval_gate_blocks_pending_message_then_clears_on_approval() { } }, ); - h.eventually("Codex PTY inject endpoint", Duration::from_secs(40), || { + h.eventually("Codex PTY inject endpoint", Duration::from_secs(90), || { let (code, stdout, _stderr) = h.run(["term", &name, "--json"]); if code == 0 && stdout.contains("\"ready\":true") { Ok(Some(())) diff --git a/tests/support/codex_mock.rs b/tests/support/codex_mock.rs index 950c25de..21095cb1 100644 --- a/tests/support/codex_mock.rs +++ b/tests/support/codex_mock.rs @@ -84,7 +84,15 @@ impl ToolCase for CodexCase { |call_id: &str| body.contains("function_call_output") && body.contains(call_id); let has_custom = |call_id: &str| body.contains("custom_tool_call_output") && body.contains(call_id); - let write_cmd = format!("echo {} > {}", ids.initial, ids.shell_rel); + let write_cmd = if cfg!(windows) { + format!( + "node -e \"require('fs').writeFileSync('{}', '{}')\"", + ids.shell_rel.replace('\\', "\\\\").replace('\'', "\\'"), + ids.initial.replace('\\', "\\\\").replace('\'', "\\'") + ) + } else { + format!("echo {} > {}", ids.initial, ids.shell_rel) + }; let patch = format!( "*** Begin Patch\n*** Add File: {}\n+{}\n*** End Patch\n", ids.file_rel, ids.initial @@ -116,21 +124,13 @@ impl ToolCase for CodexCase { } else if has_output("CALL1") { Reply::Sse(sse(&[ created("RESP2"), - function_call( - "CALL2", - "exec_command", - &serde_json::json!({ "cmd": ids.send_cmd }).to_string(), - ), + shell_call("CALL2", &ids.send_cmd), completed("RESP2"), ])) } else if has_custom("PATCH1") { Reply::Sse(sse(&[ created("RESP1B"), - function_call( - "CALL1", - "exec_command", - &serde_json::json!({ "cmd": write_cmd }).to_string(), - ), + shell_call("CALL1", &write_cmd), completed("RESP1B"), ])) } else if body.contains(&ids.initial) { @@ -245,6 +245,23 @@ pub fn function_call(call_id: &str, name: &str, arguments: &str) -> (&'static st ) } +/// Platform-specific shell function advertised by pinned Codex. +pub fn shell_call(call_id: &str, command: &str) -> (&'static str, Value) { + if cfg!(windows) { + function_call( + call_id, + "shell_command", + &serde_json::json!({ "command": command }).to_string(), + ) + } else { + function_call( + call_id, + "exec_command", + &serde_json::json!({ "cmd": command }).to_string(), + ) + } +} + /// A freeform custom-tool call, used by Codex for `apply_patch`. pub fn custom_tool_call(call_id: &str, name: &str, input: &str) -> (&'static str, Value) { ( diff --git a/tests/support/mod.rs b/tests/support/mod.rs index a1e3b5b3..46fc3235 100644 --- a/tests/support/mod.rs +++ b/tests/support/mod.rs @@ -22,7 +22,7 @@ use std::collections::{BTreeMap, HashSet}; use std::ffi::{OsStr, OsString}; use std::fs; use std::path::{Path, PathBuf}; -use std::process::Command; +use std::process::{Child, Command, Stdio}; use std::time::{Duration, Instant}; use tempfile::TempDir; @@ -45,6 +45,7 @@ pub struct Hcom { /// wins, so Claude's `ANTHROPIC_BASE_URL` actually reaches the child. launch_env: RefCell>, cleanup_pids: RefCell>, + cleanup_children: RefCell>, } impl Hcom { @@ -96,6 +97,7 @@ impl Hcom { path_env, launch_env: RefCell::new(BTreeMap::new()), cleanup_pids: RefCell::new(HashSet::new()), + cleanup_children: RefCell::new(Vec::new()), } } @@ -107,6 +109,23 @@ impl Hcom { self.root.path() } + /// Shell expression that invokes this test's exact hcom binary. + pub fn shell_hcom_command(&self) -> String { + let path = self.bin.to_string_lossy(); + if cfg!(windows) { + format!("& '{}'", path.replace('\'', "''")) + } else { + format!("'{}'", path.replace('\'', "'\\''")) + } + } + + /// Exact binary invocation for tools whose Windows shell is Git Bash + /// (not PowerShell), notably Claude's Bash tool. + pub fn bash_hcom_command(&self) -> String { + let path = self.bin.to_string_lossy().replace('\\', "/"); + format!("'{}'", path.replace('\'', "'\\''")) + } + fn apply_isolated_env(&self, command: &mut Command) { command.env_clear(); command.env("PATH", &self.path_env); @@ -121,6 +140,24 @@ impl Hcom { command.env("CI", "1"); command.env("HOME", &self.home); + command.env("HCOM_DEV_ROOT", env!("CARGO_MANIFEST_DIR")); + #[cfg(windows)] + { + // Windows PowerShell and cmd are OS components, not user state. + // Clearing SystemRoot/WINDIR can make powershell.exe fail before it + // reads the generated runner; PATHEXT/COMSPEC are required for npm + // .cmd shims. Keep user-writable profile/temp locations isolated. + for key in ["SystemRoot", "WINDIR", "COMSPEC", "PATHEXT"] { + if let Some(value) = std::env::var_os(key) { + command.env(key, value); + } + } + command.env("USERPROFILE", &self.home); + command.env("APPDATA", self.home.join("AppData/Roaming")); + command.env("LOCALAPPDATA", self.home.join("AppData/Local")); + command.env("TEMP", self.root.path().join("tmp")); + command.env("TMP", self.root.path().join("tmp")); + } command.env("HCOM_DIR", &self.hcom_dir); command.env("TMPDIR", self.root.path().join("tmp")); command.env("XDG_CONFIG_HOME", self.root.path().join("xdg/config")); @@ -193,6 +230,31 @@ impl Hcom { /// Build a non-hcom command (for example `codex --version`) with the same /// credential-stripped, isolated environment. pub fn external_cmd>(&self, program: S) -> Command { + #[cfg(windows)] + let mut command = { + let program = program.as_ref(); + let resolved = std::env::split_paths(&self.path_env) + .flat_map(|dir| { + [".COM", ".EXE", ".BAT", ".CMD", ""] + .map(move |ext| dir.join(format!("{}{ext}", program.to_string_lossy()))) + }) + .find(|candidate| candidate.is_file()); + match resolved { + Some(path) + if matches!( + path.extension().and_then(OsStr::to_str), + Some(ext) if ext.eq_ignore_ascii_case("cmd") || ext.eq_ignore_ascii_case("bat") + ) => + { + let mut command = Command::new("cmd.exe"); + command.args(["/d", "/c"]).arg(path); + command + } + Some(path) => Command::new(path), + None => Command::new(program), + } + }; + #[cfg(not(windows))] let mut command = Command::new(program); self.apply_isolated_env(&mut command); command @@ -204,7 +266,18 @@ impl Hcom { I: IntoIterator, S: AsRef, { - let out = self.cmd().args(args).output().expect("spawn hcom binary"); + let args: Vec = args + .into_iter() + .map(|arg| arg.as_ref().to_os_string()) + .collect(); + if std::env::var_os("HCOM_TEST_TRACE_COMMANDS").is_some() { + eprintln!("hcom test command: {:?}", args); + } + let out = self + .cmd() + .args(&args) + .output() + .unwrap_or_else(|error| panic!("spawn hcom binary for {:?}: {error}", args)); let code = out.status.code().unwrap_or(-1); let stdout = String::from_utf8_lossy(&out.stdout).into_owned(); let stderr = String::from_utf8_lossy(&out.stderr).into_owned(); @@ -240,6 +313,46 @@ impl Hcom { .unwrap_or_else(|| panic!("no [hcom:NAME] marker in stdout:\n{stdout}")) } + /// Start a manual identity and keep it genuinely live while a real tool + /// performs its comparatively slow startup. A bare `hcom start` identity + /// has no heartbeat source and is correctly considered stale after 30s. + pub fn start_listening_with_process_id(&self, process_id: &str) -> String { + let name = self.start_with_process_id(process_id); + let output_path = self.recipient_output_path(process_id); + let output = fs::File::create(&output_path).expect("create live recipient output"); + let mut command = self.cmd(); + command + .env("HCOM_PROCESS_ID", process_id) + .args(["listen", "--json", "--timeout", "600"]) + .stdin(Stdio::null()) + .stdout(output) + .stderr(Stdio::null()); + let child = command.spawn().expect("spawn live test recipient"); + self.track_cleanup_pid(i64::from(child.id())); + self.cleanup_children.borrow_mut().push(child); + self.eventually( + "manual test recipient to enter listening state", + Duration::from_secs(10), + || { + let instance = self.instance_json(&name)?; + Ok(instance + .filter(|value| { + value.get("status").and_then(Value::as_str) == Some("listening") + }) + .map(|_| ())) + }, + ); + name + } + + pub fn recipient_output(&self, process_id: &str) -> String { + fs::read_to_string(self.recipient_output_path(process_id)).unwrap_or_default() + } + + fn recipient_output_path(&self, process_id: &str) -> PathBuf { + self.hcom_dir.join(format!("recipient-{process_id}.jsonl")) + } + /// Run plain `hcom start` and return the auto-assigned identity name. pub fn start(&self) -> String { let (code, stdout, stderr) = self.run(["start"]); @@ -472,9 +585,10 @@ impl Hcom { out.push_str(&format!( "\n--- term {name} (exit {code}) ---\n{stdout}{stderr}" )); - let (code, stdout, stderr) = self.run(["transcript", name, "--full"]); + let (code, stdout, stderr) = + self.run(["transcript", name, "--full", "--detailed"]); out.push_str(&format!( - "\n--- transcript {name} --full (exit {code}) ---\n{stdout}{stderr}" + "\n--- transcript {name} --full --detailed (exit {code}) ---\n{stdout}{stderr}" )); } } @@ -501,38 +615,25 @@ impl Hcom { } pub fn process_group_alive(&self, pid: i64) -> bool { - if pid <= 1 || pid > i32::MAX as i64 { - return false; - } - // A negative pid addresses the process group whose id is `pid`. - let rc = unsafe { nix::libc::kill(-(pid as i32), 0) }; - if rc == 0 { - return true; - } - std::io::Error::last_os_error().raw_os_error() == Some(nix::libc::EPERM) + process_group_alive(pid) } /// Terminate one hcom-owned process group, escalating only after bounded /// polling. Returns true once the group no longer exists. pub fn terminate_process_group(&self, pid: i64) -> bool { - if !self.process_group_alive(pid) { - return true; - } - unsafe { - nix::libc::kill(-(pid as i32), nix::libc::SIGTERM); - } - if poll_until(Duration::from_secs(3), || !self.process_group_alive(pid)) { - return true; - } - unsafe { - nix::libc::kill(-(pid as i32), nix::libc::SIGKILL); - } - poll_until(Duration::from_secs(3), || !self.process_group_alive(pid)) + terminate_process_group(pid) } } impl Drop for Hcom { fn drop(&mut self) { + if std::thread::panicking() { + self.root.disable_cleanup(true); + eprintln!( + "preserving failed real-tool test directory: {}", + self.root.path().display() + ); + } // Capture pids before `hcom kill all` removes instance rows. let mut pids: HashSet = self.all_tracked_pids().into_iter().collect(); pids.extend(self.cleanup_pids.borrow().iter().copied()); @@ -557,7 +658,87 @@ impl Drop for Hcom { for pid in pids { let _ = self.terminate_process_group(pid); } + for mut child in self.cleanup_children.borrow_mut().drain(..) { + let _ = child.kill(); + let _ = child.wait(); + } + } +} + +#[cfg(unix)] +pub fn process_group_alive(pid: i64) -> bool { + if pid <= 1 || pid > i32::MAX as i64 { + return false; + } + // A negative pid addresses the process group whose id is `pid`. + let rc = unsafe { nix::libc::kill(-(pid as i32), 0) }; + if rc == 0 { + return true; + } + std::io::Error::last_os_error().raw_os_error() == Some(nix::libc::EPERM) +} + +// Windows has no process-group primitive; this fixture only ever tracks a +// single spawned pid on this codepath, so liveness degrades to a plain PID +// check. +#[cfg(windows)] +pub fn process_group_alive(pid: i64) -> bool { + if pid <= 1 || pid > u32::MAX as i64 { + return false; + } + use windows_sys::Win32::Foundation::{CloseHandle, STILL_ACTIVE}; + use windows_sys::Win32::System::Threading::{ + GetExitCodeProcess, OpenProcess, PROCESS_QUERY_LIMITED_INFORMATION, + }; + // SAFETY: query-only access mask; the handle is closed before returning. + unsafe { + let handle = OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, 0, pid as u32); + if handle.is_null() { + return false; + } + let mut exit_code = 0u32; + let ok = GetExitCodeProcess(handle, &mut exit_code) != 0; + CloseHandle(handle); + ok && exit_code == STILL_ACTIVE as u32 + } +} + +/// Terminate one hcom-owned process group, escalating only after bounded +/// polling. Returns true once the group no longer exists. +#[cfg(unix)] +pub fn terminate_process_group(pid: i64) -> bool { + if !process_group_alive(pid) { + return true; + } + unsafe { + nix::libc::kill(-(pid as i32), nix::libc::SIGTERM); + } + if poll_until(Duration::from_secs(3), || !process_group_alive(pid)) { + return true; + } + unsafe { + nix::libc::kill(-(pid as i32), nix::libc::SIGKILL); + } + poll_until(Duration::from_secs(3), || !process_group_alive(pid)) +} + +// Windows has no process-group primitive; terminate just the tracked pid. +#[cfg(windows)] +pub fn terminate_process_group(pid: i64) -> bool { + if !process_group_alive(pid) { + return true; + } + use windows_sys::Win32::Foundation::CloseHandle; + use windows_sys::Win32::System::Threading::{OpenProcess, PROCESS_TERMINATE, TerminateProcess}; + // SAFETY: opens a terminate-only handle, closes it before returning. + unsafe { + let handle = OpenProcess(PROCESS_TERMINATE, 0, pid as u32); + if !handle.is_null() { + TerminateProcess(handle, 1); + CloseHandle(handle); + } } + poll_until(Duration::from_secs(3), || !process_group_alive(pid)) } pub fn parse_hcom_marker(stdout: &str) -> Option { diff --git a/tests/support/real_tool.rs b/tests/support/real_tool.rs index 2543c350..b22d1034 100644 --- a/tests/support/real_tool.rs +++ b/tests/support/real_tool.rs @@ -232,8 +232,13 @@ fn instance_status_context(h: &Hcom, name: &str) -> Option { } /// Wait until a tool instance is process-bound; return its canonical name. +/// +/// 90s (not 40s): on Windows CI, npm-shimmed tools (codex in particular, whose +/// launcher bypasses the shim to invoke node directly, see +/// `terminal::resolve_windows_tool_launcher`) cold-start slower than on Unix +/// runners and have been observed landing just past a 40s deadline. fn wait_process_bound(h: &Hcom, case: &C, name: &str, what: &str) -> Value { - h.eventually(what, Duration::from_secs(40), || { + h.eventually(what, Duration::from_secs(90), || { let Some(instance) = h.instance_json(name)? else { return Ok(None); }; @@ -251,7 +256,7 @@ fn wait_process_bound(h: &Hcom, case: &C, name: &str, what: &str) - } fn wait_pty_ready(h: &Hcom, name: &str, what: &str) { - h.eventually(what, Duration::from_secs(40), || { + h.eventually(what, Duration::from_secs(90), || { let (code, stdout, _stderr) = h.run(["term", name, "--json"]); // `ready` matches the tool's ready pattern (Codex), but tools whose // status bar hides that pattern at an idle prompt (Claude in @@ -278,10 +283,18 @@ pub fn run_full_lifecycle(case: C) { let suffix = unique_suffix(); let recipient_process_id = format!("hcom-{tool}-recipient-{suffix}"); - let recipient = h.start_with_process_id(&recipient_process_id); + let recipient = h.start_listening_with_process_id(&recipient_process_id); let canonical_workspace = fs::canonicalize(&h.workspace).unwrap_or_else(|_| h.workspace.clone()); + // Windows canonicalize returns a `\\?\` verbatim path. That form is useful + // for filesystem syscalls but Claude's Write tool rejects it as a file URL. + // Model-visible paths must use the normal child-process representation. + #[cfg(windows)] + let canonical_workspace = { + let value = canonical_workspace.to_string_lossy(); + std::path::PathBuf::from(value.strip_prefix(r"\\?\").unwrap_or(&value)) + }; let file_path = canonical_workspace.join("lifecycle-file.txt"); let shell_path = canonical_workspace.join("lifecycle-shell.txt"); let ids = ScenarioIds { @@ -297,8 +310,20 @@ pub fn run_full_lifecycle(case: C) { send_cmd: String::new(), // filled below }; let ids = ScenarioIds { + // PowerShell treats a bare @name as splatting syntax, so quote the + // hcom target on Windows. Unix shells accept the same target bare. send_cmd: format!( - "hcom send @{recipient} --intent inform -- {token}", + "{} send {} --intent inform -- {token}", + if cfg!(windows) && tool == "claude" { + h.bash_hcom_command() + } else { + h.shell_hcom_command() + }, + if cfg!(windows) { + format!("'@{recipient}'") + } else { + format!("@{recipient}") + }, token = ids.initial ), ..ids @@ -347,11 +372,14 @@ pub fn run_full_lifecycle(case: C) { let name = launched_names[0].clone(); wait_process_bound(&h, &case, &name, "process-bound launch"); - // Clear any surfaced startup gate (Claude onboarding/trust) before readiness. - case.drive_startup(&h, &name); - let initial_pid = h.eventually("tracked process id", Duration::from_secs(10), || { + // Verify the placeholder's PID is tracked before hook/session binding + // replaces it below (this early-window registration is a separate code + // path from the later hook-bound PID, see `initial_pid` in Phase 2). + h.eventually("tracked process id", Duration::from_secs(10), || { h.instance_pid(&name).map(|pid| pid.filter(|p| *p > 1)) }); + // Clear any surfaced startup gate (Claude onboarding/trust) before readiness. + case.drive_startup(&h, &name); wait_pty_ready(&h, &name, "PTY inject endpoint"); // --- Phase 2: first turn (file tool -> shell tool -> hcom send -> proof) --- @@ -419,6 +447,14 @@ pub fn run_full_lifecycle(case: C) { .as_str() .expect("bound session id") .to_string(); + // The launcher runner is initially process-bound; the native hook then + // replaces it with the actual tool PID. Snapshot the canonical PID only + // after hook/session binding so fork assertions compare like with like. + let initial_pid = h + .instance_pid(&name) + .expect("query hook-bound process id") + .filter(|pid| *pid > 1) + .expect("hook-bound process id"); let initial_request = mock .requests() .into_iter() @@ -513,28 +549,14 @@ pub fn run_full_lifecycle(case: C) { ); // --- Phase 3: outgoing hcom message --------------------------------------- - h.eventually( - "message routed to the recipient", + let listen_stdout = h.eventually( + "message received by the live recipient", Duration::from_secs(40), || { - let list = h.list_json()?; - let unread = list - .iter() - .find(|v| v.get("base_name").and_then(Value::as_str) == Some(recipient.as_str())) - .and_then(|v| v.get("unread_count")) - .and_then(Value::as_u64) - .unwrap_or(0); - Ok((unread > 0).then_some(())) + let output = h.recipient_output(&recipient_process_id); + Ok(output.contains(&ids.initial).then_some(output)) }, ); - let (listen_code, listen_stdout, listen_stderr) = h.run_as_process( - &recipient_process_id, - ["listen", "--timeout", "1", "--json"], - ); - assert_eq!( - listen_code, 0, - "recipient listen failed: stdout={listen_stdout} stderr={listen_stderr}" - ); let delivered: Vec = listen_stdout .lines() .filter_map(|line| serde_json::from_str(line).ok()) @@ -743,7 +765,7 @@ pub fn run_full_lifecycle(case: C) { case.drive_startup(&h, &fork_name); let fork_bound = h.eventually( "forked process + hook bindings", - Duration::from_secs(40), + Duration::from_secs(90), || { let Some(instance) = h.instance_json(&fork_name)? else { return Ok(None); diff --git a/tests/test_relay_roundtrip.rs b/tests/test_relay_roundtrip.rs index 815341c6..10939ee8 100644 --- a/tests/test_relay_roundtrip.rs +++ b/tests/test_relay_roundtrip.rs @@ -22,6 +22,9 @@ //! //! Run: //! cargo test -p hcom --test test_relay_roundtrip -- --ignored --nocapture +//! +//! The harness uses platform-specific daemon cleanup where needed, but the +//! relay contract itself runs unchanged on Unix and Windows. mod support; @@ -29,7 +32,7 @@ use std::cell::RefCell; use std::fs::{self, OpenOptions}; use std::io::Write; use std::path::{Path, PathBuf}; -use std::process::{Command, Output}; +use std::process::{Command, Output, Stdio}; use std::thread; use std::time::{Duration, Instant}; @@ -108,6 +111,17 @@ fn hcom_bin() -> PathBuf { PathBuf::from(env!("CARGO_BIN_EXE_hcom")) } +/// The hcom test binary as a Git-Bash-safe, forward-slash, single-quoted +/// path, for embedding in a Bash tool command string. Claude's Bash tool runs +/// under Git Bash on Windows, whose PATH does not reliably carry this test +/// binary's directory through relay-worker → ConPTY-child → Bash-tool +/// process inheritance — reference the exact binary rather than relying on +/// bare `hcom` resolving via PATH. Mirrors `support::Hcom::bash_hcom_command`. +fn bash_hcom_command() -> String { + let path = hcom_bin().to_string_lossy().replace('\\', "/"); + format!("'{}'", path.replace('\'', "'\\''")) +} + fn hcom_with_dir(cmd: &str, hcom_dir: &str) -> Output { let bin = hcom_bin(); let mut command = Command::new(&bin); @@ -188,7 +202,62 @@ fn hcom_with_dir(cmd: &str, hcom_dir: &str) -> Output { command.env_remove(var); } - command.output().expect("failed to execute hcom") + run_command_with_timeout(command, cmd, Duration::from_secs(90)) +} + +/// Capture through files rather than `Command::output()` pipes. On Windows a +/// detached relay worker can inherit the parent's anonymous pipe handles even +/// though its own stdio is null, preventing `output()` from ever observing EOF +/// after the short-lived CLI parent exits. +fn run_command_with_timeout(mut command: Command, label: &str, timeout: Duration) -> Output { + let stdout_file = tempfile::tempfile().expect("create hcom stdout capture"); + let stderr_file = tempfile::tempfile().expect("create hcom stderr capture"); + command + .stdout(Stdio::from( + stdout_file.try_clone().expect("clone stdout capture"), + )) + .stderr(Stdio::from( + stderr_file.try_clone().expect("clone stderr capture"), + )); + + let mut child = command.spawn().expect("failed to execute hcom"); + let deadline = Instant::now() + timeout; + let status = loop { + match child.try_wait() { + Ok(Some(status)) => break status, + Ok(None) if Instant::now() >= deadline => { + let _ = child.kill(); + let _ = child.wait(); + let stdout = read_capture(&stdout_file); + let stderr = read_capture(&stderr_file); + panic!( + "hcom command timed out after {timeout:?}: {label}\n\ + -- stdout --\n{}\n-- stderr --\n{}", + String::from_utf8_lossy(&stdout), + String::from_utf8_lossy(&stderr) + ); + } + Ok(None) => thread::sleep(Duration::from_millis(25)), + Err(error) => panic!("failed waiting for hcom command `{label}`: {error}"), + } + }; + + Output { + status, + stdout: read_capture(&stdout_file), + stderr: read_capture(&stderr_file), + } +} + +fn read_capture(file: &std::fs::File) -> Vec { + use std::io::{Read, Seek, SeekFrom}; + + let mut file = file.try_clone().expect("clone command capture for reading"); + file.seek(SeekFrom::Start(0)) + .expect("rewind command capture"); + let mut bytes = Vec::new(); + file.read_to_end(&mut bytes).expect("read command capture"); + bytes } fn apply_env_passthrough(command: &mut Command, hcom_dir: &str) { @@ -287,8 +356,43 @@ fn parse_names(output: &str) -> Vec { .unwrap_or_default() } +/// Build a command for `tool` resolved against the real process `PATH`, +/// following npm's Windows `.cmd`/`.bat` shims that `CreateProcess` cannot +/// execute directly (mirrors `support::Hcom::external_cmd`, which resolves +/// against an isolated PATH instead of the real environment). +fn external_tool_command(tool: &str) -> Command { + #[cfg(windows)] + { + let path_var = std::env::var_os("PATH").unwrap_or_default(); + let resolved = std::env::split_paths(&path_var) + .flat_map(|dir| { + [".COM", ".EXE", ".BAT", ".CMD", ""] + .map(move |ext| dir.join(format!("{tool}{ext}"))) + }) + .find(|candidate| candidate.is_file()); + match resolved { + Some(path) + if matches!( + path.extension().and_then(std::ffi::OsStr::to_str), + Some(ext) if ext.eq_ignore_ascii_case("cmd") || ext.eq_ignore_ascii_case("bat") + ) => + { + let mut command = Command::new("cmd.exe"); + command.args(["/d", "/c"]).arg(path); + command + } + Some(path) => Command::new(path), + None => Command::new(tool), + } + } + #[cfg(not(windows))] + { + Command::new(tool) + } +} + fn assert_tool_pinned(tool: &str, expected_version: &str, install_hint: &str) { - let output = Command::new(tool) + let output = external_tool_command(tool) .arg("--version") .output() .unwrap_or_else(|e| { @@ -369,10 +473,51 @@ fn poll_rpc_result_on_device(hcom_dir: &str, action: &str) -> serde_json::Value ) } -/// Claude's input prompt marker — present whenever the TUI is rendered, -/// independent of the dontAsk / accept-edits mode that hides the -/// "? for shortcuts" status bar. -const CLAUDE_PROMPT_MARKER: &str = "❯"; +/// True if `text` has Claude's input prompt marker at the start of a rendered +/// screen line, present whenever the TUI is rendered, independent of the +/// dontAsk / accept-edits mode that hides the "? for shortcuts" status bar. +/// `--permission-mode bypassPermissions` (used by this test) renders a plain +/// `>` instead of the styled `❯`. Requiring the marker to lead the line (not +/// just appear anywhere) keeps this from matching an unrelated `>` in tips, +/// diffs, or other screen content. +fn screen_has_claude_prompt(text: &str) -> bool { + text.lines().any(|line| { + let trimmed = strip_term_line_number_prefix(line).trim_start(); + trimmed.starts_with('❯') || trimmed.starts_with('>') + }) +} + +/// Strip `hcom term`'s " : " row-index prefix (see `src/commands/term.rs` +/// `format!(" {i:3}: {text}")`), if present, so line-start checks work on +/// both `--json` line arrays (no prefix) and the default rendered output +/// (prefixed). +fn strip_term_line_number_prefix(line: &str) -> &str { + let trimmed = line.trim_start(); + let digits_end = trimmed + .find(|c: char| !c.is_ascii_digit()) + .unwrap_or(trimmed.len()); + if digits_end > 0 && trimmed[digits_end..].starts_with(": ") { + &trimmed[digits_end + 2..] + } else { + line + } +} + +#[test] +fn screen_has_claude_prompt_matches_styled_and_ascii_markers() { + assert!(screen_has_claude_prompt("❯ \n──────")); + assert!(screen_has_claude_prompt("> \n──────")); + assert!(screen_has_claude_prompt(" 15: > \n 16: ──────")); +} + +#[test] +fn screen_has_claude_prompt_ignores_unrelated_greater_than() { + // A `>` appearing mid-line (a tip, a diff, redirected output) is not the + // input prompt and must not produce a false positive. + assert!(!screen_has_claude_prompt("Tip: pipe output > file.txt")); + assert!(!screen_has_claude_prompt(" 12: some text > more text")); + assert!(!screen_has_claude_prompt("no prompt here at all")); +} fn get_screen_local_json(hcom_dir: &str, name: &str) -> Option { let out = hcom_with_dir(&format!("term {name} --json"), hcom_dir); @@ -474,7 +619,7 @@ fn wait_for_screen_drawn(hcom_dir: &str, name: &str, timeout: Duration) -> serde poll_until( || { let s = get_screen_local_json(hcom_dir, name)?; - let has_prompt = screen_lines_joined(&s).contains(CLAUDE_PROMPT_MARKER); + let has_prompt = screen_has_claude_prompt(&screen_lines_joined(&s)); let prompt_empty = s["prompt_empty"].as_bool() == Some(true); if has_prompt && prompt_empty { Some(s) @@ -545,11 +690,15 @@ fn try_remote_launch_claude_headless( target_device: &str, ) -> Result<(String, String), String> { // Model pinning comes via HCOM_CLAUDE_ARGS set in hcom_with_dir. - // --dir is required for remote launches; use /tmp as cwd — it always - // exists on both sides of the local-machine test. --headless keeps the + // --dir is required for remote launches; use the platform temp directory, + // which exists on both sides of this local-machine test. --headless keeps the // launched Claude on hcom's detached PTY runner, preserving term screen / // inject coverage without requiring tmux or another terminal emulator. - let cmd = format!("1 claude --device {target_device} --headless --dir /tmp --go"); + let launch_dir = std::env::temp_dir().to_string_lossy().replace('\\', "/"); + let cmd = format!( + "1 claude --device {target_device} --headless --dir {} --go", + shell_words::quote(&launch_dir) + ); let out = hcom_with_dir(&cmd, hcom_dir); if !out.status.success() { return Err(format!( @@ -578,7 +727,7 @@ fn remote_term_screen_stdout(hcom_dir: &str, remote_name: &str) -> String { last_stderr = String::from_utf8_lossy(&out.stderr).to_string(); if out.status.success() && !last_stdout.contains("Remote term screen failed") - && last_stdout.contains(CLAUDE_PROMPT_MARKER) + && screen_has_claude_prompt(&last_stdout) { return last_stdout; } @@ -648,7 +797,7 @@ fn relay_claude_mock_response(req: &RecordedRequest) -> Reply { TOOL_RELAY_PONG, "Bash", &serde_json::json!({ - "command": "hcom send @bigboss --intent inform -- PONG", + "command": format!("{} send @bigboss --intent inform -- PONG", bash_hcom_command()), "description": "send the relay roundtrip PONG response", }), )); @@ -656,12 +805,11 @@ fn relay_claude_mock_response(req: &RecordedRequest) -> Reply { Reply::Sse(claude_text("msg_relay_roundtrip", "OK")) } -/// Device A has no *local* instances (the one we launched is on Device B -/// and appears as an origin_device_id-tagged mirror row). The relay -/// worker's auto-exit watchdog checks every 30s and shuts the worker down -/// after 2 consecutive empty checks — so Device A's worker dies ~60s -/// after Phase 7, right before we need it for the long Phase 10 / 14 -/// polling. Re-arm it before each long-running RPC on Device A. +/// `hcom relay on` is idempotent — a no-op if the worker is already running. +/// The worker's auto-exit watchdog only fires when relay is *not* enabled in +/// config (see `auto_exit_watchdog` in src/relay/worker.rs); both test +/// devices enable relay in Phases 1/3, so this call is cheap insurance +/// before an RPC rather than a fix for a known auto-exit race. fn ensure_relay_worker(hcom_dir: &str) { let out = hcom_with_dir("relay on", hcom_dir); if !out.status.success() { @@ -673,10 +821,48 @@ fn ensure_relay_worker(hcom_dir: &str) { thread::sleep(Duration::from_millis(500)); } +/// Diagnostic snapshot for a Phase 10 timeout (either step): both devices' +/// relay status, Device B's live screen and recent events, and the last few +/// requests the Claude mock actually received. A bare "timed out" panic gives +/// no way to tell a relay-delivery problem from a stuck turn from a Bash tool +/// call failing outright — this makes a CI failure here diagnosable without +/// another round-trip. +fn phase10_diagnostics( + path_a: &str, + path_b: &str, + launched_name: &str, + claude_mock: &MockHttp, +) -> String { + let device_b_screen = get_screen_local_json(path_b, launched_name) + .map(|s| s.to_string()) + .unwrap_or_else(|| "".to_string()); + let device_b_events = hcom_with_dir("events --last 20", path_b); + let relay_status_a = hcom_with_dir("relay status", path_a); + let relay_status_b = hcom_with_dir("relay status", path_b); + let recent_mock_requests: String = claude_mock + .requests() + .iter() + .rev() + .take(3) + .map(|r| format!(" {} {}\n body: {}\n", r.method, r.path, r.body)) + .collect(); + format!( + "Device A relay status:\n{}\n\ + Device B relay status:\n{}\n\ + Device B screen: {device_b_screen}\n\ + Device B recent events:\n{}\n\ + Last mock requests (newest first):\n{recent_mock_requests}", + String::from_utf8_lossy(&relay_status_a.stdout), + String::from_utf8_lossy(&relay_status_b.stdout), + String::from_utf8_lossy(&device_b_events.stdout), + ) +} + /// Kill orphan debug relay-worker processes from previous failed test runs. /// Without this, a stale daemon can hold MQTT connections and interfere with /// new test runs (the test creates isolated HCOM_DIRs but can't find orphan /// PIDs once the old temp dir is deleted). +#[cfg(unix)] fn kill_orphan_debug_daemons() { let Ok(output) = std::process::Command::new("pgrep") .args(["-f", "target/debug/hcom relay-worker"]) @@ -694,25 +880,19 @@ fn kill_orphan_debug_daemons() { } } +#[cfg(windows)] +fn kill_orphan_debug_daemons() { + // Windows has no built-in command-line process matcher equivalent to + // pgrep. Each run uses unique HCOM_DIRs and its PID-file-owned daemons are + // still cleaned by RelayGuard below. +} + fn kill_daemon(hcom_dir: &str) { let pid_path = Path::new(hcom_dir).join(".tmp").join("relay.pid"); if let Ok(content) = fs::read_to_string(&pid_path) - && let Ok(pid) = content.trim().parse::() + && let Ok(pid) = content.trim().parse::() { - unsafe { - libc::kill(pid, libc::SIGTERM); - } - // Wait up to 3s - for _ in 0..30 { - thread::sleep(Duration::from_millis(100)); - if unsafe { libc::kill(pid, 0) } != 0 { - return; - } - } - // Still alive — SIGKILL - unsafe { - libc::kill(pid, libc::SIGKILL); - } + support::terminate_process_group(pid); } } @@ -1081,10 +1261,12 @@ fn test_relay_roundtrip() { // ── Phase 7: Device A remotely launches on Device B ────────── logln!(log, "\n[Phase 7] Device A: remote launch on Device B..."); + let claude_version = + std::env::var("HCOM_TEST_CLAUDE_VERSION").unwrap_or_else(|_| "2.1.185".to_string()); assert_tool_pinned( "claude", - "2.1.185", - "scripts/install-mock-tools.sh @anthropic-ai/claude-code@2.1.185", + &claude_version, + &format!("scripts/install-mock-tools.sh @anthropic-ai/claude-code@{claude_version}"), ); let baseline_event_b = last_event_id(&path_b); @@ -1148,7 +1330,7 @@ fn test_relay_roundtrip() { assert_eq!(initial_screen["prompt_empty"].as_bool(), Some(true)); logln!( log, - " OK: claude TUI drawn (prompt marker '{CLAUDE_PROMPT_MARKER}' present, prompt empty)" + " OK: claude TUI drawn (prompt marker present, prompt empty)" ); // ── Phase 8: term_screen on live instance ───────────────────── @@ -1179,7 +1361,7 @@ fn test_relay_roundtrip() { ); let rpc_content = rpc_screen["result"]["content"].as_str().unwrap_or(""); assert!( - rpc_content.contains(CLAUDE_PROMPT_MARKER), + screen_has_claude_prompt(rpc_content), "term_screen rpc_result.content missing claude prompt marker: {rpc_content}" ); logln!( @@ -1280,9 +1462,23 @@ fn test_relay_roundtrip() { Duration::from_secs(15), Duration::from_millis(500), ); + // Clearing the input line only proves that Claude accepted the submitted + // marker. ConPTY can report that frame before the turn finishes, while + // delivery is still gated. Wait for the stable idle prompt before sending + // the Phase 10 message. + wait_for_screen_drawn(&path_b, &launched_name, Duration::from_secs(60)); + poll_until( + || { + let instance = find_instance_by_base(&path_b, &launched_name)?; + (instance["status"].as_str() == Some("listening")).then_some(()) + }, + "Claude returned to listening after marker turn", + Duration::from_secs(60), + Duration::from_millis(500), + ); logln!( log, - " OK: marker consumed from input after enter; both inject RPCs ok=true" + " OK: marker turn finished and prompt returned idle; both inject RPCs ok=true" ); // ── Phase 10: real send+reply round-trip via relay ─────────── @@ -1296,6 +1492,11 @@ fn test_relay_roundtrip() { // Watermark Device A's events so we only count relayed replies that // arrive AFTER the question goes out. let pre_send_event_a = last_event_id(&path_a); + // Same for Device B's own status events: without this, a stale + // delivery→listening cycle already sitting in the last-20 window (e.g. + // Phase 9's inject turn) could satisfy the Step 1 scan below by + // coincidence rather than by actually observing this message's turn. + let pre_send_event_b = last_event_id(&path_b); // Send from bigboss (`-b`) rather than a synthetic `--from` label. // `--from ` is a CLI-only sender stamp with no return route on @@ -1318,23 +1519,39 @@ fn test_relay_roundtrip() { " OK: sent question from bigboss to @{launched_name}:{short_b}" ); - // Step 1: Device B's claude received and processed (status round-trip). poll_until( || { - let out = hcom_with_dir( - &format!("events --type status --agent {launched_name} --last 20"), - &path_b, - ); - if !out.status.success() { - return None; - } - let mut saw_delivery = false; - let mut saw_listening_after = false; + let out = hcom_with_dir("events --type message --last 20", &path_b); + let stdout = String::from_utf8_lossy(&out.stdout); + stdout.contains(question).then_some(()) + }, + "Device B received targeted Phase 10 message", + Duration::from_secs(30), + Duration::from_millis(500), + ); + logln!(log, " OK: Device B received targeted Phase 10 message"); + + // Step 1: Device B's claude received and processed (status round-trip). + let step1_deadline = Instant::now() + Duration::from_secs(120); + loop { + let out = hcom_with_dir( + &format!("events --type status --agent {launched_name} --last 20"), + &path_b, + ); + let mut saw_delivery = false; + let mut saw_listening_after = false; + if out.status.success() { for line in String::from_utf8_lossy(&out.stdout).lines() { let ev: serde_json::Value = match serde_json::from_str(line.trim()) { Ok(v) => v, Err(_) => continue, }; + // Events are returned oldest-first; ignore anything at or + // before the pre-send watermark so a stale delivery→listening + // cycle already in the last-20 window can't false-match. + if ev["id"].as_i64().unwrap_or(0) <= pre_send_event_b { + continue; + } let data = &ev["data"]; let ctx = data["context"].as_str().unwrap_or(""); let status = data["status"].as_str().unwrap_or(""); @@ -1346,24 +1563,27 @@ fn test_relay_roundtrip() { saw_listening_after = true; } } - if saw_delivery && saw_listening_after { - Some(()) - } else { - None - } - }, - &format!("{launched_name} processed message (delivery → listening)"), - Duration::from_secs(120), - Duration::from_secs(2), - ); + } + if saw_delivery && saw_listening_after { + break; + } + if Instant::now() >= step1_deadline { + panic!( + "Timeout (120s): {launched_name} processed message (delivery → listening)\n{}", + phase10_diagnostics(&path_a, &path_b, &launched_name, &claude_mock) + ); + } + thread::sleep(Duration::from_secs(2)); + } logln!( log, " OK: {launched_name} processed the message and returned to listening" ); - // Device A's worker may have auto-exited during the long status wait - // (watchdog exits after ~60s with no local instances). Re-arm so the - // PONG reply event can land here. + // Device A's worker auto-exits only when relay is *not* enabled in its + // config (see `auto_exit_watchdog` in src/relay/worker.rs) — both devices + // enabled relay in Phases 1/3, so this is just cheap insurance, not a + // known race fix. ensure_relay_worker(&path_a); // Step 2: the real round-trip — claude's PONG reply must reach @@ -1372,12 +1592,11 @@ fn test_relay_roundtrip() { // claude wrote something locally on Device B. let expected_from = format!("{launched_name}:{short_b}"); let mut last_log_count = 0usize; - let pong_event = poll_until( - || { - let out = hcom_with_dir("events --type message --last 50", &path_a); - if !out.status.success() { - return None; - } + let pong_deadline = Instant::now() + Duration::from_secs(90); + let pong_event = loop { + let out = hcom_with_dir("events --type message --last 50", &path_a); + let mut found = None; + if out.status.success() { let stdout = String::from_utf8_lossy(&out.stdout); let mut new_count = 0usize; for line in stdout.lines() { @@ -1399,21 +1618,28 @@ fn test_relay_roundtrip() { let from = data["from"].as_str().unwrap_or(""); let text = data["text"].as_str().unwrap_or(""); if from == expected_from && text.to_uppercase().contains("PONG") { - return Some(ev); + found = Some(ev); + break; } } - if new_count != last_log_count { + if found.is_none() && new_count != last_log_count { last_log_count = new_count; eprintln!( " {new_count} new message events on A, none from {expected_from} with PONG yet" ); } - None - }, - &format!("Device A receives PONG reply event from {expected_from}"), - Duration::from_secs(90), - Duration::from_secs(2), - ); + } + if let Some(ev) = found { + break ev; + } + if Instant::now() >= pong_deadline { + panic!( + "Timeout (90s): Device A receives PONG reply event from {expected_from}\n{}", + phase10_diagnostics(&path_a, &path_b, &launched_name, &claude_mock) + ); + } + thread::sleep(Duration::from_secs(2)); + }; logln!( log, " OK: Device A received PONG reply event (id={}, from={expected_from})", @@ -1507,19 +1733,14 @@ fn test_relay_roundtrip() { // Double-check directly against Device B's SQLite DB. let db_path_b = Path::new(&path_b).join("hcom.db"); - let sql_out = Command::new("sqlite3") - .arg(&db_path_b) - .arg(format!( - "SELECT tag FROM instances WHERE name='{launched_name}'" - )) - .output() - .expect("failed to run sqlite3"); - assert!( - sql_out.status.success(), - "sqlite3 failed: {}", - String::from_utf8_lossy(&sql_out.stderr) - ); - let sql_tag = String::from_utf8_lossy(&sql_out.stdout).trim().to_string(); + let db = rusqlite::Connection::open(&db_path_b).expect("open Device B database"); + let sql_tag: String = db + .query_row( + "SELECT tag FROM instances WHERE name = ?1", + rusqlite::params![launched_name], + |row| row.get(0), + ) + .expect("read Device B instance tag"); assert_eq!( sql_tag, "test-relay-tag", "Device B DB tag column != test-relay-tag: {sql_tag:?}" @@ -1562,12 +1783,24 @@ fn test_relay_roundtrip() { " OK: Device A removed mirrored remote instance after kill" ); - // After the kill, a remote term_screen must fail (no inject port). - let post_kill_term = hcom_with_dir(&format!("term {remote_name}"), &path_a); - let post_kill_stdout = String::from_utf8_lossy(&post_kill_term.stdout).to_string(); - assert!( - post_kill_stdout.contains("Remote term screen failed") || !post_kill_term.status.success(), - "term_screen should fail after kill, got stdout:\n{post_kill_stdout}" + // After the kill, a remote term_screen must eventually fail (no inject + // port). The killed instance's DB row clears as soon as its tracked child + // process dies, but the PTY manager process behind the inject port can + // legitimately outlive that by a couple of seconds — its reader thread + // joins the ConPTY pipe with a bounded 2s timeout before tearing itself + // down (see `join_with_timeout` in `src/pty/win.rs`) — so poll rather + // than asserting on the very first check. + poll_until( + || { + let post_kill_term = hcom_with_dir(&format!("term {remote_name}"), &path_a); + let post_kill_stdout = String::from_utf8_lossy(&post_kill_term.stdout).to_string(); + (post_kill_stdout.contains("Remote term screen failed") + || !post_kill_term.status.success()) + .then_some(()) + }, + "term_screen should fail after kill", + Duration::from_secs(10), + Duration::from_millis(300), ); logln!(log, " OK: term_screen after kill fails as expected");