From 8faeeac638ad9cf397dfa08cfaeef7ccf696b213 Mon Sep 17 00:00:00 2001 From: yuazha Date: Fri, 5 Jun 2026 13:10:02 +0800 Subject: [PATCH] feat(wta): PID-fallback pane scanner for hookless agent CLI panes Adds Class C session detection for the wta-helper: a 3-second tick walks the helper's owner-tab pane shell-PID children (Win32 Toolhelp32, BFS depth <= 3) and matches exe basenames against a Phase A allow-list (copilot/copilot-cli/claude/gemini). Matches produce synthetic `pane:` rows in the session management view labelled `(detected) ` so the user can Focus the pane. A two-tick confirmation gate filters one-shot invocations like `copilot --version`. Why: today the session management view only learns about shell-pane CLI sessions through PowerShell shell-integration hooks (Class B). Users without hooks installed -- the default for non-pwsh shells and any user who hasn't opted in -- see nothing, even though their CLI is clearly running in front of them. Class C fills that gap with no CLI cooperation. Scope (Phase A): - Per-tab, helper-local. No master-side aggregation; the session management view in tab N won't show PID-detected rows from tab M. Cross-tab visibility is a Phase B follow-up over ACP ext notifications. - Native exe matching only. `npx @anthropic-ai/claude-code` and friends appear as `node.exe` and are NOT detected -- Phase A.1 will add command-line inspection. - Not resumable. Synthetic rows have no real ACP session id, so Enter routes to Focus; Resume options are suppressed. - Not persisted. Helper restart drops synthetic rows; next scan re-derives. - Opt-out via `WTA_PID_SCAN=0` env var. Reducer invariants (helper-local): - Synthetic rows are REMOVED on Lost (no Ended tombstone in the picker). - Real hook-driven `SessionStarted` on the same pane removes the synthetic predecessor cleanly (no duplicate row). - Existing real rows on a pane make `PidScannerDetected` a no-op. - Same-pane PID/CLI change updates the synthetic row in place (no flicker). - `PidScanner*` events are blocked from reaching master via `unreachable!()` guards in `SessionHookParams::From` and `apply_event_locked` -- they are strictly helper-local. Tests: 15 new unit tests covering the pure `diff_snapshots` / `classify_exe` functions and the reducer arms (creation, deletion, no-op-on-real, in-place update, GUID lowercasing). All 147 existing `agent_sessions` / `session_registry` tests continue to pass. Trace target: `pid_pane_scanner` (`WTA_LOG=debug` for tick activity, `trace` for per-tick binding counts). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- AGENTS.md | 34 ++ tools/wta/Cargo.toml | 1 + tools/wta/src/agent_sessions.rs | 407 +++++++++++++++++++++- tools/wta/src/app.rs | 267 +++++++++++++++ tools/wta/src/history_loader.rs | 8 + tools/wta/src/main.rs | 52 +++ tools/wta/src/master/mod.rs | 4 + tools/wta/src/pid_pane_scanner.rs | 543 ++++++++++++++++++++++++++++++ tools/wta/src/session_registry.rs | 38 +++ tools/wta/src/ui/agents_view.rs | 2 + 10 files changed, 1355 insertions(+), 1 deletion(-) create mode 100644 tools/wta/src/pid_pane_scanner.rs diff --git a/AGENTS.md b/AGENTS.md index c0401fa06..dfb157ca7 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -129,6 +129,40 @@ once the session later connects. **Diag log**: `wta-ensure-host.log` in the WTA log directory — shows event flow, classification, and autofix triggers. +## PID-fallback pane mapping (Class C) + +For shell panes where the user launches `copilot` / `claude` / `gemini` +**without** the PowerShell shell-integration hooks installed, each +helper periodically walks its own tab's pane shell-PIDs and matches CLI +exe names. Detected panes get a synthetic session management row labelled +`"(detected) "` so Focus works and exit detection flips the row +away on the next tick. + +- **Cadence**: 3s. Two consecutive matching sightings required before + a row appears (filters one-shots like `copilot --version`). +- **Scope**: per-tab, helper-local — same per-tab visibility model as + hook Class B. The session management view in tab N won't show + PID-detected rows from tab M. +- **Opt-out**: `WTA_PID_SCAN=0` env var disables the scanner entirely. +- **Phase A limitation**: matches native exe basenames only + (`copilot.exe`, `copilot-cli.exe`, `claude.exe`, `gemini.exe`). + npm/npx-wrapped CLIs (e.g. `npx @anthropic-ai/claude-code`) appear + as `node.exe` and are NOT detected — Phase A.1 will add cmdline + inspection. +- **Title prefix**: synthetic rows show `"(detected) "` to make + them visually distinct from real ACP / hook-bound sessions. +- **Out of scope**: resumability. Phase A only surfaces the live + binding; reopening WTA discards synthetic rows (next scan re-derives). + +**Key code**: `tools/wta/src/pid_pane_scanner.rs`, +`tools/wta/src/agent_sessions.rs` (`SessionEvent::PidScanner*` arms, +`synthetic` field), `tools/wta/src/app.rs` +(`handle_pid_pane_scan_tick`, `handle_pid_pane_scan_result`), +`tools/wta/src/main.rs` (interval spawn in `run_acp_tui_mode`). + +**Trace target**: `pid_pane_scanner` (set `WTA_LOG=debug` to see +scan ticks; `trace` adds per-tick bound-pane / binding counts). + ## Hooks plugin auto-upgrade When IT is installed or upgraded, the bundled `wt-agent-hooks` plugin diff --git a/tools/wta/Cargo.toml b/tools/wta/Cargo.toml index 6050d5841..11e04dac6 100644 --- a/tools/wta/Cargo.toml +++ b/tools/wta/Cargo.toml @@ -30,6 +30,7 @@ windows-sys = { version = "0.61", features = [ "Win32_System_Environment", "Win32_System_Registry", "Win32_System_Threading", + "Win32_System_Diagnostics_ToolHelp", ] } tracing = "0.1" tracing-subscriber = { version = "0.3", features = ["env-filter", "fmt"] } diff --git a/tools/wta/src/agent_sessions.rs b/tools/wta/src/agent_sessions.rs index 024b4a863..eafeb96af 100644 --- a/tools/wta/src/agent_sessions.rs +++ b/tools/wta/src/agent_sessions.rs @@ -218,6 +218,23 @@ pub struct AgentSession { /// Provenance for this session — populated for historical rows from /// the agent-pane origin index. See [`SessionOrigin`]. pub origin: SessionOrigin, + /// True for rows synthesised by the PID-based pane scanner (Class C): + /// the user started an agent CLI directly in a shell pane without + /// hooks installed, so there's no real ACP `session_id` yet. The key + /// is `pane:`. The row exists purely so the user can + /// see and Enter-focus the pane from the session management view — + /// it is NOT persisted to + /// disk and gets REMOVED (not demoted to Ended) when the scanner + /// notices the CLI exited, or when a real hook-driven + /// `SessionStarted` arrives carrying a real sid for the same pane + /// (preventing dead `pane:` tombstones in the session management view). + pub synthetic: bool, + /// PID of the CLI process matched by the PID scanner, only set for + /// `synthetic == true` rows. Used by the reducer to detect "same + /// pane, fresh CLI invocation" so a re-run of `copilot` after + /// `/exit` re-emits a Detected row without flicker. None for all + /// non-synthetic rows. + pub synthetic_cli_pid: Option, } impl AgentSession { @@ -283,6 +300,38 @@ pub enum SessionEvent { /// (Claude/Copilot's typical fast path), the row already has the /// pane GUID and this event is a no-op for the same key+pane. ResumePaneAssigned { key: AgentKey, pane_session_id: String }, + /// PID-based scanner detected an agent CLI (`copilot` / `claude` / + /// `gemini`) running in a plain shell pane that has no real ACP + /// session_id bound to it yet. Inserts a synthetic row keyed + /// `pane:` so the session management view can surface and focus the pane. Replaced + /// (the synthetic row is removed) when a real hook-driven + /// `SessionStarted` later arrives with a real sid for the same pane. + /// + /// `cli_pid` is the PID of the matched CLI process (not the shell + /// PID) — stored so the reducer can detect "same pane, new CLI + /// invocation" as a fresh detection. + PidScannerDetected { pane_guid: String, cli_source: CliSource, cli_pid: u32 }, + /// PID-based scanner no longer sees the CLI running under the given + /// shell pane. Removes the synthetic row entirely (no tombstone in + /// session management view). No-op for non-synthetic rows — real hook-bound rows keep + /// their own lifecycle via SessionStopped/PaneClosed. + PidScannerLost { pane_guid: String }, +} + +/// Title displayed in the session management view for PID-scanner synthetic rows. +/// Kept distinct +/// from real-row titles (which are derived from CLI output / cwd / +/// session) so users can tell at a glance that the row is a +/// scanner-detected, hook-less standalone. +pub fn synthetic_title(cli: &CliSource) -> String { + let name = match cli { + CliSource::Claude => "claude", + CliSource::Codex => "codex", + CliSource::Copilot => "copilot", + CliSource::Gemini => "gemini", + CliSource::Unknown(s) => s.as_str(), + }; + format!("(detected) {name}") } /// Returns `true` for tool names that represent the agent soliciting input @@ -347,6 +396,10 @@ impl AgentSessionRegistry { SessionEvent::PaneClosed { pane_session_id: pane_session_id.to_ascii_lowercase() }, SessionEvent::ResumePaneAssigned { key, pane_session_id } => SessionEvent::ResumePaneAssigned { key, pane_session_id: pane_session_id.to_ascii_lowercase() }, + SessionEvent::PidScannerDetected { pane_guid, cli_source, cli_pid } => + SessionEvent::PidScannerDetected { pane_guid: pane_guid.to_ascii_lowercase(), cli_source, cli_pid }, + SessionEvent::PidScannerLost { pane_guid } => + SessionEvent::PidScannerLost { pane_guid: pane_guid.to_ascii_lowercase() }, other => other, }; match ev { @@ -378,7 +431,27 @@ impl AgentSessionRegistry { if pane_known { if let Some(prev_key) = self.active_by_pane.get(&pane_session_id).cloned() { if prev_key != key { - if let Some(prev) = self.sessions.get_mut(&prev_key) { + // Synthetic rows (PID-scanner Class C) must be + // REMOVED entirely when a real hook-driven + // session takes over the pane. Otherwise the + // session management view + // would carry a dead `pane:` tombstone + // alongside the real row. + let prev_synthetic = self.sessions + .get(&prev_key) + .map(|p| p.synthetic) + .unwrap_or(false); + if prev_synthetic { + self.sessions.remove(&prev_key); + self.active_by_pane.remove(&pane_session_id); + tracing::info!( + target: "agent_session_registry", + prev_key = %prev_key, + new_key = %key, + pane = %pane_session_id, + "SessionStarted removed synthetic predecessor for reused pane", + ); + } else if let Some(prev) = self.sessions.get_mut(&prev_key) { if prev.pane_session_id.as_deref() == Some(pane_session_id.as_str()) { prev.status = AgentStatus::Ended; prev.pane_session_id = None; @@ -415,6 +488,8 @@ impl AgentSessionRegistry { attention_reason: None, log_path: None, origin: SessionOrigin::default(), + synthetic: false, + synthetic_cli_pid: None, }); // If we're rebinding to a different pane, drop the old pane's mapping first. if let Some(old_pane) = entry.pane_session_id.take() { @@ -671,6 +746,101 @@ impl AgentSessionRegistry { self.dirty = true; } } + + SessionEvent::PidScannerDetected { pane_guid, cli_source, cli_pid } => { + // If this pane is already bound to *any* session (real + // hook-driven row, in-progress resume, prior synthetic), + // do nothing: the hook flow is authoritative and we + // don't want to demote it. The scanner only fills the + // gap where no other source covers the pane. + if let Some(existing_key) = self.active_by_pane.get(&pane_guid) { + // One exception: same synthetic key already owns this + // pane. Update PID and CLI source if they changed + // (the scanner's two-tick gate already filtered + // ephemeral noise; a real PID change means a fresh + // invocation in the same pane). + let existing_key = existing_key.clone(); + if let Some(entry) = self.sessions.get_mut(&existing_key) { + if entry.synthetic { + let pid_changed = entry.synthetic_cli_pid != Some(cli_pid); + let cli_changed = entry.cli_source != cli_source; + if pid_changed || cli_changed { + entry.cli_source = cli_source; + entry.synthetic_cli_pid = Some(cli_pid); + entry.title = synthetic_title(&entry.cli_source); + entry.last_activity_at = now; + self.dirty = true; + } + } + } + return; + } + + // Reuse `pane:` as the key. resolve_or_synthesize_key + // already produces this format for hook fallbacks, so a + // later real SessionStarted that arrives with a real sid + // will trigger the demote-previous branch above and + // remove this synthetic row cleanly. + let key: AgentKey = format!("pane:{pane_guid}"); + let title = synthetic_title(&cli_source); + let entry = AgentSession { + key: key.clone(), + cli_source: cli_source.clone(), + pane_session_id: Some(pane_guid.clone()), + window_id: None, + tab_id: None, + title, + cwd: PathBuf::new(), + started_at: now, + last_activity_at: now, + status: AgentStatus::Idle, + last_error: None, + current_tool: None, + attention_reason: None, + log_path: None, + origin: SessionOrigin::Unknown, + synthetic: true, + synthetic_cli_pid: Some(cli_pid), + }; + self.sessions.insert(key.clone(), entry); + self.active_by_pane.insert(pane_guid.clone(), key.clone()); + self.dirty = true; + tracing::info!( + target: "agent_session_registry", + key = %key, + pane = %pane_guid, + cli = ?cli_source, + cli_pid, + "PidScannerDetected created synthetic row", + ); + } + + SessionEvent::PidScannerLost { pane_guid } => { + // Look up the key currently bound to this pane. We can + // only safely remove a row if it is synthetic — real + // hook-driven rows have their own lifecycle (PaneClosed, + // SessionStopped) and the scanner is not authoritative + // for them. + let Some(key) = self.active_by_pane.get(&pane_guid).cloned() else { + return; + }; + let is_synthetic = self.sessions + .get(&key) + .map(|s| s.synthetic) + .unwrap_or(false); + if !is_synthetic { + return; + } + self.sessions.remove(&key); + self.active_by_pane.remove(&pane_guid); + self.dirty = true; + tracing::info!( + target: "agent_session_registry", + key = %key, + pane = %pane_guid, + "PidScannerLost removed synthetic row", + ); + } } } @@ -1271,6 +1441,8 @@ impl AgentSessionRegistry { attention_reason: None, log_path: Some(PathBuf::from("~/.gemini/logs/2026-05-03-1530.log")), origin: SessionOrigin::default(), + synthetic: false, + synthetic_cli_pid: None, }); // Stagger last_activity_at so the order in the UI matches the @@ -1804,6 +1976,8 @@ mod tests { attention_reason: None, log_path: None, origin: SessionOrigin::default(), + synthetic: false, + synthetic_cli_pid: None, }]); reg.apply(SessionEvent::ResumeDispatched { key: k("g") }); assert_eq!(reg.sessions.get(&k("g")).unwrap().status, AgentStatus::Idle, @@ -2017,6 +2191,8 @@ mod tests { attention_reason: None, log_path: None, origin: SessionOrigin::default(), + synthetic: false, + synthetic_cli_pid: None, }; // Loaded set tries to overwrite live-1 + add hist-1. @@ -2341,6 +2517,8 @@ mod tests { attention_reason: None, log_path: None, origin: SessionOrigin::Unknown, + synthetic: false, + synthetic_cli_pid: None, }; let cases = [ @@ -2525,6 +2703,8 @@ mod tests { attention_reason: None, log_path: None, origin: SessionOrigin::AgentPane, + synthetic: false, + synthetic_cli_pid: None, } } @@ -2919,4 +3099,229 @@ mod tests { "Historical rows must also be ineligible — no live target ⇒ no fallback", ); } + + // ---- PID-scanner synthetic-row reducer tests ---------------------- + // + // These cover the helper-local PID-fallback path (Class C). The + // scanner emits `PidScannerDetected` when it observes a tracked CLI + // exe under a shell pane's process tree and emits `PidScannerLost` + // when it disappears. The reducer must: + // 1. Create synthetic rows with `synthetic = true`, key + // `pane:`, status Idle, empty cwd. + // 2. Delete synthetic rows on Lost (no Ended tombstone). + // 3. Yield to authoritative hook-driven SessionStarted by removing + // the synthetic predecessor instead of demoting it. + // 4. No-op when a non-synthetic row already owns the pane. + // 5. Update PID / CLI in place when both observations are about + // the same synthetic owner (no flicker). + + fn pane_guid() -> String { "AAAAAAAA-BBBB-CCCC-DDDD-EEEEEEEEEEEE".to_string() } + + fn synth_key_for(g: &str) -> AgentKey { format!("pane:{}", g.to_ascii_lowercase()) } + + #[test] + fn pid_scanner_detected_creates_synthetic_row() { + let mut reg = AgentSessionRegistry::new(); + let g = pane_guid(); + reg.apply(SessionEvent::PidScannerDetected { + pane_guid: g.clone(), + cli_source: CliSource::Copilot, + cli_pid: 1234, + }); + + let key = synth_key_for(&g); + let s = reg.sessions.get(&key).expect("synthetic row inserted"); + assert!(s.synthetic, "row must be flagged synthetic"); + assert_eq!(s.synthetic_cli_pid, Some(1234)); + assert_eq!(s.cli_source, CliSource::Copilot); + assert_eq!(s.status, AgentStatus::Idle); + assert_eq!(s.origin, SessionOrigin::Unknown); + assert_eq!(s.cwd, PathBuf::new(), "synthetic rows have no recoverable cwd"); + assert_eq!(s.pane_session_id.as_deref(), Some(g.to_ascii_lowercase().as_str())); + assert_eq!(s.title, synthetic_title(&CliSource::Copilot)); + assert_eq!( + reg.active_by_pane.get(&g.to_ascii_lowercase()), + Some(&key), + "active_by_pane must be updated so Enter routes to the pane", + ); + } + + #[test] + fn pid_scanner_lost_removes_synthetic_row_entirely() { + let mut reg = AgentSessionRegistry::new(); + let g = pane_guid(); + reg.apply(SessionEvent::PidScannerDetected { + pane_guid: g.clone(), + cli_source: CliSource::Claude, + cli_pid: 99, + }); + let key = synth_key_for(&g); + assert!(reg.sessions.contains_key(&key)); + + reg.apply(SessionEvent::PidScannerLost { pane_guid: g.clone() }); + + assert!( + !reg.sessions.contains_key(&key), + "Lost must delete the row entirely (no Ended tombstone in the session management view)", + ); + assert!( + !reg.active_by_pane.contains_key(&g.to_ascii_lowercase()), + "active_by_pane must release the pane so a real session can claim it later", + ); + } + + #[test] + fn pid_scanner_lost_is_noop_for_non_synthetic_rows() { + // A real hook-driven row claims the pane first. If the scanner + // later thinks it's gone (e.g. a transient hiccup before a + // tab_changed snapshot arrives), it must NOT remove the real + // row — only PaneClosed / SessionStopped own real-row lifecycle. + let mut reg = AgentSessionRegistry::new(); + let g = pane_guid().to_ascii_lowercase(); + reg.apply(SessionEvent::SessionStarted { + key: k("real-sid"), + cli_source: CliSource::Copilot, + pane_session_id: g.clone(), + cwd: PathBuf::from("/work"), + title: "copilot — real".into(), + }); + assert!(reg.sessions.contains_key("real-sid")); + + reg.apply(SessionEvent::PidScannerLost { pane_guid: g.clone() }); + + assert!(reg.sessions.contains_key("real-sid"), "real row must survive"); + assert_eq!(reg.active_by_pane.get(&g), Some(&k("real-sid"))); + } + + #[test] + fn session_started_removes_synthetic_predecessor_on_same_pane() { + // Hook flow wins when both fire on the same pane. The synthetic + // row created by the scanner must be removed entirely (not + // demoted to Ended) so the session management view only shows the authoritative row. + let mut reg = AgentSessionRegistry::new(); + let g = pane_guid().to_ascii_lowercase(); + reg.apply(SessionEvent::PidScannerDetected { + pane_guid: g.clone(), + cli_source: CliSource::Copilot, + cli_pid: 77, + }); + let synth_key = synth_key_for(&g); + assert!(reg.sessions.contains_key(&synth_key)); + + reg.apply(SessionEvent::SessionStarted { + key: k("real-sid"), + cli_source: CliSource::Copilot, + pane_session_id: g.clone(), + cwd: PathBuf::from("/work"), + title: "copilot — real".into(), + }); + + assert!( + !reg.sessions.contains_key(&synth_key), + "synthetic predecessor must be removed (no Ended tombstone)", + ); + let real = reg.sessions.get("real-sid").expect("real row created"); + assert!(!real.synthetic); + assert_eq!(real.pane_session_id.as_deref(), Some(g.as_str())); + assert_eq!(reg.active_by_pane.get(&g), Some(&k("real-sid"))); + } + + #[test] + fn pid_scanner_detected_is_noop_when_pane_owned_by_real_row() { + // Mirror: real row arrives first. Subsequent scanner sightings + // must not touch the real row at all (no demote, no field + // overwrite). Scanner is the fallback, never the authority. + let mut reg = AgentSessionRegistry::new(); + let g = pane_guid().to_ascii_lowercase(); + reg.apply(SessionEvent::SessionStarted { + key: k("real-sid"), + cli_source: CliSource::Copilot, + pane_session_id: g.clone(), + cwd: PathBuf::from("/work"), + title: "copilot — real".into(), + }); + let real_before = reg.sessions.get("real-sid").cloned().unwrap(); + + reg.apply(SessionEvent::PidScannerDetected { + pane_guid: g.clone(), + cli_source: CliSource::Claude, // even mismatched CLI must not bleed in + cli_pid: 7777, + }); + + let real_after = reg.sessions.get("real-sid").expect("real row untouched"); + assert!(!real_after.synthetic); + assert_eq!(real_after.cli_source, CliSource::Copilot, "cli must not be overwritten"); + assert_eq!(real_after.synthetic_cli_pid, None); + assert_eq!(real_after.title, real_before.title); + let synth_key = synth_key_for(&g); + assert!( + !reg.sessions.contains_key(&synth_key), + "no synthetic row should be created alongside the real row", + ); + } + + #[test] + fn pid_scanner_detected_updates_existing_synthetic_in_place_on_pid_change() { + // The scanner re-emits Detected after a confirmed PID change + // (e.g. user `/exit`'d copilot and immediately re-ran it in the + // same pane). The reducer must update the existing synthetic + // row in place — no flicker (no remove + reinsert), and the + // key stays `pane:` so any UI bookkeeping survives. + let mut reg = AgentSessionRegistry::new(); + let g = pane_guid().to_ascii_lowercase(); + reg.apply(SessionEvent::PidScannerDetected { + pane_guid: g.clone(), + cli_source: CliSource::Copilot, + cli_pid: 100, + }); + let key = synth_key_for(&g); + assert_eq!(reg.sessions.get(&key).unwrap().synthetic_cli_pid, Some(100)); + + // Same pane, same CLI, different PID. + reg.apply(SessionEvent::PidScannerDetected { + pane_guid: g.clone(), + cli_source: CliSource::Copilot, + cli_pid: 200, + }); + let s = reg.sessions.get(&key).expect("row still present (no flicker)"); + assert_eq!(s.synthetic_cli_pid, Some(200)); + assert!(s.synthetic); + assert_eq!(reg.sessions.len(), 1, "must not have created a second row"); + + // Same pane, different CLI (npm wrapper would never report this + // in Phase A but the reducer should still cope correctly). + reg.apply(SessionEvent::PidScannerDetected { + pane_guid: g.clone(), + cli_source: CliSource::Claude, + cli_pid: 200, + }); + let s = reg.sessions.get(&key).unwrap(); + assert_eq!(s.cli_source, CliSource::Claude); + assert_eq!(s.title, synthetic_title(&CliSource::Claude)); + } + + #[test] + fn pid_scanner_normalises_pane_guid_to_lowercase() { + // The pane_guid lives on the input border between OS APIs + // (which can return UPPERCASE GUIDs from wt_list_panes) and + // active_by_pane (always lowercase). Detected followed by Lost + // with mixed-case input must address the same row. + let mut reg = AgentSessionRegistry::new(); + let g_upper = "AAAAAAAA-BBBB-CCCC-DDDD-EEEEEEEEEEEE".to_string(); + let g_lower = g_upper.to_ascii_lowercase(); + reg.apply(SessionEvent::PidScannerDetected { + pane_guid: g_upper.clone(), + cli_source: CliSource::Gemini, + cli_pid: 1, + }); + assert!(reg.active_by_pane.contains_key(&g_lower)); + assert!(!reg.active_by_pane.contains_key(&g_upper)); + + // Mixed-case Lost still finds the row. + reg.apply(SessionEvent::PidScannerLost { + pane_guid: "aAaAaAaA-bbbb-CCCC-dddd-EEEEEEEEEEEE".to_string(), + }); + assert!(reg.sessions.is_empty()); + assert!(reg.active_by_pane.is_empty()); + } } diff --git a/tools/wta/src/app.rs b/tools/wta/src/app.rs index 8ba57796c..b70b79ecf 100644 --- a/tools/wta/src/app.rs +++ b/tools/wta/src/app.rs @@ -1302,6 +1302,21 @@ pub enum AppEvent { MasterMutationCompleted { request_id: u64, }, + /// PID-fallback scanner tick. Posted every ~3s by the background + /// task spawned in `run_acp_tui_mode`. Handler calls + /// `pid_pane_scanner::scan_tab` against the helper's owner tab, + /// runs the two-tick gate, and emits + /// `SessionEvent::PidScannerDetected` / `PidScannerLost` into the + /// local `agent_sessions` registry. See module-level docs on + /// `crate::pid_pane_scanner` for the full design rationale. + PidPaneScanTick, + /// Result of a single PID-fallback scan. The tick handler dispatches + /// the actual `wt_list_panes` + Win32 child-walk on a spawned task + /// so it doesn't block the event loop; the task posts back this + /// variant with the raw bindings. The reducer-side state machine + /// (`pid_scan_pending` / `pid_scan_confirmed`) is then advanced on + /// the main task, keeping App's `&mut self` mutations single-writer. + PidPaneScanResult(Vec), } // --- Per-tab session storage --- @@ -2033,6 +2048,32 @@ pub struct App { /// the bootstrap RPC hasn't returned yet. Tracked as an Atomic so /// the bootstrap task can flip it from a non-`&mut self` context. pub alive_loaded: std::sync::Arc, + + // ── PID-fallback scanner state (Class C, helper-local) ───────────── + // + // Two-tick confirmation gate. The scanner runs every ~3s; we only + // promote a sighting to a real `PidScannerDetected` event after we + // see the same `(pane_guid, cli_source, cli_pid)` triple in *two* + // consecutive scans. This eliminates one-shot CLI invocations like + // `copilot --version` or shell completions that briefly spawn a + // child process without representing an interactive session. + // + // `pid_scan_pending` — bindings seen for the first time this round + // `pid_scan_confirmed` — bindings that have been Detected + // + // On each tick: + // 1. Compute `current = scan_tab(...)` + // 2. Drop any pending entries not in current (transient) + // 3. Promote pending → confirmed when current matches pending + // 4. New current entries (not in pending, not in confirmed) → + // pending, no event yet + // 5. Diff `confirmed` against `current` (restricted to entries + // that are either confirmed or promoting this tick) to emit + // Detected / Lost events into the reducer + // + // Both maps key on the lowercased pane GUID — matches `active_by_pane`. + pub(crate) pid_scan_pending: HashMap, + pub(crate) pid_scan_confirmed: HashMap, } /// How long the "Press Ctrl+C again to close pane" arm stays live. Long @@ -2131,6 +2172,8 @@ pub(crate) fn session_info_to_agent_session( attention_reason: info.attention_reason.clone(), log_path: None, origin, + synthetic: false, + synthetic_cli_pid: None, } } @@ -2222,6 +2265,8 @@ impl App { alive: crate::session_registry::InMemoryRegistry::shared(), alive_loaded: std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)), shell_mgr, + pid_scan_pending: HashMap::new(), + pid_scan_confirmed: HashMap::new(), } } @@ -3672,6 +3717,220 @@ impl App { self.event_tx = Some(tx); } + /// PID-fallback scan tick (Class C). + /// + /// Snapshots the panes in the helper's owner tab and, off the + /// event loop, walks each shell-pane's child-process tree for a + /// tracked CLI exe. The result comes back as + /// `AppEvent::PidPaneScanResult`, which is what actually advances + /// the two-tick gate and emits reducer events. Decoupling tick + /// → snapshot → reduce keeps `&mut self` mutations single-writer + /// even though the snapshot is async. + /// + /// No-ops when: + /// * the helper has no owner tab id yet (e.g. headless `wta run` + /// or pre-pane-discovery startup), + /// * there is no `event_tx` to post the result back into the loop, + /// * `WTA_PID_SCAN=0` (opt-out, captured at process start; see + /// `pid_scan_enabled` env check in `run_acp_tui_mode`). + fn handle_pid_pane_scan_tick(&mut self) { + // owner_tab_id is the helper's anchored tab GUID — the same one + // we pass to wt_list_panes when discovering pane identity at + // startup. Without it we can't scope the scan, so we bail. + let Some(tab_id) = self.owner_tab_id.clone() else { + return; + }; + let Some(tx) = self.event_tx.clone() else { + return; + }; + let shell_mgr = std::sync::Arc::clone(&self.shell_mgr); + + // Snapshot the set of panes already bound by a *real* (non-synthetic) + // session — hook-driven Class B or the helper's own ACP session. + // These panes are off-limits to the scanner: the existing owner is + // authoritative and we don't want to create a competing synthetic row. + // + // Synthetic-owned panes are deliberately NOT in this set — we need + // the scanner to keep observing them so that `pid_scan_confirmed` + // gets refreshed each tick. Otherwise a synthetic pane would drop + // out of `confirmed` on its very next tick and we'd emit a spurious + // `PidScannerLost`. + let bound_panes: std::collections::HashSet = self + .agent_sessions + .iter_sorted() + .into_iter() + .filter(|s| !s.synthetic) + .filter_map(|s| s.pane_session_id.clone()) + .map(|p| p.to_ascii_lowercase()) + .collect(); + + tokio::task::spawn_local(async move { + let started = std::time::Instant::now(); + let result = crate::pid_pane_scanner::scan_tab( + &shell_mgr, + &tab_id, + |pane| bound_panes.contains(pane), + ) + .await; + match result { + Ok(bindings) => { + tracing::trace!( + target: "pid_pane_scanner", + tab_id = %tab_id, + bound_panes = bound_panes.len(), + bindings = bindings.len(), + elapsed_ms = started.elapsed().as_millis() as u64, + "scan_tab complete", + ); + let _ = tx.send(AppEvent::PidPaneScanResult(bindings)); + } + Err(err) => { + // wt_list_panes can fail transiently during WT + // startup or after a window close; the next tick + // will retry. Don't escalate. + tracing::debug!( + target: "pid_pane_scanner", + tab_id = %tab_id, + error = %err, + "scan_tab failed (transient — will retry next tick)", + ); + } + } + }); + } + + /// Apply a single scan's bindings through the two-tick confirmation + /// gate and into the local `agent_sessions` reducer. + /// + /// Two-tick gate (filters one-shot invocations like `copilot --version`): + /// 1. For each binding in `current`: + /// * if it's already in `confirmed` with the same (cli, pid): + /// leave confirmed alone, drop from pending if present. + /// * if it's in `pending` with the same (cli, pid): promote to + /// confirmed this tick. + /// * if it's in `confirmed` with a *different* (cli, pid): + /// promote the new pair to confirmed (PID change). + /// * otherwise: insert into `pending`, don't emit yet. + /// 2. For each pane in `confirmed` *not* in `current`: remove + /// from confirmed (it's gone). + /// 3. Drop stale pending entries whose pane is no longer present + /// (transient sightings the user never invested in). + /// 4. Diff `previous_confirmed` (snapshotted at step 0) against + /// `confirmed` and emit Detected / Lost events. + fn handle_pid_pane_scan_result( + &mut self, + bindings: Vec, + ) { + use crate::agent_sessions::{CliSource, SessionEvent}; + + // Snapshot confirmed for the diff at the end. + let previous_confirmed = self.pid_scan_confirmed.clone(); + + // Pass 1: promote pending → confirmed, refresh existing confirmed, + // accumulate first-sightings into pending. + let mut new_pending: HashMap = HashMap::new(); + let mut new_confirmed: HashMap = HashMap::new(); + for b in &bindings { + let pane = b.pane_guid.clone(); + let now_pair = (b.cli_source.clone(), b.cli_pid); + + if let Some(prev) = self.pid_scan_confirmed.get(&pane) { + if prev == &now_pair { + // Stable — carry confirmed forward, drop any pending. + new_confirmed.insert(pane, now_pair); + } else { + // Confirmed pane reported a different (cli, pid). + // Promote immediately — the row was already real + // last tick, so a single observation of the new + // value is enough to act on (same logic as today's + // hook flow rebind). + new_confirmed.insert(pane, now_pair); + } + } else if let Some(pend) = self.pid_scan_pending.get(&pane) { + if pend == &now_pair { + // Second consecutive matching sighting → promote. + new_confirmed.insert(pane, now_pair); + } else { + // Pending sighting changed before promotion — reset + // pending with the new value, defer to next tick. + new_pending.insert(pane, now_pair); + } + } else { + new_pending.insert(pane, now_pair); + } + } + + self.pid_scan_pending = new_pending; + self.pid_scan_confirmed = new_confirmed; + + // Note: confirmed panes absent from `bindings` simply weren't + // copied into `new_confirmed` above, so they drop out + // automatically. The diff in pass 3 turns those into Lost events. + + // Pass 3: diff previous_confirmed against the new confirmed + // and emit events. Build the current view for the differ from + // the *new confirmed* set so we don't emit Detected for panes + // that are still in pending. + let synthesized_current: Vec = self + .pid_scan_confirmed + .iter() + .map(|(pane, (cli, pid))| crate::pid_pane_scanner::PaneCliBinding { + pane_guid: pane.clone(), + cli_source: cli.clone(), + cli_pid: *pid, + }) + .collect(); + + let (events, _) = + crate::pid_pane_scanner::diff_snapshots(&previous_confirmed, &synthesized_current); + + if events.is_empty() { + return; + } + tracing::debug!( + target: "pid_pane_scanner", + event_count = events.len(), + pending = self.pid_scan_pending.len(), + confirmed = self.pid_scan_confirmed.len(), + "applying scan-diff events to local registry", + ); + for ev in events { + // Final guard: a pane that ended up bound to a real row + // between scan dispatch and result handling must not get + // Detected — the reducer would refuse anyway, but logging + // a redundant event muddies the trace. Lost still goes + // through; the reducer guards that path independently. + if let SessionEvent::PidScannerDetected { ref pane_guid, .. } = ev { + if self.agent_sessions.is_agent_pane(pane_guid) + && !self.is_synthetic_pane(pane_guid) + { + tracing::trace!( + target: "pid_pane_scanner", + pane = %pane_guid, + "skipping Detected: pane is now bound to a non-synthetic row", + ); + continue; + } + } + self.agent_sessions.apply(ev); + } + } + + /// True iff `pane_guid` is bound to a synthetic row in the local + /// `agent_sessions` registry. Used by the scan-result handler to + /// distinguish "scanner is the current owner" (re-emit is fine) + /// from "real hook/ACP row took over" (skip). + fn is_synthetic_pane(&self, pane_guid: &str) -> bool { + let pane_lc = pane_guid.to_ascii_lowercase(); + self.agent_sessions + .iter_sorted() + .into_iter() + .any(|s| { + s.synthetic + && s.pane_session_id.as_deref() == Some(pane_lc.as_str()) + }) + } + /// First-call: spawn a blocking task to scan `~/.copilot`, `~/.claude`, /// `~/.gemini` for historical agent sessions and merge the result into /// `agent_sessions` via `AppEvent::HistoricalSessionsLoaded`. Subsequent @@ -4376,6 +4635,8 @@ impl App { AppEvent::AgentsSnapshotFailed { .. } => "agents_snapshot_failed", AppEvent::MasterMutationCompleted { .. } => "master_mutation_completed", AppEvent::RevealTick => "reveal_tick", + AppEvent::PidPaneScanTick => "pid_pane_scan_tick", + AppEvent::PidPaneScanResult(_) => "pid_pane_scan_result", } } @@ -5137,6 +5398,12 @@ impl App { tracing::debug!(target: "agents_view", request_id, "master mutation completed; refetching open views"); self.schedule_agents_refetch_for_open_views(); } + AppEvent::PidPaneScanTick => { + self.handle_pid_pane_scan_tick(); + } + AppEvent::PidPaneScanResult(bindings) => { + self.handle_pid_pane_scan_result(bindings); + } AppEvent::WtEvent { method, pane_id, diff --git a/tools/wta/src/history_loader.rs b/tools/wta/src/history_loader.rs index 49830df60..1fc15a55b 100644 --- a/tools/wta/src/history_loader.rs +++ b/tools/wta/src/history_loader.rs @@ -510,6 +510,8 @@ fn load_copilot(home: &Path) -> Vec { attention_reason: None, log_path: Some(events), origin: crate::agent_sessions::SessionOrigin::default(), + synthetic: false, + synthetic_cli_pid: None, }); } out.sort_by(|a, b| b.last_activity_at.cmp(&a.last_activity_at)); @@ -575,6 +577,8 @@ fn load_claude(home: &Path) -> Vec { attention_reason: None, log_path: Some(path), origin: crate::agent_sessions::SessionOrigin::default(), + synthetic: false, + synthetic_cli_pid: None, }); } } @@ -646,6 +650,8 @@ fn load_gemini(home: &Path) -> Vec { attention_reason: None, log_path: Some(path), origin: crate::agent_sessions::SessionOrigin::default(), + synthetic: false, + synthetic_cli_pid: None, }); } } @@ -702,6 +708,8 @@ fn load_codex(home: &Path) -> Vec { attention_reason: None, log_path: Some(path), origin: crate::agent_sessions::SessionOrigin::default(), + synthetic: false, + synthetic_cli_pid: None, }); } } diff --git a/tools/wta/src/main.rs b/tools/wta/src/main.rs index 9a337b934..35b6f157d 100644 --- a/tools/wta/src/main.rs +++ b/tools/wta/src/main.rs @@ -17,6 +17,7 @@ mod logging; mod master; mod osc52; mod pane_context; +mod pid_pane_scanner; mod protocol; mod rtl; mod runtime_paths; @@ -2719,6 +2720,57 @@ async fn run_acp_app( // `event_tx`, so they don't pay this cost. app_state.ensure_history_loaded(); + // PID-fallback scanner (Class C). Gated by: + // 1. `pane_identity.is_some()` (set further down for the + // WT-spawned path) — without an owner tab id we can't + // scope `wt_list_panes`, so the scanner's tick handler + // no-ops and posting wastes work. + // 2. `WTA_PID_SCAN=0` opt-out for users who'd rather + // keep WT pristine of background process-tree walking. + // + // The interval task lives until the App quits (event_tx + // closes on drop, so `send` becomes an error and we break). + // 3-second cadence chosen to keep two-tick promotion under + // ~6s (matches the user expectation of "I ran `copilot` + // a moment ago — why isn't it in the session management view?") without hammering + // wt_list_panes. + let pid_scan_enabled = + std::env::var("WTA_PID_SCAN").map(|v| v != "0").unwrap_or(true); + if pid_scan_enabled && pane_identity.is_some() { + let scan_tx = event_tx.clone(); + tokio::task::spawn(async move { + let mut tick = tokio::time::interval(std::time::Duration::from_secs(3)); + tick.set_missed_tick_behavior( + tokio::time::MissedTickBehavior::Skip, + ); + // Burn the first tick (fires immediately on creation) + // so we don't race the rest of helper startup. + tick.tick().await; + loop { + tick.tick().await; + if scan_tx.send(app::AppEvent::PidPaneScanTick).is_err() { + tracing::debug!( + target: "pid_pane_scanner", + "event_tx closed; stopping PID scan loop", + ); + break; + } + } + }); + tracing::info!( + target: "pid_pane_scanner", + interval_secs = 3, + "PID-fallback pane scanner enabled", + ); + } else { + tracing::info!( + target: "pid_pane_scanner", + enabled = pid_scan_enabled, + has_pane_identity = pane_identity.is_some(), + "PID-fallback scanner disabled", + ); + } + // If in setup mode, store ACP params for deferred start after login. if let Some((prompt_rx, cancel_rx, new_session_rx, load_session_rx, drop_session_rx, rename_session_rx, restart_rx, master_ext_rx)) = deferred_channels { app_state.set_acp_params( diff --git a/tools/wta/src/master/mod.rs b/tools/wta/src/master/mod.rs index 9483079e0..16e62032b 100644 --- a/tools/wta/src/master/mod.rs +++ b/tools/wta/src/master/mod.rs @@ -2209,6 +2209,10 @@ fn session_event_key(event: &crate::agent_sessions::SessionEvent) -> Option<&str | SessionEvent::ResumeDispatched { key } | SessionEvent::ResumePaneAssigned { key, .. } => Some(key.as_str()), SessionEvent::PaneClosed { .. } | SessionEvent::ConnectionFailed { .. } => None, + // PidScanner* events are helper-local; they never reach master + // via session_hook. If one ever did, there's no real ACP key to + // refresh from disk — pretend it has no key. + SessionEvent::PidScannerDetected { .. } | SessionEvent::PidScannerLost { .. } => None, } } diff --git a/tools/wta/src/pid_pane_scanner.rs b/tools/wta/src/pid_pane_scanner.rs new file mode 100644 index 000000000..f118cf2e2 --- /dev/null +++ b/tools/wta/src/pid_pane_scanner.rs @@ -0,0 +1,543 @@ +//! PID-based fallback pane scanner. +//! +//! ## Why this exists +//! +//! WTA already learns about agent-CLI panes through three mechanisms: +//! +//! 1. **Agent panes (Class A)** — the wta-helper TUI owns the pane and +//! speaks ACP directly. The agent's SessionId is bound to the pane +//! GUID at `new_session` / `load_session` time. +//! 2. **Resumed-into-shell panes** — `wtcli resume --pane` +//! returns the new pane GUID synchronously. +//! 3. **PowerShell shell-integration hooks (Class B)** — when the user +//! runs `copilot` / `claude` / `gemini` directly in a hooked pwsh +//! pane, our hook emits an `intellterm.session_started` ext-event +//! carrying both the pane GUID (`WT_SESSION`) and the agent's real +//! session id (scraped from the CLI's stdout). Master replays it +//! into the helper as a `SessionStarted` reducer event. +//! +//! If the user disables / never installs the PowerShell hooks +//! (default for unpackaged shells, or any non-pwsh shell), Class B +//! disappears entirely. The session then never shows in the session +//! management view even +//! though the CLI is alive and the user obviously *can* see its +//! output — they're staring at it in the pane. +//! +//! ## What this module does (Phase A) +//! +//! Periodically (~3 s tick) the helper: +//! +//! 1. Calls `wt_list_panes(my_tab_id)` to enumerate the shell PIDs of +//! every pane in the helper's owner tab. +//! 2. For each pane that is **not** already bound to a session in the +//! helper's `AgentSessionRegistry`, walks the shell's child +//! processes BFS (≤3 levels) looking for an exe basename matching +//! a tracked CLI (`copilot.exe`, `copilot-cli.exe`, `claude.exe`, +//! `gemini.exe`). +//! 3. Diffs the result against the last scan and emits +//! [`SessionEvent::PidScannerDetected`] / +//! [`SessionEvent::PidScannerLost`] for each change. +//! +//! The reducer in `agent_sessions::AgentSessionRegistry::apply` then +//! creates / removes a synthetic row (key `pane:`, +//! `origin = Unknown`, `synthetic = true`). Synthetic rows are removed +//! entirely — never demoted to `Ended` — when the binding goes away or +//! when an authoritative hook event lands on the same pane. +//! +//! ## Known limitations (Phase A) +//! +//! * **No `node.exe` matching.** CLIs installed via `npm i -g +//! @anthropic-ai/claude-code` and friends run under `node.exe`, which +//! we deliberately don't match because that name is shared with +//! every other Node.js tool. Detecting them requires inspecting the +//! process command line for the script path, which is deferred to +//! Phase A.1. +//! * **Owner-tab scoped.** Each helper scans only its own tab. The +//! session management view in tab N +//! tab N sees PID-detected rows from tab N; cross-tab visibility +//! would require master-side scanning, which is out of scope. +//! * **No resumability.** Synthetic rows can be focused (`active_by_pane` +//! maps the pane GUID to the synthetic key) but the row carries no +//! real ACP session id, so Resume is not offered. +//! * **Class A wins.** Agent-pane GUIDs are always in +//! `active_by_pane` and the reducer no-ops the Detected event on +//! them, so the scanner can never duplicate WTA's own panes. + +use std::collections::HashMap; + +use crate::agent_sessions::{CliSource, SessionEvent}; +use crate::shell::ShellManager; + +/// One pane → CLI binding observed in a single scan tick. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct PaneCliBinding { + /// WT pane GUID (always lowercased so it matches `active_by_pane`). + pub pane_guid: String, + /// Which tracked CLI was found under the shell's process tree. + pub cli_source: CliSource, + /// PID of the detected CLI process (so the reducer can distinguish + /// "still the same `copilot` invocation" from "user `/exit`'d and + /// re-ran it in the same pane" — same `(pane, cli)`, different PID). + pub cli_pid: u32, +} + +/// Pure diff: turns the previous confirmed snapshot + the current +/// scan into the events that should be applied to the registry. +/// +/// The returned `HashMap` is the new confirmed snapshot. Callers +/// should store it and pass it as `last` on the next tick. +/// +/// Diff rules: +/// +/// | last has pane | current has pane | (cli, pid) match | emit | +/// |---------------|------------------|------------------|------| +/// | no | yes | n/a | Detected | +/// | yes | no | n/a | Lost | +/// | yes | yes | yes | none | +/// | yes | yes | no | Lost + Detected | +/// +/// The Lost-before-Detected ordering matters: the reducer's +/// `PidScannerDetected` arm short-circuits if the pane is already +/// bound (synthetic with same PID/CLI = no-op, different = update +/// in place), but emitting Lost first matches what the reducer +/// *would* do for a clean rebind and keeps `active_by_pane` invariants +/// crisp if the reducer ever changes shape. +pub fn diff_snapshots( + last: &HashMap, + current: &[PaneCliBinding], +) -> (Vec, HashMap) { + let mut events = Vec::new(); + let mut new_state: HashMap = + HashMap::with_capacity(current.len()); + + // Build a lookup for current observations for the symmetric pass. + let mut current_map: HashMap<&str, &PaneCliBinding> = + HashMap::with_capacity(current.len()); + for b in current { + current_map.insert(b.pane_guid.as_str(), b); + } + + // Pass 1: panes that vanished or rebinding. + for (pane, (prev_cli, prev_pid)) in last { + match current_map.get(pane.as_str()) { + None => { + events.push(SessionEvent::PidScannerLost { pane_guid: pane.clone() }); + } + Some(b) if &b.cli_source == prev_cli && b.cli_pid == *prev_pid => { + // Unchanged — carry forward; no event. + new_state.insert(pane.clone(), (prev_cli.clone(), *prev_pid)); + } + Some(b) => { + // Same pane, different PID or different CLI. + events.push(SessionEvent::PidScannerLost { pane_guid: pane.clone() }); + events.push(SessionEvent::PidScannerDetected { + pane_guid: b.pane_guid.clone(), + cli_source: b.cli_source.clone(), + cli_pid: b.cli_pid, + }); + new_state.insert(b.pane_guid.clone(), (b.cli_source.clone(), b.cli_pid)); + } + } + } + + // Pass 2: new panes (in current but not in last). + for b in current { + if !last.contains_key(&b.pane_guid) { + events.push(SessionEvent::PidScannerDetected { + pane_guid: b.pane_guid.clone(), + cli_source: b.cli_source.clone(), + cli_pid: b.cli_pid, + }); + new_state.insert(b.pane_guid.clone(), (b.cli_source.clone(), b.cli_pid)); + } + } + + (events, new_state) +} + +/// Match an exe basename (case-insensitive) against the Phase A +/// allow-list of tracked CLIs. +/// +/// Returns the canonical [`CliSource`] when matched. Note: the +/// `node.exe` / `npm.cmd` / `npx.cmd` variants intentionally don't +/// match — see the module-level "Known limitations" section. +pub fn classify_exe(exe_name: &str) -> Option { + let lower = exe_name.to_ascii_lowercase(); + match lower.as_str() { + "copilot.exe" | "copilot-cli.exe" => Some(CliSource::Copilot), + "claude.exe" => Some(CliSource::Claude), + "gemini.exe" => Some(CliSource::Gemini), + _ => None, + } +} + +/// Scan the panes in `tab_id` for tracked CLIs running under their +/// shell-pid process tree. `skip_pane_if` returns `true` for panes +/// the caller has already bound to a session (real or synthetic); +/// the scanner skips them to avoid duplicate work and to leave +/// authoritative rows untouched. +/// +/// Returns one [`PaneCliBinding`] per pane where a tracked CLI was +/// found. Panes whose shell pid is unknown or whose tree contains +/// no tracked CLI are simply absent from the result. +/// +/// Async because `wt_list_panes` is an IPC call to wtcli's COM +/// channel. The Win32 child-enum is synchronous and blocking; the +/// caller wraps each per-pane scan in [`tokio::task::spawn_blocking`] +/// so a slow / hung snapshot can't stall the event loop. +pub async fn scan_tab( + shell_mgr: &ShellManager, + tab_id: &str, + skip_pane_if: impl Fn(&str) -> bool, +) -> anyhow::Result> { + let panes_resp = shell_mgr.wt_list_panes(tab_id).await?; + let panes_arr = panes_resp + .get("panes") + .and_then(|v| v.as_array()) + .map(|a| a.as_slice()) + .unwrap_or(&[]); + + let mut bindings = Vec::new(); + for pane in panes_arr { + let pane_guid_raw = match pane.get("session_id") { + Some(serde_json::Value::String(s)) => s.clone(), + // `list_panes` returns lowercase strings already, but + // numeric ids would never round-trip as GUIDs; skip them. + _ => continue, + }; + let pane_guid = pane_guid_raw.to_ascii_lowercase(); + if skip_pane_if(&pane_guid) { + continue; + } + + let shell_pid = match pane.get("pid").and_then(|v| v.as_u64()) { + Some(p) if p > 0 && p <= u32::MAX as u64 => p as u32, + _ => continue, // pane has no live shell — nothing to walk. + }; + + // Win32 process enumeration is purely synchronous. Push it + // onto the blocking pool so the runtime can keep servicing + // ACP / UI events while the snapshot completes. + let maybe_match = tokio::task::spawn_blocking(move || { + child_enum::matching_cli_under(shell_pid) + }) + .await + .unwrap_or_else(|join_err| { + tracing::warn!( + target: "pid_pane_scanner", + error = %join_err, + shell_pid, + "matching_cli_under spawn_blocking failed (likely runtime shutdown)", + ); + None + }); + + if let Some((cli_source, cli_pid)) = maybe_match { + bindings.push(PaneCliBinding { pane_guid, cli_source, cli_pid }); + } + } + Ok(bindings) +} + +#[cfg(windows)] +mod child_enum { + //! Win32 child-process enumeration. BFS from a root PID, depth ≤ 3, + //! looking for an exe basename that matches the Phase A allow-list. + //! + //! Depth limit rationale: + //! * Direct child of the shell (`pwsh -> copilot`): depth 1 + //! * `pwsh -> cmd -> copilot.exe` (user wrapper): depth 2 + //! * `pwsh -> npm.cmd -> node.exe -> copilot.exe` (theoretical): depth 3 + //! + //! Going deeper risks crawling the entire Toolhelp32 snapshot for + //! long-running shells that have spawned dozens of child trees. + + use std::collections::{HashMap, HashSet, VecDeque}; + + use windows_sys::Win32::Foundation::CloseHandle; + use windows_sys::Win32::System::Diagnostics::ToolHelp::{ + CreateToolhelp32Snapshot, Process32FirstW, Process32NextW, + PROCESSENTRY32W, TH32CS_SNAPPROCESS, + }; + + use crate::agent_sessions::CliSource; + use super::classify_exe; + + const MAX_DEPTH: u32 = 3; + + /// Walk the Toolhelp32 process snapshot starting at `root_pid`. + /// Returns the first descendant whose exe matches the CLI + /// allow-list, paired with that descendant's PID. + pub fn matching_cli_under(root_pid: u32) -> Option<(CliSource, u32)> { + // Snapshot the global process list once. Toolhelp32 is a + // copy-on-snapshot kernel API so this is consistent for + // the duration of our walk. + let snap = Snapshot::new()?; + let entries = snap.collect(); + + // Group children by parent PID for O(1) BFS expansion. + let mut children_of: HashMap> = HashMap::new(); + for e in &entries { + children_of.entry(e.parent_pid).or_default().push(e); + } + + let mut queue: VecDeque<(u32, u32)> = VecDeque::new(); // (pid, depth) + let mut visited: HashSet = HashSet::new(); + queue.push_back((root_pid, 0)); + visited.insert(root_pid); + + while let Some((pid, depth)) = queue.pop_front() { + if depth >= MAX_DEPTH { + continue; + } + let Some(kids) = children_of.get(&pid) else { continue; }; + for child in kids { + if !visited.insert(child.pid) { + continue; + } + if let Some(cli) = classify_exe(&child.exe) { + return Some((cli, child.pid)); + } + queue.push_back((child.pid, depth + 1)); + } + } + None + } + + /// Owned exe-name + pid + parent-pid triple, decoupled from the + /// raw PROCESSENTRY32W struct so the snapshot handle can close. + struct Entry { + pid: u32, + parent_pid: u32, + exe: String, + } + + /// RAII handle for a Toolhelp32 snapshot. Closing the handle in + /// `Drop` keeps the cleanup path obvious even on early returns. + struct Snapshot(windows_sys::Win32::Foundation::HANDLE); + + impl Snapshot { + fn new() -> Option { + // SAFETY: `CreateToolhelp32Snapshot` returns either a valid + // kernel object handle or INVALID_HANDLE_VALUE (-1 as + // isize). We check both. + let handle = unsafe { CreateToolhelp32Snapshot(TH32CS_SNAPPROCESS, 0) }; + if handle.is_null() || handle as isize == -1 { + tracing::debug!( + target: "pid_pane_scanner", + "CreateToolhelp32Snapshot failed (err={})", + std::io::Error::last_os_error(), + ); + return None; + } + Some(Self(handle)) + } + + fn collect(&self) -> Vec { + let mut out = Vec::new(); + // PROCESSENTRY32W requires the caller to set dwSize before + // calling Process32FirstW — otherwise the API rejects the + // struct as invalid and returns false. + let mut pe: PROCESSENTRY32W = unsafe { std::mem::zeroed() }; + pe.dwSize = std::mem::size_of::() as u32; + + // SAFETY: handle is valid by construction (checked in + // `new`); pe is a properly sized zeroed struct with the + // required size field set. + if unsafe { Process32FirstW(self.0, &mut pe) } == 0 { + return out; + } + loop { + let exe = utf16_until_nul(&pe.szExeFile); + out.push(Entry { + pid: pe.th32ProcessID, + parent_pid: pe.th32ParentProcessID, + exe, + }); + if unsafe { Process32NextW(self.0, &mut pe) } == 0 { + break; + } + } + out + } + } + + impl Drop for Snapshot { + fn drop(&mut self) { + // SAFETY: handle was a valid kernel object on construction + // and we close it exactly once. + unsafe { CloseHandle(self.0) }; + } + } + + /// Decode a NUL-terminated UTF-16 buffer into a Rust `String`. + /// PROCESSENTRY32W::szExeFile is a fixed-size MAX_PATH array + /// padded with zeros, so we stop at the first zero codepoint. + fn utf16_until_nul(buf: &[u16]) -> String { + let end = buf.iter().position(|&c| c == 0).unwrap_or(buf.len()); + String::from_utf16_lossy(&buf[..end]) + } +} + +#[cfg(not(windows))] +mod child_enum { + //! Non-Windows stub. The scanner has no useful behavior off + //! Windows (WT itself is Windows-only) but compiling the rest of + //! the module on Linux makes `cargo check --target …` workflows + //! and CI matrices simpler. + + use crate::agent_sessions::CliSource; + + pub fn matching_cli_under(_root_pid: u32) -> Option<(CliSource, u32)> { + None + } +} + +#[cfg(test)] +mod tests { + //! Unit tests cover the pure `diff_snapshots` and `classify_exe` + //! functions. The async `scan_tab` and Win32 `child_enum` are + //! exercised by the integration smoke test described in the spec + //! (manual: run `copilot` in a hookless pwsh pane, watch the + //! session management view). + + use super::*; + use std::collections::HashMap; + + fn b(pane: &str, cli: CliSource, pid: u32) -> PaneCliBinding { + PaneCliBinding { pane_guid: pane.to_string(), cli_source: cli, cli_pid: pid } + } + + #[test] + fn diff_snapshots_empty_to_empty_yields_no_events() { + let last = HashMap::new(); + let (events, new) = diff_snapshots(&last, &[]); + assert!(events.is_empty()); + assert!(new.is_empty()); + } + + #[test] + fn diff_snapshots_emits_detected_for_new_panes() { + let last = HashMap::new(); + let current = vec![b("p1", CliSource::Copilot, 100)]; + let (events, new) = diff_snapshots(&last, ¤t); + assert_eq!(events.len(), 1); + match &events[0] { + SessionEvent::PidScannerDetected { pane_guid, cli_source, cli_pid } => { + assert_eq!(pane_guid, "p1"); + assert_eq!(*cli_source, CliSource::Copilot); + assert_eq!(*cli_pid, 100); + } + other => panic!("expected Detected, got {other:?}"), + } + assert_eq!(new.get("p1"), Some(&(CliSource::Copilot, 100))); + } + + #[test] + fn diff_snapshots_emits_lost_for_vanished_panes() { + let mut last = HashMap::new(); + last.insert("p1".to_string(), (CliSource::Claude, 9)); + let (events, new) = diff_snapshots(&last, &[]); + assert_eq!(events.len(), 1); + assert!(matches!(&events[0], SessionEvent::PidScannerLost { pane_guid } if pane_guid == "p1")); + assert!(new.is_empty(), "vanished pane must drop from carry-over state"); + } + + #[test] + fn diff_snapshots_no_change_emits_nothing() { + let mut last = HashMap::new(); + last.insert("p1".to_string(), (CliSource::Gemini, 42)); + let current = vec![b("p1", CliSource::Gemini, 42)]; + let (events, new) = diff_snapshots(&last, ¤t); + assert!(events.is_empty(), "identical snapshot must be idle"); + assert_eq!(new.get("p1"), Some(&(CliSource::Gemini, 42))); + } + + #[test] + fn diff_snapshots_pid_change_emits_lost_then_detected() { + let mut last = HashMap::new(); + last.insert("p1".to_string(), (CliSource::Copilot, 100)); + let current = vec![b("p1", CliSource::Copilot, 200)]; + let (events, new) = diff_snapshots(&last, ¤t); + assert_eq!(events.len(), 2, "rebind = Lost + Detected"); + assert!(matches!(events[0], SessionEvent::PidScannerLost { .. })); + assert!(matches!(events[1], SessionEvent::PidScannerDetected { cli_pid: 200, .. })); + assert_eq!(new.get("p1"), Some(&(CliSource::Copilot, 200))); + } + + #[test] + fn diff_snapshots_cli_change_emits_lost_then_detected() { + // Edge case: same pane, same PID, but the CLI binary reported + // a different identity. In practice this can happen if the + // user kills `copilot` and starts `claude` so fast that the + // PID is reused. The reducer's "update in place" path also + // handles this, but the differ must still surface the change. + let mut last = HashMap::new(); + last.insert("p1".to_string(), (CliSource::Copilot, 100)); + let current = vec![b("p1", CliSource::Claude, 100)]; + let (events, _new) = diff_snapshots(&last, ¤t); + assert_eq!(events.len(), 2); + assert!(matches!(events[0], SessionEvent::PidScannerLost { .. })); + assert!(matches!(&events[1], + SessionEvent::PidScannerDetected { cli_source, cli_pid: 100, .. } + if *cli_source == CliSource::Claude + )); + } + + #[test] + fn diff_snapshots_handles_multiple_panes_mixed_change() { + let mut last = HashMap::new(); + last.insert("alive".to_string(), (CliSource::Copilot, 10)); + last.insert("gone".to_string(), (CliSource::Claude, 20)); + last.insert("rebind".to_string(), (CliSource::Gemini, 30)); + let current = vec![ + b("alive", CliSource::Copilot, 10), // unchanged + b("rebind", CliSource::Gemini, 31), // pid bump + b("new", CliSource::Claude, 40), // appearance + ]; + let (events, new) = diff_snapshots(&last, ¤t); + + let lost: Vec<&str> = events.iter().filter_map(|e| match e { + SessionEvent::PidScannerLost { pane_guid } => Some(pane_guid.as_str()), + _ => None, + }).collect(); + let detected: Vec<(&str, u32)> = events.iter().filter_map(|e| match e { + SessionEvent::PidScannerDetected { pane_guid, cli_pid, .. } => + Some((pane_guid.as_str(), *cli_pid)), + _ => None, + }).collect(); + + assert!(lost.contains(&"gone")); + assert!(lost.contains(&"rebind")); + assert!(detected.contains(&("rebind", 31))); + assert!(detected.contains(&("new", 40))); + assert!(!lost.contains(&"alive")); + assert!(!detected.iter().any(|(p, _)| *p == "alive")); + + assert_eq!(new.len(), 3); + assert_eq!(new.get("alive"), Some(&(CliSource::Copilot, 10))); + assert_eq!(new.get("rebind"), Some(&(CliSource::Gemini, 31))); + assert_eq!(new.get("new"), Some(&(CliSource::Claude, 40))); + } + + #[test] + fn classify_exe_matches_phase_a_allow_list() { + assert_eq!(classify_exe("copilot.exe"), Some(CliSource::Copilot)); + assert_eq!(classify_exe("COPILOT.EXE"), Some(CliSource::Copilot)); + assert_eq!(classify_exe("copilot-cli.exe"), Some(CliSource::Copilot)); + assert_eq!(classify_exe("claude.exe"), Some(CliSource::Claude)); + assert_eq!(classify_exe("Claude.Exe"), Some(CliSource::Claude)); + assert_eq!(classify_exe("gemini.exe"), Some(CliSource::Gemini)); + } + + #[test] + fn classify_exe_rejects_node_and_unrelated_exes() { + assert_eq!(classify_exe("node.exe"), None); + assert_eq!(classify_exe("npm.cmd"), None); + assert_eq!(classify_exe("npx.cmd"), None); + assert_eq!(classify_exe("pwsh.exe"), None); + assert_eq!(classify_exe("cmd.exe"), None); + assert_eq!(classify_exe("copilot.dll"), None); + assert_eq!(classify_exe(""), None); + } +} diff --git a/tools/wta/src/session_registry.rs b/tools/wta/src/session_registry.rs index 03cb350eb..23a2d44cd 100644 --- a/tools/wta/src/session_registry.rs +++ b/tools/wta/src/session_registry.rs @@ -573,6 +573,17 @@ impl From<&crate::agent_sessions::SessionEvent> for SessionHookParams { key: key.clone(), pane_session_id: pane_session_id.clone(), }, + // PidScanner* events are helper-local and must never be + // forwarded to master via session_hook. The PID-fallback + // scanner emits them directly into the helper's local + // `agent_sessions.apply(...)` and stops there. Reaching this + // arm indicates a programming error in a future change. + SessionEvent::PidScannerDetected { .. } | SessionEvent::PidScannerLost { .. } => { + unreachable!( + "PidScanner* events must not be forwarded via session_hook \ + (they are helper-local synthetic-row events)" + ); + } } } } @@ -1011,6 +1022,22 @@ fn apply_event_locked(state: &mut RegistryState, ev: SessionEvent) -> bool { other => other, }; + // PidScanner* events are helper-local synthetic-row signals and have + // no representation in the master-side registry. Drop them here + // before the main match so the reducer stays focused on canonical + // hook + Class-A events. + if matches!( + &ev, + SessionEvent::PidScannerDetected { .. } | SessionEvent::PidScannerLost { .. } + ) { + tracing::trace!( + target: "session_registry", + event = ?ev, + "ignoring PidScanner* event in master-side registry (helper-local)" + ); + return false; + } + match ev { SessionEvent::SessionStarted { key, cli_source, pane_session_id, cwd, title } => { let sid = acp::SessionId::new(key.clone()); @@ -1221,6 +1248,13 @@ fn apply_event_locked(state: &mut RegistryState, ev: SessionEvent) -> bool { state.active_by_pane.insert(pane_session_id, sid); true } + // PidScanner* are dropped by the early-return guard above this + // match. This arm exists solely to keep the match exhaustive + // and to make a programming-error path loud rather than silent. + SessionEvent::PidScannerDetected { .. } | SessionEvent::PidScannerLost { .. } => { + debug_assert!(false, "PidScanner* event should have been filtered out earlier"); + false + } } } @@ -2294,6 +2328,8 @@ mod tests { attention_reason: None, log_path: None, origin: SessionOrigin::AgentPane, + synthetic: false, + synthetic_cli_pid: None, }; let info = agent_session_to_session_info(&s); assert_eq!(info.session_id.0.as_ref(), "hist-sid"); @@ -2325,6 +2361,8 @@ mod tests { attention_reason: None, log_path: None, origin: SessionOrigin::Unknown, + synthetic: false, + synthetic_cli_pid: None, }; let info = agent_session_to_session_info(&s); assert_eq!(info.title, None, "empty title should map to None, not Some(\"\")"); diff --git a/tools/wta/src/ui/agents_view.rs b/tools/wta/src/ui/agents_view.rs index 231e9e7f5..f5aeb4b1c 100644 --- a/tools/wta/src/ui/agents_view.rs +++ b/tools/wta/src/ui/agents_view.rs @@ -883,6 +883,8 @@ mod tests { attention_reason: None, log_path: None, origin: SessionOrigin::default(), + synthetic: false, + synthetic_cli_pid: None, }; assert_eq!(cli_suffix_for(&s, true), "· codex"); assert_eq!(cli_suffix_for(&s, false), String::new());