Files
HomeAgent/internal/ptywin/conpty.go
JianFeeeee 147d0baaf9 fix: LLM 工具循环 400、中断消息注入、ConPTY 终端支持
- agent: 工具轮请求尾部补 user 占位(zen 网关强制),tool 消息正确配对
- agent: 工具提醒/中断以 system 角色注入并带 [中断消息] 前缀,不进用户履历;系统提示词说明中断消息格式
- agentcli: 基于 ConPTY 的交互式终端(ptywin fork),terminal_create/read/write/resize/close/watch
- webui: server 输出通道适配器(保留 reasoning_content/disable_thinking)
- GUI: 沉浸式标题栏、icon 圆角重制、mascot 等打磨
2026-08-14 00:48:40 +08:00

390 lines
10 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

//go:build windows
// +build windows
package ptywin
import (
"context"
"errors"
"fmt"
"unicode/utf16"
"unsafe"
"golang.org/x/sys/windows"
)
var (
modKernel32 = windows.NewLazySystemDLL("kernel32.dll")
fCreatePseudoConsole = modKernel32.NewProc("CreatePseudoConsole")
fResizePseudoConsole = modKernel32.NewProc("ResizePseudoConsole")
fClosePseudoConsole = modKernel32.NewProc("ClosePseudoConsole")
fInitializeProcThreadAttributeList = modKernel32.NewProc("InitializeProcThreadAttributeList")
fUpdateProcThreadAttribute = modKernel32.NewProc("UpdateProcThreadAttribute")
ErrConPtyUnsupported = errors.New("ConPty is not available on this version of Windows")
)
func IsConPtyAvailable() bool {
return fCreatePseudoConsole.Find() == nil &&
fResizePseudoConsole.Find() == nil &&
fClosePseudoConsole.Find() == nil &&
fInitializeProcThreadAttributeList.Find() == nil &&
fUpdateProcThreadAttribute.Find() == nil
}
const (
_STILL_ACTIVE uint32 = 259
_S_OK uintptr = 0
_PROC_THREAD_ATTRIBUTE_PSEUDOCONSOLE uintptr = 0x20016
defaultConsoleWidth = 80 // in characters
defaultConsoleHeight = 40 // in characters
)
type _COORD struct {
X, Y int16
}
func (c *_COORD) Pack() uintptr {
return uintptr((int32(c.Y) << 16) | int32(c.X))
}
type _HPCON windows.Handle
type handleIO struct {
handle windows.Handle
}
func (h *handleIO) Read(p []byte) (int, error) {
var numRead uint32 = 0
err := windows.ReadFile(h.handle, p, &numRead, nil)
return int(numRead), err
}
func (h *handleIO) Write(p []byte) (int, error) {
var numWritten uint32 = 0
err := windows.WriteFile(h.handle, p, &numWritten, nil)
return int(numWritten), err
}
func (h *handleIO) Close() error {
return windows.CloseHandle(h.handle)
}
type ConPty struct {
hpc _HPCON
pi *windows.ProcessInformation
ptyIn, ptyOut, cmdIn, cmdOut *handleIO
}
func win32ClosePseudoConsole(hPc _HPCON) {
if fClosePseudoConsole.Find() != nil {
return
}
// this kills the attached process. there is no return value.
fClosePseudoConsole.Call(uintptr(hPc))
}
func win32ResizePseudoConsole(hPc _HPCON, coord *_COORD) error {
if fResizePseudoConsole.Find() != nil {
return fmt.Errorf("ResizePseudoConsole not found")
}
ret, _, _ := fResizePseudoConsole.Call(uintptr(hPc), coord.Pack())
if ret != _S_OK {
return fmt.Errorf("ResizePseudoConsole failed with status 0x%x", ret)
}
return nil
}
func win32CreatePseudoConsole(c *_COORD, hIn, hOut windows.Handle) (_HPCON, error) {
if fCreatePseudoConsole.Find() != nil {
return 0, fmt.Errorf("CreatePseudoConsole not found")
}
var hPc _HPCON
ret, _, _ := fCreatePseudoConsole.Call(
c.Pack(),
uintptr(hIn),
uintptr(hOut),
0,
uintptr(unsafe.Pointer(&hPc)))
if ret != _S_OK {
return 0, fmt.Errorf("CreatePseudoConsole() failed with status 0x%x", ret)
}
return hPc, nil
}
type _StartupInfoEx struct {
startupInfo windows.StartupInfo
attributeList []byte
}
func getStartupInfoExForPTY(hpc _HPCON) (*_StartupInfoEx, error) {
if fInitializeProcThreadAttributeList.Find() != nil {
return nil, fmt.Errorf("InitializeProcThreadAttributeList not found")
}
if fUpdateProcThreadAttribute.Find() != nil {
return nil, fmt.Errorf("UpdateProcThreadAttribute not found")
}
var siEx _StartupInfoEx
siEx.startupInfo.Cb = uint32(unsafe.Sizeof(windows.StartupInfo{}) + unsafe.Sizeof(&siEx.attributeList[0]))
siEx.startupInfo.Flags |= windows.STARTF_USESTDHANDLES
var size uintptr
// first call is to get required size. this should return false.
ret, _, _ := fInitializeProcThreadAttributeList.Call(0, 1, 0, uintptr(unsafe.Pointer(&size)))
siEx.attributeList = make([]byte, size, size)
ret, _, err := fInitializeProcThreadAttributeList.Call(
uintptr(unsafe.Pointer(&siEx.attributeList[0])),
1,
0,
uintptr(unsafe.Pointer(&size)))
if ret != 1 {
return nil, fmt.Errorf("InitializeProcThreadAttributeList: %v", err)
}
ret, _, err = fUpdateProcThreadAttribute.Call(
uintptr(unsafe.Pointer(&siEx.attributeList[0])),
0,
_PROC_THREAD_ATTRIBUTE_PSEUDOCONSOLE,
uintptr(hpc),
unsafe.Sizeof(hpc),
0,
0)
if ret != 1 {
return nil, fmt.Errorf("InitializeProcThreadAttributeList: %v", err)
}
return &siEx, nil
}
func createConsoleProcessAttachedToPTY(hpc _HPCON, commandLine, workDir string, env []string) (*windows.ProcessInformation, error) {
cmdLine, err := windows.UTF16PtrFromString(commandLine)
if err != nil {
return nil, err
}
var currentDirectory *uint16
if workDir != "" {
currentDirectory, err = windows.UTF16PtrFromString(workDir)
if err != nil {
return nil, err
}
}
var envBlock *uint16
flags := uint32(windows.EXTENDED_STARTUPINFO_PRESENT)
if env != nil {
flags |= uint32(windows.CREATE_UNICODE_ENVIRONMENT)
envBlock = createEnvBlock(env)
}
siEx, err := getStartupInfoExForPTY(hpc)
if err != nil {
return nil, err
}
var pi windows.ProcessInformation
err = windows.CreateProcess(
nil, // use this if no args
cmdLine,
nil,
nil,
false, // inheritHandle
flags,
envBlock,
currentDirectory,
&siEx.startupInfo,
&pi)
if err != nil {
return nil, err
}
return &pi, nil
}
// createEnvBlock refers to syscall.createEnvBlock in go/src/syscall/exec_windows.go
// Sourced From: https://github.com/creack/pty/pull/155
func createEnvBlock(envv []string) *uint16 {
if len(envv) == 0 {
return &utf16.Encode([]rune("\x00\x00"))[0]
}
length := 0
for _, s := range envv {
length += len(s) + 1
}
length += 1
b := make([]byte, length)
i := 0
for _, s := range envv {
l := len(s)
copy(b[i:i+l], []byte(s))
copy(b[i+l:i+l+1], []byte{0})
i = i + l + 1
}
copy(b[i:i+1], []byte{0})
return &utf16.Encode([]rune(string(b)))[0]
}
// This will only return the first error.
func closeHandles(handles ...windows.Handle) error {
var err error
for _, h := range handles {
if h != windows.InvalidHandle {
if err == nil {
err = windows.CloseHandle(h)
} else {
windows.CloseHandle(h)
}
}
}
return err
}
// Close all open handles and terminate the process.
func (cpty *ConPty) Close() error {
// there is no return code
win32ClosePseudoConsole(cpty.hpc)
return closeHandles(
cpty.pi.Process,
cpty.pi.Thread,
cpty.ptyIn.handle,
cpty.ptyOut.handle,
cpty.cmdIn.handle,
cpty.cmdOut.handle)
}
// Wait for the process to exit and return the exit code. If context is canceled,
// Wait() will return STILL_ACTIVE and an error indicating the context was canceled.
func (cpty *ConPty) Wait(ctx context.Context) (uint32, error) {
var exitCode uint32 = _STILL_ACTIVE
for {
if err := ctx.Err(); err != nil {
return _STILL_ACTIVE, fmt.Errorf("wait canceled: %v", err)
}
ret, _ := windows.WaitForSingleObject(cpty.pi.Process, 1000)
if ret != uint32(windows.WAIT_TIMEOUT) {
err := windows.GetExitCodeProcess(cpty.pi.Process, &exitCode)
return exitCode, err
}
}
}
func (cpty *ConPty) Resize(width, height int) error {
coords := _COORD{
int16(width),
int16(height),
}
return win32ResizePseudoConsole(cpty.hpc, &coords)
}
func (cpty *ConPty) Read(p []byte) (int, error) {
return cpty.cmdOut.Read(p)
}
func (cpty *ConPty) Write(p []byte) (int, error) {
return cpty.cmdIn.Write(p)
}
func (cpty *ConPty) Pid() int {
return int(cpty.pi.ProcessId)
}
// Running 报告子进程是否仍在运行GetExitCodeProcess == STILL_ACTIVE
func (cpty *ConPty) Running() bool {
if cpty.pi == nil || cpty.pi.Process == 0 {
return false
}
var code uint32
err := windows.GetExitCodeProcess(cpty.pi.Process, &code)
if err != nil {
return false
}
return code == _STILL_ACTIVE
}
// Kill 强制终止子进程并关闭进程/线程句柄。
func (cpty *ConPty) Kill() error {
if cpty.pi == nil || cpty.pi.Process == 0 {
return nil
}
if err := windows.TerminateProcess(cpty.pi.Process, 1); err != nil {
return err
}
windows.CloseHandle(cpty.pi.Process)
windows.CloseHandle(cpty.pi.Thread)
cpty.pi.Process = 0
cpty.pi.Thread = 0
return nil
}
type conPtyArgs struct {
coords _COORD
workDir string
env []string
}
type ConPtyOption func(args *conPtyArgs)
func ConPtyDimensions(width, height int) ConPtyOption {
return func(args *conPtyArgs) {
args.coords.X = int16(width)
args.coords.Y = int16(height)
}
}
func ConPtyWorkDir(workDir string) ConPtyOption {
return func(args *conPtyArgs) {
args.workDir = workDir
}
}
func ConPtyEnv(env []string) ConPtyOption {
return func(args *conPtyArgs) {
args.env = env
}
}
// Start a new process specified in `commandLine` and attach a pseudo console using the Windows
// ConPty API. If ConPty is not available, ErrConPtyUnsupported will be returned.
//
// On successful return, an instance of ConPty is returned. You must call Close() on this to release
// any resources associated with the process. To get the exit code of the process, you can call Wait().
func Start(commandLine string, options ...ConPtyOption) (*ConPty, error) {
if !IsConPtyAvailable() {
return nil, ErrConPtyUnsupported
}
args := &conPtyArgs{
coords: _COORD{defaultConsoleWidth, defaultConsoleHeight},
}
for _, opt := range options {
opt(args)
}
var cmdIn, cmdOut, ptyIn, ptyOut windows.Handle
if err := windows.CreatePipe(&ptyIn, &cmdIn, nil, 0); err != nil {
return nil, fmt.Errorf("CreatePipe: %v", err)
}
if err := windows.CreatePipe(&cmdOut, &ptyOut, nil, 0); err != nil {
closeHandles(ptyIn, cmdIn)
return nil, fmt.Errorf("CreatePipe: %v", err)
}
hPc, err := win32CreatePseudoConsole(&args.coords, ptyIn, ptyOut)
if err != nil {
closeHandles(ptyIn, ptyOut, cmdIn, cmdOut)
return nil, err
}
pi, err := createConsoleProcessAttachedToPTY(hPc, commandLine, args.workDir, args.env)
if err != nil {
closeHandles(ptyIn, ptyOut, cmdIn, cmdOut)
win32ClosePseudoConsole(hPc)
return nil, fmt.Errorf("Failed to create console process: %v", err)
}
cpty := &ConPty{
hpc: hPc,
pi: pi,
ptyIn: &handleIO{ptyIn},
ptyOut: &handleIO{ptyOut},
cmdIn: &handleIO{cmdIn},
cmdOut: &handleIO{cmdOut},
}
return cpty, nil
}