iop/apps/node/internal/terminal/session.go
toki cd5425e89a feat(node): fix CLI adapter and session management, archive G07 task
- Fix CLI adapter files for proper session/workspace handling
- Fix session.go and session_test.go
- Archive 03+02_cli_agent_cwd task to archive
2026-06-13 20:36:48 +09:00

263 lines
4.9 KiB
Go

package terminal
import (
"context"
"errors"
"fmt"
"io"
"os"
"os/exec"
"strings"
"sync"
"time"
"github.com/creack/pty"
)
const (
terminalInputDelay = 2 * time.Millisecond
)
type Options struct {
Command string
Args []string
Env []string
Rows uint16
Cols uint16
Dir string
}
type Output struct {
Text string
}
type Snapshot struct {
Tail string
}
type Session interface {
Output() <-chan Output
Done() <-chan error
WritePrompt(ctx context.Context, prompt string) error
WriteInput(ctx context.Context, data []byte) error
Resize(rows, cols uint16) error
Signal(sig os.Signal) error
Snapshot() Snapshot
Close() error
}
type sessionImpl struct {
cmd *exec.Cmd
input io.WriteCloser
output io.ReadCloser
tail *TailBuffer
outputCh chan Output
doneCh chan error
closed bool
mu sync.Mutex
}
func StartSession(ctx context.Context, opts Options) (Session, error) {
cmd := exec.Command(opts.Command, opts.Args...)
if len(opts.Env) > 0 {
cmd.Env = append(cmd.Environ(), opts.Env...)
}
if opts.Dir != "" {
cmd.Dir = opts.Dir
}
rows := opts.Rows
if rows == 0 {
rows = 80
}
cols := opts.Cols
if cols == 0 {
cols = 240
}
ptmx, err := pty.StartWithSize(cmd, &pty.Winsize{
Rows: rows,
Cols: cols,
})
if err != nil {
return nil, fmt.Errorf("pty start: %w", err)
}
s := &sessionImpl{
cmd: cmd,
input: ptmx,
output: ptmx,
tail: NewTailBuffer(2048),
outputCh: make(chan Output, 1024),
doneCh: make(chan error, 1),
}
go s.readLoop()
return s, nil
}
func (s *sessionImpl) Output() <-chan Output {
return s.outputCh
}
func (s *sessionImpl) Done() <-chan error {
return s.doneCh
}
func (s *sessionImpl) readLoop() {
buf := make([]byte, 4096)
for {
n, err := s.output.Read(buf)
if n > 0 {
text := string(buf[:n])
s.tail.Append(text)
s.outputCh <- Output{Text: text}
}
if err != nil {
s.doneCh <- s.cmd.Wait()
close(s.outputCh)
return
}
}
}
func (s *sessionImpl) WritePrompt(ctx context.Context, prompt string) error {
s.mu.Lock()
if s.closed {
s.mu.Unlock()
return errors.New("terminal session closed")
}
s.mu.Unlock()
for _, r := range prompt {
s.mu.Lock()
if s.closed {
s.mu.Unlock()
return errors.New("terminal session closed")
}
_, err := io.WriteString(s.input, string(r))
s.mu.Unlock()
if err != nil {
return err
}
timer := time.NewTimer(terminalInputDelay)
select {
case <-ctx.Done():
timer.Stop()
return ctx.Err()
case <-timer.C:
}
}
s.mu.Lock()
defer s.mu.Unlock()
if s.closed {
return errors.New("terminal session closed")
}
_, err := io.WriteString(s.input, "\r")
return err
}
func (s *sessionImpl) Snapshot() Snapshot {
return Snapshot{
Tail: s.tail.String(),
}
}
func (s *sessionImpl) Resize(rows, cols uint16) error {
s.mu.Lock()
defer s.mu.Unlock()
if s.closed {
return errors.New("terminal session closed")
}
if rows == 0 || cols == 0 {
return errors.New("invalid terminal size: rows and cols must be greater than 0")
}
f, ok := s.input.(*os.File)
if !ok {
return errors.New("terminal input is not a file")
}
return pty.Setsize(f, &pty.Winsize{Rows: rows, Cols: cols})
}
func (s *sessionImpl) WriteInput(ctx context.Context, data []byte) error {
s.mu.Lock()
defer s.mu.Unlock()
if s.closed {
return errors.New("terminal session closed")
}
_, err := s.input.Write(data)
return err
}
func (s *sessionImpl) Signal(sig os.Signal) error {
s.mu.Lock()
defer s.mu.Unlock()
if s.closed {
return errors.New("terminal session closed")
}
if s.cmd == nil || s.cmd.Process == nil {
return errors.New("no process to signal")
}
return s.cmd.Process.Signal(sig)
}
func (s *sessionImpl) Close() error {
s.mu.Lock()
defer s.mu.Unlock()
if s.closed {
return nil
}
s.closed = true
err := s.input.Close()
if s.cmd != nil && s.cmd.Process != nil {
_ = s.cmd.Process.Kill()
}
if isAlreadyClosedError(err) {
return nil
}
return err
}
func isAlreadyClosedError(err error) bool {
if err == nil {
return false
}
if errors.Is(err, os.ErrClosed) {
return true
}
errStr := err.Error()
return strings.Contains(errStr, "file already closed") ||
strings.Contains(errStr, "use of closed file")
}
// TailBuffer holds a bounded buffer of terminal output.
type TailBuffer struct {
mu sync.Mutex
buf strings.Builder
max int
}
// NewTailBuffer creates a new TailBuffer with the given maximum capacity in bytes.
func NewTailBuffer(max int) *TailBuffer {
return &TailBuffer{max: max}
}
// Append appends a string to the buffer, truncating from the front if it exceeds capacity.
func (tb *TailBuffer) Append(s string) {
tb.mu.Lock()
defer tb.mu.Unlock()
tb.buf.WriteString(s)
raw := tb.buf.String()
if len(raw) <= tb.max {
return
}
tb.buf.Reset()
tb.buf.WriteString(raw[len(raw)-tb.max:])
}
// String returns the contents of the buffer.
func (tb *TailBuffer) String() string {
tb.mu.Lock()
defer tb.mu.Unlock()
return tb.buf.String()
}