From c41e88f7b5b9abf50c0ee0a17d281aa07ce83143 Mon Sep 17 00:00:00 2001 From: Peter Foti <4184067+thefotes@users.noreply.github.com> Date: Thu, 1 Oct 2026 17:51:46 -0400 Subject: [PATCH] Expose configurable Herdr socket key sending --- README.md | 2 +- Sources/DispatchProviders/HerdrAdapter.swift | 15 +- Sources/DispatchProviders/HerdrModels.swift | 23 +- .../HerdrProtocol22Codec.swift | 2 +- .../HerdrIntegrationTests.swift | 3 +- .../HerdrKeySendingTests.swift | 262 ++++++++++++++++++ docs/configuration.md | 60 ++++ docs/protocol/herdr-22.md | 7 + 8 files changed, 366 insertions(+), 8 deletions(-) create mode 100644 Tests/DispatchProvidersTests/HerdrKeySendingTests.swift diff --git a/README.md b/README.md index af9f27a..065d01d 100644 --- a/README.md +++ b/README.md @@ -18,7 +18,7 @@ actions, or several in a row: - **Herdr:** focus an agent by slot or name, cycle through agents, workspaces, or tabs, move between panes by direction, create a workspace, split or close - a pane, and type text into the focused pane. + a pane, type text into the focused pane, and send keys to a pane over the socket. - **macOS:** press a keyboard shortcut, type text, open or switch to an app, or bring a specific window forward. diff --git a/Sources/DispatchProviders/HerdrAdapter.swift b/Sources/DispatchProviders/HerdrAdapter.swift index fb68a85..060fc96 100644 --- a/Sources/DispatchProviders/HerdrAdapter.swift +++ b/Sources/DispatchProviders/HerdrAdapter.swift @@ -55,10 +55,15 @@ public actor HerdrAdapter: HerdrControlling { case let .fromCurrentState(resolve): try await refresh() concrete = try resolve(state) - Log.logger.notice(""" - Resolved \(String(describing: action), privacy: .public) \ - to \(String(describing: concrete), privacy: .public). - """) + if case .sendFocusedKeys = action { + // Key sequences can contain user text; keep them out of logs. + Log.logger.notice("Resolved socket key input to Herdr's focused pane.") + } else { + Log.logger.notice(""" + Resolved \(String(describing: action), privacy: .public) \ + to \(String(describing: concrete), privacy: .public). + """) + } case let .cycleText(options): try await cycleText(options) return @@ -167,6 +172,8 @@ public enum HerdrSemanticResolver { .fromCurrentState { try cycleTab(delta: delta, state: $0) } case let .splitFocusedPane(direction): .fromCurrentState { .splitPane(id: try focusedPaneID(in: $0), direction: direction) } + case let .sendFocusedKeys(keys): + .fromCurrentState { .sendKeys(paneID: try focusedPaneID(in: $0), keys: keys) } case let .cycleText(options): .cycleText(options: options) case let .cycleAgent(delta): diff --git a/Sources/DispatchProviders/HerdrModels.swift b/Sources/DispatchProviders/HerdrModels.swift index 9e89323..25b8246 100644 --- a/Sources/DispatchProviders/HerdrModels.swift +++ b/Sources/DispatchProviders/HerdrModels.swift @@ -22,6 +22,8 @@ public enum HerdrAction: Sendable, Equatable { /// option when it is still at the end of the pane's prompt. case cycleText(options: [String]) case sendText(paneID: String, text: String) + /// Resolves Herdr's focused pane from a fresh snapshot before sending keys. + case sendFocusedKeys(keys: [String]) case sendKeys(paneID: String, keys: [String]) public var identifier: String { @@ -40,7 +42,7 @@ public enum HerdrAction: Sendable, Equatable { case .splitPane: "herdr.pane.split" case .cycleText: "herdr.pane.cycleText" case .sendText: "herdr.pane.sendText" - case .sendKeys: "herdr.pane.sendKeys" + case .sendFocusedKeys, .sendKeys: "herdr.pane.sendKeys" } } } @@ -285,6 +287,7 @@ public enum HerdrActions { public static let createWorkspace: ActionID = "herdr.workspace.create" public static let splitFocusedPane: ActionID = "herdr.pane.splitFocused" public static let cycleText: ActionID = "herdr.pane.cycleText" + public static let sendKeys: ActionID = "herdr.pane.sendKeys" public static let definitions: [ActionDefinition] = [ ActionDefinition( @@ -359,6 +362,15 @@ public enum HerdrActions { title: "Cycle Text in Focused Herdr Pane", summary: "Types the next option into the focused pane, replacing the option typed by the previous press.", arguments: [.init(name: "options", type: .array, summary: "Non-empty array of strings to cycle through.")] + ), + ActionDefinition( + id: sendKeys, + title: "Send Keys to Herdr Pane", + summary: "Sends terminal keys over Herdr's socket to an explicit pane or the currently focused pane.", + arguments: [ + .init(name: "keys", type: .array, summary: "Non-empty array of Herdr key-combo strings, in order."), + .init(name: "paneID", type: .string, required: false, summary: "Omit to use Herdr's focused pane.") + ] ) ] @@ -402,6 +414,15 @@ public enum HerdrActions { return .splitFocusedPane(direction: direction) case cycleText: return .cycleText(options: try nonEmptyStrings("options", in: invocation)) + case sendKeys: + let keys = try nonEmptyStrings("keys", in: invocation) + if let paneID = try optionalString("paneID", in: invocation) { + guard !paneID.isEmpty else { + throw HerdrIntegrationError.invalidArgument(action: invocation.id, name: "paneID") + } + return .sendKeys(paneID: paneID, keys: keys) + } + return .sendFocusedKeys(keys: keys) default: throw HerdrIntegrationError.unsupportedAction(invocation.id) } diff --git a/Sources/DispatchProviders/HerdrProtocol22Codec.swift b/Sources/DispatchProviders/HerdrProtocol22Codec.swift index 50403b1..a50943f 100644 --- a/Sources/DispatchProviders/HerdrProtocol22Codec.swift +++ b/Sources/DispatchProviders/HerdrProtocol22Codec.swift @@ -7,7 +7,7 @@ public struct HerdrProtocol22Codec: HerdrWireActionEncoding { public func request(for action: HerdrAction) throws -> [String: JSONValue] { switch action { - case .focusAgentSlot, .closeFocusedPane, .cycleTab, .splitFocusedPane, .cycleText: + case .focusAgentSlot, .closeFocusedPane, .cycleTab, .splitFocusedPane, .cycleText, .sendFocusedKeys: throw HerdrProtocol22Error.semanticActionRequiresState case .cycleAgent, .cycleWorkspace: throw HerdrProtocol22Error.clientActionHasNoRequest diff --git a/Tests/DispatchProvidersTests/HerdrIntegrationTests.swift b/Tests/DispatchProvidersTests/HerdrIntegrationTests.swift index 3b6b9da..a20a9a1 100644 --- a/Tests/DispatchProvidersTests/HerdrIntegrationTests.swift +++ b/Tests/DispatchProvidersTests/HerdrIntegrationTests.swift @@ -21,7 +21,8 @@ final class HerdrIntegrationTests: XCTestCase { "herdr.workspace.cycle", "herdr.workspace.create", "herdr.pane.splitFocused", - "herdr.pane.cycleText" + "herdr.pane.cycleText", + "herdr.pane.sendKeys" ] ) } diff --git a/Tests/DispatchProvidersTests/HerdrKeySendingTests.swift b/Tests/DispatchProvidersTests/HerdrKeySendingTests.swift new file mode 100644 index 0000000..82af51f --- /dev/null +++ b/Tests/DispatchProvidersTests/HerdrKeySendingTests.swift @@ -0,0 +1,262 @@ +import DispatchCore +import DispatchProviders +import Foundation +import XCTest + +/// Configured actions reach a Herdr-like socket without using macOS automation. +final class HerdrKeySendingTests: XCTestCase { + func testConfiguredBindingSendsKeysToThePaneFocusedAtPressTime() async throws { + let (adapter, herdr, server) = try makeAdapter() + defer { server.stop() } + try await adapter.refresh() + herdr.focus("right") + let registry = try ActionRegistry(registrations: HerdrActions.registrations(controller: adapter)) + let actions = try configuredActions(#"[{"id":"herdr.pane.sendKeys","arguments":{"keys":["ctrl+z"]}}]"#) + + try await registry.execute(try XCTUnwrap(actions.first)) + + XCTAssertEqual(herdr.sentInputs, [["pane_id": .string("right"), "keys": .array([.string("ctrl+z")])]]) + XCTAssertEqual(herdr.methods, ["session.snapshot", "session.snapshot", "pane.send_keys", "session.snapshot"]) + XCTAssertEqual(herdr.focusedPane, "right") + } + + func testConfiguredMacroTargetsExplicitPanesAndPreservesKeyOrder() async throws { + let (adapter, herdr, server) = try makeAdapter() + defer { server.stop() } + try await adapter.refresh() + let registry = try ActionRegistry(registrations: HerdrActions.registrations(controller: adapter)) + let actions = try configuredActions(""" + [ + {"id":"herdr.pane.sendKeys","arguments":{"paneID":"right","keys":["esc","ctrl+z"]}}, + {"id":"herdr.pane.sendKeys","arguments":{"paneID":"left","keys":["ctrl+z"]}} + ] + """) + + for action in actions { + try await registry.execute(action) + } + + XCTAssertEqual(herdr.sentInputs, [ + ["pane_id": .string("right"), "keys": .array([.string("esc"), .string("ctrl+z")])], + ["pane_id": .string("left"), "keys": .array([.string("ctrl+z")])] + ]) + XCTAssertEqual(herdr.methods, [ + "session.snapshot", "pane.send_keys", "session.snapshot", "pane.send_keys", "session.snapshot" + ]) + XCTAssertEqual(herdr.focusedPane, "left") + } + + func testMissingFocusedPaneFailsWithoutSendingKeys() async throws { + let (adapter, herdr, server) = try makeAdapter() + defer { server.stop() } + herdr.focus(nil) + + do { + try await adapter.execute(invocation(arguments: ["keys": .array([.string("ctrl+z")])])) + XCTFail("Expected a missing focus error") + } catch let error as HerdrIntegrationError { + XCTAssertEqual(error, .unavailableState("Herdr has no focused pane.")) + } + + XCTAssertTrue(herdr.sentInputs.isEmpty) + XCTAssertEqual(herdr.methods, ["session.snapshot"]) + } + + func testHerdrKeyErrorReachesTheRegistryAndLeavesHerdrAvailable() async throws { + let (adapter, herdr, server) = try makeAdapter() + defer { server.stop() } + try await adapter.refresh() + herdr.rejectInput(code: "invalid_params", message: "Unsupported key") + let registry = try ActionRegistry(registrations: HerdrActions.registrations(controller: adapter)) + let action = try registry.catalog.makeInvocation( + id: HerdrActions.sendKeys, + arguments: ["paneID": .string("left"), "keys": .array([.string("invalid-key")])] + ) + + do { + try await registry.execute(action) + XCTFail("Expected Herdr's key validation error") + } catch let error as ActionExecutionError { + guard case let .failed(id, message) = error else { return XCTFail("Unexpected error: \(error)") } + XCTAssertEqual(id, HerdrActions.sendKeys) + XCTAssertTrue(message.contains("Unsupported key")) + } + + let available = await adapter.isAvailable() + XCTAssertTrue(available) + XCTAssertEqual(herdr.methods, ["session.snapshot", "pane.send_keys"]) + } + + func testMissingExplicitPaneDoesNotFallBackToFocus() async throws { + let (adapter, herdr, server) = try makeAdapter() + defer { server.stop() } + herdr.rejectInput(code: "not_found", message: "No pane") + + do { + try await adapter.execute(invocation(arguments: [ + "paneID": .string("gone"), "keys": .array([.string("ctrl+z")]) + ])) + XCTFail("Expected a missing pane error") + } catch let error as HerdrAPIError { + XCTAssertEqual(error, .remote(code: "not_found", message: "No pane")) + } + + XCTAssertEqual(herdr.methods, ["pane.send_keys"]) + XCTAssertEqual(herdr.sentInputs.first?["pane_id"], .string("gone")) + } + + func testDecoderRejectsMalformedKeyArraysAndEmptyPaneID() async throws { + let (adapter, herdr, server) = try makeAdapter() + defer { server.stop() } + let invalidKeys: [JSONValue] = [.array([]), .array([.integer(1)]), .array([.string("")]), .string("ctrl+z")] + for keys in invalidKeys { + let action = try invocation(arguments: ["keys": keys]) + XCTAssertThrowsError(try HerdrActions.decode(action)) { error in + XCTAssertEqual( + error as? HerdrIntegrationError, + .invalidArgument(action: HerdrActions.sendKeys, name: "keys") + ) + } + do { + try await adapter.execute(action) + XCTFail("Expected invalid keys") + } catch let error as HerdrIntegrationError { + XCTAssertEqual(error, .invalidArgument(action: HerdrActions.sendKeys, name: "keys")) + } + } + XCTAssertThrowsError(try HerdrActions.decode(invocation(arguments: [ + "keys": .array([.string("ctrl+z")]), "paneID": .string("") + ]))) { error in + XCTAssertEqual( + error as? HerdrIntegrationError, + .invalidArgument(action: HerdrActions.sendKeys, name: "paneID") + ) + } + XCTAssertTrue(herdr.methods.isEmpty) + } + + func testCatalogRequiresKeysAndRejectsWrongTypesAndUnknownArguments() throws { + let catalog = try ActionCatalog(definitions: HerdrActions.definitions) + XCTAssertThrowsError(try catalog.makeInvocation(id: HerdrActions.sendKeys)) { error in + XCTAssertEqual(error as? ActionValidationError, .missingArgument("keys")) + } + XCTAssertThrowsError(try catalog.makeInvocation(id: HerdrActions.sendKeys, arguments: ["keys": .string("esc")])) + XCTAssertThrowsError(try catalog.makeInvocation(id: HerdrActions.sendKeys, arguments: [ + "keys": .array([.string("esc")]), "paneID": .integer(1) + ])) + XCTAssertThrowsError(try catalog.makeInvocation(id: HerdrActions.sendKeys, arguments: [ + "keys": .array([.string("esc")]), "all": .boolean(true) + ])) { error in + XCTAssertEqual(error as? ActionValidationError, .unknownArgument("all")) + } + } + + func testFocusedKeysRequireResolutionBeforeWireEncoding() throws { + let action = try HerdrActions.decode(invocation(arguments: ["keys": .array([.string("ctrl+z")])])) + XCTAssertEqual(action, .sendFocusedKeys(keys: ["ctrl+z"])) + XCTAssertThrowsError(try HerdrProtocol22Codec().request(for: action)) { error in + XCTAssertEqual(error as? HerdrProtocol22Error, .semanticActionRequiresState) + } + } + + func testDisconnectedRegistryReportsKeySendingUnavailable() async throws { + let controller = RecordingHerdrController() + let registry = try ActionRegistry(registrations: HerdrActions.registrations(controller: controller)) + let action = try registry.catalog.makeInvocation( + id: HerdrActions.sendKeys, arguments: ["keys": .array([.string("esc")])] + ) + do { + try await registry.execute(action) + XCTFail("Expected unavailable key sending") + } catch let error as ActionExecutionError { + XCTAssertEqual(error, .unavailable(HerdrActions.sendKeys)) + } + } + + private func configuredActions(_ actionsJSON: String) throws -> [ActionInvocation] { + let json = """ + {"version":1,"bindings":[{ + "when":{"control":{"type":"key","index":9},"gesture":{"type":"pressed"}}, + "actions":\(actionsJSON) + }]} + """ + let configuration = try JSONDecoder().decode(DispatchConfiguration.self, from: Data(json.utf8)) + let resolver = BindingResolver(bindings: try CompiledBindings.compile( + configuration, catalog: ActionCatalog(definitions: HerdrActions.definitions) + )) + return resolver.resolve(DispatchEvent( + source: DeviceIdentity(rawValue: "test-pad"), control: .key(9), gesture: .pressed, timestamp: Date() + )) + } + + private func invocation(arguments: [String: JSONValue]) throws -> ActionInvocation { + let json: JSONValue = .object(["id": .string(HerdrActions.sendKeys.rawValue), "arguments": .object(arguments)]) + return try JSONDecoder().decode(ActionInvocation.self, from: JSONEncoder().encode(json)) + } + + private func makeAdapter() throws -> (HerdrAdapter, KeySendingSession, OneShotUnixServer) { + let path = "/tmp/dispatch-keys-\(UUID().uuidString).sock" + let herdr = KeySendingSession() + let server: OneShotUnixServer + do { + server = try OneShotUnixServer(path: path, requestCount: 20) { herdr.respond(to: $0) } + } catch let error as POSIXError where error.code == .EPERM { + throw XCTSkip("The test host sandbox does not permit binding a Unix-domain socket.") + } + return (HerdrAdapter( + client: HerdrUnixSocketClient(configuration: .init(socketPath: path, timeout: .seconds(1))), + encoder: HerdrProtocol22Codec() + ), herdr, server) + } +} + +/// A session whose focus can move outside Dispatch between polls and key presses. +private final class KeySendingSession: @unchecked Sendable { + private let lock = NSLock() + private var focused: String? = "left" + private var requests: [[String: JSONValue]] = [] + private var inputError: JSONValue? + + var focusedPane: String? { lock.withLock { focused } } + var methods: [String] { + lock.withLock { + requests.compactMap { + guard case let .string(method)? = $0["method"] else { return nil } + return method + } + } + } + var sentInputs: [[String: JSONValue]] { + lock.withLock { + requests.filter { $0["method"] == .string("pane.send_keys") }.compactMap { + guard case let .object(params)? = $0["params"] else { return nil } + return params + } + } + } + + func focus(_ pane: String?) { lock.withLock { focused = pane } } + + func rejectInput(code: String, message: String) { + lock.withLock { inputError = .object(["code": .string(code), "message": .string(message)]) } + } + + func respond(to request: [String: JSONValue]) -> [String: JSONValue] { + lock.withLock { + requests.append(request) + if request["method"] == .string("pane.send_keys") { + if let inputError { return ["id": request["id"] ?? .null, "error": inputError] } + return ["id": request["id"] ?? .null, "result": .object([:])] + } + return ["id": request["id"] ?? .null, "result": .object([ + "type": .string("session_snapshot"), + "snapshot": .object([ + "version": .string("0.9.3"), "protocol": .integer(22), + "focused_pane_id": focused.map(JSONValue.string) ?? .null, + "panes": .array([.object(["pane_id": .string("left")]), .object(["pane_id": .string("right")])]), + "tabs": .array([]), "agents": .array([]) + ]) + ])] + } + } +} diff --git a/docs/configuration.md b/docs/configuration.md index 9e7d078..bbc6917 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -70,6 +70,10 @@ snapshot: `statusPalette.ambientPriority` order (see [Status colors](#status-colors)) - `herdr.pane.closeFocused` — no arguments - `herdr.pane.focusDirection` — string `direction`; optional string `paneID` +- `herdr.pane.sendKeys` — non-empty array of non-empty strings `keys`; optional + string `paneID`. Sends the keys in order over Herdr's socket. Without `paneID`, + takes a fresh snapshot at press time and targets Herdr's focused pane. An + explicit `paneID` targets that pane without changing focus. - `herdr.tab.cycle` — signed integer `delta` - `herdr.workspace.create` — no arguments; creates a workspace and focuses it - `herdr.pane.splitFocused` — string `direction`: `right` (side by side) or @@ -177,6 +181,62 @@ Shortcut modifiers are `command`, `option`, `control`, `shift`, and `function`. Keys include letters, number words such as `one`, navigation keys, `return`, `rightCommand`, and `f13` through `f19`. Text and macro contents are never written to normal logs. +## Send terminal keys over the Herdr socket + +`herdr.pane.sendKeys` uses Herdr's terminal key syntax, including `esc`, `enter`, +`ctrl+c`, `ctrl+z`, `shift+tab`, and `f1`. Herdr validates the key names; invalid +names fail the action. These are keys for the program inside the pane, not +Herdr's `prefix+` bindings or macOS `keyboard.shortcut` key names. The terminal +window does not need to be in front, and this action needs no Accessibility +permission. + +For example, bind key 9 to send `Ctrl+Z` to Herdr's focused pane: + +```json +{ + "version": 1, + "bindings": [ + { + "when": { + "control": { "type": "key", "index": 9 }, + "gesture": { "type": "pressed" } + }, + "actions": [ + { + "id": "herdr.pane.sendKeys", + "arguments": { "keys": ["ctrl+z"] } + } + ] + } + ] +} +``` + +Merge the binding into your existing file, replacing any existing binding for +that event. `Ctrl+Z` suspends Claude Code on macOS/Linux, where `fg` at the shell +resumes it; other programs can handle it differently. Sending a key does not +promise that an agent, its child processes, or its background tasks have all +paused. See the [Claude Code keyboard reference](https://code.claude.com/docs/en/interactive-mode). + +To target specific panes, use one action per pane in the binding's `actions`: + +```json +[ + { "id": "herdr.pane.sendKeys", "arguments": { "paneID": "w1:p1", "keys": ["ctrl+z"] } }, + { "id": "herdr.pane.sendKeys", "arguments": { "paneID": "w1:p2", "keys": ["ctrl+z"] } } +] +``` + +Read live pane IDs from `herdr pane list`; they can change when panes are closed, +recreated, or moved. IDs belong to the machine Herdr currently shows, which +Dispatch selects before each request. The macro executes in order and stops +if an action fails; it does not discover all working agents or broadcast across +machines. If Herdr has no focused pane, a binding without `paneID` fails without +sending keys. Herdr reports a missing explicit pane as an action error. + +See [Herdr's socket API](https://herdr.dev/docs/socket-api/) for supported key +syntax and input semantics. + ## Agent row labels The optional `agentLabel` string sets how the menu-bar panel names each agent diff --git a/docs/protocol/herdr-22.md b/docs/protocol/herdr-22.md index e520a96..bf422e8 100644 --- a/docs/protocol/herdr-22.md +++ b/docs/protocol/herdr-22.md @@ -43,6 +43,13 @@ resolves against it before encoding one of the concrete API methods above. They remain in the `herdr.*` action namespace because their semantics have not been generalized to other providers. +The configurable `herdr.pane.sendKeys` action encodes `pane.send_keys` with +`pane_id` and the ordered `keys` array. Its optional `paneID` is resolved from a +fresh snapshot's focused pane when omitted; an explicit ID is sent directly. +Keys use Herdr's key-combo syntax (for example `esc`, `ctrl+z`, or `shift+tab`), +validated by Herdr. Key sending controls terminal input and provides no +agent-independent pause or resume operation. + Observed with Herdr 0.9.1: the snapshot's `workspaces` entries carry `workspace_id` and a display `label`, and each `agents` entry carries its `workspace_id`, its `agent` kind (such as `claude`, `codex`, or `opencode`),