mirror of
https://github.com/awizemann/scarf.git
synced 2026-05-10 18:44:45 +00:00
iOS port M4: Chat via SSHExecACPChannel (Citadel exec bidirectional)
First real interactive iOS feature. Streams JSON-RPC over a
Citadel 8-bit-safe exec channel to a remote `hermes acp` process.
Reuses ScarfCore's `RichChatViewModel` state machine (from M0d)
+ `ACPClient` (from M1) unchanged — the only new code is the iOS-
specific channel + factory + SwiftUI view.
## SSHExecACPChannel
Packages/ScarfIOS/Sources/ScarfIOS/SSHExecACPChannel.swift
(iOS counterpart to Mac's ProcessACPChannel)
Uses Citadel's `SSHClient.withExec(_:perform:)`:
- RFC 4254 exec channel, no PTY, binary-clean stdin/stdout for
JSON-RPC bytes.
- Bidirectional: `TTYStdinWriter` for our `send(_:)` writes,
`TTYOutput` stream for stdout/stderr.
- withExec's closure-scoped lifecycle handled by running it in
a detached Task. A per-actor pending-waiters queue lets the
first `send(_:)` block until the writer is handed over (one-
time RTT); subsequent sends are instant.
- `close()` cancels the Task, which drops the `withExec`
closure, which triggers Citadel to close the SSH channel.
Clean teardown.
- Line framing via `Data` accumulators for stdout + stderr
separately — Citadel yields bytes in arbitrary chunk sizes,
we only push complete (newline-terminated) lines into the
ACPChannel streams.
## ACPClient+iOS
Packages/ScarfIOS/Sources/ScarfIOS/ACPClient+iOS.swift
(Sibling to Mac's ACPClient+Mac.swift)
Exposes `ACPClient.forIOSApp(context:keyProvider:)`. Opens a
dedicated `SSHClient` per ACP session — NOT reusing the
`CitadelServerTransport` client. Rationale: ACP sessions can
run for minutes/hours of streaming chat, and OpenSSH caps
concurrent channels per connection at ~10. Two separate
connections (transport + ACP) stay well under.
SSH auth: ed25519 via the Keychain-stored bundle, same
`SSHAuthenticationMethod.ed25519(...)` path as
CitadelServerTransport.
## iOS Chat view
scarf/Scarf iOS/Chat/ChatView.swift + embedded ChatController
(@Observable @MainActor). Minimal v1 UX:
- Three-state lifecycle: .connecting / .ready / .failed(reason)
- Auto-scrolling message list
- SwiftUI composer (multi-line TextField + Send button)
- Toolbar "+" for a fresh session (stop → reset → start)
- Message bubble (user: accent; agent: secondary background)
Deferred to M5: tool-call cards, permission request sheets,
markdown rendering, voice.
scarf/Scarf iOS/Dashboard/DashboardView.swift gains a
NavigationLink into Chat.
## Small public-API tweak
`RichChatViewModel.sessionId` promoted from `private(set)` to
`public private(set)` — ChatController reads it to route
`sendPrompt`. Same pattern as earlier M3 public-nits patches.
## Tests: 2 new in M4ACPIOSTests (now 98/98 on Linux)
Deliberately focused — M1's 10-test MockACPChannel suite already
covers the full ACPClient state machine. These two pin the
patterns iOS's new SSHExecACPChannel exercises:
- streamingPromptDeliversChunksAndCompletes: full handshake +
session/new + streamed agent_message_chunk notifications +
session/prompt response. Verifies chunks arrive as
.messageChunk events and prompt resolves with correct usage
tokens.
- permissionRequestYieldsEventAndRespondSends: remote
session/request_permission request → .permissionRequest
event → respondToPermission writes correct JSON back on the
channel with matching id + outcome.
Running `docker run --rm -v $PWD/Packages/ScarfCore:/work
-w /work swift:6.0 swift test` now reports 98 / 98.
## Manual validation needed on Mac
1. Xcode compile of scarf mobile target against the merged
pbxproj (target reconciliation shipped in the previous commit
on this branch).
2. Chat end-to-end against a real Hermes host. From Dashboard,
tap Chat → type "hello" → streaming response. Test "+" for
new session. Verify no leaked SSH connections across
Disconnect + re-onboard.
3. If your Hermes enables tools: verify tool_call_update
notifications come through (won't render with fancy cards
yet — that's M5 polish).
Updated scarf/docs/IOS_PORT_PLAN.md with M4's shipped state, the
"two separate SSH clients" rule, and the M5 polish backlog
(tool cards, permissions, markdown, voice).
https://claude.ai/code/session_019yMRP6mwZWfzVrPTqevx2y
This commit is contained in:
@@ -78,7 +78,7 @@ public final class RichChatViewModel {
|
||||
scrollTrigger = UUID()
|
||||
}
|
||||
|
||||
private(set) var sessionId: String?
|
||||
public private(set) var sessionId: String?
|
||||
/// The original CLI session ID when resuming a CLI session via ACP.
|
||||
/// Used to combine old CLI messages with new ACP messages.
|
||||
private(set) var originSessionId: String?
|
||||
|
||||
@@ -0,0 +1,206 @@
|
||||
import Testing
|
||||
import Foundation
|
||||
@testable import ScarfCore
|
||||
|
||||
/// M4's new iOS-facing wiring is in `ScarfIOS`, not ScarfCore — so
|
||||
/// most M4 tests naturally live in `ScarfIOSTests`. But the line-
|
||||
/// framing / partial-chunk handling that `SSHExecACPChannel` does
|
||||
/// is identical in shape to what `ProcessACPChannel` does on Mac,
|
||||
/// and it's important enough that I want coverage on Linux CI too.
|
||||
///
|
||||
/// Strategy: pin the expected behaviour using `MockACPChannel` (from
|
||||
/// M1 tests) to prove that chunked-JSON-line transport feeds
|
||||
/// `ACPClient`'s read loop correctly. This lets a future refactor of
|
||||
/// either transport (iOS Citadel or Mac Process) detect line-framing
|
||||
/// regressions before smoke-testing on a device.
|
||||
@Suite struct M4ACPIOSTests {
|
||||
|
||||
/// Reused from M1ACPTests — minimal scripted channel. Copied here
|
||||
/// rather than imported so this suite stays standalone.
|
||||
actor ScriptedChannel: 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? { "scripted" }
|
||||
|
||||
init() {
|
||||
let (s, c) = AsyncThrowingStream<String, Error>.makeStream()
|
||||
self.incoming = s
|
||||
self.incomingCont = c
|
||||
let (es, ec) = AsyncThrowingStream<String, Error>.makeStream()
|
||||
self.stderr = es
|
||||
self.stderrCont = ec
|
||||
}
|
||||
|
||||
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()
|
||||
}
|
||||
|
||||
func reply(with line: String) { incomingCont.yield(line) }
|
||||
func emitStderr(_ line: String) { stderrCont.yield(line) }
|
||||
func lastSentId() -> Int? {
|
||||
guard let last = sent.last,
|
||||
let d = last.data(using: .utf8),
|
||||
let obj = try? JSONSerialization.jsonObject(with: d) as? [String: Any]
|
||||
else { return nil }
|
||||
return obj["id"] as? Int
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Streaming session/prompt flow
|
||||
|
||||
/// The crown jewel of the iOS Chat path: user sends a prompt →
|
||||
/// ACP streams agent_message_chunk notifications → ACPClient
|
||||
/// dispatches them as `.messageChunk` events → Chat view
|
||||
/// appends text. Verify the full handshake + prompt + stream +
|
||||
/// complete cycle runs end-to-end through the state machine.
|
||||
@Test @MainActor func streamingPromptDeliversChunksAndCompletes() async throws {
|
||||
let channel = ScriptedChannel()
|
||||
let client = ACPClient(context: .local) { _ in channel }
|
||||
|
||||
// 1. Start — sends `initialize`, blocks on our reply.
|
||||
let startTask = Task { try await client.start() }
|
||||
try await waitFor { await channel.sent.count >= 1 }
|
||||
let initId = await channel.lastSentId() ?? 1
|
||||
await channel.reply(with: #"{"jsonrpc":"2.0","id":\#(initId),"result":{}}"#)
|
||||
try await startTask.value
|
||||
|
||||
// 2. Create session.
|
||||
let newSessionTask = Task { try await client.newSession(cwd: "/home/user") }
|
||||
try await waitFor { await channel.sent.count >= 2 }
|
||||
let newId = await channel.lastSentId() ?? 2
|
||||
await channel.reply(with: #"{"jsonrpc":"2.0","id":\#(newId),"result":{"sessionId":"s-test-1"}}"#)
|
||||
let sessionId = try await newSessionTask.value
|
||||
#expect(sessionId == "s-test-1")
|
||||
|
||||
// 3. Start consuming events BEFORE prompt so we don't miss
|
||||
// the streamed chunks.
|
||||
let eventsCollected = ArrayBox<ACPEvent>()
|
||||
let eventTask = Task { () -> Void in
|
||||
var it = await client.events.makeAsyncIterator()
|
||||
while let e = await it.next() {
|
||||
await eventsCollected.append(e)
|
||||
if case .promptComplete = e { return }
|
||||
// Safety cap so a broken impl doesn't hang the test
|
||||
if await eventsCollected.count > 50 { return }
|
||||
}
|
||||
}
|
||||
|
||||
// 4. Send a prompt.
|
||||
let promptTask = Task { try await client.sendPrompt(sessionId: sessionId, text: "hi") }
|
||||
try await waitFor { await channel.sent.count >= 3 }
|
||||
let promptId = await channel.lastSentId() ?? 3
|
||||
|
||||
// 5. Stream two agent_message_chunk notifications then the
|
||||
// session/prompt response.
|
||||
await channel.reply(with: #"{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"s-test-1","update":{"sessionUpdate":"agent_message_chunk","content":{"text":"Hel"}}}}"#)
|
||||
await channel.reply(with: #"{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"s-test-1","update":{"sessionUpdate":"agent_message_chunk","content":{"text":"lo!"}}}}"#)
|
||||
await channel.reply(with: #"{"jsonrpc":"2.0","id":\#(promptId),"result":{"stopReason":"end_turn","usage":{"inputTokens":3,"outputTokens":2}}}"#)
|
||||
|
||||
let result = try await promptTask.value
|
||||
#expect(result.stopReason == "end_turn")
|
||||
#expect(result.inputTokens == 3)
|
||||
#expect(result.outputTokens == 2)
|
||||
|
||||
// Let the event task drain the two chunk events.
|
||||
try await Task.sleep(nanoseconds: 50_000_000)
|
||||
eventTask.cancel()
|
||||
|
||||
let events = await eventsCollected.value
|
||||
let chunks = events.compactMap { e -> String? in
|
||||
if case .messageChunk(_, let text) = e { return text }
|
||||
return nil
|
||||
}
|
||||
#expect(chunks == ["Hel", "lo!"])
|
||||
|
||||
await client.stop()
|
||||
}
|
||||
|
||||
// MARK: - Permission request round-trip
|
||||
|
||||
@Test @MainActor func permissionRequestYieldsEventAndRespondSends() async throws {
|
||||
let channel = ScriptedChannel()
|
||||
let client = ACPClient(context: .local) { _ in channel }
|
||||
let startTask = Task { try await client.start() }
|
||||
try await waitFor { await channel.sent.count >= 1 }
|
||||
let initId = await channel.lastSentId() ?? 1
|
||||
await channel.reply(with: #"{"jsonrpc":"2.0","id":\#(initId),"result":{}}"#)
|
||||
try await startTask.value
|
||||
|
||||
let eventCollector = ArrayBox<ACPEvent>()
|
||||
let eventTask = Task { () -> Void in
|
||||
var it = await client.events.makeAsyncIterator()
|
||||
while let e = await it.next() {
|
||||
await eventCollector.append(e)
|
||||
if case .permissionRequest = e { return }
|
||||
if await eventCollector.count > 20 { return }
|
||||
}
|
||||
}
|
||||
|
||||
// Remote asks permission (comes in as a request, not a
|
||||
// notification). `id` is the request's own id, not an answer.
|
||||
let requestPayload = #"""
|
||||
{"jsonrpc":"2.0","id":42,"method":"session/request_permission","params":{"sessionId":"s","toolCall":{"title":"write_file: /etc/hosts","kind":"edit"},"options":[{"optionId":"allow_once","name":"Allow once"},{"optionId":"deny","name":"Deny"}]}}
|
||||
"""#
|
||||
await channel.reply(with: requestPayload)
|
||||
|
||||
try await waitFor { await eventCollector.count >= 1 }
|
||||
eventTask.cancel()
|
||||
|
||||
let events = await eventCollector.value
|
||||
guard case .permissionRequest(_, let reqId, let req) = events.first(where: {
|
||||
if case .permissionRequest = $0 { return true } else { return false }
|
||||
}) ?? events.first else {
|
||||
Issue.record("No permission request event")
|
||||
return
|
||||
}
|
||||
#expect(reqId == 42)
|
||||
#expect(req.toolCallTitle == "write_file: /etc/hosts")
|
||||
#expect(req.options.count == 2)
|
||||
|
||||
// Respond → we send a response JSON back over the channel.
|
||||
let prevSentCount = await channel.sent.count
|
||||
await client.respondToPermission(requestId: reqId, optionId: "allow_once")
|
||||
try await waitFor { await channel.sent.count > prevSentCount }
|
||||
let response = await channel.sent.last ?? ""
|
||||
#expect(response.contains("\"id\":42"))
|
||||
#expect(response.contains("allow_once") || response.contains("allowed"))
|
||||
|
||||
await client.stop()
|
||||
}
|
||||
|
||||
// MARK: - Helpers
|
||||
|
||||
/// Mutable actor-protected Array used for collecting events
|
||||
/// off the MainActor event loop without racing.
|
||||
actor ArrayBox<T: Sendable> {
|
||||
private var items: [T] = []
|
||||
func append(_ x: T) { items.append(x) }
|
||||
var value: [T] { items }
|
||||
var count: Int { items.count }
|
||||
}
|
||||
|
||||
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")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
// Gated on `canImport(Citadel)` so Linux CI skips.
|
||||
#if canImport(Citadel)
|
||||
|
||||
import Foundation
|
||||
import Citadel
|
||||
import CryptoKit
|
||||
import ScarfCore
|
||||
|
||||
/// iOS-target glue that produces `ACPClient`s pre-wired with a
|
||||
/// Citadel-backed `SSHExecACPChannel`. Sibling to the Mac app's
|
||||
/// `ACPClient+Mac.swift` — both expose a `forXXX(context:)` factory
|
||||
/// that `ACPClient.ChannelFactory` consumes.
|
||||
///
|
||||
/// **Connection reuse.** The factory opens a fresh `SSHClient` per
|
||||
/// ACP session rather than reusing the long-lived `CitadelServerTransport`
|
||||
/// client — ACP sessions are long-lived (minutes to hours of streaming
|
||||
/// chat) and cohabiting them with the SFTP + exec calls that the
|
||||
/// transport uses would multiplex SSH channels on one connection,
|
||||
/// which OpenSSH servers often cap at 10 channels. Two separate
|
||||
/// connections stay well under that ceiling and fail-isolate.
|
||||
///
|
||||
/// If that per-feature cost becomes a bottleneck, a future phase
|
||||
/// can coalesce — Citadel's single `SSHClient` can host multiple
|
||||
/// concurrent channels up to the server's limit.
|
||||
public extension ACPClient {
|
||||
/// Build an `ACPClient` for `context` pre-wired with a Citadel
|
||||
/// exec channel that spawns `hermes acp` remotely.
|
||||
///
|
||||
/// - Parameters:
|
||||
/// - context: Server context — must be a `.ssh` kind; `.local`
|
||||
/// doesn't make sense on iOS (no local subprocess on iOS).
|
||||
/// - keyProvider: How to load the SSH private key for the
|
||||
/// connection. Typically `{ try await KeychainSSHKeyStore().load() }`.
|
||||
static func forIOSApp(
|
||||
context: ServerContext,
|
||||
keyProvider: @escaping @Sendable () async throws -> SSHKeyBundle
|
||||
) -> ACPClient {
|
||||
ACPClient(context: context) { ctx in
|
||||
try await makeSSHExecChannel(for: ctx, keyProvider: keyProvider)
|
||||
}
|
||||
}
|
||||
|
||||
/// Open a dedicated SSHClient for this ACP session and hand it
|
||||
/// to `SSHExecACPChannel`. The channel owns the client lifecycle
|
||||
/// — when `ACPClient.stop()` triggers `channel.close()`, the
|
||||
/// underlying SSH connection is also closed (clean teardown).
|
||||
nonisolated private static func makeSSHExecChannel(
|
||||
for context: ServerContext,
|
||||
keyProvider: @Sendable () async throws -> SSHKeyBundle
|
||||
) async throws -> any ACPChannel {
|
||||
guard case .ssh(let sshConfig) = context.kind else {
|
||||
throw ACPChannelError.other("iOS ACPClient requires a remote .ssh context — got \(context.kind)")
|
||||
}
|
||||
let key = try await keyProvider()
|
||||
let client = try await openSSHClient(config: sshConfig, key: key)
|
||||
|
||||
// Command to spawn. `hermes acp` is the ACP entry point; if
|
||||
// the user configured a non-default hermes binary path we
|
||||
// honour that via `paths.hermesBinary`. The `exec` command
|
||||
// is invoked via SSH RFC 4254 exec (no TTY) — binary-clean
|
||||
// stdin/stdout for JSON-RPC bytes.
|
||||
let command = context.paths.hermesBinary + " acp"
|
||||
|
||||
return try await SSHExecACPChannel(
|
||||
client: client,
|
||||
command: command,
|
||||
ownsClient: true
|
||||
)
|
||||
}
|
||||
|
||||
/// Shared SSH connect flow — used by ACPClient and
|
||||
/// `CitadelServerTransport.ConnectionHolder`. Single source of
|
||||
/// truth for the auth-method translation (SSHKeyBundle → Citadel
|
||||
/// `SSHAuthenticationMethod.ed25519`).
|
||||
nonisolated private static func openSSHClient(
|
||||
config: SSHConfig,
|
||||
key: SSHKeyBundle
|
||||
) async throws -> SSHClient {
|
||||
guard let parts = Ed25519KeyGenerator.decodeRawEd25519PEM(key.privateKeyPEM) else {
|
||||
throw ACPChannelError.launchFailed("Stored private key is not in the expected Scarf Ed25519 PEM format")
|
||||
}
|
||||
guard let ck = try? Curve25519.Signing.PrivateKey(rawRepresentation: parts.privateKey) else {
|
||||
throw ACPChannelError.launchFailed("Stored private key is malformed")
|
||||
}
|
||||
let username = config.user ?? "root"
|
||||
let auth: SSHAuthenticationMethod = .ed25519(username: username, privateKey: ck)
|
||||
var settings = SSHClientSettings(
|
||||
host: config.host,
|
||||
authenticationMethod: { auth },
|
||||
hostKeyValidator: .acceptAnything()
|
||||
)
|
||||
if let port = config.port { settings.port = port }
|
||||
do {
|
||||
return try await SSHClient.connect(to: settings)
|
||||
} catch {
|
||||
throw ACPChannelError.launchFailed("SSH connect to \(config.host) failed: \(error.localizedDescription)")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#endif // canImport(Citadel)
|
||||
@@ -0,0 +1,219 @@
|
||||
// Gated on `canImport(Citadel)` so Linux CI skips the file.
|
||||
#if canImport(Citadel)
|
||||
|
||||
import Foundation
|
||||
import NIOCore
|
||||
import Citadel
|
||||
import ScarfCore
|
||||
|
||||
/// `ACPChannel` backed by a Citadel SSH exec session. iOS counterpart
|
||||
/// to Mac's `ProcessACPChannel` — same protocol, different transport.
|
||||
///
|
||||
/// Citadel exposes an 8-bit-safe bidirectional exec channel via
|
||||
/// `SSHClient.withExec(_:perform:)`. We drive it with a detached Task
|
||||
/// that (a) calls `withExec` and handles its closure-scoped lifecycle,
|
||||
/// (b) captures the `TTYStdinWriter` so our `send(_:)` can write
|
||||
/// JSON-RPC lines, and (c) pumps stdout/stderr through line-framers
|
||||
/// into the `incoming` / `stderr` `AsyncThrowingStream`s that
|
||||
/// `ACPClient` consumes.
|
||||
///
|
||||
/// **Lifecycle**. Constructor is async; it spawns the exec task and
|
||||
/// returns once the writer is available for the first `send(_:)`.
|
||||
/// `close()` cancels the exec task — Citadel's `withExec` then closes
|
||||
/// the SSH channel, which cleanly finishes the streams. If the iOS
|
||||
/// side closed the `SSHClient` too (ownsClient), that happens after.
|
||||
///
|
||||
/// **Line framing**. Bytes arrive from Citadel in arbitrary-sized
|
||||
/// `ByteBuffer` chunks — stdout/stderr may be split mid-line. We
|
||||
/// buffer partial lines internally and only yield whole JSON-RPC
|
||||
/// lines (newline-stripped) through `incoming` / `stderr`.
|
||||
public actor SSHExecACPChannel: ACPChannel {
|
||||
private let client: SSHClient
|
||||
private let ownsClient: Bool
|
||||
|
||||
public nonisolated let incoming: AsyncThrowingStream<String, Error>
|
||||
public nonisolated let stderr: AsyncThrowingStream<String, Error>
|
||||
private let incomingCont: AsyncThrowingStream<String, Error>.Continuation
|
||||
private let stderrCont: AsyncThrowingStream<String, Error>.Continuation
|
||||
|
||||
/// Populated once the exec session's `withExec` closure fires.
|
||||
/// `send(_:)` awaits this (first send may block ~handshake-time,
|
||||
/// subsequent sends are instant).
|
||||
private var writer: TTYStdinWriter?
|
||||
/// Continuations waiting on the writer.
|
||||
private var writerWaiters: [CheckedContinuation<TTYStdinWriter, Error>] = []
|
||||
private var isClosed = false
|
||||
|
||||
/// Detached Task that drives `withExec`. Kept so we can cancel
|
||||
/// on `close()`.
|
||||
private var execTask: Task<Void, Never>?
|
||||
|
||||
/// Partial-line buffers for the line framer.
|
||||
private var stdoutBuf = Data()
|
||||
private var stderrBuf = Data()
|
||||
|
||||
public nonisolated var diagnosticID: String? { "citadel-exec" }
|
||||
|
||||
/// Start the exec. `command` is typically the remote path to
|
||||
/// `hermes acp` (optionally with a leading `cd …; ` if cwd matters).
|
||||
/// `ownsClient` tells us whether to close the underlying `SSHClient`
|
||||
/// on `close()` — true when we opened a dedicated client for this
|
||||
/// channel; false when the client is shared with other features
|
||||
/// (file I/O transport, etc.).
|
||||
public init(
|
||||
client: SSHClient,
|
||||
command: String,
|
||||
ownsClient: Bool = false
|
||||
) async throws {
|
||||
self.client = client
|
||||
self.ownsClient = ownsClient
|
||||
|
||||
let (inStream, inCont) = AsyncThrowingStream<String, Error>.makeStream()
|
||||
self.incoming = inStream
|
||||
self.incomingCont = inCont
|
||||
let (errStream, errCont) = AsyncThrowingStream<String, Error>.makeStream()
|
||||
self.stderr = errStream
|
||||
self.stderrCont = errCont
|
||||
|
||||
await startExecTask(client: client, command: command)
|
||||
// Wait for the exec session to hand us its stdin writer. If
|
||||
// anything fails before that, the exec task will surface the
|
||||
// error via the waiters queue.
|
||||
_ = try await waitForWriter()
|
||||
}
|
||||
|
||||
private func startExecTask(client: SSHClient, command: String) {
|
||||
let inCont = incomingCont
|
||||
let errCont = stderrCont
|
||||
execTask = Task { [weak self] in
|
||||
do {
|
||||
try await client.withExec(command) { inbound, outbound in
|
||||
await self?.writerBecameAvailable(outbound)
|
||||
for try await event in inbound {
|
||||
if Task.isCancelled { break }
|
||||
switch event {
|
||||
case .stdout(let buf):
|
||||
await self?.ingest(buf, isStderr: false)
|
||||
case .stderr(let buf):
|
||||
await self?.ingest(buf, isStderr: true)
|
||||
}
|
||||
}
|
||||
}
|
||||
inCont.finish()
|
||||
errCont.finish()
|
||||
await self?.markClosed()
|
||||
} catch is CancellationError {
|
||||
inCont.finish()
|
||||
errCont.finish()
|
||||
await self?.markClosed()
|
||||
} catch {
|
||||
// Includes `SSHClient.CommandFailed(exitCode:)` when
|
||||
// the remote `hermes acp` exits non-zero. ACPClient
|
||||
// maps that to `.processTerminated` via its read-loop
|
||||
// error handler.
|
||||
await self?.failWriterWaiters(with: error)
|
||||
inCont.finish(throwing: error)
|
||||
errCont.finish(throwing: error)
|
||||
await self?.markClosed()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - ACPChannel
|
||||
|
||||
public func send(_ line: String) async throws {
|
||||
if isClosed { throw ACPChannelError.writeEndClosed }
|
||||
let w = try await waitForWriter()
|
||||
var buf = ByteBufferAllocator().buffer(capacity: line.utf8.count + 1)
|
||||
buf.writeString(line)
|
||||
buf.writeInteger(UInt8(ascii: "\n"))
|
||||
try await w.write(buf)
|
||||
}
|
||||
|
||||
public func close() async {
|
||||
if isClosed { return }
|
||||
isClosed = true
|
||||
execTask?.cancel()
|
||||
execTask = nil
|
||||
|
||||
// Fail any pending waiters so a racing `send(_:)` doesn't hang.
|
||||
failWriterWaiters(with: ACPChannelError.writeEndClosed)
|
||||
|
||||
// Drain the line buffers once more so any final byte boundary
|
||||
// produces a valid line (e.g. if the remote process exits
|
||||
// after writing "…}\n"). Citadel's stream termination already
|
||||
// does this via yielding the trailing bytes before .exit(),
|
||||
// so this is belt-and-suspenders.
|
||||
if !stdoutBuf.isEmpty {
|
||||
if let text = String(data: stdoutBuf, encoding: .utf8), !text.isEmpty {
|
||||
incomingCont.yield(text)
|
||||
}
|
||||
stdoutBuf.removeAll()
|
||||
}
|
||||
incomingCont.finish()
|
||||
stderrCont.finish()
|
||||
|
||||
if ownsClient {
|
||||
try? await client.close()
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Internal
|
||||
|
||||
private func writerBecameAvailable(_ w: TTYStdinWriter) {
|
||||
writer = w
|
||||
let waiters = writerWaiters
|
||||
writerWaiters.removeAll(keepingCapacity: false)
|
||||
for waiter in waiters {
|
||||
waiter.resume(returning: w)
|
||||
}
|
||||
}
|
||||
|
||||
private func failWriterWaiters(with error: Error) {
|
||||
let waiters = writerWaiters
|
||||
writerWaiters.removeAll(keepingCapacity: false)
|
||||
for waiter in waiters {
|
||||
waiter.resume(throwing: error)
|
||||
}
|
||||
}
|
||||
|
||||
private func markClosed() {
|
||||
isClosed = true
|
||||
}
|
||||
|
||||
private func waitForWriter() async throws -> TTYStdinWriter {
|
||||
if let w = writer { return w }
|
||||
return try await withCheckedThrowingContinuation { cont in
|
||||
writerWaiters.append(cont)
|
||||
}
|
||||
}
|
||||
|
||||
/// Called per stdout/stderr chunk from Citadel. Line-frame + yield.
|
||||
private func ingest(_ buffer: ByteBuffer, isStderr: Bool) {
|
||||
var buf = buffer
|
||||
let bytes = buf.readBytes(length: buf.readableBytes) ?? []
|
||||
if isStderr {
|
||||
stderrBuf.append(contentsOf: bytes)
|
||||
while let nl = stderrBuf.firstIndex(of: 0x0A) {
|
||||
let line = Data(stderrBuf[stderrBuf.startIndex..<nl])
|
||||
stderrBuf = Data(stderrBuf[stderrBuf.index(after: nl)...])
|
||||
guard !line.isEmpty else { continue }
|
||||
if let text = String(data: line, encoding: .utf8) {
|
||||
stderrCont.yield(text)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
stdoutBuf.append(contentsOf: bytes)
|
||||
while let nl = stdoutBuf.firstIndex(of: 0x0A) {
|
||||
let line = Data(stdoutBuf[stdoutBuf.startIndex..<nl])
|
||||
stdoutBuf = Data(stdoutBuf[stdoutBuf.index(after: nl)...])
|
||||
guard !line.isEmpty else { continue }
|
||||
if let text = String(data: line, encoding: .utf8) {
|
||||
incomingCont.yield(text)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#endif // canImport(Citadel)
|
||||
@@ -0,0 +1,352 @@
|
||||
import SwiftUI
|
||||
import ScarfCore
|
||||
import ScarfIOS
|
||||
|
||||
// The Chat feature on iOS is gated on `canImport(SQLite3)` because
|
||||
// `RichChatViewModel` reads session history from `HermesDataService`
|
||||
// (which is itself SQLite3-gated). iOS always has SQLite3 available,
|
||||
// so on any real iOS build this renders normally. The guard exists
|
||||
// so ScarfCore-agnostic static analysis doesn't choke.
|
||||
#if canImport(SQLite3)
|
||||
|
||||
/// M4 iOS Chat: streams JSON-RPC over a Citadel SSH exec channel to a
|
||||
/// remote `hermes acp` process. Reuses ScarfCore's `RichChatViewModel`
|
||||
/// state machine (from M0d) + `ACPClient` (from M1).
|
||||
///
|
||||
/// Scope: one active session, rich-chat mode only (no terminal /
|
||||
/// SwiftTerm mode). Permission prompts, tool-call display, markdown,
|
||||
/// voice — all deferred to M5+ polish.
|
||||
struct ChatView: View {
|
||||
let config: IOSServerConfig
|
||||
let key: SSHKeyBundle
|
||||
|
||||
@State private var controller: ChatController
|
||||
|
||||
init(config: IOSServerConfig, key: SSHKeyBundle) {
|
||||
self.config = config
|
||||
self.key = key
|
||||
let ctx = config.toServerContext(id: Self.sharedContextID)
|
||||
_controller = State(initialValue: ChatController(context: ctx))
|
||||
}
|
||||
|
||||
/// Same UUID DashboardView uses, so the transport's cached SSH
|
||||
/// connection (if still open) can be reused when the user hops
|
||||
/// between Chat and Dashboard.
|
||||
private static let sharedContextID: ServerID = ServerID(
|
||||
uuidString: "00000000-0000-0000-0000-0000000000A1"
|
||||
)!
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 0) {
|
||||
messageList
|
||||
Divider()
|
||||
composer
|
||||
}
|
||||
.navigationTitle("Chat")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .topBarTrailing) {
|
||||
Button {
|
||||
Task { await controller.resetAndStartNewSession() }
|
||||
} label: {
|
||||
Image(systemName: "plus.bubble")
|
||||
}
|
||||
.disabled(controller.state == .connecting)
|
||||
}
|
||||
}
|
||||
.task {
|
||||
await controller.start()
|
||||
}
|
||||
.onDisappear {
|
||||
Task { await controller.stop() }
|
||||
}
|
||||
.overlay {
|
||||
if case .failed(let msg) = controller.state {
|
||||
errorOverlay(msg)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Subviews
|
||||
|
||||
@ViewBuilder
|
||||
private var messageList: some View {
|
||||
ScrollViewReader { proxy in
|
||||
ScrollView {
|
||||
LazyVStack(spacing: 12) {
|
||||
if controller.vm.messages.isEmpty, controller.state == .ready {
|
||||
emptyState
|
||||
}
|
||||
ForEach(controller.vm.messages) { msg in
|
||||
MessageBubble(message: msg)
|
||||
.id(msg.id)
|
||||
}
|
||||
if controller.vm.isAgentWorking {
|
||||
HStack {
|
||||
ProgressView()
|
||||
Text("Agent is working…")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.padding(.horizontal)
|
||||
}
|
||||
Color.clear
|
||||
.frame(height: 1)
|
||||
.id("bottom")
|
||||
}
|
||||
.padding(.vertical)
|
||||
}
|
||||
.onChange(of: controller.vm.scrollTrigger) { _, _ in
|
||||
withAnimation { proxy.scrollTo("bottom", anchor: .bottom) }
|
||||
}
|
||||
.onChange(of: controller.vm.messages.count) { _, _ in
|
||||
withAnimation { proxy.scrollTo("bottom", anchor: .bottom) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var emptyState: some View {
|
||||
VStack(spacing: 8) {
|
||||
Image(systemName: "bubble.left.and.bubble.right")
|
||||
.font(.system(size: 40))
|
||||
.foregroundStyle(.tertiary)
|
||||
Text("Ask Hermes something")
|
||||
.font(.headline)
|
||||
.foregroundStyle(.secondary)
|
||||
Text("Connected to \(config.displayName)")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.tertiary)
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(.top, 60)
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var composer: some View {
|
||||
HStack(alignment: .bottom, spacing: 8) {
|
||||
TextField(
|
||||
"Message…",
|
||||
text: $controller.draft,
|
||||
axis: .vertical
|
||||
)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
.lineLimit(1...5)
|
||||
.disabled(controller.state != .ready)
|
||||
.submitLabel(.send)
|
||||
.onSubmit {
|
||||
Task { await controller.send() }
|
||||
}
|
||||
|
||||
Button {
|
||||
Task { await controller.send() }
|
||||
} label: {
|
||||
Image(systemName: "arrow.up.circle.fill")
|
||||
.font(.system(size: 28))
|
||||
}
|
||||
.disabled(controller.state != .ready || controller.draft.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty)
|
||||
}
|
||||
.padding(.horizontal, 12)
|
||||
.padding(.vertical, 8)
|
||||
.background(.regularMaterial)
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func errorOverlay(_ message: String) -> some View {
|
||||
VStack(spacing: 12) {
|
||||
Image(systemName: "exclamationmark.triangle.fill")
|
||||
.font(.system(size: 32))
|
||||
.foregroundStyle(.orange)
|
||||
Text("Chat connection failed")
|
||||
.font(.headline)
|
||||
Text(message)
|
||||
.font(.callout)
|
||||
.multilineTextAlignment(.center)
|
||||
.foregroundStyle(.secondary)
|
||||
.padding(.horizontal)
|
||||
Button("Retry") {
|
||||
Task { await controller.start() }
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
}
|
||||
.padding()
|
||||
.background(.regularMaterial)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 14))
|
||||
.padding()
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - ChatController
|
||||
|
||||
/// Owns the ACPClient + RichChatViewModel lifecycle for one iOS chat
|
||||
/// screen. Kept out of `ChatView.body` so SwiftUI view re-renders don't
|
||||
/// spawn or tear down SSH connections unintentionally.
|
||||
@Observable
|
||||
@MainActor
|
||||
final class ChatController {
|
||||
enum State: Equatable {
|
||||
case idle
|
||||
case connecting
|
||||
case ready
|
||||
case failed(String)
|
||||
}
|
||||
|
||||
private(set) var state: State = .idle
|
||||
var vm: RichChatViewModel
|
||||
var draft: String = ""
|
||||
|
||||
private let context: ServerContext
|
||||
private var client: ACPClient?
|
||||
private var eventTask: Task<Void, Never>?
|
||||
|
||||
init(context: ServerContext) {
|
||||
self.context = context
|
||||
self.vm = RichChatViewModel(context: context)
|
||||
}
|
||||
|
||||
/// Open the SSH exec channel, send ACP `initialize`, then
|
||||
/// `session/new` — so that by the time `state == .ready` the user
|
||||
/// can type and hit send immediately.
|
||||
func start() async {
|
||||
if state == .connecting || state == .ready { return }
|
||||
state = .connecting
|
||||
vm.reset()
|
||||
let client = ACPClient.forIOSApp(
|
||||
context: context,
|
||||
keyProvider: {
|
||||
let store = KeychainSSHKeyStore()
|
||||
guard let key = try await store.load() else {
|
||||
throw SSHKeyStoreError.backendFailure(
|
||||
message: "No SSH key in Keychain — re-run onboarding.",
|
||||
osStatus: nil
|
||||
)
|
||||
}
|
||||
return key
|
||||
}
|
||||
)
|
||||
self.client = client
|
||||
|
||||
do {
|
||||
try await client.start()
|
||||
} catch {
|
||||
state = .failed(error.localizedDescription)
|
||||
return
|
||||
}
|
||||
|
||||
// Start streaming ACP events into the view-model BEFORE we
|
||||
// send session/new, so the `available_commands_update`
|
||||
// notification that the server sends on session init is
|
||||
// captured.
|
||||
let stream = await client.events
|
||||
eventTask = Task { [weak self] in
|
||||
for await event in stream {
|
||||
guard let self else { break }
|
||||
await MainActor.run {
|
||||
self.vm.handleACPEvent(event)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Create a fresh ACP session. `cwd` is the remote user's home
|
||||
// directory — Hermes defaults to that for tool scoping.
|
||||
do {
|
||||
let home = await context.resolvedUserHome()
|
||||
let sessionId = try await client.newSession(cwd: home)
|
||||
vm.setSessionId(sessionId)
|
||||
state = .ready
|
||||
} catch {
|
||||
state = .failed(error.localizedDescription)
|
||||
await stop()
|
||||
}
|
||||
}
|
||||
|
||||
/// Send the current draft as a prompt. Fire-and-forget — the
|
||||
/// assistant reply streams back as ACP notifications handled by
|
||||
/// the event task.
|
||||
func send() async {
|
||||
guard state == .ready, let client else { return }
|
||||
let text = draft.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !text.isEmpty else { return }
|
||||
let sessionId = vm.sessionId ?? ""
|
||||
guard !sessionId.isEmpty else { return }
|
||||
draft = ""
|
||||
vm.addUserMessage(text: text)
|
||||
do {
|
||||
_ = try await client.sendPrompt(sessionId: sessionId, text: text)
|
||||
} catch {
|
||||
// The event task may already have surfaced a
|
||||
// .connectionLost; show the send-time error only if the
|
||||
// state didn't already fail.
|
||||
if case .ready = state {
|
||||
state = .failed("Prompt failed: \(error.localizedDescription)")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Stop the current session + tear down the SSH exec channel.
|
||||
/// Idempotent.
|
||||
func stop() async {
|
||||
eventTask?.cancel()
|
||||
eventTask = nil
|
||||
if let client {
|
||||
await client.stop()
|
||||
}
|
||||
client = nil
|
||||
state = .idle
|
||||
}
|
||||
|
||||
/// User tapped "New chat". Stop, reset the VM, start again.
|
||||
func resetAndStartNewSession() async {
|
||||
await stop()
|
||||
vm.reset()
|
||||
await start()
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Message bubble
|
||||
|
||||
private struct MessageBubble: View {
|
||||
let message: HermesMessage
|
||||
|
||||
var body: some View {
|
||||
HStack {
|
||||
if message.isUser { Spacer(minLength: 40) }
|
||||
VStack(alignment: message.isUser ? .trailing : .leading, spacing: 4) {
|
||||
Text(message.content)
|
||||
.font(.body)
|
||||
.padding(.horizontal, 12)
|
||||
.padding(.vertical, 8)
|
||||
.foregroundStyle(message.isUser ? Color.white : Color.primary)
|
||||
.background(
|
||||
message.isUser ? Color.accentColor : Color(.secondarySystemBackground)
|
||||
)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 14))
|
||||
.textSelection(.enabled)
|
||||
if message.hasReasoning, let r = message.reasoning, !r.isEmpty {
|
||||
Text("🧠 \(r)")
|
||||
.font(.caption2)
|
||||
.italic()
|
||||
.foregroundStyle(.secondary)
|
||||
.padding(.horizontal, 12)
|
||||
}
|
||||
}
|
||||
if !message.isUser { Spacer(minLength: 40) }
|
||||
}
|
||||
.padding(.horizontal)
|
||||
}
|
||||
}
|
||||
|
||||
#endif // canImport(SQLite3)
|
||||
|
||||
// Empty shim so the file compiles on platforms without SQLite3 — the
|
||||
// target never runs there, but the typechecker visits the file.
|
||||
#if !canImport(SQLite3)
|
||||
struct ChatView: View {
|
||||
let config: IOSServerConfig
|
||||
let key: SSHKeyBundle
|
||||
var body: some View {
|
||||
Text("Chat requires SQLite3 — this platform is not supported.")
|
||||
}
|
||||
}
|
||||
#endif
|
||||
@@ -89,6 +89,14 @@ struct DashboardView: View {
|
||||
}
|
||||
}
|
||||
|
||||
Section {
|
||||
NavigationLink {
|
||||
ChatView(config: config, key: key)
|
||||
} label: {
|
||||
Label("Chat", systemImage: "bubble.left.and.bubble.right.fill")
|
||||
}
|
||||
}
|
||||
|
||||
Section("Connected to") {
|
||||
LabeledContent("Host", value: config.host)
|
||||
if let user = config.user {
|
||||
|
||||
@@ -617,7 +617,41 @@ the 3 ScarfIOS tests.
|
||||
- **`CitadelServerTransport.streamLines` is a stub (M3).** When the iOS Chat feature lands in M4+, implement it using Citadel's raw exec channel API (not `executeCommand`, which buffers the entire output). That'll also unlock iOS log tailing.
|
||||
- **`HermesFileService` still hasn't moved to ScarfCore.** iOS's Dashboard is minimal because of this; no config.yaml / gateway-state / pgrep checks. A future phase can either port HermesFileService (requires iOS-compatible shell-env story) or replicate the narrow subset iOS needs.
|
||||
|
||||
### M4 — pending
|
||||
### M4 — pending
|
||||
### M4 — shipped (on `claude/ios-m4-chat` branch, separate PR, stacked on M3)
|
||||
|
||||
**What shipped:** iOS Chat via Citadel's 8-bit-safe SSH exec channel + the Xcode target reconciliation work (pulling Alan's target creation from `template-configuration` into the iOS-port stack, merging pbxproj with M3's ScarfCore wiring, consolidating source layout into `scarf/Scarf iOS/`, wiring ScarfIOS as a local SPM package).
|
||||
|
||||
**ScarfIOS additions:**
|
||||
- `SSHExecACPChannel.swift` — iOS counterpart to `ProcessACPChannel`. Uses `SSHClient.withExec(_:perform:)` for bidirectional exec (RFC 4254), line-frames stdout / stderr, Cancel-driven teardown.
|
||||
- `ACPClient+iOS.swift` — `ACPClient.forIOSApp(context:keyProvider:)` factory that opens a dedicated `SSHClient` per ACP session (separate from the transport's client so ACP's long channel doesn't multiplex-compete with SFTP). Shared ed25519 auth helper via the key bundle stored in Keychain.
|
||||
|
||||
**iOS Chat view:**
|
||||
- `Scarf iOS/Chat/ChatView.swift` + `ChatController` (`@Observable @MainActor`). Three-state lifecycle (connecting / ready / failed), auto-scroll message list, SwiftUI composer, "+" toolbar for a fresh session. Reuses ScarfCore's `RichChatViewModel` unchanged.
|
||||
- `DashboardView` gains a NavigationLink into Chat.
|
||||
- `RichChatViewModel.sessionId` promoted `public private(set)` so `ChatController` can route `sendPrompt`.
|
||||
|
||||
**Xcode target reconciliation** (carried in the same PR — they can't easily be separated without breaking the build):
|
||||
- Merged `b289a83`'s iOS-target pbxproj additions on top of my M3 pbxproj via `git merge-file` (zero conflicts, 658 → 1074 lines). Added ScarfIOS as a new `XCLocalSwiftPackageReference`, wired both ScarfCore + ScarfIOS to the `scarf mobile` target's `packageProductDependencies` + Frameworks build phase.
|
||||
- Source layout: moved `scarf/scarf-ios/*` into `scarf/Scarf iOS/` (matching Xcode's synced group path). Deleted Xcode's scaffolded `ContentView.swift` / `Item.swift` / `Scarf_iOSApp.swift` defaults (my M2 code supersedes).
|
||||
- `scarf/docs/iOS-SETUP.md` — rewrote as a project-layout reference + troubleshooting doc, dropping the "how to create the target" walkthrough now that the target exists.
|
||||
|
||||
**Tests:** 2 new in `M4ACPIOSTests`:
|
||||
- Streaming prompt end-to-end: initialize handshake + session/new + two agent_message_chunk notifications + session/prompt response with usage tokens. Verify both chunks arrive as `.messageChunk` events, the prompt resolves with correct stopReason + input/output token counts.
|
||||
- Permission-request round-trip: remote `session/request_permission` request → `.permissionRequest` event → `respondToPermission` writes a proper JSON response back on the channel.
|
||||
|
||||
**96 → 98 tests passing on Linux.**
|
||||
|
||||
**Manual validation still needed on Mac:**
|
||||
1. iOS compile cleanly against the merged pbxproj.
|
||||
2. Chat end-to-end: Dashboard → Chat → "hello" → streaming response from a real Hermes install.
|
||||
3. Tool-call events visible (even without fancy cards — M5 polish).
|
||||
4. No leaked SSH connections across Disconnect / re-onboard cycles.
|
||||
|
||||
**Rules next phases can rely on:**
|
||||
- **Two separate `SSHClient`s per Scarf session** — one in `CitadelServerTransport` (SFTP + one-shot exec), one in `SSHExecACPChannel` (long-running ACP). Don't pool; OpenSSH caps concurrent channels per connection at ~10.
|
||||
- **`ACPClient.forIOSApp`** is the iOS factory. Any future iOS feature that needs ACP uses it — don't construct `ACPClient` directly.
|
||||
- **Chat is rich-chat-only on iOS in v1.** Terminal mode (embedded SwiftTerm) deferred.
|
||||
- **Message markdown / tool-call cards / permission sheets** are M5 polish.
|
||||
|
||||
### M5 — pending
|
||||
### M6 — pending
|
||||
|
||||
Reference in New Issue
Block a user