Skip to content

v1.15.0

Choose a tag to compare

@github-actions github-actions released this 09 Oct 11:48
· 33 commits to main since this release

New Features

  • execute_command takes eight literal slots, var1 … var8. Four slots were enough for one edit, but not for a call that carries several — three Update-MatchInFile pairs, or a new file plus the text to search for — so edits that belonged together had to be split across calls. The slots stay numbered rather than becoming an array: $var3 reads unambiguously both in a pipeline and in a conversation about it (an array index is one off-by-one away from the wrong text), the rule that Add-LinesToFile / Update-MatchInFile must take their text from var1 / var2 stays checkable by name, and plain string parameters are what every MCP client handles reliably, where nested objects and arrays are not. var1–var4 behave exactly as before.
  • Show-TextFiles -Context N. The lines shown around each -Pattern / -Contains match (the N- lines) were fixed at two. That is the right default for looking at one file, and the wrong one for locating lines across a tree, where -Recurse -Pattern multiplied every hit by five. -Context 0 lists matching lines only, grep-style — no context lines and no blank separator lines either, since the line numbers already show the gaps; any other value sets the width. The default stays 2, so existing prompts and habits are unaffected.

Improvements

  • The scope warning now fires when the mistake bites, not at every assignment. An AI command runs in a child scope, so $x = … is gone when the call ends and only $script:x survives. The proxy used to warn on every new local variable name, by regex: the overwhelmingly common case was a harmless temporary used within the same call, and it also fired on $x= inside a string ("OutputEncoding=$(…)"), while the moment that actually mattered — the later call that read $x and got $null — passed in silence. The warning now comes from the console itself, which parses the command (the real AST, so strings and $script: prefixes are read correctly), remembers which names earlier calls assigned locally, and checks each name a command reads against the live session with Get-Variable. A read of a name that does not exist is reported once, in the response of the call that read it: ⚠️ SCOPE: $x was assigned without a scope prefix in an earlier execute_command call … when the console remembers assigning it, ⚠️ UNDEFINED: $x is not defined in this console … otherwise — which also catches a variable left in another console or lost to a new server session. A variable that exists by any route (a $script: assignment, something you typed at the prompt, the var1–var8 slots, automatics) is never reported, and a name the same pipeline defines — an assignment, a foreach variable, a parameter, -ErrorVariable, [ref], New-Variable — is not a stale read whatever the statement order. The assignment-time warning is gone.
  • The Get-Content hint stays quiet when the text is consumed, not viewed. ⚠️ Use Show-TextFiles instead of Get-Content appeared on every invocation, including $json = Get-Content x | ConvertFrom-Json, (Get-Content x) -replace … and foreach ($l in Get-Content x), where the lines are data and Show-TextFiles' numbered listing is the wrong tool. The hint now fires only when the lines reach the display: a bare Get-Content x, or one piped solely through viewing commands (Select-Object -First, Select-String, Where-Object, Out-String, Format-*), for which it also names the Show-TextFiles parameters that replace them.
  • A Start-Process that redirects output into a new console is flagged (Windows). On a Japanese (or any non-English) Windows, Start-Process … -RedirectStandardOutput log -WindowStyle Hidden produced a log that read back as garbage. Measured: a child started without -NoNewWindow gets a new console, and a new console starts with the system OEM code page (932, 437, …) regardless of the UTF-8 this console was switched to at start-up — the same command with -NoNewWindow, or run through cmd /c … > log, writes UTF-8. A console's code page cannot be passed on to a console that does not exist yet, and the places that would make every new console UTF-8 (the user's HKCU\Console code page, the system locale) are the user's settings, not this module's to change; so the fix belongs on the command, and the response now says what to add when the pattern appears: -NoNewWindow (with the output redirected, nothing is shown in this console), or the cmd /d /s /c "… < NUL > log 2>&1" form.
  • The text an edit cmdlet writes is checked for what it is, not for whether var1 was passed. The rule exists because a double-quoted literal is changed by the parser before the cmdlet sees it — $name and $(…) expand, backticks and doubled quotes are consumed — so source code passed that way reached the file altered. It was enforced by the proxy with regexes over the pipeline text, which missed in both directions: Add-LinesToFile f -Content "test" was refused although nothing in it changes, while Update-MatchInFile f -OldText $var1 -Replacement "new $x" passed because a var1 argument existed; Remove-LinesFromFile was refused when a different command in the same pipeline had -Pattern; and the refusal came back as a bare ERROR: line without the status line every other response has. The check now runs in the console, which parses the command and binds each edit cmdlet's text parameters the way the call will (-Content, -OldText, -Replacement, Remove-LinesFromFile -Pattern / -Contains, Set-Content / Add-Content -Value; positional and abbreviated forms included). Only a double-quoted or bareword literal the parser would alter is refused — reported like a syntax error, ✗ Pipeline NOT executed (edit text refused), with the line, column and parameter. var1–var8 and other variables, single-quoted strings, plain text and computed values pass; a variable assigned in the same command is judged by its assigned value, so $c = "a $b"; Add-LinesToFile f -Content $c is caught too. A command refused before running (syntax error, exit, edit text) also no longer carries a SCOPE / UNDEFINED warning, which claimed a read that never happened.
  • execute_command now points out a needless pwsh -Command / -File wrapper. An AI used to a bash-style shell tool tends to write pwsh -NoProfile -Command "…" or pwsh -File .\script.ps1 out of habit — but the console it is talking to is pwsh, so the wrapper only starts a second PowerShell process: slower, with the quoting doubled, and with every variable, module and cwd change stranded in the child. When a pipeline starts with such an invocation (including pwsh.exe, a full path to it, or a bare pwsh script.ps1), the response now ends with a hint to run the command or script directly, in the same spirit as the existing note about a redundant leading cd. It is shown once per session — a sub-agent, which works in consoles of its own, gets its own one — because a nested pwsh is sometimes exactly what is wanted — a script tested without the profile, a run against another PowerShell version, a command that must exit the shell — and repeating the note on every call would punish those. A pwsh that is not the leading command (Start-Process pwsh, a Start-Job body, an assignment) is left alone as presumably deliberate, and powershell.exe never triggers it: Windows PowerShell 5.1 is a different engine, and running it from here is the only way to test against it.
  • execute_command's description fits in what Claude Code actually delivers. Claude Code cuts a tool description at 2048 characters (its MCP log: description truncated from 3680 to 2048 chars), so everything after the first lines of the text-file section never reached the AI there: the edit cmdlet list, the rule to pass -OldText / -Replacement / -Content through var1–var8, Get-Help -Examples, -Context 0, and the preference over the host's own file tools. The description is rewritten at about 1700 characters with that guidance kept and moved ahead of the console-input note; the repeated "sessions persist" paragraph, the API-exploration tip and the PromptAI cmdlet list are gone.
  • Show-TextFiles -Recurse skips binary files and .git, and a search that finds nothing says so. A recursive search walked everything under the path, build output included: Show-TextFiles Tests -Recurse -Pattern LineNumber matched inside the DLLs under Tests\bin, read them as UTF-16, and returned 7.9 MB of noise in 9.4 s, where the same search now returns the real hits in under a second. Under -Recurse, a file with a NUL byte in its first 8000 bytes is skipped (the test git and ripgrep use; a UTF-16/32 BOM marks the NULs as text, so such files are still searched), and .git directories are not entered. Separately, a -Pattern / -Contains search with no match printed nothing at all, which looks the same as a mistyped path; it now writes No matches in N file(s). to the warning stream, with the number of binary files skipped when there were any, so output consumed by a pipeline is unaffected.
  • Context lines are clipped at 200 characters. In a file whose lines are paragraphs — Markdown, this CHANGELOG — every N- line around a match carried a whole paragraph, so a search returned kilobytes of neighbouring text to find one line. Context lines (from Show-TextFiles -Pattern / -Contains and from every edit cmdlet) now stop at 200 characters and end in … (+N chars). They only locate the match; the matching and edited lines themselves are never clipped, and Show-TextFiles -LineRange shows a full line when it is wanted. Escape sequences don't count toward the limit and are never cut in half.
  • A console running a superseded build is no longer taken over. A console keeps the PowerShell.MCP.dll it loaded. After a deploy that replaced the DLL under the same version number, a console left over from an earlier session was claimed by the next session's proxy and silently ran the old code; after a release installed side by side, the version check let it be claimed and then failed every command with a message that blamed the proxy. An unowned console now reports itself outdated when its loaded build is not the one on disk (compared by module MVID) or its version differs from the proxy's, so it is passed over and a fresh console is started. Consoles a session already owns are unaffected.

