diff --git a/.claude/skills/self-verify-prowl/SKILL.md b/.claude/skills/self-verify-prowl/SKILL.md index 03ec0660..9d14baa0 100644 --- a/.claude/skills/self-verify-prowl/SKILL.md +++ b/.claude/skills/self-verify-prowl/SKILL.md @@ -54,7 +54,7 @@ Do not launch the built `ProwlApp` binary directly in the background from an age If plain `make run-app` reports a socket ownership problem, relaunch with the custom `PROWL_CLI_SOCKET`; the installed app may already own the standard socket. -If the socket appears but `prowl_debug list --json` returns `APP_NOT_RUNNING`, the app likely exited and left a stale socket/lock behind. Remove both socket files, confirm no debug `ProwlApp` PID is still alive, then relaunch with `PROWL_CLI_SOCKET=/tmp/prowl-self-verify.sock make run-app` in a persistent shell session. +If the socket appears but `prowl_debug list --json` returns `APP_NOT_RUNNING`, the app likely exited and left a stale socket/lock behind. Remove both socket files, confirm no debug `ProwlApp` PID is still alive, then relaunch with `PROWL_CLI_SOCKET=/tmp/prowl-self-verify.sock make run-app` in a persistent shell session. If it returns `SOCKET_PERMISSION_DENIED`, the agent sandbox cannot connect to that socket path; allowlist the path or rerun the CLI outside that sandbox. When `PROWL_CLI_SOCKET` is set, CLI auto-launch is disabled. The debug app and every CLI invocation must use the same socket value. diff --git a/ProwlCLI/AppLauncher.swift b/ProwlCLI/AppLauncher.swift index b969cbf5..3765c046 100644 --- a/ProwlCLI/AppLauncher.swift +++ b/ProwlCLI/AppLauncher.swift @@ -6,12 +6,6 @@ import AppKit import Foundation import ProwlCLIShared -#if canImport(Darwin) - import Darwin -#elseif canImport(Glibc) - import Glibc -#endif - enum AppLauncher { private static let prowlAppBundleIdentifiers: Set = [ "com.onevcat.prowl", @@ -25,7 +19,7 @@ enum AppLauncher { /// Check whether the CLI socket is currently connectable. static func isSocketAvailable() -> Bool { - canConnect(to: ProwlSocket.defaultPath) + socketStatus().isConnected } /// Ensure the app is running and the socket is ready. @@ -41,21 +35,25 @@ enum AppLauncher { return false } + let initialStatus = socketStatus() // Fast path: socket file exists and is connectable. - if isSocketAvailable() { + if initialStatus.isConnected { return false } + if let error = initialStatus.permissionBlocker { + throw error + } // Socket not available — check if the app process is running. if isAppProcessRunning() { // App is running but socket isn't ready yet. Wait without launching. - try waitForSocket() + try waitForSocket(initialStatus: initialStatus) return false } // App is genuinely not running. Launch and wait. try launchApp() - try waitForSocket() + try waitForSocket(initialStatus: initialStatus) return true } @@ -110,43 +108,46 @@ enum AppLauncher { // MARK: - Socket readiness - private static func waitForSocket() throws { - let socketPath = ProwlSocket.defaultPath + private static func waitForSocket(initialStatus: SocketConnectionProbe.Status) throws { let deadline = Date().addingTimeInterval(socketTimeoutSeconds) + var lastStatus = initialStatus while Date() < deadline { - if canConnect(to: socketPath) { + let status = socketStatus() + if status.isConnected { return } + if let error = status.immediateLaunchBlocker { + throw error + } + lastStatus = status Thread.sleep(forTimeInterval: pollIntervalSeconds) } - throw ExitError( - code: CLIErrorCode.launchFailed, - message: "Prowl CLI socket did not become available within \(Int(socketTimeoutSeconds))s." - ) + let message = """ + Prowl CLI socket did not become available within \(Int(socketTimeoutSeconds))s. \ + Last connection error: \(lastStatus.diagnosticMessage). + """ + throw ExitError(code: CLIErrorCode.launchFailed, message: message) } - private static func canConnect(to socketPath: String) -> Bool { - let socketFD = socket(AF_UNIX, SOCK_STREAM, 0) - guard socketFD >= 0 else { return false } - defer { close(socketFD) } - - var addr = sockaddr_un() - addr.sun_family = sa_family_t(AF_UNIX) - let pathBytes = Array(socketPath.utf8) - let maxLen = MemoryLayout.size(ofValue: addr.sun_path) - 1 - let copyLen = min(pathBytes.count, maxLen) - withUnsafeMutableBytes(of: &addr.sun_path) { sunPathPtr in - for idx in 0.. SocketConnectionProbe.Status { + SocketConnectionProbe.check(socketPath: ProwlSocket.defaultPath) + } +} + +extension SocketConnectionProbe.Status { + fileprivate var permissionBlocker: ExitError? { + if case .permissionDenied = self { + return exitError() } + return nil + } - let result = withUnsafePointer(to: &addr) { ptr in - ptr.withMemoryRebound(to: sockaddr.self, capacity: 1) { sockPtr in - connect(socketFD, sockPtr, socklen_t(MemoryLayout.size)) - } + fileprivate var immediateLaunchBlocker: ExitError? { + switch self { + case .connected, .appUnavailable: + nil + case .permissionDenied, .transportFailed: + exitError() } - return result == 0 } } diff --git a/ProwlCLI/Transport/SocketConnectionProbe.swift b/ProwlCLI/Transport/SocketConnectionProbe.swift new file mode 100644 index 00000000..52c48eaa --- /dev/null +++ b/ProwlCLI/Transport/SocketConnectionProbe.swift @@ -0,0 +1,210 @@ +// ProwlCLI/Transport/SocketConnectionProbe.swift +// Shared Unix socket connection diagnostics for CLI transport and app launch. + +import Foundation +import ProwlCLIShared + +#if canImport(Darwin) + import Darwin +#elseif canImport(Glibc) + import Glibc +#endif + +enum SocketConnectionProbe { + enum Status: Equatable { + case connected + case appUnavailable(Failure) + case permissionDenied(Failure) + case transportFailed(Failure) + + var isConnected: Bool { + self == .connected + } + + func exitError() -> ExitError? { + switch self { + case .connected: + nil + case .appUnavailable(let failure): + ExitError(code: CLIErrorCode.appNotRunning, message: failure.appUnavailableMessage) + case .permissionDenied(let failure): + ExitError(code: CLIErrorCode.socketPermissionDenied, message: failure.permissionDeniedMessage) + case .transportFailed(let failure): + ExitError(code: CLIErrorCode.transportFailed, message: failure.transportFailedMessage) + } + } + + var diagnosticMessage: String { + switch self { + case .connected: + "connected" + case .appUnavailable(let failure): + failure.shortDiagnostic + case .permissionDenied(let failure): + failure.shortDiagnostic + case .transportFailed(let failure): + failure.shortDiagnostic + } + } + } + + struct Failure: Equatable { + enum Kind: Equatable { + case socketCreation(errno: Int32) + case pathTooLong(maximumLength: Int) + case connect(errno: Int32) + } + + let socketPath: String + let kind: Kind + + var shortDiagnostic: String { + switch kind { + case .socketCreation(let errorNumber): + "socket creation failed (\(Self.errnoDescription(errorNumber)))" + case .pathTooLong(let maximumLength): + "socket path is too long (max \(maximumLength) bytes)" + case .connect(let errorNumber): + "connect failed (\(Self.errnoDescription(errorNumber)))" + } + } + + var appUnavailableMessage: String { + """ + Cannot connect to Prowl CLI socket at \(socketPath): app is not running or the socket is stale \ + (\(diagnosticDetail)). Start or restart Prowl, then retry. + """ + } + + var permissionDeniedMessage: String { + """ + Cannot connect to Prowl CLI socket at \(socketPath): permission denied (\(diagnosticDetail)). \ + If this command is running in a sandboxed agent, allow this Unix socket path in the sandbox profile, \ + run prowl outside the sandbox, or start both Prowl and prowl with the same PROWL_CLI_SOCKET path \ + that the sandbox can access. + """ + } + + var transportFailedMessage: String { + switch kind { + case .pathTooLong(let maximumLength): + """ + Cannot connect to Prowl CLI socket at \(socketPath): socket path is too long \ + (max \(maximumLength) bytes). If PROWL_CLI_SOCKET is set, choose a shorter path. + """ + default: + """ + Cannot connect to Prowl CLI socket at \(socketPath): transport failure (\(diagnosticDetail)). \ + Check PROWL_CLI_SOCKET and ensure it points to Prowl's Unix socket. + """ + } + } + + private var diagnosticDetail: String { + switch kind { + case .socketCreation(let errorNumber), .connect(let errorNumber): + Self.errnoDescription(errorNumber) + case .pathTooLong(let maximumLength): + "path too long; max \(maximumLength) bytes" + } + } + + private static func errnoDescription(_ errorNumber: Int32) -> String { + let name = errnoName(errorNumber) + let message = strerror(errorNumber).map { String(cString: $0) } ?? "Unknown error" + return "\(name): \(message)" + } + } + + static func check(socketPath: String) -> Status { + let socketFD = socket(AF_UNIX, SOCK_STREAM, 0) + guard socketFD >= 0 else { + return .transportFailed(Failure(socketPath: socketPath, kind: .socketCreation(errno: errno))) + } + defer { close(socketFD) } + + return connect(socketFD: socketFD, socketPath: socketPath) + } + + static func connect(socketFD: Int32, socketPath: String) -> Status { + let addr: sockaddr_un + do { + addr = try socketAddress(for: socketPath) + } catch let error as SocketAddressError { + switch error { + case .pathTooLong(let maximumLength): + return .transportFailed(Failure(socketPath: socketPath, kind: .pathTooLong(maximumLength: maximumLength))) + } + } catch { + return .transportFailed(Failure(socketPath: socketPath, kind: .connect(errno: EINVAL))) + } + + let result = withUnsafePointer(to: addr) { ptr in + ptr.withMemoryRebound(to: sockaddr.self, capacity: 1) { sockPtr in + systemConnect(socketFD, sockPtr, socklen_t(MemoryLayout.size)) + } + } + guard result == 0 else { + return status(for: Failure(socketPath: socketPath, kind: .connect(errno: errno))) + } + return .connected + } + + private static func status(for failure: Failure) -> Status { + guard case .connect(let errorNumber) = failure.kind else { + return .transportFailed(failure) + } + + switch errorNumber { + case ENOENT, ECONNREFUSED: + return .appUnavailable(failure) + case EPERM, EACCES: + return .permissionDenied(failure) + default: + return .transportFailed(failure) + } + } + + private static func socketAddress(for socketPath: String) throws -> sockaddr_un { + var addr = sockaddr_un() + addr.sun_family = sa_family_t(AF_UNIX) + let pathBytes = Array(socketPath.utf8) + let maxLength = MemoryLayout.size(ofValue: addr.sun_path) - 1 + guard pathBytes.count <= maxLength else { + throw SocketAddressError.pathTooLong(maximumLength: maxLength) + } + + withUnsafeMutableBytes(of: &addr.sun_path) { sunPathPtr in + for idx in 0.. String { + switch errorNumber { + case EACCES: "EACCES" + case ECONNREFUSED: "ECONNREFUSED" + case EINVAL: "EINVAL" + case ENOENT: "ENOENT" + case ENOTSOCK: "ENOTSOCK" + case EPERM: "EPERM" + default: "errno \(errorNumber)" + } + } + + private static func systemConnect(_ socketFD: Int32, _ address: UnsafePointer, _ length: socklen_t) -> Int32 + { + #if canImport(Darwin) + Darwin.connect(socketFD, address, length) + #else + Glibc.connect(socketFD, address, length) + #endif + } +} + +private enum SocketAddressError: Error { + case pathTooLong(maximumLength: Int) +} diff --git a/ProwlCLI/Transport/SocketTransportClient.swift b/ProwlCLI/Transport/SocketTransportClient.swift index b1658715..b685b79b 100644 --- a/ProwlCLI/Transport/SocketTransportClient.swift +++ b/ProwlCLI/Transport/SocketTransportClient.swift @@ -1,13 +1,14 @@ // ProwlCLI/Transport/SocketTransportClient.swift // Unix domain socket client for communicating with running Prowl app. +import Foundation +import ProwlCLIShared + #if canImport(Darwin) -import Darwin + import Darwin #elseif canImport(Glibc) -import Glibc + import Glibc #endif -import Foundation -import ProwlCLIShared enum SocketTransportClient { /// Send a command envelope to the Prowl app and receive a response. @@ -29,30 +30,9 @@ enum SocketTransportClient { } defer { close(clientFD) } - // Connect - var addr = sockaddr_un() - addr.sun_family = sa_family_t(AF_UNIX) - let pathBytes = Array(socketPath.utf8) - let maxLen = MemoryLayout.size(ofValue: addr.sun_path) - 1 - let copyLen = min(pathBytes.count, maxLen) - withUnsafeMutableBytes(of: &addr.sun_path) { sunPathPtr in - for idx in 0...size)) - } - } - - guard connectResult == 0 else { - throw ExitError( - code: CLIErrorCode.appNotRunning, - message: "Cannot connect to Prowl. Is the app running?" - ) + let connection = SocketConnectionProbe.connect(socketFD: clientFD, socketPath: socketPath) + if let error = connection.exitError() { + throw error } // Send length-prefixed request: 4-byte big-endian length + JSON payload @@ -83,7 +63,7 @@ enum SocketTransportClient { while offset < buffer.count { let written = Darwin.write(fildes, buffer.baseAddress!.advanced(by: offset), buffer.count - offset) guard written > 0 else { - throw ExitError(code: CLIErrorCode.transportFailed, message: "Socket write failed.") + throw ExitError(code: CLIErrorCode.transportFailed, message: socketWriteFailureMessage(bytesWritten: written)) } offset += written } @@ -99,11 +79,37 @@ enum SocketTransportClient { let toRead = min(remaining, bufferSize) let bytesRead = Darwin.read(fildes, buffer, toRead) guard bytesRead > 0 else { - throw ExitError(code: CLIErrorCode.transportFailed, message: "Socket read failed.") + throw ExitError(code: CLIErrorCode.transportFailed, message: socketReadFailureMessage(bytesRead: bytesRead)) } data.append(buffer.assumingMemoryBound(to: UInt8.self), count: bytesRead) remaining -= bytesRead } return data } + + private static func socketWriteFailureMessage(bytesWritten: Int) -> String { + if bytesWritten == 0 { + return "Socket write failed: wrote 0 bytes before the request was complete." + } + return "Socket write failed (\(errnoName(errno)): \(String(cString: strerror(errno))))." + } + + private static func socketReadFailureMessage(bytesRead: Int) -> String { + if bytesRead == 0 { + return "Socket read failed: Prowl closed the connection before sending a complete response." + } + return "Socket read failed (\(errnoName(errno)): \(String(cString: strerror(errno))))." + } + + private static func errnoName(_ errorNumber: Int32) -> String { + switch errorNumber { + case EACCES: "EACCES" + case ECONNREFUSED: "ECONNREFUSED" + case EINVAL: "EINVAL" + case ENOENT: "ENOENT" + case ENOTSOCK: "ENOTSOCK" + case EPERM: "EPERM" + default: "errno \(errorNumber)" + } + } } diff --git a/ProwlCLITests/ProwlCLIIntegrationTests.swift b/ProwlCLITests/ProwlCLIIntegrationTests.swift index faf5fba0..fe2fef19 100644 --- a/ProwlCLITests/ProwlCLIIntegrationTests.swift +++ b/ProwlCLITests/ProwlCLIIntegrationTests.swift @@ -53,6 +53,64 @@ final class ProwlCLIIntegrationTests: XCTestCase { XCTAssertEqual(error["code"] as? String, CLIErrorCode.appNotRunning) } + func testListReportsSocketPermissionDeniedWhenSocketCannotBeOpened() throws { + let socketPath = temporarySocketPath(suffix: "permission-denied") + let socket = PermissionDeniedSocket(socketPath: socketPath) + try socket.start() + defer { socket.stop() } + + let result = try runProwl( + args: ["list", "--json"], + environment: [ProwlSocket.environmentKey: socketPath] + ) + + XCTAssertNotEqual(result.exitCode, 0) + let payload = try jsonObject(from: result.stdout) + XCTAssertEqual(payload["ok"] as? Bool, false) + let error = try XCTUnwrap(payload["error"] as? [String: Any]) + XCTAssertEqual(error["code"] as? String, CLIErrorCode.socketPermissionDenied) + let message = try XCTUnwrap(error["message"] as? String) + XCTAssertTrue(message.contains("EACCES"), "Message should include errno name: \(message)") + XCTAssertTrue(message.localizedCaseInsensitiveContains("sandbox"), "Message should guide agent recovery: \(message)") + } + + func testListReportsTransportFailedWhenSocketPathIsNotASocket() throws { + let socketPath = temporarySocketPath(suffix: "not-a-socket") + XCTAssertTrue(FileManager.default.createFile(atPath: socketPath, contents: Data())) + defer { unlink(socketPath) } + + let result = try runProwl( + args: ["list", "--json"], + environment: [ProwlSocket.environmentKey: socketPath] + ) + + XCTAssertNotEqual(result.exitCode, 0) + let payload = try jsonObject(from: result.stdout) + XCTAssertEqual(payload["ok"] as? Bool, false) + let error = try XCTUnwrap(payload["error"] as? [String: Any]) + XCTAssertEqual(error["code"] as? String, CLIErrorCode.transportFailed) + let message = try XCTUnwrap(error["message"] as? String) + XCTAssertTrue(message.contains("ENOTSOCK"), "Message should include errno name: \(message)") + } + + func testListReportsTransportFailedWhenSocketPathIsTooLong() throws { + let socketPath = (Self.socketDirectory as NSString) + .appendingPathComponent("prowl-cli-\(String(repeating: "x", count: 120)).sock") + + let result = try runProwl( + args: ["list", "--json"], + environment: [ProwlSocket.environmentKey: socketPath] + ) + + XCTAssertNotEqual(result.exitCode, 0) + let payload = try jsonObject(from: result.stdout) + XCTAssertEqual(payload["ok"] as? Bool, false) + let error = try XCTUnwrap(payload["error"] as? [String: Any]) + XCTAssertEqual(error["code"] as? String, CLIErrorCode.transportFailed) + let message = try XCTUnwrap(error["message"] as? String) + XCTAssertTrue(message.localizedCaseInsensitiveContains("too long"), "Message should identify path length: \(message)") + } + func testAgentsCommandRoundTripsOverSocket() throws { let socketPath = temporarySocketPath(suffix: "agents") let response = try CommandResponse( @@ -2129,3 +2187,58 @@ private enum MockSocketError: Error { case readFailed case writeFailed } + +private final class PermissionDeniedSocket { + private let socketPath: String + private var serverFD: Int32 = -1 + + init(socketPath: String) { + self.socketPath = socketPath + } + + deinit { stop() } + + func start() throws { + unlink(socketPath) + serverFD = socket(AF_UNIX, SOCK_STREAM, 0) + guard serverFD >= 0 else { + throw MockSocketError.socketCreateFailed + } + + var addr = sockaddr_un() + addr.sun_family = sa_family_t(AF_UNIX) + let pathBytes = Array(socketPath.utf8) + let maxLength = MemoryLayout.size(ofValue: addr.sun_path) - 1 + let copyLength = min(pathBytes.count, maxLength) + withUnsafeMutableBytes(of: &addr.sun_path) { buffer in + for index in 0...size)) + } + } + guard bindResult == 0 else { + throw MockSocketError.bindFailed + } + guard listen(serverFD, 1) == 0 else { + throw MockSocketError.listenFailed + } + guard chmod(socketPath, 0) == 0 else { + throw MockSocketError.bindFailed + } + } + + func stop() { + if serverFD >= 0 { + close(serverFD) + serverFD = -1 + } + chmod(socketPath, S_IRUSR | S_IWUSR) + unlink(socketPath) + } +} diff --git a/docs/components/cli.md b/docs/components/cli.md index fecfefce..0223452e 100644 --- a/docs/components/cli.md +++ b/docs/components/cli.md @@ -220,13 +220,18 @@ inside-root / new-root), `app_launched`, `brought_to_front`, `created_tab`, and `$TMPDIR/prowl-cli.sock`. - If the app isn't running, the CLI launches it (`open -a Prowl`) and waits up to ~15s for the socket — except when `PROWL_CLI_SOCKET` is set. +- Sandboxed agents must be allowed to connect to the Unix socket. If the CLI + reports `SOCKET_PERMISSION_DENIED`, allowlist the socket path in the agent + sandbox, run `prowl` outside that sandbox, or start both the app and CLI with + the same `PROWL_CLI_SOCKET` pointing at a sandbox-accessible path. - Framed protocol: 4-byte length prefix + JSON, both directions. ## Error codes | Code | Meaning / recovery | |------|--------------------| -| `APP_NOT_RUNNING` | Can't reach Prowl. Ask before restarting it. | +| `APP_NOT_RUNNING` | Prowl is not reachable, or the socket is missing/stale. Start or restart Prowl, then retry. | +| `SOCKET_PERMISSION_DENIED` | The socket exists but the client cannot connect, usually because a sandbox blocked the Unix socket. Allowlist the socket path, run outside the sandbox, or use matching `PROWL_CLI_SOCKET` values for both app and CLI. | | `TARGET_NOT_FOUND` | Selector matched nothing — re-run `list` and pick a UUID. | | `TARGET_NOT_UNIQUE` | Selector matched several — be more specific (use `--pane`). | | `NO_ACTIVE_PANE` | No pane for focused-target; pass an explicit `--pane`. | @@ -236,7 +241,8 @@ inside-root / new-root), `app_launched`, `brought_to_front`, `created_tab`, and | `WAIT_TIMEOUT` | Command didn't finish in time — raise `--timeout` or use `--no-wait`. | | `UNSUPPORTED_KEY` / `INVALID_REPEAT` | Check `prowl key --help`. | | `PATH_NOT_FOUND` / `PATH_NOT_DIRECTORY` / `PATH_NOT_ALLOWED` | Fix the `open`/`tab create` path. | -| `LAUNCH_FAILED` | App launch or socket wait failed. | +| `LAUNCH_FAILED` | App launch or socket wait failed; the message includes the last socket diagnostic when available. | +| `TRANSPORT_FAILED` | Socket transport failed for a reason other than app availability or permission, such as `ENOTSOCK` or an invalid `PROWL_CLI_SOCKET` path. | | `*_FAILED` (`LIST_FAILED`, `AGENTS_FAILED`, `FOCUS_FAILED`, `SEND_FAILED`, `READ_FAILED`, `TAB_FAILED`, `PANE_FAILED`, `OPEN_FAILED`) | The action itself failed. | ## Safety & self-targeting diff --git a/skills/prowl-cli/SKILL.md b/skills/prowl-cli/SKILL.md index 9ae27020..c42cfca3 100644 --- a/skills/prowl-cli/SKILL.md +++ b/skills/prowl-cli/SKILL.md @@ -259,6 +259,7 @@ Avoid outer double quotes around payloads containing `$PWD`, `$VAR`, backticks, - In zsh, do not name variables `status`; it is readonly. - Parser errors are not JSON even if `--json` is present, because parsing happens before command execution. - The CLI talks to one socket owner by default. If two Prowl app instances are running, the default `prowl` command reaches whichever app owns the standard socket. For a manually launched dev instance, start the app and every CLI command with the same `PROWL_CLI_SOCKET=/tmp/name.sock`. +- Sandboxed agents must be allowed to connect to the Unix socket. `PROWL_CLI_SOCKET` is a workaround only when both the app and every CLI command use the same sandbox-accessible path. - A newer CLI command sent to an older app can fail at transport level. If `prowl agents` returns `TRANSPORT_FAILED`, confirm the running app instance was built with the command. - `cmd-w` can close a temporary tab, but double-check the pane first. @@ -272,8 +273,9 @@ In `--json` mode, command-level failures look like: Common codes and recovery: -- `APP_NOT_RUNNING`: Prowl is not reachable. Ask before restarting the app. -- `TRANSPORT_FAILED`: the socket connection broke or the running app could not decode the command. Recheck which Prowl instance owns the socket. +- `APP_NOT_RUNNING`: Prowl is not reachable, or the socket is missing/stale. Ask before restarting the app. +- `SOCKET_PERMISSION_DENIED`: the socket exists but the sandbox or filesystem permissions blocked `connect()`. Report this as a permission/sandbox problem, not as an app-liveness problem. +- `TRANSPORT_FAILED`: the socket connection broke or the socket path is invalid (for example `ENOTSOCK` or a too-long `PROWL_CLI_SOCKET`). Recheck which Prowl instance owns the socket. - `TARGET_NOT_FOUND` / `TARGET_NOT_UNIQUE`: run `prowl list --json` again and choose an explicit pane UUID. - `EMPTY_INPUT`: `send` got neither argv text nor stdin. - `NO_ACTIVE_PANE`: no pane resolved for positional (focused-pane) targeting; pass an explicit `--pane`. diff --git a/supacode/CLIService/Shared/ErrorCodes.swift b/supacode/CLIService/Shared/ErrorCodes.swift index 577563e3..1ad211d8 100644 --- a/supacode/CLIService/Shared/ErrorCodes.swift +++ b/supacode/CLIService/Shared/ErrorCodes.swift @@ -49,5 +49,6 @@ public enum CLIErrorCode { // Transport public static let transportFailed = "TRANSPORT_FAILED" + public static let socketPermissionDenied = "SOCKET_PERMISSION_DENIED" public static let timeout = "TIMEOUT" }