Skip to content

XERK-697: wire hub-agent.py's terminal layer to the ConPTY pty-host (Windows sessions can launch) - #725

Merged
xerhab merged 2 commits into
mainfrom
XERK-697
Sep 8, 2026
Merged

XERK-697: wire hub-agent.py's terminal layer to the ConPTY pty-host (Windows sessions can launch)#725
xerhab merged 2 commits into
mainfrom
XERK-697

Conversation

@xerhab

@xerhab xerhab commented Sep 8, 2026

Copy link
Copy Markdown
Contributor

Problem

The native Windows agent installs, comes online, heartbeats and triages tickets — but could not launch, capture, drive, or serve any Claude session. hub-agent.py's terminal layer shelled tmux/ttyd unconditionally at every call site and never invoked the ConPTY pty-host XERK-668 built (agent/win/pty-host.mjs), so every Windows session died at launch with WinError 2: The system cannot find the file specified. This blocked the entire session/lifecycle/restart-survival surface of the Windows-agent epic (XERK-666).

Bug verified still real on current main before changing anything: grep -n 'pty-host\|ConPTY' hub-agent.py found only comments; every terminal call site ran tmux/ttyd with no IS_WINDOWS dispatch.

This wires the manager side of the seam — the "D5 manager portability pass" the pty-host shipped the protocol for but left unbuilt.

What changed (all in agent/hub-agent.py, entirely behind IS_WINDOWS)

On Windows, one pty-host process per session replaces BOTH tmux and ttyd:

  • _spawn_in_tmux + _launch_ttyd collapse into _spawn_pty_host: spawns the pty-host (node-pty) detached, serving the terminal on the session's stable ttydPort (the hub proxies /term/<id> unchanged) and running claude. _launch_ttyd/_kill_ttyd no-op; _kill_tmux tears the pty-host down. Every kill/delete/restart path runs _kill_tmux before _kill_ttyd, so nothing leaks.
  • _launch_tmux translates its POSIX shell command into argv + an env dict (node-pty spawns claude directly, no shell): the VAR=x env-assignment prefix → env entries; the failover set -a; . <local-model.env>_read_env_file merged into the env dict (gateway key stays off the command line, as the source kept it); the claude flags → portable argv, prompt positional after --. --settings (guard + AskUserQuestion bridge) preserved. _windows_claude_launcher resolves claude.exe (argv) vs claude.cmd (cmd.exe /c claude) via PATHEXT.
  • A pure-Python RFC 6455 control client (_ws_control_rpc/_pty_control, stdlib only — no ws dependency on the Python side) drives the pty-host's JSON control websocket: inject/capture/alive/resize/kill, the 1:1 tmux-CLI replacements. _capture_pane/_type_into_pane/_tmux_alive and _pane_send_keys (interrupt / set_mode / set_model / answer_pane_prompt) all dispatch to it.
  • State-file-only model: every helper resolves the session from the pty-host's atomically-written {pid,ctrlPort,termPort} state file, so resume-on-boot ADOPT is free — a surviving detached pty-host is reached with no reattach code.

The Linux path is untouched — every change short-circuits on IS_WINDOWS. Deps (node-pty + ws) and file lay-down are already install.ps1's job; the paths (<base>\win\pty-host.mjs, node) match.

Rules updated: .claude/rules/windows-agent.md (the now-wired manager seam) and .claude/rules/windows-terminal.md (D5 done).

Verification

  • Full test_hub_agent suite green: 2163 tests OK. New TestWindowsTerminalBackend / TestWindowsTerminalBackendManager (mock IS_WINDOWS, 27 tests) pin the RFC 6455 framing, the shell→argv/env translation, credential-off-command-line, and every call-site dispatch.
  • The pure-Python control client is proven cross-language against the real ws library + real agent/win/tty-protocol.mjs control server (alive/inject/capture/resize/kill, wrong-token/empty-token refusal, unknown-op, 2-byte and 8-byte length frames) — QA reproduced 12/12 and caught two client mutations (unmasked frame, wrong handshake GUID), proving masking and the Sec-WebSocket-Accept check are load-bearing.
  • QA (adversarial qaqa-delta): first pass PARTIAL found two Windows-only defects — (1) the detached pty-host was orphaned on the spawn-timeout path (leaked process + held ttydPort); (2) _read_env_file un-quoting wasn't the inverse of write_local_model_env's shlex.quote. Both fixed (capture the Popen handle and terminate() on timeout; shlex.split-based un-quote) and a dead/misleading _pty_inject fallback removed. Re-QA PASS, each fix's guard test proven load-bearing by mutation.

