All checks were successful
PR Check / check (pull_request) Successful in 1m0s
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
266 lines
7.1 KiB
Go
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)
|