Skip to content

feat(windows): add native Windows support#67

Merged
aannoo merged 117 commits into
aannoo:mainfrom
keinstn:feat/windows-support
Jul 6, 2026
Merged

feat(windows): add native Windows support#67
aannoo merged 117 commits into
aannoo:mainfrom
keinstn:feat/windows-support

Conversation

@keinstn

@keinstn keinstn commented Jun 27, 2026

Copy link
Copy Markdown
Contributor

Related to #51

Summary

Adds native Windows support to hcom and includes Windows release packaging: an x86_64 MSVC standalone archive, a PowerShell installer, and a win_amd64 Python wheel.

The implementation replaces POSIX-only assumptions with a platform abstraction layer and a Windows ConPTY backend while sharing path resolution, delivery state, terminal presets, and screen tracking across platforms.

What changed

  • src/sys/: platform abstraction for process kill/signals, file permissions, sockets, stdin liveness. Windows process-tree kill re-scans for stragglers spawned mid-kill.
  • src/pty/win.rs: ConPTY-backed PTY proxy for I/O, resize, titles, approval/delivery scraping, sharing cross-platform behavior through src/pty/shared.rs.
  • Process launch: PowerShell runner scripts; .cmd/.bat npm shims through cmd.exe; graceful terminate and process-tree escalation; bounded child waits.
  • Cross-platform paths/hooks: shared runtime path helpers, PowerShell permission support, and terminal presets represented as executable plus argv.
  • CI: native Windows fmt, clippy, unit, release-build, package-smoke, and real-tool coverage.
  • Release: x86_64-pc-windows-msvc cargo-dist artifact, PowerShell installer, and Windows maturin wheel.

Known limitations / deferred

  • Antigravity hooks remain POSIX-only because its Windows execution mechanism is unconfirmed.
  • DB archival can fail while another hcom process holds the file open on Windows.
  • Secret files do not yet use Windows owner-only ACLs.
  • hcom term hook-orphan detection and bash workflow scripts (confess, debate, fatcow) remain limited on Windows.
  • Windows ARM64 packaging is not included; this release targets Windows x86_64.

Testing

  • Unit, formatting, clippy, and TypeScript typecheck gates
  • Native Windows build and compiled-binary smoke test
  • Real Codex, Claude, and relay lifecycle matrices on Linux and Windows
  • Manual end-to-end testing on Windows 11
  • Release planning includes the Windows archive, PowerShell installer, and wheel

@aannoo

aannoo commented Jun 29, 2026

Copy link
Copy Markdown
Owner

Nice! Here is AI review, some may not be relevant/correct, you decide:

P1

Background processes are not detached on Windows

src/sys/process.rs (detach_session)

detach_session() sets only CREATE_NEW_PROCESS_GROUP. That makes the child
signallable as a group but leaves it sharing the parent's console, so the
background powershell spawn in terminal.rs (launch_terminal) dies when the
terminal closes. Headless agents and the relay worker are affected. The earlier
Python implementation (commit 50c9bfd) used CREATE_NO_WINDOW plus a hidden
startup config. Windows needs a real detached/no-window launch here, reconciled
with the Ctrl+Break shutdown path that only reaches processes on the caller's
console.

npm tool shims can't be launched through ConPTY

src/main.rs (pty dispatch), src/pty/win.rs (Proxy::spawn), src/terminal.rs (which_candidates)

which_bin() resolves extensionless names through PATHEXT, so a normal npm
install yields gemini.cmd or codex.cmd. That path goes straight to
portable-pty as the executable, which passes it to CreateProcessW as the
application name. Windows won't run .cmd/.bat files that way — they need
cmd.exe /c. Common npm-installed agents fail before the PTY starts even though
hcom found the tool.

Generated PowerShell files are not safe for Unicode input

src/launcher.rs (create_runner_script_windows, sidecar write), src/terminal.rs (create_powershell_script)

The .ps1 launchers and env sidecars are written as BOM-less UTF-8 (fs::write,
writeln!) and run through powershell — Windows PowerShell 5.1. Without a BOM,
5.1 reads them in the legacy ANSI codepage, so non-ASCII usernames, paths,
prompts, env values, or binary paths corrupt. Write a UTF-8 BOM (or target
pwsh).

Windows does not record user keyboard activity

src/pty/win.rs (stdin thread)

The stdin thread forwards bytes to ConPTY but never updates
ScreenState.last_user_input; the Unix path does on every keystroke
(pty/mod.rs). After the initial cooldown, the delivery gate
(delivery.rs) can treat a typing user as idle. Worst for tools like Gemini
where activity detection is the main gate and prompt-empty detection is off.

Gemini, Antigravity, and Claude hooks are POSIX-only

src/hooks/gemini.rs, src/hooks/antigravity.rs, src/hooks/claude.rs

All three emit sh -c, command -v, exec, POSIX env assignment, and
redirection; Antigravity also uses printf and base64 -d; Claude's command
sets no hook shell. On Windows these tools run hook commands through PowerShell
(Claude prefers Git Bash when present), and stock Windows isn't guaranteed to
have sh. The hooks fail, which disables registration, status updates,
bootstrap, and polling. Use each tool's cross-platform exec form or generate
PowerShell.

OpenCode and Kilo config/database paths are Unix-derived

src/hooks/opencode.rs (xdg_config_home, plugin_dir_for_app, get_family_db_path), src/integration_spec.rs

current_home_dir() falls back to dirs::home_dir(), but xdg_config_home()
reads only XDG_CONFIG_HOME/HOME. With both unset (common on Windows) it
builds /.config, so hcom can write hcom.ts under C:\.config\opencode\plugins
while OpenCode reads the real config dir — hook verification then passes against
a file the tool never loads. get_family_db_path() falls back to
$HOME/.local/share/... instead of the Windows data dir, so the transcript DB
can go unrecorded. Kilo is treated as an old OpenCode layout
(KILO_CONFIG_DIR, .kilo/plugins, ~/.local/share/kilo/kilo.db); current Kilo
puts config at %USERPROFILE%\.config\kilo\kilo.jsonc and the DB under
%LOCALAPPDATA%\kilo\kilo.db.

Database archival fails while another process holds the DB open

src/db/mod.rs (ensure_schema, archive_db_at)

Before archiving an incompatible DB, ensure_schema() swaps only its own
connection to in-memory. Other agents, relay workers, and hooks keep the file
open. archive_db_at() then calls remove_file() on the live path. SQLite on
Windows opens without FILE_SHARE_DELETE, so the delete fails while any handle
is open. A schema mismatch mid-session breaks at the archive step.

Native Windows is absent from every release channel

dist-workspace.toml, .github/workflows/build-wheels.yml, publish-pypi.yml, pyproject.toml, README.md

The cargo-dist target list and the wheel matrix have no Windows target; the
installer covers macOS, Linux, Android, and WSL; the PyPI classifiers advertise
macOS and POSIX only. pip install hcom and uv tool install hcom have no
Windows wheel. The new CI job proves the source compiles but ships nothing a
Windows user can install. Shipping the feature needs a Windows artifact and an
install-and-run smoke test against it.

P2

Approval detection is missing from the Windows delivery state

src/pty/win.rs (update_screen_state)

The Windows path defers approval scraping and never sets state.approval,
disabling the approval status and delivery gate for Codex, Cursor, and
Antigravity.

A terminated process can be reported as alive

src/sys/process.rs (is_alive)

is_alive() treats any successful OpenProcess() as proof of life. A process
object stays valid after termination while another handle is open (hcom and the
job object both hold one), so a dead child reads as running — delaying cleanup
and leaving stale state. Query GetExitCodeProcess() and require STILL_ACTIVE.

Codex file-edit tracking is not started

src/pty/mod.rs (start_delivery_thread), src/pty/win.rs