Internal

  • Build-AllPlatforms.ps1 -DllOnly works while MCP consoles are open. The move-then-copy swap that lets PowerShell.MCP.dll be replaced under a running console was never applied to Ude.NetStandard.dll, which the same consoles hold, so the deploy died with "being used by another process" at exactly the moment the -DllOnly loop is meant for. The file is now left alone when identical (it almost never changes) and swapped the same way otherwise.
  • A development build can run next to the installed one, in the same conversation. Two environment variables on the proxy, both read only when set: POWERSHELL_MCP_PIPE_NAME gives the server its own pipe namespace (e.g. PSMCPDEV.* instead of PSMCP.*), and POWERSHELL_MCP_MODULE names the psd1 its consoles import instead of resolving PowerShell.MCP by name. Registered behind mcp-sitter, a dev server can then be killed, rebuilt and respawned from the conversation that is working on it, and neither server ever claims the other's consoles — which matters because a proxy claims any unowned console, and killing a proxy releases its consoles. Consoles do not inherit the proxy's environment (the Windows launcher deliberately builds a fresh user environment block), so both values are handed over in the init command. The unowned-console scan also now matches PSMCP.* rather than PSMCP*, which would have taken in PSMCPDEV.*. Setup and loop: docs/Development-Server.md.
  • Build-AllPlatforms.ps1 no longer stops running MCP servers. To overwrite the image-locked bin\<rid>\PowerShell.MCP.Proxy.exe, a proxy build killed every PowerShell.MCP.Proxy process on the assumption that the client respawns its server on next use. Claude Code does not: a session lives on in its daemon and never restarts a proxy it lost, so a build run from inside a conversation cut that conversation off from pwsh for good. The proxy is now published to a temp folder and swapped in with the same move-then-copy used for the DLLs (now one helper for all three files); running servers keep the old exe and each new session starts the new one. Old files still held are listed in the summary as such instead of as unexpected files, and are deleted by a later run once released.
  • PlatyPS\Build-Help.ps1 copies the help into the installed module wherever Get-Module -ListAvailable finds it, instead of a fixed Program Files path.

