Skip to content

Commit 61adc7d

Browse files
committed
feat(status-line): surface hidden native non-zero exits as LastExit: N tag
PowerShell treats pipeline success as "$? on the last statement", not "all natives exited 0". A pipeline like `cmd /c exit 7; "after"` ends with $? True (the "after" string succeeded) even though cmd returned 7. PowerShell.MCP previously rendered this as: ✓ Pipeline executed successfully | ... | Duration: 0.20s | Location ... — the 7 was completely silent. An AI driving a CI-style pipeline (`npm install; npm audit; npm test` and so on) would read the ✓, assume clean, and move on while npm audit's non-zero exit carried real information that just got dropped. Port the gating pattern ripple uses for its OSC 633;L extension: 1. Snapshot $global:LASTEXITCODE BEFORE Invoke-Expression runs, so we can later tell whether THIS pipeline wrote the value or inherited it from an earlier native invocation in a prior call. 2. Capture $? and $global:LASTEXITCODE IMMEDIATELY after Invoke-Expression returns — any statement between (including a bare variable assignment) would reset $? to True. 3. Report only when all three gates fire: $? True (pipeline overall succeeded → ✓ badge) lecChanged (native wrote $LASTEXITCODE this pipeline) lec non-zero (the value worth telling the AI about) Any other combination is silent: failed pipelines already show ✗ + Errors count, and a stale $LASTEXITCODE from a prior session would only add noise to current happy-path lines. The new `LastExit: N` segment sits immediately after Duration in the status line — next to the exit-code semantic domain, ahead of Errors/Warnings/Info which describe PS streams (a separate axis). Examples: cmd /c exit 7; "after" Before: ✓ ... | Duration: 0.20s | Location ... After: ✓ ... | Duration: 0.20s | LastExit: 7 | Location ... Get-Item /nope → ✗ ... | Errors: 1 | ... (lecChanged false, $? false, LastExit gates all silent) "plain string" → ✓ ... | Duration: 0.20s | ... (no native ran, LastExit silent) Session-state limitation inherited from ripple's implementation: if the prior pipeline happened to leave $LASTEXITCODE at the same value this pipeline's native produces (e.g. `cmd /c exit 7` twice in a row), lecChanged is false and LastExit stays silent. The underlying PS constraint (no runtime hook for "a native updated LASTEXITCODE") makes a fully deterministic detection impossible. Documented in docs/pwsh-mcp-vs-ripple-evaluation.md. No API or wire-shape change. The `LastExitReport` field is only added to the in-process StreamResults hashtable; existing callers tolerate its presence, and the status-line rendering omits the tag when the value is 0 (matches the zero-tag-omission discipline used for Errors/Warnings/Info).
1 parent e8596f7 commit 61adc7d

1 file changed

Lines changed: 63 additions & 4 deletions

File tree

PowerShell.MCP/Resources/MCPPollingEngine.ps1