The Unix path starts codex_file_edits::run_transcript_watcher() for Codex; the
Windows path has no watcher. It records apply_patch edits for collision
detection, so parallel Codex agents lose the file-collision signal on Windows.

Delivery starts before the tool is ready

src/pty/win.rs (Proxy::run), src/pty/mod.rs (Proxy::run)

Windows starts the delivery thread right after the IO threads. Unix gates
start_delivery_thread() on the ready pattern or the delivery-start timeout.
The in-loop ready gate still applies on Windows, so premature injection is
partly contained, but the notify/inject ports register early and the startup
delay is gone.

Delivery-init failures don't fail the launch

src/pty/win.rs (spawn_delivery_thread), src/pty/mod.rs (start_delivery_thread)

If the Windows delivery thread can't open the DB or create its notify server, it
logs and returns; the child keeps running and run() reports a normal launch.
Unix sends an init result back and errors out on failure or timeout. A Windows
agent can look launched while delivery and wake-up are permanently dead.

ConPTY is never resized after startup

src/pty/win.rs (Proxy::spawn, Proxy::run)

The size is captured once at spawn; MasterPty::resize() is never called.
Full-screen tools keep stale dimensions after the window changes, breaking
rendering, wrapping, and screen parsing.

Headless shutdown skips the graceful phase

src/hooks/common.rs (stop_instance_inner), src/sys/process.rs

The stop path calls terminate_group(), waits up to 2s, then kill_group().
On Windows both map to kill_tree_win(), so the first call already force-kills
the tree. There's no SIGTERM-equivalent grace window for shutdown hooks,
transcript flushing, or state cleanup before background agents are terminated.

Process-tree termination can miss descendants spawned after the snapshot

src/sys/process.rs (kill_tree_win)

The kill walks one ToolHelp snapshot, deepest-first, root last. A child the
root spawns after the snapshot but before the root dies isn't in the tree. The
KILL_ON_JOB_CLOSE job object backstops ConPTY children, so this mainly affects
the background PowerShell path, which has no job object.

hcom term always returns an empty screen

src/pty/win.rs (inject thread)

Every SCREEN query is answered with an empty string. Known missing feature,
not parity.

The mintty preset runs PowerShell source with bash

src/shared/terminal_presets.rs, src/terminal.rs (windows_shellify_preset)

Windows generates a .ps1, but windows_shellify_preset() leaves
mintty bash {script} unchanged, so bash is handed a PowerShell script.

Custom Windows terminal paths are parsed with POSIX rules

src/terminal.rs (shell_split)

shell_split() treats every backslash outside single quotes as an escape, so
C:\Tools\terminal.exe is mangled unless the user finds a quoting workaround.

Closing supported Windows panes still requires sh

src/terminal.rs (close_terminal_pane)

Close commands always run through sh -c, including presets marked
Windows-supported. Without a POSIX shell, wezterm cli kill-pane and similar
never run; the process dies but the pane stays open and pane_closed is false.

Workflow scripts require bash

src/commands/run.rs

Bundled workflows write .sh files run with bash, and non-Python user
workflows always go to bash. Stock Windows can't run hcom run confess,
debate, fatcow, or custom shell workflows.

The update subsystem requires a POSIX shell

src/update.rs, src/commands/update.rs

The background check runs a script with sh -c, applying an update runs
sh -c, and the default command pipes the Unix installer to sh. On stock
Windows, update checks fail silently and hcom update can't apply a release.

Login-shell env reconstruction is Unix-only

src/shell_env.rs

The resolver defaults to /bin/bash, runs -lic, captures via printf/env -0,
and rejects pwsh/powershell. On Windows it fails and falls back to parent
inheritance, so nested launches miss the clean login env. It fails open, so this
is a quality gap, not a hard break.

Hook orphan detection is disabled

src/sys/io.rs (stdin_appears_broken), src/hooks/common.rs (poll_loop)

stdin_appears_broken() always returns false on Windows. If the parent tool
exits while its instance row remains, the hook can't detect the orphan and keeps
polling and holding its notify endpoint until timeout.

Secret files are not owner-only on Windows

src/sys/fs.rs (create_private_new, set_private), src/launcher.rs, src/config.rs, src/shell_env.rs

create_private_new() and set_private() are no-ops on Windows. Env sidecars,
config.toml (which can hold relay_psk), and the serialized shell-env cache
rely on inherited ACLs. Profile-local files are private by default, so the real
exposure is a custom HCOM_DIR under a shared/permissive directory.

Transcript search uses a separate Unix-derived resolver

src/transcript/opencode.rs (get_family_db_path), src/commands/resume.rs (opencode_family_data_dir)

Transcript search derives OpenCode/Kilo paths from XDG_DATA_HOME/$HOME and
skips the dirs::data_dir() fallback that resume uses. On the same Windows
machine, resume can find a DB under %LOCALAPPDATA% while search reports none.

Dynamic titles and status icons are absent from the Windows proxy

src/pty/win.rs, src/pty/mod.rs, src/runtime_env.rs (set_terminal_title)

The Windows proxy passes current_name/current_status to the delivery loop
but never writes OSC title sequences when they change. The hook fallback writes
to /dev/tty, which does nothing on Windows. Renames and status changes don't
reach the title.

Gemini transcript discovery and version check assume a Unix layout

src/hooks/gemini.rs (derive_gemini_transcript_path, get_gemini_version)

Transcript discovery builds $HOME/.gemini/tmp with no dirs::home_dir()
fallback, returning None on Windows without HOME — losing resume metadata
and rebinding. The version check looks for package.json next to the resolved
binary, which on Windows is a .cmd shim, so detection fails; an undetectable
version is treated as supported, so an old Gemini can slip past the gate.

Claude's PowerShell tool is absent from the permission allowlist

src/hooks/claude.rs (managed permission patterns)

hcom installs rules only for Bash(hcom ...) and Bash(uvx hcom ...). Windows
Claude can run hcom commands through its PowerShell tool, which the Bash rule
doesn't match, stalling an unattended agent at an approval prompt.

The generated runner discards the agent's exit code

src/launcher.rs (create_runner_script_windows, runner invocation), src/terminal.rs

The runner ends at & hcom pty ... without saving $LASTEXITCODE or calling
exit. It's launched via powershell -File, which returns 0 unless the script
exits explicitly. A failing agent, a nonzero PTY status, or the intended 130
kill code all report success. Capture $LASTEXITCODE after hcom pty and exit
with it.

The Windows PTY drops early launch context and pre-bind diagnostics

src/pty/win.rs, src/pty/mod.rs, src/instance_binding.rs, src/delivery.rs

The Unix proxy stores an early launch_context (PID plus terminal metadata like
WEZTERM_PANE, TMUX_PANE, KITTY_WINDOW_ID, ZELLIJ_PANE_ID,
KITTY_LISTEN_ON) and, on a pre-bind exit, records the exit code, time since
spawn, visible tail, and an exited_before_bind reason. Windows stores only the
PID and, on exit, checks only whether the code is 130 before stopping delivery —
no finalizer. So hcom can't close an early-failing agent's pane (no pane id), and
failures from PowerShell parsing, Unicode paths, missing binaries, .cmd shims,
hooks, or a wrong cwd lose the output needed to diagnose them. This builder is
largely platform-independent and should be shared.

Windows CI verifies compilation, not lifecycle

.github/workflows/ci.yml (windows-build)

The job runs cargo build --locked and cargo test --locked --bins.
Integration and real-tool suites stay Linux-only. Nothing exercises the
PowerShell launchers, ConPTY input/injection, .cmd shims, cwd propagation,
background survival, archival with concurrent handles, hook polling, Unicode
paths, pane closing, or tree shutdown. Every runtime failure above passes this
job.

P3

Exit code 130 is treated as proof of hcom kill

src/pty/win.rs (Proxy::run)

