diff --git a/cmd/byre/grep.go b/cmd/byre/grep.go new file mode 100644 index 0000000..036b1c0 --- /dev/null +++ b/cmd/byre/grep.go @@ -0,0 +1,26 @@ +package main + +import ( + "context" + "flag" + "fmt" + + "g.fc.run/ai/byre/internal/grep" +) + +func grepCmd(args []string) { + fs := flag.NewFlagSet("grep", flag.ExitOnError) + opts := grep.Options{} + fs.StringVar(&opts.Glob, "glob", "", "search only files whose names match this glob") + fs.BoolVar(&opts.IgnoreCase, "ignore-case", false, "match case-insensitively") + fs.BoolVar(&opts.FilesOnly, "files-only", false, "list matching files, not lines") + fs.Parse(args) + if fs.NArg() != 2 { + usage() + } + out, err := grep.Search(context.Background(), fs.Arg(0), "", fs.Arg(1), opts) + if err != nil { + fatal("grep failed", "err", err) + } + fmt.Print(out) +} diff --git a/cmd/byre/main.go b/cmd/byre/main.go index ec586f1..18e8581 100644 --- a/cmd/byre/main.go +++ b/cmd/byre/main.go @@ -22,13 +22,15 @@ func main() { runCmd(os.Args[2:]) case "signing": signingCmd(os.Args[2:]) + case "grep": + grepCmd(os.Args[2:]) default: usage() } } func usage() { - fmt.Fprintln(os.Stderr, "usage: byre serve | byre install | byre check | byre run [--resume ID] | byre signing CHECKOUT") + fmt.Fprintln(os.Stderr, "usage: byre serve | byre install | byre check | byre run [--resume ID] | byre signing CHECKOUT | byre grep [--glob GLOB] [--ignore-case] [--files-only] PATTERN PATH") os.Exit(2) } diff --git a/docs/operations.md b/docs/operations.md index dcfbe95..59b31f3 100644 --- a/docs/operations.md +++ b/docs/operations.md @@ -378,7 +378,7 @@ Answers reach the agent as one message, listing each answer under the question i Task agents use the Macs the human has byre's app open on directly, with no approval, as they use the machine byre runs on. While it runs, the app (`mac/Sources/Byre/Remote.swift`, started with the global event stream and stopped with it) keeps a WebSocket open to `GET /api/clients/connect`, reconnecting 2s after it drops. The connection is keyed by its `Byre-Client` header and named by its `Byre-Host` header (both required); a newer connection with the same `Byre-Client` replaces the older one, while two apps open on one Mac each keep their own. The client tools take the host name, or the `id` from `list_clients`, which they need when more than one app is connected from that host (the call fails listing the ids). byre keeps the connections in memory (`internal/clients`), pinging each every 30s and dropping one that doesn't answer within 15s. The app pings too, every 10s, and reconnects when it has had no pong for 25s. That catches a socket byre dropped while the Mac slept: the close never reached the Mac, and without something sent on it the app would wait on it indefinitely. Over it byre sends `ClientRequest` text messages, each with a fresh `id`, and the app answers each with a `ClientReply` carrying that `id` (`error` when it couldn't carry it out), in any order, handling requests concurrently. A request whose client disconnects before replying fails with " disconnected before replying". -Implementer sessions get nine MCP tools for it (`internal/agents/clients.go`). The app sets its WebSocket's `maximumMessageSize` to 256 MiB, the same as byre's read limit, so file contents fit in one message each way. +Implementer sessions get ten MCP tools for it (`internal/agents/clients.go`). The app sets its WebSocket's `maximumMessageSize` to 256 MiB, the same as byre's read limit, so file contents fit in one message each way. - `list_clients`: the status's `clients` (see "Status"), with each one's host, login, `id`, connection time, app commit and `last_action_at`, and its iCloud sync error when its `sync` failed; the most recently active is listed first and marked. - `client_bash`: `op: bash`. The app runs `/bin/zsh -l -c ` in the human's home directory through `Process`, which starts it in its own process group, stdin from `/dev/null` and stdout and stderr into one temporary file, and kills the group with `SIGKILL` after `timeout_ms` (default 120000, at most 600000), replying `exit_code` -1. Otherwise `exit_code` is the exit status, or 128 plus the signal. `output` is the file's last 64 KiB. The agent's call waits the timeout plus 30s for the reply. Background children the command leaves are not waited for or killed, and a command that quits the app keeps running. @@ -388,6 +388,7 @@ Implementer sessions get nine MCP tools for it (`internal/agents/clients.go`). T - `client_read`: `op: read_file`; the app replies with the file's bytes in `data`, reading on a detached task, off the main actor. It resolves symlinks, refuses anything but a regular file (a FIFO would block the open), and reads at most 64 MiB plus one byte, refusing a longer file, so the base64 reply stays well under byre's 256 MiB read limit, past which byre would drop the connection. A `.png`, `.jpg`, `.jpeg`, `.gif` or `.webp` path comes back as an image result; other files must be UTF-8 and come back as `cat -n` style numbered lines from `offset` (default 1) for `limit` lines (default 2000), with a closing line naming the next offset when lines remain. - `client_write`: `op: write_file` with `path` and `data`; on a detached task, off the main actor, the app resolves symlinks, refuses an existing path that isn't a regular file (a FIFO would block the write), creates the parent directories and writes the file in place (`Data.write` without `.atomic`), so an existing file keeps its permissions. - `client_edit`: a `read_file`, then a `write_file` of the text with `old_string` replaced by `new_string`, refused unless `old_string` appears exactly once. Nothing locks the file or checks it is unchanged between the two, so a write in between, whether another agent's `client_edit` of the same file or the human's or an app's, is lost. +- `client_grep`: `op: bash` running `/Applications/Byre.app/Contents/Helpers/byre grep` (see "File tools") with the call's flags, pattern and `path`, each single-quoted, `~/` left outside the quotes for the shell to expand. It waits the 2 minute timeout plus 30s, and a nonzero exit fails the call with the output. A client whose app predates `byre grep` fails with its usage line. `client_read`, `client_write` and `client_edit` wait at most 2 minutes for each reply, and their paths, like `client_download`'s `path`, are absolute or start with `~/`, which the app expands. - `client_hands`: `op: hands`, with `tool` and `arguments` as the `hands` tools take them (see "Screen and app control"). The app runs one `byre-hands`, bundled at `Contents/Helpers/byre-hands`, shared by its connections to every server (two run from that one path would cancel each other's ScreenCaptureKit connection, see "Screen and app control"), started on first use and again after it exits, writes each call to its stdin as a `tools/call` line and replies with the line's `result`, which byre returns to the agent as the tool's result, images included (`mcp.Tool.Rich`; Codex gets them as `inputImage` data URLs). The macOS privacy grants it needs (Screen Recording, Accessibility, Automation of Orion) belong to byre's app on that Mac, since the app is the responsible process; `NSAppleEventsUsageDescription` in `mac/Info.plist` is what lets macOS ask for the Automation grant. When `byre-hands` exits, the calls waiting on it fail. The agent's call waits for the reply 2 minutes plus the call's `wait_ms`, twice its `timeout_ms` (which `browser` and `chrome` spend once on the load and again on `wait_for`) and every `act` step's `wait`, then fails. @@ -428,6 +429,12 @@ A `terminal_type` that leaves text on the input line leaves a line for the human 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 ` 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 `; 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. +### File tools + +Beside `bash`, agents get `read`, `write`, `edit` and `grep` MCP tools (`internal/agents/files.go`), which byre carries out itself, as the service user and outside the agent's sandbox, the same set the client Macs get as `client_read`, `client_write`, `client_edit` and `client_grep`. A path is relative to the task's worktree, absolute, or starts with `~/` for the service user's home; nothing confines it to the worktree, as nothing confines `bash`. `read` and `edit` share the client tools' code: images come back as images, text as numbered lines, and `edit` refuses unless `old_string` appears exactly once. All three refuse a path that isn't a regular file (a FIFO would block), and `read` and `edit` refuse a file over 64 MiB, as `client_read` does. `write` creates parent directories and writes in place, so an existing file keeps its permissions. + +`grep` and `client_grep` run the same search, `internal/grep`, in-process on the server and as `byre grep [--glob GLOB] [--ignore-case] [--files-only] PATTERN PATH` on a client, from the `byre` binary every Byre.app carries. The pattern is Go RE2, matched line by line as each file streams through, so memory stays bounded; a line over 1 MiB isn't searched and prints as `path:line: line over 1 MiB, not searched`, and the deadline is checked before every read. A directory is walked recursively, without following symlinks, skipping `.git` entries, binary files (a NUL byte in the first 8000 bytes), unreadable files and whatever gitignore rules exclude: the repository's `info/exclude` (in a linked worktree, whose `.git` is a file, the one in the shared git directory its `gitdir` and `commondir` lead to) and every `.gitignore` from the repository root (the nearest ancestor holding `.git`, or the searched directory outside a repository) down, matched with go-git's `plumbing/format/gitignore`. The global `core.excludesFile` is not read. A `path` that is itself ignored, or inside an ignored directory, is searched anyway, minus what `.gitignore` files inside it exclude. `--glob` matches file names only. Matches print as `path:line:text`, the path as given joined with the file's path below it, lines cut at 500 bytes, and output stops at 64 KiB with a closing line saying so; no match prints `No matches.`. The server `grep` gives up after 2 minutes. + 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: ` 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 diff --git a/go.mod b/go.mod index ddfa650..eea8ea3 100644 --- a/go.mod +++ b/go.mod @@ -23,6 +23,9 @@ require ( github.com/dustin/go-humanize v1.0.1 // indirect github.com/fxamacker/cbor/v2 v2.9.3 // indirect github.com/gaissmai/bart v0.29.0 // indirect + github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 // indirect + github.com/go-git/go-billy/v5 v5.9.2 // indirect + github.com/go-git/go-git/v5 v5.19.3 // indirect github.com/go-json-experiment/json v0.0.0-20260820222146-c27c302e5fc3 // indirect github.com/go-openapi/jsonpointer v1.0.0 // indirect github.com/godbus/dbus/v5 v5.2.2 // indirect @@ -32,6 +35,7 @@ require ( github.com/google/uuid v1.6.0 // indirect github.com/hdevalence/ed25519consensus v0.2.0 // indirect github.com/huin/goupnp v1.3.0 // indirect + github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 // indirect github.com/jsimonetti/rtnetlink v1.4.2 // indirect github.com/klauspost/compress v1.20.0 // indirect github.com/mattn/go-isatty v0.0.24 // indirect @@ -71,6 +75,7 @@ require ( golang.org/x/tools v0.50.0 // indirect golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2 // indirect golang.zx2c4.com/wireguard/windows v1.0.1 // indirect + gopkg.in/warnings.v0 v0.1.2 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect gvisor.dev/gvisor v0.0.0-20260915211658-a6f909f08a72 // indirect modernc.org/libc v1.77.1 // indirect diff --git a/go.sum b/go.sum index 1833add..7d60390 100644 --- a/go.sum +++ b/go.sum @@ -100,6 +100,12 @@ github.com/getkin/kin-openapi v0.142.0 h1:izj0vBdFprMhitfzaX8sTqztsEQyvwhssBoB6n github.com/getkin/kin-openapi v0.142.0/go.mod h1:3BH9M9XDe/y9M5DSvEocVYAYq1w0qrhJHjC/vZi0AaY= github.com/github/fakeca v0.1.0 h1:Km/MVOFvclqxPM9dZBC4+QE564nU4gz4iZ0D9pMw28I= github.com/github/fakeca v0.1.0/go.mod h1:+bormgoGMMuamOscx7N91aOuUST7wdaJ2rNjeohylyo= +github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 h1:+zs/tPmkDkHx3U66DAb0lQFJrpS6731Oaa12ikc+DiI= +github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376/go.mod h1:an3vInlBmSxCcxctByoQdvwPiA7DTK7jaaFDBTtu0ic= +github.com/go-git/go-billy/v5 v5.9.2 h1:OXFSRyz4g20upsGDJgQG9Bak1l/ZEv8GHVYB52O71sE= +github.com/go-git/go-billy/v5 v5.9.2/go.mod h1:ExsU+jcGwXTBOnyilvAnEM1wug1IxHr4yP2ZXsNRtV0= +github.com/go-git/go-git/v5 v5.19.3 h1:qHttbZ+Am7wEjdTpR1XMQ4rl42FK9SIXzZ4o9x7s6M4= +github.com/go-git/go-git/v5 v5.19.3/go.mod h1:Ye4C8dVigkqrv9UsB4NMdSLV4yAIkLiIhW61gcOyhl4= github.com/go-json-experiment/json v0.0.0-20260820222146-c27c302e5fc3 h1:UADEEmDKgfXbtnGJZ97beY5XLo9ZechG1nlU4KnRrkE= github.com/go-json-experiment/json v0.0.0-20260820222146-c27c302e5fc3/go.mod h1:tphK2c80bpPhMOI4v6bIc2xWywPfbqi1Z06+RcrMkDg= github.com/go-ole/go-ole v1.3.0 h1:Dt6ye7+vXGIKZ7Xtk4s6/xVdGDQynvom7xCFEdWr6uE= @@ -156,6 +162,8 @@ github.com/illarion/gonotify/v3 v3.0.2 h1:O7S6vcopHexutmpObkeWsnzMJt/r1hONIEogeV github.com/illarion/gonotify/v3 v3.0.2/go.mod h1:HWGPdPe817GfvY3w7cx6zkbzNZfi3QjcBm/wgVvEL1U= github.com/insomniacslk/dhcp v0.0.0-20260901064844-234b97448fae h1:nXGg65fXsylSUTNNWwvHuQsXev7mhIzQTnZREPTbWzs= github.com/insomniacslk/dhcp v0.0.0-20260901064844-234b97448fae/go.mod h1:tGfUTcnFYGYvVNCaZZhwlJySU/fQQxh9TmpsFzWXnnY= +github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 h1:BQSFePA1RWJOlocH6Fxy8MmwDt+yVQYULKfN0RoTN8A= +github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99/go.mod h1:1lJo3i6rXxKeerYnT8Nvf0QmHCRC1n8sfWVwXF2Frvo= github.com/jellydator/ttlcache/v3 v3.4.1 h1:bOdXmXiycyK6E6Qjyuj5vl+/vU3SCOoDs8a86NbHjAQ= github.com/jellydator/ttlcache/v3 v3.4.1/go.mod h1:j7LO12PNghFg5+0v9budMAT4rDK4JY969jb9vOdOBBk= github.com/jsimonetti/rtnetlink v1.4.2 h1:Df9w9TZ3npHTyDn0Ev9e1uzmN2odmXd0QX+J5GTEn90= @@ -225,6 +233,7 @@ github.com/pierrec/lz4/v4 v4.1.26 h1:GrpZw1gZttORinvzBdXPUXATeqlJjqUG/D87TKMnhjY github.com/pierrec/lz4/v4 v4.1.26/go.mod h1:EoQMVJgeeEOMsCqCzqFm2O0cJvljX2nGZjcRIPL34O4= github.com/pires/go-proxyproto v0.15.0 h1:dTshmNbFm/D+0+sbrxUuddPOZ5Y0B7c5NhtsBkm6LqI= github.com/pires/go-proxyproto v0.15.0/go.mod h1:OXsCrKwrK2tXS9YrI5tkHx5xaQlO8FH3lFW76orFh24= +github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pkg/sftp v1.13.11 h1:0N92SLTB8JqASJB14ZLHHzFnBV8mG9zw4K7jghEFWuE= github.com/pkg/sftp v1.13.11/go.mod h1:uNkH9roSXglNJqM+glJJi+TQXQUm0fXFWqCFmT8hsN0= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= @@ -388,6 +397,8 @@ gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EV gopkg.in/fsnotify.v1 v1.4.7/go.mod h1:Tz8NjZHkW78fSQdbUxIjBTcgA1z1m8ZHf0WmKUhAMys= gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7 h1:uRGJdciOHaEIrze2W8Q3AKkepLTh2hOroT7a+7czfdQ= gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWDmTeBkI65Dw0HsyUHuEVlX15mw= +gopkg.in/warnings.v0 v0.1.2 h1:wFXVbFY8DY5/xOe1ECiWdKCzZlxgshcYVNkBHstARME= +gopkg.in/warnings.v0 v0.1.2/go.mod h1:jksf8JmL6Qr/oQM2OXTHunEvvTAsrWBLb6OOjuVWRNI= gopkg.in/yaml.v2 v2.2.1/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.2.4/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= diff --git a/internal/agents/clients.go b/internal/agents/clients.go index cab80e7..80ca7e6 100644 --- a/internal/agents/clients.go +++ b/internal/agents/clients.go @@ -3,7 +3,6 @@ package agents import ( "context" "crypto/rand" - "encoding/base64" "encoding/json" "errors" "fmt" @@ -14,7 +13,6 @@ import ( "sort" "strings" "time" - "unicode/utf8" "g.fc.run/ai/byre/internal/api" "g.fc.run/ai/byre/internal/clients" @@ -29,6 +27,7 @@ const ( fileTimeout = 2 * time.Minute handsTimeout = 2 * time.Minute defaultReadLimit = 2000 + clientByre = "/Applications/Byre.app/Contents/Helpers/byre" ) func (m *Manager) clientTools(l *live) []mcp.Tool { @@ -163,6 +162,25 @@ func (m *Manager) clientTools(l *live) []mcp.Tool { return m.clientEdit(ctx, raw) }, }, + { + Name: "client_grep", + Description: grepDescription + " It searches a client Mac, with the byre in its installed Byre.app.", + InputSchema: json.RawMessage(`{ + "type": "object", + "properties": { + "client": {"type": "string", "description": "The client's name from list_clients, or its id when several are connected from that Mac."}, + "pattern": {"type": "string"}, + "path": {"type": "string", "description": "The file or directory to search, absolute or starting with ~/."}, + "glob": {"type": "string", "description": "Search only files whose names match this glob, e.g. *.go."}, + "ignore_case": {"type": "boolean"}, + "files_only": {"type": "boolean", "description": "List the matching files instead of their lines."} + }, + "required": ["client", "pattern", "path"] + }`), + Handler: func(ctx context.Context, raw json.RawMessage) (string, error) { + return m.clientGrep(ctx, raw) + }, + }, { Name: "client_hands", Description: "Call one of your hands tools (screen_list, capture, ax_tree, act, browser, chrome) on a client Mac instead of the Mac byre runs on: " + @@ -452,8 +470,6 @@ func (m *Manager) writeClientFile(ctx context.Context, client, path string, data return err } -var imageTypes = map[string]string{".png": "image/png", ".jpg": "image/jpeg", ".jpeg": "image/jpeg", ".gif": "image/gif", ".webp": "image/webp"} - type clientReadArgs struct { Client string `json:"client"` Path string `json:"path"` @@ -471,48 +487,7 @@ func (m *Manager) clientRead(ctx context.Context, raw json.RawMessage) mcp.Resul if err != nil { return mcp.TextResult(err.Error(), true) } - mime, ok := imageTypes[strings.ToLower(filepath.Ext(args.Path))] - if ok { - return mcp.Result{Content: []mcp.Content{{Type: "image", Data: base64.StdEncoding.EncodeToString(data), MimeType: mime}}} - } - text, err := numberLines(args.Path, data, args.Offset, args.Limit) - if err != nil { - return mcp.TextResult(err.Error(), true) - } - return mcp.TextResult(text, false) -} - -func numberLines(path string, data []byte, offset, limit *int) (string, error) { - if !utf8.Valid(data) { - return "", fmt.Errorf("%s isn't text; use client_download to copy it into your worktree", path) - } - if len(data) == 0 { - return fmt.Sprintf("%s is empty.", path), nil - } - start := 1 - if offset != nil { - start = *offset - } - n := defaultReadLimit - if limit != nil { - n = *limit - } - if start < 1 || n < 1 { - return "", errors.New("offset and limit must be at least 1") - } - lines := strings.Split(strings.TrimSuffix(string(data), "\n"), "\n") - if start > len(lines) { - return "", fmt.Errorf("%s has %d lines; offset %d is past the end", path, len(lines), start) - } - end := min(start-1+n, len(lines)) - b := &strings.Builder{} - for i := start - 1; i < end; i++ { - fmt.Fprintf(b, "%6d\t%s\n", i+1, lines[i]) - } - if end < len(lines) { - fmt.Fprintf(b, "(%d more lines; read on with offset %d)\n", len(lines)-end, end+1) - } - return b.String(), nil + return readResult(args.Path, data, args.Offset, args.Limit) } type clientWriteArgs struct { @@ -547,34 +522,73 @@ func (m *Manager) clientEdit(ctx context.Context, raw json.RawMessage) (string, if err != nil { return "", fmt.Errorf("malformed client_edit call: %w", err) } - if args.OldString == "" { - return "", errors.New("old_string is empty") - } - if args.OldString == args.NewString { - return "", errors.New("old_string and new_string are the same") - } data, err := m.readClientFile(ctx, args.Client, args.Path) if err != nil { return "", err } - if !utf8.Valid(data) { - return "", fmt.Errorf("%s isn't text", args.Path) + edited, err := replaceOnce(args.Path, data, args.OldString, args.NewString) + if err != nil { + return "", err } - text := string(data) - n := strings.Count(text, args.OldString) - if n == 0 { - return "", fmt.Errorf("old_string isn't in %s", args.Path) - } - if n > 1 { - return "", fmt.Errorf("old_string appears %d times in %s; include more of the text around it so it appears once", n, args.Path) - } - err = m.writeClientFile(ctx, args.Client, args.Path, []byte(strings.Replace(text, args.OldString, args.NewString, 1))) + err = m.writeClientFile(ctx, args.Client, args.Path, edited) if err != nil { return "", err } return fmt.Sprintf("Edited %s on %s.", args.Path, args.Client), nil } +type clientGrepArgs struct { + Client string `json:"client"` + grepArgs +} + +func shellQuote(s string) string { + return "'" + strings.ReplaceAll(s, "'", `'\''`) + "'" +} + +func (m *Manager) clientGrep(ctx context.Context, raw json.RawMessage) (string, error) { + args := clientGrepArgs{} + err := json.Unmarshal(raw, &args) + if err != nil { + return "", fmt.Errorf("malformed client_grep call: %w", err) + } + err = clientPath("path", args.Path) + if err != nil { + return "", err + } + path := shellQuote(args.Path) + rest, ok := strings.CutPrefix(args.Path, "~/") + if ok { + path = "~/" + shellQuote(rest) + } + command := clientByre + " grep" + if args.Glob != "" { + command += " --glob " + shellQuote(args.Glob) + } + if args.IgnoreCase { + command += " --ignore-case" + } + if args.FilesOnly { + command += " --files-only" + } + command += " -- " + shellQuote(args.Pattern) + " " + path + + ms := int(fileTimeout.Milliseconds()) + cctx, cancel := context.WithTimeout(ctx, fileTimeout+replyGrace) + defer cancel() + reply, err := m.clients.Call(cctx, args.Client, api.ClientRequest{Op: api.Bash, Command: &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) + } + if *reply.ExitCode != 0 { + return "", fmt.Errorf("byre grep on %s exited %d: %s", args.Client, *reply.ExitCode, *reply.Output) + } + return *reply.Output, nil +} + type clientHandsArgs struct { Client string `json:"client"` Tool string `json:"tool"` diff --git a/internal/agents/files.go b/internal/agents/files.go new file mode 100644 index 0000000..4e5bdb5 --- /dev/null +++ b/internal/agents/files.go @@ -0,0 +1,319 @@ +package agents + +import ( + "context" + "encoding/base64" + "encoding/json" + "errors" + "fmt" + "io/fs" + "os" + "path/filepath" + "strings" + "unicode/utf8" + + "g.fc.run/ai/byre/internal/grep" + "g.fc.run/ai/byre/internal/mcp" +) + +const grepDescription = "Search files for lines matching a regular expression (Go RE2 syntax), as ripgrep does: a directory is searched recursively, skipping .git, what .gitignore files exclude, and binary files. " + + "Each match comes back as path:line:text, at most 64 KiB in all." + +func (m *Manager) fileTools(l *live) []mcp.Tool { + return []mcp.Tool{ + { + Name: "read", + Description: "Read a file on the Mac byre runs on; byre reads it itself, outside any sandbox your own tools have. Text comes back with line numbers, starting at line 1 (or offset), at most limit lines. " + + "A PNG, JPEG, GIF or WebP image comes back as the image. Other binary files fail.", + InputSchema: json.RawMessage(`{ + "type": "object", + "properties": { + "path": {"type": "string", "description": "The file, relative to your worktree, absolute, or starting with ~/."}, + "offset": {"type": "integer", "description": "The line number to start from (default 1)."}, + "limit": {"type": "integer", "description": "The most lines to return (default 2000)."} + }, + "required": ["path"] + }`), + Rich: func(ctx context.Context, raw json.RawMessage) mcp.Result { + return m.read(l, raw) + }, + }, + { + Name: "write", + Description: "Write a file on the Mac byre runs on, replacing its contents, creating it and its parent directories when missing; byre writes it itself, outside any sandbox your own tools have.", + InputSchema: json.RawMessage(`{ + "type": "object", + "properties": { + "path": {"type": "string", "description": "The file, relative to your worktree, absolute, or starting with ~/."}, + "content": {"type": "string"} + }, + "required": ["path", "content"] + }`), + Handler: func(ctx context.Context, raw json.RawMessage) (string, error) { + return m.write(l, raw) + }, + }, + { + Name: "edit", + Description: "Replace old_string with new_string in a text file on the Mac byre runs on; byre edits it itself, outside any sandbox your own tools have. " + + "old_string must match the file exactly, whitespace included, and appear exactly once; otherwise the file is left as it was.", + InputSchema: json.RawMessage(`{ + "type": "object", + "properties": { + "path": {"type": "string", "description": "The file, relative to your worktree, absolute, or starting with ~/."}, + "old_string": {"type": "string"}, + "new_string": {"type": "string"} + }, + "required": ["path", "old_string", "new_string"] + }`), + Handler: func(ctx context.Context, raw json.RawMessage) (string, error) { + return m.edit(l, raw) + }, + }, + { + Name: "grep", + Description: grepDescription + " It searches the Mac byre runs on, outside any sandbox your own tools have.", + InputSchema: json.RawMessage(`{ + "type": "object", + "properties": { + "pattern": {"type": "string"}, + "path": {"type": "string", "description": "The file or directory to search, relative to your worktree, absolute, or starting with ~/ (default your worktree)."}, + "glob": {"type": "string", "description": "Search only files whose names match this glob, e.g. *.go."}, + "ignore_case": {"type": "boolean"}, + "files_only": {"type": "boolean", "description": "List the matching files instead of their lines."} + }, + "required": ["pattern"] + }`), + Handler: func(ctx context.Context, raw json.RawMessage) (string, error) { + return m.grep(ctx, l, raw) + }, + }, + } +} + +func (l *live) resolve(path string) (string, error) { + if path == "" { + return "", errors.New("path is empty") + } + rest, ok := strings.CutPrefix(path, "~/") + if ok { + home, err := os.UserHomeDir() + if err != nil { + return "", err + } + return filepath.Join(home, rest), nil + } + if filepath.IsAbs(path) { + return path, nil + } + return filepath.Join(l.dir, path), nil +} + +const readMax = 64 << 20 + +func regular(full string) (os.FileInfo, error) { + info, err := os.Stat(full) + if err != nil { + return nil, err + } + if !info.Mode().IsRegular() { + return nil, fmt.Errorf("%s isn't a regular file", full) + } + return info, nil +} + +func readLocal(full string) ([]byte, error) { + info, err := regular(full) + if err != nil { + return nil, err + } + if info.Size() > readMax { + return nil, fmt.Errorf("%s is %d bytes, over the 64 MiB limit", full, info.Size()) + } + return os.ReadFile(full) +} + +type readArgs struct { + Path string `json:"path"` + Offset *int `json:"offset"` + Limit *int `json:"limit"` +} + +func (m *Manager) read(l *live, raw json.RawMessage) mcp.Result { + args := readArgs{} + err := json.Unmarshal(raw, &args) + if err != nil { + return mcp.TextResult(fmt.Sprintf("malformed read call: %s", err), true) + } + full, err := l.resolve(args.Path) + if err != nil { + return mcp.TextResult(err.Error(), true) + } + data, err := readLocal(full) + if err != nil { + return mcp.TextResult(err.Error(), true) + } + return readResult(args.Path, data, args.Offset, args.Limit) +} + +type writeArgs struct { + Path string `json:"path"` + Content string `json:"content"` +} + +func (m *Manager) write(l *live, raw json.RawMessage) (string, error) { + args := writeArgs{} + err := json.Unmarshal(raw, &args) + if err != nil { + return "", fmt.Errorf("malformed write call: %w", err) + } + full, err := l.resolve(args.Path) + if err != nil { + return "", err + } + _, err = regular(full) + if err != nil && !errors.Is(err, fs.ErrNotExist) { + return "", err + } + err = os.MkdirAll(filepath.Dir(full), 0o755) + if err != nil { + return "", err + } + err = os.WriteFile(full, []byte(args.Content), 0o644) + if err != nil { + return "", err + } + return fmt.Sprintf("Wrote %d bytes to %s.", len(args.Content), args.Path), nil +} + +type editArgs struct { + Path string `json:"path"` + OldString string `json:"old_string"` + NewString string `json:"new_string"` +} + +func (m *Manager) edit(l *live, raw json.RawMessage) (string, error) { + args := editArgs{} + err := json.Unmarshal(raw, &args) + if err != nil { + return "", fmt.Errorf("malformed edit call: %w", err) + } + full, err := l.resolve(args.Path) + if err != nil { + return "", err + } + data, err := readLocal(full) + if err != nil { + return "", err + } + edited, err := replaceOnce(args.Path, data, args.OldString, args.NewString) + if err != nil { + return "", err + } + err = os.WriteFile(full, edited, 0o644) + if err != nil { + return "", err + } + return fmt.Sprintf("Edited %s.", args.Path), nil +} + +type grepArgs struct { + Pattern string `json:"pattern"` + Path string `json:"path"` + Glob string `json:"glob"` + IgnoreCase bool `json:"ignore_case"` + FilesOnly bool `json:"files_only"` +} + +func (a grepArgs) options() grep.Options { + return grep.Options{Glob: a.Glob, IgnoreCase: a.IgnoreCase, FilesOnly: a.FilesOnly} +} + +func (m *Manager) grep(ctx context.Context, l *live, raw json.RawMessage) (string, error) { + args := grepArgs{} + err := json.Unmarshal(raw, &args) + if err != nil { + return "", fmt.Errorf("malformed grep call: %w", err) + } + path := args.Path + if path == "" { + path = "." + } + if strings.HasPrefix(path, "~/") { + path, err = l.resolve(path) + if err != nil { + return "", err + } + } + cctx, cancel := context.WithTimeout(ctx, fileTimeout) + defer cancel() + return grep.Search(cctx, args.Pattern, l.dir, path, args.options()) +} + +var imageTypes = map[string]string{".png": "image/png", ".jpg": "image/jpeg", ".jpeg": "image/jpeg", ".gif": "image/gif", ".webp": "image/webp"} + +func readResult(path string, data []byte, offset, limit *int) mcp.Result { + mime, ok := imageTypes[strings.ToLower(filepath.Ext(path))] + if ok { + return mcp.Result{Content: []mcp.Content{{Type: "image", Data: base64.StdEncoding.EncodeToString(data), MimeType: mime}}} + } + text, err := numberLines(path, data, offset, limit) + if err != nil { + return mcp.TextResult(err.Error(), true) + } + return mcp.TextResult(text, false) +} + +func numberLines(path string, data []byte, offset, limit *int) (string, error) { + if !utf8.Valid(data) { + return "", fmt.Errorf("%s isn't text", path) + } + if len(data) == 0 { + return fmt.Sprintf("%s is empty.", path), nil + } + start := 1 + if offset != nil { + start = *offset + } + n := defaultReadLimit + if limit != nil { + n = *limit + } + if start < 1 || n < 1 { + return "", errors.New("offset and limit must be at least 1") + } + lines := strings.Split(strings.TrimSuffix(string(data), "\n"), "\n") + if start > len(lines) { + return "", fmt.Errorf("%s has %d lines; offset %d is past the end", path, len(lines), start) + } + end := min(start-1+n, len(lines)) + b := &strings.Builder{} + for i := start - 1; i < end; i++ { + fmt.Fprintf(b, "%6d\t%s\n", i+1, lines[i]) + } + if end < len(lines) { + fmt.Fprintf(b, "(%d more lines; read on with offset %d)\n", len(lines)-end, end+1) + } + return b.String(), nil +} + +func replaceOnce(path string, data []byte, old, new string) ([]byte, error) { + if old == "" { + return nil, errors.New("old_string is empty") + } + if old == new { + return nil, errors.New("old_string and new_string are the same") + } + if !utf8.Valid(data) { + return nil, fmt.Errorf("%s isn't text", path) + } + text := string(data) + n := strings.Count(text, old) + if n == 0 { + return nil, fmt.Errorf("old_string isn't in %s", path) + } + if n > 1 { + return nil, fmt.Errorf("old_string appears %d times in %s; include more of the text around it so it appears once", n, path) + } + return []byte(strings.Replace(text, old, new, 1)), nil +} diff --git a/internal/agents/manager.go b/internal/agents/manager.go index ac5e109..67badb3 100644 --- a/internal/agents/manager.go +++ b/internal/agents/manager.go @@ -857,7 +857,7 @@ func (m *Manager) startLive(ctx context.Context, sess api.AgentSession, task api if err != nil { return nil, err } - tools := append([]mcp.Tool{m.askTool(l), m.cancelQuestionTool(l), m.setTaskTitleTool(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), m.readKnowledgeTool()}, append(m.subtaskTools(l), m.clientTools(l)...)...) + tools := append([]mcp.Tool{m.askTool(l), m.cancelQuestionTool(l), m.setTaskTitleTool(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), m.readKnowledgeTool()}, append(append(m.fileTools(l), m.subtaskTools(l)...), m.clientTools(l)...)...) lead := true if sess.Role == api.Implementer { g += longTitleGuidance(task) diff --git a/internal/grep/grep.go b/internal/grep/grep.go new file mode 100644 index 0000000..e29dbdf --- /dev/null +++ b/internal/grep/grep.go @@ -0,0 +1,267 @@ +package grep + +import ( + "bufio" + "bytes" + "context" + "errors" + "fmt" + "io/fs" + "os" + "path/filepath" + "regexp" + "strings" + + "github.com/go-git/go-git/v5/plumbing/format/gitignore" +) + +const ( + keep = 64 << 10 + lineKeep = 500 + lineMax = 1 << 20 + sniff = 8000 +) + +type Options struct { + Glob string + IgnoreCase bool + FilesOnly bool +} + +type search struct { + ctx context.Context + re *regexp.Regexp + opts Options + given string + root string + base string + patterns []gitignore.Pattern + reader *bufio.Reader + out strings.Builder + full bool +} + +func Search(ctx context.Context, pattern, dir, path string, opts Options) (string, error) { + expr := pattern + if opts.IgnoreCase { + expr = "(?i)" + expr + } + re, err := regexp.Compile(expr) + if err != nil { + return "", fmt.Errorf("pattern: %w", err) + } + _, err = filepath.Match(opts.Glob, "") + if err != nil { + return "", fmt.Errorf("glob %q: %w", opts.Glob, err) + } + root := path + if !filepath.IsAbs(root) { + root = filepath.Join(dir, root) + } + root, err = filepath.Abs(root) + if err != nil { + return "", err + } + info, err := os.Stat(root) + if err != nil { + return "", err + } + + top := root + if !info.IsDir() { + top = filepath.Dir(root) + } + s := &search{ctx: ctx, re: re, opts: opts, given: path, root: root, base: repoRoot(top)} + s.load(filepath.Join(gitDir(s.base), "info", "exclude"), nil) + rel, err := filepath.Rel(s.base, top) + if err != nil { + return "", err + } + parts := split(rel) + for i := range parts { + s.load(filepath.Join(s.base, filepath.Join(parts[:i]...), ".gitignore"), parts[:i]) + } + if len(parts) > 0 && gitignore.NewMatcher(s.patterns).Match(parts, true) { + s.patterns = nil + } + + err = filepath.WalkDir(root, s.visit) + if err != nil { + return "", err + } + if s.full { + s.out.WriteString("(output cut off at 64 KiB; narrow the pattern, path or glob)\n") + } + if s.out.Len() == 0 { + return "No matches.", nil + } + return s.out.String(), nil +} + +func repoRoot(dir string) string { + for d := dir; ; d = filepath.Dir(d) { + _, err := os.Lstat(filepath.Join(d, ".git")) + if err == nil { + return d + } + if filepath.Dir(d) == d { + return dir + } + } +} + +func gitDir(base string) string { + dot := filepath.Join(base, ".git") + data, err := os.ReadFile(dot) + if err != nil { + return dot + } + dir, ok := strings.CutPrefix(strings.TrimSpace(string(data)), "gitdir: ") + if !ok { + return dot + } + if !filepath.IsAbs(dir) { + dir = filepath.Join(base, dir) + } + common, err := os.ReadFile(filepath.Join(dir, "commondir")) + if err != nil { + return dir + } + c := strings.TrimSpace(string(common)) + if !filepath.IsAbs(c) { + c = filepath.Join(dir, c) + } + return c +} + +func split(rel string) []string { + if rel == "." { + return nil + } + return strings.Split(filepath.ToSlash(rel), "/") +} + +func (s *search) load(file string, domain []string) { + data, err := os.ReadFile(file) + if err != nil { + return + } + for line := range strings.SplitSeq(string(data), "\n") { + line = strings.TrimSuffix(line, "\r") + if strings.HasPrefix(line, "#") || strings.TrimSpace(line) == "" { + continue + } + s.patterns = append(s.patterns, gitignore.ParsePattern(line, domain)) + } +} + +func (s *search) visit(p string, d fs.DirEntry, err error) error { + if err != nil { + if p == s.root { + return err + } + return nil + } + if s.ctx.Err() != nil { + return s.ctx.Err() + } + if s.full { + return fs.SkipAll + } + rel, err := filepath.Rel(s.base, p) + if err != nil { + return err + } + parts := split(rel) + if p != s.root && (d.Name() == ".git" || gitignore.NewMatcher(s.patterns).Match(parts, d.IsDir())) { + if d.IsDir() { + return fs.SkipDir + } + return nil + } + if d.IsDir() { + s.load(filepath.Join(p, ".gitignore"), parts) + return nil + } + if !d.Type().IsRegular() { + return nil + } + if s.opts.Glob != "" { + ok, _ := filepath.Match(s.opts.Glob, d.Name()) + if !ok { + return nil + } + } + return s.file(p) +} + +func (s *search) file(p string) error { + f, err := os.Open(p) + if err != nil { + return nil + } + defer f.Close() + if s.reader == nil { + s.reader = bufio.NewReaderSize(f, lineMax) + } + r := s.reader + r.Reset(f) + head, _ := r.Peek(sniff) + if bytes.IndexByte(head, 0) >= 0 { + return nil + } + shown := s.given + if p != s.root { + rel, err := filepath.Rel(s.root, p) + if err != nil { + return err + } + shown = filepath.Join(s.given, rel) + } + + for n := 1; ; n++ { + if s.ctx.Err() != nil { + return s.ctx.Err() + } + line, err := r.ReadSlice('\n') + if errors.Is(err, bufio.ErrBufferFull) { + s.write(fmt.Sprintf("%s:%d: line over 1 MiB, not searched\n", shown, n)) + for errors.Is(err, bufio.ErrBufferFull) { + if s.ctx.Err() != nil { + return s.ctx.Err() + } + _, err = r.ReadSlice('\n') + } + if err != nil || s.full { + return nil + } + continue + } + if len(line) == 0 { + return nil + } + line = bytes.TrimSuffix(bytes.TrimSuffix(line, []byte("\n")), []byte("\r")) + if s.re.Match(line) { + if s.opts.FilesOnly { + s.write(shown + "\n") + return nil + } + text := string(line) + if len(text) > lineKeep { + text = strings.ToValidUTF8(text[:lineKeep], "") + "…" + } + s.write(fmt.Sprintf("%s:%d:%s\n", shown, n, text)) + } + if err != nil || s.full { + return nil + } + } +} + +func (s *search) write(text string) { + if s.out.Len()+len(text) > keep { + s.full = true + return + } + s.out.WriteString(text) +}