Not verified here (host-proof, by design)

Real Windows ConPTY (a session actually launching/capturing/driving on a real host, adopt-on-reboot, Session-0 service, job-object breakaway) — this QA host is Linux and node-pty won't build here (no make). The pty-host itself is agent/win/drive.mjs's 24/24 on real Windows ConPTY (XERK-678); agent/win/pty-control-drive.py (added here) is the host-proof manager-side drive. Flagged to the XERK-678 session (on the real Windows host) for real-host validation.

Local Administrator added 2 commits September 8, 2026 13:16
…Windows sessions can launch)

The native Windows agent installs, comes online and triages tickets but could
not launch, capture, drive or serve ANY Claude session: every terminal call site
in hub-agent.py shelled tmux/ttyd unconditionally and never invoked the pty-host
XERK-668 built, so each died with `WinError 2`. This wires the manager side of
that seam (ADR D5) — the piece XERK-668 shipped the protocol for but left unbuilt.

On Windows, IS_WINDOWS dispatches every tmux/ttyd site to the per-session ConPTY
pty-host, which replaces BOTH tmux and ttyd in one detached process:

- _spawn_in_tmux + _launch_ttyd collapse into _spawn_pty_host: it spawns the
  pty-host (node-pty) DETACHED, serving the terminal on the session's stable
  ttydPort (the hub proxies /term unchanged) and running claude. _launch_tmux
  translates its POSIX shell command into argv + an env dict (node-pty spawns
  claude directly): the `VAR=x` env prefix -> env entries; `set -a; . <file>`
  failover source -> the file read and merged into the env dict (gateway key
  stays off the command line); flags -> portable argv, prompt positional after
  `--`. _windows_claude_launcher resolves claude.exe (argv) vs claude.cmd
  (cmd.exe /c) via PATHEXT.
- _capture_pane / _type_into_pane / _tmux_alive / _pane_send_keys (interrupt,
  set_mode, set_model, answer_pane_prompt) drive the pty-host's JSON control
  websocket (inject/capture/alive/resize/kill) through a pure-Python RFC 6455
  client (stdlib only, no ws dep). _kill_tmux tears the pty-host down; _kill_ttyd
  and _launch_ttyd no-op. Everything resolves from the pty-host's own state file,
  so resume-on-boot ADOPT is free.

Linux path is untouched (every branch is behind IS_WINDOWS).

Verified: full test_hub_agent suite green (2162); new TestWindowsTerminalBackend
/ TestWindowsTerminalBackendManager (26, mock IS_WINDOWS) pin framing, the
shell->argv/env translation and every call-site dispatch. The pure-Python control
client is proven cross-language against the real `ws` + tty-protocol.mjs control
server (10/10). Real-Windows ConPTY (Session-0 service, job-object breakaway)
stays host-verified separately — the pty-host itself is drive.mjs's 24/24 on real
ConPTY, and agent/win/pty-control-drive.py is the host-proof manager-side drive.
Deps (node-pty + ws) + lay-down are install.ps1's, already shipped.
…e env parse

Adversarial QA (PARTIAL) found two Windows-only defects in the seam:

- _spawn_pty_host now captures the detached Popen handle and terminate()s it on
  the spawn-timeout branch before _pty_teardown + raise. A pty-host that started
  but never published bound ports has no tmux backstop, so without this it was
  ORPHANED — leaking the process and holding ttydPort so the next launch could
  not rebind.
- _read_env_file un-quotes with shlex.split (the exact inverse of
  write_local_model_env's shlex.quote, matching Linux `. <file>` sourcing) instead
  of a naive strip('"').strip("'"), so a failover gateway value containing a
  space or a quote round-trips into the pty-host env dict.

Also dropped _pty_inject's unreachable "bracketed-paste fallback" (the control
inject always succeeds when the channel is up; the branch only ran on a channel
failure, where the fallback failed too) and corrected its comment.

Re-QA (qa-delta) PASS: full suite green (2163), each fix's guard test proven
load-bearing by mutation.
@xerhab
xerhab merged commit 731bca2 into main Sep 8, 2026
4 checks passed
@xerhab
xerhab deleted the XERK-697 branch September 8, 2026 17:40
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant