M7: pass-1 quickfixes (PATH, SFTP tilde, SOUL.md, ScarfGo bundle id)

Four fixes surfaced during the 2026-04-24 pass-1 smoke test of the
iOS companion against a local Hermes host. All discovered while
collaboratively driving the Simulator + tailing os.Logger.

1. ACPClient+iOS.swift — ACP exec command prepends common install
   paths to PATH. SSH RFC 4254 exec uses a non-interactive shell
   whose PATH is sshd's default (`/usr/bin:/bin:/usr/sbin:/sbin`);
   `.zshrc` doesn't source, so `~/.local/bin/hermes` (pipx default)
   was invisible and the agent died with "command not found: hermes".
   Mirrors HermesPathSet.hermesBinaryCandidates (the Mac-side local
   probe list) inline in the exec command.

2. CitadelServerTransport.swift — SFTP tilde expansion. Every
   Memory/Cron/Skills/Settings read used paths like
   `~/.hermes/memories/MEMORY.md`. SFTP treats `~` as a literal
   character, not a home-dir alias — so every read silently returned
   nil and the UIs showed "empty file" instead of the real content.
   Added a per-connection cached `resolveHome()` + a `resolveSFTPPath`
   helper applied to every SFTP entry point (readFile / writeFile /
   fileExists / stat / listDirectory / createDirectory / removeFile).
   This was the single biggest blocker on pass-1.

3. IOSMemoryViewModel.swift + MemoryListView.swift — SOUL.md added
   as a third Memory row. SOUL.md lives in the Personalities feature
   on Mac; folding it into Memory on iOS matches the on-the-go scope
   (all agent prompt inputs in one place). Uses the existing
   `HermesPathSet.soulMD` path; no new plumbing.

