Documents the v2.3 configuration feature for future agent sessions:
manifest schemaVersion 2 shape, supported field types, Keychain storage
conventions (service/account naming with project-path hash suffix), the
uninstaller's config-items cleanup path, exporter behaviour (schema
forwarded, values stripped), and the catalog site's schema display.
Includes the "Schema is Swift-primary" note so future edits to
TemplateConfigField.FieldType go through the right order of updates —
Swift first, then Python mirror, then widgets.js, then UI controls,
then tests on both sides. Schema drift between Swift + Python
validator would accept bundles the app later refuses at install
time, which is a catastrophic UX failure for the catalog.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Phase D of v2.3 template configuration — closes the loop between the
Swift app and the catalog pipeline. Authors can now ship schemaful
bundles; the Python validator enforces the same invariants the Swift
installer does; the catalog site displays the schema so visitors see
what they'll need to configure before installing.
Python validator (tools/build-catalog.py):
- SUPPORTED_SCHEMA_VERSIONS accepts both 1 and 2 (v1 bundles are
unchanged; v2 adds optional manifest.config).
- New _validate_config_schema function mirrors the Swift
ProjectConfigService.validateSchema rules: unique keys, supported
types, enum option presence + unique values, list itemType ==
"string", secret-field cannot declare a default,
modelRecommendation.preferred non-empty when present.
- _validate_contents_claim cross-checks contents.config (field count)
against config.schema actual length — mismatch refused.
- TemplateRecord.to_catalog_entry exposes `config` in catalog.json so
the site can render the schema.
- render_site copies each bundle's template.json to the detail dir as
manifest.json (only when the manifest has a config block — keeps
the served tree lean and makes "no manifest.json" a meaningful
404 signal in the frontend).
- catalog.json's own schemaVersion stays at 1 (independent of per-
template manifest schemaVersion).
Python tests (tools/test_build_catalog.py): 8 new cases in a new
ConfigSchemaValidationTests suite — accepts schemaful bundle, rejects
duplicate keys, rejects secret-with-default, rejects enum-without-
options, rejects unsupported field type, rejects contents.config
count mismatch, rejects unsupported list itemType, legacy v1
manifests pass unchanged. 24/24 Python tests total.
Site (site/widgets.js):
- New renderConfigSchema(container, config) — mirrors the display
on the Scarf install preview. Renders each field as a <dt>/<dd>
pair with type + required badges; enum shows choice labels; list
fields show min/max bounds; string fields show pattern/length;
secret fields get a "Stored in Keychain" reassurance. Optional
modelRecommendation panel at the bottom with preferred + rationale
+ alternatives.
- The renderer is display-only — the site never collects values;
that's the Scarf app's job.
template.html.tmpl adds a #config-schema <section>. The inline script
fetches manifest.json from the detail dir; on success hands the
config block to ScarfWidgets.renderConfigSchema; on 404 (schema-less
templates) silently leaves the section empty. CSS in styles.css
adds a config-schema panel matching the accent-green aesthetic.
24/24 Python + 50/50 Swift tests pass. site-status-checker still
renders correctly (schema-less; manifest.json isn't copied for it).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds the user-facing side of v2.3 template configuration. Install-time
flow: templates with a non-empty config.schema get a Configure step
between the parent-directory pick and the preview sheet. Post-install
flow: a Configuration button on the dashboard header + a context-menu
entry on the project list opens the same form pre-filled with current
values.
New files:
- Features/Templates/ViewModels/TemplateConfigViewModel.swift — drives
the form. Keeps freshly-entered secret bytes in `pendingSecrets`
in-memory until commit() succeeds, then calls
ProjectConfigService.storeSecret for each one. Cancelling never
leaves orphan Keychain entries — the form is transactional.
Validates via ProjectConfigService.validateValues on commit and
populates per-field `errors` the sheet surfaces inline. Two modes:
.install (needs a project passed at commit time) and
.edit(project:) (VM already holds the target).
- Features/Templates/Views/TemplateConfigSheet.swift — the form. One
row per field with a control dispatched by type: TextField (string),
TextEditor (text), number input, Toggle (bool), segmented/dropdown
Picker (enum, picks form by option count), add/remove list editor,
SecureField with show/hide toggle (secret). Required-field asterisk
+ per-field error display. Optional modelRecommendation panel at
the bottom — informational badge; no auto-switch.
- Features/Templates/ViewModels/TemplateConfigEditorViewModel.swift —
loads <project>/.scarf/manifest.json + config.json, hands a
TemplateConfigViewModel to the sheet, writes edited values back on
commit. Has a .notConfigurable stage for projects without a
manifest cache (hand-added projects, schema-less templates).
- Features/Templates/Views/ConfigEditorSheet.swift — thin wrapper
that owns the editor VM and routes its stages to loading / form /
saving / success / error / not-configurable views.
Wiring:
- TemplateInstallerViewModel gains an .awaitingConfig stage between
.awaitingParentDirectory and .planned. pickParentDirectory() now
inspects plan.configSchema and either routes to .awaitingConfig
(non-empty schema) or straight to .planned (schema-less). New
submitConfig(values:) stashes finalized values in plan.configValues
and advances; cancelConfig() returns to .awaitingParentDirectory.
- TemplateInstallSheet renders a new `configureView` that inlines
TemplateConfigSheet into the install flow for .awaitingConfig.
The existing preview (.planned) gains a new "Configuration" section
listing each field + its display value (secrets shown as "••••••
(Keychain)", lists shown as "first + N more", "(not set)" for
missing values).
- ProjectsView adds an isConfigurable(_:) check (transport.fileExists
on .scarf/manifest.json), a new @State configEditorProject for
sheet presentation, a new "Configuration…" context-menu entry on
project list rows (for configurable projects), and a new
slider.horizontal.3 button on the dashboard header next to the
existing Uninstall button.
50/50 tests still pass. This commit is UI-only — no new Phase C tests
(sheet behaviour is hard to unit-test without UI automation and the
underlying VM logic is exercised by Phase A/B's config-round-trip
tests).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Introduces the TemplateConfigSheet form and its view models, plus
the install-flow integration points: a new .awaitingConfig stage in
TemplateInstallerViewModel, the configureView step in the install
sheet, and the dashboard-header Configuration button wiring in
ProjectsView. This is the schemaful-template v2.3 UI that every
subsequent config commit builds on.
Originally landed alongside scaffolding for an iOS target in b289a83;
this is the split that keeps the template-config work and drops the
iOS scaffolding (the real iOS port is on scarf-mobile-development).
Extends the template format to schemaVersion 2 (schema-less bundles at
v1 keep working unchanged) and threads TemplateConfigSchema through
inspect → buildPlan → install → uninstall → export end-to-end.
Model additions (ProjectTemplate.swift):
- ProjectTemplateManifest gains optional `config: TemplateConfigSchema?`.
- TemplateContents gains optional `config: Int?` claim (field count)
cross-checked against the schema by `verifyClaims` so a manifest
can't hide its configuration from the preview sheet.
- TemplateInstallPlan gains `configSchema`, `configValues` (populated
by the VM just before install()), and `manifestCachePath`. New
fields also feed totalWriteCount so the preview footer is honest.
- TemplateLock gains optional `configKeychainItems: [String]?` and
`configFields: [String]?`. Optional so pre-2.3 lock files still
uninstall cleanly — Codable's default decoding skips missing fields.
Service changes:
- ProjectTemplateService.inspect now accepts schemaVersion 1 or 2.
When the manifest declares a config block, the service validates it
immediately via ProjectConfigService.validateSchema and fails the
install with a manifestParseFailed before the preview sheet ever
renders. verifyClaims cross-checks contents.config count against
the actual schema length.
- ProjectTemplateService.buildPlan populates configSchema and queues
two new entries in projectFiles: .scarf/config.json (synthesized by
the installer from configValues at write time, using an empty
sourceRelativePath sentinel) and .scarf/manifest.json (copy of the
bundle's template.json so the post-install Configuration editor can
render offline).
- ProjectTemplateInstaller.createProjectFiles now special-cases the
empty-source sentinel: for .scarf/config.json, it encodes
plan.configValues into a ProjectConfigFile on the fly. Secrets in
that file are keychain:// refs — the raw bytes were routed into the
Keychain by the VM before install() was called.
- ProjectTemplateInstaller.writeLockFile records every keychainRef
URI from configValues in lock.configKeychainItems and the schema
field keys in lock.configFields.
- ProjectTemplateUninstaller.uninstall adds a new step 4a: iterate
lock.configKeychainItems, parse each URI into a TemplateKeychainRef,
SecItemDelete each one. Absent items are no-ops (the Keychain
wrapper already handles errSecItemNotFound silently).
- ProjectTemplateExporter now reads the source project's
.scarf/manifest.json (if present) and forwards the SCHEMA through
to the exported bundle while zeroing values. schemaVersion bumps to
2 only when a schema is carried; schema-less exports stay at 1 for
byte-compatibility with v2.2 catalog validators.
Tests (ProjectTemplateTests.swift): 5 new tests in 1 new suite.
- inspectAcceptsSchemaV2Bundle: v2 manifest unpacks cleanly.
- buildPlanSurfacesSchemaAndQueuesConfigFiles: plan carries the
schema; projectFiles contains both config.json + manifest.json.
- verifyClaimsRejectsConfigCountMismatch: a manifest lying about
contents.config vs. schema.fields.count is refused at inspect.
- installWritesConfigJsonAndManifestCache: install round-trip writes
config.json (with non-secret values inline + secret as keychainRef),
manifest.json cache, and lock with configKeychainItems +
configFields. Real Keychain is exercised; the test cleans up the
single item it creates.
- uninstallDeletesKeychainItemsViaLock: install + then uninstall,
verify the Keychain entry is gone via SecItemCopyMatching.
sampleManifest test helper gains `configFieldCount` and `configSchema`
params so tests that want schemaful bundles don't need to rebuild the
whole manifest record. schemaVersion auto-bumps to 2 when a schema is
present so the fixture mirrors real bundle shape.
50/50 tests in 13 suites pass; pre-existing 45 from v2.2 unchanged.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Groundwork for v2.3 template configuration. No user-visible behaviour
yet — this commit adds the data structures, storage layer, and
validation rules that the installer/uninstaller/UI will integrate with
in the next two commits.
Models (Core/Models/TemplateConfig.swift):
- TemplateConfigSchema + TemplateConfigField for the author-declared
manifest.config block. 7 field types: string, text, number, bool,
enum, list, secret. Type-specific constraints (pattern, min/max,
min/maxLength, min/maxItems, enum options) are all optional and
the validator enforces only those applicable to the field's type.
- TemplateModelRecommendation for the author's model-of-choice hint
(preferred + rationale + alternatives). Purely advisory — Scarf
never auto-switches the active model.
- TemplateConfigValue enum: string / number / bool / list / keychainRef.
Custom Codable preserves keychain:// refs on round-trip — a round
through save/load never demotes a secret ref to plaintext.
- ProjectConfigFile is the on-disk shape at <project>/.scarf/config.json.
- TemplateKeychainRef: derives (service, account) from templateSlug +
fieldKey + project-path hash. The 32-bit FNV-1a suffix prevents two
installs of the same template in different dirs from colliding in
the login Keychain. uri <-> parse round-trips losslessly.
Keychain layer (Core/Services/ProjectConfigKeychain.swift):
- Thin wrapper over kSecClassGenericPassword. set() tries update-first
then add-if-missing so we don't trip "already exists" on a race.
- kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly: no iCloud sync,
but cron triggers can still read after the user's first unlock.
- testServiceSuffix lets unit tests route items under a distinct
service prefix so nothing leaks into the user's real Keychain.
Service layer (Core/Services/ProjectConfigService.swift):
- load/save for <project>/.scarf/config.json through the ServerContext
transport (so remote-ready for when installer goes remote).
- cacheManifest/loadCachedManifest: the installer copies template.json
into <project>/.scarf/manifest.json so the post-install "Configuration"
button can render the form offline.
- resolveSecret / storeSecret / deleteSecrets: the three Keychain paths
any caller needs. Non-secret values never pass through these.
- validateSchema: author-facing invariants (unique keys, known types,
enum opts present/unique, no defaults on secrets, non-empty model
preferred). Called by ProjectTemplateService during inspect.
- validateValues: user-facing invariants (required, pattern, numeric
range, list bounds, enum membership). Returns one error per problem
so the UI can surface them inline with the offending field.
Tests (scarfTests/TemplateConfigTests.swift): 23 tests in 5 suites.
- Schema validation: happy path + every rejection rule.
- Value validation: required, pattern, numeric range, list bounds,
enum membership, secret-via-keychain-ref acceptance.
- Keychain ref: uri round-trip, parse rejection of malformed input,
path-hash differs across project dirs but is stable for same path.
- ProjectConfigFile round-trips non-secret values cleanly AND preserves
keychain:// refs (the bug that would silently demote secrets to
plaintext if the Codable were wrong).
- Real Keychain integration: store+resolve+delete, set overwrites,
delete of missing item is a no-op, bulk delete clears all. Tests
use unique testServiceSuffix per run so no cross-contamination.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Four small fixes surfaced by a side-by-side plan-vs-shipped pass:
- README.md: adds the Template Catalog section the plan called out —
links to the live site URL, the install flows (web / file / Finder),
and templates/CONTRIBUTING.md for authors. Placed right before the
existing Contributing section, with a catalog-specific cross-link at
the end of that section too.
- CLAUDE.md: adds the Template Catalog section so future agent sessions
know the regenerator pipeline exists, how it relates to release.sh +
wiki.sh, and what the schema-sync rule is when DashboardWidget or
ProjectTemplateManifest change.
- scarf/scarfTests/ProjectTemplateTests.swift: fixes the stale
ProjectTemplateExampleTemplateTests docstring still referencing
`examples/templates/` (the example moved to `templates/awizemann/`
in 70f7cea).
- .github/workflows/validate-template-pr.yml: untangles the self-
contradictory Python-version comment. The validator is 3.9+
compatible; CI uses 3.11 for faster runner caching. Same stdlib
surface, same code paths — just clearer about why.
All tests still green: 22 Swift tests in 7 suites, 16 Python tests,
catalog check passes on the site-status-checker example.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds the CI gate that runs on every PR touching templates/, the catalog
validator, or its tests. The Action:
- runs tools/test_build_catalog.py (catches drift between validator +
its own test suite on the same PR that introduces the drift)
- runs tools/build-catalog.py --check (validates every shipped .scarftemplate
against the same invariants ProjectTemplateService.verifyClaims enforces
at install time)
- posts a PR comment with the last 3 KB of the validator log on failure,
so contributors see the specific mismatch without hunting through the
Actions UI
.github/PULL_REQUEST_TEMPLATE/template-submission.md is the author-facing
checklist that mirrors templates/CONTRIBUTING.md. Opt-in via the
?template=template-submission.md compare URL (documented in the
contribution guide). CONTRIBUTING.md now links both the PR template and
the workflow file so authors know what to expect.
Phase 4 closes the community loop — from this commit on, a stranger can
fork the repo, follow templates/CONTRIBUTING.md, push a PR, and get
deterministic green/red feedback before a maintainer ever looks at it.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds site/ with vanilla HTML + CSS + ~300 lines of JavaScript that
renders ProjectDashboard JSON directly in the browser. Each template's
detail page shows a live preview of the exact dashboard the user will
get post-install — the catalog IS the dogfood.
site/widgets.js mirrors the Swift widget dispatcher:
- stat (big number + colored icon + optional subtitle)
- progress (0..1 bar)
- text with inline markdown subset (headings, bold/italic, inline code,
code fences, bullet + numbered lists, links)
- table (plain HTML)
- list (with up/down/unknown status badges)
- chart (SVG line + bar — no Chart.js dependency)
- webview (sandboxed iframe)
- unknown (placeholder so the page doesn't silently omit widgets)
Plus the renderMarkdown helper used by the template detail page to
display the bundle's README.
site/index.html.tmpl + site/template.html.tmpl are substitution-only —
the Python regenerator swaps {{CARDS}}, {{COUNT}}, {{COUNT_PLURAL}},
{{NAME}}, {{DESC}}, {{VERSION}}, {{AUTHOR_HTML}}, {{TAGS_HTML}},
{{INSTALL_URL_ENCODED}}, {{SCARF_INSTALL_URL}}. The detail page fetches
dashboard.json + README.md at page load and hands them to widgets.js.
No client-side framework, no bundler, no npm.
site/styles.css: minimal CSS with scarf green accent, prefers-color-
scheme dark support, responsive at 680px. One file, ~280 lines.
build-catalog.py extended to copy dashboard.json + README.md out of each
bundle into its detail dir so widgets.js can fetch them without
reaching across directories (and so gh-pages doesn't need to serve zip
contents at request time).
Two new Python tests: end-to-end site rendering (both cards, install
URL wiring, static asset copy, per-template dashboard + README copy)
and the {{COUNT_PLURAL}} singular-vs-plural flip. 16/16 Python tests
green.
Smoke-tested locally with python3 -m http.server: every endpoint
(index, catalog.json, detail HTML, per-template dashboard.json + README,
widgets.js) returns 200. The .gh-pages-worktree/appcast.xml +
.gh-pages-worktree/index.html are untouched — the catalog is purely
additive under /templates/.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds the catalog pipeline without introducing any external dependencies.
tools/build-catalog.py walks templates/<author>/<name>/, validates every
shipped .scarftemplate against its manifest (same invariants Swift's
ProjectTemplateService.verifyClaims enforces at install time), and emits
templates/catalog.json for the frontend to read.
Validator invariants:
- Required bundle files: template.json, README.md, AGENTS.md, dashboard.json
- contents claim cross-checked against actual zip entries (instructions,
skills, cron count, memory appendix)
- dashboard.json widget types restricted to the vocabulary the Swift
renderer knows
- Manifest id author component must match the template directory
- 5 MB bundle-size cap on submissions (installer's own cap is 50 MB)
- High-confidence secret patterns (private keys, GitHub PATs, Slack tokens,
AWS access keys, OpenAI/Anthropic keys) block the bundle
- staging/ source tree must match the built bundle byte-for-byte — catches
the common failure mode of editing staging/ but forgetting to rebuild
scripts/catalog.sh wraps the Python script with check/build/preview/serve/
publish subcommands, mirroring the scripts/wiki.sh shape. publish adds a
second-pass hard-pattern secret scan on the rendered gh-pages output so
template prose can't leak credentials even if the Python scan missed them.
tools/test_build_catalog.py has 14 unit tests covering the main validator
paths (minimal-valid, missing-AGENTS, content-claim mismatch, author
mismatch, oversized bundle, unknown widget type, secret detection,
staging-drift detection, missing bundle, catalog.json shape, and a real-
bundle end-to-end check against templates/awizemann/site-status-checker).
Python 3.9 compatible (Xcode's bundled python3), so no runtime needs
installing.
templates/catalog.json committed as the first generated aggregate index;
maintainers regenerate on merge by running `./scripts/catalog.sh build`.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Set up the catalog directory structure this branch will fill with
community templates. The existing site-status-checker example moves
from examples/templates/ to templates/awizemann/site-status-checker/
(tracked by git as a rename so history is preserved). The examples/
directory is removed.
New top-level docs:
- templates/README.md — landing for folks browsing the catalog on
github.com. Lists the current templates and points at the live site.
- templates/CONTRIBUTING.md — author-facing submission walkthrough.
Requires AGENTS.md, pre-flight with tools/build-catalog.py --check
(added in the next commit), one template per PR, don't edit
catalog.json (maintainer regenerates it post-merge).
ProjectTemplateExampleTemplateTests.locateExample updated to search
templates/<author>/<name>/ instead of examples/templates/ — the test
still walks up from #filePath to find the repo root so it works in
both xcodebuild and Xcode IDE test runs.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
First installable template demonstrating the format:
- Dashboard with stat widgets (up/down/last-checked) + configured-sites
list + quick-start markdown.
- Cross-agent AGENTS.md with the full cron-prompt contract so any agent
that reads agents.md (Claude Code, Cursor, Codex, Aider, Jules,
Copilot, Zed, …) picks up the behavior on first run.
- Cron job (0 9 * * *) that ships paused with the [tmpl:…] tag, pinging
a user-editable sites.txt and writing results to status-log.md.
- First-run bootstrap logic in AGENTS.md: if sites.txt doesn't exist
yet the agent creates it with two placeholder URLs, then proceeds.
Plus examples/templates/README.md explaining the staging/ layout,
authoring conventions, and how to rebuild a bundle after editing. CI
validates the bundle via ProjectTemplateExampleTemplateTests so drift
between staging/ and the built .scarftemplate fails on every build.
v2.2.0 release notes cover the full feature surface including the
install preview sheet, scarf:// + file:// URL handling, skills
namespacing, cron-job tagging, memory-block markers, and the
lock-driven uninstall flow.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Shareable `.scarftemplate` bundle format lets users package a project's
dashboard, cross-agent AGENTS.md, optional per-agent instruction shims,
optional namespaced skills, optional tagged cron jobs, and an optional
memory appendix into a single zip that anyone can install with one click.
Core:
- Bundle format + manifest schema v1 (template.json with contents claim
cross-checked against zip entries to prevent hidden files).
- ProjectTemplateService inspects + validates + builds an install plan.
- ProjectTemplateInstaller executes plans with transport-routed I/O so
the v1 local-only flow extends cleanly to remote ServerContexts later.
- ProjectTemplateExporter builds bundles from existing projects with
user-selected skills + cron jobs.
- ProjectTemplateUninstaller reverses installs using template.lock.json.
Only lock-tracked files are removed; user-added files are preserved.
UI:
- Templates menu in Projects toolbar: Install from File, Install from
URL, Export as Template.
- Preview-and-confirm sheets for install, uninstall, and export with
full diff of what will be written/removed before anything runs.
- Right-click context menu on project list + dashboard header button
for uninstall (only shown when template.lock.json exists).
Deep link + file associations:
- scarf:// URL scheme registered; onOpenURL in scarfApp.swift routes
scarf://install?url=https://... and file:// URLs for .scarftemplate
files to the install sheet.
- Custom UTType com.scarf.template registered so Finder shows the file
with a Scarf icon and double-click opens the install preview.
- Cold-launch race fix: .task picks up any URL staged on the router
before the onChange observer was installed.
Safety:
- Never writes to config.yaml, auth.json, sessions, or credentials.
- Cron jobs ship paused with a [tmpl:<id>] name prefix.
- Skills install to a namespaced ~/.hermes/skills/templates/<slug>/ dir
so they never collide with user-authored skills.
- Memory appendix is wrapped in scarf-template:<id>:begin/end markers
for clean removal during uninstall.
- Download cap: 50 MB for URL-fetched templates, enforced on the actual
on-disk file size after download so chunked transfers can't bypass it.
Tests: 22 tests in 7 suites cover manifest parsing, claim verification,
URL routing (scarf:// + file://), end-to-end install and uninstall
against a minimal bundle (projects registry is snapshotted + restored),
user-added file preservation, and exporter round-trip.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Repurposes the previously-unused ServerEntry.openOnLaunch flag so users
can nominate Local or any registered remote as the server Scarf opens
into when a fresh window has no prior binding (first launch or File →
New Window).
- ServerRegistry gains `defaultServerID` (returns the flagged entry's
ID or falls back to Local) and `setDefaultServer(_:)` (flips the flag
on the named entry and clears it elsewhere, then persists).
- ScarfApp's WindowGroup defaultValue closure now returns
`registry.defaultServerID` instead of hardcoded `ServerContext.local.id`.
- ManageServersView gains a Local row at the top of the list plus a
star button per row: filled yellow on the current default, outline on
the others. Click to promote.
Backward compatible: the openOnLaunch field was already in the persisted
schema (default false), so existing servers.json files load unchanged —
Local remains the default until the user picks otherwise.
Refs #26
Wire an NSSplitView autosave name into NavigationSplitView's underlying
AppKit split view so the sidebar's drag-to-resize position is remembered
in UserDefaults and restored on next launch.
SplitViewAutosave.swift installs an invisible NSViewRepresentable that
walks up the view hierarchy from the sidebar, finds the enclosing
NSSplitView, and assigns autosaveName = "ScarfMainSidebar". AppKit
handles persistence from there — no manual UserDefaults or @AppStorage
plumbing needed.
ContentView also gets navigationSplitViewColumnWidth(min:ideal:max:)
bounds so first-launch (before any autosave exists) lands at a sensible
240pt ideal within a 180–360pt range.
Refs #26
Xcode 26.x suggested an upgrade pass that included a critical regression:
ENABLE_APP_SANDBOX = YES on the main app, which would silently break every
view that reads ~/.hermes/ (state.db, config.yaml, memory files, skills,
logs). Scarf is architected sandbox-off per CLAUDE.md — reverted.
Kept the benign pieces:
- DEAD_CODE_STRIPPING = YES on all targets (stock modern optimization)
- CLANG_ANALYZER_LOCALIZABILITY_NONLOCALIZED = YES at project level —
static analyzer warning for un-localizable call sites; directly
relevant to the i18n work in 2.1.0 and will flag regressions of the
exact patterns just cleaned up
- STRING_CATALOG_GENERATE_SYMBOLS = YES hoisted to project level
(was already set at target level; hoisting is a no-op functional
change but Xcode prefers it inherited)
- Scheme file LastUpgradeVersion bumped to 2620 to match current Xcode
Rejected:
- ENABLE_APP_SANDBOX = YES (critical — would break app file access)
- ENABLE_RESOURCE_ACCESS_AUDIO_INPUT / RUNTIME_EXCEPTION_DISABLE_LIBRARY_VALIDATION
build settings (Xcode's new form replacing the entitlements file;
keeping the entitlements file as the single source of truth since
every release 1.x → 2.1.0 shipped and notarized with that form)
- LastUpgradeCheck = 2620 (Xcode dropped 2630 → 2620; cosmetic revert)
v2.1.0 was released before this Xcode pass so no rebuild needed — the
downloaded zips and Sparkle appcast entry are unaffected.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The scarf scheme existed in every local Xcode session (Xcode auto-creates
it from xcschememanagement's ^#shared#^ entry on first open), but was
never actually committed to the repo. Release v2.1.0 hit the resulting
"project contains no schemes" error on headless xcodebuild archive after
the build/ cache was cleaned. Committing the scheme itself so future
headless builds work from a fresh clone.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Pre-release prep so that when `./scripts/release.sh 2.1.0` runs on main,
the notes file is already in tree (script's `git add` is then a no-op,
bump commit contains only the pbxproj version change).
- README gains a 2.1 "What's New" section covering translations + the
chat slash-menu; 2.0 moves down to "Previously".
- Badge row gains a language list line.
- Full release notes at releases/v2.1.0/RELEASE_NOTES.md — covers the
three stacked i18n PRs (infra, audit burn-down, translations) and the
chat slash-menu work merged in parallel.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Three connected bugs where the Label/SettingsSection APIs took a `String`,
which routes through the StringProtocol overloads and bypasses localization
entirely. Identified by the user after testing zh-Hans / de / fr — the
sidebar menu items, Settings tab bar, and Settings section headers all
remained English under any App Language override.
- SidebarSection now exposes displayName: LocalizedStringResource; SidebarView
builds Label via the Text/Image builders so the catalog key is actually
used.
- SettingsTab gets the same displayName treatment; the .tabItem Label builds
through the Text/Image builder too.
- SettingsSection.title changes from String → LocalizedStringKey so literal
call sites (all ~20 of them) now extract into the catalog. Two call sites
that were passing String variables (PlatformsView, CredentialPoolsView) are
wrapped via LocalizedStringKey(...) — brand/provider names fall through to
English as before. AuxiliaryTab's static task list gets a LocalizedStringKey
column so its section titles extract too.
This change newly extracts 65 previously-invisible section-title keys into
the catalog; translations added for all six locales. Catalog: 575 → 644
source keys, each locale translated for 583 of them (brand names / protocol
names / format-only keys intentionally fall through).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Ships first-pass AI translations for six locales on top of the existing
English base, plus a simple JSON-per-locale contributor workflow so new
languages can land as a single PR.
- 518 keys translated per locale (proper nouns / brand names / format-
only strings left to fall back to English by design — see the
"Non-blocking (intentional verbatim)" section of scarf/docs/I18N.md).
- Per-locale source-of-truth lives in tools/translations/<locale>.json;
tools/merge-translations.py writes them into Localizable.xcstrings
and is idempotent (re-runnable as translators iterate).
- InfoPlist.xcstrings (macOS microphone permission prompt) translated
for all six locales.
- knownRegions expanded: zh-Hans, de, fr now join by es, ja, pt-BR.
- CONTRIBUTING.md gains an "Adding a Language" section documenting the
fork → JSON → merge → PR flow. Native-speaker reviews welcome.
Closes#13 (the original ask: Simplified Chinese support).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Burns down the follow-ups tracked in scarf/docs/I18N.md so that future
translation passes (Phase 2+) don't see English leak through ternary UI
copy, enum rawValue displays, or fixed-format strings.
- Ternary status copy: Text(cond ? "A" : "B") → cond ? Text("A") : Text("B")
(each branch routes through LocalizedStringKey). Covers Health, Chat
(voice/TTS/recording/ACP status), Profiles, MCPServer test result,
SignalSetup, QuickCommands header.
- Enum .rawValue displays: LogFile, LogComponent, DashboardTab, Skills.Tab,
InsightsPeriod, ToolKind, AuthType each expose a
displayName: LocalizedStringResource. LogEntry.LogLevel stays verbatim
(technical jargon — DEBUG/INFO/ERROR/… are industry-standard).
- displayName passthroughs: HermesToolPlatform, ServerRegistry.Entry,
MCPServerPreset wrapped with Text(verbatim:) at call sites (brand names
and user data, not UI chrome). MCPTransport.displayName promoted to
LocalizedStringResource.
- Composite format strings: ModelPickerSheet "ctx" suffix, InsightsView
"tokens" suffix and MCPServerTestResultView "%.1fs · %d tools" rewritten
as Text("\(arg) suffix") LocalizedStringKey. Percent display uses
.formatted(.percent) after /100.
- Day-of-week chart now sources from Calendar.current.shortWeekdaySymbols,
re-indexed for the existing Mon=0 data model.
- ConnectionStatusPill's label + tooltip return Text (not String) so the
.help(Text) / direct-render paths localize correctly.
- Catalog re-synced: 545 → 575 keys (+30 from new ternary branches and
enum displayName values).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Picks up 7 new Text("…") keys introduced by a68e0c5 and c8208de
(loading state copy, slash-menu empty states, argument-hint placeholder).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Lays the groundwork for zh-Hans / de / fr translations on an English base.
No user-visible English-locale behavior changes. See scarf/docs/I18N.md for
the full plan and remaining audit follow-ups.
- Localizable.xcstrings seeded with 538 keys auto-extracted via
`xcstringstool sync` from the Swift sources
- InfoPlist.xcstrings carrying NSMicrophoneUsageDescription
- knownRegions += zh-Hans, de, fr
- Currency / byte-count / compact-number String(format:) sites migrated to
Locale.current-aware .formatted() style (currency, byteCount(.file),
compactName notation) — previously rendered POSIX separators + English
unit names regardless of user locale
Refs #13.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- Slash menu: filter at the parent and pass the pre-filtered list to
SlashCommandMenu (pure-prefix match, no description fallback). Adds
`.id(menuQuery)` to force a fresh view on every query so SwiftUI can't
render stale props — this was the cause of "typing /mo still shows
/help" (the old description fallback plus a cached child view kept
/help pinned regardless of query).
- Auto-scroll to bottom when the user submits a message and again when
the prompt completes. `.defaultScrollAnchor(.bottom)` handles slow
streaming fine, but rapid slash-command responses outran the anchor
and left the response off-screen.
- Loading state: add `ChatViewModel.isPreparingSession` (true during
Starting / Creating / Loading / Reconnecting). While true, the message
list swaps its placeholder for a ProgressView — non-blocking, just a
view inside the ScrollView.
- Center the empty-state placeholder properly: replace
`.padding(.vertical, 80)` with Spacers inside
`.containerRelativeFrame(.vertical)` so the placeholder sits in the
true vertical center of the chat pane at any window size.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- Add floating slash-command menu driven by ACP available_commands_update
and user-defined quick_commands from config.yaml. ↑/↓ navigate, Tab or
Enter completes, Esc dismisses. Commands with argument hints insert a
trailing space so the user can type the argument.
- New HermesSlashCommand model carries name/description/argumentHint/source;
RichChatViewModel stores ACP + quick_commands separately and merges them
for the menu. QuickCommandsViewModel exposes a reusable static loader.
- Menu renders as a sibling above the input HStack (not a popover or
overlay) — guaranteed to render regardless of focus/z-order quirks.
- Hide the dedicated /compress button once the menu has more than one
command; keep it as a fallback when only /compress is advertised.
- Fix long-standing "session loads with whitespace, must scroll up to see
chat" bug by switching LazyVStack → VStack in RichChatMessageList.
LazyVStack's estimated row heights were fooling .defaultScrollAnchor(.bottom)
into overshooting real content; VStack measures every row upfront so the
anchor has real heights to work with.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Two tests pinning the invariants that were violated / introduced
by the #19 / PR #20 fix:
- controlDirPathFitsMacOSSocketLimit: asserts dir + '/' + 64-char
%C hash + NUL <= 104 bytes. Would have caught the original
Caches-based path landing at 105 bytes for users with longer
$HOME strings.
- controlDirPathIsPerUser: asserts the path includes the current
uid, pinning the per-user-isolation invariant against any future
refactor that drops it (since /tmp is shared across all local
users).
scarfTests was a stub before this — these are the suite's first
real tests.
Layered hardening on top of the /tmp ControlPath move from #20:
- ensureControlDir uses POSIX mkdir(0700) + lstat instead of
createDirectory + setAttributes. Closes the /tmp pre-creation
TOCTOU: any local user can pre-create /tmp/scarf-ssh-<uid>, and
the old code would silently fail to chmod a hostile dir back to
0700 (since we wouldn't own it). Now we refuse to use a dir that
isn't a real directory we own with mode 0700, and log via
os.Logger.
- sweepStaleControlSockets removes ControlMaster socket files
older than 30 minutes from controlDirPath() at app launch.
Symmetric to sweepOrphanSnapshots — keeps /tmp/scarf-ssh-<uid>/
from accumulating crashed-master / unclean-exit orphans
indefinitely until reboot. The 30-min threshold (vs ControlPersist's
10 min) ensures any concurrent Scarf instance's live sockets
are untouched.
Public docs now live at https://github.com/awizemann/scarf/wiki (separate
git repo cloned to .wiki-worktree/, mirroring the .gh-pages-worktree/
pattern). Internal dev notes stay in scarf/docs/.
scripts/wiki.sh wraps pull/commit/push with a two-pass secret-scan: hard
patterns (token regexes + private-key headers + a user-maintained
scripts/wiki-blocklist.txt) abort with non-zero exit; soft assignment
patterns (api_key=…, password=…, token=…) warn and require --force-terms.
CLAUDE.md gains a Wiki section listing the update triggers (new feature,
new service, architecture change, Hermes version bump, full release,
keyboard/sidebar change) and the workflow. CONTRIBUTING.md points
external contributors at the wiki Edit button or a direct clone.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Closes#19 (remote SSH connections showed connected but every view
read as empty). Eight commits bring:
- Result-returning readers in HermesFileService that surface errors
instead of silently returning nil
- HermesDataService.open records lastOpenError with humanized hints
- Dashboard orange banner when remote reads fail
- New Remote Diagnostics sheet (14-probe checklist, stethoscope icon)
- Yellow 'degraded' pill state for 'connected but can't read' case
- Auto-suggest remoteHome in Test Connection for systemd/Docker
installs at /var/lib/hermes/.hermes etc.
- Log-noise suppression for expected 'No such file' reads
- Diagnostics script pipes via stdin to sh -s (not sh -c argv), so
multi-line scripts run in one sh process with variable scope
- Pill UX: state-specific SF Symbol instead of dot, no custom
background, centered via .principal
- README 'Remote setup requirements' + troubleshooting section
Investigation notes + deferred follow-ups recorded in the session
transcript. See releases/v2.0.1/RELEASE_NOTES.md for the full
user-facing breakdown.
Reflect the three post-initial-commit fixes:
- log-noise suppression (skill.yaml / optional-file 'No such file'
warnings no longer spam Console via the new Result-returning readers)
- diagnostics script now stdin-pipes to sh -s instead of sh -c <script>
argv, so it runs as one sh process with variable scope preserved
- pill UX: replaced colored dot with state-specific SF Symbol
(checkmark / stethoscope / arrows / triangle), removed custom
background, kept .principal placement for centering
Also expanded the 'Known follow-ups' section so users know what's
explicitly deferred post-2.0.1.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Rolling back the .primaryAction placement (the pill shifted right and
lost its centered position in the toolbar). The "funny background with
shadow" visible in the toolbar is macOS's own .principal emphasis bezel
— not something Scarf draws, and not something we can cleanly hide
without disabling the toolbar surface itself. The native bezel is the
pill's frame; we just have to make the pill's interior read well inside
it.
Two changes to make the pill itself look like a toolbar tool inside
that bezel:
- Drop the colored dot, replace with a state-specific SF Symbol. The
icon's shape signals clickability (looks like a tool button), and its
color signals state (green/orange/yellow/red hierarchical). Less
"status chip", more "toolbar button with status".
- Icons per state:
- connected → checkmark.circle.fill (click to re-probe)
- degraded → stethoscope (click to run diagnostics, matches the
stethoscope on the Manage Servers row)
- idle → arrow.triangle.2.circlepath (checking/retry)
- error → exclamationmark.triangle.fill (click for stderr)
Horizontal padding = 4 so the icon-and-label sit balanced inside the
bezel rather than pushed up against its edges.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
macOS applies a centered emphasis bezel (light capsule + drop shadow)
to ToolbarItem(placement: .principal) — visible in screenshots as a
doubly-framed "capsule behind the pill" look. The pill itself doesn't
own that background; the toolbar placement does.
.primaryAction (right side of the toolbar) has no decorative
background, so the pill renders as just the colored dot + label text
directly on the toolbar surface. Fits the intended minimal look.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The toolbar item already draws its own bezel for the principal-placement
slot; painting a `Color.secondary.opacity(0.08)` capsule on top gave the
pill a doubly-framed look. Drop the pill's background + the padding that
was only there to fit inside the capsule. The dot + label now sit
directly on the toolbar's native surface.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The previous fix (direct ssh argv, bypassing transport.runProcess) got
us from 0/14 to 7/14, but \$H was empty everywhere it was referenced —
the user's 7/14 report showed:
- probe 4 (hermesHomeConfigured): PASS with empty detail
- probe 5 (hermesDirExists FAIL): "not a directory:" (empty after colon)
- probe 11 (sqlite3CanOpenStateDB FAIL): 'unable to open "/state.db"'
Root cause: `ssh host -- /bin/sh -c <script>` doesn't travel as three
argv entries to the remote. ssh concatenates them with single spaces
into one command string and sends that to the remote's LOGIN shell.
The login shell then runs `$LOGIN_SHELL -c "$string"`, and bash's
parser treats unquoted newlines inside `$string` as command separators.
So the first newline splits the script: `/bin/sh -c H="..."` becomes
one command (which runs in an ephemeral sh subprocess that exits
immediately), and every subsequent line runs in the login shell with
no \$H set.
TestConnectionProbe happens to still work because its downstream lines
don't depend on an assignment from the first line — but the diagnostic
script's \$H is used everywhere, so the entire script is effectively
running with \$H="".
Fix: pipe the script into `/bin/sh -s` on stdin via ssh's own stdin
channel. `sh -s` reads a shell program from stdin and executes it in
one process, variable scope preserved. Implementation uses
Process.standardInput with a Pipe, writing the script after proc.run()
and closing the write end so sh sees EOF. Same as
`cat script.sh | ssh host -- /bin/sh -s` from the command line.
Also: raw-output disclosure panel in the diagnostics sheet now shows
whenever ANY probe fails, not only when all fail. Partial failures are
the most common failure mode and the raw stdout is the only way to see
why a specific detail came back the way it did.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
First-run of diagnostics against a working Mardon returned 0/14 passing
with "(no output)" for every probe — including the trivial "emit
connectivity PASS" that the script emits unconditionally. That meant the
script wasn't executing as written; the parser saw `__END__` but no
probe lines.
Root cause: SSHTransport.runProcess wraps every argument through
`remotePathArg`, which is designed for PATHS (it rewrites `~/` to
`$HOME/` and double-quotes the result with backslash-escapes). Passing
a multi-line shell script with embedded `"$1"` / `"$2"` / `"$3"` and
`printf '\n'` escape sequences through that is corruption — the remote
sh -c receives a scrambled script and silently emits nothing.
TestConnectionProbe already works around this: it builds the ssh argv
directly (ssh host -- /bin/sh -c <script>) so the script travels as a
single opaque argv entry and ssh forwards it to the remote shell
unchanged.
Mirror that approach. RemoteDiagnosticsViewModel.execute now:
- For remote contexts: builds ssh argv directly (ControlMaster-aware,
uses the same socket as SSHTransport so it's effectively free after
the first connection), then passes /bin/sh -c <script> as argv.
- For local contexts: spawns /bin/sh -c <script> via Process directly.
Also surfaces raw stdout/stderr/exit-code in a disclosure panel at the
bottom of the sheet, visible only when ALL probes fail. Makes any
future transport-level breakage self-diagnosing: the user sees exactly
what the remote returned, not just "(no output)" rows.
Expose SSHTransport.controlDirPath (already static) as a public helper
so the diagnostics probe reuses the same ControlMaster socket as the
connection itself.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The Result-returning readers I added for the v2.0.1 diagnostics surface
were logging EVERY failure, including routine "file doesn't exist" cases
— e.g. skill.yaml files under ~/.hermes/skills/*/ that are optional
metadata, gateway_state.json before Hermes has started, memories/USER.md
on fresh installs.
In practice this meant the Platforms view and similar feature loaders
that walk directories and read optional files now spam the Console with
warnings on every refresh. That's noisier than useful and actively hides
the signal (permission denied, connection failure, sqlite3 missing) we
added the logging to surface.
readFileDataResult now detects the "no such file" case via either:
- TransportError.fileIO(_, "No such file...") from SSHTransport
- NSCocoaErrorDomain code 260 (NSFileNoSuchFileError) from FileManager
- NSPOSIXErrorDomain code 2 (ENOENT)
and suppresses the warning log for those paths. The Result.failure is
still returned, so any caller that cares (Dashboard's banner, Remote
Diagnostics) can still distinguish missing from present-but-unreadable.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Three users reported on day-one of v2.0 that SSH connections showed a
green "Connected" pill but every data view read as empty / "not running"
/ "not configured". The common thread across Docker, homelab VM, and
Ubuntu VPS setups: file-access failures on the remote that Scarf
silently swallowed into nil/empty defaults.
Stop swallowing errors
- HermesFileService gains Result-returning variants for the four
dashboard-critical readers: loadConfigResult, loadGatewayStateResult,
hermesPIDResult, plus readFileResult / readFileDataResult as
primitives. Each logs os.Logger warnings on failure. Legacy nil-
returning signatures remain as thin forwarders.
- HermesDataService.open records lastOpenError with humanized hints
for the top three failure modes — sqlite3 not installed, permission
denied, file not found. Each maps to concrete remediation (`apt
install sqlite3`, "check file perms", "set Hermes data directory").
Dashboard surfaces the error
- DashboardViewModel collects errors from every loader into
lastReadError, only on remote contexts (local skips the banner).
- DashboardView renders an orange banner above the stats with the
specific error text, a copy-selectable detail, and a "Run
Diagnostics…" button.
New Remote Diagnostics sheet (stethoscope icon)
- RemoteDiagnosticsViewModel runs 14 checks in one SSH round-trip via
a pipe-delimited "KEY|STATUS|DETAIL" protocol. Covers: SSH
connectivity, remote user/$HOME, Hermes dir existence + readability,
config.yaml readability + actual read (distinct from just `test -e`
which can't detect permission issues), state.db readability, sqlite3
binary presence, sqlite3 open test, hermes binary on non-login AND
login PATH, pgrep availability.
- Each probe row shows a targeted hint on fail (e.g. "check perms on
~/.hermes", "apt install sqlite3", "move PATH export from .bashrc
to .zshenv"). A Copy Full Report button dumps plain-text output
for GitHub issues.
- Accessible from Manage Servers (stethoscope button per row) and
directly from the yellow pill.
Yellow "degraded" connection state
- ConnectionStatusViewModel.Status gains .degraded(reason:) between
.connected and .error. After tier-1 `true` passes, the probe runs
tier-2 `test -r $HOME/.hermes/config.yaml` in the same SSH round-
trip. On tier-2 fail, pill is orange with "Connected — can't read
Hermes state" tooltip.
- Clicking a degraded pill opens Remote Diagnostics directly. Exactly
the symptom in #19 is now one click from a specific answer.
Auto-suggest remoteHome for non-default installs
- TestConnectionProbe.TestResult.success gains suggestedRemoteHome:
String?. When state.db isn't found at the configured path, the
probe also checks /var/lib/hermes/.hermes, /opt/hermes/.hermes,
/home/hermes/.hermes, /root/.hermes — the common alternates for
systemd services, Docker containers, and single-user VPSes — and
surfaces the first hit as a "Use this" suggestion in Add Server.
- AddServerSheet relabels "Remote ~/.hermes override" to "Hermes data
directory" with an explanation of when you'd use it.
README
- New "Remote setup requirements" subsection lists the four concrete
prereqs (SSH, sqlite3, pgrep, read access to ~/.hermes).
- New "Troubleshooting remote connections" paragraph describes the
diagnostics sheet and remoteHome auto-suggest for the two most
common failure modes.
Releases
- releases/v2.0.1/RELEASE_NOTES.md for the GitHub release body.
- Ship via `./scripts/release.sh 2.0.1`.
Closes#19.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The ControlMaster socket path ~/Library/Caches/scarf/ssh/%C can
exceed the 104-byte macOS Unix domain socket limit when the
username is long, causing ssh to silently exit 255 with
"unix_listener: path too long for Unix domain socket".
Switch to /tmp/scarf-ssh-<uid> which stays well within the limit.
Brings multi-window + multi-server + remote-SSH support to main,
plus the full correctness/UX/concurrency polish pass.
Two commits land:
- 00ca722 feat: multi-window + remote SSH server support (Phases 0-4)
- 5920923 feat: v2.0 — correctness + UX polish on multi-server + remote SSH
See releases/v2.0.0/RELEASE_NOTES.md for the user-facing summary.
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>