diff --git a/cmd/byre/list.go b/cmd/byre/list.go new file mode 100644 index 0000000..9f815a3 --- /dev/null +++ b/cmd/byre/list.go @@ -0,0 +1,20 @@ +package main + +import ( + "fmt" + "os" + + "g.fc.run/ai/byre/internal/dirlist" +) + +func listCmd(args []string) { + if len(args) != 1 { + usage() + } + out, err := dirlist.List(args[0]) + if err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } + fmt.Print(out) +} diff --git a/cmd/byre/main.go b/cmd/byre/main.go index 18e8581..46db25b 100644 --- a/cmd/byre/main.go +++ b/cmd/byre/main.go @@ -24,13 +24,15 @@ func main() { signingCmd(os.Args[2:]) case "grep": grepCmd(os.Args[2:]) + case "list": + listCmd(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 | byre grep [--glob GLOB] [--ignore-case] [--files-only] PATTERN PATH") + 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 | byre list PATH") os.Exit(2) } diff --git a/docs/operations.md b/docs/operations.md index c69c6f1..10dcd13 100644 --- a/docs/operations.md +++ b/docs/operations.md @@ -273,7 +273,7 @@ Agent health is `internal/agents/health.go`: The left pane's **Status** tab shows, from the top: a Problems section only when there are zombies or protocol errors (one line per zombie, and per vendor the error count with the latest line folded under it); each running turn, linking to its task, with the time since its process's last event and `stuck` or `exited` when it is; each vendor's usage, headed `rejected` (with the `rejected` reason when there is one) when `rejected` is set or any window's `used` has reached its `limit` (else `allowed`) and the time since its `updated_at`, and per window its label, the amount used formatted by `unit` (`$150.94 / $200.00` for `usd`, `36 / 40 requests`, `1.2M tokens` without a limit, empty for `percent`), the share used in its own right-aligned column (`75%`; `used` itself for `percent`, `—` without a limit), the share of the window's time elapsed (from `resets_at` and `window_seconds`, `—` without both), a pace bar whose fill is the share used and whose vertical line is the share elapsed, the fill warn-coloured when usage is ahead of the line, all vendors in one table so their columns and pace bars line up, each cell on one line and the table scrolling sideways when it is wider than the pane; a Clients table, one row per entry in `clients`, with the Mac, user, the build's commit (cut to 8 characters, the full one in its tooltip), the time since it connected and the time since its last action; and the server counts. It refetches the status when a `session.created`, `session.state`, `task.*`, `project.created`, `admin.health` or `admin.clients` event arrives, when an `agent.vendor` event carries a `rate_limit_event`, and when the SSE stream reconnects. Other events advance the last-event seq it shows, and any event carrying a process's `agent_session_id` (stored or not, such as `agent.delta`) moves that process's last-event time forward, re-rendering at most once a second per process. A re-render keeps the scroll position and which folded lines are open. There is no timer, so "time since last event" and the "in 2h 10m" beside a reset time are as of the last render; a process going quiet long enough to be stuck shows up when the next health check broadcasts `admin.health`. -Agent activity becomes events with `agent_session_id` set: `user.message`, `agent.message`, `agent.thinking`, `agent.tool_call`, `agent.tool_result`, `turn.ended`, `session.state`, `session.subagents`, `session.model`, and `session.context`. Anything else Claude Code emits (rate-limit status, task progress, state changes) is recorded unchanged as `agent.vendor`. Codex items map onto the same kinds (`handleCodex` in `internal/agents/codex.go`): an `agentMessage` becomes `agent.message`, a `reasoning` item's summary `agent.thinking`, and `commandExecution` (as `Bash`), `fileChange` (as `apply_patch`), `dynamicToolCall` (as `mcp____`, so byre's tools carry the same names as Claude's), `mcpToolCall` and `webSearch` items an `agent.tool_call` when they start and an `agent.tool_result` when they complete. Other completed items and the `error` and `warning` notifications are recorded as `agent.vendor`; the remaining notifications (thread status, MCP startup and the like) aren't recorded. `item/agentMessage/delta` becomes `agent.delta`, and `thread/tokenUsage/updated` `session.context`. Antigravity steps map the same way (`handleAntigravity` in `internal/agents/antigravity.go`): an `agent_response` step's `text_delta`s become `agent.delta` and its finished text `agent.message`, and a `tool` step an `agent.tool_call` when it starts and an `agent.tool_result` when it is done, with `run_command` recorded as `Bash` and `call_mcp_tool` as `mcp____` with its `Arguments` as input; `user_input` steps aren't recorded, and other step types are recorded as `agent.vendor`. The one exception is partial output: each top-level `text_delta` is broadcast to SSE clients as an `agent.delta` event (`payload.text`, `seq` 0) that is never stored, and the transcript grows a live message from them until the finished `agent.message` replaces it. A `turn.ended` event carries `summary: true` when its `result` is the turn's summary (see above). Activity inside a subagent (`agent.message`, `agent.thinking`, `agent.tool_call`, `agent.tool_result`) carries `payload.parent_tool_use_id`, the ID of the tool call that started the subagent, and never updates `session.context`. `session.context` records the context size whenever it changes: `used` is the last assistant message's input, cache-read, cache-creation and output tokens, and `window` is the model's context window from the turn's `result` (`modelUsage[model].contextWindow`), so it is 0 until the session's first turn after a server start ends. The task header's **Compact** button shows the latest one as a background fill across the button, proportional to the window, tinted amber from 70% and red from 90%; the exact count is in its tooltip. +Agent activity becomes events with `agent_session_id` set: `user.message`, `agent.message`, `agent.thinking`, `agent.tool_call`, `agent.tool_result`, `turn.ended`, `session.state`, `session.subagents`, `session.model`, and `session.context`. Anything else Claude Code emits (rate-limit status, task progress, state changes) is recorded unchanged as `agent.vendor`. Codex items map onto the same kinds (`handleCodex` in `internal/agents/codex.go`): an `agentMessage` becomes `agent.message`, a `reasoning` item's summary `agent.thinking`, and `commandExecution` (as `Bash`), `fileChange` (as `apply_patch`), `dynamicToolCall` (as `mcp____`, so byre's tools carry the same names as Claude's), `mcpToolCall` and `webSearch` items an `agent.tool_call` when they start and an `agent.tool_result` when they complete. Other completed items and the `error` and `warning` notifications are recorded as `agent.vendor`; the remaining notifications (thread status, MCP startup and the like) aren't recorded. `item/agentMessage/delta` becomes `agent.delta`, and `thread/tokenUsage/updated` `session.context`. Antigravity steps map the same way (`handleAntigravity` in `internal/agents/antigravity.go`): an `agent_response` step's `text_delta`s become `agent.delta` and its finished text `agent.message`, and a `tool` step an `agent.tool_call` when it starts and an `agent.tool_result` when it is done, with `run_command` recorded as `Bash`, `read_file` and `view_file` as `Read` with their `Path` or `AbsolutePath` as `file_path`, and `call_mcp_tool` as `mcp____` with its `Arguments` as input; `user_input` steps aren't recorded, and other step types are recorded as `agent.vendor`. The one exception is partial output: each top-level `text_delta` is broadcast to SSE clients as an `agent.delta` event (`payload.text`, `seq` 0) that is never stored, and the transcript grows a live message from them until the finished `agent.message` replaces it. A `turn.ended` event carries `summary: true` when its `result` is the turn's summary (see above). Activity inside a subagent (`agent.message`, `agent.thinking`, `agent.tool_call`, `agent.tool_result`) carries `payload.parent_tool_use_id`, the ID of the tool call that started the subagent, and never updates `session.context`. `session.context` records the context size whenever it changes: `used` is the last assistant message's input, cache-read, cache-creation and output tokens, and `window` is the model's context window from the turn's `result` (`modelUsage[model].contextWindow`), so it is 0 until the session's first turn after a server start ends. The task header's **Compact** button shows the latest one as a background fill across the button, proportional to the window, tinted amber from 70% and red from 90%; the exact count is in its tooltip. Event payloads are stored whole in `events.payload`, and `agent.tool_result` content (full command output, file reads, diffs) dominates the log's size; the app loads every active task's whole history when it starts (see "Events"), so this is what its startup costs. `sqlite3 byre.db "SELECT kind, count(*), sum(length(payload)) FROM events GROUP BY kind ORDER BY 3 DESC"` shows the split, and `sqlite3 byre.db "SELECT kind, count(*), sum(length(payload)) FROM events WHERE task_id = '' GROUP BY kind ORDER BY 3 DESC"` one task's. @@ -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 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. +Implementer sessions get eleven 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. @@ -389,6 +389,7 @@ Implementer sessions get ten MCP tools for it (`internal/agents/clients.go`). Th - `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_list`: the same, running `byre list` with the single-quoted `path`. `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. @@ -431,10 +432,14 @@ For commands that don't need the human to see them, agents have the `bash` MCP t ### 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. +Beside `bash`, agents get `read`, `write`, `edit`, `grep` and `list` 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`, `client_grep` and `client_list`. 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. +`list` and `client_list` run the same listing, `internal/dirlist`, in-process on the server (`path` defaults to the worktree) and as `byre list PATH` on a client. It reads one directory, not recursively, and prints each entry's name on its own line, sorted by name, with `/` after directories, following symlinks to decide; nothing is skipped, `.git` and gitignored entries included. An empty directory prints ` is empty.`, and output stops at 64 KiB with a line counting the entries left out. + +The transcript shows a tool call under the name in its event, so a vendor's own tool appears under the vendor's name unless byre records it under Claude's (see "Agent sessions"); it labels every byre tool from its name (`mcp__byre__client_list` shows as `Client List`, `toolLabel` in `mac/Sources/Byre/TranscriptBuilder.swift`), and shows its `file_path`, or else its `path`, beside the label, relative to the worktree. + 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/internal/agents/antigravity.go b/internal/agents/antigravity.go index bcb7c4e..15a7ea9 100644 --- a/internal/agents/antigravity.go +++ b/internal/agents/antigravity.go @@ -1,6 +1,7 @@ package agents import ( + "cmp" "context" "encoding/json" "fmt" @@ -132,6 +133,13 @@ func agyToolCall(name string, params json.RawMessage) (string, any, error) { }{} err := json.Unmarshal(params, &p) return "Bash", map[string]any{"command": p.CommandLine}, err + case "read_file", "view_file": + p := struct { + Path string + AbsolutePath string + }{} + err := json.Unmarshal(params, &p) + return "Read", map[string]any{"file_path": cmp.Or(p.Path, p.AbsolutePath)}, err case "call_mcp_tool": p := struct { ServerName string diff --git a/internal/agents/clients.go b/internal/agents/clients.go index 80ca7e6..56b94ad 100644 --- a/internal/agents/clients.go +++ b/internal/agents/clients.go @@ -181,6 +181,21 @@ func (m *Manager) clientTools(l *live) []mcp.Tool { return m.clientGrep(ctx, raw) }, }, + { + Name: "client_list", + Description: listDescription + " It lists a directory on 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."}, + "path": {"type": "string", "description": "The directory, absolute or starting with ~/."} + }, + "required": ["client", "path"] + }`), + Handler: func(ctx context.Context, raw json.RawMessage) (string, error) { + return m.clientList(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: " + @@ -552,39 +567,67 @@ func (m *Manager) clientGrep(ctx context.Context, raw json.RawMessage) (string, if err != nil { return "", fmt.Errorf("malformed client_grep call: %w", err) } - err = clientPath("path", args.Path) + path, err := clientShellPath(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" + flags := "" if args.Glob != "" { - command += " --glob " + shellQuote(args.Glob) + flags += " --glob " + shellQuote(args.Glob) } if args.IgnoreCase { - command += " --ignore-case" + flags += " --ignore-case" } if args.FilesOnly { - command += " --files-only" + flags += " --files-only" } - command += " -- " + shellQuote(args.Pattern) + " " + path + return m.runClientByre(ctx, args.Client, "grep", flags+" -- "+shellQuote(args.Pattern)+" "+path) +} +type clientListArgs struct { + Client string `json:"client"` + Path string `json:"path"` +} + +func (m *Manager) clientList(ctx context.Context, raw json.RawMessage) (string, error) { + args := clientListArgs{} + err := json.Unmarshal(raw, &args) + if err != nil { + return "", fmt.Errorf("malformed client_list call: %w", err) + } + path, err := clientShellPath(args.Path) + if err != nil { + return "", err + } + return m.runClientByre(ctx, args.Client, "list", " "+path) +} + +func clientShellPath(p string) (string, error) { + err := clientPath("path", p) + if err != nil { + return "", err + } + rest, ok := strings.CutPrefix(p, "~/") + if ok { + return "~/" + shellQuote(rest), nil + } + return shellQuote(p), nil +} + +func (m *Manager) runClientByre(ctx context.Context, client, sub, args string) (string, error) { + command := clientByre + " " + sub + args 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}) + reply, err := m.clients.Call(cctx, 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) + return "", fmt.Errorf("%s's reply has no exit_code or output", client) } if *reply.ExitCode != 0 { - return "", fmt.Errorf("byre grep on %s exited %d: %s", args.Client, *reply.ExitCode, *reply.Output) + return "", fmt.Errorf("byre %s on %s exited %d: %s", sub, client, *reply.ExitCode, *reply.Output) } return *reply.Output, nil } diff --git a/internal/agents/files.go b/internal/agents/files.go index 4e5bdb5..cc6ece0 100644 --- a/internal/agents/files.go +++ b/internal/agents/files.go @@ -12,6 +12,7 @@ import ( "strings" "unicode/utf8" + "g.fc.run/ai/byre/internal/dirlist" "g.fc.run/ai/byre/internal/grep" "g.fc.run/ai/byre/internal/mcp" ) @@ -19,6 +20,9 @@ import ( 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." +const listDescription = "List one directory's entries, not recursively, one name per line sorted by name, with / after each directory (symlinks to directories included). " + + "Nothing is left out: dotfiles, binary and gitignored files all show. At most 64 KiB in all." + func (m *Manager) fileTools(l *live) []mcp.Tool { return []mcp.Tool{ { @@ -88,6 +92,19 @@ func (m *Manager) fileTools(l *live) []mcp.Tool { return m.grep(ctx, l, raw) }, }, + { + Name: "list", + Description: listDescription + " It lists a directory on the Mac byre runs on, outside any sandbox your own tools have.", + InputSchema: json.RawMessage(`{ + "type": "object", + "properties": { + "path": {"type": "string", "description": "The directory, relative to your worktree, absolute, or starting with ~/ (default your worktree)."} + } + }`), + Handler: func(ctx context.Context, raw json.RawMessage) (string, error) { + return m.list(l, raw) + }, + }, } } @@ -250,6 +267,26 @@ func (m *Manager) grep(ctx context.Context, l *live, raw json.RawMessage) (strin return grep.Search(cctx, args.Pattern, l.dir, path, args.options()) } +type listArgs struct { + Path string `json:"path"` +} + +func (m *Manager) list(l *live, raw json.RawMessage) (string, error) { + args := listArgs{} + err := json.Unmarshal(raw, &args) + if err != nil { + return "", fmt.Errorf("malformed list call: %w", err) + } + if args.Path == "" { + args.Path = "." + } + full, err := l.resolve(args.Path) + if err != nil { + return "", err + } + return dirlist.List(full) +} + 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 { diff --git a/internal/dirlist/dirlist.go b/internal/dirlist/dirlist.go new file mode 100644 index 0000000..9a02e5b --- /dev/null +++ b/internal/dirlist/dirlist.go @@ -0,0 +1,34 @@ +package dirlist + +import ( + "fmt" + "os" + "path/filepath" + "strings" +) + +const keep = 64<<10 - 100 + +func List(path string) (string, error) { + entries, err := os.ReadDir(path) + if err != nil { + return "", err + } + if len(entries) == 0 { + return fmt.Sprintf("%s is empty.\n", path), nil + } + b := &strings.Builder{} + for i, e := range entries { + name := e.Name() + info, err := os.Stat(filepath.Join(path, name)) + if err == nil && info.IsDir() { + name += "/" + } + if b.Len()+len(name)+1 > keep { + fmt.Fprintf(b, "(%d more entries not shown; output stops at 64 KiB)\n", len(entries)-i) + break + } + b.WriteString(name + "\n") + } + return b.String(), nil +} diff --git a/internal/store/migrations/076-normalize-tool-call-names.sql b/internal/store/migrations/076-normalize-tool-call-names.sql new file mode 100644 index 0000000..e53db22 --- /dev/null +++ b/internal/store/migrations/076-normalize-tool-call-names.sql @@ -0,0 +1,5 @@ +UPDATE events SET payload = json_set(payload, '$.name', 'Read', '$.input', json_object('file_path', coalesce(json_extract(payload, '$.input.Path'), json_extract(payload, '$.input.AbsolutePath')))) +WHERE kind = 'agent.tool_call' AND json_extract(payload, '$.name') IN ('read_file', 'view_file'); + +UPDATE events SET payload = json_set(payload, '$.name', 'mcp__byre__' || json_extract(payload, '$.name')) +WHERE kind = 'agent.tool_call' AND json_extract(payload, '$.name') IN ('submit_review', 'set_task_summary', 'finish_stage', 'take_snapshot'); diff --git a/mac/Sources/Byre/TranscriptBuilder.swift b/mac/Sources/Byre/TranscriptBuilder.swift index c7ad365..af79988 100644 --- a/mac/Sources/Byre/TranscriptBuilder.swift +++ b/mac/Sources/Byre/TranscriptBuilder.swift @@ -1031,13 +1031,14 @@ private func toolBrief(_ input: (any Sendable)?, _ root: String) -> String { guard let i = input as? JSONDict else { return "" } - let brief = ["command", "file_path", "pattern", "url", "question", "description"].lazy.compactMap { i[$0] ?? nil }.first + let brief = ["command", "file_path", "pattern", "path", "url", "question", "description"].lazy.compactMap { i[$0] ?? nil }.first let text = relative(brief.map { jsString($0) } ?? "", root) return text.count > 120 ? "\(text.prefix(120))…" : text } private func toolTarget(_ input: (any Sendable)?, _ root: String) -> String { - guard let path = (input as? JSONDict)?["file_path"] as? String else { + let i = input as? JSONDict + guard let path = (i?["file_path"] ?? i?["path"]) as? String else { return "" } return relative(path, root)