Per-tab AI agents via a multi-agent wta-master (id-based, GPO-aware)#296
Open
frarteaga wants to merge 17 commits into
Open
Per-tab AI agents via a multi-agent wta-master (id-based, GPO-aware)#296frarteaga wants to merge 17 commits into
frarteaga wants to merge 17 commits into
Conversation
wta-master previously spawned exactly one agent CLI at startup and multiplexed every helper onto it, so all tabs/windows shared a single agent. Rework it into a pool keyed by the agent command line: - Helpers declare their agent (agent_cmd/agent_id) in the `initialize` handshake via `_meta.wta` (WtaMeta gains agent_cmd/agent_id). - The master lazily spawns/reuses one agent CLI per distinct command line (`get_or_spawn_agent` + per-key `OnceCell`), each with its own cached initialize response and resolved `cli_source`. - HelperHandler resolves its agent during `initialize` and forwards all subsequent requests to that CLI; session routing is unchanged (keyed by SessionId, unique per agent in practice). - A dead agent CLI is reaped from the pool without killing the master, so sibling tabs on other agents keep working. `--agent`/`--agent-id` remain as the fallback default for helpers that don't declare one. Fixes the per-session cli_source stamping (each row now reflects its real CLI) as a side effect. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Let each tab run its own agent (e.g. Gemini in one tab, Claude in another) on top of the multi-agent master: - Tab gains a runtime-only agent override (agent id + model + custom command); empty id means "follow the global default". - Agent-pane spawn resolves the effective agent per tab (`_ResolveAgentCliPathForId` / override-or-global) and passes the tab's agent to its helper, which declares it to the master. - Clicking the agent-bar chip opens a flyout of GPO-allowed agents (`AgentSwitchRequested` event); TerminalPage records the per-tab override and rebuilds just that tab's pane (`_RebuildAgentPaneForTab`, no master restart). - `_RebuildAgentStack` (global-agent change) now skips tabs with an override and no longer restarts the shared master — fresh helpers declare the new agent and the multi-agent master spawns/reuses it. - `OnAgentStatusChanged` no longer persists a tab's agent back to the global default when that tab has an override. Runtime-only by design: new tabs start on the global default. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Contributor
There was a problem hiding this comment.
Pull request overview
Note
Copilot was unable to run its full agentic suite in this review.
Adds multi-agent support for Windows Terminal Agent (WTA) by letting each tab declare its desired agent during ACP initialize, enabling the master to lazily spawn/reuse one agent CLI per distinct command line and providing a per-tab agent switch UI.
Changes:
- Extend
_meta.wtato carry per-tabagent_cmdandagent_id, and round-trip them through helper/master. - Replace single cached agent connection in the master with a per-command agent-CLI pool (lazy spawn + reuse).
- Add per-tab agent override + UI flyout to switch agents without restarting the shared master.
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/session_registry.rs | Adds agent_cmd/agent_id to WTA meta and round-trip test coverage. |
| tools/wta/src/protocol/acp/client.rs | Helper forwards per-tab agent identity in ACP initialize meta. |
| tools/wta/src/master/mod.rs | Master becomes a multi-agent broker with a keyed agent-CLI pool and per-helper binding. |
| tools/wta/src/main.rs | Plumbs helper CLI’s agent identity into the pipe client startup. |
| src/cascadia/TerminalApp/TerminalPage.h | Declares _RebuildAgentPaneForTab for scoped per-tab rebuilds. |
| src/cascadia/TerminalApp/TerminalPage.cpp | Implements per-tab agent selection, rebuild behavior, and avoids persisting overrides globally. |
| src/cascadia/TerminalApp/Tab.h | Adds runtime-only per-tab agent override state and accessors. |
| src/cascadia/TerminalApp/AgentPaneContent.idl | Adds AgentSwitchRequested event to request per-tab agent switching. |
| src/cascadia/TerminalApp/AgentPaneContent.h | Declares event + tap handler for agent picker flyout. |
| src/cascadia/TerminalApp/AgentPaneContent.cpp | Implements agent picker flyout and wires it to the agent-bar chip. |
Author
@microsoft-github-policy-service agree |
Security finding on PR microsoft#296: the multi-agent master spawned whatever `agent_cmd` string a helper sent over the named pipe, making it an arbitrary-process-spawn surface for any same-user process. - Helpers now declare identity (agent_id + model) in `_meta.wta`, not a command line. `WtaMeta` gains `model`; `agent_cmd` stays on the wire for diagnostics but is never executed. - New `resolve_agent_selection` rebuilds the command from the id via `build_acp_command`, only for KNOWN ids that pass the host's GPO-filtered allowlist (`--allowed-agent-ids`); anything else (unknown, custom, blocked, empty) falls back to the trusted `--agent` default. The agent-CLI pool is keyed by the reconstructed command, never by pipe input. - Defense in depth: the master pipe is created with a tightened SDDL (protected DACL = SYSTEM + current user, medium-IL no-write-up label) plus reject_remote_clients, at both create sites; falls back to the default ACL if the descriptor can't be built. Also fixes a reaper leak: the child/IO reapers were spawned before `initialize`, so an init failure leaked the agent subprocess + I/O task and stranded the pool slot (repeated respawns). The child is now owned across init and killed on failure; the I/O reaper (which must drive init) runs first and reaps the slot. The I/O loop result is logged (clean vs error) instead of discarded. Adds unit tests for the selection policy (id reconstruction, GPO allowlist, fallback, "agent_cmd is never executed", pool-key dedupe) and the WtaMeta model round-trip. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…agent chip - `_BuildSharedWtaExtraArgs` now emits `--allowed-agent-ids` (the GPO-filtered `FilteredAcpAgents()` set) so the multi-agent master can validate per-tab agent selections declared over the pipe. Pairs with the master-side agent_id-only resolution that no longer trusts a command string off the pipe. - The agent-bar chip is now a focusable Button instead of a tapped Border, so the per-tab agent picker opens on keyboard (Enter/Space) and pointer activation alike and is reachable via Tab; the flyout build moves into `_showAgentPicker()`. Adds a localized automation name (AgentPane_SwitchAgentAutomationName) for screen readers. Addresses Copilot review feedback on PR microsoft#296. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This comment has been minimized.
This comment has been minimized.
The check-spelling bot flagged four tokens introduced by this PR: - `GPO` — a real recurring acronym (Group Policy Object); added to the check-spelling expect list. The per-tab agent feature uses it in a test name and many comments, so it can't be reworded away. - `allowset` → renamed the test helper to `allow_set` (idiomatic snake_case), avoiding a one-off junk dictionary entry. - `chokepoint` → reworded to "choke point". - `chromeless` → reworded the XAML comment to "has no chrome". No behavior change; the 41 wta master tests still pass. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Contributor
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 14 out of 14 changed files in this pull request and generated 5 comments.
Comments suppressed due to low confidence (1)
src/cascadia/TerminalApp/TerminalPage.cpp:1
_ResolveAgentCliPathForIdis documented to return empty to signal “fall back”, but the caller currently does not implement a fallback when policy is not configured. In that case, a tab override that yields an empty command line (blocked/invalid/no usable command) will proceed withagentCliPathempty, which can propagate an empty--agentvalue into helper launch. Fix by explicitly falling back to the global/default resolution whentab->HasAgentOverride()butagentCliPath.empty().
This comment has been minimized.
This comment has been minimized.
Reconciles ~94 commits of main's WTA evolution (since 2026-05-29) with the per-tab agent feature. All conflicts were in the 4 wta files; resolved by keeping the multi-agent broker + security work as the base and folding in main's platform improvements: - session_registry.rs / client.rs: WtaMeta carries BOTH per-tab identity (agent_id + model) AND main's owner_tab_id (microsoft#266 pane<->session binding); helper initialize keeps the _meta.wta inject and adopts main's ACP telemetry (microsoft#272). - master/mod.rs: kept the lazy multi-agent broker (agents pool, agent_id-only resolution + GPO allowlist + pipe SDDL) and folded in main's helper_meta crash-recovery + restart_agent_pane (microsoft#145/microsoft#266), session/new timeout-to- master + telemetry (microsoft#268/microsoft#272), WT connection_state->PaneClosed bridging (microsoft#208), and stderr-at-debug. Dropped main's single-agent eager spawn / shutdown channel / cached_init_resp (superseded by the per-agent reaper). - Cargo.toml: union of windows-sys features. - app.rs: thread agent_cmd/agent_id through main's pipe-mode reconnect caller. Verified: cargo test 729 pass (incl. the agent_cmd-never-spawned security test + main's recovery tests); TerminalApp C++ builds clean. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This comment has been minimized.
This comment has been minimized.
main advanced 5 more commits while the first merge was being resolved. Only master/mod.rs conflicted: main's microsoft#288 added a hookless Class-B session watcher + a Class-B liveness poll in run_master_loop, right where the multi-agent broker sits — accepted both (independent features) ahead of the WT-event subscriber. Also tracked main's microsoft#257/microsoft#280 Claude ACP adapter migration (@zed-industries/claude-code-acp -> @agentclientprotocol/claude-agent-acp) in the two tests that hard-code the adapter command. Verified: cargo test 824 pass; TerminalApp C++ builds clean. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This comment has been minimized.
This comment has been minimized.
…arison `resolve_agent_selection` decided whether a helper-requested agent id was "known" via `lookup_profile_by_id(id).id != DEFAULT_PROFILE.id`. That conflates the default agent with the unknown-id fallback: the day `DEFAULT_PROFILE.id` becomes a real, selectable agent id, a helper requesting that id would be treated as unknown — forced onto the default command and silently dropping any requested-model folding. Add `agent_registry::is_known_id`, a membership test against KNOWN_AGENTS that is decoupled from DEFAULT_PROFILE, and use it in `resolve_agent_selection`. Apply the same predicate to the preflight custom/unknown check in main.rs, which had the identical latent coupling (`lookup_profile_by_id(id).id == "unknown"`). Tests: unit-test is_known_id membership semantics, plus a master-side regression test asserting every KNOWN_AGENTS id is honored (rebuilt command + own id stamp) rather than falling back. Addresses PR review: microsoft#296 (comment) Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
… logging, docs
- master: collapse an empty / all-blank --allowed-agent-ids to None (not
Some(empty_set)). With clap value_delimiter=',', `--allowed-agent-ids ""`
parses to [""]; treating that as Some({}) made resolve_agent_selection
refuse every id (a silent block-all). Extracted normalize_allowed_agent_ids
+ regression test.
- master: log agent stderr at debug, not warn. Agent stderr routinely
carries prompt/file content and adapter chatter; warn was noisy and an
information-leak in release.
- master: fix HelperHandler.agent doc — the binding is resolved from
_meta.wta.agent_id (+ model), not the no-longer-trusted agent_cmd.
- session_registry: extract_wta_meta drops empty / whitespace-only string
fields to None so empty values round-trip as None (WtaMeta::is_empty stays
correct and no empty _meta.wta is kept alive). Mirrors the helper's
injection-side filter. + regression test.
- client/main/app: drop the unused agent_cmd param from
run_acp_client_over_pipe (per-tab identity travels as agent_id over the
wire); removes the `let _ = &agent_cmd` dead-store suppression.
- TerminalPage: when a per-tab agent override resolves to an empty command
line (unknown/blocked id or empty custom command), fall back to the
global/default agent per _ResolveAgentCliPathForId's contract instead of
launching the helper with no --agent and a stale --agent-id.
- spelling: accept the lowercase "gpo" token (expect.txt).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
`buf` is a Vec<u8> (alignment 1) but TOKEN_USER contains a pointer and needs pointer alignment, so creating a reference into the buffer was UB. Copy the header out with std::ptr::read_unaligned; the Sid pointer still points into `buf`, which outlives the conversion. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
inject_wta_meta now mirrors extract_wta_meta: whitespace-only fields are filtered out and the wta namespace is omitted entirely when no field survives, so we never emit empty-string metadata over the wire. Adds a regression test covering all-blank and partially-blank inputs. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Trim and ASCII-lowercase the agent_id before forwarding, and only pass through ids that agent_registry::is_known_id accepts, matching how the master canonicalizes. Unknown or malformed ids are dropped rather than declared. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
In _AutoCreateHiddenAgentPaneShared, when the effective agent id is empty but a CLI path was resolved, fall back to _DetectAgentCli() so the helper is launched with a concrete --agent-id instead of an empty one. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
_showAgentPicker now skips FilteredAcpAgents entries whose id is empty so the menu never renders a dead item that selects nothing, mirroring the empty-id filtering already done in TerminalPage. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…wlist, docs)
AgentPaneContent: match current agent by id (not display name) so the
checkmark tracks correctly even when two agents share a display name.
Replace MenuFlyoutItem+Accept-icon with RadioMenuFlyoutItem (MUXC) so
screen readers see IsChecked semantics; items are grouped under
GroupName("agents") for mutual-exclusion UIA semantics.
wta/master: normalize_allowed_agent_ids now calls is_known_id to drop
custom / unrecognised ids before building the allowlist — unknown ids
would be silently ignored by the dispatcher anyway, so admitting them
into the set leaks information and wastes the set capacity.
Document the pool eviction policy on the agents field (warm-for-life,
reaped on crash, no idle-timeout).
Build: restore Host.Proxy before Settings Model/Editor, add NuGet
packages.config restore step, set CL_MPCount=1 for RAM-constrained
machines (FRANK11 ~7.4 GB).
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This comment has been minimized.
This comment has been minimized.
Comment on lines
+1997
to
+2009
| fn normalize_allowed_agent_ids(raw: &[String]) -> Option<std::collections::HashSet<String>> { | ||
| let set: std::collections::HashSet<String> = raw | ||
| .iter() | ||
| .map(|s| s.trim().to_ascii_lowercase()) | ||
| .filter(|s| !s.is_empty()) | ||
| .filter(|s| crate::agent_registry::is_known_id(s)) | ||
| .collect(); | ||
| if set.is_empty() { | ||
| None | ||
| } else { | ||
| Some(set) | ||
| } | ||
| } |
Comment on lines
+71
to
+75
| self.pane_session_id.is_none() | ||
| && self.agent_cmd.is_none() | ||
| && self.agent_id.is_none() | ||
| && self.model.is_none() | ||
| && self.owner_tab_id.is_none() |
Comment on lines
+3
to
+6
| set MSBUILD="C:\Program Files\Microsoft Visual Studio\2022\Community\MSBuild\Current\Bin\MSBuild.exe" | ||
| set SOLUTION_DIR=%CD%\ | ||
| set COMMON=/p:Platform=x64 /p:Configuration=Release /p:WindowsTerminalBranding=Dev /p:SolutionDir=%SOLUTION_DIR% /m /nologo | ||
| set CL_MPCount=1 | ||
| set COMMON=/p:Platform=x64 /p:Configuration=Release /p:WindowsTerminalBranding=Dev /p:SolutionDir=%SOLUTION_DIR% /m:1 /nologo |
Comment on lines
+95
to
+107
| for (const auto& a : Reg::FilteredAcpAgents()) | ||
| { | ||
| const std::wstring_view dn{ a.displayName }; | ||
| if (dn.size() == _agentName.size() && | ||
| std::equal(dn.begin(), dn.end(), _agentName.begin(), | ||
| [](wchar_t x, wchar_t y) { | ||
| return std::towlower(x) == std::towlower(y); | ||
| })) | ||
| { | ||
| currentAgentId = winrt::hstring{ a.id }; | ||
| break; | ||
| } | ||
| } |
Comment on lines
+111
to
+112
| for (const auto& agent : Reg::FilteredAcpAgents()) | ||
| { |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary of the Pull Request
Adds per-tab AI agent support: each tab can run its own agent simultaneously (e.g. Gemini in one tab, Claude in another) in the same window. Reworks
wta-masterfrom a single-agent multiplexer into a lazy multi-agent broker, and adds a runtime per-tab agent override inTerminalAppselectable from the agent-bar chip.References and Relevant Issues
Closes #295
Implements the "per-pane different agent" direction described in
doc/specs/Multi-window-agent-pane.md§9.Detailed Description of the Pull Request / Additional comments
Rust —
wta-masterbecomes a lazy multi-agent broker (tools/wta/src/master/mod.rs,session_registry.rs,protocol/acp/client.rs,main.rs):agent_cmd/agent_id) in the ACPinitializehandshake via_meta.wta(WtaMetaextended).get_or_spawn_agent+ per-keyOnceCell), each with its own cached initialize response and resolvedcli_source.HelperHandlerresolves its agent duringinitializeand forwards all later requests to that CLI. Session routing is unchanged (keyed bySessionId).--agent/--agent-idremain the fallback default for helpers that don't declare one.cli_sourceis now stamped per agent (the F2 list shows each row's real CLI instead of one process-wide value).C++ — per-tab override + chip flyout (
src/cascadia/TerminalApp/*):Tabgains a runtime-only agent override (id + model + custom command); empty id = follow the global default._ResolveAgentCliPathForId) and passes it to that tab's helper.AgentSwitchRequested);TerminalPagerecords the override and rebuilds only that tab's pane (_RebuildAgentPaneForTab, no master restart)._RebuildAgentStack(global-agent change) now skips overridden tabs and no longer restarts the shared master;OnAgentStatusChangedno longer persists an overridden tab's agent to the global default.Runtime-only by design: new tabs start on the global default (no settings.json/schema changes).
Validation Steps Performed
cargo build+cargo testfortools/wta— 559 tests pass, including a new_meta.wtaagent-identity round-trip test.OpenConsole.slnx(Debug x64) — 0 errors; newAgentSwitchRequestedsymbol linked intoTerminalApp.dll.agent_cmd=npx -y @zed-industries/claude-code-acp,cli_source=Claude) and gemini (agent_cmd=gemini --experimental-acp,agent_id=gemini)._RebuildAgentStackran without crashing.PR Checklist