From 6a4218c08af6ac121d813c00c3057fee5f3d9f1c Mon Sep 17 00:00:00 2001 From: iamnbutler Date: Thu, 12 Nov 2026 14:48:46 -0500 Subject: [PATCH] Host: read the keyboard layout on the main thread; a call cut off mid-action is unknown (#244) Mac.systemState(), which every desktop_apps inventory and the observation after desktop_open read, called Text Input Sources off the main thread. HIToolbox traps on a queue assertion there, and the host crashed mid-call; the crash reports show it in the release app and the dev app. The agent saw only "The Desktop Tools host closed the connection" although the open had happened. The MCP server now reports a connection lost during an action as outcome unknown (HOST_CONNECTION_LOST, retry_safety unsafe), and during a read as safe to call again. Why: native2/Sources/DesktopMac/System.swift | Text Input Sources (TIS*) calls must run on the main thread: HIToolbox asserts its queue and traps the whole host otherwise, only sometimes, so it passes in tests Why: native2/Sources/desktop-tools-mcp/main.swift | a connection that drops after an action was sent means the action may have run; report unknown, never a plain error an agent reads as nothing happened Co-Authored-By: Claude Opus 5.5 --- native2/Sources/DesktopMac/System.swift | 12 +++++++----- native2/Sources/desktop-tools-mcp/main.swift | 17 ++++++++++++++++- 2 files changed, 23 insertions(+), 6 deletions(-) diff --git a/native2/Sources/DesktopMac/System.swift b/native2/Sources/DesktopMac/System.swift index 2788e94e..1e40bb45 100644 --- a/native2/Sources/DesktopMac/System.swift +++ b/native2/Sources/DesktopMac/System.swift @@ -58,11 +58,13 @@ extension Mac { NSWorkspace.shared.runningApplications.contains { $0.bundleIdentifier == "com.apple.ScreenSaver.Engine" } } - var layout: JSON? - if let source = TISCopyCurrentKeyboardLayoutInputSource()?.takeRetainedValue(), - let id = tisString(source, kTISPropertyInputSourceID) - { - layout = .object(["id": JSON(id), "name": tisString(source, kTISPropertyLocalizedName).map(JSON.string)]) + // Text Input Sources must be read on the main thread: off it, HIToolbox can trap on a queue assertion and take + // the host down mid-call, which agents saw as "host closed the connection" after a desktop_open (#244). + let layout = await MainActor.run { () -> JSON? in + guard let source = TISCopyCurrentKeyboardLayoutInputSource()?.takeRetainedValue(), + let id = tisString(source, kTISPropertyInputSourceID) + else { return nil } + return .object(["id": JSON(id), "name": tisString(source, kTISPropertyLocalizedName).map(JSON.string)]) } let pointer = CGEvent(source: nil).map(\.location).map { point -> JSON in .object(["x": JSON(Double(point.x)), "y": JSON(Double(point.y)), "display_index": WindowPlaces().pointerDisplay(point).map { JSON($0) }]) diff --git a/native2/Sources/desktop-tools-mcp/main.swift b/native2/Sources/desktop-tools-mcp/main.swift index 61954b7d..dcd3597b 100644 --- a/native2/Sources/desktop-tools-mcp/main.swift +++ b/native2/Sources/desktop-tools-mcp/main.swift @@ -203,7 +203,22 @@ func call(_ key: String, tool: String, arguments: JSON) async -> JSON { if case .system("connect", _) = error { return errorResult("The Desktop Tools 2 host isn't serving at \(socketPath). Start it with: native2/.build/debug/desktop-tools-host") } - return errorResult(error.description) + // The request may have reached the host, which can stop or restart mid-call. An action may then have taken + // effect, as a desktop_open whose host stopped after Xcode opened the file did (#244), so it is unknown, never + // a plain error an agent reads as "nothing happened". + if Contract.actions.contains(tool) { + let action: JSON = .object([("action", .object([ + ("outcome", "unknown"), ("action", JSON(tool)), ("retry_safety", "unsafe"), + ("error", .object([("code", "HOST_CONNECTION_LOST"), ("message", JSON( + "\(error.description) The Desktop Tools host stopped while running \(tool), so it may have taken effect. " + + "Observe the target (desktop_windows, desktop_inspect) before deciding whether to act again; don't repeat it blindly."))])), + ]))]) + return .object([ + ("content", [.object([("type", "text"), ("text", "Outcome: unknown.")]), .object([("type", "text"), ("text", JSON(action.text))])]), + ("isError", true), + ]) + } + return errorResult("\(error.description) Nothing was changed by this read; call it again.") } catch { return errorResult(String(describing: error)) }