diff --git a/scarf/Packages/ScarfCore/Sources/ScarfCore/ACP/ACPChannel.swift b/scarf/Packages/ScarfCore/Sources/ScarfCore/ACP/ACPChannel.swift new file mode 100644 index 0000000..e93b696 --- /dev/null +++ b/scarf/Packages/ScarfCore/Sources/ScarfCore/ACP/ACPChannel.swift @@ -0,0 +1,82 @@ +import Foundation + +/// The bidirectional line-oriented transport that `ACPClient` speaks +/// JSON-RPC over. Abstracts away whether the other end is a local +/// `hermes acp` subprocess (macOS) or a remote SSH exec channel (iOS via +/// Citadel in M4+). ACPClient never touches `Process`, `Pipe`, file +/// descriptors, or SSH sessions directly — it just sends and receives +/// newline-delimited JSON lines over one of these. +/// +/// **Line framing.** Senders pass a JSON object serialized to a single +/// line (no embedded `\n`). The channel appends the terminator itself. +/// The receiver yields one complete JSON line per `incoming` element; +/// partial lines are buffered internally until a newline arrives. +/// +/// **Lifecycle.** A channel is "already live" when you hold a reference — +/// the constructor (or channel-factory call) spawns the subprocess / opens +/// the SSH exec channel. `close()` tears down and causes `incoming` / +/// `stderr` to finish. After `close()`, `send(_:)` throws. +/// +/// **Errors.** Transport errors (broken pipe, SSH disconnect, process +/// died) surface as an error-terminated `incoming` stream — consumers +/// should be prepared for that, not just for clean `.finished` stream +/// termination. `send(_:)` also throws on these. +public protocol ACPChannel: Sendable { + /// Append `\n` and write atomically. Thread-safe (the actor boundary + /// is on the implementation side, not the protocol). + func send(_ line: String) async throws + + /// One complete JSON-RPC line per element, without the trailing + /// newline. Yields in arrival order. Finishes (clean or error) when + /// the underlying transport closes. + var incoming: AsyncThrowingStream { get } + + /// Diagnostic stderr. For `ProcessACPChannel` this is the spawned + /// process's stderr, line-buffered. For future SSH-exec channels + /// where stderr folds into events, this is an empty stream. + /// Lines are yielded without the trailing newline. + var stderr: AsyncThrowingStream { get } + + /// Request graceful shutdown. Closes stdin first (so the remote side + /// sees EOF and can flush), then waits briefly for the subprocess / + /// exec channel to exit, then force-terminates. Idempotent — calling + /// `close()` on an already-closed channel is a no-op. + func close() async + + /// Short identifier for logs. Process channels return the child PID; + /// SSH exec channels return the SSH channel id or `nil` when not + /// applicable. + var diagnosticID: String? { get async } +} + +/// Errors raised by `ACPChannel` implementations when the underlying +/// transport breaks. JSON-RPC errors (the remote returning an `error` +/// field) are not in this enum — they ride as valid `incoming` lines and +/// are ACPClient's problem to decode. +public enum ACPChannelError: Error, LocalizedError { + /// The underlying subprocess or SSH exec channel exited. `exitCode` + /// is the subprocess exit status (or a synthetic value for SSH). + case closed(exitCode: Int32) + /// `send(_:)` was called on a channel whose write end is already + /// closed. Typically means a previous `close()` call or a pipe + /// broken by a remote termination. + case writeEndClosed + /// Bytes sent or received couldn't be encoded/decoded as UTF-8. + /// Hermes emits only UTF-8; hitting this usually means a framing + /// bug or random binary junk on the channel. + case invalidEncoding + /// Failed to launch the subprocess or open the SSH exec channel. + case launchFailed(String) + /// Catch-all for everything else with a context string. + case other(String) + + public var errorDescription: String? { + switch self { + case .closed(let code): return "ACP channel closed (exit \(code))" + case .writeEndClosed: return "ACP channel write end is closed" + case .invalidEncoding: return "ACP channel carried non-UTF-8 bytes" + case .launchFailed(let msg): return "Failed to launch ACP channel: \(msg)" + case .other(let msg): return msg + } + } +} diff --git a/scarf/scarf/Core/Services/ACPClient.swift b/scarf/Packages/ScarfCore/Sources/ScarfCore/ACP/ACPClient.swift similarity index 55% rename from scarf/scarf/Core/Services/ACPClient.swift rename to scarf/Packages/ScarfCore/Sources/ScarfCore/ACP/ACPClient.swift index f6f8af3..1dfb9c8 100644 --- a/scarf/scarf/Core/Services/ACPClient.swift +++ b/scarf/Packages/ScarfCore/Sources/ScarfCore/ACP/ACPClient.swift @@ -1,17 +1,35 @@ import Foundation -import ScarfCore +#if canImport(os) import os +#endif -/// Manages a `hermes acp` subprocess and communicates via JSON-RPC over stdio. -/// Provides an async event stream for real-time session updates. -actor ACPClient { +/// Manages an ACP (Agent Client Protocol) session with a backing Hermes +/// agent. Talks JSON-RPC over an `ACPChannel` — the channel itself owns +/// the transport (subprocess for macOS, SSH exec session for iOS via +/// Citadel in M4+). This actor is transport-agnostic. +/// +/// **Channel factory injection.** Construction takes a closure that +/// builds a channel on demand. The Mac target wires this at app launch +/// to produce a `ProcessACPChannel` configured with the enriched +/// shell env (PATH, credentials). iOS will wire a `SSHExecACPChannel` +/// factory at app launch. +/// +/// Under iOS the `ProcessACPChannel` implementation is skipped at +/// compile time (`#if !os(iOS)`) — an iOS `ACPClient` that tried to +/// spawn a subprocess would be a build error, not a runtime bug. +public actor ACPClient { + #if canImport(os) private let logger = Logger(subsystem: "com.scarf", category: "ACPClient") + #endif - private var process: Process? - private var stdinPipe: Pipe? - private var stdoutPipe: Pipe? - private var stderrPipe: Pipe? - private var stdinFd: Int32 = -1 + /// Returns a fresh ACPChannel connected to `hermes acp` for this + /// context. Mac wires this to spawn a `ProcessACPChannel` with the + /// enriched env (so `hermes` can find Homebrew/nvm/asdf binaries + /// on PATH). iOS wires a Citadel-backed channel in M4+. + public typealias ChannelFactory = @Sendable (ServerContext) async throws -> any ACPChannel + + private var channel: (any ACPChannel)? + private let channelFactory: ChannelFactory private var nextRequestId = 1 private var pendingRequests: [Int: CheckedContinuation] = [:] @@ -21,27 +39,29 @@ actor ACPClient { private var eventContinuation: AsyncStream.Continuation? private var _eventStream: AsyncStream? - private(set) var isConnected = false - private(set) var currentSessionId: String? - private(set) var statusMessage = "" + public private(set) var isConnected = false + public private(set) var currentSessionId: String? + public private(set) var statusMessage = "" - let context: ServerContext - private let transport: any ServerTransport + public let context: ServerContext - init(context: ServerContext = .local) { + public init( + context: ServerContext = .local, + channelFactory: @escaping ChannelFactory + ) { self.context = context - self.transport = context.makeTransport() + self.channelFactory = channelFactory } - /// Ring buffer of recent stderr lines from `hermes acp` — used to attach - /// a diagnostic tail to user-visible errors. Capped to avoid unbounded - /// growth when the subprocess logs heavily. + /// Ring buffer of recent stderr lines from the ACP channel — used to + /// attach a diagnostic tail to user-visible errors. Capped to avoid + /// unbounded growth when the subprocess logs heavily. private var stderrBuffer: [String] = [] private static let stderrBufferMaxLines = 50 - /// Returns the last ~`stderrBufferMaxLines` stderr lines captured from the - /// `hermes acp` subprocess, joined by newlines. - var recentStderr: String { + /// Returns the last ~`stderrBufferMaxLines` stderr lines captured + /// from the ACP channel, joined by newlines. + public var recentStderr: String { stderrBuffer.joined(separator: "\n") } @@ -54,121 +74,79 @@ actor ACPClient { } } - /// Check if the underlying process is still alive and connected. - var isHealthy: Bool { - guard isConnected, let process else { return false } - return process.isRunning + /// True while the underlying channel is alive. Equivalent to the + /// old `process.isRunning` check. + public var isHealthy: Bool { + isConnected && channel != nil } // MARK: - Event Stream - /// Access the event stream. Must call `start()` first. - var events: AsyncStream { - guard let stream = _eventStream else { - // Return an empty stream if not started - return AsyncStream { $0.finish() } - } - return stream + /// Access the event stream. Must call `start()` first. Before start, + /// returns an immediately-finished stream so callers can iterate + /// without a nil check. + public var events: AsyncStream { + _eventStream ?? AsyncStream { $0.finish() } } // MARK: - Lifecycle - func start() async throws { - guard process == nil else { return } + public func start() async throws { + guard channel == nil else { return } - // Ignore SIGPIPE so broken-pipe writes return EPIPE instead of crashing - signal(SIGPIPE, SIG_IGN) - - // Create the event stream BEFORE anything else so no events are lost + // Create the event stream BEFORE anything else so no events are + // lost while the channel is handshaking. let (stream, continuation) = AsyncStream.makeStream(of: ACPEvent.self) self._eventStream = stream self.eventContinuation = continuation - // For local: Process is `hermes acp` directly. - // For remote: the transport returns a Process configured as - // `/usr/bin/ssh -T host -- acp`. ACP's JSON-RPC - // over stdio works identically because `-T` keeps the ssh channel - // byte-clean and stdin/stdout travel end-to-end unmodified. - let proc = transport.makeProcess( - executable: context.paths.hermesBinary, - args: ["acp"] - ) - - let stdin = Pipe() - let stdout = Pipe() - let stderr = Pipe() - - proc.standardInput = stdin - proc.standardOutput = stdout - proc.standardError = stderr - - // ACP uses JSON-RPC over pipes — do NOT set TERM to avoid terminal escape pollution. - if context.isRemote { - // Remote: this is the LOCAL ssh process spawning `ssh host … - // hermes acp`. We don't forward our local PATH/credentials to - // the remote (hermes runs under the remote user's login env), - // but the ssh binary itself needs SSH_AUTH_SOCK to reach the - // local ssh-agent for key-based auth. - var env = ProcessInfo.processInfo.environment - let shellEnv = HermesFileService.enrichedEnvironment() - for key in ["SSH_AUTH_SOCK", "SSH_AGENT_PID"] { - if env[key] == nil, let v = shellEnv[key], !v.isEmpty { - env[key] = v - } - } - env.removeValue(forKey: "TERM") - proc.environment = env - } else { - // Local: enriched env so any tools hermes spawns (MCP servers, - // shell commands) can find brew/nvm/asdf binaries on PATH. - var env = HermesFileService.enrichedEnvironment() - env.removeValue(forKey: "TERM") - proc.environment = env - } - - proc.terminationHandler = { [weak self] proc in - Task { await self?.handleTermination(exitCode: proc.terminationStatus) } - } - statusMessage = "Starting hermes acp..." + let ch: any ACPChannel do { - try proc.run() + ch = try await channelFactory(context) } catch { statusMessage = "Failed to start: \(error.localizedDescription)" - logger.error("Failed to start hermes acp: \(error.localizedDescription)") + #if canImport(os) + logger.error("Failed to open ACP channel: \(error.localizedDescription)") + #endif continuation.finish() throw error } - self.process = proc - self.stdinPipe = stdin - self.stdoutPipe = stdout - self.stderrPipe = stderr - self.stdinFd = stdin.fileHandleForWriting.fileDescriptor + self.channel = ch self.isConnected = true - // Start reading stdout BEFORE sending initialize (so we catch the response) - startReadLoop(stdout: stdout, stderr: stderr) - logger.info("hermes acp process started (pid: \(proc.processIdentifier))") + // Start reading incoming JSON-RPC BEFORE sending initialize so + // we catch the response. + startReadLoops(channel: ch) + #if canImport(os) + if let id = await ch.diagnosticID { + logger.info("ACP channel opened (\(id, privacy: .public))") + } else { + logger.info("ACP channel opened") + } + #endif statusMessage = "Initializing..." - // Initialize the ACP connection + // Initialize the ACP connection. let initParams: [String: AnyCodable] = [ "protocolVersion": AnyCodable(1), "clientCapabilities": AnyCodable([String: Any]()), "clientInfo": AnyCodable([ "name": "Scarf", - "version": "1.0" - ] as [String: Any]) + "version": "1.0", + ] as [String: Any]), ] _ = try await sendRequest(method: "initialize", params: initParams) statusMessage = "Connected" + #if canImport(os) logger.info("ACP connection initialized") + #endif startKeepalive() } - func stop() async { + public func stop() async { readTask?.cancel() readTask = nil stderrTask?.cancel() @@ -184,34 +162,16 @@ actor ACPClient { } pendingRequests.removeAll() - // Close stdin first so the subprocess sees EOF and can shut down gracefully - stdinPipe?.fileHandleForWriting.closeFile() - - if let process, process.isRunning { - // SIGINT for graceful Python shutdown (raises KeyboardInterrupt cleanly) - process.interrupt() - // Watchdog: force-kill if still running after 2 seconds - let watchdogProcess = process - Task.detached { - try? await Task.sleep(nanoseconds: 2_000_000_000) - if watchdogProcess.isRunning { - watchdogProcess.terminate() - } - } + if let ch = channel { + await ch.close() } - stdinPipe?.fileHandleForReading.closeFile() - stdoutPipe?.fileHandleForReading.closeFile() - stderrPipe?.fileHandleForReading.closeFile() - - process = nil - stdinPipe = nil - stdoutPipe = nil - stderrPipe = nil - stdinFd = -1 + channel = nil isConnected = false currentSessionId = nil statusMessage = "Disconnected" + #if canImport(os) logger.info("ACP client stopped") + #endif } // MARK: - Keepalive @@ -226,89 +186,94 @@ actor ACPClient { } } - /// Valid JSON-RPC notification used as a keepalive probe. - /// Sending bare newlines causes `json.loads("")` errors in the ACP library. - private static let keepalivePayload: Data = { - let json = #"{"jsonrpc":"2.0","method":"$/ping"}"# + "\n" - return Data(json.utf8) - }() + /// Valid JSON-RPC notification used as a keepalive probe. Plain + /// newlines upstream produce `json.loads("")` errors in the ACP + /// server so we send a real method. + private static let keepalivePayload: String = #"{"jsonrpc":"2.0","method":"$/ping"}"# - private func sendKeepalive() { - let fd = stdinFd - guard fd >= 0 else { return } - Task.detached { [weak self] in - let ok = Self.safeWrite(fd: fd, data: Self.keepalivePayload) - if !ok { - await self?.handleWriteFailed() - } + private func sendKeepalive() async { + guard let ch = channel else { return } + do { + try await ch.send(Self.keepalivePayload) + } catch { + await handleWriteFailed() } } // MARK: - Session Management - func newSession(cwd: String) async throws -> String { + public func newSession(cwd: String) async throws -> String { statusMessage = "Creating session..." let params: [String: AnyCodable] = [ "cwd": AnyCodable(cwd), - "mcpServers": AnyCodable([Any]()) + "mcpServers": AnyCodable([Any]()), ] let result = try await sendRequest(method: "session/new", params: params) guard let dict = result?.dictValue, - let sessionId = dict["sessionId"] as? String else { + let sessionId = dict["sessionId"] as? String + else { throw ACPClientError.invalidResponse("Missing sessionId in session/new response") } currentSessionId = sessionId statusMessage = "Session ready" + #if canImport(os) logger.info("Created new ACP session: \(sessionId)") + #endif return sessionId } - func loadSession(cwd: String, sessionId: String) async throws -> String { + public func loadSession(cwd: String, sessionId: String) async throws -> String { statusMessage = "Loading session \(sessionId.prefix(12))..." let params: [String: AnyCodable] = [ "cwd": AnyCodable(cwd), "sessionId": AnyCodable(sessionId), - "mcpServers": AnyCodable([Any]()) + "mcpServers": AnyCodable([Any]()), ] let result = try await sendRequest(method: "session/load", params: params) - // ACP returns {} on success (no sessionId echoed), or an error if not found. - // If we got here without throwing, the session was loaded. Use the ID we sent. + // ACP returns {} on success (no sessionId echoed), or an error if + // not found. If we got here without throwing, the session was + // loaded — use the ID we sent. let loadedId = (result?.dictValue?["sessionId"] as? String) ?? sessionId currentSessionId = loadedId statusMessage = "Session loaded" + #if canImport(os) logger.info("Loaded ACP session: \(loadedId)") + #endif return loadedId } - func resumeSession(cwd: String, sessionId: String) async throws -> String { + public func resumeSession(cwd: String, sessionId: String) async throws -> String { statusMessage = "Resuming session..." let params: [String: AnyCodable] = [ "cwd": AnyCodable(cwd), "sessionId": AnyCodable(sessionId), - "mcpServers": AnyCodable([Any]()) + "mcpServers": AnyCodable([Any]()), ] let result = try await sendRequest(method: "session/resume", params: params) guard let dict = result?.dictValue, - let resumedId = dict["sessionId"] as? String else { + let resumedId = dict["sessionId"] as? String + else { throw ACPClientError.invalidResponse("Missing sessionId in session/resume response") } currentSessionId = resumedId statusMessage = "Session resumed" + #if canImport(os) logger.info("Resumed ACP session: \(resumedId)") + #endif return resumedId } // MARK: - Messaging - func sendPrompt(sessionId: String, text: String) async throws -> ACPPromptResult { + public func sendPrompt(sessionId: String, text: String) async throws -> ACPPromptResult { statusMessage = "Sending prompt..." let messageId = UUID().uuidString let params: [String: AnyCodable] = [ "sessionId": AnyCodable(sessionId), "messageId": AnyCodable(messageId), "prompt": AnyCodable([ - ["type": "text", "text": text] as [String: Any] - ] as [Any]) + ["type": "text", "text": text] as [String: Any], + ] as [Any]), ] let result = try await sendRequest(method: "session/prompt", params: params) let dict = result?.dictValue ?? [:] @@ -324,26 +289,26 @@ actor ACPClient { ) } - func cancel(sessionId: String) async throws { + public func cancel(sessionId: String) async throws { let params: [String: AnyCodable] = [ - "sessionId": AnyCodable(sessionId) + "sessionId": AnyCodable(sessionId), ] _ = try await sendRequest(method: "session/cancel", params: params) statusMessage = "Cancelled" } - func respondToPermission(requestId: Int, optionId: String) { + public func respondToPermission(requestId: Int, optionId: String) async { let response: [String: Any] = [ "jsonrpc": "2.0", "id": requestId, "result": [ "outcome": [ "kind": optionId == "deny" ? "rejected" : "allowed", - "optionId": optionId - ] as [String: Any] - ] as [String: Any] + "optionId": optionId, + ] as [String: Any], + ] as [String: Any], ] - writeJSON(response) + await writeJSON(response) } // MARK: - JSON-RPC Transport @@ -353,15 +318,18 @@ actor ACPClient { nextRequestId += 1 let request = ACPRequest(id: requestId, method: method, params: params) - - guard let data = try? JSONEncoder().encode(request) else { + guard let data = try? JSONEncoder().encode(request), + let line = String(data: data, encoding: .utf8) + else { throw ACPClientError.encodingFailed } + #if canImport(os) logger.debug("Sending: \(method) (id: \(requestId))") + #endif - // session/prompt streams events and can run for minutes — no hard timeout. - // Control messages get a 30s watchdog. + // session/prompt streams events and can run for minutes — no hard + // timeout. Control messages get a 30s watchdog. let timeoutTask: Task? = if method != "session/prompt" { Task { [weak self] in try await Task.sleep(nanoseconds: 30 * 1_000_000_000) @@ -370,26 +338,23 @@ actor ACPClient { } else { nil } - defer { timeoutTask?.cancel() } - let fd = stdinFd + guard let ch = channel else { + throw ACPClientError.notConnected + } + return try await withCheckedThrowingContinuation { (continuation: CheckedContinuation) in pendingRequests[requestId] = continuation - guard fd >= 0 else { - pendingRequests.removeValue(forKey: requestId) - continuation.resume(throwing: ACPClientError.notConnected) - return - } - - var payload = data - payload.append(contentsOf: "\n".utf8) - // Write in a detached task to avoid blocking the actor's executor. - // The continuation is already stored; the response arrives via the read loop. + // Write in a detached task so the actor can process incoming + // response messages while we're awaiting the send. The + // continuation is already stored; the response arrives via + // the read loop. Task.detached { [weak self] in - let ok = Self.safeWrite(fd: fd, data: payload) - if !ok { + do { + try await ch.send(line) + } catch { await self?.handleWriteFailedForRequest(id: requestId) } } @@ -398,93 +363,97 @@ actor ACPClient { private func timeoutRequest(id: Int, method: String) { guard let continuation = pendingRequests.removeValue(forKey: id) else { return } + #if canImport(os) logger.error("Request timed out: \(method) (id: \(id))") + #endif statusMessage = "Request timed out" continuation.resume(throwing: ACPClientError.requestTimeout(method: method)) } - private func writeJSON(_ dict: [String: Any]) { - let fd = stdinFd - guard fd >= 0, - let data = try? JSONSerialization.data(withJSONObject: dict) else { return } - var payload = data - payload.append(contentsOf: "\n".utf8) - Task.detached { [weak self] in - let ok = Self.safeWrite(fd: fd, data: payload) - if !ok { - await self?.handleWriteFailed() - } + private func writeJSON(_ dict: [String: Any]) async { + guard let ch = channel, + let data = try? JSONSerialization.data(withJSONObject: dict), + let line = String(data: data, encoding: .utf8) + else { return } + do { + try await ch.send(line) + } catch { + await handleWriteFailed() } } - // MARK: - Read Loop - - private func startReadLoop(stdout: Pipe, stderr: Pipe) { - // Read stdout for JSON-RPC messages - readTask = Task.detached { [weak self] in - let handle = stdout.fileHandleForReading - var buffer = Data() - - while !Task.isCancelled { - let chunk = handle.availableData - if chunk.isEmpty { break } // EOF - buffer.append(chunk) - - while let newlineIndex = buffer.firstIndex(of: UInt8(ascii: "\n")) { - let lineData = Data(buffer[buffer.startIndex.. Bool { - data.withUnsafeBytes { buf in - guard let base = buf.baseAddress else { return false } - var written = 0 - let total = buf.count - while written < total { - let result = Darwin.write(fd, base.advanced(by: written), total - written) - if result <= 0 { return false } - written += result - } - return true - } - } } // MARK: - Errors -enum ACPClientError: Error, LocalizedError { +public enum ACPClientError: Error, LocalizedError { case notConnected case encodingFailed case invalidResponse(String) @@ -560,7 +510,7 @@ enum ACPClientError: Error, LocalizedError { case processTerminated case requestTimeout(method: String) - var errorDescription: String? { + public var errorDescription: String? { switch self { case .notConnected: return "ACP client is not connected" case .encodingFailed: return "Failed to encode JSON-RPC request" @@ -575,8 +525,8 @@ enum ACPClientError: Error, LocalizedError { /// Maps a raw error message (RPC message or captured stderr) to a short /// human-readable hint for the chat UI. Pattern-matches the most common /// fresh-install failure modes. Returns nil when no known pattern matches. -enum ACPErrorHint { - static func classify(errorMessage: String, stderrTail: String) -> String? { +public enum ACPErrorHint { + public static func classify(errorMessage: String, stderrTail: String) -> String? { let haystack = errorMessage + "\n" + stderrTail if haystack.range(of: #"No\s+(Anthropic|OpenAI|OpenRouter|Gemini|Google|Groq|Mistral|XAI)?\s*credentials\s+found"#, options: .regularExpression) != nil diff --git a/scarf/Packages/ScarfCore/Sources/ScarfCore/ACP/ProcessACPChannel.swift b/scarf/Packages/ScarfCore/Sources/ScarfCore/ACP/ProcessACPChannel.swift new file mode 100644 index 0000000..fab4fe6 --- /dev/null +++ b/scarf/Packages/ScarfCore/Sources/ScarfCore/ACP/ProcessACPChannel.swift @@ -0,0 +1,253 @@ +// iOS can't spawn subprocesses (no `Process`, sandboxed away from fork/exec). +// Everything below only makes sense on platforms that can — macOS and Linux. +// iOS gets its ACP transport from a future `SSHExecACPChannel` (Citadel) +// landing in M4. +#if !os(iOS) + +import Foundation + +/// `ACPChannel` backed by a `Foundation.Process` spawning `hermes acp` +/// (local) or `ssh -T host -- hermes acp` (remote, via +/// `SSHTransport.makeProcess`). Owns the process lifecycle, stdin/stdout +/// pipes, and a small ring-buffered stderr capture for diagnostics. +/// +/// The per-call `send(_:)` path uses raw POSIX `write(2)` instead of +/// `FileHandle.write` — `FileHandle.write` crashes the whole app on +/// EPIPE (broken pipe) rather than throwing, so the original ACPClient +/// installed a `SIGPIPE` handler and a POSIX-write helper. That logic +/// moves here intact. +public actor ProcessACPChannel: ACPChannel { + private let process: Process + private let stdinPipe: Pipe + private let stdoutPipe: Pipe + private let stderrPipe: Pipe + /// Cached raw file descriptor for the stdin write end. Captured on + /// init because `Process.standardInput` gets nilled after `close()`. + private let stdinFd: Int32 + + private let incomingContinuation: AsyncThrowingStream.Continuation + /// Retain the stream — callers get it lazily; we stash it here so the + /// continuation doesn't outlive its producer. + public nonisolated let incoming: AsyncThrowingStream + private let stderrContinuation: AsyncThrowingStream.Continuation + public nonisolated let stderr: AsyncThrowingStream + + private var isClosed = false + private var readerTask: Task? + private var stderrTask: Task? + + /// The subprocess's PID as a human-readable string. + public var diagnosticID: String? { + "pid=\(process.processIdentifier)" + } + + /// Spawn `executable` with `args`, wiring its stdin/stdout/stderr into + /// this channel. `env` is passed verbatim to the subprocess (callers + /// are responsible for running it through whatever enrichment they + /// need — this layer doesn't know about `SSH_AUTH_SOCK` or PATH). + /// + /// For remote contexts, the Mac caller passes a pre-configured + /// `Process` via `init(process:)` below — `SSHTransport.makeProcess` + /// already set up the ssh argv. + public init( + executable: String, + args: [String], + env: [String: String] + ) async throws { + let proc = Process() + proc.executableURL = URL(fileURLWithPath: executable) + proc.arguments = args + proc.environment = env + try await Self.launch(process: proc, self_: nil) + try Self.ignoreSIGPIPE_once() + + self.process = proc + self.stdinPipe = proc.standardInput as! Pipe + self.stdoutPipe = proc.standardOutput as! Pipe + self.stderrPipe = proc.standardError as! Pipe + self.stdinFd = stdinPipe.fileHandleForWriting.fileDescriptor + + let (inStream, inContinuation) = AsyncThrowingStream.makeStream() + self.incoming = inStream + self.incomingContinuation = inContinuation + + let (errStream, errContinuation) = AsyncThrowingStream.makeStream() + self.stderr = errStream + self.stderrContinuation = errContinuation + + await startReaders() + } + + /// Secondary entry point for callers that have a pre-configured + /// `Process` (typically from `SSHTransport.makeProcess`). The process + /// must NOT already be running — this initializer calls `run()`. + public init(process: Process) async throws { + try await Self.launch(process: process, self_: nil) + try Self.ignoreSIGPIPE_once() + + self.process = process + self.stdinPipe = process.standardInput as! Pipe + self.stdoutPipe = process.standardOutput as! Pipe + self.stderrPipe = process.standardError as! Pipe + self.stdinFd = stdinPipe.fileHandleForWriting.fileDescriptor + + let (inStream, inContinuation) = AsyncThrowingStream.makeStream() + self.incoming = inStream + self.incomingContinuation = inContinuation + + let (errStream, errContinuation) = AsyncThrowingStream.makeStream() + self.stderr = errStream + self.stderrContinuation = errContinuation + + await startReaders() + } + + /// Wire fresh stdin/stdout/stderr pipes (overwriting any the caller + /// set) and start the subprocess. `self_` is unused today — the + /// placeholder keeps the signature ready for a future hook that + /// captures termination in `proc.terminationHandler` and routes it + /// into the channel's actor state. + private static func launch(process: Process, self_: Any?) async throws { + process.standardInput = Pipe() + process.standardOutput = Pipe() + process.standardError = Pipe() + do { + try process.run() + } catch { + throw ACPChannelError.launchFailed(error.localizedDescription) + } + } + + /// Ignore SIGPIPE once per process so a broken-pipe write returns + /// `EPIPE` (which we surface as `.writeEndClosed`) instead of + /// delivering SIGPIPE and tearing the app down. Idempotent; the + /// kernel is fine with repeated `SIG_IGN` installs. + nonisolated private static func ignoreSIGPIPE_once() throws { + signal(SIGPIPE, SIG_IGN) + } + + // MARK: - Send + + public func send(_ line: String) async throws { + guard !isClosed else { throw ACPChannelError.writeEndClosed } + guard var data = line.data(using: .utf8) else { + throw ACPChannelError.invalidEncoding + } + data.append(0x0A) // '\n' + let fd = stdinFd + // POSIX write, looping on partial writes and surfacing EPIPE as + // `.writeEndClosed`. Crucial: `FileHandle.write(_:)` crashes the + // app on EPIPE rather than throwing; the original ACPClient used + // this same `Darwin.write` (or `Glibc.write` on Linux) technique. + let ok = Self.safeWrite(fd: fd, data: data) + if !ok { + throw ACPChannelError.writeEndClosed + } + } + + nonisolated private static func safeWrite(fd: Int32, data: Data) -> Bool { + data.withUnsafeBytes { buf in + guard let base = buf.baseAddress else { return false } + var written = 0 + let total = buf.count + while written < total { + #if canImport(Darwin) + let result = Darwin.write(fd, base.advanced(by: written), total - written) + #elseif canImport(Glibc) + let result = Glibc.write(fd, base.advanced(by: written), total - written) + #else + return false + #endif + if result <= 0 { return false } + written += result + } + return true + } + } + + // MARK: - Close + + public func close() async { + guard !isClosed else { return } + isClosed = true + + // Close stdin so the child sees EOF and can flush. readerTask + // will see the pipe close and finish naturally. + stdinPipe.fileHandleForWriting.closeFile() + + if process.isRunning { + // SIGINT for graceful Python shutdown — raises KeyboardInterrupt + // cleanly instead of aborting in the middle of a JSON write. + process.interrupt() + // Watchdog: force-kill if still running after 2s. A stuck + // child shouldn't keep the app's close() hanging. + let watchdog = process + Task.detached { + try? await Task.sleep(nanoseconds: 2_000_000_000) + if watchdog.isRunning { watchdog.terminate() } + } + } + + stdinPipe.fileHandleForReading.closeFile() + stdoutPipe.fileHandleForReading.closeFile() + stderrPipe.fileHandleForReading.closeFile() + + readerTask?.cancel() + stderrTask?.cancel() + incomingContinuation.finish() + stderrContinuation.finish() + } + + // MARK: - Reader loops + + private func startReaders() { + let outHandle = stdoutPipe.fileHandleForReading + let errHandle = stderrPipe.fileHandleForReading + let inCont = incomingContinuation + let errCont = stderrContinuation + + readerTask = Task.detached { + var buffer = Data() + while !Task.isCancelled { + let chunk = outHandle.availableData + if chunk.isEmpty { break } // EOF + buffer.append(chunk) + while let nl = buffer.firstIndex(of: 0x0A) { + let lineData = Data(buffer[buffer.startIndex.. + nonisolated let stderr: AsyncThrowingStream + private let incomingCont: AsyncThrowingStream.Continuation + private let stderrCont: AsyncThrowingStream.Continuation + + private(set) var sent: [String] = [] + private(set) var closed = false + + public var diagnosticID: String? { "mock-channel" } + + init() { + let (inStream, inCont) = AsyncThrowingStream.makeStream() + let (errStream, errCont) = AsyncThrowingStream.makeStream() + self.incoming = inStream + self.incomingCont = inCont + self.stderr = errStream + self.stderrCont = errCont + } + + func send(_ line: String) async throws { + if closed { throw ACPChannelError.writeEndClosed } + sent.append(line) + } + + func close() async { + guard !closed else { return } + closed = true + incomingCont.finish() + stderrCont.finish() + } + + // Test-only scripting entry points. + func reply(with line: String) { + incomingCont.yield(line) + } + + func emitStderr(_ line: String) { + stderrCont.yield(line) + } + + func simulateEOF() { + incomingCont.finish() + } + + func simulateError(_ error: Error) { + incomingCont.finish(throwing: error) + } + + func lastSentRequestId() -> Int? { + // Pull the last sent line, decode as JSON-RPC, return id. + guard let last = sent.last, + let data = last.data(using: .utf8), + let obj = try? JSONSerialization.jsonObject(with: data) as? [String: Any] + else { return nil } + return obj["id"] as? Int + } + } + + // MARK: - ACPChannel protocol basics + + @Test func channelMockBasicSendReceive() async throws { + let ch = MockACPChannel() + try await ch.send(#"{"jsonrpc":"2.0","method":"ping"}"#) + let sent = await ch.sent + #expect(sent.count == 1) + await ch.reply(with: #"{"jsonrpc":"2.0","result":{}}"#) + + // Drain one incoming line to prove the stream works. + var iterator = ch.incoming.makeAsyncIterator() + let first = try await iterator.next() + #expect(first == #"{"jsonrpc":"2.0","result":{}}"#) + } + + @Test func channelWriteFailsAfterClose() async { + let ch = MockACPChannel() + await ch.close() + do { + try await ch.send("should fail") + Issue.record("expected writeEndClosed error") + } catch let error as ACPChannelError { + if case .writeEndClosed = error {} else { + Issue.record("expected .writeEndClosed, got \(error)") + } + } catch { + Issue.record("unexpected error: \(error)") + } + } + + @Test func channelErrorDescriptions() { + #expect(ACPChannelError.closed(exitCode: 2).errorDescription?.contains("exit 2") == true) + #expect(ACPChannelError.writeEndClosed.errorDescription?.contains("closed") == true) + #expect(ACPChannelError.invalidEncoding.errorDescription?.contains("UTF-8") == true) + #expect(ACPChannelError.launchFailed("nope").errorDescription?.contains("nope") == true) + #expect(ACPChannelError.other("x").errorDescription == "x") + } + + // MARK: - ACPClient state machine + + /// Build an ACPClient wired to the mock and kick off `start()`. + /// Returns `(client, mock, startTask)` — `startTask` is pending + /// until the mock replies to the initialize request. + @MainActor + private func buildClientWithMock() async -> (ACPClient, MockACPChannel, Task) { + let mock = MockACPChannel() + let client = ACPClient(context: .local) { _ in mock } + + let startTask = Task { + try await client.start() + } + return (client, mock, startTask) + } + + @Test @MainActor func clientInitiallyDisconnected() async { + let mock = MockACPChannel() + let client = ACPClient(context: .local) { _ in mock } + let connected = await client.isConnected + let healthy = await client.isHealthy + #expect(connected == false) + #expect(healthy == false) + } + + @Test @MainActor func clientStartSendsInitializeAndSetsConnected() async throws { + let (client, mock, startTask) = await buildClientWithMock() + + // Wait until the client has sent the initialize request. + try await waitFor { await mock.sent.count >= 1 } + let first = await mock.sent[0] + #expect(first.contains(#""method":"initialize""#)) + + // Reply to that initialize. + let id = await mock.lastSentRequestId() ?? 1 + await mock.reply(with: #"{"jsonrpc":"2.0","id":\#(id),"result":{}}"#) + + try await startTask.value + let connected = await client.isConnected + #expect(connected == true) + let status = await client.statusMessage + #expect(status == "Connected") + + await client.stop() + } + + @Test @MainActor func clientRpcErrorIsSurfaced() async throws { + let (client, mock, startTask) = await buildClientWithMock() + try await waitFor { await mock.sent.count >= 1 } + let id = await mock.lastSentRequestId() ?? 1 + await mock.reply(with: #"{"jsonrpc":"2.0","id":\#(id),"error":{"code":-32601,"message":"method not found"}}"#) + + do { + try await startTask.value + Issue.record("expected start() to throw") + } catch let error as ACPClientError { + if case .rpcError(let code, let msg) = error { + #expect(code == -32601) + #expect(msg.contains("method not found")) + } else { + Issue.record("expected .rpcError, got \(error)") + } + } + await client.stop() + } + + @Test @MainActor func clientChannelCloseSurfacesAsProcessTerminated() async throws { + let (client, mock, startTask) = await buildClientWithMock() + try await waitFor { await mock.sent.count >= 1 } + let id = await mock.lastSentRequestId() ?? 1 + await mock.reply(with: #"{"jsonrpc":"2.0","id":\#(id),"result":{}}"#) + try await startTask.value + + // Client is connected. Issue a session/new; before the mock + // replies, close the channel. The pending request should + // resolve with `.processTerminated`. + let sessionTask = Task { + try await client.newSession(cwd: "/tmp") + } + try await waitFor { await mock.sent.count >= 2 } + await mock.simulateEOF() + + do { + _ = try await sessionTask.value + Issue.record("expected session/new to throw") + } catch let error as ACPClientError { + if case .processTerminated = error {} else { + Issue.record("expected .processTerminated, got \(error)") + } + } + + let connected = await client.isConnected + #expect(connected == false) + await client.stop() + } + + @Test @MainActor func clientRoutesSessionUpdateNotificationToEventStream() async throws { + let (client, mock, startTask) = await buildClientWithMock() + try await waitFor { await mock.sent.count >= 1 } + let id = await mock.lastSentRequestId() ?? 1 + await mock.reply(with: #"{"jsonrpc":"2.0","id":\#(id),"result":{}}"#) + try await startTask.value + + // Start event consumption. + let eventTask = Task { () -> ACPEvent? in + var it = await client.events.makeAsyncIterator() + return await it.next() + } + + // Emit a session/update notification for an agent_message_chunk. + let notification = #"{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"s1","update":{"sessionUpdate":"agent_message_chunk","content":{"text":"hello"}}}}"# + await mock.reply(with: notification) + + let event = try await withTimeout(seconds: 2) { + await eventTask.value + } + guard case .messageChunk(let sid, let text) = event else { + Issue.record("expected .messageChunk, got \(String(describing: event))") + return + } + #expect(sid == "s1") + #expect(text == "hello") + await client.stop() + } + + @Test @MainActor func clientStderrFeedsRecentStderrRingBuffer() async throws { + let (client, mock, startTask) = await buildClientWithMock() + try await waitFor { await mock.sent.count >= 1 } + let id = await mock.lastSentRequestId() ?? 1 + await mock.reply(with: #"{"jsonrpc":"2.0","id":\#(id),"result":{}}"#) + try await startTask.value + + await mock.emitStderr("WARNING: something") + await mock.emitStderr("ERROR: boom") + + // Wait for the read loop to drain. + try await waitFor { await client.recentStderr.contains("boom") } + let tail = await client.recentStderr + #expect(tail.contains("WARNING: something")) + #expect(tail.contains("ERROR: boom")) + await client.stop() + } + + // MARK: - ACPErrorHint + + @Test func errorHintsClassifyCommonFailures() { + let noCreds = ACPErrorHint.classify( + errorMessage: "No Anthropic credentials found", + stderrTail: "" + ) + #expect(noCreds?.contains("ANTHROPIC_API_KEY") == true) + + let missingBinary = ACPErrorHint.classify( + errorMessage: "", + stderrTail: "No such file or directory: 'npx'" + ) + #expect(missingBinary?.contains("npx") == true) + + let rateLimit = ACPErrorHint.classify( + errorMessage: "", + stderrTail: "HTTP 429 Too Many Requests: rate limit" + ) + #expect(rateLimit?.contains("rate-limit") == true) + + let unknown = ACPErrorHint.classify( + errorMessage: "weird thing", + stderrTail: "other weird thing" + ) + #expect(unknown == nil) + } + + // MARK: - Helpers + + /// Poll `predicate` every ~20ms up to `timeout` seconds. Fails if + /// the condition never becomes true. Used to bridge between + /// ACPClient's detached tasks (send loops, read loop, etc.) and + /// the synchronous test assertions without leaning on Thread.sleep. + private func waitFor( + timeout: TimeInterval = 2.0, + _ predicate: @escaping @Sendable () async -> Bool + ) async throws { + let deadline = Date().addingTimeInterval(timeout) + while Date() < deadline { + if await predicate() { return } + try await Task.sleep(nanoseconds: 20_000_000) + } + Issue.record("waitFor timed out after \(timeout)s") + } + + /// Run `op` with an awaited timeout — if it doesn't finish in time, + /// record an Issue and return `op`'s pending value (cancellation + /// lets the test fail cleanly rather than hang CI). + private func withTimeout( + seconds: TimeInterval, + _ op: @escaping @Sendable () async -> T + ) async throws -> T { + try await withThrowingTaskGroup(of: T?.self) { group in + group.addTask { await op() } + group.addTask { + try await Task.sleep(nanoseconds: UInt64(seconds * 1_000_000_000)) + return nil + } + let first = try await group.next() + group.cancelAll() + guard let result = first, let value = result else { + throw ACPChannelError.other("withTimeout timed out after \(seconds)s") + } + return value + } + } +} diff --git a/scarf/docs/IOS_PORT_PLAN.md b/scarf/docs/IOS_PORT_PLAN.md index 2eac576..24c9136 100644 --- a/scarf/docs/IOS_PORT_PLAN.md +++ b/scarf/docs/IOS_PORT_PLAN.md @@ -410,7 +410,51 @@ stderr patterns, and round-trip an actual local file through - Types used only from the Mac app target (`GatewayInfo`, `PlatformInfo`, etc.) should NOT be marked `public` — keep them internal. My sed sometimes adds `public` to main-target-internal types when I'm reverting a move; strip those back with a second sed pass. - Views are deliberately **not** in ScarfCore. iOS will build its own Views against the shared ViewModels. M3 is where iOS's ViewRegistry / tab bar / NavigationStack composition happens. -### M1 — pending +### M0 verification — shipped (commit `f399579`) + +Two real regressions caught by a pre-M1 audit, both silent: + +1. **`GatewayViewModel.swift` lost its `import ScarfCore`** during the M0d revert. It references `ServerContext` throughout — would not have compiled in Xcode without the import. Added back. +2. **`SSHTransport.sshSubprocessEnvironment()` regressed in M0b.** The original Mac code ran `HermesFileService.enrichedEnvironment()` which probes `zsh -l -i` first (sources `.zshrc` — where 1Password / Secretive / manual `ssh-add` export `SSH_AUTH_SOCK`), falling back to `zsh -l`. My M0b replacement used only `zsh -l`, so users with agents in `.zshrc` would have seen "Permission denied" (exit 255) on every remote SSH attempt. Fixed by **reverting to dependency injection**: `SSHTransport.environmentEnricher` is a `(@Sendable () -> [String: String])?` static wired at app startup to the Mac's full `HermesFileService.enrichedEnvironment()` — same exact code path as pre-M0b. iOS leaves it nil. Test pins the injection-point shape. + +### M1 — shipped + +**Shipped:** + +- New `Packages/ScarfCore/Sources/ScarfCore/ACP/` directory with: + - **`ACPChannel.swift`** — protocol + error enum. Line-oriented bidirectional transport that `ACPClient` speaks JSON-RPC over. Channel implementations own subprocess / SSH lifecycle; ACPClient never touches `Process`, `Pipe`, file descriptors, or SSH sessions directly. + - **`ProcessACPChannel.swift`** — Mac/Linux impl, gated on `#if !os(iOS)` (iOS can't spawn subprocesses). Wraps the `Process` + `Pipe` + raw POSIX `write(2)` path that the old ACPClient used inline. Handles SIGPIPE-ignore, partial-write loops, EPIPE → `.writeEndClosed`, graceful SIGINT shutdown with a 2s SIGKILL watchdog. Available on both `Darwin` (macOS) and `Glibc` (Linux CI) via per-platform `#if canImport` on the raw write. + - **`ACPClient.swift`** — moved from the Mac target and refactored to be channel-agnostic. `Process`/`Pipe`/`stdinFd`/`Darwin.write` state replaced with a single `channel: any ACPChannel` reference. Channel creation goes through a caller-provided `ChannelFactory` closure so Mac can wire `ProcessACPChannel` and iOS can (in M4+) wire a Citadel-backed `SSHExecACPChannel` the same way. +- **`scarf/Core/Services/ACPClient+Mac.swift`** (new Mac-target sibling file) — carries the `ACPClient.forMacApp(context:)` factory that constructs an `ACPClient` pre-wired with the Mac channel factory. The channel factory closure: + - Local: spawns `hermes acp` with `HermesFileService.enrichedEnvironment()` (full PATH + credentials) minus `TERM`. + - Remote: uses `SSHTransport.makeProcess` to get `ssh -T host -- hermes acp`, merging just `SSH_AUTH_SOCK` / `SSH_AGENT_PID` into the local ssh subprocess's env. + - Both paths identical to pre-M1 behavior — no behavior change. +- **`ChatViewModel`** call sites updated from `ACPClient(context:)` to `ACPClient.forMacApp(context:)` (3 sites). +- The old `scarf/Core/Services/ACPClient.swift` (605 lines) deleted. + +**Public API changes ACPClient callers need to know about:** + +- `respondToPermission(requestId:optionId:)` is now `async`. `ChatViewModel` already awaited it, so the upgrade is a no-op there. + +**Test coverage (`M1ACPTests`):** 10 new tests using a `MockACPChannel` actor to script JSON-RPC deterministically — no real subprocess or SSH, so the tests exercise the state machine alone: + +- `ACPChannel` protocol — mock basic send/receive, write-after-close fails with `.writeEndClosed`, error-description strings. +- `ACPClient` initial state (disconnected, unhealthy). +- `start()` happy path — sends `initialize`, flips `isConnected` on reply. +- `start()` with an RPC error reply — surfaces as `ACPClientError.rpcError`. +- Mid-flight channel close — pending request resolves with `.processTerminated`, `isConnected` flips false. +- `session/update` notification routes into the `events` stream as `.messageChunk`. +- Stderr lines feed `recentStderr` ring buffer. +- `ACPErrorHint.classify` across credential / missing-binary / rate-limit / unknown cases. + +**Rules next phases can rely on:** + +- **iOS M2–M4:** The iOS target will provide a sibling `ACPClient+iOS.swift` with its own `ACPClient.forIOS(context:session:)` factory that returns a Citadel-backed `SSHExecACPChannel`. Everything above that layer — session lifecycle, event routing, permission requests, keepalive, recentStderr, token counting — runs unchanged. +- **ProcessACPChannel is test-less on Linux** (spawning real subprocesses in CI is brittle). Every meaningful ACP test uses `MockACPChannel` via protocol dependency injection. If you need to exercise the real subprocess path, do it on the Mac smoke-test side. +- **The `ChannelFactory` closure is `@Sendable` and async.** Any per-context setup (env enrichment, SSH handshake) happens inside the factory — not inside `ACPClient.start()`. That keeps `start()` boring and portable. +- **`ACPClient` does not handle subprocess spontaneous exits via `terminationHandler`** anymore — it notices via channel-stream EOF. Pipe-EOF fires reliably when a Mac subprocess exits (OS closes the pipe). If a future phase sees "session hangs after crash" symptoms, add a `terminationHandler` inside `ProcessACPChannel` that explicitly finishes the `incoming` continuation. + +### M2 — pending ### M2 — pending ### M3 — pending ### M4 — pending diff --git a/scarf/scarf/Core/Services/ACPClient+Mac.swift b/scarf/scarf/Core/Services/ACPClient+Mac.swift new file mode 100644 index 0000000..6d24670 --- /dev/null +++ b/scarf/scarf/Core/Services/ACPClient+Mac.swift @@ -0,0 +1,62 @@ +import Foundation +import ScarfCore + +/// Mac-target glue that wires `ACPClient` (now in `ScarfCore`) with a +/// `ProcessACPChannel` factory. The channel spawns `hermes acp` +/// locally, or `ssh -T host -- hermes acp` remotely via +/// `SSHTransport.makeProcess`, carrying the enriched shell env so +/// Hermes can find Homebrew / nvm / asdf binaries and credentials. +/// +/// iOS will ship a sibling `ACPClient+iOS.swift` in M4+ that wires a +/// `SSHExecACPChannel` (Citadel) factory instead. +extension ACPClient { + /// Convenience: build an `ACPClient` for `context` pre-wired with a + /// `ProcessACPChannel` factory. Use this at every call site that + /// used to do `ACPClient(context:)` before M1. + public static func forMacApp(context: ServerContext = .local) -> ACPClient { + ACPClient(context: context) { ctx in + try await makeProcessChannel(for: ctx) + } + } + + /// Build the channel — spawn `hermes acp` (local) or `ssh host -- + /// hermes acp` (remote via `SSHTransport.makeProcess`) and hand the + /// configured Process to `ProcessACPChannel`. Env merges the full + /// shell-enriched environment (so PATH includes brew/nvm/asdf and + /// credentials exported from `.zprofile` / `.zshrc` are visible) + /// minus `TERM` (ACP speaks raw JSON over stdio, any terminal + /// escape sequence would corrupt it). + nonisolated private static func makeProcessChannel(for context: ServerContext) async throws -> any ACPChannel { + let transport = context.makeTransport() + let proc = transport.makeProcess( + executable: context.paths.hermesBinary, + args: ["acp"] + ) + + if context.isRemote { + // Remote: this is the LOCAL ssh process spawning + // `ssh host … hermes acp`. We don't forward our local + // PATH / credentials to the remote (hermes runs under the + // remote user's login env), but the ssh binary itself needs + // SSH_AUTH_SOCK to reach the local ssh-agent for auth. + var env = ProcessInfo.processInfo.environment + let shellEnv = HermesFileService.enrichedEnvironment() + for key in ["SSH_AUTH_SOCK", "SSH_AGENT_PID"] { + if env[key] == nil, let v = shellEnv[key], !v.isEmpty { + env[key] = v + } + } + env.removeValue(forKey: "TERM") + proc.environment = env + } else { + // Local: enriched env so any tools hermes spawns (MCP + // servers, shell commands) can find brew/nvm/asdf binaries + // on PATH. + var env = HermesFileService.enrichedEnvironment() + env.removeValue(forKey: "TERM") + proc.environment = env + } + + return try await ProcessACPChannel(process: proc) + } +} diff --git a/scarf/scarf/Features/Chat/ViewModels/ChatViewModel.swift b/scarf/scarf/Features/Chat/ViewModels/ChatViewModel.swift index 094eaf7..bdc2368 100644 --- a/scarf/scarf/Features/Chat/ViewModels/ChatViewModel.swift +++ b/scarf/scarf/Features/Chat/ViewModels/ChatViewModel.swift @@ -207,7 +207,7 @@ final class ChatViewModel { Task { @MainActor in let sessionToResume = richChatViewModel.sessionId - let client = ACPClient(context: context) + let client = ACPClient.forMacApp(context: context) self.acpClient = client do { @@ -295,7 +295,7 @@ final class ChatViewModel { clearACPErrorState() acpStatus = "Starting..." - let client = ACPClient(context: context) + let client = ACPClient.forMacApp(context: context) self.acpClient = client Task { @MainActor in @@ -433,7 +433,7 @@ final class ChatViewModel { guard !Task.isCancelled else { return } } - let client = ACPClient(context: context) + let client = ACPClient.forMacApp(context: context) do { try await client.start()