4. project.pbxproj — bundle id rename for ScarfGo branding:
   - CFBundleDisplayName: "Scarf Mobile" -> "ScarfGo"
   - PRODUCT_BUNDLE_IDENTIFIER: com.scarf-mobile.app -> com.scarfgo.app
   Xcode target name stays "scarf mobile" internally (rename surgery
   isn't worth the PBX churn). Home-screen label + bundle id now
   match the product name.

Both schemes build green. Phase 1 starter commit — per-item M7
fixes follow in subsequent commits.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Alan Wizemann
2026-04-24 13:05:29 +02:00
parent 19b4ba9995
commit f41ac1c84e
5 changed files with 92 additions and 27 deletions
@@ -15,7 +15,7 @@ import Observation
@Observable
@MainActor
public final class IOSMemoryViewModel {
public enum Kind: Sendable, Equatable {
public enum Kind: Sendable, Equatable, CaseIterable {
/// `~/.hermes/memories/MEMORY.md` the agent's persistent
/// memory. Visible (and editable) to the agent at every
/// session start.
@@ -23,12 +23,18 @@ public final class IOSMemoryViewModel {
/// `~/.hermes/memories/USER.md` user-profile notes the
/// agent reads but (by default) does not write.
case user
/// `~/.hermes/SOUL.md` the agent's persona / character
/// (voice, tone, style). Lives in the Personalities feature
/// on macOS; on iOS we fold it into Memory so the whole
/// "edit the agent's prompt inputs" surface is in one place.
case soul
/// Heading shown in the UI.
public var displayName: String {
switch self {
case .memory: return "MEMORY.md"
case .user: return "USER.md"
case .soul: return "SOUL.md"
}
}
@@ -37,6 +43,7 @@ public final class IOSMemoryViewModel {
switch self {
case .memory: return "brain.head.profile"
case .user: return "person.crop.square"
case .soul: return "sparkles"
}
}
@@ -47,16 +54,19 @@ public final class IOSMemoryViewModel {
return "Agent's persistent memory. Appears in every session prompt."
case .user:
return "Notes about you. Read by the agent but not modified automatically."
case .soul:
return "Agent persona — voice, tone, personality."
}
}
/// Resolve the remote path for this memory file on the
/// given context. `ServerContext.paths` exposes both
/// `memoryMD` and `userMD` directly.
/// given context. `ServerContext.paths` exposes
/// `memoryMD`, `userMD`, and `soulMD` directly.
public func path(on context: ServerContext) -> String {
switch self {
case .memory: return context.paths.memoryMD
case .user: return context.paths.userMD
case .soul: return context.paths.soulMD
}
}
}
@@ -54,12 +54,22 @@ public extension ACPClient {
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"
// Command to spawn. `hermes acp` is the ACP entry point.
//
// SSH `exec` (RFC 4254) runs a non-interactive, non-login shell
// whose PATH is the sshd default (`/usr/bin:/bin:/usr/sbin:/sbin`).
// Common Hermes install locations like `~/.local/bin` (pipx),
// `/opt/homebrew/bin`, and `/usr/local/bin` aren't on that PATH,
// and `-l` alone doesn't help because most users add PATH exports
// to `.zshrc` which is only sourced for interactive shells.
//
// We mirror `HermesPathSet.hermesBinaryCandidates` (the Mac-side
// local probe list) by prepending all four common locations to
// PATH before `exec`-ing hermes. Binary-clean stdio (JSON-RPC on
// stdin/stdout) is preserved because `exec` replaces the shell
// process no intermediate layer buffers the bytes.
let hermesCmd = context.paths.hermesBinary + " acp"
let command = "PATH=\"$HOME/.local/bin:/opt/homebrew/bin:/usr/local/bin:$HOME/.hermes/bin:$PATH\" exec \(hermesCmd)"
return try await SSHExecACPChannel(
client: client,
@@ -216,9 +216,23 @@ public final class CitadelServerTransport: ServerTransport, @unchecked Sendable
// MARK: - Async primitives (package-private, testable through subclassing)
/// Rewrite a leading `~/` or bare `~` to the probed absolute
/// `$HOME`. SFTP (per RFC 4254 / SFTP protocol) does NOT expand
/// tildes a path like `~/.hermes/memories/MEMORY.md` is treated
/// as a relative path with a literal `~` directory name, so every
/// SFTP op silently fails to locate the file. Normalize here before
/// handing paths to Citadel's SFTP client.
private func resolveSFTPPath(_ path: String) async throws -> String {
guard path == "~" || path.hasPrefix("~/") else { return path }
let home = try await connectionHolder.resolveHome()
if path == "~" { return home }
return home + "/" + path.dropFirst(2)
}
private func asyncReadFile(_ path: String) async throws -> Data {
let sftp = try await connectionHolder.sftp()
return try await sftp.withFile(filePath: path, flags: [.read]) { file in
let resolved = try await resolveSFTPPath(path)
return try await sftp.withFile(filePath: resolved, flags: [.read]) { file in
let buf = try await file.readAll()
return Data(buffer: buf)
}
@@ -226,9 +240,10 @@ public final class CitadelServerTransport: ServerTransport, @unchecked Sendable
private func asyncWriteFile(_ path: String, data: Data) async throws {
let sftp = try await connectionHolder.sftp()
let resolved = try await resolveSFTPPath(path)
let byteBuffer = ByteBuffer(bytes: data)
try await sftp.withFile(
filePath: path,
filePath: resolved,
flags: [.write, .create, .truncate]
) { file in
try await file.write(byteBuffer, at: 0)
@@ -237,8 +252,9 @@ public final class CitadelServerTransport: ServerTransport, @unchecked Sendable
private func asyncFileExists(_ path: String) async throws -> Bool {
let sftp = try await connectionHolder.sftp()
let resolved = try await resolveSFTPPath(path)
do {
_ = try await sftp.getAttributes(at: path)
_ = try await sftp.getAttributes(at: resolved)
return true
} catch {
return false
@@ -247,8 +263,9 @@ public final class CitadelServerTransport: ServerTransport, @unchecked Sendable
private func asyncStat(_ path: String) async throws -> FileStat? {
let sftp = try await connectionHolder.sftp()
let resolved = try await resolveSFTPPath(path)
do {
let attrs = try await sftp.getAttributes(at: path)
let attrs = try await sftp.getAttributes(at: resolved)
let size = attrs.size.map { Int64($0) } ?? 0
let mtime = attrs.accessModificationTime?.modificationTime ?? Date(timeIntervalSince1970: 0)
// SFTPFileAttributes doesn't expose a "type" field directly;
@@ -265,7 +282,8 @@ public final class CitadelServerTransport: ServerTransport, @unchecked Sendable
private func asyncListDirectory(_ path: String) async throws -> [String] {
let sftp = try await connectionHolder.sftp()
let listing = try await sftp.listDirectory(atPath: path)
let resolved = try await resolveSFTPPath(path)
let listing = try await sftp.listDirectory(atPath: resolved)
// Flatten all components across the response batches, strip the
// conventional "." / ".." entries to match
// `FileManager.contentsOfDirectory` behaviour.
@@ -275,12 +293,13 @@ public final class CitadelServerTransport: ServerTransport, @unchecked Sendable
private func asyncCreateDirectory(_ path: String) async throws {
let sftp = try await connectionHolder.sftp()
let resolved = try await resolveSFTPPath(path)
// `createDirectory` at Citadel layer fails if the dir exists;
// we want mkdir -p semantics so we walk the path and create
// each component. Absolute paths only the iOS app never
// passes a relative path.
let components = path.split(separator: "/", omittingEmptySubsequences: true).map(String.init)
var cursor = path.hasPrefix("/") ? "" : ""
let components = resolved.split(separator: "/", omittingEmptySubsequences: true).map(String.init)
var cursor = resolved.hasPrefix("/") ? "" : ""
for component in components {
cursor += "/" + component
do {
@@ -299,10 +318,11 @@ public final class CitadelServerTransport: ServerTransport, @unchecked Sendable
private func asyncRemoveFile(_ path: String) async throws {
let sftp = try await connectionHolder.sftp()
let resolved = try await resolveSFTPPath(path)
// Parallel to LocalTransport: no-op if the file doesn't exist.
let exists = try await asyncFileExists(path)
let exists = try await asyncFileExists(resolved)
if !exists { return }
try await sftp.remove(at: path)
try await sftp.remove(at: resolved)
}
private func asyncRunProcess(
@@ -449,6 +469,12 @@ private actor ConnectionHolder {
private var sshClient: SSHClient?
private var sftpClient: SFTPClient?
/// Resolved absolute `$HOME` on the remote host. Probed once per
/// connection via `echo $HOME` over SSH exec, then memoized. Used
/// to rewrite `~/` SFTP paths (SFTP does NOT expand tildes it
/// treats them as literal characters, so `~/.hermes/` reads fail
/// unless we rewrite to the absolute path client-side).
private var resolvedHome: String?
init(
contextID: ServerID,
@@ -460,6 +486,22 @@ private actor ConnectionHolder {
self.keyProvider = keyProvider
}
/// Probe + cache the remote user's home directory. Returns the
/// absolute path (e.g. `/Users/alan`). Falls back to the original
/// tilde-form on probe failure so callers get a best-effort path
/// rather than a hard error; those callers will surface the real
/// failure via the subsequent SFTP op.
func resolveHome() async throws -> String {
if let cached = resolvedHome { return cached }
let client = try await ssh()
let buffer = try await client.executeCommand("echo $HOME")
let raw = buffer.getString(at: 0, length: buffer.readableBytes) ?? ""
let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines)
let home = trimmed.isEmpty ? "~" : trimmed
resolvedHome = home
return home
}
func ssh() async throws -> SSHClient {
if let existing = sshClient, existing.isConnected {
return existing