Bug Fixes

  • Show-TextFiles … | Select-Object -First N no longer reports an error. When Select-Object -First had enough and stopped the pipeline, the stop signal (PipelineStoppedException, thrown out of WriteObject) was caught by the per-file error handler, reported as a read error ("The pipeline has been stopped"), and the loop went on reading every remaining file. It now propagates and the command ends quietly.
  • An AI command no longer sees the console engine's own variables. Commands ran as a child of the engine's timer scope, so a command that read a name the engine uses — $cmd, $duration, $ok, $sb, $lec, $event, $sender and a dozen more — without assigning it got the engine's value, silently, since the variable existed (if ($ok) with no $ok of your own tested the engine's last result). The slots var1–var8 were set in that scope too and stayed there, so a call that passed no var1 read the previous call's. Commands now run in a sandbox: a dynamic module whose scope hangs directly off the global one. They still see everything global (what you typed at the prompt, preferences, drives), $script: and function script: still persist per console, the slots are placed in the sandbox for each call and removed before the next, and the scope warning's existence check runs in the same sandbox. Import-Module from a command still imports into the session you share — a module scope would otherwise take the import for itself, so the sandbox carries a proxy of Import-Module that defaults to -Global (an explicit -Global or -Scope is honoured as given). Writing to such a name was always harmless (it created a local); only reads leaked.
  • A command containing exit is refused instead of silently killing the console's engine. An exit statement anywhere in a command — exit 1, if (…) { exit }, & { exit } — raised an ExitException that unwound through the engine's timer action and ended its event job: the window stayed open and the prompt still worked, but the engine never ticked again, so the console was reported as "user command running" for good, the proxy moved on to a fresh console, and neither cancel nor Restart-MCPServer brought it back; the idle reaper runs on the same timer, so the window was never closed either. The command is now parsed for exit before anything runs and refused as NOT executed (exit refused), naming the line and column, with the same treatment as a syntax error (echoed in the console, nothing executed). This is also the right meaning: the console is shared with the user, and a command never closes it. exit inside a script file the command invokes is unaffected — that ends only the script.
  • A stranger's console no longer makes wait_for_completion return early. While looking for a console to use, the proxy also probes unowned consoles (a user-started one, or one a finished session released) so it can claim one; when such a console was not ready, its PID was entered in the session's busy list — the list that wait_for_completion watches and that the "⚠ Console #… was closed" notices are built from. A console this session never owned could therefore cut a wait_for_completion short with a closed-console notice about it, as happened with a released console from an earlier session that was closed by hand. Unowned consoles that are not ready are now simply skipped; only consoles the session owns are tracked.
  • A sub-agent's console now closes itself when idle, like any other. The idle-close introduced in 1.13.0 keeps the most recently used console of a session alive as the survivor, and elects that survivor within each agent's own group of consoles. A sub-agent (is_subagent=true) gets its own agent_id and therefore its own group, which almost always holds exactly one console — and a group of one is always its own survivor. So a console a sub-agent had started never printed the warning and never closed, even hours after the sub-agent had finished: one abandoned window per sub-agent, which is precisely the pile-up the feature exists to prevent. A sub-agent's consoles are no longer exempted as survivors; idle for the configured time, they warn and close like a stale standby console, while every other guard (busy, holding uncollected output, text typed at the prompt, any activity during the grace period) still applies. The session's main consoles behave as before, and a sub-agent that is still alive and returns to a closed console simply gets a fresh one from the proxy.
  • Update-MatchInFile numbers every line a multi-line replacement produces. A one-line match replaced by text containing newlines was shown as a single N: entry with the new lines run together under it, and every context line after it kept its old number — off by the number of lines added. Each resulting line now gets its own number, the replacement's color carries over to each of them, and later lines show their numbers in the updated file. A multi-line -OldText replacement is numbered the way Update-LinesInFile is: the removed lines carry no number, the inserted ones their position in the result (they used to be the other way round). It also shows every occurrence now, each with two context lines on either side, and the whole lines when the text starts or ends mid-line; before, only the first occurrence appeared, without context, and a mid-line match showed the replacement text alone.
  • The first command of a new server session is reported as itself. On a session's first attach the proxy moves the console to $HOME by prefixing the AI's pipeline with Set-Location -LiteralPath '<home>';. The prefix became part of the command: the console echoed it, and the status line's Pipeline: summary named Set-Location -LiteralPath '... instead of what the AI ran. The move is now a silent command of its own, sent just before the pipeline.
  • A break or continue outside a loop no longer kills the console's engine. 'a'; break; 'b', or a break inside a ForEach-Object body (which is not a loop), unwound through every caller until a loop caught it — and the first loop on the way was the engine's own, so the event job ended exactly as it did with exit: the console was reported as "user command running" for good and the proxy moved to a fresh one. The engine now runs each command inside a one-pass loop that absorbs the stray break / continue; the command ends there, and the output it produced before is returned as usual.
  • The edit-text check judges the text itself, not every literal near it. Three things were wrong. The variable look-up compared VariablePath.UnqualifiedPath, which is empty for a plain $name, so empty matched empty and every assignment in the command counted as the variable's: $b = [IO.File]::ReadAllBytes("$PWD\$p"); Update-MatchInFile … -OldText $var1 was refused, pointing at the "$PWD\$p". A literal anywhere inside an assigned expression was judged, so $t = $var1 -replace "rn", "n"; Add-LinesToFile … -Content $twas refused for the backticks in its regex pattern. And commands inside a nested script block were judged, so1..3 | ForEach-Object { Add-Content log.txt "entry $_" }, where the text is computed per item and nothing else could write it, was refused too. Now the variable is matched by name, only the expression an argument (or the value assigned to its variable) amounts to is judged — parentheses looked through, array literals element by element — and commands inside a nested script block are left alone. The check also follows aliases, so ac file "text $x"is refused likeAdd-Content` is.
  • The Start-Process hint no longer suggests an invalid combination. It said "add -NoNewWindow" even when the command already had -WindowStyle, which Start-Process refuses to combine with it. With -WindowStyle present the hint now says to replace it.
  • A silent command does not queue behind a running user command. The first-attach Set-Location and the window-title update went to the console without the busy check execute_command has: when the user had just started a command of their own, the request blocked until it finished and the silent command then ran in the user's session. Both now return busy and are dropped, as best-effort commands should be; the execute_command that follows gets the usual busy answer.
  • Fewer false UNDEFINED warnings. Tee-Object -Variable t; $t, Import-LocalizedData -BindingVariable m; $m, Invoke-WebRequest … -SessionVariable s; $s were reported as reads of undefined names although the command creates them; a dot-sourced file or an Invoke-Expression string can define anything, so a command containing one is not judged; $OFS and $PSModuleAutoLoadingPreference are preference variables that exist only once set, and reading them is how they are consulted.
  • Show-TextFiles -Recurse honours a UTF-16/32 -Encoding when deciding what is binary. A BOM-less UTF-16 file has a NUL in every other byte and was skipped as binary even when the caller had said -Encoding Unicode; the non-recursive form found it. The sniff is now skipped for a wide encoding named by the caller.
  • Update-MatchInFile shows a whole-line replacement correctly. When -OldText ended with a newline and the replacement did too (or was empty), the line after the match was shown both removed and inserted, and at the end of the file a phantom empty line was shown removed. The file itself was always right.
  • A console that is passed over as outdated says so. An unowned console whose DLL has been replaced on disk, or whose version differs from the proxy's, is no longer claimed (above) — but it printed nothing, so the window just sat there with no hint as to why no session used it. It now prints one line naming the reason and suggesting a new console.
  • A UTF-8 file with little non-ASCII text is no longer rewritten in a legacy code page. Encoding detection asked the charset guesser first and checked for valid UTF-8 only when it had no answer. Given a 17 KB source file whose only non-ASCII characters were a dash and an emoji, the guesser said windows-1252; the edit cmdlets read it that way, wrote it back that way, and the characters the edit added became bytes that are not UTF-8. The reverse corruption was one byte away: a Japanese character carries bytes windows-1252 cannot round-trip. A sample that holds a multi-byte sequence and validates as UTF-8 is now UTF-8 before the guesser is consulted; pure ASCII and invalid UTF-8 go to the guesser as before.