mirror of
https://github.com/awizemann/scarf.git
synced 2026-05-10 10:36:35 +00:00
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:
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
+203
-253
@@ -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<AnyCodable?, Error>] = [:]
|
||||
@@ -21,27 +39,29 @@ actor ACPClient {
|
||||
private var eventContinuation: AsyncStream<ACPEvent>.Continuation?
|
||||
private var _eventStream: AsyncStream<ACPEvent>?
|
||||
|
||||
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<ACPEvent> {
|
||||
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<ACPEvent> {
|
||||
_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 <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..."
|
||||
|
||||
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<Void, Error>? = 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<AnyCodable?, Error>) 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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 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))")
|
||||
}
|
||||
|
||||
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 {
|
||||
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)
|
||||
} 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
|
||||
// capture so we can attach a tail to user-visible errors.
|
||||
stderrTask = Task.detached { [weak self] in
|
||||
let handle = stderr.fileHandleForReading
|
||||
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))")
|
||||
// Mirror stderr into the diagnostic ring buffer.
|
||||
stderrTask = Task { [weak self] in
|
||||
do {
|
||||
for try await text in ch.stderr {
|
||||
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) {
|
||||
if message.isResponse {
|
||||
if let requestId = message.id,
|
||||
let continuation = pendingRequests.removeValue(forKey: requestId) {
|
||||
if let error = message.error {
|
||||
#if canImport(os)
|
||||
logger.error("ACP RPC error (id: \(requestId)): \(error.message)")
|
||||
#endif
|
||||
statusMessage = "Error: \(error.message)"
|
||||
continuation.resume(throwing: ACPClientError.rpcError(code: error.code, message: error.message))
|
||||
} else {
|
||||
#if canImport(os)
|
||||
logger.debug("ACP response (id: \(requestId))")
|
||||
#endif
|
||||
continuation.resume(returning: message.result)
|
||||
}
|
||||
} else {
|
||||
#if canImport(os)
|
||||
logger.warning("ACP response for unknown request id: \(message.id ?? -1)")
|
||||
#endif
|
||||
}
|
||||
} else if message.isNotification {
|
||||
if let event = ACPEventParser.parse(notification: message) {
|
||||
logger.debug("ACP event: \(String(describing: event).prefix(100))")
|
||||
eventContinuation?.yield(event)
|
||||
}
|
||||
} else if message.isRequest {
|
||||
@@ -501,7 +470,9 @@ actor ACPClient {
|
||||
/// Single idempotent cleanup path for all disconnect scenarios.
|
||||
private func performDisconnectCleanup(reason: String) {
|
||||
guard isConnected else { return }
|
||||
#if canImport(os)
|
||||
logger.warning("ACP disconnecting: \(reason)")
|
||||
#endif
|
||||
isConnected = false
|
||||
statusMessage = "Connection lost"
|
||||
for (_, continuation) in pendingRequests {
|
||||
@@ -512,12 +483,9 @@ actor ACPClient {
|
||||
eventContinuation = nil
|
||||
}
|
||||
|
||||
private func handleReadLoopEnded() {
|
||||
performDisconnectCleanup(reason: "read loop ended (EOF)")
|
||||
}
|
||||
|
||||
private func handleTermination(exitCode: Int32) {
|
||||
performDisconnectCleanup(reason: "process exited (\(exitCode))")
|
||||
private func handleReadLoopEnded(cleanly: Bool, error: Error? = nil) {
|
||||
let reason = cleanly ? "read loop ended (EOF)" : "read loop failed: \(error?.localizedDescription ?? "unknown")"
|
||||
performDisconnectCleanup(reason: reason)
|
||||
}
|
||||
|
||||
private func handleWriteFailed() {
|
||||
@@ -530,29 +498,11 @@ actor ACPClient {
|
||||
}
|
||||
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
|
||||
|
||||
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
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
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()
|
||||
|
||||
|
||||
Reference in New Issue
Block a user