Skip to content

feat(wta): PID-fallback pane scanner for hookless agent CLI panes#240

Open
DDKinger wants to merge 1 commit into
mainfrom
dev/yuazha/pid-fallback-v2
Open

feat(wta): PID-fallback pane scanner for hookless agent CLI panes#240
DDKinger wants to merge 1 commit into
mainfrom
dev/yuazha/pid-fallback-v2

Conversation

@DDKinger

@DDKinger DDKinger commented Jun 5, 2026

Copy link
Copy Markdown
Contributor

What

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.exe / copilot-cli.exe / claude.exe / gemini.exe). Matches produce synthetic pane:<guid> rows in the session management view labelled (detected) <cli> 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 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 WTA_PID_SCAN=0 env var disables the scanner entirely.

Reducer invariants (helper-local)

  • Synthetic rows are REMOVED on PidScannerLost (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 — scanner is the fallback, never the authority.
  • 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 in pid_pane_scanner.rs + agent_sessions.rs covering: diff_snapshots (empty, new, lost, no-change, PID change, CLI change, multi-pane mixed), classify_exe (allow-list + node/npm rejection), reducer arms (creation, deletion, no-op-on-real, in-place update, GUID lowercasing).
  • All 147 existing agent_sessions / session_registry tests continue to pass.

Phase B (out of scope here)

Master-side aggregation via intellterm.wta/session_added-style ext notification so the session management view in any tab/window sees the same set of PID-detected sessions. Plus Win32_System_ProcessSnapshotting / cmdline inspection to identify npx-wrapped CLIs.

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:<guid>` rows in the session management view labelled `(detected) <cli>` 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>
Copilot AI review requested due to automatic review settings June 5, 2026 08:35
Comment thread AGENTS.md
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
/// 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):
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
Comment thread tools/wta/src/app.rs
//
// 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
//! * `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
exe: String,
}

/// RAII handle for a Toolhelp32 snapshot. Closing the handle in
// 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) };

