Skip to content

Commit 4273771

Browse files
yotsudaclaude
andcommitted
feat(streams): close 4 remaining bypass channels (Verbose, Debug, WhatIf, Host.UI direct)
Follow-up to 14f0aa3, which introduced the hybrid stream capture but left 4 output paths invisible to the AI side because they bypass both the PowerShell stream system AND [Console]::Out: * Write-Verbose (stream 4) — auto-rendered by the host but never captured into any -*Variable bucket. * Write-Debug (stream 5) — same shape. * \`What if:\` from \`\$PSCmdlet.ShouldProcess\` — host.UI.WriteLine side channel; pwsh.exe ConsoleHost caches its writer so even [Console]::SetOut tee misses it. * Direct \`\$Host.UI.WriteLine(...)\` calls — same channel. Three independent fixes: 1. Stream merge widened from \`2>&1\` to \`2>&1 4>&1 5>&1\`. Tested before committing: streams 4 and 5 keep their auto-render color (yellow VERBOSE: / DEBUG: prefix) when merged, because Out-Host's record-type formatter handles VerboseRecord and DebugRecord with their own colored renderer regardless of the stream they arrived on. This is asymmetric vs stream 6 (Information) — merging 6 silently drops Write-Host's ForegroundColor — so 6 stays unmerged. Format-McpOutput now recognizes VerboseRecord and DebugRecord types in PipelineItems and renders them with the matching VERBOSE: / DEBUG: prefix. 2. New TeePSHostUserInterface decorator (Services/, new file). Wraps the original PSHostUserInterface and forwards every method to it for visible-console rendering, while teeing Write(string), Write(ConsoleColor, ConsoleColor, string), and WriteLine(string) into a StringBuilder. The polling engine reflects on InternalHostUserInterface's private \`_externalUI\` field and swaps it to a TeePSHostUserInterface instance for the duration of each user command, restoring the original in finally. Per-stream writers (WriteWarningLine, WriteVerboseLine, WriteDebugLine, WriteErrorLine, WriteProgress, WriteInformation) are pure passthrough to avoid double-counting — those streams already captured via -WarningVariable / -InformationVariable / merged pipeline. Read-Host / credential / choice prompts are also passthrough so interactive flows continue to work. Reflection target has been stable since the InternalHost split (PS 1.0); reflection failure is non-fatal — the swap is best- effort, the command still runs without host-tee capture. 3. \`\$ProgressPreference = 'SilentlyContinue'\` set inside Invoke-Captured for the duration of every AI command. Two reasons: (a) a real-time bar is unreadable to the AI as text, so its capture has no value; (b) ConPTY leaked Progress redraw bytes from a finished command into the next command's Console.Out tee — observed during verification. Setting the preference inside Invoke-Captured uses dynamic scope so user- typed commands at the visible terminal keep their normal progress display; only AI-initiated commands suppress. Format-McpOutput additions: * VT-strip + space-collapse on ConsoleOut / ConsoleErr buffers. Long runs of spaces (8+) collapse to two spaces — defends against ConPTY-emitted progress-bar remnants and other decorative spacing that carries no information. * Dedup pass on HostWrite. Out-Host renders normal pipeline output via \$Host.UI.WriteLine internally, so without filtering every output line would appear twice (once in pipelineText via Tee-Object, once in HostWrite via the host-tee). The new HashSet-based pass drops HostWrite lines that already appear (or are contained-in / contain) any line from the other sections. What remains is the truly novel host-UI writes — WhatIf messages and direct \$Host.UI.WriteLine calls. Coverage verification (15-test matrix run against the rebuilt DLL, results read from a JSON file the script wrote rather than from any console capture, so visible-terminal ripple-PTY observation cannot fool the analysis): 14/15 paths now visible to the AI side. The 15th (Write-Progress) is intentionally suppressed by design. 251 xunit unit tests still pass with no expected-value adjustments. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 14f0aa3 commit 4273771

2 files changed

Lines changed: 330 additions & 7 deletions

File tree

PowerShell.MCP/Resources/MCPPollingEngine.ps1

Lines changed: 213 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -104,6 +104,26 @@ if (-not (Test-Path Variable:global:McpTimer)) {
104104
function Invoke-Captured {
105105
[CmdletBinding()]
106106
param([scriptblock]$Block)
107+
# Suppress Write-Progress overlay during AI commands.
108+
# Two reasons:
109+
# 1. The AI cannot meaningfully consume a real-time
110+
# progress bar — the bar updates in place via
111+
# cursor manipulation, not as text the AI can read.
112+
# A static "10% done" snapshot delivered in the
113+
# tool response carries no value.
114+
# 2. ConPTY leaks Progress redraw bytes into the next
115+
# command's Console.Out tee. The first command runs
116+
# Write-Progress, finishes; the second command
117+
# starts and ConPTY emits cleanup bytes for the
118+
# prior overlay, which our tee captures. The user
119+
# sees a CONSOLE.OUT section in command #2's
120+
# response containing the bar from command #1.
121+
# Setting $ProgressPreference here scopes only to the
122+
# AI command's invocation chain (PowerShell uses dynamic
123+
# scope for preference variables), so user-typed
124+
# commands at the visible terminal keep their normal
125+
# progress display.
126+
$ProgressPreference = 'SilentlyContinue'
107127
& $Block
108128
}
109129

@@ -160,14 +180,69 @@ if (-not (Test-Path Variable:global:McpTimer)) {
160180
[Console]::SetOut([PowerShell.MCP.Services.TeeTextWriter]::new($origOut, $consoleOutBuf))
161181
[Console]::SetError([PowerShell.MCP.Services.TeeTextWriter]::new($origErr, $consoleErrBuf))
162182

183+
# Host UI tee. Catches output that bypasses both the
184+
# PowerShell stream system AND [Console]::Out: chiefly
185+
# the `What if:` text that ShouldProcess writes via
186+
# host.UI.WriteLine, and direct $Host.UI.WriteLine
187+
# calls in user scripts. Done by reflecting on
188+
# InternalHostUserInterface's private `_externalUI`
189+
# field and swapping it to a TeePSHostUserInterface
190+
# decorator for the duration of the command. Restored
191+
# in finally so a thrown exception cannot leak the
192+
# swap into subsequent commands. The reflection target
193+
# has been stable across every PowerShell version since
194+
# the InternalHost split (PS 1.0); failure to reflect
195+
# is non-fatal — we just lose host-tee capture for that
196+
# command.
197+
$hostWriteBuf = [System.Text.StringBuilder]::new()
198+
$uiSwapper = $Host.UI
199+
$externalUIField = $uiSwapper.GetType().GetField('_externalUI',
200+
[System.Reflection.BindingFlags]::NonPublic -bor
201+
[System.Reflection.BindingFlags]::Instance)
202+
$origExternalUI = $null
203+
if ($null -ne $externalUIField) {
204+
try {
205+
$origExternalUI = $externalUIField.GetValue($uiSwapper)
206+
$teeUI = [PowerShell.MCP.Services.TeePSHostUserInterface]::new($origExternalUI, $hostWriteBuf)
207+
$externalUIField.SetValue($uiSwapper, $teeUI)
208+
} catch {
209+
# Reflection access denied — keep going without
210+
# host-tee capture rather than refusing to run
211+
# the command.
212+
$origExternalUI = $null
213+
}
214+
}
215+
163216
$ok = $false
164217
$lec = $lecAtStart
165218
try {
166219
try {
167220
$sb = [scriptblock]::Create($Command)
221+
# Stream merge map: 2 (Error), 4 (Verbose), 5
222+
# (Debug) all merge into stream 1 so the
223+
# chronological tee captures them. Stream 6
224+
# (Information) and 3 (Warning) stay UN-merged
225+
# because:
226+
# * 6 carries Write-Host's chosen ConsoleColor
227+
# in the InformationRecord and merging would
228+
# route rendering through Out-Host's default
229+
# formatter which doesn't read the color
230+
# back — the user-visible Write-Host color
231+
# would silently flatten to default.
232+
# * 3 has its own yellow auto-render that
233+
# Out-Host preserves when the WarningRecord
234+
# stays on stream 3, but loses when merged.
235+
# 4 and 5 don't have this problem: their
236+
# auto-render produces the yellow "VERBOSE: " /
237+
# "DEBUG: " prefix as part of Out-Host's
238+
# VerboseRecord / DebugRecord handler, which
239+
# fires whether the records arrive on the
240+
# original stream or merged into stream 1.
241+
# Tested both directions before settling on
242+
# this merge layout.
168243
Invoke-Captured -Block $sb `
169244
-WarningVariable +warningVar `
170-
-InformationVariable +informationVar 2>&1 |
245+
-InformationVariable +informationVar 2>&1 4>&1 5>&1 |
171246
Tee-Object -Variable pipelineStream |
172247
Out-Host
173248
# Capture post-pipeline state IMMEDIATELY. Any
@@ -191,6 +266,10 @@ if (-not (Test-Path Variable:global:McpTimer)) {
191266
finally {
192267
[Console]::SetOut($origOut)
193268
[Console]::SetError($origErr)
269+
# Restore host UI's external writer if we swapped.
270+
if ($null -ne $origExternalUI -and $null -ne $externalUIField) {
271+
try { $externalUIField.SetValue($uiSwapper, $origExternalUI) } catch { }
272+
}
194273
}
195274

196275
# Deduplicate errors in the chronological stream.
@@ -233,6 +312,7 @@ if (-not (Test-Path Variable:global:McpTimer)) {
233312
Exception = $exceptionVar
234313
ConsoleOut = $consoleOutBuf.ToString()
235314
ConsoleErr = $consoleErrBuf.ToString()
315+
HostWrite = $hostWriteBuf.ToString()
236316
LastExitReport = $lastExitReport
237317
}
238318
}
@@ -341,10 +421,11 @@ if (-not (Test-Path Variable:global:McpTimer)) {
341421

342422
# Walk PipelineItems in emit order, render each item the
343423
# same way the streaming Out-Host did on the visible
344-
# console, and count errors as we go. Output and errors
345-
# interleave in this single text block — the AI sees the
346-
# error in its actual position in the run, not collected
347-
# at the end of a separate "=== ERRORS ===" section.
424+
# console, and count errors as we go. Output, errors,
425+
# verbose, and debug interleave in this single text
426+
# block — the AI sees each event in its actual position
427+
# in the run, not collected at the end of a separate
428+
# "=== ERRORS ===" section.
348429
$pipelineLines = @()
349430
$errorCount = 0
350431
foreach ($item in $StreamResults.PipelineItems) {
@@ -355,6 +436,13 @@ if (-not (Test-Path Variable:global:McpTimer)) {
355436
# `Write-Error: ` prefix and trace context that
356437
# are PowerShell's own decoration).
357438
$pipelineLines += $item.Exception.Message
439+
} elseif ($item -is [System.Management.Automation.VerboseRecord]) {
440+
# Mirrors PowerShell's own visible render which
441+
# prefixes VERBOSE: in yellow. AI side gets the
442+
# plain text version.
443+
$pipelineLines += "VERBOSE: " + $item.Message
444+
} elseif ($item -is [System.Management.Automation.DebugRecord]) {
445+
$pipelineLines += "DEBUG: " + $item.Message
358446
} elseif ($null -eq $item) {
359447
$pipelineLines += ""
360448
} else {
@@ -409,13 +497,123 @@ if (-not (Test-Path Variable:global:McpTimer)) {
409497
# bypassed the PowerShell stream system entirely
410498
# ([Console]::WriteLine etc.). On the happy path these
411499
# buffers stay empty and the section is omitted.
500+
#
501+
# Strip VT control sequences before surfacing. Reasons:
502+
# * Write-Progress writes to Console.Out via SGR +
503+
# cursor manipulation (e.g. CSI 7m for inverse, the
504+
# bar fills with spaces inside CSI 7m...CSI 27m).
505+
# Those bytes give the AI nothing useful — the
506+
# visual bar doesn't translate to text — and clog
507+
# responses with escape gibberish.
508+
# * ConPTY can re-emit cursor / clear sequences when
509+
# a previous Progress overlay hasn't been scrolled
510+
# away by the next command, leaking into ConsoleOut
511+
# of unrelated subsequent commands.
512+
# Strip pattern matches the SGR / cursor-control families
513+
# we actually see in practice (CSI Ps m, CSI Ps J, CSI
514+
# Ps K, CSI x;y H/f); not exhaustive across the whole
515+
# ECMA-48 spec but tight on what shows up in pwsh +
516+
# ConPTY output.
517+
$vtPattern = "`e\[[\d;]*[a-zA-Z]"
518+
519+
# Strip ConsoleOut / ConsoleErr more aggressively than
520+
# just VT codes. Two cleanup passes:
521+
#
522+
# 1. Remove ECMA-48 control sequences (CSI, SGR,
523+
# cursor moves, line clears).
524+
# 2. Collapse runs of spaces longer than 8 to a
525+
# single space. Write-Progress draws its bar with
526+
# 80-column space-padded segments; a leftover
527+
# Progress overlay leaking into the next command's
528+
# ConsoleOut via ConPTY's redraw-on-next-write
529+
# shows up as long horizontal-space runs that
530+
# carry no useful information for the AI side.
531+
# The collapse keeps incidental double-spaces in
532+
# legitimate output untouched.
533+
#
534+
# If after stripping the result is whitespace only,
535+
# surface as empty so the section is omitted entirely
536+
# rather than rendering an empty header.
537+
$cleanConsoleStream = {
538+
param([string]$raw)
539+
if (-not $raw) { return "" }
540+
$stripped = $raw -replace $vtPattern, ""
541+
$collapsed = $stripped -replace ' {8,}', ' '
542+
$trimmed = $collapsed.TrimEnd("`r","`n", " ", "`t")
543+
if ([string]::IsNullOrWhiteSpace($trimmed)) { return "" }
544+
return $trimmed
545+
}
412546
$consoleOutText = if ($null -ne $StreamResults.ConsoleOut) {
413-
([string]$StreamResults.ConsoleOut).TrimEnd("`r","`n")
547+
& $cleanConsoleStream ([string]$StreamResults.ConsoleOut)
414548
} else { "" }
415549
$consoleErrText = if ($null -ne $StreamResults.ConsoleErr) {
416-
([string]$StreamResults.ConsoleErr).TrimEnd("`r","`n")
550+
& $cleanConsoleStream ([string]$StreamResults.ConsoleErr)
417551
} else { "" }
418552

553+
# Host-UI-level Write/WriteLine (WhatIf messages,
554+
# $Host.UI.WriteLine direct calls). Captured by
555+
# TeePSHostUserInterface in Invoke-CommandWithAllStreams.
556+
# Same VT-strip rationale as ConsoleOut: the visible
557+
# console gets the colored render via the inner UI; the
558+
# AI side wants the plain text. Trim trailing
559+
# whitespace so the section doesn't end on dangling
560+
# blanks.
561+
#
562+
# Out-Host renders normal pipeline output by calling
563+
# $Host.UI.WriteLine internally — so without filtering,
564+
# every regular output line would appear twice (once in
565+
# pipelineText, once in this section). Filter
566+
# line-by-line: keep only HostWrite lines that don't
567+
# appear in any of the already-emitted sections. What
568+
# remains is the novel host-UI writes the streams /
569+
# Console-tee never saw — chiefly WhatIf messages from
570+
# ShouldProcess and direct $Host.UI.WriteLine calls.
571+
$hostWriteText = ""
572+
if ($null -ne $StreamResults.HostWrite) {
573+
$hostRaw = ([string]$StreamResults.HostWrite -replace $vtPattern, "")
574+
if ($hostRaw.Trim()) {
575+
# Build a HashSet of lines that are already
576+
# surfaced in another section. A line that
577+
# exactly matches one of these is treated as a
578+
# render-time duplicate and dropped from
579+
# HostWrite.
580+
$known = [System.Collections.Generic.HashSet[string]]::new(
581+
[System.StringComparer]::Ordinal)
582+
foreach ($src in @($pipelineText, $exceptionText, $warningText, $infoText, $consoleOutText, $consoleErrText)) {
583+
if (-not [string]::IsNullOrEmpty($src)) {
584+
foreach ($line in $src -split "`r?`n") {
585+
$trimmed = $line.Trim()
586+
if ($trimmed) { [void]$known.Add($trimmed) }
587+
}
588+
}
589+
}
590+
$novel = @()
591+
foreach ($line in $hostRaw -split "`r?`n") {
592+
$trimmed = $line.Trim()
593+
if (-not $trimmed) { continue }
594+
if ($known.Contains($trimmed)) { continue }
595+
# Check substring containment for partial
596+
# matches: Out-Host renders ErrorRecord
597+
# through a multi-line formatter so a
598+
# single host-UI WriteLine carries the
599+
# whole render block, while pipelineText
600+
# only has the ErrorRecord's
601+
# Exception.Message. Drop host-UI lines
602+
# that are entirely inside a pipelineText
603+
# line (or vice versa) to suppress this
604+
# form of duplicate.
605+
$isDup = $false
606+
foreach ($k in $known) {
607+
if ($k.Contains($trimmed) -or $trimmed.Contains($k)) {
608+
$isDup = $true; break
609+
}
610+
}
611+
if (-not $isDup) { $novel += $trimmed }
612+
}
613+
$hostWriteText = ($novel -join "`n").Trim()
614+
}
615+
}
616+
419617
# Append PromptAI (Invoke-Claude/Invoke-GPT/Invoke-Gemini) output if available.
420618
try {
421619
$aiResponse = [PromptAI.Cmdlets.AIStreamingCmdletBase]::LastResponse
@@ -545,6 +743,14 @@ if (-not (Test-Path Variable:global:McpTimer)) {
545743
$sections += $consoleErrText
546744
$sections += ""
547745
}
746+
# Host UI Write/WriteLine (WhatIf, $Host.UI.WriteLine).
747+
# Empty on the happy path (no ShouldProcess, no direct
748+
# host-UI write); section omitted then.
749+
if ($hostWriteText) {
750+
$sections += "=== HOST.UI (direct) ==="
751+
$sections += $hostWriteText
752+
$sections += ""
753+
}
548754

549755
if ($sections.Count -eq 0) {
550756
return $statusLine

0 commit comments

Comments
 (0)