256 lines
8.7 KiB
Go
256 lines
8.7 KiB
Go
package agents
|
|||
|
|
|
||
|
|
import (
|
||
|
|
"context"
|
||
|
|
"encoding/json"
|
||
|
|
"errors"
|
||
|
|
"fmt"
|
||
|
|
"os"
|
||
|
|
"path/filepath"
|
||
|
|
"sort"
|
||
|
|
"strings"
|
||
|
|
"time"
|
||
|
|
|
||
|
|
"g.fc.run/misc/byre/internal/api"
|
||
|
|
"g.fc.run/misc/byre/internal/clients"
|
||
|
|
"g.fc.run/misc/byre/internal/mcp"
|
||
|
|
)
|
||
|
|
|
||
|
|
const (
|
||
|
|
defaultBashTimeout = 2 * time.Minute
|
||
|
|
maxBashTimeout = 10 * time.Minute
|
||
|
|
replyGrace = 30 * time.Second
|
||
|
|
copyTimeout = 10 * time.Minute
|
||
|
|
handsTimeout = 11 * time.Minute
|
||
|
|
)
|
||
|
|
|
||
|
|
func (m *Manager) clientTools(l *live) []mcp.Tool {
|
||
|
|
return []mcp.Tool{
|
||
|
|
{
|
||
|
|
Name: "list_clients",
|
||
|
|
Description: "List the Macs with byre's app open: the human's own machines, not the one byre (and your worktree) runs on. " +
|
||
|
|
"Each has its name, which the other client tools take as client, the human using it, and when the human last did something in byre from it. " +
|
||
|
|
"The most recently active one is listed first; it is usually the Mac the human sits at.",
|
||
|
|
InputSchema: json.RawMessage(`{"type": "object", "properties": {}}`),
|
||
|
|
Handler: func(ctx context.Context, raw json.RawMessage) (string, error) {
|
||
|
|
return m.listClients(ctx)
|
||
|
|
},
|
||
|
|
},
|
||
|
|
{
|
||
|
|
Name: "client_bash",
|
||
|
|
Description: "Run a shell command on a client Mac, as the human, with zsh as a login shell in their home directory, and get its exit status and output (stdout and stderr together, the last 64 KiB). " +
|
||
|
|
"It runs at once, with no approval. The command runs in its own process group, which is killed at the timeout. " +
|
||
|
|
"A command that quits byre's app on that Mac, for example to install a new build, ends this call with the client disconnecting, but the command keeps running.",
|
||
|
|
InputSchema: json.RawMessage(`{
|
||
|
|
"type": "object",
|
||
|
|
"properties": {
|
||
|
|
"client": {"type": "string", "description": "The client's name from list_clients."},
|
||
|
|
"command": {"type": "string"},
|
||
|
|
"timeout_ms": {"type": "integer", "description": "Kill the command after this long (default 120000, at most 600000)."}
|
||
|
|
},
|
||
|
|
"required": ["client", "command"]
|
||
|
|
}`),
|
||
|
|
Handler: func(ctx context.Context, raw json.RawMessage) (string, error) {
|
||
|
|
return m.clientBash(ctx, raw)
|
||
|
|
},
|
||
|
|
},
|
||
|
|
{
|
||
|
|
Name: "client_copy",
|
||
|
|
Description: "Copy a file or directory from your worktree to a client Mac, replacing whatever is at dest there. " +
|
||
|
|
"Directories, app bundles included, are copied whole with their permissions and symlinks.",
|
||
|
|
InputSchema: json.RawMessage(`{
|
||
|
|
"type": "object",
|
||
|
|
"properties": {
|
||
|
|
"client": {"type": "string", "description": "The client's name from list_clients."},
|
||
|
|
"path": {"type": "string", "description": "The file or directory, relative to your worktree."},
|
||
|
|
"dest": {"type": "string", "description": "Its new path on the client, absolute or starting with ~/, e.g. ~/Applications/byre.app."}
|
||
|
|
},
|
||
|
|
"required": ["client", "path", "dest"]
|
||
|
|
}`),
|
||
|
|
Handler: func(ctx context.Context, raw json.RawMessage) (string, error) {
|
||
|
|
return m.clientCopy(ctx, l, raw)
|
||
|
|
},
|
||
|
|
},
|
||
|
|
{
|
||
|
|
Name: "client_hands",
|
||
|
|
Description: "Call one of your hands tools (screen_list, capture, ax_tree, act, browser) on a client Mac instead of the Mac byre runs on: " +
|
||
|
|
"tool is the tool's name and arguments exactly what that tool takes. Window ids, refs and displays are that Mac's, so start with screen_list there. " +
|
||
|
|
"Every agent using that Mac shares one set of refs, so read the tree again if another agent may have. " +
|
||
|
|
"A call fails until the human has given byre's app Screen Recording and Accessibility (and Automation of Orion, for browser) on that Mac; say so when it does.",
|
||
|
|
InputSchema: json.RawMessage(`{
|
||
|
|
"type": "object",
|
||
|
|
"properties": {
|
||
|
|
"client": {"type": "string", "description": "The client's name from list_clients."},
|
||
|
|
"tool": {"type": "string", "enum": ["screen_list", "capture", "ax_tree", "act", "browser"]},
|
||
|
|
"arguments": {"type": "object", "description": "The tool's arguments."}
|
||
|
|
},
|
||
|
|
"required": ["client", "tool"]
|
||
|
|
}`),
|
||
|
|
Rich: func(ctx context.Context, raw json.RawMessage) mcp.Result {
|
||
|
|
return m.clientHands(ctx, raw)
|
||
|
|
},
|
||
|
|
},
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
func (m *Manager) listClients(ctx context.Context) (string, error) {
|
||
|
|
status, err := m.status(ctx)
|
||
|
|
if err != nil {
|
||
|
|
return "", err
|
||
|
|
}
|
||
|
|
type row struct {
|
||
|
|
c clients.Client
|
||
|
|
last *time.Time
|
||
|
|
}
|
||
|
|
rows := []row{}
|
||
|
|
for _, c := range m.clients.List() {
|
||
|
|
r := row{c: c}
|
||
|
|
for _, s := range status.Clients {
|
||
|
|
if s.Login == c.Login && s.Host == c.Host && s.Commit == c.Commit {
|
||
|
|
r.last = s.LastActionAt
|
||
|
|
}
|
||
|
|
}
|
||
|
|
rows = append(rows, r)
|
||
|
|
}
|
||
|
|
if len(rows) == 0 {
|
||
|
|
return "No client Macs are connected.", nil
|
||
|
|
}
|
||
|
|
sort.SliceStable(rows, func(i, j int) bool {
|
||
|
|
a, b := rows[i].last, rows[j].last
|
||
|
|
return a != nil && (b == nil || a.After(*b))
|
||
|
|
})
|
||
|
|
|
||
|
|
now := time.Now()
|
||
|
|
b := &strings.Builder{}
|
||
|
|
for i, r := range rows {
|
||
|
|
fmt.Fprintf(b, "- %s (%s): ", r.c.Host, r.c.Login)
|
||
|
|
if r.last == nil {
|
||
|
|
b.WriteString("no action since byre started")
|
||
|
|
} else {
|
||
|
|
fmt.Fprintf(b, "last action %s ago", now.Sub(*r.last).Round(time.Second))
|
||
|
|
}
|
||
|
|
fmt.Fprintf(b, ", connected %s ago, app built from %s", now.Sub(r.c.ConnectedAt).Round(time.Second), r.c.Commit)
|
||
|
|
if i == 0 && r.last != nil {
|
||
|
|
b.WriteString(" (most recently active)")
|
||
|
|
}
|
||
|
|
b.WriteString("\n")
|
||
|
|
}
|
||
|
|
return b.String(), nil
|
||
|
|
}
|
||
|
|
|
||
|
|
type clientBashArgs struct {
|
||
|
|
Client string `json:"client"`
|
||
|
|
Command string `json:"command"`
|
||
|
|
TimeoutMs *int `json:"timeout_ms"`
|
||
|
|
}
|
||
|
|
|
||
|
|
func (m *Manager) clientBash(ctx context.Context, raw json.RawMessage) (string, error) {
|
||
|
|
args := clientBashArgs{}
|
||
|
|
err := json.Unmarshal(raw, &args)
|
||
|
|
if err != nil {
|
||
|
|
return "", fmt.Errorf("malformed client_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())
|
||
|
|
}
|
||
|
|
|
||
|
|
ms := int(timeout.Milliseconds())
|
||
|
|
cctx, cancel := context.WithTimeout(ctx, timeout+replyGrace)
|
||
|
|
defer cancel()
|
||
|
|
reply, err := m.clients.Call(cctx, args.Client, api.ClientRequest{Op: api.Bash, Command: &args.Command, TimeoutMs: &ms})
|
||
|
|
if err != nil {
|
||
|
|
return "", err
|
||
|
|
}
|
||
|
|
if reply.ExitCode == nil || reply.Output == nil {
|
||
|
|
return "", fmt.Errorf("%s's reply has no exit_code or output", args.Client)
|
||
|
|
}
|
||
|
|
status := fmt.Sprintf("exit status %d", *reply.ExitCode)
|
||
|
|
if *reply.ExitCode == -1 {
|
||
|
|
status = fmt.Sprintf("killed after %s", timeout)
|
||
|
|
}
|
||
|
|
return status + "\n" + *reply.Output, nil
|
||
|
|
}
|
||
|
|
|
||
|
|
type clientCopyArgs struct {
|
||
|
|
Client string `json:"client"`
|
||
|
|
Path string `json:"path"`
|
||
|
|
Dest string `json:"dest"`
|
||
|
|
}
|
||
|
|
|
||
|
|
func (m *Manager) clientCopy(ctx context.Context, l *live, raw json.RawMessage) (string, error) {
|
||
|
|
args := clientCopyArgs{}
|
||
|
|
err := json.Unmarshal(raw, &args)
|
||
|
|
if err != nil {
|
||
|
|
return "", fmt.Errorf("malformed client_copy call: %w", err)
|
||
|
|
}
|
||
|
|
if !filepath.IsAbs(args.Dest) && !strings.HasPrefix(args.Dest, "~/") {
|
||
|
|
return "", fmt.Errorf("dest %q must be absolute or start with ~/", args.Dest)
|
||
|
|
}
|
||
|
|
|
||
|
|
clean := filepath.Clean(args.Path)
|
||
|
|
if filepath.IsAbs(clean) {
|
||
|
|
return "", fmt.Errorf("path %q must be relative to your worktree", args.Path)
|
||
|
|
}
|
||
|
|
root, err := os.OpenRoot(l.dir)
|
||
|
|
if err != nil {
|
||
|
|
return "", err
|
||
|
|
}
|
||
|
|
_, err = root.Lstat(clean)
|
||
|
|
root.Close()
|
||
|
|
if err != nil {
|
||
|
|
return "", fmt.Errorf("path %q: %w", args.Path, err)
|
||
|
|
}
|
||
|
|
|
||
|
|
full := filepath.Join(l.dir, clean)
|
||
|
|
id := m.clients.AddTransfer(clients.Transfer{Dir: filepath.Dir(full), Name: filepath.Base(full)})
|
||
|
|
defer m.clients.RemoveTransfer(id)
|
||
|
|
|
||
|
|
cctx, cancel := context.WithTimeout(ctx, copyTimeout)
|
||
|
|
defer cancel()
|
||
|
|
_, err = m.clients.Call(cctx, args.Client, api.ClientRequest{Op: api.Copy, Transfer: &id, Dest: &args.Dest})
|
||
|
|
if err != nil {
|
||
|
|
return "", err
|
||
|
|
}
|
||
|
|
return fmt.Sprintf("Copied %s to %s on %s.", clean, args.Dest, args.Client), nil
|
||
|
|
}
|
||
|
|
|
||
|
|
type clientHandsArgs struct {
|
||
|
|
Client string `json:"client"`
|
||
|
|
Tool string `json:"tool"`
|
||
|
|
Arguments *map[string]any `json:"arguments"`
|
||
|
|
}
|
||
|
|
|
||
|
|
func (m *Manager) clientHands(ctx context.Context, raw json.RawMessage) mcp.Result {
|
||
|
|
args := clientHandsArgs{}
|
||
|
|
err := json.Unmarshal(raw, &args)
|
||
|
|
if err != nil {
|
||
|
|
return mcp.TextResult(fmt.Sprintf("malformed client_hands call: %s", err), true)
|
||
|
|
}
|
||
|
|
if args.Arguments == nil {
|
||
|
|
args.Arguments = &map[string]any{}
|
||
|
|
}
|
||
|
|
|
||
|
|
cctx, cancel := context.WithTimeout(ctx, handsTimeout)
|
||
|
|
defer cancel()
|
||
|
|
reply, err := m.clients.Call(cctx, args.Client, api.ClientRequest{Op: api.Hands, Tool: &args.Tool, Arguments: args.Arguments})
|
||
|
|
if err != nil {
|
||
|
|
return mcp.TextResult(err.Error(), true)
|
||
|
|
}
|
||
|
|
if reply.Result == nil {
|
||
|
|
return mcp.TextResult(fmt.Sprintf("%s's reply has no result", args.Client), true)
|
||
|
|
}
|
||
|
|
res := mcp.Result{}
|
||
|
|
err = remarshal(*reply.Result, &res)
|
||
|
|
if err != nil {
|
||
|
|
return mcp.TextResult(fmt.Sprintf("%s's result isn't an MCP tool result: %s", args.Client, err), true)
|
||
|
|
}
|
||
|
|
return res
|
||
|
|
}
|