Lines changed: 63 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -102,13 +102,29 @@ if (-not (Test-Path Variable:global:McpTimer)) {
102102
$warningVar = @()
103103
$informationVar = @()
104104

105+
# Snapshot $LASTEXITCODE BEFORE the pipeline so we can
106+
# tell whether this invocation TOUCHED it (a native exe
107+
# ran and updated the variable) vs inherited a stale
108+
# value from an earlier native in a prior call. Without
109+
# this snapshot, reporting $LASTEXITCODE verbatim would
110+
# leak a stale 7 from `cmd /c exit 7` into every
111+
# subsequent pure-PowerShell pipeline.
112+
$lecAtStart = $global:LASTEXITCODE
113+
105114
try {
106115
$redirectedOutput = Invoke-Expression $Command `
107116
-OutVariable outVar `
108117
-ErrorVariable errorVar `
109118
-WarningVariable warningVar `
110119
-InformationVariable informationVar
111120

121+
# Capture post-pipeline state IMMEDIATELY. Any
122+
# statement below (even a bare variable assignment)
123+
# resets $? to True, so grab the two signals we
124+
# care about before the dedup loop runs.
125+
$ok = $?
126+
$lec = $global:LASTEXITCODE
127+
112128
# Deduplicate errors (PowerShell ErrorVariable can record the same error multiple times)
113129
$uniqueErrors = @()
114130
$seenErrors = @{}
@@ -126,12 +142,31 @@ if (-not (Test-Path Variable:global:McpTimer)) {
126142
}
127143
}
128144

145+
# LastExitReport gating: only surface a non-zero
146+
# native exit when the pipeline OVERALL SUCCEEDED
147+
# ($? True) AND $LASTEXITCODE was actually written
148+
# by this pipeline (not stale from before) AND the
149+
# written value is non-zero. When $? is False the
150+
# status icon flips to ✗ and errorCount is already
151+
# surfacing the failure, so LastExit would be
152+
# redundant. Zero means "silent — no native
153+
# reported, or the last one returned 0". Mirrors
154+
# ripple's OSC 633;L contract so the two MCPs
155+
# surface the same semantic across shells.
156+
$lecChanged = $lec -ne $lecAtStart
157+
$lastExitReport = if ($ok -and $lecChanged -and $null -ne $lec -and $lec -ne 0) {
158+
[int]$lec
159+
} else {
160+
0
161+
}
162+
129163
return @{
130164
Success = $outVar
131165
Error = $uniqueErrors
132166
Exception = @()
133167
Warning = $warningVar
134168
Information = $informationVar
169+
LastExitReport = $lastExitReport
135170
}
136171
}
137172
catch {
@@ -143,6 +178,13 @@ if (-not (Test-Path Variable:global:McpTimer)) {
143178
Exception = $exceptionVar
144179
Warning = $warningVar
145180
Information = $informationVar
181+
# Pipeline threw — the exception itself is the
182+
# primary failure signal. Don't also surface
183+
# LastExit: a native that set $LASTEXITCODE
184+
# before the throw is of lower interest than
185+
# the thrown exception and would just add
186+
# noise to the error-path response.
187+
LastExitReport = 0
146188
}
147189
}
148190
}
@@ -318,6 +360,16 @@ if (-not (Test-Path Variable:global:McpTimer)) {
318360
$warningCount = $outputStreams.Warning.Count
319361
$infoCount = $outputStreams.Information.Count
320362
$hasErrors = $errorCount -gt 0
363+
# LastExitReport is 0 when the invocation did not
364+
# surface a hidden native exit (see
365+
# Invoke-CommandWithAllStreams for the gating). Null-
366+
# safe read — the older wire shape without this field
367+
# still drains to 0 silently.
368+
$lastExitReport = if ($StreamResults.ContainsKey('LastExitReport')) {
369+
[int]$StreamResults.LastExitReport
370+
} else {
371+
0
372+
}
321373

322374
# Truncate pipeline for status line
323375
# Split by newline, pipe character, and limit to 30 chars
@@ -358,10 +410,17 @@ if (-not (Test-Path Variable:global:McpTimer)) {
358410
# by construction. This follows the same "omit what
359411
# equals zero" discipline the status line already uses
360412
# for `$pipelineInfo`.
361-
$errInfo = if ($errorCount -gt 0) { " | Errors: $errorCount" } else { "" }
362-
$warnInfo = if ($warningCount -gt 0) { " | Warnings: $warningCount" } else { "" }
363-
$infoInfo = if ($infoCount -gt 0) { " | Info: $infoCount" } else { "" }
364-
$statusLine = "$statusIcon Pipeline $statusText | Window: $windowTitle | Status: $Status$pipelineInfo | Duration: $durationText$errInfo$warnInfo$infoInfo | $LocationInfo"
413+
$errInfo = if ($errorCount -gt 0) { " | Errors: $errorCount" } else { "" }
414+
$warnInfo = if ($warningCount -gt 0) { " | Warnings: $warningCount" } else { "" }
415+
$infoInfo = if ($infoCount -gt 0) { " | Info: $infoCount" } else { "" }
416+
# `LastExit: N` surfaces a native exe that returned
417+
# non-zero mid-pipeline when the pipeline overall
418+
# succeeded — the ✓ badge would otherwise silently
419+
# hide the non-zero exit. Positioned right after
420+
# Duration so it sits next to the exit-code domain
421+
# (Errors is about PS $Error stream, a separate axis).
422+
$lastExitInfo = if ($lastExitReport -gt 0) { " | LastExit: $lastExitReport" } else { "" }
423+
$statusLine = "$statusIcon Pipeline $statusText | Window: $windowTitle | Status: $Status$pipelineInfo | Duration: $durationText$lastExitInfo$errInfo$warnInfo$infoInfo | $LocationInfo"
365424

366425
# Generate structured output strings
367426
$structuredOutput = @{

0 commit comments

Comments
 (0)