Merge branch 'main' into scarf-mobile-development (v2.3.0)

Brings the iOS companion branch current with main's v2.2.0, v2.2.1,
and v2.3.0 landings — templates + configuration + catalog (v2.2),
projects folder hierarchy + per-project Sessions sidecar + AGENTS.md
context block + Tool Gateway + Nous Portal OAuth + hermes dashboard
webview (v2.3), and credential-pool OAuth expiry + Nous agent-key
rotation (post-v2.3).

Resolutions:
- ScarfCore Models (HermesConfig, ProjectDashboard, HermesPathSet) —
  forward-ported Tool Gateway's platformToolsets, project-registry v2
  folder/archived fields, and sessionProjectMap path into the moved
  ScarfCore copies. Deleted the old Mac-target paths.
- ScarfCore ModelCatalogService — merged main's overlay-only provider
  support (Nous Portal + OpenAI Codex + Qwen OAuth + …) so iOS and
  macOS pickers see the same provider list. Widened HermesProviderInfo
  / HermesProviderOverlay APIs to public.
- ScarfCore ProjectsViewModel — layered main's v2.3 registry verbs
  (moveProject / renameProject / archive / unarchive / folders) onto
  the M0d-extracted VM, keeping public surface for the Mac target.
- ScarfCore ConnectionStatusViewModel / RichChatViewModel — widened
  `private(set)` to `public private(set)` so Mac views can read
  status, lastSuccess, acp*Tokens, originSessionId, acpCommands,
  quickCommands.
- ScarfCore HermesConfig+YAML — added platform_toolsets parsing to
  the iOS YAML path so config.yaml round-trips the same as macOS.
- RichChatViewModel quick-commands — inlined the Mac-target's
  QuickCommandsViewModel.loadQuickCommands into ScarfCore using the
  existing HermesYAML parser, removing the cross-module dependency.
- HealthViewModel — took main's Tool Gateway + hermes-dashboard
  webview sections wholesale; file stays macOS-only.
- ChatView auto-merge — confirmed resume-session fix (5ae8db2) is
  present; made the PendingPermission.id extension public to satisfy
  Identifiable conformance across module boundary.
- ProjectSessionsViewModel — moved back to the Mac target since it
  depends on SessionAttributionService (also Mac-target). Defer the
  iOS SFTP parity of attribution to M7.
- LocalTransport.runProcess + SSHTransport.runLocal — wrapped the
  Process body in `#if !os(iOS)` with an explicit throw on iOS so
  ScarfCore compiles under the iOS SDK. iOS uses
  CitadelServerTransport (ScarfIOS) as the real implementation.
- CitadelServerTransport — updated `sftp.remove(atPath:)` to
  `sftp.remove(at:)` for the current Citadel API shape.

Cross-module imports: added `import ScarfCore` to 25 Mac-target files
that consumed ScarfCore types (13 v2.3 additions + 12 post-merge
errors caught by MemberImportVisibility: Settings tabs, SidebarView,
MCPServerEditorView, TemplateExportSheet, tests).

Version lockstep: bumped `scarf mobile` target to
MARKETING_VERSION=2.3.0, CURRENT_PROJECT_VERSION=25 to match main.

Builds green for both schemes:
- swift build (ScarfCore standalone)
- xcodebuild scarf -destination platform=macOS
- xcodebuild 'scarf mobile' -destination generic/platform=iOS

