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
3 changes: 2 additions & 1 deletion go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -3,12 +3,13 @@ module github.com/sipeed/picoclaw
go 1.25.8

require (
github.com/BurntSushi/toml v1.6.0
fyne.io/systray v1.12.0
github.com/BurntSushi/toml v1.6.0
github.com/adhocore/gronx v1.19.6
github.com/anthropics/anthropic-sdk-go v1.26.0
github.com/bwmarrin/discordgo v0.29.0
github.com/caarlos0/env/v11 v11.4.0
github.com/creack/pty v1.1.9
github.com/ergochat/irc-go v0.6.0
github.com/ergochat/readline v0.1.3
github.com/gdamore/tcell/v2 v2.13.8
Expand Down
1 change: 1 addition & 0 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ github.com/coder/websocket v1.8.14 h1:9L0p0iKiNOibykf283eHkKUHHrpG7f65OE3BhhO7v9
github.com/coder/websocket v1.8.14/go.mod h1:NX3SzP+inril6yawo5CQXx8+fk145lPDC6pumgx0mVg=
github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc=
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
github.com/creack/pty v1.1.9 h1:uDmaGzcdjhF4i/plgjmEsriH11Y0o7RKapEf/LDaM3w=
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
Expand Down
5 changes: 3 additions & 2 deletions pkg/agent/instance_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -236,8 +236,9 @@ func TestNewAgentInstance_AllowsMediaTempDirForReadListAndExec(t *testing.T) {
t.Fatal("exec tool not registered")
}
execResult := execTool.Execute(context.Background(), map[string]any{
"command": "cat " + filepath.Base(mediaPath),
"working_dir": mediaDir,
"action": "run",
"command": "cat " + filepath.Base(mediaPath),
"cwd": mediaDir,
})
if execResult.IsError {
t.Fatalf("exec should allow media temp dir, got: %s", execResult.ForLLM)
Expand Down
252 changes: 252 additions & 0 deletions pkg/tools/session.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,252 @@
package tools

import (
"bytes"
"errors"
"io"
"os"
"sync"
"time"

"github.com/google/uuid"
)

const maxOutputBufferSize = 100 * 1024 * 1024 // 100MB

const outputTruncateMarker = "\n... [output truncated, exceeded 100MB]\n"

// PtyKeyMode represents arrow key encoding mode for PTY sessions.
// Programs send smkx/rmkx sequences to switch between CSI and SS3 modes.
type PtyKeyMode uint8

const (
PtyKeyModeCSI PtyKeyMode = iota // triggered by rmkx (\x1b[?1l)
PtyKeyModeSS3 // triggered by smkx (\x1b[?1h)
)

const PtyKeyModeNotFound PtyKeyMode = 255

var (
ErrSessionNotFound = errors.New("session not found")
ErrSessionDone = errors.New("session already completed")
ErrPTYNotSupported = errors.New("PTY is not supported on this platform")
ErrNoStdin = errors.New("no stdin available")
)

type ProcessSession struct {
mu sync.Mutex
ID string
PID int
Command string
PTY bool
Background bool
StartTime int64
ExitCode int
Status string
stdinWriter io.Writer
stdoutPipe io.Reader
outputBuffer *bytes.Buffer
outputTruncated bool
ptyMaster *os.File

// ptyKeyMode tracks arrow key encoding mode (CSI vs SS3)
ptyKeyMode PtyKeyMode
}

func (s *ProcessSession) IsDone() bool {
s.mu.Lock()
defer s.mu.Unlock()
return s.Status == "done" || s.Status == "exited"
}

func (s *ProcessSession) GetPtyKeyMode() PtyKeyMode {
s.mu.Lock()
defer s.mu.Unlock()
return s.ptyKeyMode
}

func (s *ProcessSession) SetPtyKeyMode(mode PtyKeyMode) {
s.mu.Lock()
defer s.mu.Unlock()
s.ptyKeyMode = mode
}

func (s *ProcessSession) GetStatus() string {
s.mu.Lock()
defer s.mu.Unlock()
return s.Status
}

func (s *ProcessSession) SetStatus(status string) {
s.mu.Lock()
defer s.mu.Unlock()
s.Status = status
}

func (s *ProcessSession) GetExitCode() int {
s.mu.Lock()
defer s.mu.Unlock()
return s.ExitCode
}

func (s *ProcessSession) SetExitCode(code int) {
s.mu.Lock()
defer s.mu.Unlock()
s.ExitCode = code
}

func (s *ProcessSession) killProcess() error {
s.mu.Lock()
defer s.mu.Unlock()

if s.Status != "running" {
return ErrSessionDone
}

pid := s.PID
if pid <= 0 {
return ErrSessionNotFound
}

if err := killProcessGroup(pid); err != nil {
return err
}

s.Status = "done"
s.ExitCode = -1
return nil
}

func (s *ProcessSession) Kill() error {
return s.killProcess()
}

func (s *ProcessSession) Write(data string) error {
s.mu.Lock()
defer s.mu.Unlock()

if s.Status != "running" {
return ErrSessionDone
}

var writer io.Writer
if s.PTY && s.ptyMaster != nil {
writer = s.ptyMaster
} else if s.stdinWriter != nil {
writer = s.stdinWriter
} else {
return ErrNoStdin
}

_, err := writer.Write([]byte(data))
return err
}

func (s *ProcessSession) Read() string {
s.mu.Lock()
defer s.mu.Unlock()

if s.outputBuffer.Len() == 0 {
return ""
}

data := s.outputBuffer.String()
s.outputBuffer.Reset()
return data
}

func (s *ProcessSession) ToSessionInfo() SessionInfo {
s.mu.Lock()
defer s.mu.Unlock()

return SessionInfo{
ID: s.ID,
Command: s.Command,
Status: s.Status,
PID: s.PID,
StartedAt: s.StartTime,
}
}

type SessionManager struct {
mu sync.RWMutex
sessions map[string]*ProcessSession
}

func NewSessionManager() *SessionManager {
sm := &SessionManager{
sessions: make(map[string]*ProcessSession),
}

// Start cleaner goroutine - runs every 5 minutes, cleans up sessions done for >30 minutes
go func() {
ticker := time.NewTicker(5 * time.Minute)
defer ticker.Stop()
for range ticker.C {
sm.cleanupOldSessions()
}
}()

return sm
}

// cleanupOldSessions removes sessions that are done and older than 30 minutes
func (sm *SessionManager) cleanupOldSessions() {
sm.mu.Lock()
defer sm.mu.Unlock()

cutoff := time.Now().Add(-30 * time.Minute)
for id, session := range sm.sessions {
if session.IsDone() && session.StartTime < cutoff.Unix() {
delete(sm.sessions, id)
}
}
}

func (sm *SessionManager) Add(session *ProcessSession) {
sm.mu.Lock()
defer sm.mu.Unlock()
sm.sessions[session.ID] = session
}

func (sm *SessionManager) Get(sessionID string) (*ProcessSession, error) {
sm.mu.RLock()
defer sm.mu.RUnlock()

session, ok := sm.sessions[sessionID]
if !ok {
return nil, ErrSessionNotFound
}

return session, nil
}

func (sm *SessionManager) Remove(sessionID string) {
sm.mu.Lock()
defer sm.mu.Unlock()
delete(sm.sessions, sessionID)
}

func (sm *SessionManager) List() []SessionInfo {
sm.mu.RLock()
defer sm.mu.RUnlock()

result := make([]SessionInfo, 0, len(sm.sessions))
for _, session := range sm.sessions {
result = append(result, session.ToSessionInfo())
}

return result
}

func generateSessionID() string {
return uuid.New().String()[:8]
}

type SessionInfo struct {
ID string `json:"id"`
Command string `json:"command"`
Status string `json:"status"`
PID int `json:"pid"`
StartedAt int64 `json:"startedAt"`
}
14 changes: 14 additions & 0 deletions pkg/tools/session_process_unix.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
//go:build !windows

package tools

import (
"syscall"
)

func killProcessGroup(pid int) error {
if err := syscall.Kill(-pid, syscall.SIGKILL); err != nil {
_ = syscall.Kill(pid, syscall.SIGKILL)
}
return nil
}
13 changes: 13 additions & 0 deletions pkg/tools/session_process_windows.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
//go:build windows

package tools

import (
"os/exec"
"strconv"
)

func killProcessGroup(pid int) error {
_ = exec.Command("taskkill", "/T", "/F", "/PID", strconv.Itoa(pid)).Run()
return nil
}
Loading
Loading