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:
Claude
2026-04-22 23:57:37 +00:00
parent 110611549e
commit bd6e722029
7 changed files with 923 additions and 3 deletions
@@ -78,7 +78,7 @@ public final class RichChatViewModel {
scrollTrigger = UUID() 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. /// The original CLI session ID when resuming a CLI session via ACP.
/// Used to combine old CLI messages with new ACP messages. /// Used to combine old CLI messages with new ACP messages.
private(set) var originSessionId: String? 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)
+352
View File
@@ -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") { Section("Connected to") {
LabeledContent("Host", value: config.host) LabeledContent("Host", value: config.host)
if let user = config.user { if let user = config.user {
+36 -2
View File
@@ -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. - **`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. - **`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 — shipped (on `claude/ios-m4-chat` branch, separate PR, stacked on M3)
### M4 — pending
**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 ### M5 — pending
### M6 — pending ### M6 — pending