Deferred to M7 (iOS SFTP parity):
- NousSubscriptionService auth.json reader
- ProjectAgentContextService AGENTS.md write-before-chat
- SessionAttributionService session_project_map.json read/watch
All currently Mac-target-gated; iOS still builds without them.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Alan Wizemann
2026-04-24 10:53:23 +02:00
98 changed files with 5728 additions and 195 deletions
@@ -627,6 +627,13 @@ public struct HermesConfig: Sendable {
public var prefillMessagesFile: String
public var skillsExternalDirs: [String]
/// Per-platform toolset allowlists as written by `hermes setup tools`.
/// Keyed by platform (`cli`, `slack`, ) to enabled toolset identifiers
/// (`browser`, `messaging`, `nous-tools`, ). Hermes v0.10.0's Tool
/// Gateway; enabling `nous-tools` here is how subscribers opt-in per
/// platform. Scarf reads for display; edits go through Hermes CLI.
public var platformToolsets: [String: [String]]
// Grouped blocks
public var display: DisplaySettings
public var terminal: TerminalSettings
@@ -686,6 +693,7 @@ public struct HermesConfig: Sendable {
cronWrapResponse: Bool,
prefillMessagesFile: String,
skillsExternalDirs: [String],
platformToolsets: [String: [String]],
display: DisplaySettings,
terminal: TerminalSettings,
browser: BrowserSettings,
@@ -742,6 +750,7 @@ public struct HermesConfig: Sendable {
self.cronWrapResponse = cronWrapResponse
self.prefillMessagesFile = prefillMessagesFile
self.skillsExternalDirs = skillsExternalDirs
self.platformToolsets = platformToolsets
self.display = display
self.terminal = terminal
self.browser = browser
@@ -799,6 +808,7 @@ public struct HermesConfig: Sendable {
cronWrapResponse: true,
prefillMessagesFile: "",
skillsExternalDirs: [],
platformToolsets: [:],
display: .empty,
terminal: .empty,
browser: .empty,
@@ -65,6 +65,10 @@ public struct HermesPathSet: Sendable, Hashable {
public nonisolated var gatewayLog: String { home + "/logs/gateway.log" }
public nonisolated var scarfDir: String { home + "/scarf" }
public nonisolated var projectsRegistry: String { scarfDir + "/projects.json" }
/// Maps Hermes session IDs to the Scarf project path a chat was
/// started for. Scarf-owned; Hermes never touches this file.
public nonisolated var sessionProjectMap: String { scarfDir + "/session_project_map.json" }
public nonisolated var mcpTokensDir: String { home + "/mcp-tokens" }
// MARK: - Binary resolution
@@ -17,15 +17,51 @@ public struct ProjectEntry: Codable, Sendable, Identifiable, Hashable {
public let name: String
public let path: String
/// Folder path for sidebar grouping. `nil` means top-level.
/// v2.3 registry schema v2; v2.2 files decode cleanly as `nil`.
public var folder: String?
/// Soft-archive flag. Archived projects are hidden from the sidebar
/// by default; non-destructive. v2.3 schema v2; defaults to `false`.
public var archived: Bool
public init(
name: String,
path: String
path: String,
folder: String? = nil,
archived: Bool = false
) {
self.name = name
self.path = path
self.folder = folder
self.archived = archived
}
public var dashboardPath: String { path + "/.scarf/dashboard.json" }
// MARK: - Codable (custom for backward compat)
private enum CodingKeys: String, CodingKey {
case name, path, folder, archived
}
public init(from decoder: Decoder) throws {
let c = try decoder.container(keyedBy: CodingKeys.self)
self.name = try c.decode(String.self, forKey: .name)
self.path = try c.decode(String.self, forKey: .path)
self.folder = try c.decodeIfPresent(String.self, forKey: .folder)
self.archived = try c.decodeIfPresent(Bool.self, forKey: .archived) ?? false
}
public func encode(to encoder: Encoder) throws {
var c = encoder.container(keyedBy: CodingKeys.self)
try c.encode(name, forKey: .name)
try c.encode(path, forKey: .path)
try c.encodeIfPresent(folder, forKey: .folder)
if archived {
try c.encode(archived, forKey: .archived)
}
}
}
// MARK: - Dashboard
@@ -205,6 +205,16 @@ public extension HermesConfig {
replyPrefix: str("whatsapp.reply_prefix")
)
// `platform_toolsets.<platform>` is a dict of lists in config.yaml
// parseNestedYAML flattens nested lists into dotted-path keys. Pull
// every key under the prefix and strip it.
var platformToolsets: [String: [String]] = [:]
for (key, items) in lists where key.hasPrefix("platform_toolsets.") {
let platform = String(key.dropFirst("platform_toolsets.".count))
guard !platform.isEmpty else { continue }
platformToolsets[platform] = items
}
// Home Assistant lives under `platforms.homeassistant.extra.*`.
let homeAssistant = HomeAssistantSettings(
watchDomains: lists["platforms.homeassistant.extra.watch_domains"] ?? [],
@@ -252,6 +262,7 @@ public extension HermesConfig {
cronWrapResponse: bool("cron.wrap_response", default: true),
prefillMessagesFile: str("prefill_messages_file"),
skillsExternalDirs: lists["skills.external_dirs"] ?? [],
platformToolsets: platformToolsets,
display: display,
terminal: terminal,
browser: browser,
@@ -70,19 +70,32 @@ public struct HermesProviderInfo: Sendable, Identifiable, Hashable {
public let envVars: [String] // e.g. ["ANTHROPIC_API_KEY"]
public let docURL: String?
public let modelCount: Int
/// True when this provider is surfaced only by the Hermes overlay list
/// i.e. no entry in `models_dev_cache.json`. The picker renders a
/// different right-column affordance (subscription CTA or free-form
/// model entry).
public let isOverlay: Bool
/// True for providers whose tool access is subscription-gated rather
/// than BYO API key. Nous Portal is the only such provider as of
/// hermes-agent v0.10.0.
public let subscriptionGated: Bool
public init(
providerID: String,
providerName: String,
envVars: [String],
docURL: String?,
modelCount: Int
modelCount: Int,
isOverlay: Bool = false,
subscriptionGated: Bool = false
) {
self.providerID = providerID
self.providerName = providerName
self.envVars = envVars
self.docURL = docURL
self.modelCount = modelCount
self.isOverlay = isOverlay
self.subscriptionGated = subscriptionGated
}
}
@@ -111,20 +124,49 @@ public struct ModelCatalogService: Sendable {
self.transport = LocalTransport()
}
/// All providers, sorted by display name.
/// All providers, sorted with subscription-gated providers first (Nous
/// Portal), then alphabetical by display name. Merges the models.dev
/// cache with `Self.overlayOnlyProviders` so Hermes-injected providers
/// (Nous Portal, OpenAI Codex, ) appear in the picker even when
/// they're absent from `models_dev_cache.json`.
public func loadProviders() -> [HermesProviderInfo] {
guard let catalog = loadCatalog() else { return [] }
return catalog
.map { (id, p) in
HermesProviderInfo(
providerID: id,
providerName: p.name ?? id,
envVars: p.env ?? [],
docURL: p.doc,
modelCount: p.models?.count ?? 0
)
let catalog = loadCatalog() ?? [:]
var byID: [String: HermesProviderInfo] = [:]
for (id, p) in catalog {
byID[id] = HermesProviderInfo(
providerID: id,
providerName: p.name ?? id,
envVars: p.env ?? [],
docURL: p.doc,
modelCount: p.models?.count ?? 0,
isOverlay: false,
subscriptionGated: false
)
}
for (id, overlay) in Self.overlayOnlyProviders where byID[id] == nil {
byID[id] = HermesProviderInfo(
providerID: id,
providerName: overlay.displayName,
envVars: [],
docURL: overlay.docURL,
modelCount: 0,
isOverlay: true,
subscriptionGated: overlay.subscriptionGated
)
}
return byID.values.sorted { lhs, rhs in
if lhs.subscriptionGated != rhs.subscriptionGated {
return lhs.subscriptionGated
}
.sorted { $0.providerName.localizedCaseInsensitiveCompare($1.providerName) == .orderedAscending }
return lhs.providerName.localizedCaseInsensitiveCompare(rhs.providerName) == .orderedAscending
}
}
/// Overlay metadata for a provider that isn't in the models.dev catalog
/// Scarf needs to surface these so the picker matches `hermes model` on
/// the CLI.
public func overlayMetadata(for providerID: String) -> HermesProviderOverlay? {
Self.overlayOnlyProviders[providerID]
}
/// Models for one provider, sorted by release date (newest first), then name.
@@ -167,7 +209,9 @@ public struct ModelCatalogService: Sendable {
providerName: p.name ?? providerID,
envVars: p.env ?? [],
docURL: p.doc,
modelCount: p.models?.count ?? 0
modelCount: p.models?.count ?? 0,
isOverlay: false,
subscriptionGated: false
)
}
}
@@ -181,13 +225,45 @@ public struct ModelCatalogService: Sendable {
providerName: p.name ?? prefix,
envVars: p.env ?? [],
docURL: p.doc,
modelCount: p.models?.count ?? 0
modelCount: p.models?.count ?? 0,
isOverlay: false,
subscriptionGated: false
)
}
}
return nil
}
/// Look up a provider by ID, falling back to overlays when the cache has
/// no entry. Use this when resolving a stored `model.provider` to display
/// metadata `nous` and other overlay-only IDs never appear in the
/// cache, so a plain catalog lookup returns nil for them.
public func providerByID(_ providerID: String) -> HermesProviderInfo? {
if let catalog = loadCatalog(), let p = catalog[providerID] {
return HermesProviderInfo(
providerID: providerID,
providerName: p.name ?? providerID,
envVars: p.env ?? [],
docURL: p.doc,
modelCount: p.models?.count ?? 0,
isOverlay: false,
subscriptionGated: false
)
}
if let overlay = Self.overlayOnlyProviders[providerID] {
return HermesProviderInfo(
providerID: providerID,
providerName: overlay.displayName,
envVars: [],
docURL: overlay.docURL,
modelCount: 0,
isOverlay: true,
subscriptionGated: overlay.subscriptionGated
)
}
return nil
}
/// Look up a specific model by provider + ID. Returns nil if not in the
/// catalog (e.g., free-typed custom model).
public func model(providerID: String, modelID: String) -> HermesModelInfo? {
@@ -253,4 +329,93 @@ public struct ModelCatalogService: Sendable {
let context: Int?
let output: Int?
}
// MARK: - Hermes overlay providers
/// The six providers Hermes surfaces via `hermes model` that have no
/// entry in `models_dev_cache.json` (models.dev doesn't mirror them).
/// Mirrors the overlay-only subset of `HERMES_OVERLAYS` in
/// `hermes-agent/hermes_cli/providers.py`. The other ~19 overlay entries
/// already ship in the cache and only add augmentation (base-URL
/// override, extra env vars) that Scarf doesn't currently display.
///
/// Keep this in sync with the Python side on Hermes version bumps.
static let overlayOnlyProviders: [String: HermesProviderOverlay] = [
"nous": HermesProviderOverlay(
displayName: "Nous Portal",
baseURL: "https://inference-api.nousresearch.com/v1",
authType: .oauthDeviceCode,
subscriptionGated: true,
docURL: "https://hermes-agent.nousresearch.com/docs/user-guide/setup/nous-portal"
),
"openai-codex": HermesProviderOverlay(
displayName: "OpenAI Codex",
baseURL: "https://chatgpt.com/backend-api/codex",
authType: .oauthExternal,
subscriptionGated: false,
docURL: nil
),
"qwen-oauth": HermesProviderOverlay(
displayName: "Qwen (OAuth)",
baseURL: "https://portal.qwen.ai/v1",
authType: .oauthExternal,
subscriptionGated: false,
docURL: nil
),
"google-gemini-cli": HermesProviderOverlay(
displayName: "Google Gemini CLI",
baseURL: "cloudcode-pa://google",
authType: .oauthExternal,
subscriptionGated: false,
docURL: nil
),
"copilot-acp": HermesProviderOverlay(
displayName: "GitHub Copilot ACP",
baseURL: "acp://copilot",
authType: .externalProcess,
subscriptionGated: false,
docURL: nil
),
"arcee": HermesProviderOverlay(
displayName: "Arcee",
baseURL: "https://api.arcee.ai/api/v1",
authType: .apiKey,
subscriptionGated: false,
docURL: nil
),
]
}
/// Scarf-side mirror of `HermesOverlay` from hermes-agent's
/// `hermes_cli/providers.py`. Describes a provider that isn't in the
/// models.dev catalog.
public struct HermesProviderOverlay: Sendable {
public let displayName: String
public let baseURL: String?
public let authType: AuthType
/// True for providers whose tool access is subscription-gated rather than
/// BYO-API-key. Nous Portal is the only `true` entry today.
public let subscriptionGated: Bool
public let docURL: String?
public init(
displayName: String,
baseURL: String?,
authType: AuthType,
subscriptionGated: Bool,
docURL: String?
) {
self.displayName = displayName
self.baseURL = baseURL
self.authType = authType
self.subscriptionGated = subscriptionGated
self.docURL = docURL
}
public enum AuthType: String, Sendable {
case apiKey
case oauthDeviceCode
case oauthExternal
case externalProcess
}
}
@@ -108,6 +108,11 @@ public struct LocalTransport: ServerTransport {
// MARK: - Processes
public func runProcess(executable: String, args: [String], stdin: Data?, timeout: TimeInterval?) throws -> ProcessResult {
#if os(iOS)
// iOS can't spawn processes. Callers on iOS use `CitadelServerTransport`
// (from the ScarfIOS package) instead; reaching here is a wiring bug.
throw TransportError.other(message: "LocalTransport.runProcess is unavailable on iOS")
#else
let proc = Process()
proc.executableURL = URL(fileURLWithPath: executable)
proc.arguments = args
@@ -148,6 +153,7 @@ public struct LocalTransport: ServerTransport {
try? stderrPipe.fileHandleForReading.close()
try? stdinPipe.fileHandleForWriting.close()
return ProcessResult(exitCode: proc.terminationStatus, stdout: out, stderr: err)
#endif
}
#if !os(iOS)
@@ -637,6 +637,11 @@ public struct SSHTransport: ServerTransport {
/// SSH-specific code paths live on this type and we want all Process
/// lifecycle in one place per transport.
nonisolated private func runLocal(executable: String, args: [String], stdin: Data?, timeout: TimeInterval?) throws -> ProcessResult {
#if os(iOS)
// iOS uses `CitadelServerTransport` instead of spawning ssh/scp
// binaries. Reaching here from iOS is a wiring bug.
throw TransportError.other(message: "SSHTransport.runLocal is unavailable on iOS")
#else
ensureControlDir()
let proc = Process()
proc.executableURL = URL(fileURLWithPath: executable)
@@ -682,5 +687,6 @@ public struct SSHTransport: ServerTransport {
try? stderrPipe.fileHandleForReading.close()
try? stdinPipe.fileHandleForWriting.close()
return ProcessResult(exitCode: proc.terminationStatus, stdout: out, stderr: err)
#endif
}
}
@@ -30,15 +30,15 @@ public final class ConnectionStatusViewModel {
case error(message: String, stderr: String)
}
private(set) var status: Status = .idle
public private(set) var status: Status = .idle
/// Timestamp of the last successful probe. Used by the UI to show how
/// fresh the status indicator is ("just now", "2m ago").
private(set) var lastSuccess: Date?
public private(set) var lastSuccess: Date?
/// Number of consecutive probe failures. Surfaced as a yellow "Reconnecting"
/// state for the first failure (silent retry), then promoted to red after
/// `consecutiveFailureThreshold` failures so flaky connections don't
/// flap the indicator on every dropped packet.
private(set) var consecutiveFailures = 0
public private(set) var consecutiveFailures = 0
private let consecutiveFailureThreshold = 2
public let context: ServerContext
@@ -30,7 +30,7 @@ public final class LogsViewModel {
switch self {
case .agent: return "Agent"
case .errors: return "Errors"
case .gateway: return "Gateway"
case .gateway: return "Messaging Gateway"
}
}
#endif
@@ -58,7 +58,7 @@ public final class LogsViewModel {
public var displayName: LocalizedStringResource {
switch self {
case .all: return "All"
case .gateway: return "Gateway"
case .gateway: return "Messaging Gateway"
case .agent: return "Agent"
case .tools: return "Tools"
case .cli: return "CLI"
@@ -73,6 +73,88 @@ public final class ProjectsViewModel {
}
}
// MARK: - v2.3 registry verbs (folder / archive / rename)
/// Move a project into a folder. `nil` folder returns the project
/// to the top level. No-op when the target already matches.
public func moveProject(_ project: ProjectEntry, toFolder folder: String?) {
mutateEntry(project) { $0.folder = folder }
}
/// Rename a project. `name` is the registry's unique key + the
/// Identifiable id; rejects renames that would collide with an
/// existing project's name. Returns true on success.
@discardableResult
public func renameProject(_ project: ProjectEntry, to newName: String) -> Bool {
let trimmed = newName.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else { return false }
guard trimmed != project.name else { return true }
var registry = service.loadRegistry()
guard !registry.projects.contains(where: { $0.name == trimmed }) else { return false }
guard let index = registry.projects.firstIndex(where: { $0.name == project.name }) else { return false }
let old = registry.projects[index]
registry.projects[index] = ProjectEntry(
name: trimmed,
path: old.path,
folder: old.folder,
archived: old.archived
)
do {
try service.saveRegistry(registry)
} catch {
logger.error("renameProject couldn't persist registry: \(error.localizedDescription, privacy: .public)")
return false
}
projects = registry.projects
if selectedProject?.name == project.name {
selectedProject = registry.projects[index]
}
return true
}
/// Soft-archive a project. Stays on disk + in the registry; the
/// sidebar just hides it unless `showArchived` is on.
public func archiveProject(_ project: ProjectEntry) {
mutateEntry(project) { $0.archived = true }
if selectedProject?.name == project.name {
selectedProject = nil
dashboard = nil
}
}
/// Restore an archived project to the default view.
public func unarchiveProject(_ project: ProjectEntry) {
mutateEntry(project) { $0.archived = false }
}
/// Distinct folder labels across the current project set, sorted
/// alphabetically. Drives the sidebar's DisclosureGroups + the
/// Move-to-Folder sheet's existing-folder list.
public var folders: [String] {
let set = Set(projects.compactMap(\.folder).filter { !$0.isEmpty })
return set.sorted()
}
// MARK: - Helpers
private func mutateEntry(_ project: ProjectEntry, _ mutation: (inout ProjectEntry) -> Void) {
var registry = service.loadRegistry()
guard let index = registry.projects.firstIndex(where: { $0.name == project.name }) else { return }
var entry = registry.projects[index]
mutation(&entry)
registry.projects[index] = entry
do {
try service.saveRegistry(registry)
} catch {
logger.error("mutateEntry couldn't persist registry for \(project.name, privacy: .public): \(error.localizedDescription, privacy: .public)")
return
}
projects = registry.projects
if selectedProject?.name == project.name {
selectedProject = entry
}
}
public func refreshDashboard() {
guard let project = selectedProject else { return }
loadDashboard(for: project)
@@ -50,15 +50,15 @@ public final class RichChatViewModel {
public var scrollTrigger = UUID()
// Cumulative ACP token tracking (ACP returns tokens per prompt but DB has none)
private(set) var acpInputTokens = 0
private(set) var acpOutputTokens = 0
private(set) var acpThoughtTokens = 0
private(set) var acpCachedReadTokens = 0
public private(set) var acpInputTokens = 0
public private(set) var acpOutputTokens = 0
public private(set) var acpThoughtTokens = 0
public private(set) var acpCachedReadTokens = 0
/// Slash commands advertised by the ACP server via `available_commands_update`.
private(set) var acpCommands: [HermesSlashCommand] = []
public private(set) var acpCommands: [HermesSlashCommand] = []
/// User-defined commands parsed from `config.yaml` `quick_commands`.
private(set) var quickCommands: [HermesSlashCommand] = []
public private(set) var quickCommands: [HermesSlashCommand] = []
/// Merged list, ACP-first, de-duplicated by name.
public var availableCommands: [HermesSlashCommand] {
@@ -81,7 +81,7 @@ public final class RichChatViewModel {
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?
public private(set) var originSessionId: String?
private var nextLocalId = -1
private var streamingAssistantText = ""
private var streamingThinkingText = ""
@@ -253,13 +253,13 @@ public final class RichChatViewModel {
public func loadQuickCommands() {
let ctx = context
Task.detached { [weak self] in
let loaded = QuickCommandsViewModel.loadQuickCommands(context: ctx)
let mapped = loaded.map { qc -> HermesSlashCommand in
let truncated = qc.command.count > 60
? String(qc.command.prefix(60)) + ""
: qc.command
let loaded = Self.loadQuickCommands(context: ctx)
let mapped = loaded.map { (name, command) -> HermesSlashCommand in
let truncated = command.count > 60
? String(command.prefix(60)) + ""
: command
return HermesSlashCommand(
name: qc.name,
name: name,
description: "Run: \(truncated)",
argumentHint: nil,
source: .quickCommand
@@ -271,6 +271,33 @@ public final class RichChatViewModel {
}
}
/// Parse `quick_commands` from `<context>/config.yaml`. Returns
/// `[(name, command)]` for every well-formed `type: exec` entry.
/// Mac-side `QuickCommandsViewModel` uses a richer model + adds
/// an `isDangerous` check; here we only need the slash-menu
/// projection, so we keep the parser minimal and ScarfCore-local.
nonisolated static func loadQuickCommands(context: ServerContext) -> [(name: String, command: String)] {
guard let yaml = context.readText(context.paths.configYAML) else { return [] }
let parsed = HermesYAML.parseNestedYAML(yaml)
var byName: [String: (type: String, command: String)] = [:]
for (key, value) in parsed.values where key.hasPrefix("quick_commands.") {
let parts = key.split(separator: ".", maxSplits: 2, omittingEmptySubsequences: false)
guard parts.count == 3 else { continue }
let name = String(parts[1])
let field = String(parts[2])
var existing = byName[name] ?? (type: "exec", command: "")
let stripped = HermesYAML.stripYAMLQuotes(value)
if field == "type" { existing.type = stripped }
if field == "command" { existing.command = stripped }
byName[name] = existing
}
return byName.compactMap { (name, entry) in
guard entry.type == "exec", !entry.command.isEmpty else { return nil }
return (name: name, command: entry.command)
}
.sorted { $0.name < $1.name }
}
private func appendMessageChunk(text: String) {
streamingAssistantText += text
upsertStreamingMessage()
@@ -302,7 +302,7 @@ public final class CitadelServerTransport: ServerTransport, @unchecked Sendable
// Parallel to LocalTransport: no-op if the file doesn't exist.
let exists = try await asyncFileExists(path)
if !exists { return }
try await sftp.remove(atPath: path)
try await sftp.remove(at: path)
}
private func asyncRunProcess(