Files
MuyueWorkspace/internal/api/terminal_conpty_windows.go
Muyue d557b8e74c
All checks were successful
PR Check / check (pull_request) Successful in 1m0s
fix(windows): native ConPTY + kernel32 metrics + agent loop cap (v0.7.6)
Three issues reported on Windows + one user-requested limit bump:

1. Dashboard CPU/RAM/Network all at 0
   handleSystemMetrics read /proc/* exclusively. Replaced with a
   platform-split:
   - metrics_unix.go (!windows): existing /proc reading code.
   - metrics_windows.go: kernel32!GetSystemTimes for CPU
     (delta of idle vs kernel+user FILETIMEs) and
     kernel32!GlobalMemoryStatusEx for memory. Network left at zero
     for now — MIB_IF_ROW2 is too version-sensitive to parse by hand.
   handlers_info.go::handleSystemMetrics reduced to one delegating
   call.

2. Terminal black screen on Windows
   creack/pty/v2 returns "unsupported" on Windows; the v0.7.1 pipe
   fallback works but pipes don't carry TTY signals, so cmd/pwsh/wsl
   go silent. Implemented native ConPTY:
   - terminal_conpty_windows.go: CreatePseudoConsole + STARTUPINFOEX
     + PROC_THREAD_ATTRIBUTE_PSEUDOCONSOLE wiring via
     windows.NewProcThreadAttributeList. CreateProcessW launches
     child with the PC attached, full ANSI / line discipline /
     resize.
   - canUseConPTY() probes once at startup (Win10 1809+ check).
   - Restructure: terminal_session.go now holds just the interface
     + ptySession + pipeSession structs. terminal_session_unix.go
     wires creack/pty. terminal_session_windows.go tries ConPTY
     first, falls back to pipeSession.

3. Agent stops after 15 tool calls
   MaxToolIterations bumped 15 → 500. Doc comment explains why the
   cap exists at all (infinite-loop safety) and that 500 is well
   above realistic usage.

- internal/version/version.go: 0.7.5 → 0.7.6
- CHANGELOG.md: v0.7.6 entry covers the three fixes
2026-04-27 14:04:41 +02:00

266 lines
7.1 KiB
Go

//go:build windows
package api
// Windows ConPTY (Pseudo Console) backend for the terminal tab.
//
// creack/pty/v2 returns "operating system not supported" on Windows, so the
// previous fallback was plain stdin/stdout pipes (terminal_session.go::
// pipeSession). Pipes don't carry TTY signals, so cmd.exe / pwsh / wsl
// detect "no TTY" and either go silent or wait forever — the user sees a
// black screen. This file implements a real pseudo console using the
// kernel32 ConPTY API, so the spawned shell behaves as if it were attached
// to a real terminal: prompts render, ANSI escapes are honoured, resize
// events propagate.
//
// Requires Windows 10 v1809 (build 17763) or newer. On older hosts
// CreatePseudoConsole returns an error and startTermSession_windows falls
// back to pipeSession.
import (
"fmt"
"io"
"os/exec"
"sync"
"syscall"
"unsafe"
"golang.org/x/sys/windows"
)
const (
procThreadAttributePseudoconsole = 0x00020016
extendedStartupinfoPresent = 0x00080000
createUnicodeEnvironment = 0x00000400
)
// conptySession drives a Windows pseudo console.
type conptySession struct {
hPC windows.Handle
inWrite windows.Handle
outRead windows.Handle
procInfo windows.ProcessInformation
closed bool
mu sync.Mutex
}
// startConptySession spins up the pseudo console, plumbs the pipes, and
// CreateProcessW's the child with the PC attached via STARTUPINFOEX.
func startConptySession(cmd *exec.Cmd) (termSession, error) {
// 1. Two pipe pairs: in (we write → child stdin) and out (child stdout → we read).
var inRead, inWrite, outRead, outWrite windows.Handle
if err := windows.CreatePipe(&inRead, &inWrite, nil, 0); err != nil {
return nil, fmt.Errorf("create stdin pipe: %w", err)
}
if err := windows.CreatePipe(&outRead, &outWrite, nil, 0); err != nil {
windows.CloseHandle(inRead)
windows.CloseHandle(inWrite)
return nil, fmt.Errorf("create stdout pipe: %w", err)
}
// 2. Create the pseudo console. After this call ConPTY effectively owns
// the child-facing pipe ends (inRead, outWrite); we close our copy.
var hPC windows.Handle
sz := windows.Coord{X: 120, Y: 30}
if err := windows.CreatePseudoConsole(sz, inRead, outWrite, 0, &hPC); err != nil {
windows.CloseHandle(inRead)
windows.CloseHandle(inWrite)
windows.CloseHandle(outRead)
windows.CloseHandle(outWrite)
return nil, fmt.Errorf("CreatePseudoConsole: %w", err)
}
windows.CloseHandle(inRead)
windows.CloseHandle(outWrite)
// 3. Allocate an attribute list with one slot for the PC attribute.
attrList, err := windows.NewProcThreadAttributeList(1)
if err != nil {
windows.ClosePseudoConsole(hPC)
windows.CloseHandle(inWrite)
windows.CloseHandle(outRead)
return nil, fmt.Errorf("NewProcThreadAttributeList: %w", err)
}
if err := attrList.Update(
procThreadAttributePseudoconsole,
unsafe.Pointer(&hPC),
unsafe.Sizeof(hPC),
); err != nil {
attrList.Delete()
windows.ClosePseudoConsole(hPC)
windows.CloseHandle(inWrite)
windows.CloseHandle(outRead)
return nil, fmt.Errorf("attrList.Update: %w", err)
}
// 4. Build command line.
cmdLine, err := buildCommandLine(cmd)
if err != nil {
attrList.Delete()
windows.ClosePseudoConsole(hPC)
windows.CloseHandle(inWrite)
windows.CloseHandle(outRead)
return nil, err
}
cmdLineUTF16, err := windows.UTF16PtrFromString(cmdLine)
if err != nil {
attrList.Delete()
windows.ClosePseudoConsole(hPC)
windows.CloseHandle(inWrite)
windows.CloseHandle(outRead)
return nil, err
}
// 5. Build the env block (key=value\0...\0\0).
var envBlock *uint16
if cmd.Env != nil {
eb, err := makeEnvBlock(cmd.Env)
if err != nil {
attrList.Delete()
windows.ClosePseudoConsole(hPC)
windows.CloseHandle(inWrite)
windows.CloseHandle(outRead)
return nil, err
}
envBlock = eb
}
si := windows.StartupInfoEx{}
si.StartupInfo.Cb = uint32(unsafe.Sizeof(si))
si.ProcThreadAttributeList = attrList.List()
flags := uint32(extendedStartupinfoPresent)
if envBlock != nil {
flags |= createUnicodeEnvironment
}
var pi windows.ProcessInformation
err = windows.CreateProcess(
nil, // application name (null = parse from cmdline)
cmdLineUTF16,
nil, // process security attrs
nil, // thread security attrs
false, // inherit handles (ConPTY hands handles via attribute list)
flags,
envBlock,
nil, // working dir
&si.StartupInfo,
&pi,
)
attrList.Delete()
if err != nil {
windows.ClosePseudoConsole(hPC)
windows.CloseHandle(inWrite)
windows.CloseHandle(outRead)
return nil, fmt.Errorf("CreateProcess: %w", err)
}
return &conptySession{
hPC: hPC,
inWrite: inWrite,
outRead: outRead,
procInfo: pi,
}, nil
}
func (s *conptySession) Read(p []byte) (int, error) {
var n uint32
err := windows.ReadFile(s.outRead, p, &n, nil)
if err != nil {
if n > 0 {
return int(n), nil
}
return 0, io.EOF
}
return int(n), nil
}
func (s *conptySession) Write(p []byte) (int, error) {
var n uint32
err := windows.WriteFile(s.inWrite, p, &n, nil)
if err != nil {
return int(n), err
}
return int(n), nil
}
func (s *conptySession) Resize(rows, cols uint16) error {
return windows.ResizePseudoConsole(s.hPC, windows.Coord{X: int16(cols), Y: int16(rows)})
}
func (s *conptySession) Close() error {
s.mu.Lock()
if s.closed {
s.mu.Unlock()
return nil
}
s.closed = true
s.mu.Unlock()
// Order matters: close the pseudo console first so the child sees EOF,
// then close our pipe ends, then terminate / close handles.
windows.ClosePseudoConsole(s.hPC)
windows.CloseHandle(s.inWrite)
windows.CloseHandle(s.outRead)
if s.procInfo.Process != 0 {
windows.TerminateProcess(s.procInfo.Process, 0)
windows.CloseHandle(s.procInfo.Process)
}
if s.procInfo.Thread != 0 {
windows.CloseHandle(s.procInfo.Thread)
}
return nil
}
func (s *conptySession) Wait() error {
if s.procInfo.Process == 0 {
return nil
}
_, err := windows.WaitForSingleObject(s.procInfo.Process, windows.INFINITE)
return err
}
func (s *conptySession) Pid() int {
return int(s.procInfo.ProcessId)
}
// --- helpers -----------------------------------------------------------
// buildCommandLine produces the Windows command-line string for an
// *exec.Cmd, mirroring what os/exec uses internally (escaping spaces and
// quotes per Windows convention).
func buildCommandLine(cmd *exec.Cmd) (string, error) {
if cmd.Path == "" {
return "", fmt.Errorf("empty cmd.Path")
}
parts := []string{cmd.Path}
if len(cmd.Args) > 1 {
parts = append(parts, cmd.Args[1:]...)
}
out := syscall.EscapeArg(parts[0])
for _, a := range parts[1:] {
out += " " + syscall.EscapeArg(a)
}
return out, nil
}
// makeEnvBlock packs a Go environ slice into the Windows UTF-16 env block
// format: key=value\0key=value\0\0.
func makeEnvBlock(env []string) (*uint16, error) {
var buf []uint16
for _, kv := range env {
s, err := syscall.UTF16FromString(kv)
if err != nil {
return nil, err
}
buf = append(buf, s...) // includes trailing NUL
}
buf = append(buf, 0) // final terminator
if len(buf) == 0 {
return nil, nil
}
return &buf[0], nil
}
// Compile-time interface assertion.
var _ termSession = (*conptySession)(nil)