The hardcoded `providers` array in SettingsViewModel was never referenced — no view reads `viewModel.providers`; the Model picker uses the models.dev catalog via `ModelCatalogService.loadProviders()` and Provider is shown as a `ReadOnlyRow` in the General tab. Leaving the dead list around makes issues like #33 look plausible (users reasonably guess a stale enum is normalising `openai-codex` → `openai` on save, which the code does not actually do).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Three follow-ups from reviewing 1989fee (sidebar-width persist) and
4163595 (default server on launch):
- `SplitViewAutosaveFinder` hardcoded `"ScarfMainSidebar"` for every
window. Since Scarf's `WindowGroup` spawns one window per `ServerID`,
all windows shared the same `NSSplitView.autosaveName` — AppKit
documents that name as required-unique, and in practice per-window
widths collapsed onto a single UserDefaults key. Thread the window's
`ServerContext` in through `@Environment(\.serverContext)` (already
wired at `WindowGroup` construction) and suffix the name with the
server UUID.
- `setDefaultServer` fired `onEntriesChanged`, whose sole consumer is
`ServerLiveStatusRegistry.rebuild()` for menu-bar fanout. Flipping a
default flag doesn't change the set of servers; the callback was
semantic noise. Drop the call — SwiftUI views still redraw on the
flag flip via `@Observable`'s tracking of `entries`.
- The filled-yellow star in `ManageServersView` had a no-op action
inside `if !isDefault { ... }` but still animated its pressed state
on click. Replace the conditional with `.disabled(isDefault)` so the
row is visually inert when it already is the default.
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>
Adds the ability to manage multiple Hermes installations — local and
remote over SSH — from the same Scarf app, each in its own window.
Architecture:
- ServerContext value type carries per-server identity + paths through
every VM and service. ContentView routes serverContext into each
feature view's init; all 22 routed views thread it through to their
@State VMs.
- ServerTransport protocol with LocalTransport (FileManager/Process/
FSEvents) and SSHTransport (system ssh + scp + ControlMaster).
Services were ported from direct Foundation I/O to transport-routed
helpers so the same code runs against local or remote.
- WindowGroup(for: ServerID.self) gives each window its own
AppCoordinator + HermesFileWatcher + ChatViewModel. File menu has
Open Server commands with keyboard shortcuts (⌘1..⌘9). MenuBarExtra
fans out per-server with start/stop/restart controls.
- ServerRegistry persists connections to ~/Library/Application
Support/scarf/servers.json. Add Server sheet probes the remote with
ssh -v to capture the full handshake on failure.
- Connection-status pill in remote-window toolbars with silent reconnect
(3s retry on first failure, escalate to red after 2 consecutive),
known-hosts-mismatch + ssh-add hint cards with copy buttons.
Concurrency / UX hardening (the parts learned the hard way during
dogfooding — captured in the feedback memory):
- ServerContext exposes context.readText / readData / writeText /
fileExists / runHermes / openInLocalEditor as the canonical I/O
surface. Every VM uses these; never raw FileManager / Process() /
NSWorkspace.open with a Hermes path.
- SSHTransport.remotePathArg rewrites ~/foo to "$HOME/foo" so paths
expand correctly inside the sh -c command we build (POSIX shells
don't expand ~ inside any quotes).
- Heavy VM load() methods detach to a background task and commit
results back via MainActor.run, so synchronous ssh round-trips don't
beach-ball the UI. Applied to Dashboard, Memory, Settings,
MCPServers, Cron, Plugins, Personalities, QuickCommands, Skills,
Gateway, Health, CredentialPools.
- LoadingOverlay modifier shows a spinner over empty/stale section
content during background reloads.
- enrichedShellEnv (zsh -l -i probe, up to 8s) is now warmed at app
launch off-main so first MainActor caller doesn't block.
- Drop the file watcher's 5s heartbeat — FSEvents covers real changes
and the heartbeat was triggering wasted reloads across every
subscribing view.
Chat polish:
- ChatViewModel.hermesBinaryExists is a stored bool probed once at
init, not a sync transport call evaluated on every body re-render.
- MessageGroupView identifies assistant bubbles by array offset rather
than message.id, so the streaming → finalized id transition no
longer destroys + recreates the bubble.
- Static scroll anchor in RichChatMessageList prevents two onChange
handlers from racing on isWorking flips.
Branch state: feature complete, in active dogfooding. Plan + per-phase
status live at ~/.claude/plans/we-developed-an-application-harmonic-stroustrup.md;
the four hard-won transport/concurrency rules are saved in the
ServerContext-pattern feedback memory for future sessions.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The script was creating the GitHub release before pushing main, which
caused gh to auto-create the v<VERSION> tag at the then-current origin
HEAD (one commit behind the bump, since main hadn't been pushed yet).
The subsequent `git push origin v<VERSION>` was then rejected as
non-fast-forward, leaving the remote tag pointing at the wrong commit.
Caught during v1.6.2. The remote tag for v1.6.2 was force-corrected to
12610fa (the bump commit); the release artifacts themselves were always
correct.
New order: push main → tag main locally → push tag → gh release create.
Gh will now find the tag already on origin and attach to the right
commit. Non-destructive: a retry-safe release can always be resumed.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>