From d55218d1dbb2b7639ad6543d492f3a0d2093db29 Mon Sep 17 00:00:00 2001 From: Ian Gulliver Date: Mon, 19 Oct 2026 12:12:17 -0700 Subject: [PATCH] Fix clipped server badge and client_hands hang across servers --- docs/operations.md | 2 +- internal/agents/clients.go | 14 ++++++++++++-- mac/Sources/Byre/Format.swift | 1 + mac/Sources/Byre/Remote.swift | 12 +----------- 4 files changed, 15 insertions(+), 14 deletions(-) diff --git a/docs/operations.md b/docs/operations.md index d5ee22c..f6b37e0 100644 --- a/docs/operations.md +++ b/docs/operations.md @@ -358,7 +358,7 @@ Implementer sessions get eight MCP tools for it (`internal/agents/clients.go`). - `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_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`, 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. Each `byre-hands` process has its own pending calls, so one that exits fails only the calls sent to it. +- `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. A byre restart drops every connection; each app reconnects 2s later, and calls in flight at the restart fail as disconnected. Updating a client's app goes through the tools themselves: `client_upload` the new `Byre.app`, then `client_bash` a command that quits the old app and opens the new one, which outlives the app and reconnects. An app that doesn't know the `upload` op can't be updated this way, so its update is by hand. diff --git a/internal/agents/clients.go b/internal/agents/clients.go index 269fd2e..5bf6843 100644 --- a/internal/agents/clients.go +++ b/internal/agents/clients.go @@ -26,7 +26,7 @@ const ( replyGrace = 30 * time.Second copyTimeout = 10 * time.Minute fileTimeout = 2 * time.Minute - handsTimeout = 11 * time.Minute + handsTimeout = 2 * time.Minute defaultReadLimit = 2000 ) @@ -528,7 +528,17 @@ func (m *Manager) clientHands(ctx context.Context, raw json.RawMessage) mcp.Resu args.Arguments = &map[string]any{} } - cctx, cancel := context.WithTimeout(ctx, handsTimeout) + ms := func(m map[string]any, key string) time.Duration { + v, _ := m[key].(float64) + return time.Duration(v) * time.Millisecond + } + timeout := handsTimeout + ms(*args.Arguments, "wait_ms") + 2*ms(*args.Arguments, "timeout_ms") + steps, _ := (*args.Arguments)["steps"].([]any) + for _, s := range steps { + step, _ := s.(map[string]any) + timeout += ms(step, "wait") + } + cctx, cancel := context.WithTimeout(ctx, timeout) defer cancel() reply, err := m.clients.Call(cctx, args.Client, api.ClientRequest{Op: api.Hands, Tool: &args.Tool, Arguments: args.Arguments}) if err != nil { diff --git a/mac/Sources/Byre/Format.swift b/mac/Sources/Byre/Format.swift index 31f49cb..c3b6653 100644 --- a/mac/Sources/Byre/Format.swift +++ b/mac/Sources/Byre/Format.swift @@ -350,6 +350,7 @@ struct Badge: View { var body: some View { Text("\(count)") .font(Fonts.font(11, weight: 700)) + .fixedSize() .foregroundStyle(Palette.danger) .padding(.horizontal, 7) .background(Palette.dangerTint, in: Capsule()) diff --git a/mac/Sources/Byre/Remote.swift b/mac/Sources/Byre/Remote.swift index c2553e8..0d06019 100644 --- a/mac/Sources/Byre/Remote.swift +++ b/mac/Sources/Byre/Remote.swift @@ -13,6 +13,7 @@ private let maxOutput = 64 << 10 private let maxMessage = 256 << 20 private let maxRead = 64 << 20 private let copyTimeoutMs = 10 * 60 * 1000 +private let hands = Hands() // The signature goes to a file, since a login shell's startup files can print to stdout. private let signScript = """ @@ -28,7 +29,6 @@ private let signScript = """ @MainActor final class Remote { let api: API - private let hands = Hands() private var ws: URLSessionWebSocketTask? private var stopped = false private var heard = Date() @@ -63,9 +63,6 @@ final class Remote { checker?.cancel() ws?.cancel(with: .normalClosure, reason: nil) ws = nil - Task { [hands] in - await hands.stop() - } } // A socket byre closed while this Mac slept stays open here until something is sent on it. @@ -370,7 +367,6 @@ private struct HandsResponse: Decodable { } private actor Hands { - private var process: Process? private var input: FileHandle? private var next = 0 private var pending: [Int: CheckedContinuation] = [:] @@ -392,10 +388,6 @@ private actor Hands { } } - func stop() { - process?.terminate() - } - private func running() throws -> FileHandle { if let input { return input @@ -410,7 +402,6 @@ private actor Hands { p.standardError = FileHandle.nullDevice let exit = try start(p) log.info("byre-hands started: pid \(p.processIdentifier)") - process = p input = stdin.fileHandleForWriting Task { await serve(p, stdout.fileHandleForReading, exit) @@ -435,7 +426,6 @@ private actor Hands { for await _ in exit {} let status = p.terminationStatus log.info("byre-hands exited: status \(status)") - process = nil input = nil let waiting = pending pending = [:]