diff --git a/src/core/cli_agent.rs b/src/core/cli_agent.rs index 1c2ea277..d411fa12 100644 --- a/src/core/cli_agent.rs +++ b/src/core/cli_agent.rs @@ -6,12 +6,15 @@ //! deliberately *not* tty7's own agent: it only observes and enriches whatever //! agent the user launched. //! -//! Detection is command-based: the daemon already +//! Detection is command-based: on macOS/Linux the daemon already //! reads the foreground process's `argv` for SSH-context sniffing, so we reuse //! that to match the invoked command against a known agent. Matching is a pure //! function over `argv` — [`CLIAgent::detect_from_argv`] — kept here in `core` //! (framework-light, unit-tested) and called daemon-side, with the resulting -//! `Option` streamed to the client for the UI. +//! `Option` streamed to the client for the UI. On Windows ConPTY +//! exposes no foreground process group, so the input is the *typed command +//! line* the shell integration captures at preexec and carries on the `133;C` +//! mark — [`CLIAgent::detect_from_command_with`] matches it the same way. //! //! The enum is serialized across the daemon↔client protocol, so its variants //! are the wire contract; add new agents at the end. @@ -25,7 +28,6 @@ //! the client. use std::collections::HashMap; -use std::path::Path; use serde::{Deserialize, Serialize}; @@ -310,7 +312,7 @@ impl CLIAgent { if arg.starts_with('-') { continue; } - for segment in arg.split('/') { + for segment in arg.split(['/', '\\']) { if let Some(agent) = CLIAgent::match_token(&base_stem(segment).to_ascii_lowercase()) { @@ -322,6 +324,37 @@ impl CLIAgent { None } + + /// [`detect_from_argv_with`](Self::detect_from_argv_with) over a *typed + /// command line* rather than a live process `argv` — the Windows detection + /// input. ConPTY has no foreground process group to resolve to an argv, so + /// there the daemon learns what runs from the shell integration instead: + /// PowerShell's `PSConsoleHostReadLine` wrapper reports the submitted line + /// on the `133;C` mark (the same capture Warp's Windows integration uses), + /// and this matches it like an argv. + /// + /// The tokenization is deliberately naive — whitespace split, surrounding + /// quotes trimmed, a leading PowerShell call operator (`&`) dropped, and + /// everything lowercased (Windows commands are case-insensitive). A quoted + /// launcher path containing spaces splits wrong and misses — notably + /// PSReadLine tab-completion's `& 'C:\Program Files\…\claude.exe'` — the + /// accepted trade-off for not writing a shell parser; the dominant shapes + /// (a bare shim on PATH, `npx …`) tokenize fine, and `agent_commands` + /// rules cover personal wrappers. + pub fn detect_from_command_with( + command: &str, + custom: &HashMap, + ) -> Option { + let mut argv: Vec = command + .split_whitespace() + .map(|t| t.trim_matches(['"', '\'']).to_ascii_lowercase()) + .filter(|t| !t.is_empty()) + .collect(); + if argv.first().is_some_and(|t| t == "&") { + argv.remove(0); + } + Self::detect_from_argv_with(&argv, custom) + } } /// A `KEY=value` shell environment assignment prefix (`FOO=bar cmd`). The `KEY` @@ -345,14 +378,24 @@ fn is_env_assignment(token: &str) -> bool { /// The final path component with a leading dir and a trailing script extension /// stripped, lowercased-ready but case preserved (callers lowercase when they /// match interpreter args). `/usr/bin/claude` → `claude`, `cli.js` → `cli`. +/// Splits on both separators by hand (not [`Path`]) so a Windows path in a +/// captured command line (`C:\…\claude.cmd`) resolves the same on every +/// platform — including in tests run on Unix. fn base_stem(token: &str) -> &str { - let name = Path::new(token) - .file_name() - .and_then(|n| n.to_str()) - .unwrap_or(token); - // Strip one known script extension; leave unknown suffixes intact so - // `claude-code` stays whole. - for ext in [".js", ".mjs", ".cjs", ".ts", ".py", ".rb", ".sh"] { + // Trailing separators are dropped first (`claude/` → `claude`, matching + // the old `Path::file_name` behavior), then everything up to the last + // separator. + let trimmed = token.trim_end_matches(['/', '\\']); + let name = match trimmed.rfind(['/', '\\']) { + Some(i) => &trimmed[i + 1..], + None => trimmed, + }; + // Strip one known script/launcher extension; leave unknown suffixes intact + // so `claude-code` stays whole. The Windows set covers npm's shim trio + // (`claude.cmd` / `claude.ps1` / `claude.exe`). + for ext in [ + ".js", ".mjs", ".cjs", ".ts", ".py", ".rb", ".sh", ".exe", ".cmd", ".bat", ".ps1", + ] { if let Some(stem) = name.strip_suffix(ext) { return stem; } @@ -617,6 +660,11 @@ mod tests { CLIAgent::detect_from_argv(&argv(&["cursor-agent"])), Some(CLIAgent::Cursor) ); + // A trailing separator is tolerated, matching Path::file_name. + assert_eq!( + CLIAgent::detect_from_argv(&argv(&["claude/"])), + Some(CLIAgent::Claude) + ); } #[test] @@ -737,6 +785,66 @@ mod tests { ); } + #[test] + fn detects_from_typed_command_lines() { + let none = HashMap::new(); + // Plain invocations, flags in tow. + assert_eq!( + CLIAgent::detect_from_command_with("claude --resume abc", &none), + Some(CLIAgent::Claude) + ); + // Windows launcher shapes: npm shims, absolute backslash paths, and + // case-insensitive names. + assert_eq!( + CLIAgent::detect_from_command_with("claude.exe", &none), + Some(CLIAgent::Claude) + ); + assert_eq!( + CLIAgent::detect_from_command_with( + r"C:\Users\x\AppData\Roaming\npm\claude.cmd --model opus", + &none + ), + Some(CLIAgent::Claude) + ); + assert_eq!( + CLIAgent::detect_from_command_with("CLAUDE", &none), + Some(CLIAgent::Claude) + ); + // PowerShell call operator + a quoted (space-free) path. + assert_eq!( + CLIAgent::detect_from_command_with(r#"& "C:\tools\codex.exe""#, &none), + Some(CLIAgent::Codex) + ); + // Interpreter-wrapped, Windows separators in the script path. + assert_eq!( + CLIAgent::detect_from_command_with( + r"node C:\x\node_modules\@anthropic-ai\claude-code\cli.js", + &none + ), + Some(CLIAgent::Claude) + ); + assert_eq!( + CLIAgent::detect_from_command_with("npx.cmd @google/gemini-cli", &none), + Some(CLIAgent::Gemini) + ); + // Non-interpreter launchers never match on their arguments. + assert_eq!( + CLIAgent::detect_from_command_with("notepad claude.txt", &none), + None + ); + assert_eq!( + CLIAgent::detect_from_command_with("cat codex.md", &none), + None + ); + assert_eq!(CLIAgent::detect_from_command_with("", &none), None); + // Custom rules apply to the typed launcher too. + let custom: HashMap = [("cc".to_string(), "claude".to_string())].into(); + assert_eq!( + CLIAgent::detect_from_command_with("cc -c", &custom), + Some(CLIAgent::Claude) + ); + } + #[test] fn parses_sentinel_events() { let ev = parse_agent_event( diff --git a/src/daemon/pane.rs b/src/daemon/pane.rs index de005b84..d0f77d2b 100644 --- a/src/daemon/pane.rs +++ b/src/daemon/pane.rs @@ -404,7 +404,13 @@ enum PaneBackend { /// keeps the reader's signature readable. struct ForegroundProbes { remote: Box Option + Send>, - agent: Box Option + Send>, + /// Outer `None` means this backend has no process-table view of the PTY + /// foreground at all (native SSH; Windows, where ConPTY has no foreground + /// process group) — "no opinion", never applied, so it can't wipe an agent + /// identified another way (sentinel events, the Windows `133;C;` + /// mark). `Some(answer)` is a real poll result; its inner `None` ("polled, + /// no agent") clears the chip. + agent: Box Option> + Send>, } /// The local-PTY backend: the same handles `DaemonPane` has always owned. @@ -715,6 +721,9 @@ impl DaemonPane { // gate closures answer "nothing local": OSC 133 marks from the remote // shell are trusted verbatim (correct — the remote shell *is* the session), // and no process-table SSH detection runs (this pane already *is* SSH). + // The agent probe's `None` is "no opinion" (never applied), so an agent + // identified from its sentinel events keeps its chip — the poll used to + // wipe it within half a second. let reader = Self::spawn_reader( state, shutting_down, @@ -895,7 +904,11 @@ impl DaemonPane { } else { None }; - let agent = poll_now.then(&foreground_agent_fn); + // Flattened: a fired poll whose probe has no + // process-table view (native SSH, Windows) folds to + // "no opinion" and is never applied — see + // [`ForegroundProbes::agent`]. + let agent = poll_now.then(&foreground_agent_fn).flatten(); let tr1 = trace.then(std::time::Instant::now); let mut st = state.lock().unwrap(); @@ -1604,6 +1617,18 @@ fn apply_signals(st: &mut PaneState, signals: SniffSignals) { } } if let Some(shell) = signals.shell { + // Windows: agent identity rides the C mark's command capture — ConPTY + // has no foreground process group for the Unix 0.5 s poll to read an + // argv from. `C;` detects, the prompt marks (`A`/`B`/`D`) cleared + // `command` so they apply `None` and clear the chip. Unix keeps the + // poll (it sees through scripts and wrappers) and never consults the + // mark. Applied before the sentinel events below so an event naming + // the agent can still re-brand within the same chunk. + #[cfg(windows)] + apply_agent( + st, + agent_from_shell_mark(&shell, crate::core::config::agent_commands_cached()), + ); st.shell = shell.clone(); if let Some(sub) = &st.subscriber { let _ = sub.send(DaemonMsg::Prompt { @@ -1616,6 +1641,23 @@ fn apply_signals(st: &mut PaneState, signals: SniffSignals) { apply_agent_signals(st, signals.agent_events, signals.notification); } +/// The coding agent named by the shell's last `133;C;` capture — the +/// Windows detection input ([`apply_signals`] applies it there on every shell +/// mark). `None` both at the prompt (`D` cleared `command`) and for an +/// unrecognized command, so applying the answer verbatim also clears +/// the chip when the command ends. Compiled on every platform so the unit +/// tests cover it from Unix dev machines; only the Windows build calls it. +#[cfg_attr(not(windows), allow(dead_code))] +fn agent_from_shell_mark( + shell: &ShellState, + custom: &std::collections::HashMap, +) -> Option { + shell + .command + .as_deref() + .and_then(|cmd| crate::core::cli_agent::CLIAgent::detect_from_command_with(cmd, custom)) +} + /// Fold the chunk's agent signals into the pane's session state and push any /// resulting change. Called with the state lock held. /// @@ -1758,22 +1800,32 @@ fn foreground_remote_context(_master: &Mutex>) -> Opti /// Identify the third-party CLI coding agent (Claude Code, Codex, …) owning the /// PTY foreground, from its `argv`. Same process-table read as /// [`foreground_remote_context`]; runs off the hot path on the 0.5 s poll. +/// Always `Some(answer)` — this platform *has* the process-table view, so even +/// "no agent" is a real answer that must apply (it clears the chip when the +/// agent exits). See [`ForegroundProbes::agent`] for the outer option's contract. #[cfg(any(target_os = "macos", target_os = "linux"))] fn foreground_agent( master: &Mutex>, -) -> Option { - let pid = master.lock().ok().and_then(|m| m.process_group_leader())?; - let argv = crate::daemon::remote::foreground_argv(pid)?; - crate::core::cli_agent::CLIAgent::detect_from_argv_with( - &argv, - crate::core::config::agent_commands_cached(), - ) +) -> Option> { + let detect = || { + let pid = master.lock().ok().and_then(|m| m.process_group_leader())?; + let argv = crate::daemon::remote::foreground_argv(pid)?; + crate::core::cli_agent::CLIAgent::detect_from_argv_with( + &argv, + crate::core::config::agent_commands_cached(), + ) + }; + Some(detect()) } +/// Windows: ConPTY has no foreground process group, so there is no process +/// table to poll — "no opinion" (`None`), never applied. Agent identity comes +/// from the shell integration's `133;C;` capture instead, applied in +/// [`apply_signals`] via [`agent_from_shell_mark`]. #[cfg(not(any(target_os = "macos", target_os = "linux")))] fn foreground_agent( _master: &Mutex>, -) -> Option { +) -> Option> { None } @@ -1791,6 +1843,11 @@ struct ShellState { active: bool, at_prompt: bool, last_exit_code: Option, + /// The command line the shell reported on its last `133;C;` mark + /// (percent-decoded), cleared when the command finishes (`D`). All of + /// tty7's shell integrations carry the payload; it is the Windows + /// coding-agent detection input (see [`agent_from_shell_mark`]). + command: Option, } /// Changes a `feed` call produced, if any. @@ -1869,10 +1926,26 @@ fn handle_osc133(shell: &mut ShellState, rest: &[u8]) -> bool { // grid) instead of the editor — the "un-deletable char / doubled prompt" // glitch. Setting it as early as `D`/`A` closes that window. match rest.first() { + // A/B deliberately leave `command` alone: every tty7 integration emits + // D *before* A at a real prompt (so it's already cleared there), while + // a stray A/B from a foreign integration mid-command (a nested or + // remote shell drawing its own prompt — Windows has no pgid gate to + // reject it with, cf. issue #26) must not wipe the agent chip. Some(b'A') | Some(b'B') => shell.at_prompt = true, - Some(b'C') => shell.at_prompt = false, + Some(b'C') => { + shell.at_prompt = false; + // tty7 extension: our shell integrations append the submitted + // command line, percent-encoded — the Windows agent-detection + // input (see [`agent_from_shell_mark`]). Bare `C` (a foreign + // shell's own integration) leaves it `None`. + shell.command = rest + .strip_prefix(b"C;") + .map(|c| String::from_utf8_lossy(&percent_decode(c)).into_owned()) + .filter(|s| !s.trim().is_empty()); + } Some(b'D') => { shell.at_prompt = true; + shell.command = None; shell.last_exit_code = rest .strip_prefix(b"D;") .and_then(|c| std::str::from_utf8(c).ok()) @@ -2041,7 +2114,9 @@ mod tests { // hanging CI. let mut detected = None; for _ in 0..200 { - if let Some(agent) = foreground_agent(&master) { + // Flatten: the outer Some is just "this platform has a process + // table"; the poll keeps going until detection actually answers. + if let Some(agent) = foreground_agent(&master).flatten() { detected = Some(agent); break; } @@ -2406,6 +2481,53 @@ mod tests { assert_eq!(d.shell.as_ref().unwrap().last_exit_code, Some(130)); } + /// The C mark's command capture (tty7 extension, PowerShell integration) — + /// the Windows agent-detection input: `C;` records the submitted line + /// percent-decoded, every prompt mark clears it, and + /// [`agent_from_shell_mark`] turns it into the chip's agent. + #[test] + fn sniff_osc133_command_capture_drives_agent_detection() { + let custom = std::collections::HashMap::new(); + let mut s = OscSniffer::new(); + + // A submitted `claude --help` (space percent-encoded, as the + // PowerShell body emits it). + let c = s.feed(b"\x1b]133;C;claude%20--help\x07"); + let shell = c.shell.as_ref().unwrap(); + assert!(!shell.at_prompt); + assert_eq!(shell.command.as_deref(), Some("claude --help")); + assert_eq!( + agent_from_shell_mark(shell, &custom), + Some(crate::core::cli_agent::CLIAgent::Claude) + ); + + // The command finishing (D) clears the capture → the agent clears. + let d = s.feed(b"\x1b]133;D;0\x07"); + let shell = d.shell.as_ref().unwrap(); + assert_eq!(shell.command, None); + assert_eq!(agent_from_shell_mark(shell, &custom), None); + + // A non-agent command sets the capture but detects nothing. + let c = s.feed(b"\x1b]133;C;git%20status\x07"); + let shell = c.shell.as_ref().unwrap(); + assert_eq!(shell.command.as_deref(), Some("git status")); + assert_eq!(agent_from_shell_mark(shell, &custom), None); + + // A bare `C` (a foreign shell integration) leaves no capture. + let c = s.feed(b"\x1b]133;C\x07"); + assert_eq!(c.shell.as_ref().unwrap().command, None); + + // A stray A/B mid-command (a nested/remote shell drawing its own + // prompt) must NOT wipe the capture — only D (command finished) does. + // Windows has no pgid gate to reject foreign marks with, so this is + // what keeps the agent chip alive while the agent runs. + let _ = s.feed(b"\x1b]133;C;codex\x07"); + let a = s.feed(b"\x1b]133;A\x1b]133;B\x07"); + assert_eq!(a.shell.as_ref().unwrap().command.as_deref(), Some("codex")); + let d = s.feed(b"\x1b]133;D;0\x07"); + assert_eq!(d.shell.as_ref().unwrap().command, None); + } + #[test] fn sniff_osc133_edit_mode_does_not_emit_prompt_state() { let mut s = OscSniffer::new(); @@ -3034,6 +3156,7 @@ mod tests { active: true, at_prompt: true, last_exit_code: Some(0), + command: None, }), ..SniffSignals::default() }, diff --git a/src/daemon/shell_integration.rs b/src/daemon/shell_integration.rs index 59474e71..abc4f101 100644 --- a/src/daemon/shell_integration.rs +++ b/src/daemon/shell_integration.rs @@ -7,9 +7,12 @@ //! interoperates with the wider ecosystem rather than a bespoke scheme: //! - `OSC 133 ; A ST` prompt start //! - `OSC 133 ; B ST` prompt end / command input begins -//! - `OSC 133 ; C ST` command output begins (command executing) +//! - `OSC 133 ; C [; ] ST` command output begins; all four integrations +//! append the submitted command line percent-encoded (tty7 extension — the +//! Windows coding-agent detection input, see `core::cli_agent`) //! - `OSC 133 ; D ; ST` command finished, with its exit code //! - `OSC 133 ; V ; 0/1 ST` tty7 extension: shell edit mode +//! //! plus `OSC 7` to report the cwd precisely (many login shells don't emit it //! unless they think they're in Terminal.app). //! @@ -106,10 +109,21 @@ if [[ -o interactive ]] && [[ -z "$TTY7_SHELL_INTEGRATION" ]]; then # preexec runs after the user hits Enter, before the command runs: mark the # start of command output (C). We track an "active" flag so the very first - # prompt (no command yet) doesn't emit a bogus D. + # prompt (no command yet) doesn't emit a bogus D. The C mark carries the + # submitted line ($1), truncated (detection only reads the front) and with + # the bytes that would break OSC framing or the daemon's percent-decode + # escaped (% ESC BEL CR NL) — the coding-agent detection input on Windows, + # where ConPTY has no process table to poll (see core::cli_agent). __tty7_preexec() { __tty7_cmd_active=1 - __tty7_osc "133;C" + local cmd=$1 + cmd=${cmd[1,512]} + cmd=${cmd//\%/%25} + cmd=${cmd//$'\e'/%1B} + cmd=${cmd//$'\a'/%07} + cmd=${cmd//$'\r'/%0D} + cmd=${cmd//$'\n'/%0A} + __tty7_osc "133;C;$cmd" } autoload -Uz add-zsh-hook @@ -177,9 +191,16 @@ if status is-interactive; and test -z "$TTY7_SHELL_INTEGRATION" printf '\e]7;file://%s%s\a' (hostname) (string replace --all '%' '%25' -- $PWD) end + # The C mark carries the submitted line, truncated and with the bytes that + # would break OSC framing or the daemon's percent-decode escaped (% ESC BEL + # CR NL) — the Windows agent-detection input (see core::cli_agent). fish + # command substitution splits output on newlines, so a multi-line command + # arrives as a list; the final `string join` re-joins it with the escaped + # newline. `%` must be escaped first (the other escapes introduce `%`). function __tty7_preexec --on-event fish_preexec set -g __tty7_cmd_active 1 - __tty7_osc "133;C" + set -l cmd (string sub -l 512 -- $argv[1] | string replace -a '%' '%25' | string replace -a \e '%1B' | string replace -a \a '%07' | string replace -a \r '%0D' | string join '%0A') + __tty7_osc "133;C;$cmd" end # Runs on the fish_prompt *event*, which fires before fish calls the @@ -270,9 +291,18 @@ if [[ $- == *i* ]] && [[ -z "$TTY7_SHELL_INTEGRATION" ]]; then return $ret } + # The C mark carries the submitted line ($1, from bash-preexec), truncated + # and escaped the same way as the zsh path — the Windows agent-detection + # input (git-bash; see core::cli_agent). __tty7_preexec() { __tty7_cmd_active=1 - __tty7_osc "133;C" + local cmd=${1:0:512} + cmd=${cmd//\%/%25} + cmd=${cmd//$'\e'/%1B} + cmd=${cmd//$'\a'/%07} + cmd=${cmd//$'\r'/%0D} + cmd=${cmd//$'\n'/%0A} + __tty7_osc "133;C;$cmd" } if [[ -z "${bash_preexec_imported:-}" ]]; then @@ -464,7 +494,13 @@ fi /// sniffer keys `at_prompt` off (see `daemon::pane::handle_osc133`). /// - **`PSConsoleHostReadLine`** is PSReadLine's line reader — the closest /// thing PowerShell has to a preexec. After it returns the submitted line, -/// before the command runs, we emit `133;C` (command output begins). +/// before the command runs, we emit `133;C;` (command output +/// begins), carrying the submitted line percent-encoded as a tty7 +/// extension. That capture is the Windows coding-agent detection input: +/// ConPTY has no foreground process group for the daemon's process-table +/// poll to read an `argv` from, so — like Warp — the daemon learns what +/// runs from the line the shell itself reported (see +/// `core::cli_agent::CLIAgent::detect_from_command_with`). /// /// `$?` must be captured as the very first statement of `prompt` (an /// assignment sets `$?` to true, clobbering it), and is restored before the @@ -543,7 +579,25 @@ if (-not $env:TTY7_SHELL_INTEGRATION) { $line = & $global:__Tty7OrigReadLine if (-not [string]::IsNullOrWhiteSpace($line)) { $global:__Tty7CmdActive = $true - Write-Host -NoNewline "$($global:__Tty7Esc)]133;C$($global:__Tty7Bel)" + # Carry the submitted line on the C mark (tty7 extension): the daemon + # detects coding agents from it on Windows, where ConPTY exposes no + # foreground process group to read an argv from. Truncated (detection + # only reads the front) and percent-encoded so the payload can't carry + # a raw `;`, ESC or BEL into the OSC framing. The cut can split a + # surrogate pair, and a lone high surrogate makes EscapeDataString + # throw on .NET Framework (PS 5.1) — inside this wrapper that would + # swallow the submitted line, so drop it and keep the whole mark + # best-effort: a plain `C` still flips the prompt state. + $cmd = if ($line.Length -gt 512) { $line.Substring(0, 512) } else { $line } + if ([char]::IsHighSurrogate($cmd[$cmd.Length - 1])) { + $cmd = $cmd.Substring(0, $cmd.Length - 1) + } + try { + $cmd = [Uri]::EscapeDataString($cmd) + Write-Host -NoNewline "$($global:__Tty7Esc)]133;C;$cmd$($global:__Tty7Bel)" + } catch { + Write-Host -NoNewline "$($global:__Tty7Esc)]133;C$($global:__Tty7Bel)" + } } $line }