fn collect(&self) -> Vec<Entry> {
let mut out = Vec::new();
// PROCESSENTRY32W requires the caller to set dwSize before
// 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() };
// 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::<PROCESSENTRY32W>() as u32;
@github-actions

github-actions Bot commented Jun 5, 2026

Copy link
Copy Markdown

@check-spelling-bot Report

⚠️ Dictionary not found

Problems were encountered retrieving check dictionaries (cspell:public-licenses/src/additional-licenses.txt cspell:golang/dict/go.txt cspell:cpp/src/compiler-clang-attributes.txt cspell:cpp/src/stdlib-cpp.txt cspell:fullstack/dict/fullstack.txt cspell:ruby/dict/ruby.txt cspell:elixir/dict/elixir.txt cspell:typescript/dict/typescript.txt cspell:cpp/src/people.txt cspell:sql/src/sql.txt cspell:scala/dict/scala.txt cspell:java/src/java.txt cspell:cpp/src/stdlib-c.txt cspell:latex/dict/latex.txt cspell:python/src/common/extra.txt cspell:cpp/src/compiler-gcc.txt cspell:swift/src/swift.txt cspell:cpp/src/lang-keywords.txt cspell:ada/dict/ada.txt cspell:dotnet/dict/dotnet.txt cspell:python/src/python/python.txt cspell:css/dict/css.txt cspell:public-licenses/src/generated/public-licenses.txt cspell:cpp/src/lang-jargon.txt cspell:cpp/src/template-strings.txt cspell:cpp/src/compiler-msvc.txt cspell:haskell/dict/haskell.txt cspell:python/src/python/python-lib.txt cspell:redis/dict/redis.txt cspell:shell/dict/shell-all-words.txt cspell:clojure/src/clojure.txt cspell:php/dict/php.txt cspell:sql/src/tsql.txt cspell:dart/src/dart.txt cspell:npm/dict/npm.txt cspell:docker/src/docker-words.txt cspell:lua/dict/lua.txt cspell:rust/dict/rust.txt cspell:gaming-terms/dict/gaming-terms.txt cspell:k8s/dict/k8s.txt cspell:cpp/src/ecosystem.txt cspell:cpp/src/stdlib-cerrno.txt cspell:node/dict/node.txt cspell:software-terms/dict/webServices.txt cspell:monkeyc/src/monkeyc_keywords.txt cspell:svelte/dict/svelte.txt cspell:python/src/additional_words.txt cspell:django/dict/django.txt cspell:powershell/dict/powershell.txt cspell:cpp/src/stdlib-cmath.txt cspell:html/dict/html.txt cspell:r/src/r.txt cspell:software-terms/dict/softwareTerms.txt cspell:java/src/java-terms.txt).

⚠️ For more information, see check-dictionary-not-found.

🔴 Please review

See the 📂 files view, the 📜action log, 👼 SARIF report, or 📝 job summary for details.

Unrecognized words (5)

basenames
PROCESSENTRY
SNAPPROCESS
synthesised
Toolhelp

These words are not needed and should be removed Backgrounder CANTCALLOUT Ccc cplusplus ctl Debian dotnet drv endptr EOFs evt Fullwidth gitlab hdr idl IME inbox ININPUTSYNCCALL intelligentterminal Ioctl KVM lbl lld lsb NODEFAULT NONINFRINGEMENT notif oss outdir Podcast pri prioritization rcv segfault SND sourced SWP Tbl testname transitioning unk unparseable unregisters Virt VMs webpage websites WINVER xsi

To accept these unrecognized words as correct and remove the previously acknowledged and now absent words, you could run the following commands

... in a clone of the git@github.com:microsoft/intelligent-terminal.git repository
on the dev/yuazha/pid-fallback-v2 branch (ℹ️ how do I use this?):

curl -s -S -L 'https://raw.githubusercontent.com/check-spelling/check-spelling/cfb6f7e75bbfc89c71eaa30366d0c166f1bd9c8c/apply.pl' |
perl - 'https://github.com/microsoft/intelligent-terminal/actions/runs/27004613511/attempts/1' &&
git commit -m 'Update check-spelling metadata'
Available 📚 dictionaries could cover words (expected and unrecognized) not in the 📘 dictionary

This includes both expected items (2064) from .github/actions/spelling/expect/alphabet.txt .github/actions/spelling/expect/expect.txt .github/actions/spelling/expect/web.txt and unrecognized words (5)

Dictionary Entries Covers Uniquely
cspell:csharp/csharp.txt 32 2 2
cspell:aws/aws.txt 232 2 2
cspell:fonts/fonts.txt 536 1 1

Consider adding to the extra_dictionaries array (in the .github/actions/spelling/config.json file):

    "cspell:csharp/csharp.txt",
    "cspell:aws/aws.txt",
    "cspell:fonts/fonts.txt",

To stop checking additional dictionaries, put (in the .github/actions/spelling/config.json file):

"check_extra_dictionaries": []

Forbidden patterns 🙅 (1)

In order to address this, you could change the content to not match the forbidden patterns (comments before forbidden patterns may help explain why they're forbidden), add patterns for acceptable instances, or adjust the forbidden patterns themselves.

These forbidden patterns matched content:

Should probably be Otherwise,
(?<=\. )Otherwise\s
Errors and Warnings ❌ (2)

See the 📂 files view, the 📜action log, 👼 SARIF report, or 📝 job summary for details.

❌ Errors and Warnings Count
⚠️ check-dictionary-not-found 54
❌ forbidden-pattern 2

See ❌ Event descriptions for more information.

✏️ Contributor please read this

By default the command suggestion will generate a file named based on your commit. That's generally ok as long as you add the file to your commit. Someone can reorganize it later.

If the listed items are:

  • ... misspelled, then please correct them instead of using the command.
  • ... names, please add them to .github/actions/spelling/allow/names.txt.
  • ... APIs, you can add them to a file in .github/actions/spelling/allow/.
  • ... just things you're using, please add them to an appropriate file in .github/actions/spelling/expect/.
  • ... tokens you only need in one place and shouldn't generally be used, you can add an item in an appropriate file in .github/actions/spelling/patterns/.

See the README.md in each directory for more information.

🔬 You can test your commits without appending to a PR by creating a new branch with that extra change and pushing it to your fork. The check-spelling action will run in response to your push -- it doesn't require an open pull request. By using such a branch, you can limit the number of typos your peers see you make. 😉

If the flagged items are 🤯 false positives

If items relate to a ...

  • binary file (or some other file you wouldn't want to check at all).

    Please add a file path to the excludes.txt file matching the containing file.

    File paths are Perl 5 Regular Expressions - you can test yours before committing to verify it will match your files.

    ^ refers to the file's path from the root of the repository, so ^README\.md$ would exclude README.md (on whichever branch you're using).

  • well-formed pattern.

    If you can write a pattern that would match it,
    try adding it to the patterns.txt file.

    Patterns are Perl 5 Regular Expressions - you can test yours before committing to verify it will match your lines.

    Note that patterns can't match multiline strings.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR adds a helper-local “Class C” PID-based fallback scanner to surface hookless agent CLI panes (copilot/claude/gemini) in the WTA session management view by periodically walking each pane’s child process tree and creating/removing synthetic pane:<guid> rows.

Changes:

  • Introduces a new pid_pane_scanner module (Toolhelp32 BFS depth ≤ 3 + allow-list exe matching) and associated diffing/tests.
  • Extends session reducer/state to support PID-scanner synthetic rows (synthetic, synthetic_cli_pid, PidScanner* events) and ensures hook-driven sessions replace synthetic predecessors cleanly.
  • Wires periodic scan ticks into the helper App event loop and explicitly blocks PidScanner* events from master-side registry/hook forwarding.

Reviewed changes

Copilot reviewed 10 out of 10 changed files in this pull request and generated 5 comments.

Show a summary per file
File Description
tools/wta/src/pid_pane_scanner.rs New PID-based scanner implementation + snapshot diffing + unit tests
tools/wta/src/app.rs Adds scan tick/result events and two-tick confirmation gate state + reducer dispatch
tools/wta/src/agent_sessions.rs Adds synthetic-row fields + PidScanner* events + reducer behavior
tools/wta/src/main.rs Spawns the periodic scan tick task (gated by env/identity)
tools/wta/src/session_registry.rs Drops PidScanner* in master-side registry and guards hook forwarding
tools/wta/src/master/mod.rs Treats PidScanner* as keyless (should not refresh from disk)
tools/wta/src/history_loader.rs Initializes new AgentSession fields for historical rows
tools/wta/src/ui/agents_view.rs Updates tests for new AgentSession fields
tools/wta/Cargo.toml Enables Win32_System_Diagnostics_ToolHelp for Toolhelp32 APIs
AGENTS.md Documents the new Class C PID-fallback behavior and limitations

Comment on lines +320 to +334
fn new() -> Option<Self> {
// 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))
}
Comment thread tools/wta/src/main.rs
Comment on lines +2737 to +2740
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();
Comment on lines +55 to +58
//! * **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.
Comment on lines +321 to +324
/// 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
Comment on lines +188 to +223
pub async fn scan_tab(
shell_mgr: &ShellManager,
tab_id: &str,
skip_pane_if: impl Fn(&str) -> bool,
) -> anyhow::Result<Vec<PaneCliBinding>> {
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)
})
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants