Let task agents use client Macs directly: bash, copy and hands
This commit is contained in:
@@ -20,5 +20,7 @@
|
||||
<string>27.0</string>
|
||||
<key>NSHighResolutionCapable</key>
|
||||
<true/>
|
||||
<key>NSAppleEventsUsageDescription</key>
|
||||
<string>byre's agents drive Orion on this Mac through AppleScript when a task asks them to.</string>
|
||||
</dict>
|
||||
</plist>
|
||||
|
||||
@@ -1,166 +0,0 @@
|
||||
import ByreAPI
|
||||
import Foundation
|
||||
|
||||
private let maxOutput = 64 << 10
|
||||
|
||||
private let runsDir = FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask)[0].appending(path: "run.fc.byre.mac/runs", directoryHint: .isDirectory)
|
||||
|
||||
@MainActor
|
||||
private var watching: Set<String> = []
|
||||
|
||||
@MainActor
|
||||
func runOnThisMac(_ api: API, _ item: InboxItem) async throws {
|
||||
let dir = runsDir.appending(path: UUID().uuidString, directoryHint: .isDirectory)
|
||||
watching.insert(dir.path)
|
||||
do {
|
||||
try await start(api, item, dir)
|
||||
} catch {
|
||||
try? FileManager.default.removeItem(at: dir)
|
||||
watching.remove(dir.path)
|
||||
throw error
|
||||
}
|
||||
poll(api, dir)
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private func start(_ api: API, _ item: InboxItem, _ dir: URL) async throws {
|
||||
let files = dir.appending(path: "files", directoryHint: .isDirectory)
|
||||
try FileManager.default.createDirectory(at: files, withIntermediateDirectories: true)
|
||||
try item.id.write(to: dir.appending(path: "item"), atomically: true, encoding: .utf8)
|
||||
try api.origin.absoluteString.write(to: dir.appending(path: "server"), atomically: true, encoding: .utf8)
|
||||
try str(item.payload.value, "command")!.write(to: dir.appending(path: "command.sh"), atomically: true, encoding: .utf8)
|
||||
|
||||
let (bundle, response) = try await api.session.download(from: api.url("/api/inbox/\(item.id)/bundle"))
|
||||
let code = (response as! HTTPURLResponse).statusCode
|
||||
if code != 200 {
|
||||
let text = (try? String(contentsOf: bundle, encoding: .utf8)) ?? ""
|
||||
throw ApiError(status: code, message: "download bundle: \(code) \(text)")
|
||||
}
|
||||
try await untar(bundle, into: files, errors: dir.appending(path: "untar.log"))
|
||||
try? FileManager.default.removeItem(at: bundle)
|
||||
|
||||
let p = Process()
|
||||
p.executableURL = URL(filePath: "/usr/bin/lockf")
|
||||
p.arguments = ["-k", "lock", "/bin/zsh", "-c", "cd files && /bin/zsh -l ../command.sh >../output.log 2>&1; echo $? >../exit.tmp; mv ../exit.tmp ../exit"]
|
||||
p.currentDirectoryURL = dir
|
||||
p.standardInput = FileHandle.nullDevice
|
||||
p.standardOutput = FileHandle.nullDevice
|
||||
p.standardError = FileHandle.nullDevice
|
||||
try p.run()
|
||||
let lock = dir.appending(path: "lock")
|
||||
while !FileManager.default.fileExists(atPath: lock.path) {
|
||||
if !p.isRunning {
|
||||
throw ApiError(status: 0, message: "start command: lockf exited \(p.terminationStatus) before taking its lock")
|
||||
}
|
||||
try await Task.sleep(for: .milliseconds(20))
|
||||
}
|
||||
log.info("client run started: item \(item.id, privacy: .public) in \(dir.path, privacy: .public)")
|
||||
}
|
||||
|
||||
private func untar(_ bundle: URL, into dir: URL, errors: URL) async throws {
|
||||
FileManager.default.createFile(atPath: errors.path, contents: nil)
|
||||
let errFile = try FileHandle(forWritingTo: errors)
|
||||
defer {
|
||||
try? errFile.close()
|
||||
}
|
||||
let p = Process()
|
||||
p.executableURL = URL(filePath: "/usr/bin/tar")
|
||||
p.arguments = ["-xzf", bundle.path, "-C", dir.path]
|
||||
p.standardError = errFile
|
||||
p.standardOutput = FileHandle.nullDevice
|
||||
try await withCheckedThrowingContinuation { (c: CheckedContinuation<Void, any Error>) in
|
||||
p.terminationHandler = { _ in
|
||||
c.resume()
|
||||
}
|
||||
do {
|
||||
try p.run()
|
||||
} catch {
|
||||
c.resume(throwing: error)
|
||||
}
|
||||
}
|
||||
if p.terminationStatus != 0 {
|
||||
let text = tail(errors)
|
||||
throw ApiError(status: 0, message: "unpack bundle: tar exited \(p.terminationStatus): \(text)")
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
func resumeClientRuns(_ api: API) {
|
||||
let dirs = (try? FileManager.default.contentsOfDirectory(at: runsDir, includingPropertiesForKeys: nil)) ?? []
|
||||
for dir in dirs where !watching.contains(dir.path) && (try? String(contentsOf: dir.appending(path: "server"), encoding: .utf8)) == api.origin.absoluteString {
|
||||
if !FileManager.default.fileExists(atPath: dir.appending(path: "lock").path) {
|
||||
log.info("client run never started; removing \(dir.path, privacy: .public)")
|
||||
try? FileManager.default.removeItem(at: dir)
|
||||
continue
|
||||
}
|
||||
watching.insert(dir.path)
|
||||
poll(api, dir)
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private func poll(_ api: API, _ dir: URL) {
|
||||
Task {
|
||||
let exitFile = dir.appending(path: "exit").path
|
||||
var interrupted = false
|
||||
while !FileManager.default.fileExists(atPath: exitFile) {
|
||||
if lockFree(dir.appending(path: "lock").path) {
|
||||
interrupted = !FileManager.default.fileExists(atPath: exitFile)
|
||||
break
|
||||
}
|
||||
try? await Task.sleep(for: .seconds(1))
|
||||
}
|
||||
await report(api, dir, interrupted: interrupted)
|
||||
watching.remove(dir.path)
|
||||
}
|
||||
}
|
||||
|
||||
private func lockFree(_ path: String) -> Bool {
|
||||
guard let h = FileHandle(forReadingAtPath: path) else {
|
||||
return false
|
||||
}
|
||||
defer {
|
||||
try? h.close()
|
||||
}
|
||||
let fd = h.fileDescriptor
|
||||
if flock(fd, LOCK_EX | LOCK_NB) != 0 {
|
||||
return false
|
||||
}
|
||||
flock(fd, LOCK_UN)
|
||||
return true
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private func report(_ api: API, _ dir: URL, interrupted: Bool) async {
|
||||
do {
|
||||
let itemId = trimmed(try String(contentsOf: dir.appending(path: "item"), encoding: .utf8))
|
||||
var exit = -1
|
||||
var output = tail(dir.appending(path: "output.log"))
|
||||
if interrupted {
|
||||
output += "\n[byre: the command stopped without finishing, for example because the Mac restarted]"
|
||||
} else {
|
||||
exit = Int(trimmed(try String(contentsOf: dir.appending(path: "exit"), encoding: .utf8))) ?? -1
|
||||
}
|
||||
let client = Host.current().localizedName ?? ProcessInfo.processInfo.hostName
|
||||
_ = try await api.client.reportRun(path: .init(itemId: itemId), body: .json(.init(runId: dir.lastPathComponent, client: client, exitCode: exit, output: output))).ok
|
||||
log.info("client run reported: item \(itemId, privacy: .public) exit \(exit)")
|
||||
} catch {
|
||||
log.error("report client run \(dir.path, privacy: .public): \(errorText(error), privacy: .public)")
|
||||
guard let e = rootError(error) as? ApiError, (400..<500).contains(e.status) else {
|
||||
return
|
||||
}
|
||||
}
|
||||
try? FileManager.default.removeItem(at: dir)
|
||||
}
|
||||
|
||||
private func tail(_ file: URL) -> String {
|
||||
guard let h = try? FileHandle(forReadingFrom: file) else {
|
||||
return ""
|
||||
}
|
||||
defer {
|
||||
try? h.close()
|
||||
}
|
||||
let size = (try? h.seekToEnd()) ?? 0
|
||||
try? h.seek(toOffset: size > UInt64(maxOutput) ? size - UInt64(maxOutput) : 0)
|
||||
return String(decoding: (try? h.readToEnd()) ?? Data(), as: UTF8.self)
|
||||
}
|
||||
@@ -192,21 +192,6 @@ private final class CardModel {
|
||||
}
|
||||
}
|
||||
|
||||
func run() {
|
||||
if busy {
|
||||
return
|
||||
}
|
||||
busy = true
|
||||
Task {
|
||||
do {
|
||||
try await runOnThisMac(store.api, item)
|
||||
} catch {
|
||||
overlays.showAlert(errorText(error))
|
||||
}
|
||||
busy = false
|
||||
}
|
||||
}
|
||||
|
||||
func submitOther() {
|
||||
if typed.isEmpty && !hasImages {
|
||||
return
|
||||
@@ -304,29 +289,10 @@ private struct CardBody: View {
|
||||
}
|
||||
.textSelection(.enabled)
|
||||
.padding(.vertical, 10)
|
||||
case .clientRun:
|
||||
VStack(alignment: .leading, spacing: 10) {
|
||||
MarkdownView(blocks: parseMarkdown(jsString(p["why"] ?? "")), ctx: MDContext(taskId: item.taskId))
|
||||
MarkdownView(blocks: runDetails(p), ctx: MDContext(taskId: item.taskId))
|
||||
}
|
||||
.padding(.vertical, 10)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func runDetails(_ p: JSONDict) -> [MDBlock] {
|
||||
let paths = (p["paths"] as? [(any Sendable)?] ?? []).compactMap { $0 as? String }
|
||||
let bytes = (p["bytes"] as? Int) ?? Int((p["bytes"] as? Double) ?? 0)
|
||||
var files: [MDInline] = [.text("No files.")]
|
||||
if !paths.isEmpty {
|
||||
files = [.text("Files (\(ByteCountFormatter.string(fromByteCount: Int64(bytes), countStyle: .file))): ")]
|
||||
for (i, path) in paths.enumerated() {
|
||||
files += i == 0 ? [.code(path)] : [.text(", "), .code(path)]
|
||||
}
|
||||
}
|
||||
return [.code(jsString(p["command"] ?? "")), .paragraph(files)]
|
||||
}
|
||||
|
||||
private func attachments(_ v: (any Sendable)?) -> [Attachment] {
|
||||
guard let v else {
|
||||
return []
|
||||
@@ -485,19 +451,6 @@ private struct CardActions: View {
|
||||
}
|
||||
.disabled(model.busy)
|
||||
}
|
||||
} else if model.item.kind == .clientRun {
|
||||
HStack(spacing: 8) {
|
||||
Button("Dismiss") {
|
||||
model.submit(InboxResponseCreate(itemId: model.item.id, _type: .dismiss))
|
||||
}
|
||||
.buttonStyle(WebButtonStyle())
|
||||
Spacer()
|
||||
Button("Run on this Mac") {
|
||||
model.run()
|
||||
}
|
||||
.buttonStyle(WebButtonStyle(primary: true))
|
||||
}
|
||||
.disabled(model.busy)
|
||||
} else {
|
||||
HStack(alignment: .top, spacing: 8) {
|
||||
TextEntry(model: model, label: "Reply…", placeholder: "Reply to the agent", shared: false, focus: focus) {
|
||||
@@ -643,19 +596,11 @@ private struct Thread: View {
|
||||
if !item.responses.isEmpty {
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
ForEach(item.responses, id: \.id) { r in
|
||||
if r._type == .run {
|
||||
Text("\(Text(r.user).font(Fonts.font(11, weight: 700))) ran it on \(r.client ?? ""): exit \(r.exitCode ?? -1)")
|
||||
.textSelection(.enabled)
|
||||
if let out = r.text, !trimmed(out).isEmpty {
|
||||
MarkdownView(blocks: [.details(summary: [.text("Output")], body: [.code(out)])], ctx: MDContext(taskId: item.taskId))
|
||||
}
|
||||
} else {
|
||||
let images = r.images.isEmpty ? "" : " (\(r.images.count == 1 ? "1 image" : "\(r.images.count) images"))"
|
||||
let rest = " \(r._type.rawValue.replacingOccurrences(of: "_", with: " "))\(r.options.map { ": \($0.joined(separator: "; "))" } ?? "")\(r.text.map { " — \($0)" } ?? "")\(images)"
|
||||
Text("\(Text(r.user).font(Fonts.font(11, weight: 700)))\(rest)")
|
||||
.textSelection(.enabled)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
}
|
||||
let images = r.images.isEmpty ? "" : " (\(r.images.count == 1 ? "1 image" : "\(r.images.count) images"))"
|
||||
let rest = " \(r._type.rawValue.replacingOccurrences(of: "_", with: " "))\(r.options.map { ": \($0.joined(separator: "; "))" } ?? "")\(r.text.map { " — \($0)" } ?? "")\(images)"
|
||||
Text("\(Text(r.user).font(Fonts.font(11, weight: 700)))\(rest)")
|
||||
.textSelection(.enabled)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
}
|
||||
}
|
||||
.font(Fonts.font(11))
|
||||
|
||||
@@ -43,7 +43,7 @@ final class Notifier: NSObject, UNUserNotificationCenterDelegate {
|
||||
|
||||
private func wants(_ p: NotificationPrefs, _ item: InboxItem) -> Bool {
|
||||
switch item.kind {
|
||||
case .question, .clientRun: p.questions
|
||||
case .question: p.questions
|
||||
case .sessionError: p.errors
|
||||
case .rateLimited: p.rateLimits
|
||||
}
|
||||
@@ -55,7 +55,6 @@ private func itemText(_ item: InboxItem) -> String {
|
||||
case .question: return str(p, "question") ?? ""
|
||||
case .sessionError: return "Error: \(str(p, "error") ?? "")"
|
||||
case .rateLimited: return "Rate limited"
|
||||
case .clientRun: return "Run on your Mac: \(str(p, "why") ?? "")"
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,358 @@
|
||||
import ByreAPI
|
||||
import Darwin
|
||||
import Foundation
|
||||
import OpenAPIRuntime
|
||||
|
||||
typealias ClientRequest = Components.Schemas.ClientRequest
|
||||
typealias ClientReply = Components.Schemas.ClientReply
|
||||
|
||||
private let retryAfter = Duration.seconds(2)
|
||||
private let maxOutput = 64 << 10
|
||||
private let copyTimeoutMs = 10 * 60 * 1000
|
||||
|
||||
@MainActor
|
||||
final class Remote {
|
||||
let api: API
|
||||
private let hands = Hands()
|
||||
private var ws: URLSessionWebSocketTask?
|
||||
private var stopped = false
|
||||
|
||||
init(api: API) {
|
||||
self.api = api
|
||||
}
|
||||
|
||||
func start() {
|
||||
guard !stopped else {
|
||||
return
|
||||
}
|
||||
let task = api.session.webSocketTask(with: api.url("/api/clients/connect", scheme: "wss"))
|
||||
ws = task
|
||||
task.resume()
|
||||
receive(task)
|
||||
}
|
||||
|
||||
func stop() {
|
||||
stopped = true
|
||||
ws?.cancel(with: .normalClosure, reason: nil)
|
||||
ws = nil
|
||||
hands.stop()
|
||||
}
|
||||
|
||||
private func receive(_ task: URLSessionWebSocketTask) {
|
||||
task.receive { result in
|
||||
Task { @MainActor in
|
||||
guard self.ws === task, !self.stopped else {
|
||||
return
|
||||
}
|
||||
switch result {
|
||||
case .success(.string(let s)):
|
||||
self.handle(task, Data(s.utf8))
|
||||
self.receive(task)
|
||||
case .success(.data(let d)):
|
||||
self.handle(task, d)
|
||||
self.receive(task)
|
||||
case .success:
|
||||
self.receive(task)
|
||||
case .failure(let error):
|
||||
log.info("client connection closed: \(errorText(error), privacy: .public)")
|
||||
self.ws = nil
|
||||
try? await Task.sleep(for: retryAfter)
|
||||
self.start()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func handle(_ task: URLSessionWebSocketTask, _ data: Data) {
|
||||
let req: ClientRequest
|
||||
do {
|
||||
req = try jsonDecoder().decode(ClientRequest.self, from: data)
|
||||
} catch {
|
||||
log.error("decode client request: \(String(describing: error), privacy: .public)")
|
||||
return
|
||||
}
|
||||
log.info("client request \(req.id, privacy: .public): \(req.op.rawValue, privacy: .public)")
|
||||
Task {
|
||||
var reply: ClientReply
|
||||
do {
|
||||
reply = try await perform(req)
|
||||
} catch {
|
||||
reply = ClientReply(id: req.id, error: errorText(error))
|
||||
}
|
||||
do {
|
||||
try await task.send(.string(String(decoding: try JSONEncoder().encode(reply), as: UTF8.self)))
|
||||
} catch {
|
||||
log.error("send client reply \(req.id, privacy: .public): \(errorText(error), privacy: .public)")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func perform(_ req: ClientRequest) async throws -> ClientReply {
|
||||
switch req.op {
|
||||
case .bash:
|
||||
let home = FileManager.default.homeDirectoryForCurrentUser.path
|
||||
let (code, output) = try await spawn(["/bin/zsh", "-l", "-c", req.command ?? ""], in: home, timeoutMs: req.timeoutMs ?? 120_000)
|
||||
return ClientReply(id: req.id, exitCode: code, output: output)
|
||||
case .copy:
|
||||
try await copy(transfer: req.transfer ?? "", to: req.dest ?? "")
|
||||
return ClientReply(id: req.id)
|
||||
case .hands:
|
||||
let result = try await hands.call(req.tool ?? "", req.arguments?.additionalProperties ?? OpenAPIObjectContainer())
|
||||
return ClientReply(id: req.id, result: .init(additionalProperties: result))
|
||||
}
|
||||
}
|
||||
|
||||
private func copy(transfer: String, to dest: String) async throws {
|
||||
let target = URL(filePath: (dest as NSString).expandingTildeInPath)
|
||||
let parent = target.deletingLastPathComponent()
|
||||
let fm = FileManager.default
|
||||
try fm.createDirectory(at: parent, withIntermediateDirectories: true)
|
||||
|
||||
let (archive, response) = try await api.session.download(from: api.url("/api/clients/transfers/\(transfer)"))
|
||||
defer {
|
||||
try? fm.removeItem(at: archive)
|
||||
}
|
||||
let code = (response as! HTTPURLResponse).statusCode
|
||||
if code != 200 {
|
||||
throw ApiError(status: code, message: "download transfer: \(code) \(String(decoding: (try? Data(contentsOf: archive)) ?? Data(), as: UTF8.self))")
|
||||
}
|
||||
|
||||
let tmp = parent.appending(path: ".byre-copy-\(UUID().uuidString)", directoryHint: .isDirectory)
|
||||
try fm.createDirectory(at: tmp, withIntermediateDirectories: false)
|
||||
defer {
|
||||
try? fm.removeItem(at: tmp)
|
||||
}
|
||||
let (status, output) = try await spawn(["/usr/bin/tar", "-xzf", archive.path, "-C", tmp.path], in: tmp.path, timeoutMs: copyTimeoutMs)
|
||||
if status != 0 {
|
||||
throw ApiError(status: 0, message: "unpack: tar exited \(status): \(output)")
|
||||
}
|
||||
let entries = try fm.contentsOfDirectory(at: tmp, includingPropertiesForKeys: nil)
|
||||
guard entries.count == 1 else {
|
||||
throw ApiError(status: 0, message: "unpack: the archive holds \(entries.count) entries, not 1")
|
||||
}
|
||||
if (try? fm.attributesOfItem(atPath: target.path)) != nil {
|
||||
try fm.removeItem(at: target)
|
||||
}
|
||||
try fm.moveItem(at: entries[0], to: target)
|
||||
}
|
||||
}
|
||||
|
||||
private final class Flag: @unchecked Sendable {
|
||||
private let lock = NSLock()
|
||||
private var value = false
|
||||
|
||||
func set() -> Bool {
|
||||
lock.withLock {
|
||||
let was = value
|
||||
value = true
|
||||
return !was
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func spawn(_ argv: [String], in dir: String, timeoutMs: Int) async throws -> (Int, String) {
|
||||
let out = FileManager.default.temporaryDirectory.appending(path: "byre-\(UUID().uuidString).log")
|
||||
defer {
|
||||
try? FileManager.default.removeItem(at: out)
|
||||
}
|
||||
|
||||
var attr: posix_spawnattr_t?
|
||||
unsafe posix_spawnattr_init(&attr)
|
||||
defer {
|
||||
unsafe posix_spawnattr_destroy(&attr)
|
||||
}
|
||||
unsafe posix_spawnattr_setflags(&attr, Int16(POSIX_SPAWN_SETPGROUP))
|
||||
unsafe posix_spawnattr_setpgroup(&attr, 0)
|
||||
|
||||
var actions: posix_spawn_file_actions_t?
|
||||
unsafe posix_spawn_file_actions_init(&actions)
|
||||
defer {
|
||||
unsafe posix_spawn_file_actions_destroy(&actions)
|
||||
}
|
||||
unsafe posix_spawn_file_actions_addopen(&actions, 0, "/dev/null", O_RDONLY, 0)
|
||||
unsafe posix_spawn_file_actions_addopen(&actions, 1, out.path, O_WRONLY | O_CREAT | O_TRUNC, 0o600)
|
||||
unsafe posix_spawn_file_actions_adddup2(&actions, 1, 2)
|
||||
unsafe posix_spawn_file_actions_addchdir(&actions, dir)
|
||||
|
||||
var cargs = unsafe argv.map { unsafe strdup($0) } + [nil]
|
||||
defer {
|
||||
for unsafe p in unsafe cargs {
|
||||
unsafe free(p)
|
||||
}
|
||||
}
|
||||
var spawned: pid_t = 0
|
||||
let rc = unsafe posix_spawn(&spawned, argv[0], &actions, &attr, &cargs, environ)
|
||||
if rc != 0 {
|
||||
throw ApiError(status: 0, message: "start \(argv[0]): \(unsafe String(cString: strerror(rc)))")
|
||||
}
|
||||
let pid = spawned
|
||||
|
||||
let done = Flag()
|
||||
DispatchQueue.global().asyncAfter(deadline: .now() + .milliseconds(timeoutMs)) {
|
||||
if done.set() {
|
||||
kill(-pid, SIGKILL)
|
||||
}
|
||||
}
|
||||
let status: Int32 = await withCheckedContinuation { c in
|
||||
DispatchQueue.global().async {
|
||||
var s: Int32 = 0
|
||||
while unsafe waitpid(pid, &s, 0) == -1 && errno == EINTR {}
|
||||
c.resume(returning: s)
|
||||
}
|
||||
}
|
||||
let timedOut = !done.set()
|
||||
|
||||
let output = tail(out)
|
||||
if timedOut {
|
||||
return (-1, output)
|
||||
}
|
||||
if status & 0x7f == 0 {
|
||||
return (Int((status >> 8) & 0xff), output)
|
||||
}
|
||||
return (128 + Int(status & 0x7f), output)
|
||||
}
|
||||
|
||||
private func tail(_ file: URL) -> String {
|
||||
guard let h = try? FileHandle(forReadingFrom: file) else {
|
||||
return ""
|
||||
}
|
||||
defer {
|
||||
try? h.close()
|
||||
}
|
||||
let size = (try? h.seekToEnd()) ?? 0
|
||||
try? h.seek(toOffset: size > UInt64(maxOutput) ? size - UInt64(maxOutput) : 0)
|
||||
return String(decoding: (try? h.readToEnd()) ?? Data(), as: UTF8.self)
|
||||
}
|
||||
|
||||
private struct HandsRequest: Encodable {
|
||||
struct Params: Encodable {
|
||||
let name: String
|
||||
let arguments: OpenAPIObjectContainer
|
||||
}
|
||||
|
||||
let jsonrpc = "2.0"
|
||||
let id: Int
|
||||
let method = "tools/call"
|
||||
let params: Params
|
||||
}
|
||||
|
||||
private struct HandsResponse: Decodable {
|
||||
struct RPCError: Decodable {
|
||||
let message: String
|
||||
}
|
||||
|
||||
let id: Int
|
||||
let result: OpenAPIObjectContainer?
|
||||
let error: RPCError?
|
||||
}
|
||||
|
||||
private final class Helper: @unchecked Sendable {
|
||||
let process = Process()
|
||||
let input: FileHandle
|
||||
var buffer = Data()
|
||||
var pending: [Int: CheckedContinuation<OpenAPIObjectContainer, any Error>] = [:]
|
||||
|
||||
init(input: FileHandle) {
|
||||
self.input = input
|
||||
}
|
||||
}
|
||||
|
||||
private final class Hands: @unchecked Sendable {
|
||||
private let lock = NSLock()
|
||||
private var helper: Helper?
|
||||
private var next = 0
|
||||
|
||||
func call(_ tool: String, _ arguments: OpenAPIObjectContainer) async throws -> OpenAPIObjectContainer {
|
||||
try await withCheckedThrowingContinuation { c in
|
||||
lock.withLock {
|
||||
next += 1
|
||||
let id = next
|
||||
var target: Helper?
|
||||
do {
|
||||
let h = try running()
|
||||
target = h
|
||||
var line = try JSONEncoder().encode(HandsRequest(id: id, params: .init(name: tool, arguments: arguments)))
|
||||
line.append(0x0a)
|
||||
h.pending[id] = c
|
||||
try h.input.write(contentsOf: line)
|
||||
} catch {
|
||||
target?.pending[id] = nil
|
||||
c.resume(throwing: error)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func stop() {
|
||||
lock.withLock {
|
||||
helper?.process.terminate()
|
||||
}
|
||||
}
|
||||
|
||||
private func running() throws -> Helper {
|
||||
if let helper, helper.process.isRunning {
|
||||
return helper
|
||||
}
|
||||
signal(SIGPIPE, SIG_IGN)
|
||||
let stdin = Pipe()
|
||||
let stdout = Pipe()
|
||||
let h = Helper(input: stdin.fileHandleForWriting)
|
||||
let p = h.process
|
||||
p.executableURL = Bundle.main.executableURL!.deletingLastPathComponent().appending(path: "byre-hands")
|
||||
p.standardInput = stdin
|
||||
p.standardOutput = stdout
|
||||
p.standardError = FileHandle.nullDevice
|
||||
stdout.fileHandleForReading.readabilityHandler = { [weak self] fh in
|
||||
self?.read(h, fh.availableData)
|
||||
}
|
||||
p.terminationHandler = { [weak self] _ in
|
||||
self?.exited(h)
|
||||
}
|
||||
try p.run()
|
||||
log.info("byre-hands started: pid \(p.processIdentifier)")
|
||||
helper = h
|
||||
return h
|
||||
}
|
||||
|
||||
private func read(_ h: Helper, _ data: Data) {
|
||||
let lines: [Data] = lock.withLock {
|
||||
h.buffer.append(data)
|
||||
var out: [Data] = []
|
||||
while let nl = h.buffer.firstIndex(of: 0x0a) {
|
||||
out.append(h.buffer[h.buffer.startIndex..<nl])
|
||||
h.buffer = Data(h.buffer[(nl + 1)...])
|
||||
}
|
||||
return out
|
||||
}
|
||||
for line in lines {
|
||||
guard let resp = try? JSONDecoder().decode(HandsResponse.self, from: line) else {
|
||||
log.error("byre-hands sent an unreadable line")
|
||||
continue
|
||||
}
|
||||
let c = lock.withLock {
|
||||
h.pending.removeValue(forKey: resp.id)
|
||||
}
|
||||
if let result = resp.result {
|
||||
c?.resume(returning: result)
|
||||
} else {
|
||||
c?.resume(throwing: ApiError(status: 0, message: "byre-hands: \(resp.error?.message ?? "no result")"))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func exited(_ h: Helper) {
|
||||
let waiting = lock.withLock {
|
||||
let all = h.pending
|
||||
h.pending = [:]
|
||||
if helper === h {
|
||||
helper = nil
|
||||
}
|
||||
return all
|
||||
}
|
||||
let status = h.process.terminationStatus
|
||||
log.info("byre-hands exited: status \(status)")
|
||||
for c in waiting.values {
|
||||
c.resume(throwing: ApiError(status: 0, message: "byre-hands exited with status \(status) before replying"))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -121,7 +121,7 @@ final class Connection {
|
||||
}
|
||||
phase = .running(store)
|
||||
store.startGlobalStream(after: status.lastEventSeq)
|
||||
resumeClientRuns(store.api)
|
||||
store.remote.start()
|
||||
Notifier.shared.deliver()
|
||||
} catch {
|
||||
await store.stop()
|
||||
|
||||
@@ -50,6 +50,7 @@ final class Store {
|
||||
let images = ImageDrafts()
|
||||
let events = Listeners<ByreEvent>()
|
||||
let paneFocus: PaneFocus
|
||||
let remote: Remote
|
||||
|
||||
private var taskFeeds: [String: Feed] = [:]
|
||||
@ObservationIgnored private var historyLoads: Set<ObjectIdentifier> = []
|
||||
@@ -62,12 +63,14 @@ final class Store {
|
||||
self.api = api
|
||||
leadFeed = Feed(shown: { true })
|
||||
paneFocus = PaneFocus(api: api)
|
||||
remote = Remote(api: api)
|
||||
}
|
||||
|
||||
func stop() async {
|
||||
stopped = true
|
||||
globalStream?.stop()
|
||||
leadStream?.stop()
|
||||
remote.stop()
|
||||
drafts?.flush()
|
||||
await paneFocus.leave()
|
||||
}
|
||||
@@ -163,9 +166,6 @@ final class Store {
|
||||
if open.contains(where: { $0.kind == .question }) {
|
||||
return TaskState(kind: .answer, label: "Needs your answer", title: "The agent asked a question in the inbox")
|
||||
}
|
||||
if open.contains(where: { $0.kind == .clientRun }) {
|
||||
return TaskState(kind: .answer, label: "Ready to run", title: "The agent asked to run something on your Mac; see the inbox")
|
||||
}
|
||||
if task.merging {
|
||||
return TaskState(kind: .merging, label: "Merging", title: "The merge turn is running")
|
||||
}
|
||||
|
||||
+6
-2
@@ -9,10 +9,12 @@ svg=$mac/icon.svg
|
||||
icns=$build/byre.icns
|
||||
mkdir -p "$shared" "$build"
|
||||
lockf -k "$shared.lock" sh -ec '
|
||||
rsync -rlc --delete --exclude /mac/.build "$1/mac" "$1/api" "$2/"
|
||||
rsync -rlc --delete --exclude /mac/.build --exclude /hands/.build "$1/mac" "$1/api" "$1/hands" "$2/"
|
||||
swift build --package-path "$2/mac" --configuration release --no-color-diagnostics
|
||||
swift build --package-path "$2/hands" --configuration release --no-color-diagnostics
|
||||
cp "$2/mac/.build/release/Byre" "$3"
|
||||
' sh "$root" "$shared" "$build/Byre"
|
||||
cp "$2/hands/.build/release/byre-hands" "$4"
|
||||
' sh "$root" "$shared" "$build/Byre" "$build/byre-hands"
|
||||
if [ ! "$icns" -nt "$svg" ]; then
|
||||
iconset=$build/byre.iconset
|
||||
rm -rf "$iconset"
|
||||
@@ -26,6 +28,8 @@ fi
|
||||
rm -rf "$app"
|
||||
mkdir -p "$app/Contents/MacOS" "$app/Contents/Resources/fonts"
|
||||
cp "$build/Byre" "$app/Contents/MacOS/Byre"
|
||||
cp "$build/byre-hands" "$app/Contents/MacOS/byre-hands"
|
||||
codesign --force --sign "Apple Development" --identifier run.fc.byre.hands "$app/Contents/MacOS/byre-hands"
|
||||
cp "$mac/Info.plist" "$app/Contents/Info.plist"
|
||||
plutil -insert ByreCommit -string "$(git -C "$root" rev-parse HEAD)" "$app/Contents/Info.plist"
|
||||
cp "$mac"/fonts/*.woff2 "$app/Contents/Resources/fonts/"
|
||||
|
||||
Reference in New Issue
Block a user