mirror of
https://github.com/awizemann/scarf.git
synced 2026-05-10 10:36:35 +00:00
5920923d92
The multi-window / multi-server / remote-SSH work that landed in
00ca722 (feat: multi-window + remote SSH server support (Phases 0-4))
was feature-complete but accumulated rough edges during dogfooding
against a remote Mac mini. This commit finishes the 2.0 release:
correctness fixes on remote, a chat-view UX overhaul, and a Swift 6
complete-concurrency sweep across the service layer.
Correctness on remote
- Kill the WAL-error spam: snapshotSQLite now runs `PRAGMA
journal_mode=DELETE` on the remote temp DB before scp, so the
pulled file is self-contained. Open remote snapshots with
`file:...?immutable=1` URI as defense-in-depth, and drop the
pointless `PRAGMA journal_mode=WAL` from HermesDataService.open.
- loadSessionHistory and refreshMessages now force a fresh snapshot
via refresh(), so resuming a session on a remote shows messages
persisted since launch (previously stuck on the first snapshot).
- New SnapshotCoordinator actor dedupes concurrent snapshotSQLite
calls per ServerID — Dashboard + Sessions + Activity no longer
issue three parallel SSH backups for the same fetch.
- ACP cwd comes from the remote's $HOME (probed once, cached per
server in UserHomeCache), not the local Mac's NSHomeDirectory().
- Typing into a blank Chat always creates a new session. The old
auto-resume-most-recent fallback was picking up cron-spawned
sessions that Hermes had already GC'd, producing silent prompt
failures.
- handlePromptComplete surfaces non-success stopReasons ("refusal",
"error", "max_tokens") as a system message so failed prompts no
longer sit under a forever-spinning "Agent working…".
Chat UX
- Replace six racing onChange-driven scrollTo calls with
`.defaultScrollAnchor(.bottom)` alone. Manual proxy.scrollTo
against a LazyVStack that hadn't finished laying out was
overshooting into whitespace. Layout-pass-integrated anchor
behaves correctly at stream start and finish.
- Remove ContentUnavailableView swap in RichChatView — it tore down
the whole ScrollView hierarchy on first message. Empty state now
lives inside the scroll view.
- continueLastSession surfaces an acpError banner if open() fails,
instead of silently returning.
Lifecycle hygiene
- ServerRegistry.removeServer closes the server's SSH ControlMaster
(`ssh -O exit`), prunes its snapshot cache dir, and invalidates
UserHomeCache for that ID. App launch sweeps orphan snapshot dirs
whose UUIDs aren't in the registry anymore.
- NSWorkspace.activateFileViewerSelecting (backup-saved-to dialog)
gated on !context.isRemote; remote surfaces the remote path in the
saveMessage instead of silently no-op'ing on a nonexistent local
path.
Swift 6 concurrency — 230 warnings → 1
- Mark ServerContext, HermesPathSet, ServerTransport (protocol),
LocalTransport, SSHTransport, HermesFileService, and every value-
type accessor as `nonisolated`. Prevents AppKit-import-driven
MainActor inference from bleeding onto data-only types.
- Hand-written Codable conformances (vs. synthesized) for
ACPRequest, ACPRawMessage, ACPError, GatewayState, PlatformState,
HermesCronJob, CronSchedule, CronJobsFile, AuthFile, AuthEntry.
Synthesized inits were inferred @MainActor by Swift 6's default-
isolation rule; hand-written ones are explicitly nonisolated.
- Captured-var refactors in MCPServerEditorViewModel, PluginsView
Model, LocalTransport.watchPaths. Thread.sleep → Task.sleep in
TestConnectionProbe.
- Remaining warning is AnyCodable.value mutation in init(from:) —
Any-typed storage can't be strictly Sendable; acknowledged via
@unchecked Sendable.
ACP adapter upstream bug (not fixed here, but handled)
- Hermes's ACP adapter returns JSON-RPC success `{"result":{}}` for
session/load on a missing session, logging the warning only to
stderr. Scarf can't distinguish "loaded" from "silently missing"
at that layer; the stopReason=refusal surfacing above catches the
downstream symptom. Upstream issue worth filing.
Release docs
- releases/v2.0.0/RELEASE_NOTES.md with full user-facing breakdown.
- README.md "What's New" bumped to 2.0 with a multi-server section.
Compatibility table adds v0.10.0 as verified.
- GitHub repo description updated (via `gh repo edit`) to call out
multi-server + remote SSH.
35 files changed, +809/-350.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
121 lines
4.6 KiB
Swift
121 lines
4.6 KiB
Swift
import Foundation
|
|
|
|
@Observable
|
|
final class MCPServerEditorViewModel {
|
|
struct KeyValueRow: Identifiable, Equatable {
|
|
let id = UUID()
|
|
var key: String
|
|
var value: String
|
|
}
|
|
|
|
let context: ServerContext
|
|
private let fileService: HermesFileService
|
|
let server: HermesMCPServer
|
|
|
|
var envDraft: [KeyValueRow]
|
|
var headersDraft: [KeyValueRow]
|
|
var includeDraft: String
|
|
var excludeDraft: String
|
|
var resourcesEnabled: Bool
|
|
var promptsEnabled: Bool
|
|
var timeoutDraft: String
|
|
var connectTimeoutDraft: String
|
|
var showSecrets: Bool = false
|
|
var isSaving: Bool = false
|
|
var saveError: String?
|
|
|
|
init(server: HermesMCPServer, context: ServerContext = .local) {
|
|
self.server = server
|
|
self.context = context
|
|
self.fileService = HermesFileService(context: context)
|
|
self.envDraft = server.env.keys.sorted().map { KeyValueRow(key: $0, value: server.env[$0] ?? "") }
|
|
self.headersDraft = server.headers.keys.sorted().map { KeyValueRow(key: $0, value: server.headers[$0] ?? "") }
|
|
self.includeDraft = server.toolsInclude.joined(separator: ", ")
|
|
self.excludeDraft = server.toolsExclude.joined(separator: ", ")
|
|
self.resourcesEnabled = server.resourcesEnabled
|
|
self.promptsEnabled = server.promptsEnabled
|
|
self.timeoutDraft = server.timeout.map { String($0) } ?? ""
|
|
self.connectTimeoutDraft = server.connectTimeout.map { String($0) } ?? ""
|
|
}
|
|
|
|
func appendEnvRow() {
|
|
envDraft.append(KeyValueRow(key: "", value: ""))
|
|
}
|
|
|
|
func removeEnvRow(id: UUID) {
|
|
envDraft.removeAll { $0.id == id }
|
|
}
|
|
|
|
func appendHeaderRow() {
|
|
headersDraft.append(KeyValueRow(key: "", value: ""))
|
|
}
|
|
|
|
func removeHeaderRow(id: UUID) {
|
|
headersDraft.removeAll { $0.id == id }
|
|
}
|
|
|
|
func save(completion: @escaping (Bool) -> Void) {
|
|
isSaving = true
|
|
saveError = nil
|
|
|
|
let envMap = Dictionary(uniqueKeysWithValues: envDraft
|
|
.filter { !$0.key.trimmingCharacters(in: .whitespaces).isEmpty }
|
|
.map { ($0.key.trimmingCharacters(in: .whitespaces), $0.value) })
|
|
let headerMap = Dictionary(uniqueKeysWithValues: headersDraft
|
|
.filter { !$0.key.trimmingCharacters(in: .whitespaces).isEmpty }
|
|
.map { ($0.key.trimmingCharacters(in: .whitespaces), $0.value) })
|
|
let include = includeDraft.split(separator: ",").map { $0.trimmingCharacters(in: .whitespaces) }.filter { !$0.isEmpty }
|
|
let exclude = excludeDraft.split(separator: ",").map { $0.trimmingCharacters(in: .whitespaces) }.filter { !$0.isEmpty }
|
|
let timeoutValue = Int(timeoutDraft.trimmingCharacters(in: .whitespaces))
|
|
let connectValue = Int(connectTimeoutDraft.trimmingCharacters(in: .whitespaces))
|
|
|
|
let service = fileService
|
|
let transport = server.transport
|
|
let name = server.name
|
|
let resources = resourcesEnabled
|
|
let prompts = promptsEnabled
|
|
|
|
Task.detached {
|
|
// Compute success as an immutable so the MainActor.run closure
|
|
// captures a value, not a mutable var. Swift 6 rejects
|
|
// var-captures across concurrent closures as data races.
|
|
let success: Bool = {
|
|
var ok = true
|
|
switch transport {
|
|
case .stdio:
|
|
if !service.setMCPServerEnv(name: name, env: envMap) { ok = false }
|
|
case .http:
|
|
if !service.setMCPServerHeaders(name: name, headers: headerMap) { ok = false }
|
|
}
|
|
if !service.updateMCPToolFilters(
|
|
name: name,
|
|
include: include,
|
|
exclude: exclude,
|
|
resources: resources,
|
|
prompts: prompts
|
|
) { ok = false }
|
|
if !service.setMCPServerTimeouts(name: name, timeout: timeoutValue, connectTimeout: connectValue) {
|
|
ok = false
|
|
}
|
|
return ok
|
|
}()
|
|
await MainActor.run {
|
|
self.isSaving = false
|
|
if !success {
|
|
self.saveError = "One or more fields could not be written. Check \(self.context.paths.configYAML)."
|
|
}
|
|
completion(success)
|
|
}
|
|
}
|
|
}
|
|
|
|
func clearOAuthToken(completion: @escaping (Bool) -> Void) {
|
|
let service = fileService
|
|
let name = server.name
|
|
Task.detached {
|
|
let ok = service.deleteMCPOAuthToken(name: name)
|
|
await MainActor.run { completion(ok) }
|
|
}
|
|
}
|
|
}
|