Wake agents when the human runs a command they left in the terminal
This commit is contained in:
@@ -5,6 +5,8 @@ import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
@@ -53,6 +55,7 @@ func (m *Manager) terminalTypeTool(l *live) mcp.Tool {
|
||||
Description: "Type into your task's terminal: the shell in this task's worktree that the human uses from the Terminal tab, starting it when none is running and waiting for its prompt before typing. " +
|
||||
"text goes to the shell exactly as keystrokes, so end a command with \"\\n\" to run it, and send control characters for keys (\"\\u0003\" is Ctrl-C). " +
|
||||
"The human sees everything you type and its output. " +
|
||||
"To hand the human a command to run (one needing their password, say), type it without the newline and tell them to press Enter in the Terminal tab. In a zsh terminal, the reply then says byre will message you when the shell next returns to its prompt, with the exit status and output, starting a turn if you are idle, so end your turn rather than asking whether they ran it. " +
|
||||
"Returns the output that followed, as terminal_read does, once the terminal has been quiet for half a second or after 10 seconds; read the rest of a longer command's output with terminal_read.",
|
||||
InputSchema: json.RawMessage(`{
|
||||
"type": "object",
|
||||
@@ -78,12 +81,33 @@ func (m *Manager) terminalTypeTool(l *live) mcp.Tool {
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return typeAndWait(ctx, term, args.Text)
|
||||
if args.Text == "" || strings.HasSuffix(args.Text, "\n") || strings.HasSuffix(args.Text, "\r") {
|
||||
return typeAndWait(ctx, term, args.Text, nil)
|
||||
}
|
||||
if !term.Prompts {
|
||||
out, err := typeAndWait(ctx, term, args.Text, nil)
|
||||
return out + "\n[this shell isn't zsh, so byre can't tell when the line is run: ask the human to tell you once they have run it]", err
|
||||
}
|
||||
out, err := typeAndWait(ctx, term, args.Text, m.wakeOnPrompt(l))
|
||||
return out + "\n[byre messages you when the shell is next back at its prompt]", err
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func typeAndWait(ctx context.Context, term *terminal.Terminal, text string) (string, error) {
|
||||
func (m *Manager) wakeOnPrompt(l *live) func(string, []byte) {
|
||||
return func(exit string, out []byte) {
|
||||
text := fmt.Sprintf("The line you left unsent in the terminal was run, and the shell is back at its prompt with exit status %s (130 means it was cancelled with Ctrl-C). The terminal's output since you typed it:\n\n%s", exit, terminal.Text(out))
|
||||
_, _, err := m.Message(context.Background(), l.id, "byre", text, "")
|
||||
if err != nil {
|
||||
slog.Error("wake agent on terminal prompt", "session", l.id, "task", l.taskID, "err", err)
|
||||
return
|
||||
}
|
||||
slog.Info("woke agent on terminal prompt", "session", l.id, "task", l.taskID, "exit", exit)
|
||||
}
|
||||
}
|
||||
|
||||
func typeAndWait(ctx context.Context, term *terminal.Terminal, text string, wake func(string, []byte)) (string, error) {
|
||||
term.CancelWake()
|
||||
backlog, out := term.Subscribe()
|
||||
if out == nil {
|
||||
exit, _ := term.ExitStatus()
|
||||
@@ -114,6 +138,9 @@ func typeAndWait(ctx context.Context, term *terminal.Terminal, text string) (str
|
||||
}
|
||||
typed.Add(1)
|
||||
}
|
||||
if wake != nil && typed.Load() == int64(len(text)) {
|
||||
term.WakeOnPrompt(wake)
|
||||
}
|
||||
written <- nil
|
||||
}()
|
||||
|
||||
|
||||
@@ -44,9 +44,13 @@ func New(dataDir string, portFirst int) (*Server, error) {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
terminals, err := terminal.New(filepath.Join(dataDir, "zsh"))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
s := &Server{
|
||||
hub: newHub(),
|
||||
terminals: terminal.New(),
|
||||
terminals: terminals,
|
||||
startedAt: time.Now().UTC(),
|
||||
users: map[string]string{},
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"log/slog"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"sync"
|
||||
"syscall"
|
||||
"time"
|
||||
@@ -20,16 +21,33 @@ const (
|
||||
backlogSize = 256 << 10
|
||||
clientBuf = 256
|
||||
MaxSize = 1 << 15
|
||||
promptMark = "\x1b]133;D;"
|
||||
)
|
||||
|
||||
const zshenv = `if [[ -n $BYRE_ZDOTDIR ]]; then
|
||||
ZDOTDIR=$BYRE_ZDOTDIR
|
||||
else
|
||||
unset ZDOTDIR
|
||||
fi
|
||||
unset BYRE_ZDOTDIR
|
||||
[[ -f ${ZDOTDIR:-$HOME}/.zshenv ]] && source ${ZDOTDIR:-$HOME}/.zshenv
|
||||
if [[ -o interactive ]]; then
|
||||
_byre_precmd() { print -n "\e]133;D;$?\a" }
|
||||
precmd_functions+=(_byre_precmd)
|
||||
fi
|
||||
`
|
||||
|
||||
type Terminals struct {
|
||||
zdotdir string
|
||||
|
||||
mu sync.Mutex
|
||||
byTask map[string]*Terminal
|
||||
}
|
||||
|
||||
type Terminal struct {
|
||||
ptmx *os.File
|
||||
cmd *exec.Cmd
|
||||
ptmx *os.File
|
||||
cmd *exec.Cmd
|
||||
Prompts bool
|
||||
|
||||
mu sync.Mutex
|
||||
backlog []byte
|
||||
@@ -38,10 +56,24 @@ type Terminal struct {
|
||||
exited bool
|
||||
started time.Time
|
||||
lastOutput time.Time
|
||||
wake *wake
|
||||
}
|
||||
|
||||
func New() *Terminals {
|
||||
return &Terminals{byTask: map[string]*Terminal{}}
|
||||
type wake struct {
|
||||
fn func(exit string, out []byte)
|
||||
out []byte
|
||||
}
|
||||
|
||||
func New(zdotdir string) (*Terminals, error) {
|
||||
err := os.MkdirAll(zdotdir, 0o755)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
err = os.WriteFile(filepath.Join(zdotdir, ".zshenv"), []byte(zshenv), 0o644)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &Terminals{zdotdir: zdotdir, byTask: map[string]*Terminal{}}, nil
|
||||
}
|
||||
|
||||
func (ts *Terminals) Get(taskID string) *Terminal {
|
||||
@@ -66,12 +98,16 @@ func (ts *Terminals) Attach(t api.Task, size api.TerminalSize) (*Terminal, error
|
||||
cmd := exec.Command(shell, "-l")
|
||||
cmd.Dir = t.WorktreePath
|
||||
cmd.Env = append(os.Environ(), "TERM=xterm-256color", "COLORTERM=truecolor", fmt.Sprintf("PORT=%d", t.PortBase))
|
||||
prompts := filepath.Base(shell) == "zsh"
|
||||
if prompts {
|
||||
cmd.Env = append(cmd.Env, "BYRE_ZDOTDIR="+os.Getenv("ZDOTDIR"), "ZDOTDIR="+ts.zdotdir)
|
||||
}
|
||||
ptmx, err := pty.StartWithSize(cmd, winsize(size))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("start %s: %w", shell, err)
|
||||
}
|
||||
|
||||
term = &Terminal{ptmx: ptmx, cmd: cmd, clients: map[chan []byte]bool{}, started: time.Now()}
|
||||
term = &Terminal{ptmx: ptmx, cmd: cmd, Prompts: prompts, clients: map[chan []byte]bool{}, started: time.Now()}
|
||||
ts.byTask[t.Id] = term
|
||||
slog.Info("terminal started", "task", t.Id, "shell", shell, "pid", cmd.Process.Pid)
|
||||
go func() {
|
||||
@@ -148,6 +184,9 @@ func (term *Terminal) output(b []byte) {
|
||||
defer term.mu.Unlock()
|
||||
|
||||
term.lastOutput = time.Now()
|
||||
if term.wake != nil {
|
||||
term.checkWake(b)
|
||||
}
|
||||
term.backlog = append(term.backlog, b...)
|
||||
if len(term.backlog) > 2*backlogSize {
|
||||
term.backlog = bytes.Clone(term.backlog[len(term.backlog)-backlogSize:])
|
||||
@@ -162,6 +201,38 @@ func (term *Terminal) output(b []byte) {
|
||||
}
|
||||
}
|
||||
|
||||
func (term *Terminal) checkWake(b []byte) {
|
||||
w := term.wake
|
||||
from := max(0, len(w.out)-len(promptMark)-8)
|
||||
w.out = append(w.out, b...)
|
||||
i := bytes.Index(w.out[from:], []byte(promptMark))
|
||||
if i < 0 {
|
||||
if len(w.out) > 2*backlogSize {
|
||||
w.out = bytes.Clone(w.out[len(w.out)-backlogSize:])
|
||||
}
|
||||
return
|
||||
}
|
||||
start := from + i
|
||||
end := bytes.IndexByte(w.out[start:], '\a')
|
||||
if end < 0 {
|
||||
return
|
||||
}
|
||||
term.wake = nil
|
||||
go w.fn(string(w.out[start+len(promptMark):start+end]), w.out[:start])
|
||||
}
|
||||
|
||||
func (term *Terminal) WakeOnPrompt(fn func(exit string, out []byte)) {
|
||||
term.mu.Lock()
|
||||
defer term.mu.Unlock()
|
||||
term.wake = &wake{fn: fn}
|
||||
}
|
||||
|
||||
func (term *Terminal) CancelWake() {
|
||||
term.mu.Lock()
|
||||
defer term.mu.Unlock()
|
||||
term.wake = nil
|
||||
}
|
||||
|
||||
func (term *Terminal) Backlog() []byte {
|
||||
term.mu.Lock()
|
||||
defer term.mu.Unlock()
|
||||
|
||||
Reference in New Issue
Block a user