MUL-2708: fix(agent): preserve multi-line Pi prompt on Windows by bypassing the .cmd shim (#3417)

Pi is installed on Windows via npm, which lays down `pi.cmd` → `pi.ps1`
→ `node_modules/@mariozechner/pi-coding-agent/dist/cli.js`. The daemon
spawns Pi with `exec.Command("pi", ...)`; PATHEXT resolves that to
`pi.cmd`, and cmd.exe expands `%*` in the shim by re-tokenising the
original command line, which truncates any argv containing newlines.

buildPiArgs passes the full prompt as the last positional argv, so the
multi-line system+user prompt is silently cut at the first newline
before it reaches the JS entrypoint. The session JSONL then records
only the first line ("You are running as a chat assistant for a Multica
workspace.") and Pi replies as if the user message were missing
(GitHub multica-ai/multica#3306).

Mirror the existing cursor-agent fix: when LookPath resolves Pi to a
.cmd/.bat launcher and a sibling pi.ps1 exists, invoke PowerShell with
`-File <ps1>` directly and forward each arg as a discrete token. This
keeps us on the official launch path while skipping the cmd.exe %*
re-expansion. Falls back to the original launcher when pi.ps1 or
PowerShell can't be located.

The Windows test asserts the rewrite produces the expected argv and
that the multi-line positional prompt survives unchanged.

Co-authored-by: J <j@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
This commit is contained in:
Bohan Jiang
2026-05-28 12:36:16 +08:00
committed by GitHub
parent 7722a98a6a
commit 2bda4065d0
6 changed files with 267 additions and 7 deletions

View File

@@ -174,12 +174,13 @@ func isPiToolNameByte(b byte) bool {
}
func (b *piBackend) Execute(ctx context.Context, prompt string, opts ExecOptions) (*Session, error) {
execPath := b.cfg.ExecutablePath
if execPath == "" {
execPath = "pi"
execName := b.cfg.ExecutablePath
if execName == "" {
execName = "pi"
}
if _, err := exec.LookPath(execPath); err != nil {
return nil, fmt.Errorf("pi executable not found at %q: %w", execPath, err)
lookedUp, err := exec.LookPath(execName)
if err != nil {
return nil, fmt.Errorf("pi executable not found at %q: %w", execName, err)
}
timeout := opts.Timeout
@@ -205,10 +206,11 @@ func (b *piBackend) Execute(ctx context.Context, prompt string, opts ExecOptions
runCtx, cancel := context.WithTimeout(ctx, timeout)
args := buildPiArgs(prompt, sessionPath, opts, b.cfg.Logger)
argv0, cmdArgs := choosePiInvocation(execName, lookedUp, args, b.cfg.Logger)
cmd := exec.CommandContext(runCtx, execPath, args...)
cmd := exec.CommandContext(runCtx, argv0, cmdArgs...)
hideAgentWindow(cmd)
b.cfg.Logger.Info("agent command", "exec", execPath, "args", args)
b.cfg.Logger.Info("agent command", "exec", argv0, "args", cmdArgs)
cmd.WaitDelay = 10 * time.Second
if opts.Cwd != "" {
cmd.Dir = opts.Cwd

View File

@@ -0,0 +1,32 @@
package agent
import "log/slog"
// choosePiInvocation selects the actual program (argv[0]) and the full
// argv to spawn a Pi run.
//
// Background:
// - On macOS/Linux, the npm-installed `pi` binstub is a shebang script
// that execs node directly with the JS entrypoint, so argv passes
// through unchanged.
// - On Windows, the npm installer ships `pi.cmd` whose body is
// "powershell ... -File pi.ps1 %*". CreateProcess for a .cmd file
// goes through cmd.exe, and %* in a .cmd batch file is expanded by
// re-tokenising the original command line, which mangles arguments
// containing newlines or other whitespace — for Pi, that's the
// multi-line positional prompt passed by buildPiArgs. Symptom: the
// Pi session JSONL records only the first line of the prompt
// (#3306). To stay on the official launch path while avoiding that
// re-tokenisation, we resolve pi.ps1 next to the .cmd and invoke
// PowerShell with `-File <ps1>` directly, letting Go pass each argv
// as a separate token.
//
// The Windows-specific behaviour is implemented in
// pi_invocation_windows.go; on other platforms we fall through to a
// passthrough.
func choosePiInvocation(execName, lookedUp string, args []string, logger *slog.Logger) (string, []string) {
if argv0, full, ok := platformPiInvocation(lookedUp, args, logger); ok {
return argv0, full
}
return execName, args
}

View File

@@ -0,0 +1,12 @@
//go:build !windows
package agent
import "log/slog"
// platformPiInvocation is a no-op on non-Windows platforms: Pi's binstub
// invokes node directly via shebang and Go's os/exec can pass argv
// unchanged.
func platformPiInvocation(_ string, _ []string, _ *slog.Logger) (string, []string, bool) {
return "", nil, false
}

View File

@@ -0,0 +1,36 @@
package agent
import (
"io"
"log/slog"
"path/filepath"
"reflect"
"testing"
)
// TestChoosePiInvocation_PassthroughForNonLauncher verifies that when the
// resolved executable is not a Windows .cmd/.bat launcher, both argv[0] and
// the argv list are returned unchanged on every platform. This guards
// against accidental rewriting on macOS/Linux and for direct binary
// launches on Windows.
func TestChoosePiInvocation_PassthroughForNonLauncher(t *testing.T) {
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
execName := "pi"
lookedUp := filepath.Join(t.TempDir(), "pi") // no .cmd / .bat
args := []string{
"-p",
"--mode", "json",
"--session", "/tmp/pi-session.jsonl",
"You are running as a chat assistant for a Multica workspace.\n\nUser message:\n我需要创建一个issue\n",
}
gotExec, gotArgs := choosePiInvocation(execName, lookedUp, args, logger)
if gotExec != execName {
t.Errorf("argv0 changed unexpectedly: got %q want %q", gotExec, execName)
}
if !reflect.DeepEqual(gotArgs, args) {
t.Errorf("argv changed unexpectedly:\n got %#v\n want %#v", gotArgs, args)
}
}

View File

@@ -0,0 +1,60 @@
//go:build windows
package agent
import (
"log/slog"
"os"
"path/filepath"
"strings"
)
// platformPiInvocation rewrites the pi invocation on Windows when the
// resolved executable is the npm-installed pi.cmd launcher (or a .bat
// alias) that delegates to pi.ps1.
//
// We replace
//
// pi.cmd <args...>
//
// with
//
// powershell.exe -NoProfile -ExecutionPolicy Bypass -File pi.ps1 <args...>
//
// which is what the .cmd does internally, but lets Go pass each arg as
// a discrete token instead of routing through cmd.exe's %* re-expansion
// (which mangles the multi-line positional prompt the daemon builds in
// buildPiArgs — see #3306).
//
// powerShellLookup is shared with the cursor backend: both npm shims
// have the same launcher shape and need the same PowerShell host on the
// same Windows installation.
func platformPiInvocation(lookedUp string, args []string, logger *slog.Logger) (string, []string, bool) {
ext := strings.ToLower(filepath.Ext(lookedUp))
if ext != ".cmd" && ext != ".bat" {
return "", nil, false
}
dir := filepath.Dir(lookedUp)
ps1 := filepath.Join(dir, "pi.ps1")
if st, err := os.Stat(ps1); err != nil || st.IsDir() {
return "", nil, false
}
psExe, ok := powerShellLookup()
if !ok {
return "", nil, false
}
full := make([]string, 0, 5+len(args))
full = append(full, "-NoProfile", "-ExecutionPolicy", "Bypass", "-File", ps1)
full = append(full, args...)
if logger != nil {
logger.Info("pi: routing through powershell -File to preserve argv tokens",
"powershell", psExe,
"ps1", ps1,
"original", lookedUp,
)
}
return psExe, full, true
}

View File

@@ -0,0 +1,118 @@
//go:build windows
package agent
import (
"io"
"log/slog"
"path/filepath"
"reflect"
"testing"
)
// TestPlatformPiInvocation_RewritesCmdLauncherToPowerShellFile is the core
// Windows test: when LookPath resolves pi to the npm-installed .cmd
// launcher and a sibling pi.ps1 exists, we should invoke PowerShell with
// -File <ps1> and forward every original arg unchanged — including the
// multi-line positional prompt that would otherwise be mangled by the
// cmd.exe %* re-expansion inside pi.cmd. This is the regression test for
// #3306: daemon argv carried the full prompt, but Pi's session JSONL only
// recorded the first line.
func TestPlatformPiInvocation_RewritesCmdLauncherToPowerShellFile(t *testing.T) {
dir := t.TempDir()
cmdPath := filepath.Join(dir, "pi.cmd")
ps1Path := filepath.Join(dir, "pi.ps1")
writeFile(t, cmdPath, "@echo off\r\npowershell -NoProfile -ExecutionPolicy Bypass -File \"%~dp0pi.ps1\" %*\r\n")
writeFile(t, ps1Path, "# fake pi.ps1\r\n")
fakePS := filepath.Join(dir, "powershell.exe")
writeFile(t, fakePS, "")
stubPowerShell(t, fakePS, true)
multiLinePrompt := "You are running as a chat assistant for a Multica workspace.\n\nUser message:\n我需要创建一个issue\n"
args := []string{
"-p",
"--mode", "json",
"--session", `C:\Users\X\.multica\pi-sessions\20260528T040000.jsonl`,
multiLinePrompt,
}
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
gotExec, gotArgs, ok := platformPiInvocation(cmdPath, args, logger)
if !ok {
t.Fatalf("expected platform rewrite to be applied, got ok=false")
}
if gotExec != fakePS {
t.Errorf("argv0: got %q want %q", gotExec, fakePS)
}
wantArgs := append([]string{
"-NoProfile",
"-ExecutionPolicy", "Bypass",
"-File", ps1Path,
}, args...)
if !reflect.DeepEqual(gotArgs, wantArgs) {
t.Errorf("argv mismatch:\n got %#v\n want %#v", gotArgs, wantArgs)
}
// Explicit check: the last argv (the positional prompt) must still
// contain every line of the original multi-line prompt. This is the
// concrete property #3306 violates when cmd.exe re-tokenises %*.
if gotArgs[len(gotArgs)-1] != multiLinePrompt {
t.Errorf("multi-line prompt was mangled:\n got %q\n want %q", gotArgs[len(gotArgs)-1], multiLinePrompt)
}
}
// TestPlatformPiInvocation_SkipsWhenNotCmdOrBat ensures we leave argv alone
// when the user explicitly resolved pi to something that isn't a batch
// launcher (e.g. a real binary or a node script via shebang shim).
func TestPlatformPiInvocation_SkipsWhenNotCmdOrBat(t *testing.T) {
dir := t.TempDir()
exePath := filepath.Join(dir, "pi.exe")
writeFile(t, exePath, "")
// A sibling .ps1 must not trick us into rewriting a non-launcher exec.
writeFile(t, filepath.Join(dir, "pi.ps1"), "")
stubPowerShell(t, filepath.Join(dir, "powershell.exe"), true)
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
if _, _, ok := platformPiInvocation(exePath, []string{"-p", "hello"}, logger); ok {
t.Fatalf("expected ok=false for non-.cmd/.bat launcher")
}
}
// TestPlatformPiInvocation_SkipsWhenPS1Missing covers the rare case where a
// .cmd was found but its companion .ps1 is missing (e.g. a partial install
// or a third-party shim that wraps Pi differently). We must fall back to
// the original launcher rather than synthesising an invalid powershell
// -File invocation against a non-existent script.
func TestPlatformPiInvocation_SkipsWhenPS1Missing(t *testing.T) {
dir := t.TempDir()
cmdPath := filepath.Join(dir, "pi.cmd")
writeFile(t, cmdPath, "@echo off\r\n")
stubPowerShell(t, filepath.Join(dir, "powershell.exe"), true)
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
if _, _, ok := platformPiInvocation(cmdPath, []string{"-p", "hello"}, logger); ok {
t.Fatalf("expected ok=false when pi.ps1 is missing")
}
}
// TestPlatformPiInvocation_SkipsWhenPowerShellMissing covers a stripped
// down environment in which neither pwsh.exe nor powershell.exe can be
// resolved. We must not fabricate an empty-string argv[0].
func TestPlatformPiInvocation_SkipsWhenPowerShellMissing(t *testing.T) {
dir := t.TempDir()
cmdPath := filepath.Join(dir, "pi.cmd")
ps1Path := filepath.Join(dir, "pi.ps1")
writeFile(t, cmdPath, "@echo off\r\n")
writeFile(t, ps1Path, "# fake\r\n")
stubPowerShell(t, "", false)
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
if _, _, ok := platformPiInvocation(cmdPath, []string{"-p", "hello"}, logger); ok {
t.Fatalf("expected ok=false when no powershell host is available")
}
}