iOS port M1: decouple ACPClient from Process via ACPChannel protocol

Introduces the key architectural abstraction that lets iOS share the
ACP state machine with Mac in M4+. ACPClient no longer touches
`Process`, `Pipe`, file descriptors, or SSH sessions directly — it
reads / writes line-oriented JSON-RPC through an `ACPChannel`.

New in ScarfCore/ACP/:
  - ACPChannel.swift (protocol + ACPChannelError enum)
  - ProcessACPChannel.swift (Mac + Linux; `#if !os(iOS)` guard —
    iOS can't spawn subprocesses). Wraps the Process + Pipe +
    raw POSIX write(2) code that used to live inline inside
    ACPClient: SIGPIPE-ignore, partial-write loops, EPIPE →
    `.writeEndClosed`, graceful SIGINT + 2s SIGKILL watchdog.
    Uses `canImport(Darwin)` / `canImport(Glibc)` for the
    platform-specific `write(2)` binding.
  - ACPClient.swift (moved from scarf/Core/Services and refactored).
    Process/Pipe/stdinFd/Darwin.write state replaced with a single
    `channel: any ACPChannel` reference. Construction takes a
    `ChannelFactory = @Sendable (ServerContext) async throws -> any ACPChannel`
    closure — Mac wires ProcessACPChannel, iOS will wire a Citadel
    SSHExecACPChannel in M4.

Mac-side glue (stays in main target):
  - scarf/Core/Services/ACPClient+Mac.swift (new) carries the
    `ACPClient.forMacApp(context:)` factory. Internally spawns
    `hermes acp` locally or `ssh -T host -- hermes acp` remotely
    via SSHTransport.makeProcess, passing the enriched shell env
    (local: full PATH + credentials; remote: just SSH_AUTH_SOCK
    + SSH_AGENT_PID) with TERM stripped. Behaviour identical to
    pre-M1.
  - ChatViewModel updated at 3 sites from `ACPClient(context:)`
    to `ACPClient.forMacApp(context:)`.

Public API change callers need to know about:
  - `ACPClient.respondToPermission(requestId:optionId:)` is now
    `async`. ChatViewModel already `await`ed it, so that upgrade
    is a no-op; no other callers.

Also deleted scarf/Core/Services/ACPClient.swift (605 lines;
replaced by ScarfCore version).

Test coverage (M1ACPTests, 10 tests):
  Using a MockACPChannel actor to script JSON-RPC deterministically,
  not a real subprocess:
  - ACPChannel protocol (mock send/receive, write-after-close,
    error descriptions).
  - ACPClient initial state.
  - start() sends initialize and flips isConnected on reply.
  - 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 the recentStderr ring buffer.
  - ACPErrorHint.classify across credential / missing-binary /
    rate-limit / unknown cases.

`swift test` on Linux now reports 62 / 62 passing.

Updated scarf/docs/IOS_PORT_PLAN.md with M1's shipped state, the
behavior-preservation rationale for the Mac factory, and the
iOS hook point M2–M4 will plug into.

https://claude.ai/code/session_019yMRP6mwZWfzVrPTqevx2y
This commit is contained in:
Claude
2026-04-22 22:49:24 +00:00
parent 920c86b4f8
commit bdf31d6781
7 changed files with 972 additions and 253 deletions
@@ -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<String, Error> { 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<String, Error> { 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
}
}
}
@@ -1,17 +1,35 @@
import Foundation import Foundation
import ScarfCore #if canImport(os)
import os import os
#endif
/// Manages a `hermes acp` subprocess and communicates via JSON-RPC over stdio. /// Manages an ACP (Agent Client Protocol) session with a backing Hermes
/// Provides an async event stream for real-time session updates. /// agent. Talks JSON-RPC over an `ACPChannel` the channel itself owns
actor ACPClient { /// 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") private let logger = Logger(subsystem: "com.scarf", category: "ACPClient")
#endif
private var process: Process? /// Returns a fresh ACPChannel connected to `hermes acp` for this
private var stdinPipe: Pipe? /// context. Mac wires this to spawn a `ProcessACPChannel` with the
private var stdoutPipe: Pipe? /// enriched env (so `hermes` can find Homebrew/nvm/asdf binaries
private var stderrPipe: Pipe? /// on PATH). iOS wires a Citadel-backed channel in M4+.
private var stdinFd: Int32 = -1 public typealias ChannelFactory = @Sendable (ServerContext) async throws -> any ACPChannel
private var channel: (any ACPChannel)?
private let channelFactory: ChannelFactory
private var nextRequestId = 1 private var nextRequestId = 1
private var pendingRequests: [Int: CheckedContinuation<AnyCodable?, Error>] = [:] private var pendingRequests: [Int: CheckedContinuation<AnyCodable?, Error>] = [:]
@@ -21,27 +39,29 @@ actor ACPClient {
private var eventContinuation: AsyncStream<ACPEvent>.Continuation? private var eventContinuation: AsyncStream<ACPEvent>.Continuation?
private var _eventStream: AsyncStream<ACPEvent>? private var _eventStream: AsyncStream<ACPEvent>?
private(set) var isConnected = false public private(set) var isConnected = false
private(set) var currentSessionId: String? public private(set) var currentSessionId: String?
private(set) var statusMessage = "" public private(set) var statusMessage = ""
let context: ServerContext public let context: ServerContext
private let transport: any ServerTransport
init(context: ServerContext = .local) { public init(
context: ServerContext = .local,
channelFactory: @escaping ChannelFactory
) {
self.context = context self.context = context
self.transport = context.makeTransport() self.channelFactory = channelFactory
} }
/// Ring buffer of recent stderr lines from `hermes acp` used to attach /// Ring buffer of recent stderr lines from the ACP channel used to
/// a diagnostic tail to user-visible errors. Capped to avoid unbounded /// attach a diagnostic tail to user-visible errors. Capped to avoid
/// growth when the subprocess logs heavily. /// unbounded growth when the subprocess logs heavily.
private var stderrBuffer: [String] = [] private var stderrBuffer: [String] = []
private static let stderrBufferMaxLines = 50 private static let stderrBufferMaxLines = 50
/// Returns the last ~`stderrBufferMaxLines` stderr lines captured from the /// Returns the last ~`stderrBufferMaxLines` stderr lines captured
/// `hermes acp` subprocess, joined by newlines. /// from the ACP channel, joined by newlines.
var recentStderr: String { public var recentStderr: String {
stderrBuffer.joined(separator: "\n") stderrBuffer.joined(separator: "\n")
} }
@@ -54,121 +74,79 @@ actor ACPClient {
} }
} }
/// Check if the underlying process is still alive and connected. /// True while the underlying channel is alive. Equivalent to the
var isHealthy: Bool { /// old `process.isRunning` check.
guard isConnected, let process else { return false } public var isHealthy: Bool {
return process.isRunning isConnected && channel != nil
} }
// MARK: - Event Stream // MARK: - Event Stream
/// Access the event stream. Must call `start()` first. /// Access the event stream. Must call `start()` first. Before start,
var events: AsyncStream<ACPEvent> { /// returns an immediately-finished stream so callers can iterate
guard let stream = _eventStream else { /// without a nil check.
// Return an empty stream if not started public var events: AsyncStream<ACPEvent> {
return AsyncStream { $0.finish() } _eventStream ?? AsyncStream { $0.finish() }
}
return stream
} }
// MARK: - Lifecycle // MARK: - Lifecycle
func start() async throws { public func start() async throws {
guard process == nil else { return } guard channel == nil else { return }
// Ignore SIGPIPE so broken-pipe writes return EPIPE instead of crashing // Create the event stream BEFORE anything else so no events are
signal(SIGPIPE, SIG_IGN) // lost while the channel is handshaking.
// Create the event stream BEFORE anything else so no events are lost
let (stream, continuation) = AsyncStream.makeStream(of: ACPEvent.self) let (stream, continuation) = AsyncStream.makeStream(of: ACPEvent.self)
self._eventStream = stream self._eventStream = stream
self.eventContinuation = continuation self.eventContinuation = continuation
// For local: Process is `hermes acp` directly.
// For remote: the transport returns a Process configured as
// `/usr/bin/ssh -T <opts> host -- <hermes> 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..." statusMessage = "Starting hermes acp..."
let ch: any ACPChannel
do { do {
try proc.run() ch = try await channelFactory(context)
} catch { } catch {
statusMessage = "Failed to start: \(error.localizedDescription)" 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() continuation.finish()
throw error throw error
} }
self.process = proc self.channel = ch
self.stdinPipe = stdin
self.stdoutPipe = stdout
self.stderrPipe = stderr
self.stdinFd = stdin.fileHandleForWriting.fileDescriptor
self.isConnected = true self.isConnected = true
// Start reading stdout BEFORE sending initialize (so we catch the response) // Start reading incoming JSON-RPC BEFORE sending initialize so
startReadLoop(stdout: stdout, stderr: stderr) // we catch the response.
logger.info("hermes acp process started (pid: \(proc.processIdentifier))") 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..." statusMessage = "Initializing..."
// Initialize the ACP connection // Initialize the ACP connection.
let initParams: [String: AnyCodable] = [ let initParams: [String: AnyCodable] = [
"protocolVersion": AnyCodable(1), "protocolVersion": AnyCodable(1),
"clientCapabilities": AnyCodable([String: Any]()), "clientCapabilities": AnyCodable([String: Any]()),
"clientInfo": AnyCodable([ "clientInfo": AnyCodable([
"name": "Scarf", "name": "Scarf",
"version": "1.0" "version": "1.0",
] as [String: Any]) ] as [String: Any]),
] ]
_ = try await sendRequest(method: "initialize", params: initParams) _ = try await sendRequest(method: "initialize", params: initParams)
statusMessage = "Connected" statusMessage = "Connected"
#if canImport(os)
logger.info("ACP connection initialized") logger.info("ACP connection initialized")
#endif
startKeepalive() startKeepalive()
} }
func stop() async { public func stop() async {
readTask?.cancel() readTask?.cancel()
readTask = nil readTask = nil
stderrTask?.cancel() stderrTask?.cancel()
@@ -184,34 +162,16 @@ actor ACPClient {
} }
pendingRequests.removeAll() pendingRequests.removeAll()
// Close stdin first so the subprocess sees EOF and can shut down gracefully if let ch = channel {
stdinPipe?.fileHandleForWriting.closeFile() await ch.close()
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()
} }
} channel = nil
}
stdinPipe?.fileHandleForReading.closeFile()
stdoutPipe?.fileHandleForReading.closeFile()
stderrPipe?.fileHandleForReading.closeFile()
process = nil
stdinPipe = nil
stdoutPipe = nil
stderrPipe = nil
stdinFd = -1
isConnected = false isConnected = false
currentSessionId = nil currentSessionId = nil
statusMessage = "Disconnected" statusMessage = "Disconnected"
#if canImport(os)
logger.info("ACP client stopped") logger.info("ACP client stopped")
#endif
} }
// MARK: - Keepalive // MARK: - Keepalive
@@ -226,89 +186,94 @@ actor ACPClient {
} }
} }
/// Valid JSON-RPC notification used as a keepalive probe. /// Valid JSON-RPC notification used as a keepalive probe. Plain
/// Sending bare newlines causes `json.loads("")` errors in the ACP library. /// newlines upstream produce `json.loads("")` errors in the ACP
private static let keepalivePayload: Data = { /// server so we send a real method.
let json = #"{"jsonrpc":"2.0","method":"$/ping"}"# + "\n" private static let keepalivePayload: String = #"{"jsonrpc":"2.0","method":"$/ping"}"#
return Data(json.utf8)
}()
private func sendKeepalive() { private func sendKeepalive() async {
let fd = stdinFd guard let ch = channel else { return }
guard fd >= 0 else { return } do {
Task.detached { [weak self] in try await ch.send(Self.keepalivePayload)
let ok = Self.safeWrite(fd: fd, data: Self.keepalivePayload) } catch {
if !ok { await handleWriteFailed()
await self?.handleWriteFailed()
}
} }
} }
// MARK: - Session Management // MARK: - Session Management
func newSession(cwd: String) async throws -> String { public func newSession(cwd: String) async throws -> String {
statusMessage = "Creating session..." statusMessage = "Creating session..."
let params: [String: AnyCodable] = [ let params: [String: AnyCodable] = [
"cwd": AnyCodable(cwd), "cwd": AnyCodable(cwd),
"mcpServers": AnyCodable([Any]()) "mcpServers": AnyCodable([Any]()),
] ]
let result = try await sendRequest(method: "session/new", params: params) let result = try await sendRequest(method: "session/new", params: params)
guard let dict = result?.dictValue, 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") throw ACPClientError.invalidResponse("Missing sessionId in session/new response")
} }
currentSessionId = sessionId currentSessionId = sessionId
statusMessage = "Session ready" statusMessage = "Session ready"
#if canImport(os)
logger.info("Created new ACP session: \(sessionId)") logger.info("Created new ACP session: \(sessionId)")
#endif
return sessionId 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))..." statusMessage = "Loading session \(sessionId.prefix(12))..."
let params: [String: AnyCodable] = [ let params: [String: AnyCodable] = [
"cwd": AnyCodable(cwd), "cwd": AnyCodable(cwd),
"sessionId": AnyCodable(sessionId), "sessionId": AnyCodable(sessionId),
"mcpServers": AnyCodable([Any]()) "mcpServers": AnyCodable([Any]()),
] ]
let result = try await sendRequest(method: "session/load", params: params) let result = try await sendRequest(method: "session/load", params: params)
// ACP returns {} on success (no sessionId echoed), or an error if not found. // ACP returns {} on success (no sessionId echoed), or an error if
// If we got here without throwing, the session was loaded. Use the ID we sent. // 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 let loadedId = (result?.dictValue?["sessionId"] as? String) ?? sessionId
currentSessionId = loadedId currentSessionId = loadedId
statusMessage = "Session loaded" statusMessage = "Session loaded"
#if canImport(os)
logger.info("Loaded ACP session: \(loadedId)") logger.info("Loaded ACP session: \(loadedId)")
#endif
return loadedId return loadedId
} }
func resumeSession(cwd: String, sessionId: String) async throws -> String { public func resumeSession(cwd: String, sessionId: String) async throws -> String {
statusMessage = "Resuming session..." statusMessage = "Resuming session..."
let params: [String: AnyCodable] = [ let params: [String: AnyCodable] = [
"cwd": AnyCodable(cwd), "cwd": AnyCodable(cwd),
"sessionId": AnyCodable(sessionId), "sessionId": AnyCodable(sessionId),
"mcpServers": AnyCodable([Any]()) "mcpServers": AnyCodable([Any]()),
] ]
let result = try await sendRequest(method: "session/resume", params: params) let result = try await sendRequest(method: "session/resume", params: params)
guard let dict = result?.dictValue, 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") throw ACPClientError.invalidResponse("Missing sessionId in session/resume response")
} }
currentSessionId = resumedId currentSessionId = resumedId
statusMessage = "Session resumed" statusMessage = "Session resumed"
#if canImport(os)
logger.info("Resumed ACP session: \(resumedId)") logger.info("Resumed ACP session: \(resumedId)")
#endif
return resumedId return resumedId
} }
// MARK: - Messaging // MARK: - Messaging
func sendPrompt(sessionId: String, text: String) async throws -> ACPPromptResult { public func sendPrompt(sessionId: String, text: String) async throws -> ACPPromptResult {
statusMessage = "Sending prompt..." statusMessage = "Sending prompt..."
let messageId = UUID().uuidString let messageId = UUID().uuidString
let params: [String: AnyCodable] = [ let params: [String: AnyCodable] = [
"sessionId": AnyCodable(sessionId), "sessionId": AnyCodable(sessionId),
"messageId": AnyCodable(messageId), "messageId": AnyCodable(messageId),
"prompt": AnyCodable([ "prompt": AnyCodable([
["type": "text", "text": text] as [String: Any] ["type": "text", "text": text] as [String: Any],
] as [Any]) ] as [Any]),
] ]
let result = try await sendRequest(method: "session/prompt", params: params) let result = try await sendRequest(method: "session/prompt", params: params)
let dict = result?.dictValue ?? [:] 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] = [ let params: [String: AnyCodable] = [
"sessionId": AnyCodable(sessionId) "sessionId": AnyCodable(sessionId),
] ]
_ = try await sendRequest(method: "session/cancel", params: params) _ = try await sendRequest(method: "session/cancel", params: params)
statusMessage = "Cancelled" statusMessage = "Cancelled"
} }
func respondToPermission(requestId: Int, optionId: String) { public func respondToPermission(requestId: Int, optionId: String) async {
let response: [String: Any] = [ let response: [String: Any] = [
"jsonrpc": "2.0", "jsonrpc": "2.0",
"id": requestId, "id": requestId,
"result": [ "result": [
"outcome": [ "outcome": [
"kind": optionId == "deny" ? "rejected" : "allowed", "kind": optionId == "deny" ? "rejected" : "allowed",
"optionId": optionId "optionId": optionId,
] as [String: Any] ] as [String: Any],
] as [String: Any] ] as [String: Any],
] ]
writeJSON(response) await writeJSON(response)
} }
// MARK: - JSON-RPC Transport // MARK: - JSON-RPC Transport
@@ -353,15 +318,18 @@ actor ACPClient {
nextRequestId += 1 nextRequestId += 1
let request = ACPRequest(id: requestId, method: method, params: params) let request = ACPRequest(id: requestId, method: method, params: params)
guard let data = try? JSONEncoder().encode(request),
guard let data = try? JSONEncoder().encode(request) else { let line = String(data: data, encoding: .utf8)
else {
throw ACPClientError.encodingFailed throw ACPClientError.encodingFailed
} }
#if canImport(os)
logger.debug("Sending: \(method) (id: \(requestId))") logger.debug("Sending: \(method) (id: \(requestId))")
#endif
// session/prompt streams events and can run for minutes no hard timeout. // session/prompt streams events and can run for minutes no hard
// Control messages get a 30s watchdog. // timeout. Control messages get a 30s watchdog.
let timeoutTask: Task<Void, Error>? = if method != "session/prompt" { let timeoutTask: Task<Void, Error>? = if method != "session/prompt" {
Task { [weak self] in Task { [weak self] in
try await Task.sleep(nanoseconds: 30 * 1_000_000_000) try await Task.sleep(nanoseconds: 30 * 1_000_000_000)
@@ -370,26 +338,23 @@ actor ACPClient {
} else { } else {
nil nil
} }
defer { timeoutTask?.cancel() } defer { timeoutTask?.cancel() }
let fd = stdinFd guard let ch = channel else {
throw ACPClientError.notConnected
}
return try await withCheckedThrowingContinuation { (continuation: CheckedContinuation<AnyCodable?, Error>) in return try await withCheckedThrowingContinuation { (continuation: CheckedContinuation<AnyCodable?, Error>) in
pendingRequests[requestId] = continuation pendingRequests[requestId] = continuation
guard fd >= 0 else { // Write in a detached task so the actor can process incoming
pendingRequests.removeValue(forKey: requestId) // response messages while we're awaiting the send. The
continuation.resume(throwing: ACPClientError.notConnected) // continuation is already stored; the response arrives via
return // the read loop.
}
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.
Task.detached { [weak self] in Task.detached { [weak self] in
let ok = Self.safeWrite(fd: fd, data: payload) do {
if !ok { try await ch.send(line)
} catch {
await self?.handleWriteFailedForRequest(id: requestId) await self?.handleWriteFailedForRequest(id: requestId)
} }
} }
@@ -398,93 +363,97 @@ actor ACPClient {
private func timeoutRequest(id: Int, method: String) { private func timeoutRequest(id: Int, method: String) {
guard let continuation = pendingRequests.removeValue(forKey: id) else { return } guard let continuation = pendingRequests.removeValue(forKey: id) else { return }
#if canImport(os)
logger.error("Request timed out: \(method) (id: \(id))") logger.error("Request timed out: \(method) (id: \(id))")
#endif
statusMessage = "Request timed out" statusMessage = "Request timed out"
continuation.resume(throwing: ACPClientError.requestTimeout(method: method)) continuation.resume(throwing: ACPClientError.requestTimeout(method: method))
} }
private func writeJSON(_ dict: [String: Any]) { private func writeJSON(_ dict: [String: Any]) async {
let fd = stdinFd guard let ch = channel,
guard fd >= 0, let data = try? JSONSerialization.data(withJSONObject: dict),
let data = try? JSONSerialization.data(withJSONObject: dict) else { return } let line = String(data: data, encoding: .utf8)
var payload = data else { return }
payload.append(contentsOf: "\n".utf8)
Task.detached { [weak self] in
let ok = Self.safeWrite(fd: fd, data: payload)
if !ok {
await self?.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..<newlineIndex])
buffer = Data(buffer[buffer.index(after: newlineIndex)...])
guard !lineData.isEmpty else { continue }
if let lineStr = String(data: lineData, encoding: .utf8) {
self?.logger.debug("ACP recv: \(lineStr.prefix(200))")
}
do { do {
let message = try JSONDecoder().decode(ACPRawMessage.self, from: lineData) try await ch.send(line)
} catch {
await handleWriteFailed()
}
}
// MARK: - Read Loops
private func startReadLoops(channel ch: any ACPChannel) {
// Consume incoming JSON-RPC lines from the channel.
readTask = Task { [weak self] in
do {
for try await line in ch.incoming {
guard let data = line.data(using: .utf8) else { continue }
do {
let message = try JSONDecoder().decode(ACPRawMessage.self, from: data)
await self?.handleMessage(message) await self?.handleMessage(message)
} catch { } catch {
self?.logger.warning("Failed to decode ACP message: \(error.localizedDescription)") #if canImport(os)
await self?.logParseFailure(error, line: line)
#endif
} }
} }
await self?.handleReadLoopEnded(cleanly: true)
} catch {
await self?.handleReadLoopEnded(cleanly: false, error: error)
} }
await self?.handleReadLoopEnded()
} }
// Read stderr in background for diagnostic logging AND ring-buffer // Mirror stderr into the diagnostic ring buffer.
// capture so we can attach a tail to user-visible errors. stderrTask = Task { [weak self] in
stderrTask = Task.detached { [weak self] in do {
let handle = stderr.fileHandleForReading for try await text in ch.stderr {
while !Task.isCancelled {
let data = handle.availableData
if data.isEmpty { break }
if let text = String(data: data, encoding: .utf8)?.trimmingCharacters(in: .whitespacesAndNewlines),
!text.isEmpty {
self?.logger.info("ACP stderr: \(text.prefix(500))")
await self?.appendStderr(text) await self?.appendStderr(text)
#if canImport(os)
await self?.logStderrLine(text)
#endif
}
} catch {
// Stderr errors don't matter we already handle EOF on
// the incoming stream.
} }
} }
} }
#if canImport(os)
private func logParseFailure(_ error: Error, line: String) {
logger.warning("Failed to decode ACP message: \(error.localizedDescription)")
} }
private func logStderrLine(_ text: String) {
logger.info("ACP stderr: \(text.prefix(500))")
}
#endif
private func handleMessage(_ message: ACPRawMessage) { private func handleMessage(_ message: ACPRawMessage) {
if message.isResponse { if message.isResponse {
if let requestId = message.id, if let requestId = message.id,
let continuation = pendingRequests.removeValue(forKey: requestId) { let continuation = pendingRequests.removeValue(forKey: requestId) {
if let error = message.error { if let error = message.error {
#if canImport(os)
logger.error("ACP RPC error (id: \(requestId)): \(error.message)") logger.error("ACP RPC error (id: \(requestId)): \(error.message)")
#endif
statusMessage = "Error: \(error.message)" statusMessage = "Error: \(error.message)"
continuation.resume(throwing: ACPClientError.rpcError(code: error.code, message: error.message)) continuation.resume(throwing: ACPClientError.rpcError(code: error.code, message: error.message))
} else { } else {
#if canImport(os)
logger.debug("ACP response (id: \(requestId))") logger.debug("ACP response (id: \(requestId))")
#endif
continuation.resume(returning: message.result) continuation.resume(returning: message.result)
} }
} else { } else {
#if canImport(os)
logger.warning("ACP response for unknown request id: \(message.id ?? -1)") logger.warning("ACP response for unknown request id: \(message.id ?? -1)")
#endif
} }
} else if message.isNotification { } else if message.isNotification {
if let event = ACPEventParser.parse(notification: message) { if let event = ACPEventParser.parse(notification: message) {
logger.debug("ACP event: \(String(describing: event).prefix(100))")
eventContinuation?.yield(event) eventContinuation?.yield(event)
} }
} else if message.isRequest { } else if message.isRequest {
@@ -501,7 +470,9 @@ actor ACPClient {
/// Single idempotent cleanup path for all disconnect scenarios. /// Single idempotent cleanup path for all disconnect scenarios.
private func performDisconnectCleanup(reason: String) { private func performDisconnectCleanup(reason: String) {
guard isConnected else { return } guard isConnected else { return }
#if canImport(os)
logger.warning("ACP disconnecting: \(reason)") logger.warning("ACP disconnecting: \(reason)")
#endif
isConnected = false isConnected = false
statusMessage = "Connection lost" statusMessage = "Connection lost"
for (_, continuation) in pendingRequests { for (_, continuation) in pendingRequests {
@@ -512,12 +483,9 @@ actor ACPClient {
eventContinuation = nil eventContinuation = nil
} }
private func handleReadLoopEnded() { private func handleReadLoopEnded(cleanly: Bool, error: Error? = nil) {
performDisconnectCleanup(reason: "read loop ended (EOF)") let reason = cleanly ? "read loop ended (EOF)" : "read loop failed: \(error?.localizedDescription ?? "unknown")"
} performDisconnectCleanup(reason: reason)
private func handleTermination(exitCode: Int32) {
performDisconnectCleanup(reason: "process exited (\(exitCode))")
} }
private func handleWriteFailed() { private func handleWriteFailed() {
@@ -530,29 +498,11 @@ actor ACPClient {
} }
performDisconnectCleanup(reason: "write failed (broken pipe)") performDisconnectCleanup(reason: "write failed (broken pipe)")
} }
// MARK: - Safe POSIX Write
/// Write data to a file descriptor using POSIX write(), returning false on error.
/// Handles partial writes and returns false on EPIPE or other errors.
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 {
let result = Darwin.write(fd, base.advanced(by: written), total - written)
if result <= 0 { return false }
written += result
}
return true
}
}
} }
// MARK: - Errors // MARK: - Errors
enum ACPClientError: Error, LocalizedError { public enum ACPClientError: Error, LocalizedError {
case notConnected case notConnected
case encodingFailed case encodingFailed
case invalidResponse(String) case invalidResponse(String)
@@ -560,7 +510,7 @@ enum ACPClientError: Error, LocalizedError {
case processTerminated case processTerminated
case requestTimeout(method: String) case requestTimeout(method: String)
var errorDescription: String? { public var errorDescription: String? {
switch self { switch self {
case .notConnected: return "ACP client is not connected" case .notConnected: return "ACP client is not connected"
case .encodingFailed: return "Failed to encode JSON-RPC request" 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 /// 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 /// human-readable hint for the chat UI. Pattern-matches the most common
/// fresh-install failure modes. Returns nil when no known pattern matches. /// fresh-install failure modes. Returns nil when no known pattern matches.
enum ACPErrorHint { public enum ACPErrorHint {
static func classify(errorMessage: String, stderrTail: String) -> String? { public static func classify(errorMessage: String, stderrTail: String) -> String? {
let haystack = errorMessage + "\n" + stderrTail let haystack = errorMessage + "\n" + stderrTail
if haystack.range(of: #"No\s+(Anthropic|OpenAI|OpenRouter|Gemini|Google|Groq|Mistral|XAI)?\s*credentials\s+found"#, if haystack.range(of: #"No\s+(Anthropic|OpenAI|OpenRouter|Gemini|Google|Groq|Mistral|XAI)?\s*credentials\s+found"#,
options: .regularExpression) != nil options: .regularExpression) != nil
@@ -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<String, Error>.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<String, Error>
private let stderrContinuation: AsyncThrowingStream<String, Error>.Continuation
public nonisolated let stderr: AsyncThrowingStream<String, Error>
private var isClosed = false
private var readerTask: Task<Void, Never>?
private var stderrTask: Task<Void, Never>?
/// 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<String, Error>.makeStream()
self.incoming = inStream
self.incomingContinuation = inContinuation
let (errStream, errContinuation) = AsyncThrowingStream<String, Error>.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<String, Error>.makeStream()
self.incoming = inStream
self.incomingContinuation = inContinuation
let (errStream, errContinuation) = AsyncThrowingStream<String, Error>.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..<nl])
buffer = Data(buffer[buffer.index(after: nl)...])
guard !lineData.isEmpty else { continue }
if let text = String(data: lineData, encoding: .utf8) {
inCont.yield(text)
} else {
inCont.finish(throwing: ACPChannelError.invalidEncoding)
return
}
}
}
inCont.finish()
}
stderrTask = Task.detached {
var buffer = Data()
while !Task.isCancelled {
let chunk = errHandle.availableData
if chunk.isEmpty { break }
buffer.append(chunk)
while let nl = buffer.firstIndex(of: 0x0A) {
let lineData = Data(buffer[buffer.startIndex..<nl])
buffer = Data(buffer[buffer.index(after: nl)...])
guard !lineData.isEmpty else { continue }
if let text = String(data: lineData, encoding: .utf8) {
errCont.yield(text)
}
// Non-UTF-8 stderr lines are dropped silently;
// we're not going to crash the channel over a
// weird byte in a log line.
}
}
errCont.finish()
}
}
}
#endif // !os(iOS)
@@ -0,0 +1,328 @@
import Testing
import Foundation
@testable import ScarfCore
/// Exercises M1's `ACPChannel` abstraction and the refactored
/// `ACPClient`. Uses a `MockACPChannel` to script JSON-RPC responses
/// deterministically no subprocess, no SSH, no timing flakiness.
///
/// `ProcessACPChannel` itself isn't exercised here because spawning a
/// real `hermes acp` subprocess in CI would be brittle; the channel's
/// POSIX-write / pipe-framing behaviour is covered on the Mac side
/// during smoke-run testing.
@Suite struct M1ACPTests {
// MARK: - Mock
/// In-memory `ACPChannel` for tests. Send queue captures outgoing
/// lines so tests can assert what ACPClient wrote; `reply(with:)`
/// / `emit(event:)` script incoming JSON-RPC responses /
/// notifications; `simulateClose()` closes both streams.
actor MockACPChannel: ACPChannel {
nonisolated let incoming: AsyncThrowingStream<String, Error>
nonisolated let stderr: AsyncThrowingStream<String, Error>
private let incomingCont: AsyncThrowingStream<String, Error>.Continuation
private let stderrCont: AsyncThrowingStream<String, Error>.Continuation
private(set) var sent: [String] = []
private(set) var closed = false
public var diagnosticID: String? { "mock-channel" }
init() {
let (inStream, inCont) = AsyncThrowingStream<String, Error>.makeStream()
let (errStream, errCont) = AsyncThrowingStream<String, Error>.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<Void, Error>) {
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<T: Sendable>(
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
}
}
}
+45 -1
View File
@@ -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. - 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. - 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 M2M4:** 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 ### M2 — pending
### M3 — pending ### M3 — pending
### M4 — pending ### M4 — pending
@@ -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)
}
}
@@ -207,7 +207,7 @@ final class ChatViewModel {
Task { @MainActor in Task { @MainActor in
let sessionToResume = richChatViewModel.sessionId let sessionToResume = richChatViewModel.sessionId
let client = ACPClient(context: context) let client = ACPClient.forMacApp(context: context)
self.acpClient = client self.acpClient = client
do { do {
@@ -295,7 +295,7 @@ final class ChatViewModel {
clearACPErrorState() clearACPErrorState()
acpStatus = "Starting..." acpStatus = "Starting..."
let client = ACPClient(context: context) let client = ACPClient.forMacApp(context: context)
self.acpClient = client self.acpClient = client
Task { @MainActor in Task { @MainActor in
@@ -433,7 +433,7 @@ final class ChatViewModel {
guard !Task.isCancelled else { return } guard !Task.isCancelled else { return }
} }
let client = ACPClient(context: context) let client = ACPClient.forMacApp(context: context)
do { do {
try await client.start() try await client.start()