Fix clipped server badge and client_hands hang across servers

This commit is contained in:
Ian Gulliver
2026-10-19 12:12:17 -07:00
parent 0797b56568
commit d55218d1db
4 changed files with 15 additions and 14 deletions
+1 -1
View File
@@ -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.
+12 -2
View File
@@ -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 {
+1
View File
@@ -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())
+1 -11
View File
@@ -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<OpenAPIObjectContainer, any Error>] = [:]
@@ -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 = [:]