From 95c76d449822da94c6abd0ec994fa31ce781fcc1 Mon Sep 17 00:00:00 2001 From: Dan Shapiro <3732858+danshapiro@users.noreply.github.com> Date: Sun, 4 Oct 2026 18:12:38 -0700 Subject: [PATCH 1/3] docs: plan Claude CLI status repair --- .../2026-10-04-claude-cli-pane-status.md | 69 +++++++++++++++++++ 1 file changed, 69 insertions(+) create mode 100644 docs/plans/2026-10-04-claude-cli-pane-status.md diff --git a/docs/plans/2026-10-04-claude-cli-pane-status.md b/docs/plans/2026-10-04-claude-cli-pane-status.md new file mode 100644 index 000000000..7073365d3 --- /dev/null +++ b/docs/plans/2026-10-04-claude-cli-pane-status.md @@ -0,0 +1,69 @@ +# Claude CLI pane status repair + +## Goal + +Make Claude CLI panes return to idle when a turn ends, including native Windows launches, and repair the origin/main watcher test race that failed the pre-worktree baseline before beginning the feature changes. + +## User Request + +### Requested result +Repair the red origin/main Rust watcher-race test in the dedicated worktree before implementing Kata 98xc, then make Claude CLI panes return to idle after Claude finishes a turn. + +### Explicit constraints +- Follow the usual workflow. +- Use Kata 98xc as the work and tracking record. +- Complete and verify the baseline test repair before starting the Claude pane status implementation. + +### Accepted tradeoffs and residuals +- None. + +## Findings + +The pre-worktree `origin/main` gate failed in `claude::tests::a_watcher_race_on_the_stamp_take_never_panics_and_completes_coherently`. The test treated a recorded identity row as proof that ownership was Live and the retained stop stamp existed. Since commit `129613906` moved the durable binding write before the Live commit, a kill can begin in that gap and refuse before reaching the test pause. The production ownership order is intentional; the test must synchronize on the retained stamp instead. + +Kata 98xc identifies the completion path gap: Claude's Stop hook attempts to write BEL directly to a console device, where Claude hooks do not reliably own a controlling terminal. The hook should return Claude's documented `terminalSequence` response so Claude writes BEL through its own PTY. Windows hook command strings also embed PowerShell `$` expressions in shell-form commands, which may be expanded by Git Bash before PowerShell receives them. Separately, the Windows transcript truth source omits the `USERPROFILE\\.claude` default when `HOME` is unset. + +The activity parser and WebSocket busy-to-idle transition already have behavioral coverage. The implementation must add execution-level coverage for generated hook commands and Windows profile discovery; exact serialized-settings assertions alone are not sufficient. Windows must use the platform home from `std::env::home_dir()` even when `HOME` is set, matching the repository's Windows home rule. Explicit Claude roots still take precedence. + +## Ordered implementation + +### 1. Repair and verify the baseline watcher test + +- Change only the race test's readiness barrier in `crates/freshell-freshagent/src/claude.rs` to wait for the retained ownership stamp for `FRESH_CREATE_DURABLE_ID`; retain the existing pause, raced take, and completion assertions. +- Keep production ownership ordering unchanged. +- Run the exact test and the focused `freshell-freshagent` test suite. Confirm the barrier times out with a useful message and does not weaken the race assertions. +- Commit this standalone test-harness repair before editing Claude hook or transcript code. +- Once the exact race test and focused `freshell-freshagent` suite pass, begin the feature work. The already captured baseline showed the other lanes passing; the full coordinated gate remains part of final verification. + +### 2. Return completion through Claude's PTY + +- Update Unix and Windows Stop-hook command generation in `crates/freshell-platform/src/cli_launch.rs` to return a valid Claude hook JSON response with exactly one BEL in `terminalSequence`; do not write BEL directly to `/dev/tty` or `CONOUT$`. +- Make Windows shell-form commands safe when Claude launches them under Git Bash or PowerShell. Encode the PowerShell body as UTF-16LE Base64 for `-EncodedCommand`, keeping the outer command free of `$` expansion and nested PowerShell quoting. Preserve the Windows SessionStart signal-file behavior. +- Remove the settings-byte-only golden test if it blocks the command change; a test that only checks serialized configuration text is not behavior coverage. Keep launch argument order/shape coverage by comparing the actual `--settings` argument with the runtime `claude_settings_json` output. +- Add execution coverage that extracts the generated commands from the actual `--settings` argument returned by the launch resolver, runs the Stop command, and parses its stdout JSON response. On native Windows, execute SessionStart and Stop with dummy hook stdin and a temporary `USERPROFILE`; assert the signal is written only there and Stop returns a response whose `terminalSequence` contains exactly one BEL. Run the commands under both Git Bash and PowerShell when Git Bash is installed, and under PowerShell otherwise. Do not invoke Claude or a model. +- Reuse the existing activity tracker and WebSocket BEL-to-idle tests after verifying the generated response contains one BEL. Do not add a separate PTY-to-hub integration test. + +### 3. Resolve Claude transcript truth under the Windows profile + +- In `crates/freshell-ws/src/claude_truth.rs`, preserve the current ordered explicit roots and append the platform default after them, deduplicating it if already present. On Windows only, derive the default from `std::env::home_dir()` (USERPROFILE; ignore HOME); leave the existing Unix HOME lookup unchanged. +- Add a native Windows subprocess test with a temporary profile, a conflicting `HOME`, and `CLAUDE_CONFIG_DIR` and `CLAUDE_HOME` unset. Put synthetic in-flight and ended JSONL records only under the temporary profile's `.claude` tree; assert the truth source classifies both correctly without touching a real Claude profile. +- Add the focused Windows-only Rust test commands to the existing `windows-2022` CI workflow. +- Retain current isolated `with_roots` transcript classification tests and current activity/WebSocket completion behavior tests. + +### 4. Review and close + +- Run focused platform, activity, WebSocket, transcript, and fresh-agent tests as they become relevant. +- Run the repository-supported full gates from this worktree after the changes, using the already configured cloud Vitest backend and the shared coordinator. Do not deploy or restart the self-hosted server. +- Have an independent reviewer inspect the complete delta, address actionable findings, then rerun the affected focused checks. +- Update Kata 98xc with the fix and evidence, close it only after verification, and leave the feature branch ready for PR review. Do not create a PR without explicit approval. + +## Test approach + +Use behavior-focused regression tests. The watcher race repair changes the barrier in the existing regression test rather than asserting source text. For hook generation, execute commands extracted from the launch resolver's actual settings argument and parse the response; for Windows, run them under the available shells with an isolated temporary profile. For transcript resolution, launch a subprocess with controlled environment variables so parallel tests cannot observe mutated process-global environment. Reuse the existing activity tracker and WebSocket tests for the busy-to-idle event contract. + +## Risks and constraints + +- A shell-form hook can be interpreted by Git Bash even on Windows; test both Git Bash and PowerShell on the Windows runner when Git Bash is available. +- Native Windows execution cannot be established by cross-compiling from WSL. Run the Windows-only smoke on the repository's Windows CI runner and record local native Windows evidence if available. +- The broad baseline gate is coordinated and cloud-backed. Use `GCLOUD_ROBOT_REQUIRE=1`; do not run competing broad gates outside the coordinator. +- Keep commits focused: baseline test synchronization first, then feature implementation, then any narrowly justified follow-up. From 0ea1e3cb238ef8742794340f7391b22af5512f20 Mon Sep 17 00:00:00 2001 From: Dan Shapiro <3732858+danshapiro@users.noreply.github.com> Date: Sun, 4 Oct 2026 18:43:24 -0700 Subject: [PATCH 2/3] test: wait for retained stamp in watcher race --- crates/freshell-freshagent/src/claude.rs | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/crates/freshell-freshagent/src/claude.rs b/crates/freshell-freshagent/src/claude.rs index 2bd6567f0..107ad64f7 100644 --- a/crates/freshell-freshagent/src/claude.rs +++ b/crates/freshell-freshagent/src/claude.rs @@ -13143,10 +13143,15 @@ rl.on('line', (line) => { let created = await_claude_created(&mut rx, "req-e3r2-f2").await; let placeholder = created["sessionId"].as_str().unwrap().to_string(); let deadline = tokio::time::Instant::now() + std::time::Duration::from_secs(15); - while !sink.was_recorded("claude", FRESH_CREATE_DURABLE_ID) { + while crate::ownership_lane::peek_retained_stamp( + &st.ownership_stamps, + FRESH_CREATE_DURABLE_ID, + ) + .is_none() + { assert!( tokio::time::Instant::now() < deadline, - "the create's adoption never recorded the binding" + "the create's adoption never retained the ownership stamp" ); tokio::time::sleep(std::time::Duration::from_millis(10)).await; } From 0908a46247d4a6404cd0d1d753cd4432ea9d8766 Mon Sep 17 00:00:00 2001 From: Dan Shapiro <3732858+danshapiro@users.noreply.github.com> Date: Sun, 4 Oct 2026 19:52:19 -0700 Subject: [PATCH 3/3] fix: return Claude CLI panes to idle --- .github/workflows/electron-build.yml | 10 + crates/freshell-platform/src/cli_launch.rs | 35 +-- .../src/cli_launch_goldens.rs | 276 ++++++++++++++++-- crates/freshell-ws/src/claude_truth.rs | 171 ++++++++++- 4 files changed, 448 insertions(+), 44 deletions(-) diff --git a/.github/workflows/electron-build.yml b/.github/workflows/electron-build.yml index eb03e0bf9..eb6d1c7b3 100644 --- a/.github/workflows/electron-build.yml +++ b/.github/workflows/electron-build.yml @@ -90,6 +90,16 @@ jobs: - name: Install dependencies run: pnpm install --frozen-lockfile + # Exercise the native Windows Claude profile lookup and the generated + # terminal hook behavior. Keep this scoped to the two owning Rust crates. + - name: Test Windows Claude profile lookup + if: matrix.os == 'windows-2022' + run: cargo test --locked -p freshell-ws --lib claude_truth::tests::windows_from_env_uses_userprofile_default_and_ignores_home + + - name: Test generated Windows Claude hooks + if: matrix.os == 'windows-2022' + run: cargo test --locked -p freshell-platform --lib cli_launch::cli_argv_goldens_file::native_windows_generated_claude_hooks_work_in_shells + - name: Run Electron unit tests run: pnpm run test:electron diff --git a/crates/freshell-platform/src/cli_launch.rs b/crates/freshell-platform/src/cli_launch.rs index 9f89ebb08..0a307962d 100644 --- a/crates/freshell-platform/src/cli_launch.rs +++ b/crates/freshell-platform/src/cli_launch.rs @@ -188,14 +188,16 @@ pub const CODEX_TUI_NOTIFICATION_ARGS: &[&str] = &[ "tui.notifications=['agent-turn-complete']", ]; -/// The claude unix bell command (`terminal-registry.ts:219`, runtime bytes — -/// the source's `printf '\\a'` template-unescapes to a literal backslash-`a`). -/// U3 (spec §5): pinned by executing the reference's own source lines. -pub const CLAUDE_BELL_COMMAND_UNIX: &str = "sh -lc \"printf '\\a' > /dev/tty 2>/dev/null || true\""; +/// The Claude Unix Stop hook returns the BEL through Claude's hook response. +/// Claude runs shell-form hooks in its shell, so keep this to a direct printf: +/// a nested login shell could print profile text ahead of the required JSON. +pub const CLAUDE_BELL_COMMAND_UNIX: &str = "printf '%s\\n' '{\"terminalSequence\":\"\\u0007\"}'"; -/// The claude windows bell command (`terminal-registry.ts:218`, runtime bytes — -/// `'\\\\.\\CONOUT$'` unescapes to `\\.\CONOUT$`, the Win32 console device path). -pub const CLAUDE_BELL_COMMAND_WINDOWS: &str = "powershell.exe -NoLogo -NoProfile -NonInteractive -Command \"$bell=[char]7; $ok=$false; try {[System.IO.File]::AppendAllText('\\\\.\\CONOUT$', [string]$bell); $ok=$true} catch {}; if (-not $ok) { try {[Console]::Out.Write($bell); $ok=$true} catch {} }; if (-not $ok) { try {[Console]::Error.Write($bell)} catch {} }\""; +/// The Claude Windows Stop hook emits the same hook response via PowerShell. +/// `-EncodedCommand` takes UTF-16LE Base64, so the shell-form command consists +/// only of safe ASCII tokens and works when Claude launches it through either +/// Git Bash or PowerShell. +pub const CLAUDE_BELL_COMMAND_WINDOWS: &str = "powershell.exe -NoLogo -NoProfile -NonInteractive -EncodedCommand WwBDAG8AbgBzAG8AbABlAF0AOgA6AE8AdQB0AC4AVwByAGkAdABlAEwAaQBuAGUAKAAnAHsAIgB0AGUAcgBtAGkAbgBhAGwAUwBlAHEAdQBlAG4AYwBlACIAOgAiAFwAdQAwADAAMAA3ACIAfQAnACkA"; /// SessionStart fires with the CURRENT session id on startup/resume/clear -- /// the deterministic signal for claude's in-TUI session switches. On claude @@ -225,20 +227,19 @@ pub const CLAUDE_BELL_COMMAND_WINDOWS: &str = "powershell.exe -NoLogo -NoProfile pub const CLAUDE_SESSION_START_COMMAND_UNIX: &str = "sh -lc 'd=\"$HOME/.freshell/session-signals/claude\"; n=$(date +%s%N 2>/dev/null); case \"$n\" in *[!0-9]*|\"\") n=\"$(date +%s)000000000\";; esac; f=\"$d/${FRESHELL_TERMINAL_ID:-unknown}__$n-$$\"; mkdir -p \"$d\" && cat > \"$f.tmp\" && mv \"$f.tmp\" \"$f.json\"' 2>/dev/null || true"; /// Windows twin of [`CLAUDE_SESSION_START_COMMAND_UNIX`] (see its doc for the -/// semantics + A7 degradation notes): reads the hook's stdin JSON via -/// `[Console]::In.ReadToEnd()` and writes it atomically (tmp + `Move-Item`) -/// to `%USERPROFILE%\.freshell\session-signals\claude\__.json`, -/// mirroring the [`CLAUDE_BELL_COMMAND_WINDOWS`] powershell one-liner style — -/// the whole body is a `try {..} catch {}` so it never blocks or fails the CLI. -pub const CLAUDE_SESSION_START_COMMAND_WINDOWS: &str = "powershell.exe -NoLogo -NoProfile -NonInteractive -Command \"try { $tid = if ($env:FRESHELL_TERMINAL_ID) { $env:FRESHELL_TERMINAL_ID } else { 'unknown' }; $d = Join-Path $env:USERPROFILE '.freshell\\session-signals\\claude'; New-Item -ItemType Directory -Force -Path $d | Out-Null; $f = Join-Path $d ($tid + '__' + [DateTime]::UtcNow.Ticks); [System.IO.File]::WriteAllText($f + '.tmp', [Console]::In.ReadToEnd()); Move-Item -Force ($f + '.tmp') ($f + '.json') } catch {}\""; +/// semantics + A7 degradation notes): reads hook stdin and atomically writes +/// it below `%USERPROFILE%\\.freshell\\session-signals\\claude`. Its encoded +/// UTF-16LE PowerShell command is safe as shell-form text in Git Bash and +/// PowerShell. The body remains a `try/catch` so signal-file failures do not +/// fail the CLI. +pub const CLAUDE_SESSION_START_COMMAND_WINDOWS: &str = "powershell.exe -NoLogo -NoProfile -NonInteractive -EncodedCommand dAByAHkAewAkAGkAPQAkAGUAbgB2ADoARgBSAEUAUwBIAEUATABMAF8AVABFAFIATQBJAE4AQQBMAF8ASQBEADsAaQBmACgAIQAkAGkAKQB7ACQAaQA9ACcAdQBuAGsAbgBvAHcAbgAnAH0AOwAkAGQAPQBKAG8AaQBuAC0AUABhAHQAaAAgACQAZQBuAHYAOgBVAFMARQBSAFAAUgBPAEYASQBMAEUAIAAnAC4AZgByAGUAcwBoAGUAbABsAFwAcwBlAHMAcwBpAG8AbgAtAHMAaQBnAG4AYQBsAHMAXABjAGwAYQB1AGQAZQAnADsATgBlAHcALQBJAHQAZQBtACAALQBGAG8AcgBjAGUAIAAtAEkAdABlAG0AVAB5AHAAZQAgAEQAaQByAGUAYwB0AG8AcgB5ACAAJABkAHwATwB1AHQALQBOAHUAbABsADsAJABmAD0ASgBvAGkAbgAtAFAAYQB0AGgAIAAkAGQAIAAoACQAaQArACcAXwBfACcAKwBbAEQAYQB0AGUAVABpAG0AZQBdADoAOgBVAHQAYwBOAG8AdwAuAFQAaQBjAGsAcwApADsAWwBJAE8ALgBGAGkAbABlAF0AOgA6AFcAcgBpAHQAZQBBAGwAbABUAGUAeAB0ACgAJABmACsAJwAuAHQAbQBwACcALABbAEMAbwBuAHMAbwBsAGUAXQA6ADoASQBuAC4AUgBlAGEAZABUAG8ARQBuAGQAKAApACkAOwBNAG8AdgBlAC0ASQB0AGUAbQAgAC0ARgBvAHIAYwBlACAAKAAkAGYAKwAnAC4AdABtAHAAJwApACAAKAAkAGYAKwAnAC4AagBzAG8AbgAnACkAfQBjAGEAdABjAGgAewB9AA=="; /// The claude `--settings` payload: compact JSON of the hook settings object /// (`terminal-registry.ts:216-238` origin, P4 extends it) — /// `{"hooks":{"SessionStart":[...],"Stop":[...]}}` with the session-id signal -/// hook first and the bell Stop hook unchanged. Built via `serde_json` (the -/// workspace enables `preserve_order`, so key order is insertion order: -/// `SessionStart` before `Stop`). Exact bytes pinned by the §4 goldens -/// (`CLAUDE_SETTINGS_UNIX`/`_WIN`). +/// hook first and a `Stop` hook which returns the BEL in Claude hook JSON. +/// Built via `serde_json` (the workspace enables `preserve_order`, so key +/// order is insertion order: `SessionStart` before `Stop`). pub fn claude_settings_json(target: ProviderTarget) -> String { let (session_start, bell) = match target { ProviderTarget::Windows => ( diff --git a/crates/freshell-platform/src/cli_launch_goldens.rs b/crates/freshell-platform/src/cli_launch_goldens.rs index c90c34b19..5a30a35fc 100644 --- a/crates/freshell-platform/src/cli_launch_goldens.rs +++ b/crates/freshell-platform/src/cli_launch_goldens.rs @@ -5,15 +5,6 @@ use super::*; use crate::detect::HostOs; use crate::spawn::{build_windows_cli_spawn_spec, quote_powershell_literal, ShellType}; -/// `CLAUDE_SETTINGS_UNIX` (§4 conventions) — exact compact-JSON bytes: -/// `SessionStart` (session-id signal file hook, P4) then `Stop` (bell). -const CLAUDE_SETTINGS_UNIX: &str = r#"{"hooks":{"SessionStart":[{"hooks":[{"type":"command","command":"sh -lc 'd=\"$HOME/.freshell/session-signals/claude\"; n=$(date +%s%N 2>/dev/null); case \"$n\" in *[!0-9]*|\"\") n=\"$(date +%s)000000000\";; esac; f=\"$d/${FRESHELL_TERMINAL_ID:-unknown}__$n-$$\"; mkdir -p \"$d\" && cat > \"$f.tmp\" && mv \"$f.tmp\" \"$f.json\"' 2>/dev/null || true"}]}],"Stop":[{"hooks":[{"type":"command","command":"sh -lc \"printf '\\a' > /dev/tty 2>/dev/null || true\""}]}]}}"#; - -/// `CLAUDE_SETTINGS_WIN` — compact JSON: `SessionStart` (signal file hook, -/// `\` appears in JSON as `\\`) then `Stop` (the windows bell string; -/// `'\\.\CONOUT$'` appears in JSON as `'\\\\.\\CONOUT$'`). -const CLAUDE_SETTINGS_WIN: &str = r#"{"hooks":{"SessionStart":[{"hooks":[{"type":"command","command":"powershell.exe -NoLogo -NoProfile -NonInteractive -Command \"try { $tid = if ($env:FRESHELL_TERMINAL_ID) { $env:FRESHELL_TERMINAL_ID } else { 'unknown' }; $d = Join-Path $env:USERPROFILE '.freshell\\session-signals\\claude'; New-Item -ItemType Directory -Force -Path $d | Out-Null; $f = Join-Path $d ($tid + '__' + [DateTime]::UtcNow.Ticks); [System.IO.File]::WriteAllText($f + '.tmp', [Console]::In.ReadToEnd()); Move-Item -Force ($f + '.tmp') ($f + '.json') } catch {}\""}]}],"Stop":[{"hooks":[{"type":"command","command":"powershell.exe -NoLogo -NoProfile -NonInteractive -Command \"$bell=[char]7; $ok=$false; try {[System.IO.File]::AppendAllText('\\\\.\\CONOUT$', [string]$bell); $ok=$true} catch {}; if (-not $ok) { try {[Console]::Out.Write($bell); $ok=$true} catch {} }; if (-not $ok) { try {[Console]::Error.Write($bell)} catch {} }\""}]}]}}"#; - /// Dev-mode MCP server args (`MCP_UNIX`, §4 conventions). const MCP_UNIX: &[&str] = &[ "--import", @@ -141,17 +132,253 @@ fn codex_mcp_unix() -> McpInjection { } } -/// Pins the exact byte-level notification constants (U3 executed proof). +fn claude_settings_arg(launch: &CliLaunch) -> &str { + launch + .args + .windows(2) + .find(|pair| pair[0] == "--settings") + .map(|pair| pair[1].as_str()) + .expect("Claude launch should include --settings") +} + +fn claude_hook_command<'a>(settings: &'a serde_json::Value, event: &str) -> &'a str { + settings["hooks"][event][0]["hooks"][0]["command"] + .as_str() + .expect("Claude hook should have a command") +} + +/// The generated Unix Stop hook must return the BEL as Claude hook JSON so +/// the terminal output tracker can observe it. +#[cfg(unix)] #[test] -fn claude_settings_json_bytes_are_pinned() { - assert_eq!( - claude_settings_json(ProviderTarget::Unix), - CLAUDE_SETTINGS_UNIX - ); - assert_eq!( - claude_settings_json(ProviderTarget::Windows), - CLAUDE_SETTINGS_WIN +fn claude_stop_hook_returns_one_bell_in_hook_json() { + use std::fs; + use std::process::{Command, Stdio}; + + let launch = + resolve_coding_cli_command(&specs(), &claude_inputs(claude_mcp_unix()), &env_of(&[])) + .unwrap() + .unwrap(); + let settings: serde_json::Value = serde_json::from_str(claude_settings_arg(&launch)).unwrap(); + let command = claude_hook_command(&settings, "Stop"); + let profile = tempfile::tempdir().unwrap(); + fs::write( + profile.path().join(".profile"), + "printf 'unexpected login profile output\\n'\n", + ) + .unwrap(); + let child = Command::new("sh") + .args(["-c", command]) + .env("HOME", profile.path()) + .stdin(Stdio::null()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .expect("start generated Claude Stop hook"); + let output = child.wait_with_output().unwrap(); + assert!( + output.status.success(), + "Stop hook failed: {}", + String::from_utf8_lossy(&output.stderr) ); + let response: serde_json::Value = + serde_json::from_slice(&output.stdout).unwrap_or_else(|error| { + panic!( + "Stop hook stdout must be JSON ({error}); got {:?}", + String::from_utf8_lossy(&output.stdout) + ) + }); + let sequence = response["terminalSequence"] + .as_str() + .expect("Stop hook response should contain terminalSequence"); + assert_eq!(sequence.chars().filter(|ch| *ch == '\u{0007}').count(), 1); + assert_eq!(sequence, "\u{0007}"); +} + +#[cfg(windows)] +#[derive(Clone)] +enum NativeWindowsShell { + PowerShell, + GitBash(std::path::PathBuf), +} + +#[cfg(windows)] +impl NativeWindowsShell { + fn label(&self) -> &'static str { + match self { + Self::PowerShell => "PowerShell", + Self::GitBash(_) => "Git Bash", + } + } +} + +#[cfg(windows)] +fn find_git_bash() -> Option { + use std::path::PathBuf; + use std::process::Command; + + let mut candidates = Vec::new(); + if let Some(git_bash) = std::env::var_os("CLAUDE_CODE_GIT_BASH_PATH") { + candidates.push(PathBuf::from(git_bash)); + } + for variable in ["ProgramFiles", "ProgramFiles(x86)"] { + if let Some(program_files) = std::env::var_os(variable) { + candidates.push( + PathBuf::from(program_files) + .join("Git") + .join("bin") + .join("bash.exe"), + ); + } + } + if let Ok(output) = Command::new("where.exe").arg("bash.exe").output() { + candidates.extend( + String::from_utf8_lossy(&output.stdout) + .lines() + .map(PathBuf::from) + .filter(|path| { + path.to_string_lossy() + .to_ascii_lowercase() + .contains(r"\git\") + }), + ); + } + candidates.into_iter().find(|path| path.is_file()) +} + +#[cfg(windows)] +fn run_windows_hook( + shell: &NativeWindowsShell, + hook_command: &str, + stdin: &[u8], + cwd: &std::path::Path, + user_profile: &std::path::Path, +) -> std::process::Output { + use std::io::Write; + use std::process::{Command, Stdio}; + + let mut command = match shell { + NativeWindowsShell::PowerShell => { + let mut command = Command::new("powershell.exe"); + command.args(["-NoLogo", "-NoProfile", "-NonInteractive", "-Command"]); + command.arg(hook_command); + command + } + NativeWindowsShell::GitBash(path) => { + let mut command = Command::new(path); + command.args(["--noprofile", "--norc", "-c", hook_command]); + command + } + }; + let mut child = command + .current_dir(cwd) + .env("USERPROFILE", user_profile) + .env("FRESHELL_TERMINAL_ID", "hook-smoke-terminal") + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .unwrap_or_else(|error| panic!("start generated hook through {}: {error}", shell.label())); + child + .stdin + .take() + .unwrap() + .write_all(stdin) + .unwrap_or_else(|error| panic!("write hook stdin through {}: {error}", shell.label())); + child.wait_with_output().unwrap() +} + +/// Native Windows smoke for both generated shell-form hook commands. It never +/// starts Claude and confines SessionStart output to a scratch USERPROFILE. +#[cfg(windows)] +#[test] +fn native_windows_generated_claude_hooks_work_in_shells() { + use std::fs; + use std::path::PathBuf; + + let mut inputs = claude_inputs(McpInjection::default()); + inputs.target = ProviderTarget::Windows; + let launch = resolve_coding_cli_command(&specs(), &inputs, &env_of(&[])) + .unwrap() + .unwrap(); + let settings: serde_json::Value = serde_json::from_str(claude_settings_arg(&launch)).unwrap(); + let session_start = claude_hook_command(&settings, "SessionStart"); + let stop = claude_hook_command(&settings, "Stop"); + let fixture = br#"{"session_id":"hook-smoke"}"#; + let mut shells = vec![NativeWindowsShell::PowerShell]; + if let Some(git_bash) = find_git_bash() { + shells.push(NativeWindowsShell::GitBash(git_bash)); + } + + for shell in shells { + let scratch = tempfile::tempdir().unwrap(); + let profile = scratch.path().join("temporary-profile"); + fs::create_dir(&profile).unwrap(); + + let session_output = + run_windows_hook(&shell, session_start, fixture, scratch.path(), &profile); + assert!( + session_output.status.success(), + "SessionStart failed through {}: {}", + shell.label(), + String::from_utf8_lossy(&session_output.stderr) + ); + let scratch_entries = fs::read_dir(scratch.path()) + .unwrap() + .map(|entry| entry.unwrap().path()) + .collect::>(); + assert_eq!( + scratch_entries, + vec![profile.clone()], + "SessionStart wrote outside its temporary USERPROFILE through {}", + shell.label() + ); + let signal_dir = profile + .join(".freshell") + .join("session-signals") + .join("claude"); + let signal_files = fs::read_dir(&signal_dir) + .unwrap() + .map(|entry| entry.unwrap().path()) + .collect::>(); + assert_eq!( + signal_files.len(), + 1, + "expected one signal file through {}", + shell.label() + ); + assert!( + signal_files[0] + .file_name() + .unwrap() + .to_string_lossy() + .starts_with("hook-smoke-terminal__"), + "signal file should use the configured terminal id through {}", + shell.label() + ); + assert_eq!(fs::read(&signal_files[0]).unwrap(), fixture); + + let stop_output = run_windows_hook(&shell, stop, fixture, scratch.path(), &profile); + assert!( + stop_output.status.success(), + "Stop failed through {}: {}", + shell.label(), + String::from_utf8_lossy(&stop_output.stderr) + ); + let response: serde_json::Value = serde_json::from_slice(&stop_output.stdout) + .unwrap_or_else(|error| { + panic!( + "Stop stdout through {} must be JSON ({error}); got {:?}", + shell.label(), + String::from_utf8_lossy(&stop_output.stdout) + ) + }); + let sequence = response["terminalSequence"] + .as_str() + .expect("Stop response should contain terminalSequence"); + assert_eq!(sequence.chars().filter(|ch| *ch == '\u{0007}').count(), 1); + assert_eq!(sequence, "\u{0007}"); + } } /// G-C1 — claude, linux, fresh, defaults — RESOLVER-LEVEL ONLY (the live path @@ -167,7 +394,7 @@ fn g_c1_claude_linux_fresh_defaults_resolver_level() { launch.args, vec![ "--settings".to_string(), - CLAUDE_SETTINGS_UNIX.to_string(), + claude_settings_json(ProviderTarget::Unix), "--mcp-config".to_string(), "/tmp/freshell-mcp/term1.json".to_string(), ] @@ -188,7 +415,7 @@ fn g_c2_claude_resume_permission_mode_plan() { launch.args, vec![ "--settings".to_string(), - CLAUDE_SETTINGS_UNIX.to_string(), + claude_settings_json(ProviderTarget::Unix), "--mcp-config".to_string(), "/tmp/freshell-mcp/term1.json".to_string(), "--permission-mode".to_string(), @@ -212,7 +439,7 @@ fn g_c3_claude_start_intent_session_id() { launch.args, vec![ "--settings".to_string(), - CLAUDE_SETTINGS_UNIX.to_string(), + claude_settings_json(ProviderTarget::Unix), "--mcp-config".to_string(), "/tmp/freshell-mcp/term1.json".to_string(), "--session-id".to_string(), @@ -240,7 +467,7 @@ fn g_c4_claude_native_windows_target() { launch.args, vec![ "--settings".to_string(), - CLAUDE_SETTINGS_WIN.to_string(), + claude_settings_json(ProviderTarget::Windows), "--mcp-config".to_string(), "C:\\Users\\u\\AppData\\Local\\Temp\\freshell-mcp\\term1.json".to_string(), ] @@ -261,7 +488,7 @@ fn g_c4_claude_native_windows_target() { assert_eq!(spec.program, "powershell.exe"); let expected_invocation = format!( "Set-Location -LiteralPath 'C:\\ws'; & 'claude' '--settings' {} '--mcp-config' 'C:\\Users\\u\\AppData\\Local\\Temp\\freshell-mcp\\term1.json'", - quote_powershell_literal(CLAUDE_SETTINGS_WIN) + quote_powershell_literal(&claude_settings_json(ProviderTarget::Windows)) ); assert_eq!( spec.args, @@ -272,9 +499,6 @@ fn g_c4_claude_native_windows_target() { expected_invocation, ] ); - // quotePowerShellLiteral doubled the settings' single quotes around the - // JSON-escaped CONOUT$ device path. - assert!(spec.args[3].contains(r"''\\\\.\\CONOUT$''")); } fn codex_inputs<'a>(injection: McpInjection) -> CliLaunchInputs<'a> { diff --git a/crates/freshell-ws/src/claude_truth.rs b/crates/freshell-ws/src/claude_truth.rs index 4b981a71a..030ce4a40 100644 --- a/crates/freshell-ws/src/claude_truth.rs +++ b/crates/freshell-ws/src/claude_truth.rs @@ -51,6 +51,57 @@ const TAIL_PROBE_MAX_BYTES: u64 = 8 * 1024 * 1024; /// Read by GET /api/server-info as "claudeTruthAnomalies" (Task 10). pub static CLAUDE_TRUTH_ANOMALIES: AtomicU64 = AtomicU64::new(0); +#[cfg(windows)] +fn same_windows_path(left: &Path, right: &Path) -> bool { + use std::os::windows::ffi::OsStrExt; + use std::path::Component; + + const CSTR_EQUAL: i32 = 2; + const TRUE: i32 = 1; + + let components_match = |left: Component<'_>, right: Component<'_>| { + let left = left.as_os_str().encode_wide().collect::>(); + let right = right.as_os_str().encode_wide().collect::>(); + let (Ok(left_len), Ok(right_len)) = (i32::try_from(left.len()), i32::try_from(right.len())) + else { + return false; + }; + if left_len == 0 || right_len == 0 { + return false; + } + + // SAFETY: both buffers remain alive for the call, and each explicit + // length is the number of valid UTF-16 code units in that buffer. + unsafe { + compare_string_ordinal(left.as_ptr(), left_len, right.as_ptr(), right_len, TRUE) + == CSTR_EQUAL + } + }; + + let mut left = left.components(); + let mut right = right.components(); + loop { + match (left.next(), right.next()) { + (Some(left), Some(right)) if components_match(left, right) => {} + (None, None) => return true, + _ => return false, + } + } +} + +#[cfg(windows)] +#[link(name = "kernel32")] +unsafe extern "system" { + #[link_name = "CompareStringOrdinal"] + fn compare_string_ordinal( + string1: *const u16, + count1: i32, + string2: *const u16, + count2: i32, + ignore_case: i32, + ) -> i32; +} + #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum TurnProbe { /// Non-sidechain user/assistant activity after the last end-boundary @@ -115,7 +166,8 @@ fn note_format_anomaly(session_id: &str, records: &[serde_json::Value], reason: impl FsClaudeTruth { /// Candidate roots, priority order — the same ladder as - /// `claude_snapshot.rs`: CLAUDE_CONFIG_DIR > CLAUDE_HOME > ~/.claude. + /// `claude_snapshot.rs`: CLAUDE_CONFIG_DIR > CLAUDE_HOME > platform + /// default `~/.claude`. /// Empty-string values are SKIPPED (claude_snapshot.rs precedent — /// an empty env var must not produce a bogus relative root). pub fn from_env() -> Self { @@ -130,6 +182,17 @@ impl FsClaudeTruth { roots.push(PathBuf::from(dir)); } } + #[cfg(windows)] + if let Some(home) = std::env::home_dir() { + let default_root = home.join(".claude"); + if !roots + .iter() + .any(|root| same_windows_path(root, &default_root)) + { + roots.push(default_root); + } + } + #[cfg(not(windows))] if let Some(home) = std::env::var_os("HOME") { if !home.is_empty() { roots.push(PathBuf::from(home).join(".claude")); @@ -482,6 +545,112 @@ mod tests { assert!(matches!(truth.probe_turn_state("S"), TurnProbe::Ended)); } + /// Run the real env lookup in a fresh process so environment values from + /// parallel Rust tests cannot affect the Windows home-directory contract. + /// Both roots are scratch directories; this never reads a real Claude + /// profile. + #[cfg(windows)] + #[test] + fn windows_from_env_uses_userprofile_default_and_ignores_home() { + const CHILD_FLAG: &str = "FRESHELL_CLAUDE_TRUTH_WINDOWS_TEST_CHILD"; + + if let Some(mode) = std::env::var_os(CHILD_FLAG) { + let truth = FsClaudeTruth::from_env(); + match mode.to_string_lossy().as_ref() { + "classify" => { + assert_eq!( + truth.probe_turn_state("windows-profile-in-flight"), + TurnProbe::InFlight + ); + assert_eq!( + truth.probe_turn_state("windows-profile-ended"), + TurnProbe::Ended + ); + } + "dedupe" => { + let configured = PathBuf::from(std::env::var_os("CLAUDE_CONFIG_DIR").unwrap()); + assert_eq!(truth.roots, vec![configured]); + } + other => panic!("unexpected child mode: {other}"), + } + return; + } + + let scratch = tempfile::tempdir().unwrap(); + let profile_root = scratch.path().join("user-profile"); + let home_root = scratch.path().join("conflicting-home"); + let profile_claude = profile_root.join(".claude"); + let home_claude = home_root.join(".claude"); + + write_transcript( + &profile_claude, + "windows-profile-in-flight", + &[TURN_START, ASSISTANT], + ); + write_transcript( + &profile_claude, + "windows-profile-ended", + &[TURN_START, ASSISTANT, TURN_END], + ); + // If HOME is treated as a second Windows home candidate, these + // opposite classifications expose it even though the session ids + // also exist below USERPROFILE. + write_transcript( + &home_claude, + "windows-profile-in-flight", + &[TURN_START, ASSISTANT, TURN_END], + ); + write_transcript( + &home_claude, + "windows-profile-ended", + &[TURN_START, ASSISTANT], + ); + + let run_child = |mode: &str, config_dir: Option<&std::path::Path>| { + let mut command = std::process::Command::new(std::env::current_exe().unwrap()); + command + .args([ + "--exact", + "claude_truth::tests::windows_from_env_uses_userprofile_default_and_ignores_home", + "--nocapture", + ]) + .env(CHILD_FLAG, mode) + .env("USERPROFILE", &profile_root) + .env("HOME", &home_root) + .env_remove("CLAUDE_HOME"); + if let Some(config_dir) = config_dir { + command.env("CLAUDE_CONFIG_DIR", config_dir); + } else { + command.env_remove("CLAUDE_CONFIG_DIR"); + } + command + .status() + .expect("launch isolated Windows transcript test process") + }; + + // The classification case leaves both explicit roots unset and gives + // HOME conflicting transcripts for the same session ids. + let status = run_child("classify", None); + assert!(status.success(), "isolated Windows transcript test failed"); + + // A differently cased spelling of the Windows default is the same + // directory and must not cause a duplicate transcript scan. + let equivalent_spelling = profile_claude + .to_string_lossy() + .chars() + .map(|ch| match ch { + 'a'..='z' => ch.to_ascii_uppercase(), + 'A'..='Z' => ch.to_ascii_lowercase(), + _ => ch, + }) + .collect::() + .replace('\\', "/"); + let equivalent_spelling = PathBuf::from(format!("{equivalent_spelling}/")); + assert!(equivalent_spelling.exists()); + let status = run_child("dedupe", Some(&equivalent_spelling)); + assert!(status.success(), "Windows default-root dedupe test failed"); + } + #[test] fn adaptive_window_grows_past_a_giant_line() { // Corpus: individual lines reach 1,365,273 bytes — a fixed