run() sets EXIT_WAS_KILLED whenever the child exits 130, without checking
that hcom sent the request. Any unrelated 130 is logged as a kill. 130 is a Unix
convention rarely produced on Windows, so collisions are unlikely.

Claude print-mode args use bash quoting in PowerShell

src/launcher.rs (build_claude_command), src/terminal.rs

build_claude_command() serializes args with the POSIX shell_quote(), so
don't becomes bash's 'don'\''t' instead of PowerShell's 'don''t' when
embedded in the script. Only the explicit print path (-p/--print, background
NativePrint) is affected, and only for args containing quotes.

Windows PTY input uses competing blocking writers and constant polling

src/pty/win.rs (stdin thread, inject thread)

Both threads write to ConPTY through the same Mutex<Box<dyn Write>>, each
holding it across a blocking write_all()/flush, so one can stall the other;
write errors are dropped. The inject thread also wakes every 10ms regardless of
activity. A single channel-fed writer plus an event-driven inject wait would
serialize both and centralize error handling.

Design notes

Two structural points behind several findings above:

  • The Windows PTY reimplements the proxy's lifecycle and delivery orchestration
    rather than just the platform transport. That duplication is why readiness
    sequencing, init acknowledgement, launch metadata, failure finalization,
    approval state, resize, titles, and the Codex watcher were kept on Unix and
    dropped on Windows. The shared layer should own launch state, delivery
    startup, screen-state updates, status/title, finalization, and shutdown; the
    backend should only spawn, read, write, resize, wait, and kill.
  • Terminal presets are shell strings parsed with POSIX rules and text-rewritten
    to PowerShell on Windows, expected to stay valid across bash, PowerShell, and
    cmd.exe. That's the root of the mintty, backslash, and quoting bugs. Presets
    should be an executable plus an argument vector with explicit platform
    variants, substituted per argument and launched directly.

@keinstn

keinstn commented Jun 29, 2026

Copy link
Copy Markdown
Contributor Author

Hi, @aannoo

Thank you for your feedback! I'll check them later!

@keinstn

keinstn commented Jun 30, 2026

Copy link
Copy Markdown
Contributor Author

Progress on review feedback

Here's where things stand. 18 of 37 items are resolved; the remaining 19 are work in progress.

Summary

Priority Resolved Open Total
P1 2 5 7
P2 13 12 25
P3 1 2 3
Design notes 2 0 2
Total 18 19 37

P1 — Resolved ✅

Issue Commit
Database archival fails while another process holds the DB open 178ed19 fix(windows): unblock schema archive + gate Unix-only unit tests
Gemini/Antigravity/Claude hooks are POSIX-only (Claude only; Gemini/Antigravity remain open) 55756a5 fix(windows): make Claude hooks fire on Windows
3c22da6 fix(windows): Claude runs hooks via Git Bash

P1 — Open ❌ (work in progress)

Issue Status
Background processes are not detached on Windows CREATE_NEW_PROCESS_GROUP only; CREATE_NO_WINDOW not added (sys/process.rs:208)
npm tool shims can't be launched through ConPTY which_bin resolves to .cmd, passed directly to CreateProcessW with no cmd.exe /c wrapper
Generated PowerShell files are not safe for Unicode input Written via fs::write with no UTF-8 BOM (src/launcher.rs:872)
Gemini / Antigravity hooks are POSIX-only sh -c, printf, base64 -d — no PowerShell equivalent yet
OpenCode and Kilo config/database paths are Unix-derived xdg_config_home / get_family_db_path unchanged
Native Windows absent from every release channel No Windows target in dist-workspace.toml / build-wheels.yml / pyproject.toml

P2 — Resolved ✅

Issue Commit
Approval detection missing from Windows delivery state 0d499df, 07dd78b
Windows does not record user keyboard activity 0d499dfnote_user_keystroke called from stdin thread, updates last_user_input
Codex file-edit tracking is not started 0d499dfshared::start_delivery_thread now spawns the transcript watcher
Delivery starts before the tool is ready 0d499df, c47ea0e, ad15222, b4e8911 — ready-or-timeout gate moved into the reader thread
Delivery-init failures don't fail the launch 0d499df, ad15222, c47ea0e, 0c9a731launch_failed atomic + retry on transient errors
ConPTY is never resized after startup 0d499dfspawn_resize_watcher polls at ~200 ms and calls MasterPty::resize
Headless shutdown skips the graceful phase 1b19239terminate() now sends CTRL_BREAK_EVENT instead of force-killing
Process-tree termination can miss late descendants 9bf4582CreateToolhelp32Snapshot retried once on failure; KILL_ON_JOB_CLOSE job object backstops ConPTY children
Dynamic titles and status icons absent from the Windows proxy 0d499df — reader thread writes OSC title escapes via build_title_escape
Windows PTY drops early launch context and pre-bind diagnostics 4a5a41f, 0d499dflast_tail capture + finalize_launch_failure_after_exit
Windows CI verifies compilation only 2e9da0d — unit tests now run on Windows, not just cargo build
mintty preset hands a .ps1 to bash 1207acb — mintty open argv is now mintty powershell -ExecutionPolicy Bypass -NoExit -File {script}
Closing supported Windows panes still requires sh 1207acb — close commands are now launched directly via Command::spawn; no sh -c

P2 — Open ❌ (work in progress)

Issue Status
Terminated process can be reported as alive is_alive checks OpenProcess only; GetExitCodeProcess + STILL_ACTIVE not added (sys/process.rs:13)
hcom term always returns an empty screen Known missing feature
Custom Windows terminal paths parsed with POSIX rules args_common::shell_split still used for the HCOM_TERMINAL env-var path (terminal.rs:1861); d680b7c adds a doc comment only
Workflow scripts require bash src/commands/run.rs unchanged
Update subsystem requires a POSIX shell src/update.rs unchanged
Login-shell env reconstruction is Unix-only src/shell_env.rs unchanged
Hook orphan detection is disabled stdin_appears_broken always returns false on Windows (sys/io.rs:10)
Secret files are not owner-only on Windows create_private_new / set_private are no-ops on Windows (sys/fs.rs)
Transcript search uses a Unix-derived resolver src/transcript/opencode.rs unchanged
Gemini transcript discovery / version check assume Unix layout derive_gemini_transcript_path / get_gemini_version unchanged
Claude's PowerShell tool absent from permission allowlist src/hooks/claude.rs unchanged
Generated runner discards the agent's exit code No $LASTEXITCODE capture or exit after {run_line} in create_runner_script_windows (launcher.rs:868); create_powershell_script is fixed but the native PTY runner path is not

P3 — Resolved ✅

Issue Commit
Exit code 130 treated as proof of hcom kill b6cfc5fEXIT_WAS_KILLED now only set when the kill was actually requested by hcom

P3 — Open ❌ (work in progress)

Issue Status
Claude print-mode args use bash quoting in PowerShell build_claude_command uses POSIX shell_quote (launcher.rs:670)
Windows PTY input uses competing blocking writers stdin and inject threads share the same Mutex<Box<dyn Write>> across blocking writes

Design notes — Resolved ✅

Issue Commit
Windows PTY reimplements lifecycle/delivery orchestration 0d499df — portable PTY orchestration extracted to pty/shared.rs; Windows proxy now shares delivery-thread startup, ready-gate, approval state, resize, titles, and finalization with Unix
Terminal presets are shell strings parsed with POSIX rules 1207acb — presets rewritten as executable + argument vector with per-platform variants (PlatformArgv { default, windows }); windows_shellify_preset, shell_split, and parse_terminal_command removed

The bulk of the P2 runtime gaps (ConPTY lifecycle, delivery orchestration, approval detection, keyboard activity, Codex watcher, resize, dynamic titles, early launch diagnostics) and both structural design-note items are addressed. The remaining open items — hooks (Gemini/Antigravity), the update/workflow/shell-env stack, the release pipeline, and the three P1 launch-path issues (process detach, .cmd shims, Unicode BOM) — are work in progress.

keinstn and others added 26 commits July 1, 2026 07:38
…itives

Introduce src/sys/ as the single boundary for OS-specific behavior. Migrate
process liveness/termination/session-setup and file identity/permissions off
direct libc/nix/std::os::unix calls so call sites are platform-neutral.

- sys::process: is_alive, kill, terminate, kill_child_group, detach_session
  (Unix: libc/nix signals + setsid; Windows: Win32 process APIs + job group flag)
- sys::fs: set_private, set_executable, file_id
  (Unix: mode bits + inode; Windows: no-op modes + GetFileInformationByHandle)
- Make nix/libc/signal-hook Unix-only; add windows-sys for the Windows backend.

Migrated callers: pidtrack, shell_env, db, commands/{daemon,relay}.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The PTY proxy is built on nix openpty/termios/poll and cannot compile for
Windows. Gate the module and its entry points so the rest of hcom builds on
Windows, where run_pty returns a clear 'not yet supported' error.

- main.rs: #[cfg(unix)] mod pty; split run_pty into Unix impl + Windows stub.
- Move EXIT_WAS_KILLED from pty into delivery so the delivery loop compiles
  without the wrapper; the proxy still sets it on Unix.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…layer

Add sys::{net,io,signal} and extend sys::{fs,process} so all direct
libc/nix/std::os::unix usage outside the Unix-only PTY module funnels through
the platform boundary. With this, the crate compiles for x86_64-pc-windows
(cargo check) while macOS/Linux behavior is unchanged.

- sys::net::wait_readable: notify-server socket wait (Unix poll / Windows select)
- sys::io::stdin_appears_broken: orphan-detection stdin probe
- sys::signal::register_{term,int}: shutdown flags (signal-hook / ConsoleCtrlHandler)
- sys::fs: lock_exclusive, create_private_new, is_socket
- sys::process: terminate_group/kill_group (GroupSignal), exec_replace

Migrated: notify/server, hooks/common, commands/listen, relay/{worker,mod},
instance_names, instance_lifecycle, launcher, router, terminal.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Context/FromStr are only used by the Unix run_pty; gate them behind cfg(unix)
so the Windows build has no unused-import warnings.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Add a windows-latest job that builds the crate and compiles all test targets.
The Unix PTY wrapper is gated off on Windows (Phase 1); running the full test
suite on Windows is a follow-up once Windows runtime paths are validated on a
real host, so this job asserts the build/compile only.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- Run rustfmt over the cfg(windows) bodies in sys/net.rs and
  sys/process.rs that fmt --check flagged (formatted regardless of
  target).
- Gate the ExitStatusExt-based terminal tests with #[cfg(unix)] so the
  bin unit tests compile on Windows.
- Scope the windows-build test-compile to --bins; the tests/ integration
  suite is Unix-only (process groups, real-tool lifecycle) and is not
  compiled on Windows in Phase 1.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Phase 2 / M1 step 1. Add create_powershell_script as the Windows-native
counterpart to create_bash_script: it emits a .ps1 that sets the window
title, scrubs inherited tool/identity vars, prepends discovered tool
dirs to PATH (;-separated), assigns the per-launch env ($env:K = 'V'),
changes directory, and runs the tool via the call operator. Window-open
vs run-once cleanup mirrors the bash version (window persists via the
forthcoming powershell -NoExit invocation).

launch_terminal now selects the PowerShell generator and a .ps1
extension on Windows (cfg!(windows) runtime branch so both arms compile
everywhere). The launch *invocation* is still bash-only and is wired up
in the next step; this commit only changes the generated script content.

Pure string generation, covered by unit tests that run on macOS/Linux.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Phase 2 / M1 step 2 — the launch invocation side.

