Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
---
# beans-bz15
title: Migrate from creack/pty to aymanbagabas/go-pty for cross-platform PTY support
status: completed
type: task
priority: normal
created_at: 2026-03-20T17:50:59Z
updated_at: 2026-03-20T17:53:49Z
---

Replace creack/pty (Unix-only, unmaintained) with aymanbagabas/go-pty which provides a unified API for Unix PTY and Windows ConPTY. Only internal/terminal/terminal.go needs changes.

## Summary of Changes

Replaced `creack/pty` with `aymanbagabas/go-pty` in `internal/terminal/terminal.go`:
- Swapped `*os.File` PTY handle for `gopty.Pty` interface (`io.ReadWriteCloser` + `Resize`)
- Swapped `*exec.Cmd` for `*gopty.Cmd` (same API: `Process`, `Start`, `Wait`)
- Added `defaultShell()` helper with Windows support (`pwsh.exe`/`cmd.exe` fallback)
- All existing tests pass, no API changes to `Session` or `Manager`
4 changes: 3 additions & 1 deletion go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -8,12 +8,12 @@ require (
github.com/99designs/gqlgen v0.17.84
github.com/adrg/frontmatter v0.2.0
github.com/atotto/clipboard v0.1.4
github.com/aymanbagabas/go-pty v0.2.2
github.com/blevesearch/bleve/v2 v2.5.6
github.com/charmbracelet/bubbles v0.21.1-0.20250623103423-23b8fd6302d7
github.com/charmbracelet/bubbletea v1.3.10
github.com/charmbracelet/glamour v0.10.0
github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834
github.com/creack/pty v1.1.24
github.com/fsnotify/fsnotify v1.9.0
github.com/gin-gonic/gin v1.11.0
github.com/google/uuid v1.6.0
Expand Down Expand Up @@ -62,6 +62,7 @@ require (
github.com/clipperhouse/stringish v0.1.1 // indirect
github.com/clipperhouse/uax29/v2 v2.3.0 // indirect
github.com/cloudwego/base64x v0.1.6 // indirect
github.com/creack/pty v1.1.24 // indirect
github.com/dlclark/regexp2 v1.11.5 // indirect
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect
github.com/gabriel-vasile/mimetype v1.4.8 // indirect
Expand Down Expand Up @@ -99,6 +100,7 @@ require (
github.com/sosodev/duration v1.3.1 // indirect
github.com/spf13/pflag v1.0.10 // indirect
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
github.com/u-root/u-root v0.11.0 // indirect
github.com/ugorji/go/codec v1.3.0 // indirect
github.com/urfave/cli/v3 v3.6.1 // indirect
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
Expand Down
6 changes: 6 additions & 0 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,8 @@ github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z
github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI=
github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k=
github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8=
github.com/aymanbagabas/go-pty v0.2.2 h1:YZREB4eSj+1xdbbItIokX0ekjjeifgJOA+ZvxU4/WM8=
github.com/aymanbagabas/go-pty v0.2.2/go.mod h1:gfvlwH+0U66BCwxJREjJaAOEs9H1OFf3YFjI9WSiZ04=
github.com/aymanbagabas/go-udiff v0.3.1 h1:LV+qyBQ2pqe0u42ZsUEtPiCaUoqgA9gYRDs3vj1nolY=
github.com/aymanbagabas/go-udiff v0.3.1/go.mod h1:G0fsKmG+P6ylD0r6N/KgQD/nWzgfnl8ZBcNLgcbrw8E=
github.com/aymerick/douceur v0.2.0 h1:Mv+mAeH1Q+n9Fr+oyamOlAkUNPWPlA8PPGR0QAaYuPk=
Expand Down Expand Up @@ -225,6 +227,10 @@ github.com/tidwall/pretty v1.2.1 h1:qjsOFOWWQl+N3RsoF5/ssm1pHmJJwhjlSbZ51I6wMl4=
github.com/tidwall/pretty v1.2.1/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU=
github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI=
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
github.com/u-root/gobusybox/src v0.0.0-20221229083637-46b2883a7f90 h1:zTk5683I9K62wtZ6eUa6vu6IWwVHXPnoKK5n2unAwv0=
github.com/u-root/gobusybox/src v0.0.0-20221229083637-46b2883a7f90/go.mod h1:lYt+LVfZBBwDZ3+PHk4k/c/TnKOkjJXiJO73E32Mmpc=
github.com/u-root/u-root v0.11.0 h1:6gCZLOeRyevw7gbTwMj3fKxnr9+yHFlgF3N7udUVNO8=
github.com/u-root/u-root v0.11.0/go.mod h1:DBkDtiZyONk9hzVEdB/PWI9B4TxDkElWlVTHseglrZY=
github.com/ugorji/go/codec v1.3.0 h1:Qd2W2sQawAfG8XSvzwhBeoGq71zXOC/Q1E9y/wUcsUA=
github.com/ugorji/go/codec v1.3.0/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2Wjqmfxj4=
github.com/urfave/cli/v3 v3.6.1 h1:j8Qq8NyUawj/7rTYdBGrxcH7A/j7/G8Q5LhWEW4G3Mo=
Expand Down
94 changes: 69 additions & 25 deletions internal/terminal/terminal.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,10 @@ import (
"fmt"
"os"
"os/exec"
"runtime"
"sync"

"github.com/creack/pty"
gopty "github.com/aymanbagabas/go-pty"
)

const scrollbackSize = 64 * 1024 // 64KB
Expand Down Expand Up @@ -67,10 +68,10 @@ func (r *RingBuffer) Bytes() []byte {

// Session represents an active PTY session with scrollback buffering.
type Session struct {
id string
cmd *exec.Cmd
ptyF *os.File // PTY master file descriptor
mu sync.Mutex
id string
pty gopty.Pty
cmd *gopty.Cmd
mu sync.Mutex

scrollback *RingBuffer
scrollMu sync.Mutex
Expand All @@ -85,14 +86,14 @@ type Session struct {

// Write sends input to the PTY.
func (s *Session) Write(data []byte) (int, error) {
return s.ptyF.Write(data)
return s.pty.Write(data)
}

// Resize changes the PTY window size.
func (s *Session) Resize(cols, rows uint16) error {
s.mu.Lock()
defer s.mu.Unlock()
return pty.Setsize(s.ptyF, &pty.Winsize{Cols: cols, Rows: rows})
return s.pty.Resize(int(cols), int(rows))
}

// Attach connects a client to receive PTY output.
Expand Down Expand Up @@ -149,7 +150,7 @@ func (s *Session) readLoop() {
defer close(s.done)
buf := make([]byte, 4096)
for {
n, err := s.ptyF.Read(buf)
n, err := s.pty.Read(buf)
if n > 0 {
data := make([]byte, n)
copy(data, buf[:n])
Expand Down Expand Up @@ -183,10 +184,33 @@ func (s *Session) Close() {
if s.cmd.Process != nil {
_ = s.cmd.Process.Kill()
}
_ = s.ptyF.Close()
_ = s.pty.Close()
_ = s.cmd.Wait()
}

// closeSlave closes the slave end of a Unix PTY in the parent process after
// the child has been started. This is necessary on Linux so the master gets
// EOF when the child exits. On non-Unix platforms this is a no-op.
func closeSlave(p gopty.Pty) {
if up, ok := p.(gopty.UnixPty); ok {
up.Slave().Close()
}
}

// defaultShell returns the default shell for the current platform.
func defaultShell() string {
if runtime.GOOS == "windows" {
if ps, err := exec.LookPath("pwsh.exe"); err == nil {
return ps
}
return "cmd.exe"
}
if shell := os.Getenv("SHELL"); shell != "" {
return shell
}
return "/bin/sh"
}

// EnvFunc returns extra environment variables for a given session ID.
// Used to inject workspace-specific env vars (e.g. BEANS_WORKSPACE_PORT).
type EnvFunc func(sessionID string) []string
Expand Down Expand Up @@ -241,28 +265,38 @@ func (m *Manager) GetOrCreate(sessionID, workDir string, cols, rows uint16) (*Se
}

func (m *Manager) createLocked(sessionID, workDir string, cols, rows uint16) (*Session, error) {
shell := os.Getenv("SHELL")
if shell == "" {
shell = "/bin/sh"
shell := defaultShell()

p, err := gopty.New()
if err != nil {
return nil, fmt.Errorf("failed to create PTY: %w", err)
}

if err := p.Resize(int(cols), int(rows)); err != nil {
p.Close()
return nil, fmt.Errorf("failed to resize PTY: %w", err)
}

cmd := exec.Command(shell, "-l")
cmd.Dir = workDir
env := append(os.Environ(), "TERM=xterm-256color")
if m.envFunc != nil {
env = append(env, m.envFunc(sessionID)...)
}

cmd := p.Command(shell, "-l")
cmd.Dir = workDir
cmd.Env = env

ptmx, err := pty.StartWithSize(cmd, &pty.Winsize{Cols: cols, Rows: rows})
if err != nil {
if err := cmd.Start(); err != nil {
p.Close()
return nil, fmt.Errorf("failed to start PTY: %w", err)
}

closeSlave(p)

sess := &Session{
id: sessionID,
pty: p,
cmd: cmd,
ptyF: ptmx,
scrollback: NewRingBuffer(scrollbackSize),
done: make(chan struct{}),
}
Expand All @@ -285,28 +319,38 @@ func (m *Manager) CreateWithCommand(sessionID, workDir string, cols, rows uint16
delete(m.sessions, sessionID)
}

shell := os.Getenv("SHELL")
if shell == "" {
shell = "/bin/sh"
shell := defaultShell()

p, err := gopty.New()
if err != nil {
return nil, fmt.Errorf("failed to create PTY: %w", err)
}

if err := p.Resize(int(cols), int(rows)); err != nil {
p.Close()
return nil, fmt.Errorf("failed to resize PTY: %w", err)
}

cmd := exec.Command(shell, "-l", "-c", command)
cmd.Dir = workDir
env := append(os.Environ(), "TERM=xterm-256color")
if m.envFunc != nil {
env = append(env, m.envFunc(sessionID)...)
}

cmd := p.Command(shell, "-l", "-c", command)
cmd.Dir = workDir
cmd.Env = env

ptmx, err := pty.StartWithSize(cmd, &pty.Winsize{Cols: cols, Rows: rows})
if err != nil {
if err := cmd.Start(); err != nil {
p.Close()
return nil, fmt.Errorf("failed to start PTY: %w", err)
}

closeSlave(p)

sess := &Session{
id: sessionID,
pty: p,
cmd: cmd,
ptyF: ptmx,
scrollback: NewRingBuffer(scrollbackSize),
done: make(chan struct{}),
}
Expand Down
Loading