Add multiple terminals per task and saved commands that start one
This commit is contained in:
@@ -492,7 +492,7 @@ func (m *Manager) leadTools(l *live) []mcp.Tool {
|
||||
tool("show",
|
||||
"Point the human's right pane at a task, or at a project with no task, and select one of its tabs. The tabs' order is fixed. "+
|
||||
"Whenever the human asks you to show something (\"show me that when you can\") or you want to show something, call show right away; never hold back because you expect a refusal, since only a refused show arranges byre's signal to try again. "+
|
||||
"Tabs: transcript, board (a team task's board: its agents' posts, the human's and yours), changes, files, terminal (a shell in the task's worktree), workflow (a workflow stage task's run: its graph and each stage's state, attempts and outputs), project (the project's dashboard: its active tasks and its TODO.md items); with only project_id, only project shows anything. "+
|
||||
"Tabs: transcript, board (a team task's board: its agents' posts, the human's and yours), changes, files, terminal (the task's shells in its worktree, and buttons that start one running a saved command), workflow (a workflow stage task's run: its graph and each stage's state, attempts and outputs), project (the project's dashboard: its active tasks and its TODO.md items); with only project_id, only project shows anything. "+
|
||||
"Omit both task_id and project_id to clear the pane. "+
|
||||
"If the pane already shows that task (or project) on that tab, nothing changes and you are told so. "+
|
||||
"Otherwise refused for 10 seconds after the human last moved the pane themselves (switching task or tab), whatever else holds; if a show was refused for it, byre signals you \"The move hold is over\" when those 10 seconds pass without another move, and sends no signal otherwise. "+
|
||||
|
||||
+60
-65
@@ -1,7 +1,6 @@
|
||||
package agents
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
@@ -15,23 +14,34 @@ import (
|
||||
"g.fc.run/ai/byre/internal/terminal"
|
||||
)
|
||||
|
||||
const (
|
||||
typeQuiet = 500 * time.Millisecond
|
||||
typeMaxWait = 10 * time.Second
|
||||
typeKeep = 256 << 10
|
||||
)
|
||||
|
||||
var agentTerminalSize = api.TerminalSize{Cols: 120, Rows: 40}
|
||||
|
||||
const terminalArg = `"terminal": {"type": "string", "description": "The id of one of the task's terminals, as the list at the end of each reply gives it. Without it, the first."}`
|
||||
|
||||
type terminalReadArgs struct {
|
||||
Terminal string `json:"terminal"`
|
||||
}
|
||||
|
||||
func (m *Manager) terminalReadTool(l *live) mcp.Tool {
|
||||
return mcp.Tool{
|
||||
Name: "terminal_read",
|
||||
Description: "Read your task's terminal: the shell in this task's worktree that the human uses from the Terminal tab, and that terminal_type types into. " +
|
||||
"Returns its recent output as plain text, escape sequences removed, at most the last 64 KiB. " +
|
||||
Description: "Read one of your task's terminals: the shells in this task's worktree that the human uses from the Terminal tab, and that terminal_type types into. " +
|
||||
"Returns its recent output as plain text, escape sequences removed, at most the last 64 KiB, then the task's terminals by id and name. " +
|
||||
"Full-screen programs (vim, top, less) come out garbled, since this is the output stream, not a picture of the screen.",
|
||||
InputSchema: json.RawMessage(`{"type": "object", "properties": {}}`),
|
||||
Handler: func(ctx context.Context, args json.RawMessage) (string, error) {
|
||||
term := m.terminals.Get(l.taskID)
|
||||
InputSchema: json.RawMessage(`{"type": "object", "properties": {` + terminalArg + `}}`),
|
||||
Handler: func(ctx context.Context, raw json.RawMessage) (string, error) {
|
||||
args := terminalReadArgs{}
|
||||
err := json.Unmarshal(raw, &args)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("malformed terminal_read call: %w", err)
|
||||
}
|
||||
term := m.terminals.First(l.taskID)
|
||||
if args.Terminal != "" {
|
||||
term = m.terminals.Get(l.taskID, args.Terminal)
|
||||
if term == nil {
|
||||
return "", fmt.Errorf("no terminal %s in this task%s", args.Terminal, m.terminalList(l.taskID))
|
||||
}
|
||||
}
|
||||
if term == nil {
|
||||
return "No shell is running in this task's terminal.", nil
|
||||
}
|
||||
@@ -40,11 +50,23 @@ func (m *Manager) terminalReadTool(l *live) mcp.Tool {
|
||||
if exited {
|
||||
out += "\n[shell ended: " + exit + "]"
|
||||
}
|
||||
return out + inputLine(term), nil
|
||||
return out + inputLine(term) + m.terminalList(l.taskID), nil
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func (m *Manager) terminalList(taskID string) string {
|
||||
terms := m.terminals.List(taskID)
|
||||
if len(terms) == 0 {
|
||||
return ""
|
||||
}
|
||||
out := "\n[terminals:"
|
||||
for _, t := range terms {
|
||||
out += fmt.Sprintf(" %s %q", t.ID, t.Name)
|
||||
}
|
||||
return out + "]"
|
||||
}
|
||||
|
||||
func inputLine(term *terminal.Terminal) string {
|
||||
line, ok := term.InputLine()
|
||||
if !ok {
|
||||
@@ -74,15 +96,16 @@ func init() {
|
||||
}
|
||||
|
||||
type terminalTypeArgs struct {
|
||||
Keys []string `json:"keys"`
|
||||
Text string `json:"text"`
|
||||
Append bool `json:"append"`
|
||||
Terminal string `json:"terminal"`
|
||||
Keys []string `json:"keys"`
|
||||
Text string `json:"text"`
|
||||
Append bool `json:"append"`
|
||||
}
|
||||
|
||||
func (m *Manager) terminalTypeTool(l *live) mcp.Tool {
|
||||
return mcp.Tool{
|
||||
Name: "terminal_type",
|
||||
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 one of your task's terminals: the shells in this task's worktree that the human uses from the Terminal tab, by default the first, starting a shell when none is running and waiting for its prompt before typing. " +
|
||||
"keys are named keys sent first (ctrl-c cancels, ctrl-u clears the input line, enter runs it), then text goes to the shell exactly as keystrokes, so end a command with a real line break to run it (an escape you write in text, such as \"\\u0003\", can arrive as those literal characters, so send control keys through keys), or press enter in a later call. " +
|
||||
"When the input line still holds unsent text after keys, text is refused, so that it isn't joined onto someone else's command: clear it with ctrl-u first, or set append to add to it on purpose. " +
|
||||
"Replies from this tool and terminal_read end with the shell's current input line when zsh is at its prompt. " +
|
||||
@@ -92,6 +115,7 @@ func (m *Manager) terminalTypeTool(l *live) mcp.Tool {
|
||||
InputSchema: json.RawMessage(`{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
` + terminalArg + `,
|
||||
"keys": {"type": "array", "items": {"type": "string", "enum": ["ctrl-a", "ctrl-b", "ctrl-c", "ctrl-d", "ctrl-e", "ctrl-f", "ctrl-g", "ctrl-h", "ctrl-i", "ctrl-j", "ctrl-k", "ctrl-l", "ctrl-m", "ctrl-n", "ctrl-o", "ctrl-p", "ctrl-q", "ctrl-r", "ctrl-s", "ctrl-t", "ctrl-u", "ctrl-v", "ctrl-w", "ctrl-x", "ctrl-y", "ctrl-z", "enter", "escape", "tab", "backspace", "up", "down", "left", "right"]}, "description": "Named keys to press, in order, before text."},
|
||||
"text": {"type": "string", "description": "The keystrokes to send after keys."},
|
||||
"append": {"type": "boolean", "description": "Type text even though the input line already holds unsent text, adding to it."}
|
||||
@@ -110,9 +134,18 @@ func (m *Manager) terminalTypeTool(l *live) mcp.Tool {
|
||||
if task.Status != api.TaskStatusActive {
|
||||
return "", fmt.Errorf("task %s is %s", task.Id, task.Status)
|
||||
}
|
||||
term, err := m.terminals.Attach(task, agentTerminalSize)
|
||||
if err != nil {
|
||||
return "", err
|
||||
term := m.terminals.First(l.taskID)
|
||||
if args.Terminal != "" {
|
||||
term = m.terminals.Get(l.taskID, args.Terminal)
|
||||
if term == nil {
|
||||
return "", fmt.Errorf("no terminal %s in this task%s", args.Terminal, m.terminalList(l.taskID))
|
||||
}
|
||||
}
|
||||
if term == nil {
|
||||
term, err = m.terminals.Start(task, "Shell", "", agentTerminalSize)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
}
|
||||
keys := ""
|
||||
for _, k := range args.Keys {
|
||||
@@ -144,7 +177,7 @@ func (m *Manager) terminalTypeTool(l *live) mcp.Tool {
|
||||
sent += args.Text
|
||||
}
|
||||
}
|
||||
out += inputLine(term)
|
||||
out += inputLine(term) + m.terminalList(l.taskID)
|
||||
submitted := strings.HasSuffix(sent, "\n") || strings.HasSuffix(sent, "\r")
|
||||
if !term.Prompts {
|
||||
if args.Text != "" {
|
||||
@@ -184,9 +217,9 @@ func typeAndWait(ctx context.Context, term *terminal.Terminal, text string) (str
|
||||
defer term.Unsubscribe(out)
|
||||
|
||||
got := []byte{}
|
||||
if promptWait(term) > 0 {
|
||||
if term.Settling() {
|
||||
var err error
|
||||
got, err = awaitPrompt(ctx, term, out, keepTail(backlog))
|
||||
got, err = term.AwaitPrompt(ctx, out, terminal.KeepTail(backlog))
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
@@ -220,8 +253,8 @@ func typeAndWait(ctx context.Context, term *terminal.Terminal, text string) (str
|
||||
}
|
||||
return res
|
||||
}
|
||||
deadline := time.After(typeMaxWait)
|
||||
quiet := time.NewTimer(typeQuiet)
|
||||
deadline := time.After(terminal.MaxWait)
|
||||
quiet := time.NewTimer(terminal.Quiet)
|
||||
defer quiet.Stop()
|
||||
for {
|
||||
select {
|
||||
@@ -240,46 +273,8 @@ func typeAndWait(ctx context.Context, term *terminal.Terminal, text string) (str
|
||||
if !open {
|
||||
return result(), nil
|
||||
}
|
||||
got = keepTail(append(got, b...))
|
||||
quiet.Reset(typeQuiet)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func keepTail(b []byte) []byte {
|
||||
if len(b) > 2*typeKeep {
|
||||
return bytes.Clone(b[len(b)-typeKeep:])
|
||||
}
|
||||
return b
|
||||
}
|
||||
|
||||
func promptWait(term *terminal.Terminal) time.Duration {
|
||||
started, last := term.Activity()
|
||||
wait := time.Until(started.Add(typeMaxWait))
|
||||
if !last.IsZero() {
|
||||
wait = min(wait, time.Until(last.Add(typeQuiet)))
|
||||
}
|
||||
return wait
|
||||
}
|
||||
|
||||
func awaitPrompt(ctx context.Context, term *terminal.Terminal, out chan []byte, got []byte) ([]byte, error) {
|
||||
for {
|
||||
wait := promptWait(term)
|
||||
if wait <= 0 {
|
||||
return got, nil
|
||||
}
|
||||
timer := time.NewTimer(wait)
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
timer.Stop()
|
||||
return nil, ctx.Err()
|
||||
case <-timer.C:
|
||||
case b, open := <-out:
|
||||
timer.Stop()
|
||||
if !open {
|
||||
return got, nil
|
||||
}
|
||||
got = keepTail(append(got, b...))
|
||||
got = terminal.KeepTail(append(got, b...))
|
||||
quiet.Reset(terminal.Quiet)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user