Two follow-ups per review:
## Citadel: current stable
Citadel is at 0.12.1, not 0.9.x as I'd been writing against. Bumped
the pin from `from: "0.7.0"` to `.upToNextMinor(from: "0.12.0")`
— tight because Citadel's pre-1.0 authentication-method variants
have shifted between minor releases (0.7 → 0.9 → 0.12), so
explicit bump-and-review is safer than letting the version float.
Downloaded Citadel 0.12.1's source and verified every API call in
CitadelSSHService against it:
- SSHAuthenticationMethod.ed25519(username:, privateKey:) ✓
- SSHClientSettings(host:, authenticationMethod:, hostKeyValidator:) ✓
- SSHHostKeyValidator.acceptAnything() ✓
- SSHClient.connect(to: settings) ✓
- client.executeCommand(_:) -> ByteBuffer ✓
- client.close() async throws ✓
Dropped the "FIXME — may need adjustment" disclaimer in the file
header; replaced with a "verified against 0.12.1" note that says
re-verify if the pin bumps to 0.13+. Same change in SETUP.md
troubleshooting.
## Assets.xcassets (app icon + accent color)
scarf/scarf-ios/Assets.xcassets/ now exists with:
- AppIcon.appiconset/
AppIcon-1024.png (1024×1024, copied from the Mac app's
icon set — same art)
Contents.json (idiom: universal, platform: ios,
size: 1024x1024 — iOS 14+ renders all
smaller sizes from this automatically)
- AccentColor.colorset/
Contents.json (Scarf teal: sRGB 0.227/0.525/0.722
light, 0.400/0.690/0.902 dark)
- Contents.json (root, empty — just version metadata)
SETUP.md updated:
- Instructs Alan to delete Xcode's scaffolded Assets.xcassets AND
import ours, not the other way around.
- Notes the accent color values so a different palette choice is
a one-file edit.
- Removes the obsolete "drop your icon asset" step.
No functional code changes; tests still 88/88 on Linux.
https://claude.ai/code/session_019yMRP6mwZWfzVrPTqevx2y
33 KiB
Scarf iOS Port — Plan & Progress Log
Living document. Updated at the end of each phase. Read this before starting any phase so you know what the prior phase did, what shipped, and what the next phase is allowed to assume.
Locked Decisions
- iOS 18 minimum. Matches the Mac app's
@Observable/NavigationStackAPIs so ViewModels can move intoScarfCorewithout#if os(iOS)gymnastics on the navigation layer. - iPhone only for v1. iPad Universal deferred (+1 week to add later).
- No APNs push for v1. Requires a Hermes-side server component. Deferred.
- Remote-only on iOS. No local Hermes mode — iOS sandbox can't read
~/.hermes/and can't spawn subprocesses. SSH to a user-owned Hermes install (Mac, home server, VPS) is the only connection model. This is by design, not a regression. - SSH library: Citadel (pure Swift, SwiftNIO, MIT licensed).
- Distribution: TestFlight → App Store. No Sparkle on iOS. Apple
Developer team
3Q6X2L86C4is reused. - Shared-code strategy: local Swift Package (
ScarfCore). Not a multiplatform target.PBXFileSystemSynchronizedRootGroupmakes per-file target membership impractical, so the Mac and iOS apps each consume a separate SPM package and provide their own platform shells.
Target Architecture
scarf/ (repo root)
scarf/ (Xcode project folder)
scarf.xcodeproj/
Packages/
ScarfCore/ (local SPM — platform-neutral)
Package.swift
Sources/ScarfCore/
Models/ (added in M0a)
Transport/ (added in M0b)
Services/ (added in M0c — portable subset)
ViewModels/ (added in M0d — portable subset)
Views/ (added in M0d — portable subset)
Tests/ScarfCoreTests/
scarf/ (macOS app — PBXFileSystemSynchronizedRootGroup)
MacApp/ (Mac-only glue: Sparkle, SwiftTerm, NSWorkspace shims)
Core/Services/ (Mac-only services remain here)
Features/ (Mac-only features remain here)
Navigation/
scarf-ios/ (iOS app — added in M2)
iOSApp/ (iOS-only glue: CitadelTransport, tab/stack nav)
What We Give Up On iOS (Intentional)
| Dropped | Reason |
|---|---|
| Local Hermes mode | Sandbox + no subprocess on iOS |
| Sparkle auto-updates | App Store handles updates |
| Terminal mode in Chat (SwiftTerm) | Mac-only in v1; SwiftTerm does support iOS, defer to v1.1 |
| Embedded terminal platform-setup (Signal/WhatsApp pairing) | Same SwiftTerm dependency |
NSWorkspace.open(_:) "open in editor" / "reveal" |
No equivalent; use UIApplication.open(_:) for URLs |
| Multi-window (one window per server) | iPhone-only v1; iPad scenes may come later |
| Menu bar, global shortcuts, drag-and-drop from Finder | Not applicable on iOS |
What Ships In The v1 iOS App
Dashboard, Sessions Browser, Sessions Detail, Activity Feed, Insights, Memory viewer/editor, Skills, Cron, Logs, Health, Rich Chat, Settings (read-mostly). ~70% of the current Mac feature surface.
The One Real Refactor: Decouple ACP from Process
Core/Services/ACPClient.swift currently pokes at Process.isRunning,
Process.terminationHandler, and Darwin.write() on raw pipe file
descriptors. Those APIs don't exist on iOS. We introduce:
protocol ACPChannel: Sendable {
var isOpen: Bool { get }
func send(_ line: String) async throws // JSON line + "\n"
var incoming: AsyncThrowingStream<String, Error> { get }
func close() async
}
- Mac:
ProcessACPChannelwraps today'sProcess+Pipecode. - iOS:
SSHExecACPChannelwraps a Citadel exec session.
This lands in M1.
SSH on iOS: Citadel
orlandos-nl/Citadel is pure-Swift
SSH on SwiftNIO. What we use:
- Public-key auth, keys imported from Files.app or generated on-device and
exported as public key for
authorized_keys. - Long-lived exec channel for ACP JSON-RPC over stdio.
- SFTP for
state.dbsnapshot pulls (same flow as Mac'sscp). - One-shot exec for
stat/cat/sqlite3 .backupused by existing services.
What we lose vs. system ssh: no ~/.ssh/config, no ProxyJump, no
ControlMaster, no ssh-agent. We run a per-app in-memory session pool (one
session per server, reused across calls) to recover the perf benefit.
Distribution, Testing, CI
- TestFlight primary beta channel.
- App Store production distribution.
- CI (GitHub Actions,
macos-latest):swift testagainstPackages/ScarfCore— fast, no simulator.xcodebuild test -scheme scarf-ios -destination 'platform=iOS Simulator,...'for iOS UI tests (added in M2+).xcodebuild test -scheme scarffor the Mac target (unchanged).
- Release script
scripts/release-ios.shadded in M6:xcodebuild archive→-exportArchivewith App Store profile →xcrun notarytool-free path (App Store review replaces notarization for iOS). The existingscripts/release.shkeeps its Mac-specific Sparkle flow.
Milestones
| ID | Scope | Size |
|---|---|---|
| M0 | Extract ScarfCore package (Mac-only, no iOS yet) |
1–2 weeks |
| M1 | Decouple ACP from Process via ACPChannel protocol |
2–3 days |
| M2 | iOS app skeleton — Citadel, onboarding, Dashboard only | ~1 week |
| M3 | iOS monitor surface — Sessions, Activity, Insights, Logs, Health | 1–2 weeks |
| M4 | iOS Rich Chat — SSHExecACPChannel + ACPClient wiring |
~1 week |
| M5 | iOS writes — Memory, Cron, Skills, Settings | 3–5 days |
| M6 | Polish, TestFlight public beta, App Store submission | ~1 week |
Total: 6–9 weeks.
M0 Sub-Phases (each is its own PR)
Because M0 is too large for a single safe PR (no ability to run builds between commits), it's split into 4 self-contained sub-PRs that each leave the Mac app in a working state:
- M0a — Package scaffolding + move 13 leaf Models to
ScarfCore - M0b — Move Transport +
ServerContexttoScarfCore - M0c — Move portable Services (
HermesDataService,HermesLogService,ModelCatalogService,ProjectDashboardService) toScarfCore - M0d — Move portable ViewModels + Views to
ScarfCore
Rules For Future Phases
- Any new feature lands in
ScarfCoreby default. macOS-only is allowed for features that needProcess,NSWorkspace, embeddedSwiftTerm, or menu-bar integration — document why in the feature's header comment. - Every PR leaves the Mac app building and passing tests. If the PR's own changes can't be verified in the sandbox agent environment, the PR description must list a manual verification checklist for Alan to run before merging.
- Wiki updates follow the CLAUDE.md rules — if the feature was moved, the wiki page for that feature should note whether it's available on macOS, iOS, or both.
- Version numbers stay in lockstep. Mac and iOS bump to the same
MARKETING_VERSIONin one commit.
Progress Log
M0a — shipped in PR #31
Shipped:
Packages/ScarfCore/Package.swift(Swift tools 6.0, targets macOS 14 + iOS 18). Language mode pinned at.v5to match the Mac app'sSWIFT_VERSION = 5.0. Two types (ACPEvent.availableCommandsandACPToolCallEvent.rawInput) claimSendablewhile carrying[String: Any]payloads — strict Swift 6 rejects that. A future cleanup phase should replace those with typed payloads and bump to.v6.- 13 leaf model files moved under
Sources/ScarfCore/Models/. HermesConstants.swiftsplit:sqliteTransient+QueryDefaults+FileSizeUnitare in ScarfCore; the deprecatedHermesPathsenum is parked in the Mac target atHermesPaths+Deprecated.swift. Zero callers in-tree — it can be deleted in M0b alongsideServerContext.- Every moved type, member, and (where needed) nested
CodingKeysispublic. Every struct got an explicitpublic init(...)— Swift's synthesized memberwise init isinternaland would have broken cross-module construction. A throwaway Python generator did the mechanical work; tests inScarfCoreTestsexercise every generated init so parameter drift would fail CI, not a reviewer. scarf.xcodeproj/project.pbxprojgains oneXCLocalSwiftPackageReferenceforPackages/ScarfCoreand links the product into thescarftarget.- 49 main-target files (not 35 as originally estimated — many
Viewfiles onlyimport SwiftUIwithoutFoundation) gotimport ScarfCore.
Linux-CI compatibility additions (for swift test in containers):
SQLite3system module exists on macOS/iOS but not on Linux swift-corelibs.sqliteTransientinHermesConstants.swiftis wrapped in#if canImport(SQLite3). Apple platforms compile it unchanged; Linux just doesn't see it (no one on Linux will execute Hermes DB code anyway).LocalizedStringResourceis an Apple-only Foundation type.ToolKind.displayName(inHermesMessage.swift) andMCPTransport.displayName(inHermesMCPServer.swift) are wrapped in#if canImport(Darwin). Apple platforms compile them unchanged; Linux builds skip them.
Test coverage (ScarfCoreTests): 16 tests that construct every
moved type via its public init, verify computed properties, round-trip
Codable (HermesCronJob, WidgetValue), exercise nested config
.empty chains, and assert KnownPlatforms / MCPServerPreset.gallery
statics are readable. Run via docker run --rm -v $PWD/scarf/Packages/ScarfCore:/work -w /work swift:6.0 swift test.
Rules next phases can rely on:
- The
public initpattern is now established for ScarfCore structs. M0b+ should add explicitpublic init(...)to every new struct moved into the package. #if canImport(Darwin)is the package's "Apple-only API" guard. Prefer this overos(iOS) || os(macOS) || ...— it's shorter and catches the same platforms.#if canImport(SQLite3)is the pattern for anything that needs Apple's built-in SQLite. When HermesDataService moves in M0c, use this same guard for the actual Swift-SQLite bindings.- The Mac app still uses Swift 5 language mode. Do not add
nonisolatedto new ScarfCore APIs pre-emptively; match the surrounding conventions.
M0b — shipped
Shipped:
- 4 Transport files moved to
Packages/ScarfCore/Sources/ScarfCore/Transport/:ServerTransport.swift,LocalTransport.swift,SSHTransport.swift,TransportErrors.swift. ServerContext.swiftmoved toPackages/ScarfCore/Sources/ScarfCore/Models/. TherunHermes(_:timeout:stdin:)andopenInLocalEditor(_:)extension methods — the only two that depend on main-targetHermesFileServiceor on AppKit'sNSWorkspace— are split out into a new main-target filescarf/Core/Models/ServerContext+Mac.swift.HermesFileService.enrichedEnvironment()reference insideSSHTransport.sshSubprocessEnvironment()replaced with a local#if os(macOS)helpermacLoginShellSSHAgent()that does a narrowzsh -l -cprobe for onlySSH_AUTH_SOCK/SSH_AGENT_PID(instead of the broader PATH + credentials harvest that still lives inHermesFileService). This breaks the Mac-target dependency from ScarfCore. Behavior-identical on macOS; a no-op on iOS (where the SSH agent comes from Citadel in M4, not the user's shell) and on Linux CI.HermesPaths+Deprecated.swiftdeleted. Its only justification was thatServerContextwas in the Mac target; withServerContextin ScarfCore now, the deprecated forwarders are both unreachable AND unused (zero callers). Good riddance.- Added
import ScarfCoreto 54 more consumer files that reference Transport types orServerContextbut weren't already importing ScarfCore from M0a.scarfTests/scarfTests.swiftalso gets the import — itsControlPathTestsnow hits the publicSSHTransportvia ScarfCore.
Platform guards applied in ScarfCore:
#if canImport(os)— Apple'sos.Logger(import os+ every call site). Linux gets silent logging. Exception: the large block inSSHTransport.ensureControlDir()usesDarwin.stat/lstat/mkdir/chmodalongside its Logger calls — the whole method body is wrapped in#if canImport(Darwin)with a simpleFileManager.createDirectoryfallback for Linux (stubbed because SSH isn't exercised at runtime on Linux anyway).#if canImport(Darwin)—Darwin.open/Darwin.close+ FSEvents-basedDispatchSourceFileSystemObjectinLocalTransport.watchPaths. Linux gets a no-op empty stream.#if canImport(SwiftUI)—EnvironmentKey/EnvironmentValuesplumbing inServerContext.swift.#if canImport(AppKit)— only in the split-outServerContext+Mac.swift, whereNSWorkspace.shared.openlives. iOS will provide its own equivalent (UIApplication.open(_:)) when the target lands in M2.
Bug fixed while moving: the sed transform in M0a accidentally promoted
protocol ServerTransport requirements to public nonisolated var contextID ....
Protocol requirements inherit the protocol's access level and must
not carry an explicit modifier — that's a Swift compile error. Fixed
in this PR's ServerTransport.swift.
Test coverage (M0bTransportTests): 18 new tests that construct
SSHConfig with and without defaults, round-trip it through Codable,
verify ServerKind pattern-matching, pin ServerContext.local's
hard-coded UUID, assert local-vs-remote path derivation, verify
makeTransport() dispatches to the right impl, exercise FileStat /
ProcessResult / WatchEvent / TransportError shapes + error-classifier
stderr patterns, and round-trip an actual local file through
LocalTransport (write → read → stat → remove).
Rules next phases can rely on:
ServerContextis the canonical multi-server entry point. Any new service added in M0c or later takes aServerContextin its init.ServerContext+Mac.swiftis the pattern for Mac-only methods on ScarfCore types. iOS will have a siblingServerContext+iOS.swiftwhen the iOS target lands. Keep platform-specific methods out of ScarfCore itself and in these sibling files.- Logger pattern:
#if canImport(os) ... #endifaround each call site. If there are 3+ sites in one method, consider wrapping the whole method body in#if canImport(Darwin)with a Linux-safe fallback. - SSH env enrichment is now self-contained in
SSHTransport.swift. When iOS's Citadel-based transport lands (M4), it will provide its own env story — the existing macOS helper stays untouched.
M0c — shipped
Shipped:
- 4 portable Services moved to
Packages/ScarfCore/Sources/ScarfCore/Services/:HermesDataService.swift(658 lines, SQLite3-backed session/message/activity reader +SnapshotCoordinatoractor)HermesLogService.swift(log tailing + parsing,LogEntry+LogLevel)ModelCatalogService.swift(models.dev cache reader,HermesModelInfo+HermesProviderInfo)ProjectDashboardService.swift(per-project dashboard JSON I/O)
HermesFileService.swift,HermesEnvService.swift,HermesFileWatcher.swift,ACPClient.swift, andUpdaterService.swiftstay in the Mac target.HermesFileServiceholds the big shell-enrichment logic and is the only non-portable heavyweight — a later phase can port it once iOS has a clearer story for shell-env-less ACP spawning.ACPClientis M1's job (theACPChannelrefactor).UpdaterServicewraps Sparkle and stays Mac-only forever.- The one remaining external consumer that wasn't already importing
ScarfCore (
Features/Settings/Views/Components/ModelPickerSheet.swift) now hasimport ScarfCoreadded.
Platform guards:
HermesDataService.swiftis wrapped in#if canImport(SQLite3)/#endif— the whole file. SQLite3 isn't a system module on Linux swift-corelibs-foundation, and the service is unusable without it. Apple platforms (the real runtime targets) compile it unchanged. Linux builds just skip it. Nothing in ScarfCore referencesHermesDataServicefrom outside that file, so there's no downstream fallout.ModelCatalogService.swift—import os/ logger definition / logger call sites all guarded with#if canImport(os). Linux gets silent logging.
Test coverage (M0cServicesTests): 8 new tests.
HermesLogService.parseLineexercised viareadLastLinesagainst a real local log file with three lines (v0.9.0+ format with session tag, older format without, and a garbage fallback line). Verifies the optional session tag handling called out in CLAUDE.md.LogEntry.LogLevelcolour strings pinned (SwiftUI views depend on them matching colour names).HermesModelInfo.contextDisplaytested across1M,200K,500, andnilcases;costDisplaytested with and without costs.ModelCatalogServiceload path exercised end-to-end against a syntheticmodels_dev_cache.jsonlookalike — providers sorted alphabetically, models filtered by provider,provider(for:)finds models both by full scan AND viaprovider/modelslash-prefix fallback.- Malformed + missing file paths return empty results, no crash.
ProjectDashboardServiceround-trips aProjectRegistryto disk and reads back a synthetic.scarf/dashboard.json.
Rules next phases can rely on:
- The
#if canImport(SQLite3)gate pattern is established — any future ScarfCore code that touches SQLite3 directly should use the same whole-file or whole-block guard rather than trying to abstract SQLite behind a protocol (overkill; SQLite is reliably available on every target that can run Hermes client code). - Services take
ServerContextin their init and construct their own transport viacontext.makeTransport(). M0d ViewModels should follow the same convention when they move to ScarfCore. LocalTransport()(no-arg init) is the fast path for tests — usesServerContext.local.id. Test helpers in ScarfCoreTests lean on this heavily.
M0d — shipped
Scope decision: ViewModels only; Views stay in the Mac target for now. SwiftUI Views have heavy cross-feature coupling (AppCoordinator navigation, sidebar integration), AppKit-dependent widgets (NSOpenPanel, NSWorkspace.open for "reveal in Finder"), and platform-specific layout idioms that iPhone should re-implement rather than inherit. The Mac target will keep its current Views; M3+ builds fresh iOS Views on top of the shared ViewModels.
Moved (6 ViewModels):
ActivityViewModel.swift— wrapsHermesDataService.fetchToolCalls. Gated on#if canImport(SQLite3).ConnectionStatusViewModel.swift— heartbeat for remote SSH health;@MainActor @Observable.InsightsViewModel.swift— aggregates over sessions viaHermesDataService. Also exportsInsightsPeriod,ModelUsage,PlatformUsage,ToolUsage,NotableSessionand the free functionsformatDuration(_:)/formatTokens(_:). Gated on#if canImport(SQLite3).LogsViewModel.swift— log tail + filter state (level, component, search). Uses onlyHermesLogService; no SQLite3 gate needed. ExposesLogFileandLogComponentnested enums with#if canImport(Darwin)-guardedLocalizedStringResourcedisplay names.ProjectsViewModel.swift— wrapsProjectDashboardService. Fully portable.RichChatViewModel.swift— ~700 lines of ACP-event + message-group handling. Gated on#if canImport(SQLite3)because it pulls message history fromHermesDataService. Also exportsChatDisplayModeandMessageGroup.
Reverted during M0d (wasn't actually portable):
GatewayViewModel.swift— my initial audit grepped for service-type names but missed that this VM callscontext.runHermes(), which is a Mac-target-only extension (ServerContext+Mac.swift). Moving the extension would require draggingHermesFileServicetoo. Left in the Mac target; a later phase can revisit onceHermesFileServicemoves or a different CLI-invocation surface lands.
Discovered while moving:
- The sed transform needs a
s/^@Observable$/@Observable/neutralization — earlier I was accidentally producing@Observable publicwhich is a Swift syntax error (the straypublichas no target). Post-fix, thepubliclives on thepublic final class Xline as intended. - Swift's
Observationframework (for@Observable) needs an explicitimport Observationin ScarfCore files because ScarfCore doesn't pull in SwiftUI. The Mac target getsObservationimplicitly through SwiftUI, but a pure ScarfCore file doesn't.Observationis in the Swift toolchain from 5.9 onwards and compiles fine on Linux too. - Nested enums inside a public enclosing type do not inherit
publicfor theirIdentifiable.idrequirement — that property has to bepublic var idexplicitly when the enum declaresIdentifiableconformance. My sed didn't touch deeper indent levels (nested types at indent 4 inside a class at indent 0) so these had to be fixed by hand. CharacterSet.whitespacesis present in swift-corelibs-foundation on Linux — no guard needed there. The build error I saw was cascaded fromrunHermesnot existing.
Test coverage (M0dViewModelsTests):
ConnectionStatusViewModel: local context always-connected invariant; remote context idle-start;StatusEquatable.LogsViewModel: init defaults,filteredEntriesacross level / search / component filters, nested enumIdentifiableids andloggerPrefixrouting.ProjectsViewModel: init binding to.local.ActivityViewModel,InsightsViewModel,RichChatViewModel: construction + key initial state. Tests wrapped in#if canImport(SQLite3)so they only run on Apple-target CI.MessageGroup.allMessages/toolCallCount(also SQLite3-gated).InsightsPeriod.sinceDateordering.ChatDisplayModecase coverage.
Rules next phases can rely on:
- When moving a file with
@Observable, remember to addimport Observationand to fix the stray@Observable publicthat sed produces. - ViewModels that call
context.runHermes(...)orcontext.openInLocalEditor(...)are not portable to ScarfCore — those methods live inServerContext+Mac.swift. Either leave the VM in the Mac target, or add the specific extension method to ScarfCore with a platform-neutral implementation path. - Types used only from the Mac app target (
GatewayInfo,PlatformInfo, etc.) should NOT be markedpublic— keep them internal. My sed sometimes addspublicto 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.
M0 verification — shipped (commit f399579)
Two real regressions caught by a pre-M1 audit, both silent:
GatewayViewModel.swiftlost itsimport ScarfCoreduring the M0d revert. It referencesServerContextthroughout — would not have compiled in Xcode without the import. Added back.SSHTransport.sshSubprocessEnvironment()regressed in M0b. The original Mac code ranHermesFileService.enrichedEnvironment()which probeszsh -l -ifirst (sources.zshrc— where 1Password / Secretive / manualssh-addexportSSH_AUTH_SOCK), falling back tozsh -l. My M0b replacement used onlyzsh -l, so users with agents in.zshrcwould have seen "Permission denied" (exit 255) on every remote SSH attempt. Fixed by reverting to dependency injection:SSHTransport.environmentEnricheris a(@Sendable () -> [String: String])?static wired at app startup to the Mac's fullHermesFileService.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 thatACPClientspeaks JSON-RPC over. Channel implementations own subprocess / SSH lifecycle; ACPClient never touchesProcess,Pipe, file descriptors, or SSH sessions directly.ProcessACPChannel.swift— Mac/Linux impl, gated on#if !os(iOS)(iOS can't spawn subprocesses). Wraps theProcess+Pipe+ raw POSIXwrite(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 bothDarwin(macOS) andGlibc(Linux CI) via per-platform#if canImporton the raw write.ACPClient.swift— moved from the Mac target and refactored to be channel-agnostic.Process/Pipe/stdinFd/Darwin.writestate replaced with a singlechannel: any ACPChannelreference. Channel creation goes through a caller-providedChannelFactoryclosure so Mac can wireProcessACPChanneland iOS can (in M4+) wire a Citadel-backedSSHExecACPChannelthe same way.
scarf/Core/Services/ACPClient+Mac.swift(new Mac-target sibling file) — carries theACPClient.forMacApp(context:)factory that constructs anACPClientpre-wired with the Mac channel factory. The channel factory closure:- Local: spawns
hermes acpwithHermesFileService.enrichedEnvironment()(full PATH + credentials) minusTERM. - Remote: uses
SSHTransport.makeProcessto getssh -T host -- hermes acp, merging justSSH_AUTH_SOCK/SSH_AGENT_PIDinto the local ssh subprocess's env. - Both paths identical to pre-M1 behavior — no behavior change.
- Local: spawns
ChatViewModelcall sites updated fromACPClient(context:)toACPClient.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 nowasync.ChatViewModelalready 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:
ACPChannelprotocol — mock basic send/receive, write-after-close fails with.writeEndClosed, error-description strings.ACPClientinitial state (disconnected, unhealthy).start()happy path — sendsinitialize, flipsisConnectedon reply.start()with an RPC error reply — surfaces asACPClientError.rpcError.- Mid-flight channel close — pending request resolves with
.processTerminated,isConnectedflips false. session/updatenotification routes into theeventsstream as.messageChunk.- Stderr lines feed
recentStderrring buffer. ACPErrorHint.classifyacross 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.swiftwith its ownACPClient.forIOS(context:session:)factory that returns a Citadel-backedSSHExecACPChannel. 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
MockACPChannelvia protocol dependency injection. If you need to exercise the real subprocess path, do it on the Mac smoke-test side. - The
ChannelFactoryclosure is@Sendableand async. Any per-context setup (env enrichment, SSH handshake) happens inside the factory — not insideACPClient.start(). That keepsstart()boring and portable. ACPClientdoes not handle subprocess spontaneous exits viaterminationHandleranymore — 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 aterminationHandlerinsideProcessACPChannelthat explicitly finishes theincomingcontinuation.
M2 — shipped (on claude/ios-m2-skeleton branch, separate PR from M0+M1)
Scope note: M2 delivers all the code needed for TestFlight
(onboarding, Keychain, Citadel, Dashboard placeholder, unit tests) but
not the scarf-ios.xcodeproj. Hand-editing ~600 lines of pbxproj
from scratch is too high-risk without an iOS SDK to build against, so
the Xcode target is created once in Xcode's UI following the written
instructions in scarf/scarf-ios/SETUP.md. Total setup time: ~5
minutes.
Shipped — ScarfCore additions (testable on Linux):
Security/SSHKey.swift—SSHKeyBundlestruct,SSHKeyStoreprotocol,InMemorySSHKeyStoretest actor.Security/IOSServerConfig.swift—IOSServerConfigstruct (single-server v1),IOSServerConfigStoreprotocol,InMemoryIOSServerConfigStore.toServerContext(id:)bridges to the existingServerContexttype so the rest of ScarfCore's services work against an iOS-configured server unchanged.Security/OnboardingState.swift—OnboardingStepenum,OnboardingKeyChoice,OnboardingServerDetailsValidation, pure functionsOnboardingLogic.validateServerDetails/authorizedKeysLine(for:)/isLikelyValidOpenSSHPrivateKey/parseOpenSSHPublicKeyLine.Security/SSHConnectionTester.swift— protocol +SSHConnectionTestErrorenum +MockSSHConnectionTester.Security/OnboardingViewModel.swift—@Observable @MainActorstate machine. Dependency-injectsSSHKeyStore,IOSServerConfigStore,SSHConnectionTester, and aKeyGeneratorclosure so every transition is testable with mocks.
Shipped — new Packages/ScarfIOS local SPM package:
- Depends on local ScarfCore + remote Citadel
(
.upToNextMinor(from: "0.12.0")— tight pin because Citadel's pre-1.0 authentication-method variant names have changed between minors; explicit bump → review → smoke-test is the flow). KeychainSSHKeyStore.swift— real iOS Keychain impl (kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly, no iCloud sync).UserDefaultsIOSServerConfigStore.swift— JSON in UserDefaults.Ed25519KeyGenerator.swift— mints fresh Ed25519 keypairs via CryptoKit, emits standard OpenSSH public-key lines, stores the private half in a compact custom PEM thatCitadelSSHServicedecodes back intoCurve25519.Signing.PrivateKey.CitadelSSHService.swift—SSHConnectionTesterconformance + key-generation wrapper. Runs a one-shot SSH exec (echo scarf-ok) for the onboarding probe. Every Citadel API call in the file was cross-checked against the0.12.1tag (SSHAuthenticationMethod. ed25519, SSHClientSettings init, SSHHostKeyValidator.acceptAnything, SSHClient.connect, executeCommand, close) — should build clean on first try.
Shipped — scarf/scarf-ios/ iOS app source tree:
App/ScarfIOSApp.swift—@main+RootModelrouting to onboarding / dashboard based on stored state.Onboarding/OnboardingRootView.swift— 8 sub-views, one perOnboardingStep. Validated server-details form, key-source picker, generate / show / import / test / retry / connected.Dashboard/DashboardView.swift— M2 placeholder: connected server details + Disconnect. M3 replaces with real data.
Shipped — scarf/scarf-ios/SETUP.md:
Step-by-step Xcode project creation + troubleshooting. Alan runs this once on a Mac (~5 minutes).
Test coverage:
- ScarfCore (Linux): 26 new tests covering key-bundle memberwise,
both in-memory stores, config-to-ServerContext bridging, all
OnboardingLogicvalidators (empty / whitespace / port range / legacy-RSA rejection), mock tester, and 10 end-to-endOnboardingViewModelpaths (happy, bad import, connection-failure → retry-success, reset). - ScarfIOS (Apple-only): 3 smoke tests for the Ed25519 generator, OpenSSH public-key wire format (byte-length pinned at 51), and corrupted-PEM rejection on round-trip decode.
Total: 88 passing on Linux (62 pre-M2 + 26 new). Apple CI adds the 3 ScarfIOS tests.
Manual validation needed on Mac:
- Xcode project creation per SETUP.md. The
Assets.xcassets/is pre-built (1024×1024 icon copied from the Mac app's set; Scarf-teal AccentColor with light + dark variants) so the target should ship with a real icon on first archive. - Onboarding end-to-end: simulator → physical iPhone via TestFlight
→ real SSH host with the public key added to
authorized_keys. Citadel 0.12.1 APIs were verified in source; no expected Citadel drift.
Rules next phases can rely on:
- M3 adds a Citadel-backed
ServerTransportin ScarfIOS; iOSIOSServerConfig.toServerContext(...).makeTransport()dispatches to it automatically. - M4 adds
SSHExecACPChannelin ScarfIOS; iOS wires theACPClient.ChannelFactoryhook (from M1) to produce it — sibling to Mac'sACPClient+Mac.swift. - iOS is single-server in v1 — don't prematurely generalize the onboarding flow.
- Source tree stays pure SwiftUI + Foundation + ScarfCore + ScarfIOS;
#if canImport(UIKit)fine for pasteboard but keep it minimal.