mirror of
https://github.com/awizemann/scarf.git
synced 2026-05-10 10:36:35 +00:00
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:
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
+3
-3
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user