- which_bin: make cross-platform. Split PATH with std::env::split_paths
  (so ';' separates and Windows 'C:' drive letters aren't split) and, on
  Windows, probe PATHEXT extensions (.exe/.cmd/...) for an extension-less
  name. Unix behavior is unchanged.
- get_windows_terminal_argv: default Windows launch prefers Windows
  Terminal ("wt -- powershell -ExecutionPolicy Bypass -NoExit -File
  {script}"), falling back to a fresh console via "cmd /c start". Wires
  the previously-bailing "Windows" arm of the platform-default match.
- windows_shellify_preset: rewrite a preset's "bash {script}" to the
  PowerShell invocation on Windows (wezterm/wt), leaving mintty (Git
  Bash) alone. Applied to the resolved custom command template.

Both new helpers are referenced through cfg!(windows)/runtime match arms
so they compile on every target. Unit tests cover the preset rewrite.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Phase 2 / M1 step 3 — make the remaining launch modes consistent with
the .ps1 script the generator now emits on Windows.

The new-window path was already wired (step 2), but background and
run-here still invoked the script through bash. On Windows they now run
it through "powershell -ExecutionPolicy Bypass -File <script>"; the Unix
branches are byte-identical to before (bash for background, resolved
bash path for run-here). This closes the only paths that would have
created a .ps1 and then tried to execute it with bash.

Verified: macOS build + full test suite (1838 passed) + clippy clean;
Windows (x86_64-pc-windows-gnu) full build links.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Runtime testing surfaced that interactive launch routes through the
runner script's `hcom pty <tool>` call — the PTY wrapper, which is gated
off on Windows (Phase 1). So the agent never started and the bash runner
also mangled the Windows path.

Since ConPTY is a later phase, give Windows a non-PTY direct launch (the
documented Phase 1 behavior: the agent runs in a terminal window and
participates in hcom via hooks; screen-tracking / injection arrive with
ConPTY):

- create_runner_script_windows: emit a .ps1 that runs the tool directly
  ("& '<toolpath>' <args>") instead of `hcom pty <tool>`, mirroring the
  bash runner's env scrub, HCOM env, secret sidecar (now a dot-sourced
  .ps1), and PATH setup. Sets HCOM_LAUNCHED=1 so hooks engage.
  create_runner_script early-returns to it on Windows; the bash path is
  unchanged.
- runner_invocation_command: run the runner via
  "powershell -ExecutionPolicy Bypass -File <script>" on Windows instead
  of "bash <script>". Used by both foreground and background launches.
- terminal: expose ps_quote; add a "powershell" build_env_string format
  ($env:K = 'V').

This removes the bash layer entirely from the Windows launch path, which
also fixes the path-mangling. Verified: macOS build + full suite (1839
passed) + clippy clean; Windows (x86_64-pc-windows-gnu) full build links.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Runtime testing: claude launched and registered, but it neither learned
the hcom commands (no bootstrap) nor received messages. Cause: the Claude
hook command is POSIX-shell only, and Claude runs Windows hooks through
cmd.exe — so every hook failed silently (no SessionStart bootstrap, no
delivery).

- build_hook_entry_command: emit a cmd.exe form on Windows —
  "where %HCOM% >nul 2>nul && %HCOM% <suffix> || exit /b 0" — using the
  %HCOM% var from the settings env block and exiting 0 when hcom is
  absent. Unix keeps the POSIX form. (%HCOM% is already recognized by the
  hook-removal matcher, so install/uninstall stays idempotent.)
- create_runner_script_windows: also prepend the launching binary's own
  directory to the child PATH, so the tool's hooks can call back to this
  same hcom by name even when it isn't on the global PATH (dev builds).

Verified: macOS build + full suite (1839 passed) + clippy clean; Windows
(x86_64-pc-windows-gnu) full build links.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Runtime testing showed Claude executes hooks through /usr/bin/bash (Git
Bash) on Windows, not cmd.exe: the cmd.exe form from the previous commit
made bash choke on 'exit /b 0' and BLOCK the prompt. Revert the hook
command to the POSIX form, which Git Bash runs correctly on all
platforms.

The real cause of the earlier silent failure was just that hcom wasn't on
the PATH the hook's 'command -v hcom' searches; that stays fixed by the
launcher prepending the launching binary's own directory to the child
PATH.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Runtime testing: claude received messages but never auto-reacted to
them. The Stop hook logged pty_mode=true on Windows, so handle_poll took
the PTY branch — set status=listening and returned immediately, deferring
delivery to a PTY wrapper that does not exist on Windows. The 120s poll
loop never ran, so woken messages were dropped.

tool_extra_env set HCOM_PTY_MODE=1 for claude unconditionally. That is
right on Unix (claude runs under `hcom pty claude`) but wrong on
Windows, where the tool is launched directly. Gate it with
!cfg!(windows) so the Windows Stop hook runs the non-PTY poll loop that
blocks on the notify socket and delivers messages.

Verified: macOS build + full suite (1839 passed) + clippy clean; Windows
(x86_64-pc-windows-gnu) full build links.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Phase 2 / M2 step 1. The pty module was entirely #[cfg(unix)]; to add a
Windows ConPTY proxy the reusable pieces (ScreenTracker, InjectServer,
shared PtyTarget/ProxyConfig types) must compile on Windows while the
nix-based Unix Proxy stays gated.

- main.rs: drop the cfg(unix) gate on `mod pty`.
- pty/mod.rs: gate every nix/libc/os-fd import and the Unix Proxy
  (struct + impl + Drop), the byte/OSC parsing helpers, signal handlers,
  and nix IO helpers with #[cfg(unix)]; keep PtyTarget and ProxyConfig
  shared. The Unix proxy code is byte-identical, just gated.
- pty/inject.rs: InjectServer is now portable — the two raw-fd accessors
  are #[cfg(unix)] and a portable client_count() (cfg(any(test,windows)))
  backs the tests and the future Windows loop.
- Add portable-pty 0.9 as a Windows dependency (used by the ConPTY proxy
  in the next step); confirmed it cross-builds to windows-gnu.

run_pty on Windows still returns the 'not supported' stub; the ConPTY
proxy that uses this foundation lands next. Unix behavior unchanged.

Verified: macOS fmt/clippy clean + full suite (1839 passed); Windows
(x86_64-pc-windows-gnu) bin builds and bin unit-tests compile.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Phase 2 / M2 step 2. Implement the Windows side of the PTY wrapper so an
idle agent can be woken (the M1 limitation) and `hcom pty` works on
Windows.

pty/win.rs: a ConPTY-backed Proxy (portable-pty) mirroring the Unix
proxy's public surface (spawn/run). It drives the ConPTY with blocking
IO threads instead of nix::poll:
- reader thread: PTY output -> our stdout, feeding ScreenTracker and the
  shared ScreenState the delivery loop reads (update_screen_state is a
  trimmed Windows counterpart of the Unix update_delivery_state).
- stdin thread: our stdin -> ConPTY input.
- inject thread: InjectServer -> ConPTY input (text injection + term
  query stub).
- delivery thread: run_delivery_loop, which injects <hcom> on message
  arrival — this is what wakes an idle agent.
A RawConsoleGuard puts our console into raw + VT passthrough (restored on
drop) so the tool's TUI renders.

main.rs: run_pty is now cross-platform (drops the Windows 'not supported'
stub); pty::Proxy is ConPTY on Windows, openpty on Unix.

The launcher is intentionally unchanged: `hcom <tool>` still uses the M1
direct-launch path (verified working), so this adds `hcom pty <tool>` on
Windows for runtime validation without regressing the working path. The
launcher flip to ConPTY is a follow-up once verified on a real host.

Verified: macOS fmt/clippy clean + full suite (1839 passed); Windows
(x86_64-pc-windows-gnu) bin builds and bin unit-tests compile.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Phase 2 / M2 step 3. Now that hcom pty works on Windows (ConPTY proxy),
flip the Windows launcher to use it so `hcom claude` is driven by the
delivery loop and idle agents can be woken — the M1 limitation this whole
phase targets.

- create_runner_script_windows: the .ps1 now calls `hcom pty <tool>`
  instead of running the tool directly. The tool's dir stays on PATH so
  the wrapper can resolve it.
- tool_extra_env: restore HCOM_PTY_MODE=1 for claude on all platforms;
  with the ConPTY wrapper present, the Stop hook should again defer
  delivery to the wrapper (reverts the M1c Windows-only exception).

This supersedes the M1b/M1c direct-launch path for claude on Windows.
Messaging still flows; idle-wake now goes through ConPTY injection
instead of being unavailable.

Verified: macOS fmt/clippy clean + full suite (1839 passed); Windows
(x86_64-pc-windows-gnu) bin builds.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Root cause of the 'Starting Claude Code...' hang (diagnosed from the byte
logs): claude emits ESC[?9001h (Win32 Input Mode) + ESC[?1004h (focus
reporting) then ESC[6n (cursor-position DSR). The proxy passed ESC[?9001h
straight to the *outer* terminal, which then encoded its DSR reply as
Win32 input records instead of a plain ESC[15;1R. claude waits forever
for a reply it can parse -> hang.

Add OutputModeFilter: a stateful CSI parser on the child->stdout path
that drops complete CSI ? 9001/1004 h|l sequences (handling splits across
reads) and passes everything else through unchanged. With the outer
terminal kept in normal VT mode, the DSR reply round-trips correctly and
the child proceeds. Mirrors the Unix side's focus/OSC filtering.

Replaces the temporary first-read diagnostic logging in the reader
thread. Filter unit tests live in the Windows-only module.

Verified: macOS clippy clean + full suite (1839 passed, win.rs not built
there); Windows (x86_64-pc-windows-gnu) builds and test-compiles.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Windows has no POSIX process groups, so the Phase-1 kill_group/terminate_group
only terminated the single recorded PID. For background agents that PID is the
`powershell` host and the real agent (claude) runs as its child, so killing the
recorded PID orphaned the agent and its subprocesses.

Two complementary mechanisms now implement "kill the agent and its children":

- kill_group/terminate_group/kill_child_group walk a process snapshot
  (CreateToolhelp32Snapshot) to collect the target PID's full descendant set,
  then TerminateProcess each (deepest first). The set is frozen before any kill
  so a dead parent can't strand a child behind a stale PID. This is the
  race-free path used by `hcom kill` and headless stop from an unrelated process.

- The ConPTY proxy assigns its child to a KILL_ON_JOB_CLOSE job object, so the
  child's whole tree is reaped even if the proxy dies abnormally and Drop never
  runs. Drop additionally does the snapshot walk for clean exits.

Adds Win32_Security + Win32_System_Diagnostics_ToolHelp windows-sys features.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Promote the windows-build job from `cargo test --no-run --bins` to
`cargo test --bins`, so the bin unit tests actually execute on windows-latest
now that the native launch / ConPTY / kill-group paths are validated. The
integration tests under tests/ stay excluded (Unix-only: process groups,
real-tool lifecycle, std::os::unix), so the suite is scoped to --bins.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The promoted windows-build job (running --bins) surfaced 15 failures. One is a
real Windows product bug, the rest are Unix-only test assumptions.

Real fix: ensure_schema's archive_db_at deleted the DB file while self.conn
still held it open. Unix unlinks an open file fine; Windows returns os error 32
("being used by another process"). Release the handle (swap self.conn to an
in-memory connection) before archiving, then reopen the fresh DB.

Gated #[cfg(unix)] (test-only POSIX assumptions, product behavior is correct on
a real Windows host):
- config/{default_uses_home,dir_overrides_home,dir_absolute_stays_absolute}:
  $HOME + POSIX absolute paths
- runtime_env tests: $HOME resolution + \\?\ canonicalization
- commands::status::test_is_in_path: is_in_path splits PATH on ':' (Unix-only)
- shell_env resolver test: drives a POSIX shell
- terminal warp tests: Warp is macOS-only, pins POSIX paths
- launcher runner-script test: asserts the bash runner; Windows emits PowerShell
- cursor_preprocessing slug test: slugifies POSIX absolute paths
- antigravity/cursor hook tests: redirect home via $HOME, which dirs::home_dir()
  ignores on Windows (reads USERPROFILE)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
replacen("{tool_cmd} ", ...) never matches when the command has no
arguments because there is no trailing space. Add a fallback that
replaces the bare tool name so the generated script always invokes
the full path regardless of whether arguments are present.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
On Windows there are no signals, so the Unix SIGTERM handler that sets
EXIT_WAS_KILLED never fires. The delivery thread then always records
exit:closed instead of exit:killed, silencing kill-triggered hooks.

Fix: terminate_win now uses exit code 130 (the SIGTERM-kill convention)
as a cross-process sentinel. pty/win.rs::run() detects exit code 130
after child.wait() and sets EXIT_WAS_KILLED before joining the delivery
thread, so the delivery loop records exit:killed correctly.

Also set EXIT_WAS_KILLED in Proxy::Drop for the error path where run()
exits early via ? and leaves the delivery thread still running.

Race fix: reader thread no longer sets running=false on EOF. run() is
the sole writer of running, ensuring EXIT_WAS_KILLED is committed before
the delivery thread sees running=false and enters cleanup.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…tree_win

Under high load CreateToolhelp32Snapshot can fail transiently. When it
does, kill_tree_win falls back to killing only the root process, leaving
descendants alive until the job object closes. A single immediate retry
reduces the chance of hitting the partial-kill fallback path in CI.

Also improves the fallback comment to note that surviving descendants
are reaped by the job object (KillOnDropJob) when the proxy exits.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
On Windows terminate() mapped to TerminateProcess, an immediate hard
kill, while daemon_stop printed "Sent SIGTERM" and then ran a pointless
wait + SIGKILL-escalation loop against an already-dead process. This
diverged from Unix, where terminate() = SIGTERM only *requests* exit and
the caller escalates to kill() if the process does not comply.

Bring Windows to parity: terminate() now sends CTRL_BREAK_EVENT to the
target's process group (a process group exists because detach_session
uses CREATE_NEW_PROCESS_GROUP), which sets the shutdown flag in a process
that registered a handler via sys::signal::register_term. Delivery is
best-effort — it only reaches a process sharing the caller's console — so
the existing wait + kill() fallback in daemon_stop / stop_relay_worker_quiet
still guarantees termination, exactly as SIGTERM→SIGKILL does on Unix.

stop_relay_worker's return value is ignored by all callers, so the
best-effort delivery (which returns false when the worker is on another
console) does not regress shutdown — the callers' kill() fallback covers it.

Also fix the misleading daemon_stop output: SIGTERM/SIGKILL wording is
Unix-only jargon and was wrong on Windows.

Note: this does not change the pre-existing 30s watchdog latency that
makes graceful completion within the 5s wait window unlikely on both
platforms — that is an orthogonal, cross-platform issue out of scope here.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@keinstn

keinstn commented Jul 4, 2026

Copy link
Copy Markdown
Contributor Author

Pushed the fixes for everything I committed to in my previous reply — merged into feat/windows-support at 31fc83e. All landed as three review-scoped merge commits so each is separately diffable: 6ec8f5d (PTY/delivery), 5f89e09 (terminal/config), 31fc83e (cleanups).

Landed

P0

  • 1 — ConPTY ESC[6n no longer hangs headless/background launches: the reader answers the DSR itself when stdout isn't a real console.
  • 22 — the cmd/start fallback no longer pre-quotes {script}; spaced paths work through Command's own quoting.
  • 23 — a reader-thread startup failure now fails the launch loudly instead of running a session with no screen tracking or delivery.

Delivery/PTY correctness

  • 4hcom term no longer returns a silent ""; the reader maintains a snapshot the inject thread serves, refreshed within ~120ms of an idle agent's last frame.
  • 5 — the Codex transcript watcher now starts only after delivery-init succeeds, so a retried transient failure can't spawn a duplicate.
  • 6 — the delivery-start timeout no longer drops the join handle; it's kept and joined at shutdown. Windows only — Unix (macOS/Linux) keeps today's behavior (a delivery-init timeout aborts the session) to keep this PR's diff scoped to Windows; happy to open that as a separate change if you'd like the same fix there.
  • 8 — delivery-start is now driven by an independent coordinator thread on a timer, not gated on the reader's next read().
  • 15OutputModeFilter parses each DEC private-mode parameter individually now, instead of dropping/leaking the whole sequence based on prefix order.
  • 25 — background launches resolve bash the same way run-here does (PATH then /bin/bash fallback), instead of a bare Command::new("bash").

Lower priority

  • 11 — the ambient-env sidecar strip folds case on Windows (matches the paired PowerShell Remove-Item Env:), keeping Unix exact-case.
  • 17 — a preset unsupported on the host platform is now rejected at both config validate and launch, closing the HCOM_TERMINAL bypass. User-defined TOML presets (including ones that override a built-in's name) are exempt from this gate, at both sites.
  • 19hcom status reports "Windows Terminal"/"cmd.exe" instead of "unknown" on native Windows, mirroring the launch planner.

Cleanup (your suggested fix, adopted as-is)

  • 24shell_split takes is_windows: bool now, matching the config.rs open_argv(is_windows) pattern; both branches are exercised on every host.
  • 26 — the duplicated TOML-escape expression is one helper (runtime_env::toml_escape_path) now.

Partial adoption (as described previously)

  • 20 — logs SetConsoleCtrlHandler failure; no retry (explained why in the prior reply).
  • 21bash -c/-lc {script} (and more generally any non-adjacent {script} after a bash-family interpreter — bash, bash.exe, /bin/bash, etc.) now fails fast with a clear error instead of silently launching a broken command. Only the adjacent bash {script} form is rewritten to PowerShell.
  • 30 — fixed the misleading caller-side comment in hooks/common.rs; the poll loop logic is unchanged (it correctly waits out Windows' async TerminateProcess).

Decisions section — "Git Bash required, fail loudly"

  • hcom run's bash-dependent paths (workflow scripts, the non-.py branch) now give a clear "Git Bash required — install it and ensure bash is on PATH" error on Windows instead of an opaque spawn failure.

One thing I added beyond the original list

hcom status preset-availability display (commands/status.rs) wasn't in your review and isn't touched by the diff otherwise, but fixing 17/19 made it start lying: it showed for a preset the new platform gate would actually reject, and (pre-existing, unrelated to platform) for a valid user-defined preset. Made it consistent with the same gate. Flagging since it's outside your original 27 items — let me know if you'd rather that were a separate change.

Bugs my own fixes introduced, caught in review before landing

Ran a review pass on the three branches before merging and found four real regressions in my own work, all fixed before this merge:

  • The hcom term snapshot (4) was leading-edge-throttled only — an idle agent's final frame could be missing indefinitely. Added a trailing-edge debounce (reader split into a producer/consumer pair with a bounded recv_timeout).
  • That producer/consumer split initially used an unbounded channel, silently dropping the backpressure the old single-thread reader gave the child under heavy output. Bounded it (sync_channel).
  • The bash--c detector (21) had both false negatives (bash -x {script}, bash.exe -c {script}, /bin/bash -c {script} all bypassed it) and a coincidentally-correct-but-arbitrary contains('c') heuristic. Replaced with the actual rule: any non-adjacent {script} after a bash-family interpreter is unrunnable, full stop.
  • The platform-preset gate (17) rejected a user's own override of a built-in preset name, contradicting get_merged_preset's existing contract that an override replaces the built-in on both platforms. Fixed as above.

Still held on your answers

Nothing has changed here since my last reply — still waiting on you for:

  • 2 (TS plugins hardcoding hcom) — my read is still that this is a launcher-side PATH gap on Unix, not a plugin fix. Haven't touched it.
  • 34 (Codex writable_roots override) — still proposing fail-fast/warn over silently mutating the user's override. Haven't touched it.
  • 12 (hcom run hard-codes python3) — held per your instruction, no action taken.
  • 13 (config --edit default vim) — held, out of this branch's diff, no action taken.
  • 14 (transcript search needs rg/grep) — held, out of this branch's diff, no action taken.

Ready for another pass whenever you have time.

aannoo added 23 commits July 5, 2026 23:20
…d relay worker spawn

- Retry a transient delivery-init error on the next tick instead of aborting
  the session immediately; bound the wait for a timed-out initializer's
  eventual result so a permanently stuck init can't hang the coordinator.
- Preserve FIFO order when draining the inject-server client queue (so
  `hcom term inject --enter`'s text+Enter frames can't be reordered), but
  stop enforcing that order once the head client stalls past a timeout so
  later, independent clients (e.g. `hcom term` screen queries) aren't
  starved indefinitely.
- Spawn the relay worker via `spawn_detached` instead of a plain `spawn`, so
  it doesn't inherit the parent's stdio handles on Windows (which could
  otherwise wedge a caller piping hcom's output).
- Accept Copilot's SessionStart hook binding as authoritative launch-ready
  evidence, independent of screen-scraped footer text that can vary across
  Copilot versions.
- Adjust the ConPTY inject-server's text→Enter delay for Windows timing and
  keep the Unix proxy's `DeliveryStart::Pending` handling in sync with its
  new timeout-carrying signature.
…m.exe in dev_root_binary

- Add child_process_path() to strip the \?\ / \?\UNC\ verbatim-path prefix
  Path::canonicalize() adds on Windows, which cmd.exe and .cmd shims don't
  accept as a working directory. Apply it to the canonicalized --dir passed
  to launch/resume so the child process (and its trust pre-seeding) gets a
  normal path.
- dev_root_binary() now looks for hcom{EXE_SUFFIX} instead of a bare "hcom",
  so worktree binary resolution finds hcom.exe on Windows.
…hed spawns

- which_bin(): fall through to well-known-location fallbacks even when PATH
  is entirely unset (not just missing the target name); add an %LOCALAPPDATA%
  fallback for agy.
- Add executable_command() and route codex's hook subprocess spawns (version
  probe, app-server) through it so npm's `codex.cmd` shim gets wrapped in
  `cmd.exe /d /c` instead of failing to exec directly.
- Add resolve_windows_tool_launcher() to bypass the codex.cmd shim entirely
  for the interactive ConPTY launch, invoking its Node entrypoint directly —
  the shim's cmd.exe `%*` expansion corrupts quote-bearing config values like
  the multiline developer_instructions bootstrap. Encode that value as a real
  TOML string (was a raw multiline literal) so quotes/backslashes/newlines
  survive the round trip on every platform.
- Force a UTF-8 BOM on generated PowerShell scripts so stock Windows
  PowerShell 5.1 doesn't mis-decode non-ASCII content via the active ANSI
  code page.
- Detach wezterm's own launch with DETACHED_PROCESS | CREATE_NEW_PROCESS_GROUP
  and route background-terminal spawns through spawn_detached() instead of a
  plain spawn, avoiding stdio-handle inheritance into the long-lived runner.
- Reintroduce a cross-platform is_in_path() (PATH scan tolerant of dangling
  symlinks) for `hcom status`'s tool-installed check, since which_bin()'s
  is_file() requirement doesn't count a symlink mid-upgrade as installed.
… preset validation

- zellij's preset now has a Windows open command (PowerShell -File) alongside
  the existing bash one, gated to the Darwin/Linux/Windows platform set.
- `hcom config terminal` lists presets with an "(unavailable on this
  platform)" marker and rejects setting a built-in preset that isn't
  available on the current platform.
- A malformed user-defined preset override (invalid `open`/`close` TOML) no
  longer silently exempts a same-named built-in from that platform gate —
  config validation now surfaces the TOML error instead of treating the
  malformed override as a valid user preset.
hook_sh_cmd() previously always emitted a POSIX `sh -c '...'` invocation.
Add a cmd.exe branch (`where {bin} >nul 2>nul && (...) || (...)`) so the
Antigravity hook fires on Windows without depending on a POSIX shell being
on PATH.
…irs::home_dir

kimi_config_dir() now goes through crate::runtime_env::tool_config_root(),
matching the other tool adapters' project-local-unless-overridden resolution
(and respecting KIMI_CODE_HOME) instead of always falling back to
dirs::home_dir()/.kimi-code. Adds KIMI_CODE_HOME to the hooks test harness's
saved/restored env vars.
…ution

- resolve_git_bash() replaces a bare which_bin("bash") check with an actual
  `bash --version` probe, so the Windows-only WSL launcher stub (which
  reports "found" but isn't a working Bash) is caught with a clear error
  instead of failing confusingly when a .sh script runs.
- python_command() tries `python`, then the Python launcher (`py -3`), each
  verified with a --version probe, before falling back to an error; PYTHON
  env var still takes precedence on every platform.
- get_update_cmd() now detects Windows-style install paths (uv tool /
  site-packages / venv under a Windows path) case-insensitively, and falls
  back to a PowerShell one-liner (irm ... | iex) instead of the POSIX curl
  installer when no known install method matches.
- cmd_update() runs the PowerShell installer command directly on Windows
  instead of refusing to auto-update, while still refusing an unexpected
  POSIX shell-pipe command there.
…oring them

archive_and_clear_db() used to fs::remove_file the db/-wal/-shm files with
errors discarded (`let _ = ...`), so a locked file (e.g. another hcom
process still holding it open, common under Windows' mandatory file
locking) looked like a successful reset. remove_database_files() now
removes sidecars first and the primary db last, and returns an error naming
the file and telling the user to stop other hcom processes and retry,
instead of claiming the reset succeeded.
…results

Transcript search treated any non-success rg/grep invocation (missing
binary, crash, bad args) the same as "no matches", silently returning an
empty result. run_search_tool() now distinguishes a real no-match exit
(status 1) from a missing binary or other failure, and search reports an
error in the latter cases instead of pretending nothing matched.
`hcom listen --sql stopped:<name>` expands to the underlying life-event SQL
condition (type='life' AND instance=<name> AND action='stopped'), sparing
callers from hand-writing the json_extract condition to wait for a specific
agent to stop.
… regex

When an alternate hcom command is configured, get_bootstrap() rewrites bare
"hcom" word-boundary matches to that command via regex. The bootstrap text's
own [hcom:...] identity marker and <hcom>/</hcom> tags contain "hcom" too, so
without protecting them first the rewrite corrupted those literal markers.
Sentinel-protect them (alongside the existing command sentinel) before the
regex pass and restore them after.
…isolation

- apply_isolated_env() preserves the Windows OS-component env vars
  (SystemRoot/WINDIR/COMSPEC/PATHEXT) needed for powershell.exe and npm .cmd
  shims to run, and points USERPROFILE/APPDATA/LOCALAPPDATA/TEMP/TMP at the
  test's isolated home; sets HCOM_DEV_ROOT so test-spawned hcom processes
  resolve back to this worktree's binary.
- external_cmd() resolves .cmd/.bat shims (npm-installed tool binaries) via
  cmd.exe on Windows instead of trying to exec them directly.
- Add shell_hcom_command()/bash_hcom_command() so tests can build exact
  binary invocations correctly quoted for PowerShell vs. Git Bash.
- Add start_listening_with_process_id(): a genuinely live listener (real
  heartbeat, not a bare `hcom start` identity that goes stale after 30s) so
  delivery to an active recipient can be asserted against its captured
  output instead of a one-shot `listen --timeout` poll; track spawned child
  processes for cleanup alongside tracked PIDs.
- Preserve a failed real-tool test's working directory instead of cleaning
  it up, and trace test-spawned hcom invocations when HCOM_TEST_TRACE_COMMANDS
  is set.
- test_relay_roundtrip.rs: capture child output through temp files with a
  bounded wait instead of `Command::output()` (a detached relay worker can
  inherit the parent's anonymous pipe handles on Windows, so `output()` may
  never observe EOF); migrate the sqlite3-CLI tag assertion to rusqlite;
  replace the Unix-only pgrep-based orphan-daemon cleanup with a Windows
  no-op (relies on RelayGuard's PID-file-owned cleanup instead); use the
  platform temp directory instead of a hardcoded /tmp for remote launch.
- codex_mock.rs: advertise a platform-appropriate shell function name to the
  pinned Codex build and write files via `node -e` instead of shell `echo`
  so the mocked write command works under both bash and PowerShell.
…ow scripts

- Add a windows-latest wheel target (x86_64-pc-windows-msvc) to build-wheels.yml
  and dist-workspace.toml, a powershell installer to dist-workspace.toml, and
  a Windows classifier to pyproject.toml.
- ci.yml: add a native windows-latest fast gate (fmt/clippy/test/release
  build + packaged-binary smoke test) alongside the existing Ubuntu job, and
  extend the real-tool-tests matrix to run on windows-latest too (with a
  pwsh variant of the pinned-tool install/PATH/test steps).
- Justfile: add ci-windows, mock-tools-windows, real-tool-tests-windows, and
  package-smoke-windows recipes mirroring the Unix `ci`/`mock-tools` gate for
  native PowerShell use. package-smoke-windows moves (not copies) the built
  release binary into target/package-smoke, since real-tool-tests-windows
  runs next and every test-spawned hcom process sets HCOM_DEV_ROOT — leaving
  a freshly-built target/release/hcom.exe behind would make dev_root_binary()
  prefer it (newer mtime) over the debug binary cargo test just built,
  silently testing the wrong binary.
- Add scripts/install-mock-tools.ps1, the PowerShell counterpart of
  install-mock-tools.sh, for installing pinned mock CLI tools in Windows CI
  and local `just ci-windows` runs.
- Document the PowerShell installer and `just ci-windows` in README.md.
hcom term inject --enter blindly slept 300ms between writing text and
writing Enter, hoping the TUI had rendered by then. Under contention
that margin isn't reliable, causing real_tool_codex to flake ~50% of
the time on Windows CI (text typed but never submitted). Poll the
screen and send Enter once input_text actually matches, same
exclusive-ownership check the delivery state machine already uses.

Also fix an unrelated pre-existing flake in test_relay_roundtrip: it
asserted a killed instance's inject port fails immediately, but the
PTY manager process can legitimately outlive its child by up to ~2s
(bounded ConPTY pipe join in win.rs) before tearing down. Poll instead
of asserting on the first check, matching every other post-kill check
in the same test.
…iden codex CI timeouts

assert_tool_pinned used Command::new(tool) directly, which cannot execute
npm's Windows .cmd shims (CreateProcess needs an explicit .cmd/.bat
extension); the CI mock claude install is shim-only, so Phase 7 failed with
"program not found" while the actual hcom-launched claude worked fine.

Also widen the process-bound/PTY-ready wait timeouts from 40s to 90s: the
Windows codex real-tool tests landed just past the 40s deadline on CI's
shared runners (codex's launcher bypasses the npm shim to invoke node
directly, adding cold-start latency Unix/claude don't hit).
… mode

get_claude_input_text only matched the styled `❯` prompt glyph. On the CI-
pinned claude-code@2.1.185 build, `--permission-mode bypassPermissions`
renders a plain `>` in the same bordered input box instead, so prompt_empty
never went true and drive_claude_startup (relay test Phase 7) timed out
waiting for it forever — confirmed reproducible, not a timing flake.

Accept either glyph within the existing border + dim-detection checks (no
loosening of the anti-false-positive heuristics). Mirror the same tolerance
in the relay test's own prompt-marker checks, keeping them line-start aware
(not a bare substring match) so an unrelated `>` in tips/output/diffs can't
produce a false positive.

Verified against the actual CI-pinned mock claude binary locally: Phase 7-9
of test_relay_roundtrip now pass (previously stuck at Phase 7 indefinitely).
Same root cause as the earlier codex launch/PTY-ready timeouts: the fork
relaunch's "forked process + hook bindings" wait was still hardcoded at 40s
and missed in that pass, failing on Windows CI at exactly the deadline.

Also replace the bare poll_until timeout panic for the relay test's Phase 10
PONG round-trip with one that captures Device B's live screen and recent
events, so a future CI failure there is diagnosable without another
round-trip.
Phase 10 only called ensure_relay_worker(&path_a) after waiting for Device
B's claude to finish processing — but B's `hcom send ... PONG` reply is
published as part of that same turn. If A's worker was still down (or
auto-exits mid-wait, watchdog ~60s idle) when B publishes, a non-retained
MQTT publish with no subscriber present is simply lost, and the later poll
waits forever for an event that already came and went. Re-arm before the
wait starts and keep re-arming every poll iteration through it.

Also watermark Device B's own status-event scan by id (previously
unwatermarked), so a stale delivery->listening cycle already sitting in the
last-20 window can't satisfy the check by coincidence, and add relay status
+ mock request diagnostics to the Phase 10 timeout panic.
…s Bash call

Root cause (confirmed directly from a completed CI job log): the mock's
scripted Bash tool call ran bare `hcom send @bigboss --intent inform --
PONG`. Claude's Bash tool runs under Git Bash on Windows, and its PATH does
not reliably carry the test binary's directory through relay-worker ->
ConPTY-child -> Bash-tool process inheritance, so the call failed with
`hcom: command not found` (exit 127) — the turn still completed (fast,
since the failure was immediate), but no PONG event was ever sent. This
explains why Step 1 (delivery -> listening) passed quickly while Step 2
(PONG receipt) hung forever. Reference the exact CARGO_BIN_EXE_hcom binary
via a Git-Bash-safe quoted path instead, mirroring the existing
support::Hcom::bash_hcom_command pattern.

Also revert the previous commit's relay-worker re-arm changes and their
watchdog-race narrative: auto_exit_watchdog only fires when relay is *not*
enabled in config, and both test devices enable it in Phases 1/3, so that
was never the actual mechanism. Keeps the still-valid parts: the Device B
status-event id watermark (prevents a stale delivery->listening cycle from
false-matching) and a shared phase10_diagnostics() helper used by both
Step 1 and Step 2 timeouts, so a future CI failure here carries relay
status, live screen, recent events, and the mock's actual recorded
requests instead of a bare "timed out".
@aannoo

aannoo commented Jul 6, 2026

Copy link
Copy Markdown
Owner

Nice, made some fixes

# Conflicts:
#	.github/workflows/ci.yml
#	Justfile
@aannoo aannoo merged commit 3f13b66 into aannoo:main Jul 6, 2026
18 checks passed
@aannoo aannoo mentioned this pull request Jul 6, 2026
@keinstn

keinstn commented Jul 6, 2026

Copy link
Copy Markdown
Contributor Author

Thank you for the thorough review throughout this PR, and for taking it the rest of the way to merge — it made the Windows support much more solid than it would have been otherwise.

One nice touch: a couple of your follow-on commits resolved items I'd deliberately left as deferred follow-ups (the delivery-init retry behavior, and probing bash --version instead of trusting which_bin). Glad to see it land in v0.7.23.

Thanks again for the collaboration — happy to help with anything that comes up down the line.

@keinstn keinstn deleted the feat/windows-support branch July 6, 2026 20:23
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants