Wake agents when the human runs a command they left in the terminal
This commit is contained in:
@@ -421,6 +421,8 @@ Each active task can have one terminal: a login shell (`$SHELL -l`, from byre's
|
|||||||
|
|
||||||
A task's agents use the same terminal through two MCP tools (`internal/agents/terminal.go`). `terminal_read` returns the backlog as plain text; it starts no shell, and says so when none is running. `terminal_type` writes `text` to the PTY as keystrokes, starting the shell at 120×40 if none is running and leaving a running one's size alone (refused once the task is closed). Within a shell's first 10s, it first waits for the prompt: until the shell has printed something and then gone 500ms without output (`Terminal.Activity` gives the start and last output times), or until 10s after the start. Typing before that would reach the PTY while it still echoes in cooked mode, before zsh's line editor takes over, so the text would show twice. The startup output, prompt included, is part of the reply. It then returns, as plain text, the output that followed, once none has arrived for 500ms or after 10s, whichever comes first, holding at most the last 512 KiB of raw output while it waits. It types on its own goroutine one byte per write and stops between bytes once the call returns. A program in raw mode that stops reading blocks a PTY write, and the PTY's file doesn't support deadlines, so typing one byte at a time keeps the call within its limits while leaving at most one byte to arrive late. The reply then says how many bytes were typed. Typing from an agent doesn't count as the human acting (`last_action_at`). The plain text comes from `terminal.Text` (`internal/terminal/text.go`). It replays the output one line at a time, keeping track of the cursor column, so a carriage return overwrites the line (progress bars, zsh's `\r\r\n` after a command) instead of duplicating it, and a backspace moves the cursor back. It applies erase-to-end-of-line (`CSI K`) and drops every other escape sequence and control character. It keeps the last 64 KiB, cut at a line start. Full-screen programs come out garbled, since it isn't a screen emulator.
|
A task's agents use the same terminal through two MCP tools (`internal/agents/terminal.go`). `terminal_read` returns the backlog as plain text; it starts no shell, and says so when none is running. `terminal_type` writes `text` to the PTY as keystrokes, starting the shell at 120×40 if none is running and leaving a running one's size alone (refused once the task is closed). Within a shell's first 10s, it first waits for the prompt: until the shell has printed something and then gone 500ms without output (`Terminal.Activity` gives the start and last output times), or until 10s after the start. Typing before that would reach the PTY while it still echoes in cooked mode, before zsh's line editor takes over, so the text would show twice. The startup output, prompt included, is part of the reply. It then returns, as plain text, the output that followed, once none has arrived for 500ms or after 10s, whichever comes first, holding at most the last 512 KiB of raw output while it waits. It types on its own goroutine one byte per write and stops between bytes once the call returns. A program in raw mode that stops reading blocks a PTY write, and the PTY's file doesn't support deadlines, so typing one byte at a time keeps the call within its limits while leaving at most one byte to arrive late. The reply then says how many bytes were typed. Typing from an agent doesn't count as the human acting (`last_action_at`). The plain text comes from `terminal.Text` (`internal/terminal/text.go`). It replays the output one line at a time, keeping track of the cursor column, so a carriage return overwrites the line (progress bars, zsh's `\r\r\n` after a command) instead of duplicating it, and a backspace moves the cursor back. It applies erase-to-end-of-line (`CSI K`) and drops every other escape sequence and control character. It keeps the last 64 KiB, cut at a line start. Full-screen programs come out garbled, since it isn't a screen emulator.
|
||||||
|
|
||||||
|
A `terminal_type` whose text doesn't end in a newline (`\n` or `\r`) leaves a line for the human to run, and wakes its agent when the shell next returns to its prompt. A zsh terminal gets shell integration for this: `terminal.New` writes a `.zshenv` (the `zshenv` constant in `internal/terminal/terminal.go`) into `<data>/zsh`, and the shell starts with `ZDOTDIR` pointing there and the user's own `ZDOTDIR` (empty when unset) in `BYRE_ZDOTDIR`. That `.zshenv` puts `ZDOTDIR` back (unsetting it when it was empty), sources the user's `.zshenv`, and in an interactive shell appends a `precmd` hook that prints `ESC ] 133 ; D ; <exit status> BEL` (the OSC 133 "command finished" mark) before every prompt; the rest of the user's startup files then load from their own `ZDOTDIR` as usual. Terminals ignore OSC sequences they don't know, and `terminal.Text` drops them, so nothing shows. Other shells get no integration (`Terminal.Prompts` is false), and nothing wakes their agents. The reply to such a `terminal_type` ends with a line saying which: byre will message the agent, or, in another shell, the agent must ask the human to say when they have run it. Once all of the text is typed, `Terminal.WakeOnPrompt` arms a one-shot wake that collects the output from then on and fires at the first mark; every `terminal_type` first cancels any armed wake (`CancelWake`), so the agent pressing Enter itself, or typing more, doesn't wake it. A zsh line editor runs `precmd` after Enter on an empty line and after Ctrl-C too (exit status 130), so either wakes the agent. On the mark, byre sends that agent's session a `byre` message (`Manager.wakeOnPrompt`, through `Manager.Message`, so it starts a turn when idle, reaches it mid-turn, and is held while the human has interrupted the agent) with the exit status and the output as plain text. The log records `woke agent on terminal prompt` with the session, task and exit status.
|
||||||
|
|
||||||
For commands that don't need the human to see them, agents have the `bash` MCP tool (`internal/agents/bash.go`), for agents whose own shell tool runs in a sandbox that gets in the way. byre runs `$SHELL -l -c <command>` itself, as the service user and outside the agent's sandbox, in the task's worktree with byre's environment plus the task's `PORT`, stdin empty, stdout and stderr into one buffer, in its own process group. The group gets `SIGKILL` after `timeout_ms` (default 120000, at most 600000) or when the call is cancelled (in a Claude session, an MCP call's context ends when Claude sends `control_cancel_request` for it, as on an interrupt, or when the session's output ends), and the reply says `killed after <timeout>`; otherwise it starts with the exit status (`exit status 1`, `signal: killed`). The output is the last 64 KiB, as raw text. Background children holding the output open are waited for at most 5s after the command exits, and the reply then says the output was cut off.
|
For commands that don't need the human to see them, agents have the `bash` MCP tool (`internal/agents/bash.go`), for agents whose own shell tool runs in a sandbox that gets in the way. byre runs `$SHELL -l -c <command>` itself, as the service user and outside the agent's sandbox, in the task's worktree with byre's environment plus the task's `PORT`, stdin empty, stdout and stderr into one buffer, in its own process group. The group gets `SIGKILL` after `timeout_ms` (default 120000, at most 600000) or when the call is cancelled (in a Claude session, an MCP call's context ends when Claude sends `control_cancel_request` for it, as on an interrupt, or when the session's output ends), and the reply says `killed after <timeout>`; otherwise it starts with the exit status (`exit status 1`, `signal: killed`). The output is the last 64 KiB, as raw text. Background children holding the output open are waited for at most 5s after the command exits, and the reply then says the output was cut off.
|
||||||
|
|
||||||
The **Terminal** tab (`mac/Sources/Byre/Terminal.swift`) draws it with SwiftTerm's `TerminalView`. The terminal uses the UI font and is coloured from the palette (background, text, accent cursor, accent-tint selection); ANSI colours are SwiftTerm's defaults. The colours are set once per light/dark scheme (`TerminalSession.scheme`), never on other SwiftUI updates, since each colour set makes SwiftTerm redraw the whole screen and the tab re-renders on every task change in the store. It fits the pane and resizes with it. Opening the tab, or selecting another task while it is open, opens one socket for the selected task; switching tab or task closes it, and the shell keeps running, so coming back replays its recent output. A socket that drops for any other reason reconnects after 2s while the task is active; after a byre restart that starts a new shell. When the shell exits the header shows `shell ended: <status>` and a **New shell** button; when it can't be started (the socket closes with 1011, the error in byre's log as `start terminal`), the header says so, with the same button to try again. Ctrl key combinations typed in the terminal go to the shell, not to byre's Ctrl-K, Ctrl-P, Ctrl-L and Ctrl-S shortcuts; ⌘K, ⌘P, ⌘L and ⌘S still work.
|
The **Terminal** tab (`mac/Sources/Byre/Terminal.swift`) draws it with SwiftTerm's `TerminalView`. The terminal uses the UI font and is coloured from the palette (background, text, accent cursor, accent-tint selection); ANSI colours are SwiftTerm's defaults. The colours are set once per light/dark scheme (`TerminalSession.scheme`), never on other SwiftUI updates, since each colour set makes SwiftTerm redraw the whole screen and the tab re-renders on every task change in the store. It fits the pane and resizes with it. Opening the tab, or selecting another task while it is open, opens one socket for the selected task; switching tab or task closes it, and the shell keeps running, so coming back replays its recent output. A socket that drops for any other reason reconnects after 2s while the task is active; after a byre restart that starts a new shell. When the shell exits the header shows `shell ended: <status>` and a **New shell** button; when it can't be started (the socket closes with 1011, the error in byre's log as `start terminal`), the header says so, with the same button to try again. Ctrl key combinations typed in the terminal go to the shell, not to byre's Ctrl-K, Ctrl-P, Ctrl-L and Ctrl-S shortcuts; ⌘K, ⌘P, ⌘L and ⌘S still work.
|
||||||
|
|||||||
@@ -5,6 +5,8 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"log/slog"
|
||||||
|
"strings"
|
||||||
"sync/atomic"
|
"sync/atomic"
|
||||||
"time"
|
"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. " +
|
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). " +
|
"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. " +
|
"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.",
|
"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(`{
|
InputSchema: json.RawMessage(`{
|
||||||
"type": "object",
|
"type": "object",
|
||||||
@@ -78,12 +81,33 @@ func (m *Manager) terminalTypeTool(l *live) mcp.Tool {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return "", err
|
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()
|
backlog, out := term.Subscribe()
|
||||||
if out == nil {
|
if out == nil {
|
||||||
exit, _ := term.ExitStatus()
|
exit, _ := term.ExitStatus()
|
||||||
@@ -114,6 +138,9 @@ func typeAndWait(ctx context.Context, term *terminal.Terminal, text string) (str
|
|||||||
}
|
}
|
||||||
typed.Add(1)
|
typed.Add(1)
|
||||||
}
|
}
|
||||||
|
if wake != nil && typed.Load() == int64(len(text)) {
|
||||||
|
term.WakeOnPrompt(wake)
|
||||||
|
}
|
||||||
written <- nil
|
written <- nil
|
||||||
}()
|
}()
|
||||||
|
|
||||||
|
|||||||
@@ -44,9 +44,13 @@ func New(dataDir string, portFirst int) (*Server, error) {
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
terminals, err := terminal.New(filepath.Join(dataDir, "zsh"))
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
s := &Server{
|
s := &Server{
|
||||||
hub: newHub(),
|
hub: newHub(),
|
||||||
terminals: terminal.New(),
|
terminals: terminals,
|
||||||
startedAt: time.Now().UTC(),
|
startedAt: time.Now().UTC(),
|
||||||
users: map[string]string{},
|
users: map[string]string{},
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import (
|
|||||||
"log/slog"
|
"log/slog"
|
||||||
"os"
|
"os"
|
||||||
"os/exec"
|
"os/exec"
|
||||||
|
"path/filepath"
|
||||||
"sync"
|
"sync"
|
||||||
"syscall"
|
"syscall"
|
||||||
"time"
|
"time"
|
||||||
@@ -20,9 +21,25 @@ const (
|
|||||||
backlogSize = 256 << 10
|
backlogSize = 256 << 10
|
||||||
clientBuf = 256
|
clientBuf = 256
|
||||||
MaxSize = 1 << 15
|
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 {
|
type Terminals struct {
|
||||||
|
zdotdir string
|
||||||
|
|
||||||
mu sync.Mutex
|
mu sync.Mutex
|
||||||
byTask map[string]*Terminal
|
byTask map[string]*Terminal
|
||||||
}
|
}
|
||||||
@@ -30,6 +47,7 @@ type Terminals struct {
|
|||||||
type Terminal struct {
|
type Terminal struct {
|
||||||
ptmx *os.File
|
ptmx *os.File
|
||||||
cmd *exec.Cmd
|
cmd *exec.Cmd
|
||||||
|
Prompts bool
|
||||||
|
|
||||||
mu sync.Mutex
|
mu sync.Mutex
|
||||||
backlog []byte
|
backlog []byte
|
||||||
@@ -38,10 +56,24 @@ type Terminal struct {
|
|||||||
exited bool
|
exited bool
|
||||||
started time.Time
|
started time.Time
|
||||||
lastOutput time.Time
|
lastOutput time.Time
|
||||||
|
wake *wake
|
||||||
}
|
}
|
||||||
|
|
||||||
func New() *Terminals {
|
type wake struct {
|
||||||
return &Terminals{byTask: map[string]*Terminal{}}
|
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 {
|
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 := exec.Command(shell, "-l")
|
||||||
cmd.Dir = t.WorktreePath
|
cmd.Dir = t.WorktreePath
|
||||||
cmd.Env = append(os.Environ(), "TERM=xterm-256color", "COLORTERM=truecolor", fmt.Sprintf("PORT=%d", t.PortBase))
|
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))
|
ptmx, err := pty.StartWithSize(cmd, winsize(size))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("start %s: %w", shell, err)
|
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
|
ts.byTask[t.Id] = term
|
||||||
slog.Info("terminal started", "task", t.Id, "shell", shell, "pid", cmd.Process.Pid)
|
slog.Info("terminal started", "task", t.Id, "shell", shell, "pid", cmd.Process.Pid)
|
||||||
go func() {
|
go func() {
|
||||||
@@ -148,6 +184,9 @@ func (term *Terminal) output(b []byte) {
|
|||||||
defer term.mu.Unlock()
|
defer term.mu.Unlock()
|
||||||
|
|
||||||
term.lastOutput = time.Now()
|
term.lastOutput = time.Now()
|
||||||
|
if term.wake != nil {
|
||||||
|
term.checkWake(b)
|
||||||
|
}
|
||||||
term.backlog = append(term.backlog, b...)
|
term.backlog = append(term.backlog, b...)
|
||||||
if len(term.backlog) > 2*backlogSize {
|
if len(term.backlog) > 2*backlogSize {
|
||||||
term.backlog = bytes.Clone(term.backlog[len(term.backlog)-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 {
|
func (term *Terminal) Backlog() []byte {
|
||||||
term.mu.Lock()
|
term.mu.Lock()
|
||||||
defer term.mu.Unlock()
|
defer term.mu.Unlock()
|
||||||
|
|||||||
Reference in New Issue
Block a user