exec tools
This commit is contained in:
@@ -390,6 +390,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.
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
## Snapshots
|
## Snapshots
|
||||||
|
|||||||
@@ -0,0 +1,111 @@
|
|||||||
|
package agents
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"strings"
|
||||||
|
"syscall"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"g.fc.run/ai/byre/internal/mcp"
|
||||||
|
)
|
||||||
|
|
||||||
|
const bashKeep = 64 << 10
|
||||||
|
|
||||||
|
type bashArgs struct {
|
||||||
|
Command string `json:"command"`
|
||||||
|
TimeoutMs *int `json:"timeout_ms"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Manager) bashTool(l *live) mcp.Tool {
|
||||||
|
return mcp.Tool{
|
||||||
|
Name: "bash",
|
||||||
|
Description: "Run a shell command on the Mac byre runs on, in your task's worktree, with $SHELL as a login shell, and get its exit status and output (stdout and stderr together, the last 64 KiB). " +
|
||||||
|
"byre runs it itself, outside any sandbox your own shell tool has, with byre's environment and $PORT set to this task's first port. " +
|
||||||
|
"Stdin is empty. The command runs in its own process group, which is killed at the timeout.",
|
||||||
|
InputSchema: json.RawMessage(`{
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"command": {"type": "string"},
|
||||||
|
"timeout_ms": {"type": "integer", "description": "Kill the command after this long (default 120000, at most 600000)."}
|
||||||
|
},
|
||||||
|
"required": ["command"]
|
||||||
|
}`),
|
||||||
|
Handler: func(ctx context.Context, raw json.RawMessage) (string, error) {
|
||||||
|
return m.bash(ctx, l, raw)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Manager) bash(ctx context.Context, l *live, raw json.RawMessage) (string, error) {
|
||||||
|
args := bashArgs{}
|
||||||
|
err := json.Unmarshal(raw, &args)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("malformed bash call: %w", err)
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(args.Command) == "" {
|
||||||
|
return "", errors.New("command is empty")
|
||||||
|
}
|
||||||
|
timeout := defaultBashTimeout
|
||||||
|
if args.TimeoutMs != nil {
|
||||||
|
timeout = time.Duration(*args.TimeoutMs) * time.Millisecond
|
||||||
|
}
|
||||||
|
if timeout <= 0 || timeout > maxBashTimeout {
|
||||||
|
return "", fmt.Errorf("timeout_ms must be between 1 and %d", maxBashTimeout.Milliseconds())
|
||||||
|
}
|
||||||
|
task, err := m.store.GetTask(ctx, l.taskID)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
shell := os.Getenv("SHELL")
|
||||||
|
if shell == "" {
|
||||||
|
return "", errors.New("SHELL is not set in byre's environment")
|
||||||
|
}
|
||||||
|
|
||||||
|
cctx, cancel := context.WithTimeout(ctx, timeout)
|
||||||
|
defer cancel()
|
||||||
|
cmd := exec.CommandContext(cctx, shell, "-l", "-c", args.Command)
|
||||||
|
cmd.Dir = l.dir
|
||||||
|
cmd.Env = append(os.Environ(), portEnv(task)...)
|
||||||
|
cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true}
|
||||||
|
cmd.Cancel = func() error {
|
||||||
|
return syscall.Kill(-cmd.Process.Pid, syscall.SIGKILL)
|
||||||
|
}
|
||||||
|
cmd.WaitDelay = 5 * time.Second
|
||||||
|
out := &tailWriter{}
|
||||||
|
cmd.Stdout = out
|
||||||
|
cmd.Stderr = out
|
||||||
|
err = cmd.Run()
|
||||||
|
|
||||||
|
status := "exit status 0"
|
||||||
|
exit := &exec.ExitError{}
|
||||||
|
switch {
|
||||||
|
case ctx.Err() != nil:
|
||||||
|
return "", ctx.Err()
|
||||||
|
case cctx.Err() != nil:
|
||||||
|
status = fmt.Sprintf("killed after %s", timeout)
|
||||||
|
case errors.As(err, &exit):
|
||||||
|
status = exit.ProcessState.String()
|
||||||
|
case errors.Is(err, exec.ErrWaitDelay):
|
||||||
|
status = "exit status 0; output cut off after 5s: a background process kept it open"
|
||||||
|
case err != nil:
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return status + "\n" + string(out.b[max(0, len(out.b)-bashKeep):]), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type tailWriter struct {
|
||||||
|
b []byte
|
||||||
|
}
|
||||||
|
|
||||||
|
func (w *tailWriter) Write(p []byte) (int, error) {
|
||||||
|
w.b = append(w.b, p...)
|
||||||
|
if len(w.b) > 2*bashKeep {
|
||||||
|
w.b = append([]byte{}, w.b[len(w.b)-bashKeep:]...)
|
||||||
|
}
|
||||||
|
return len(p), nil
|
||||||
|
}
|
||||||
@@ -738,7 +738,7 @@ func (m *Manager) startLive(ctx context.Context, sess api.AgentSession, task api
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
tools := append([]mcp.Tool{m.askTool(l), m.cancelQuestionTool(l), m.setMergeTitleTool(l), m.stopMergeTool(l), m.mergeToMainTool(l), m.setTaskSummaryTool(l), m.searchClosedTool(l), m.exposePortTool(l), m.takeSnapshotTool(l), m.terminalReadTool(l), m.terminalTypeTool(l)}, append(m.subtaskTools(l), m.clientTools(l)...)...)
|
tools := append([]mcp.Tool{m.askTool(l), m.cancelQuestionTool(l), m.setMergeTitleTool(l), m.stopMergeTool(l), m.mergeToMainTool(l), m.setTaskSummaryTool(l), m.searchClosedTool(l), m.exposePortTool(l), m.takeSnapshotTool(l), m.terminalReadTool(l), m.terminalTypeTool(l), m.bashTool(l)}, append(m.subtaskTools(l), m.clientTools(l)...)...)
|
||||||
lead := true
|
lead := true
|
||||||
if sess.Role == api.Implementer {
|
if sess.Role == api.Implementer {
|
||||||
g += longTitleGuidance(task)
|
g += longTitleGuidance(task)
|
||||||
|
|||||||
@@ -206,6 +206,11 @@ type Session struct {
|
|||||||
pendingMu sync.Mutex
|
pendingMu sync.Mutex
|
||||||
pending map[string]chan controlResult
|
pending map[string]chan controlResult
|
||||||
|
|
||||||
|
ctx context.Context
|
||||||
|
stop context.CancelFunc
|
||||||
|
inflightMu sync.Mutex
|
||||||
|
inflight map[string]context.CancelFunc
|
||||||
|
|
||||||
hooks map[string]HookFunc
|
hooks map[string]HookFunc
|
||||||
servers map[string]*mcp.Server
|
servers map[string]*mcp.Server
|
||||||
|
|
||||||
@@ -236,12 +241,14 @@ func Start(ctx context.Context, opts Options) (*Session, error) {
|
|||||||
done: make(chan struct{}),
|
done: make(chan struct{}),
|
||||||
transcript: opts.Transcript,
|
transcript: opts.Transcript,
|
||||||
pending: map[string]chan controlResult{},
|
pending: map[string]chan controlResult{},
|
||||||
|
inflight: map[string]context.CancelFunc{},
|
||||||
hooks: map[string]HookFunc{},
|
hooks: map[string]HookFunc{},
|
||||||
servers: map[string]*mcp.Server{},
|
servers: map[string]*mcp.Server{},
|
||||||
id: initialID(opts),
|
id: initialID(opts),
|
||||||
onBadLine: opts.OnBadLine,
|
onBadLine: opts.OnBadLine,
|
||||||
}
|
}
|
||||||
s.lastLine.Store(time.Now().UnixNano())
|
s.lastLine.Store(time.Now().UnixNano())
|
||||||
|
s.ctx, s.stop = context.WithCancel(context.Background())
|
||||||
|
|
||||||
args, err := s.buildArgs(opts)
|
args, err := s.buildArgs(opts)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -556,6 +563,7 @@ func (s *Session) record(dir string, line []byte) {
|
|||||||
func (s *Session) readLoop(stdout io.Reader) {
|
func (s *Session) readLoop(stdout io.Reader) {
|
||||||
defer close(s.done)
|
defer close(s.done)
|
||||||
defer close(s.events)
|
defer close(s.events)
|
||||||
|
defer s.stop()
|
||||||
|
|
||||||
r := bufio.NewReader(stdout)
|
r := bufio.NewReader(stdout)
|
||||||
for {
|
for {
|
||||||
@@ -606,10 +614,23 @@ func (s *Session) handleLine(line []byte) {
|
|||||||
return
|
return
|
||||||
|
|
||||||
case "control_request":
|
case "control_request":
|
||||||
go s.handleControlRequest(env.RequestID, env.Request)
|
ctx, cancel := context.WithCancel(s.ctx)
|
||||||
|
s.inflightMu.Lock()
|
||||||
|
s.inflight[env.RequestID] = cancel
|
||||||
|
s.inflightMu.Unlock()
|
||||||
|
go s.handleControlRequest(ctx, cancel, env.RequestID, env.Request)
|
||||||
return
|
return
|
||||||
|
|
||||||
case "control_cancel_request", "keep_alive":
|
case "control_cancel_request":
|
||||||
|
s.inflightMu.Lock()
|
||||||
|
cancel := s.inflight[env.RequestID]
|
||||||
|
s.inflightMu.Unlock()
|
||||||
|
if cancel != nil {
|
||||||
|
cancel()
|
||||||
|
}
|
||||||
|
return
|
||||||
|
|
||||||
|
case "keep_alive":
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -674,8 +695,15 @@ func (s *Session) handleControlResponse(raw json.RawMessage) {
|
|||||||
ch <- controlResult{response: resp.Response}
|
ch <- controlResult{response: resp.Response}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Session) handleControlRequest(id string, raw json.RawMessage) {
|
func (s *Session) handleControlRequest(ctx context.Context, cancel context.CancelFunc, id string, raw json.RawMessage) {
|
||||||
result, err := s.dispatchControl(raw)
|
defer func() {
|
||||||
|
s.inflightMu.Lock()
|
||||||
|
delete(s.inflight, id)
|
||||||
|
s.inflightMu.Unlock()
|
||||||
|
cancel()
|
||||||
|
}()
|
||||||
|
|
||||||
|
result, err := s.dispatchControl(ctx, raw)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
slog.Error("control request failed", "request_id", id, "err", err)
|
slog.Error("control request failed", "request_id", id, "err", err)
|
||||||
s.write(map[string]any{
|
s.write(map[string]any{
|
||||||
@@ -699,7 +727,7 @@ func (s *Session) handleControlRequest(id string, raw json.RawMessage) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Session) dispatchControl(raw json.RawMessage) (any, error) {
|
func (s *Session) dispatchControl(ctx context.Context, raw json.RawMessage) (any, error) {
|
||||||
req := struct {
|
req := struct {
|
||||||
Subtype string `json:"subtype"`
|
Subtype string `json:"subtype"`
|
||||||
CallbackID string `json:"callback_id"`
|
CallbackID string `json:"callback_id"`
|
||||||
@@ -712,8 +740,6 @@ func (s *Session) dispatchControl(raw json.RawMessage) (any, error) {
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
ctx := context.Background()
|
|
||||||
|
|
||||||
switch req.Subtype {
|
switch req.Subtype {
|
||||||
case "hook_callback":
|
case "hook_callback":
|
||||||
fn := s.hooks[req.CallbackID]
|
fn := s.hooks[req.CallbackID]
|
||||||
|
|||||||
Reference in New Issue
Block a user