Closes the deferred Layer B install-drive that v2.7's smoke test
left as future work. The new test
(`testFullCatalogToInstallToDashboardJourney`) drives the full
install/uninstall pipeline end-to-end and validates 9 assertion
points along the way:
- Window surfaces under `--scarf-test-mode`
- Sidebar navigation to Projects
- Install sheet appears (URL handoff via launch arg)
- Parent-dir field accepts custom path + Continue
- Configure sheet renders + commit clicks
- Confirm Install runs the install pipeline
- Open Project advances to success view
- Project row appears in sidebar with uniquified name
- Right-click Uninstall + confirm Remove + Done removes the row
Runs in ~30s green on the dev Mac.
## What needed wiring up
**SwiftUI Menu / NSToolbarItem accessibility-bridging.** macOS
toolbar Menus don't propagate `.accessibilityIdentifier` through to
XCUITest — neither the menu trigger NOR the popup contents are
queryable by ID. Verified by tree-dump diagnostics. The test
sidesteps this entirely by routing the install URL through a new
`--scarf-test-install-url <https-url>` launch arg that calls
`TemplateURLRouter.shared.handle(scarf://install?url=...)` at App
init, gated on `TestModeFlags.shared.isTestMode`. Production
launches (no flag) untouched.
**Accessibility IDs added** on the new install/uninstall path:
- `templateConfig.commitButton`, `templateConfig.cancelButton`
- `projects.row.<name>`, `sidebar.section.<rawValue>`
- `projects.contextMenu.uninstallTemplate`
- `templateUninstall.confirmRemove`
- `templateInstall.success.openProject`
- `templateUninstall.success.done`
**Sandboxed-runner caveat.** The XCUITest runner's `/tmp` is
sandbox-protected (createDirectory throws EPERM); we use
`NSTemporaryDirectory()` which resolves to the runner's container
tmp (`~/Library/Containers/com.scarfUITests.xctrunner/Data/tmp/`),
which the unsandboxed Scarf app can read since it has full disk
access.
## Known cohabitation hazard (pre-existing uninstaller bug)
If the dev Mac already has a project from the same template
installed, the install pipeline uniquifies the new project's name
("HackerNews Daily Digest 2") but BOTH projects' cron jobs get
registered under the same `[tmpl:awizemann/hackernews-digest] Daily
HN digest` name. `ProjectTemplateUninstaller.loadUninstallPlan`
resolves cron jobs to remove by NAME and can target the wrong
project's job. The Layer B test surfaces this — manifests as: test
passes, the dev's real project's cron job disappears.
**Fix (separate work):** store cron-job IDs in
`<project>/.scarf/template.lock.json` at install time and resolve
by ID at uninstall time. Until then, the test docstring warns
about cohabitation; recovery is `hermes cron create` to recreate
the lost job.
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Cron rows now surface the same OAuth-refresh-revoked recovery flow as
chat instead of a generic red dot, plus three previously-missing
observability cues:
- ACPErrorHint.classify is reused on `job.lastError`. When it returns
`oauthRefreshRevoked(provider)` the detail pane shows the human hint
+ a "Re-authenticate" button that drops the user into Credential
Pools via `coordinator.pendingOAuthReauth = provider` — same wiring
ChatView's banner uses. Unrecognized errors fall back to the legacy
red `lastError` text (no regression).
- Row dot turns blue + pulses when `state == "running"` (taking
precedence over disabled / error / success); the detail header gains
a `ScarfBadge("running…", kind: .info)` next to active/paused. No new
polling — `HermesFileWatcher.lastChangeDate` (already wired into
ActivityView/Logs) drives `CronViewModel.load()` so state flips
surface within a watcher tick.
- "LAST RUN OUTPUT" replaces the inline `LAST OUTPUT` block with a
collapsible panel: a one-line summary (`<timestamp> — ok|error|running…`)
always visible, full monospaced terminal-style scroll view on
expand, auto-scrolls to bottom when new runs land.
Also fixes a pre-existing bug in `HermesFileService.loadCronOutput`:
Hermes nests per-run output under `~/.hermes/cron/output/<jobId>/<ts>.md`
but the loader treated the dir as flat, so the cron output panel never
rendered any content. The fix walks the per-job subdir + keeps the
legacy flat-file fallback for older Hermes layouts.
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(templates): hackernews-digest template + dogfooding test harness
First pass of the dogfooding-templates initiative. Each pre-release cycle
ships one new official `.scarftemplate` and uses installing/exercising
that template as the regression test. v1 lands the harness scaffolding
plus the first template under it.
- HackerNews Daily Digest template (`templates/awizemann/hackernews-digest/`):
config-driven (min_score / max_items / topics) cron-only template.
No secrets — keeps the harness minimal until the fake-Keychain shim
lands. Bundle validates against `tools/build-catalog.py`; entry added
to `templates/catalog.json`.
- `SCARF_HERMES_HOME` env-var override at `HermesProfileResolver` —
the seam every Layer-B test relies on to drive Scarf against an
isolated Hermes home. Bypasses cache + active_profile lookup; rejects
relative paths. 5 unit tests + 3 ServerContext integration tests.
- `TestModeFlags.shared.isTestMode` — reads `--scarf-test-mode` once
from `CommandLine.arguments`. Wiring only; gating sites (Sparkle,
capability probe, first-run walkthrough) land as Layer-B exercises
them.
- Layer A (`scarf/scarfTests/TemplateE2ETests.swift`): parses + plans
the shipped HN bundle the way the app does at install time;
asserts manifest, config schema, dashboard widgets, and cron prompt
contract. Mirrors the existing site-status-checker coverage.
- Layer B scaffold (`scarf/scarfUITests/TemplateInstallUITests.swift`):
proves the launch-arg + env-var plumbing reaches Scarf. Full install
click-through deferred until fixture-Hermes-home and accessibility
IDs land.
Wiki pages added separately on the `.wiki-worktree` branch:
- `Template-Ideas.md` — backlog of 9 v1-feasible templates +
full-spec v3 epic for Project-Site-as-Living-Surface (eBay listings
use case).
- `Test-Harness.md` — contributor guide for extending the harness.
Verification: scarfTests 124/124, ScarfCore 220/220, new Layer A 3/3,
Layer B scaffold 1/1, build-catalog.py + its 28 unit tests all green.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(test-harness): Layer B pivot to real ~/.hermes + a11y IDs + Sparkle gating
Discovered during Layer B work that XCUITest runners are sandboxed:
they can read ~/.hermes/ but writes throw NSFileWriteNoPermissionError.
That kills the SCARF_HERMES_HOME-based isolation pattern for UI tests —
snapshot/restore from inside the runner can't work. Pivot:
- Layer B drives the real ~/.hermes the dev Mac is already running
against. The harness assumes a working Hermes install (XCTSkip if
the binary isn't there). Cleanup is via the app's own UI flows
(which have full disk access), not direct file I/O. Layer A keeps
its env-var seam — those tests run inside the host app's address
space and write freely.
- SwiftUI's WindowGroup(for: ServerID.self) doesn't auto-surface a
window on a fresh XCUIApplication.launch(). The harness sends ⌘1
(the "Open Server → Local" menu shortcut wired in scarfApp.swift's
OpenServerCommands) to take the same code path real users hit via
Dock click.
- Real user home resolved via getpwuid(getuid()) rather than
NSHomeDirectory(), which inside the sandboxed runner returns
~/Library/Containers/com.scarfUITests.xctrunner/Data.
- 8 accessibility IDs added on the install path so the next iteration
can drive the full Templates → Install from URL → Parent dir →
Confirm Install flow without depending on view-tree label scraping:
templates.toolbar.menu, templates.installFromFile,
templates.installFromURL, templates.installURL.field,
templates.installURL.confirm, templateInstall.parentDir.field,
templateInstall.parentDir.continue, templateInstall.confirmInstall.
- TestModeFlags.shared.isTestMode now gates UpdaterService —
--scarf-test-mode launches Sparkle inert so update prompts don't
pop on top of an XCUITest-driven window. Production launches
unchanged.
FixtureHermesHome.swift removed — the fixture-tmpdir approach is
abandoned in favour of using the real installation. Layer A's
SCARF_HERMES_HOME tests still pass; they just don't need a populated
home to exercise path derivation.
Verification: scarfTests 124/124, ScarfCore 220/220, Layer B smoke
1/1 (after fresh build — XCUITest is sensitive to stale binaries).
catalog.py --check still green.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(chat): clip placeholder to TextEditor bounds and clear it on focus
Two related bugs in the Mac chat composer's placeholder overlay:
* The "Message Hermes… / for commands · drag images to attach" hint had
no width constraint, so on narrower window geometries it visibly
overflowed past the rounded TextEditor boundary. Add `lineLimit(1)`,
`truncationMode(.tail)`, and `frame(maxWidth: .infinity, alignment:
.leading)` so it ellipsizes inside the field instead.
* The opacity formula `text.isEmpty ? 1 : 0` only hid the placeholder
once content was typed, not when the field gained focus. Standard
NSTextField / UITextField semantics clear the placeholder on focus.
Switch to `(text.isEmpty && !isFocused) ? 1 : 0` so the hint
disappears the moment the user clicks into the field.
The opaque-background ghosting mitigation from #65 is preserved
unchanged.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(chat): surface OAuth refresh-revoked errors with in-app re-auth
When an OAuth provider's refresh token was revoked, Hermes printed
"Refresh session has been revoked. Run `hermes model` to re-authenticate."
to stderr but Scarf swallowed it — the user saw a typing indicator that
silently disappeared with no banner, no system message, no actionable
hint. The error classifier had no pattern for OAuth revocation.
- `ACPErrorHint.classify` now returns a `Classification` struct
carrying the hint plus an optional `oauthProvider` name. New
patterns match "Refresh session has been revoked", "re-authenticate",
and 401-with-OAuth-provider-name (whole-word so `anthropicapi`
doesn't false-match `anthropic`). Provider extraction lets the UI
dispatch the right re-auth flow.
- Chat error banner ([ChatView.swift]) gains a "Re-authenticate" button
when an OAuth provider was identified — sets
`AppCoordinator.pendingOAuthReauth` and routes to Credential Pools.
- Credential Pools view consumes the hand-off slot to auto-present
AddCredentialSheet seeded with the affected provider, AND adds a
per-row "Re-authenticate" button on every OAuth provider so users
who go straight there don't have to retype the provider name.
- `AddCredentialSheet` accepts an optional `initialProvider` that
pre-fills providerID + authType=.oauth; the existing Nous-vs-PKCE-
vs-CLI gate dispatches re-auth identically to first-time setup —
reuses the same `OAuthFlowController` / `NousSignInSheet` plumbing,
no new flow code.
Verification: ScarfCore 221/221 (incl. new
errorHintsClassifyOAuthRefreshRevoked covering the four patterns +
word-boundary guard); Mac app builds clean.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* feat(catalog): in-app template catalog browser + sentinel-marker test isolation
The v2.8 catalog browser surfaces every shipped .scarftemplate from
awizemann.github.io/scarf/templates/catalog.json directly in Scarf.
Users now discover and install templates without leaving the app.
Closes the gap that publishing the catalog updated the website but
nothing inside Scarf.
Architecture mirrors NousModelCatalogService 1:1: cache-first fetch,
24h TTL at ~/.hermes/scarf/catalog_cache.json, result enum (fresh /
cache / fallback) with bundled fallback so a fresh-install / offline
user still sees something. Search + category filter + sort
(awizemann official first). Detail page renders entry.config schema
preview without separate README fetch — what's in catalog.json is
what we render. Install hands the HTTPS URL to the existing
TemplateInstallerViewModel.openRemoteURL flow; nothing about the
installer itself changes.
Files:
- Core/Models/CatalogEntry.swift — Decodable mirror of catalog.json
per-template shape. Identity-based Equatable/Hashable on `id`.
- Core/Services/CatalogService.swift — fetch + cache + fallback
- Core/Services/InstalledTemplatesIndex.swift — walks projects.json +
template.lock.json to build [templateId: version] map; classify()
helper for Installed / Update available / Not installed badges
- Features/Templates/ViewModels/CatalogViewModel.swift — @Observable
- Features/Templates/Views/{CatalogView,CatalogRowView,CatalogDetailView,CatalogCategoryFilter}.swift
- Packages/ScarfCore/.../HermesPathSet.swift — adds catalogCache path
- Features/Projects/Views/ProjectsView.swift — Templates toolbar
menu now opens with "Browse Catalog…"; sheet binding.
Tests (20 new, all passing in isolation):
- CatalogServiceTests (6) — live catalog.json snapshot, cache lifecycle,
staleness boundary, schema-version mismatch rejection, bundled fallback
- InstalledTemplatesIndexTests (5) — empty registry, templated project,
ad-hoc project skip, corrupt lock skip, classify() branches
- CatalogViewModelTests (6) — search filter, category filter, official-first
sort, deduped categories, install state, install URL pass-through
Accessibility IDs (6, on the catalog path): templates.browseCatalog,
catalog.searchField, catalog.refreshButton, catalog.row.<detailSlug>,
catalog.categoryFilter, catalogDetail.installButton.
## Sentinel-marker hardening on SCARF_HERMES_HOME (incident response)
While iterating on v2.8 tests, the env-var override pattern racing
under Swift Testing's parallel-suite scheduler caused
~/.hermes/scarf/projects.json to be overwritten with fixture data
from ProjectsViewModelTests. Recovered the user's projects from the
on-disk dirs they referenced + cron-job prompt paths (6 projects
restored).
To make this class of incident impossible going forward:
HermesProfileResolver.scarfHermesHomeOverride() now requires the
override path to contain a sentinel marker file
(`.scarf-test-home-marker`). Without the marker, the override is
ignored and Scarf falls through to the real ~/.hermes/. Even if a
test crashes mid-teardown leaving the env var set, even if the var
leaks to a non-test process, even if a misconfigured launchctl plist
exports it — the override only activates against directories that
explicitly opt in by carrying the marker. Tests drop the marker in
their tmpdir setUp; production never carries it.
HermesProfileResolverTests gains overrideIsIgnoredWhenMarkerMissing
which verifies the guard is load-bearing. All test files using
SCARF_HERMES_HOME (CatalogServiceTests, CatalogViewModelTests,
InstalledTemplatesIndexTests, TemplateE2ETests) now drop the marker
before setenv.
Verification: 20/20 v2.8 + v2.7 hardened tests pass; 45/45 adjacent
existing tests pass; ScarfCore package tests pass (221/221); catalog
validator clean (3 templates); wiki secret-scan clean.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(swift6): retroactive conformance + verbatim help text + xcstrings refresh
Three small Swift 6 compile-cleanups that landed during the
dogfooding-templates iteration:
- MessageSpeechService — drop `@preconcurrency` on the
AVSpeechSynthesizerDelegate conformance now that the protocol's
Sendable annotations are upstreamed.
- ChatView — mark `RichChatViewModel.PendingPermission: Identifiable`
as `@retroactive`. We don't own either the type or the protocol; the
Swift 6 compiler flags this so downstream breakage is loud if
ScarfCore ever adds the conformance upstream.
- CredentialPoolsView — wrap the `.help(...)` string in
`Text(verbatim:)` so the backticks render literally instead of being
interpreted as markdown inline-code by the LocalizedStringKey
overload (which `.help(_:)` rejects styled).
Localizable.xcstrings: auto-generated catalog refresh picking up the
new active-profile + chat error-hint strings landed in earlier
commits on this branch (acd3692, 301806d).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix(catalog): error logging + MainActor I/O + semver pre-release + decoder fault tolerance
- InstalledTemplatesIndex: replace bare `try?` reads/decodes with logged
do/catch so corrupt registry/lock files leave a breadcrumb instead of a
silent nil.
- InstalledTemplatesIndex.isVersionNewer: handle pre-release suffixes per
semver §11 — `1.0.0-beta` no longer reports as newer than `1.0.0`,
preventing a ghost "Update available" that would downgrade users.
- CatalogViewModel.refresh: dispatch the synchronous index walk through
`Task.detached` so registry + N lock-file reads don't run on
@MainActor.
- Catalog decoder: per-element fault tolerance via custom `init(from:)` —
one malformed catalog entry is dropped with a logged warning instead
of failing the whole catalog decode (honors the per-entry doc-comment
contract).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Two related bugs in the Mac chat composer's placeholder overlay:
* The "Message Hermes… / for commands · drag images to attach" hint had
no width constraint, so on narrower window geometries it visibly
overflowed past the rounded TextEditor boundary. Add `lineLimit(1)`,
`truncationMode(.tail)`, and `frame(maxWidth: .infinity, alignment:
.leading)` so it ellipsizes inside the field instead.
* The opacity formula `text.isEmpty ? 1 : 0` only hid the placeholder
once content was typed, not when the field gained focus. Standard
NSTextField / UITextField semantics clear the placeholder on focus.
Switch to `(text.isEmpty && !isFocused) ? 1 : 0` so the hint
disappears the moment the user clicks into the field.
The opaque-background ghosting mitigation from #65 is preserved
unchanged.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Profile selection had no apparent effect on Webhooks/Sessions/SOUL.md/Memory
even after restart in some user setups. The path-resolution code reads
~/.hermes/active_profile correctly on paper, so the failure mode is likely
environment-specific (HERMES_HOME exported in the shell, in-process state
that didn't reset on what the user perceived as a restart, etc). Layer a
defense that's correct regardless of root cause:
* New AppRelauncher helper spawns a fresh `open -n <bundleURL>` and asks
the current process to terminate after a 250ms delay. Refuses to fire
from Xcode/DerivedData (the .debugBuild guard) so debug sessions don't
lose their attached debugger.
* ProfilesViewModel.switchAndRelaunch runs `hermes profile use`, calls
HermesProfileResolver.invalidateCache(), then relaunches via the helper.
Existing switchTo() also gains the cache-invalidation step so the
context-menu "Set Active (no relaunch)" path stays self-consistent.
* ProfilesView replaces the passive "Restart Scarf after switching" text
with a confirmation-gated `Switch & Relaunch` primary button on the
detail pane plus the same item in each row's context menu. Confirmation
dialog flags that all Scarf windows will close.
* SidebarView header gains a brand-tinted ScarfBadge showing the
currently-active profile on local contexts. Click to jump to the
Profiles tab. The chip refreshes on `selectedSection` change so a
terminal-side `hermes profile use` is visible after the next nav.
* HermesProfileResolver success logs gain `name=…, home=…, source=…`
key=value structure across all three resolution paths (file / file-default /
default-no-file). `log show … | grep ProfileResolver` now answers
"what did the resolver decide?" unambiguously for support requests.
Closes#70
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Send button is now a 44pt circular target with an explicit color swap
(rust accent → background-tertiary) on disable, instead of relying on
SwiftUI's default opacity dim — addresses the "first tap doesn't
register" complaint by making the inactive state visibly different in
both light and dark mode. Paperclip and text field both gain a 44pt
minimum height so the row feels modern and roomy.
The text field swaps `.roundedBorder` for a plain field with a
ScarfRadius.xl rounded fill (ScarfColor.backgroundSecondary) and a
borderStrong stroke. Outer paddings and HStack spacing migrate from
magic numbers to ScarfSpace tokens.
Preserves verbatim: the `.toolbar { ToolbarItemGroup(placement: .keyboard) }`
keyboard-dismiss chevron (issue #51), draft persistence, .submitLabel,
@FocusState, photo-picker wiring, attachment-strip rendering, and every
.disabled() predicate.
Closes#69
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
iOS-only patch carrying the rotation lock + chat-start preflight
off-MainActor fixes from cb164f0. Mac side stays on the v2.6.0
binary already shipped (build 29 archive); this build number bump
only affects future Mac archives, not the one already notarized.
Uploaded to App Store Connect via altool — Apple processing now,
will land in TestFlight once the binary clears the post-upload
scan (typically 5–15 min).
Two iOS-specific crash classes from the v2.5.1 TestFlight feedback
round:
**Rotation crash** — locked the iPhone target to
`UIInterfaceOrientationPortrait` only (was Portrait + LandscapeLeft
+ LandscapeRight). The phone can't rotate the app at all anymore,
so any layout path that wasn't audited for size-class transitions
is no longer reachable. iPad orientation list left alone (target
device family is iPhone-only anyway).
**"Crash while typing" / "trying to continue an existing
conversation"** — `ChatController.passModelPreflight()` was doing
a synchronous SSH read (`context.readText(configYAML)`) on
`@MainActor` during chat-start. On a remote ScarfGo context that
blocks the main thread for seconds; iOS's non-responsive-app
watchdog kills the process around 10s. To the user this surfaces
as a "crash" while they're typing — they kept tapping the keyboard
while the connect was hung. Move the read to `Task.detached` and
await it; the UI stays responsive while the SSH I/O drains. Three
callers (`start`, `start(projectPath:)`, `startResuming`) updated
to `await passModelPreflight(...)` — they were already async.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
TestFlight feedback / crash JSONs land here while we're working
through an iOS fix round. They carry tester PII (emails, carriers,
locales) and aren't meant for the public repo. Kept local-only;
deleted after the round closes.
Replaces the 2.5 "What's New" block with a 2.6 summary that
covers the Hermes v0.12 surfaces (Curator, multimodal images, 5
new providers, Teams + Yuanbao, Kanban, Skills v0.12, cron
--workdir, settings deltas, ScarfGo Webhooks/Plugins/Profiles)
and the post-merge chat fix round (#67/#68/#65/#62/#63/#64/#66/
#61). Verified-versions table gains v0.12.0 as the current target;
recommended-Hermes line points at v0.12.0+ for full feature
support. ScarfGo block kept but de-emphasised since it shipped
in 2.5.
Adds a "Chat composer + transcript (post-merge round)" subsection
to the bug-fixes block covering #67, #68, #65, #62, #63, #64,
#66, and the partial #61 ACP-timeout bump. The pre-merge
test-target / iOS-build fixes stay grouped under "Pre-merge".
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Field-reported (#61): under realistic concurrency where the
Hermes gateway is also running, state.db lock contention
(Discord sync / skill registration / cron scheduling all
holding write locks) stalls ACP's `initialize` / `session/new` /
`session/load` past the previous 30s watchdog, surfacing as
"Starting…" indefinitely or an opaque timeout error.
SQLite contention on a healthy host clears in seconds, so 60s
gives the lock-resolution path room to breathe while still
surfacing genuinely broken transports promptly. `session/prompt`
remains untimed (it streams events and can run for minutes).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds a small speaker glyph to the metadata footer of each settled
assistant bubble. Tap to read the reply aloud through
`AVSpeechSynthesizer`; tap again (or any other bubble's button) to
stop. Picks up the user's macOS Spoken Content default voice
automatically — no Hermes dependency, works offline.
- New `MessageSpeechService` (`Core/Services/`) — shared
`@Observable` synthesizer; `playingMessageId` drives icon
state. Markdown control characters (asterisks, backticks,
link syntax) are stripped before speech so the user doesn't
hear "asterisk asterisk bold".
- `SpeakMessageButton` lives outside `RichMessageBubble.==` so
the bubble's Equatable short-circuit doesn't freeze the icon
when playback flips between messages.
The full Hermes-provider TTS pipeline (Edge / ElevenLabs /
OpenAI / NeuTTS / Piper from Settings → Voice) is a much bigger
follow-up — wiring per-provider audio fetching, caching, and
streamed playback is its own quarter. v2.6.0 ships the immediate
"listen while doing something else" affordance.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Sending a long prompt and switching to other work — the canonical
async-agent flow — required polling the chat to know when the
response landed. Wire a local UNUserNotificationCenter notification
to fire when an ACP prompt completes while Scarf isn't the
foreground app.
- New `ChatNotificationService` (Core/Services) handles lazy
authorization, foreground gating, and post.
- `ChatViewModel.sendViaACP` calls it on successful prompt
completion with the assistant's first-line preview and the
active session title.
- Settings → Display → Feedback adds a "Notify when Hermes finishes"
toggle, default on. Skipped for `/steer`-style mid-run sends —
those don't end a turn.
Dock badges and per-session unread state from the issue are
worthwhile follow-ups but out of scope for v2.6.0.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
When a user sent a prompt and immediately switched to a different
session before Hermes flushed the row to state.db, `resumeSession`
ran `reset()` (which clears `messages`) and then
`loadSessionHistory` read the un-persisted DB and replaced the
array with an empty result. The user's bubble came back blank or
disappeared on return.
Hold local-only user messages (negative ids) in a per-session
cache that survives `reset()`. `loadSessionHistory` re-injects any
still-pending entries for the loaded session, dedups against any
DB row that finally caught up (matching content with persisted id
≥ 0), and clears the cache as the DB confirms each entry.
Cache is bounded by sessions sent-in during one app run; entries
clean themselves out as Hermes persists, and orphaned entries
(deleted sessions etc.) are tiny and never re-surface since
session ids are unique per session.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
`RichChatInputBar`'s `@State` `text` and `attachments` survived
session switches because the surrounding view tree is structurally
identical across sessions — SwiftUI happily reused the same
instance and leaked the previous session's unsent draft into the
new one.
Bind the composer's identity to `richChat.sessionId` so SwiftUI
rebuilds the view (and its `@State`) on session change. A stable
fallback string covers the brief "no session selected" window;
using `UUID()` here would mint a fresh id on every render and
trash the composer per body re-eval.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
`TextEditor`'s NSTextView surfaces a typed glyph one frame before
the SwiftUI binding propagates, so the bare `if text.isEmpty`
overlay rendered the translucent placeholder text directly on top
of the just-typed character — the "behind or around" ghost the
reporter described.
Two mitigations:
- Pin an opaque `ScarfColor.backgroundSecondary` rect behind the
placeholder Text. During any single-frame binding lag the user
now sees a clean placeholder rather than layered glyphs.
- Switch the conditional to `.opacity(text.isEmpty ? 1 : 0)` so the
view tree stays stable per keystroke. Pairs with the composer
perf fix from #67.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The chat font-size slider only set `\.dynamicTypeSize` on the chat
root, but ScarfFont tokens are fixed-point (`Font.system(size: 14, …)`)
so dynamic type didn't reach bubble text, reasoning, tool chips, code
blocks, or markdown headings. Slider moved between 85%–130% with
little visible effect.
Plumb a separate `\.chatFontScale: Double` env value from
`RichChatView` and have the chat content views read it:
- `RichMessageBubble` — user bubble body, reasoning (disclosure +
inline), REASONING label, token chip, tool-chip name, metadata
footer.
- `MarkdownContentView` — paragraphs (now pinned to a scaled body
font instead of inheriting), headings (1..5), inline-rendered code
blocks, code-language label.
- `CodeBlockView` — code body and language label.
`ChatFontScale.{body, callout, caption, captionStrong, caption2,
mono, monoSmall, codeBlock, codeInline}(_ scale:)` helpers mirror
`ScarfFont`'s base sizes so scale = 1.0 is byte-for-byte identical
to today's UI; the slider now actually moves the visible chat text.
Other surfaces (settings, sidebar, etc.) still use the static
ScarfFont tokens — chat scaling stays scoped to the chat surface.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Typing in the chat composer became unusably laggy because
`updateMenuState()` ran on every keystroke and unconditionally wrote
both `showMenu` and `selectedIndex`. Two state writes inside one
`onChange(of: text)` handler tripped SwiftUI's "action tried to
update multiple times per frame" warning, and each redundant write
forced a full body re-eval — visible as the slow-HID stalls and the
main-thread layout churn the reporter captured in sampling.
Two changes:
- Compute the new selection up front and write only the deltas. Same
semantics; no spurious mutations.
- Short-circuit the whole handler when the user is composing normal
text (no `/` prefix) and the menu is already hidden — the common
case. Stops paying for `SlashCommandMenu.filter` on every keystroke
of regular prose.
- Replace `.onChange(of: commands.map(\.id))` with
`.onChange(of: commands.count)`. The mapped form allocated a fresh
`[String]` on every body re-eval; counting is one int read.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Xcode-autogenerated strings for the v12 surface — curator chip labels,
image attachment button + counter, archived-skill banner — that the
extractor produced while the v12-updates branch was being authored.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
CLAUDE.md was rewritten in 3d85b91 to describe the new v0.12 surfaces
but several claims drifted from what actually shipped (or have since
walked back during the review-fix pass):
- Curator iOS panel was described as "read-only"; it ships Run Now /
Pause / Resume actions and inline pin toggles.
- Curator path symbols were named `curatorReportJSON` / `curatorReportMD`;
the actual additions to `HermesPathSet` are `curatorLogsDir` and
`curatorStateFile`, with the per-cycle `run.json` / `REPORT.md`
resolved at runtime via the state file's `last_report_path`.
- The `flush_memories` bullet claimed Scarf had dropped the field; it's
preserved on pre-v0.12 hosts via `hasFlushMemoriesAux` (restored in
commit 33022ae).
- The cron `--workdir` bullet didn't mention the capability gating that
landed in commit 4a2ef74, nor the empty-string clear gesture from
commit 46cec81.
- The v0.12 surface list omitted the iOS Phase H catch-up
(Webhooks/Plugins/Profiles read-only tabs + HermesVersionBanner)
shipped in commit 799332f.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
`ingestPickerItems` ran loadTransferable + encode sequentially per
selected image. PhotosPickerItem.loadTransferable is async and hops
off MainActor (nonisolated), but for 5+ iCloud-backed PHAssets the
sequential pipeline meant five round-trips back-to-back instead of
five concurrent ones.
Switched to `withTaskGroup` keyed by selection index so:
- Slot cap is computed once up front and items past the cap are
dropped (previously we mid-loop-broke after the first overage).
- Each item's loadTransferable + ImageEncoder runs concurrently.
- Results land back in selection order via index sort, so the
attachment chip row matches what the user picked.
Errors carry a Sendable `String` message rather than the raw `Error`,
which isn't Sendable under strict concurrency.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The post-load assignment was a true no-op:
`self.lastError = parsed.isEmpty && !result.isEmpty ? nil : nil` —
both ternary branches assigned `nil`. The intent (visible from the
condition shape) was to set an error message when the CLI returned
text but the parser produced no webhooks.
Now that branch sets a "Couldn't parse webhook list output" message
which the existing banner at line 33 renders. Normal flow (parse
succeeds, or empty output) still clears the error.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
`KanbanViewModel.load` previously assigned the combined stdout+stderr
output of `runHermesCLI` into both the JSON-parse `data` and the
`stderr` slot of its result tuple. Two consequences:
- On non-zero exit, the error banner showed combined output (often
stdout usage text concatenated with the actual error), reducing the
signal-to-noise ratio when troubleshooting.
- On non-zero exit with mixed output, JSON decoding could fail because
stderr text was prepended to the JSON body.
Added `HermesFileService.runHermesCLISplit` — a sibling of `runHermesCLI`
that returns `(exitCode, stdout, stderr)` separately, leaning on the
already-separated `stdoutString` / `stderrString` from the transport
layer. KanbanViewModel now uses it: stdout is the JSON parse target,
stderr is the error-banner source. Existing `runHermesCLI` callers are
untouched.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
`updateJob` only emitted `--workdir <path>` when the value was non-empty,
so once a workdir was set on a job, the user had no way to remove it
through Scarf — clearing the TextField and saving was a silent no-op.
Hermes' `cron edit --workdir` argparse documents passing an empty string
as the explicit clear gesture (mirroring the existing `--script` shape,
which already passes empty through here). Drop the `!isEmpty` predicate
so a non-nil value — including "" — reaches the CLI.
The previous capability gate keeps this safe on pre-v0.12 hosts: CronView
passes `workdir: nil` there, so the flag is omitted and v0.11 argparse
is never asked about an unknown arg.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The disabled-skill row's "OFF" pill used `.font(.system(size: 9, weight:
.semibold))`, which the project CLAUDE.md flags as a code smell ("bypass
the type scale… is a code smell"). The design system documents
`scarfStyle(.captionUppercase)` as the canonical badge font; switching
to it picks up the matching tracking + uppercase casing as a bonus.
The pin glyph above (`Image(systemName: "pin.fill").font(.system(size:
9))`) is left as-is — that's intentional glyph sizing on an `Image`,
which the design rule explicitly excludes.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
`readDisabledSkillNames` broke out of the loop on `leading <= baseIndent`,
but PyYAML's default `yaml.dump` (what Hermes uses to write the disabled
list) emits list items at the SAME indent as the parent key:
skills:
disabled:
- foo
- bar
Here `disabled:` is at indent 2 and `- foo` is also at indent 2, so the
old check terminated before any item was appended — every disabled skill
written by Hermes would have appeared enabled in the UI.
Now the loop only breaks when the indent is strictly shallower than the
`disabled:` line, or when a same-indent line isn't a list item (sibling
key — that's still the end of the block). The deeper-indent layout still
parses correctly.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Phase B removed the `flushMemories` field from `AuxiliarySettings`,
the `aux("flush_memories")` reader from the YAML parser, and the
"Flush Memories" row from `AuxiliaryTab.tasks` outright. But
`HermesCapabilities.hasFlushMemoriesAux` still claims (with inverse
semantics) that the row should stay visible on pre-v0.12 hosts where
the task is alive. Project CLAUDE.md documents the same contract.
Restored:
- `AuxiliarySettings.flushMemories: AuxiliaryModel` (and `.empty`).
- `aux("flush_memories")` in both YAML readers
(`HermesConfig+YAML.swift` and the `HermesFileService` mirror).
- `AuxiliaryTab.tasks` appends the Flush Memories row when
`hasFlushMemoriesAux` is true, mirroring how `curator` is appended
on the v0.12+ branch.
On v0.12+ hosts the flag is `false` so the field stays `.empty` and
the row is hidden — no behaviour change for current users.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
`HermesCapabilities.hasCronWorkdir` was added but never consumed: the
editor sheet always rendered the Workdir TextField and the view model
unconditionally appended `--workdir <path>` whenever the field was
non-empty. On a pre-v0.12 host argparse rejects the unknown flag and
the entire `cron create`/`cron edit` call fails.
Two-layer gate:
- CronJobEditor takes a `supportsWorkdir` flag and hides the field on
pre-v0.12 hosts.
- CronView reads `\.hermesCapabilities` and forces the workdir argument
to "" / nil when the capability is absent, so an editing-an-existing-
job path that hydrates `form.workdir` from a pre-existing value can't
smuggle the flag through.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
`presentImagePicker()` ran `Data(contentsOf: url)` synchronously on
MainActor inside the URL loop before the detached `encode()`. A 24 MP
HEIC at 8-15 MB stalled the chat composer per file. The drag/drop and
paste paths already read off-main via `loadObject`/`loadDataRepresentation`
callbacks; this brings the open-panel branch in line by capturing the
URLs into a `Task.detached` and reading bytes there.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds releases/v2.6.0/RELEASE_NOTES.md covering every Phase A-H surface
(Curator, multimodal image input, 5 new providers, Skills v0.12,
Settings deltas, Cron workdir, Teams + Yuanbao, read-only Kanban, iOS
read-only Webhooks/Plugins/Profiles, version banner, internal
capability detector). Drops a paragraph at the top noting Hermes
v0.11 hosts continue to work — every new surface is gated on
HermesCapabilities so v2.6 against v0.11 looks identical to v2.5.2
against v0.11.
Polishes CLAUDE.md inaccuracies introduced in Phase A's first pass:
- ACP image wire shape: corrected to {"type":"image","data":...,"mimeType":...}
(matches acp.schema.ImageContentBlock); previous Anthropic-style
source: {type: base64, ...} sketch was wrong.
- Cron --context-from: clarified that Hermes hasn't exposed it as a
CLI flag yet (read-only via HermesCronJob.contextFrom), only
--workdir is writable.
- hermes memory setup: noted that the interactive verb stays in
Terminal (no in-app shellout); Settings → Memory just exposes the
provider picker.
- Skills surface: more precise about which CLI verbs back the Mac UI
affordances and why the disable-toggle is deferred to v2.7.
215 ScarfCore tests green; both Mac and iOS schemes build clean. Wiki
update + the actual release.sh ship are deferred to the user's
typical release-prep flow (the wiki repo is a separate worktree
that needs scripts/wiki.sh pull/commit/push, and release.sh expects
a clean working tree pointed at main).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Closes the iOS read-only inspection gap on three CLI-driven Hermes
surfaces and adds a Hermes-version banner so mobile users on remote
v0.11 hosts see the upgrade nudge inline.
Components:
- Scarf iOS/Components/HermesVersionBanner.swift — yellow banner shown
on the Dashboard when the active server's HermesCapabilities returns
detected==true && hasCurator==false. One-tap session dismiss; comes
back on next app open. Lists the v0.12 capabilities the user is
missing out on (curator, multimodal, new providers).
- Scarf iOS/Webhooks/WebhooksView.swift — read-only list rendered from
`hermes webhook list`. Tolerant block parser mirrors the Mac
WebhooksViewModel shape so future drift fixes in one canonical place
if/when promoted into ScarfCore. Detects the "platform not enabled"
state and shows a setup-required pane instead of synthesizing rows
from instructional text.
- Scarf iOS/Plugins/PluginsView.swift — filesystem-first scan over
`~/.hermes/plugins/<name>/` with plugin.json / plugin.yaml manifest
reads (mirrors the Mac VM). Enabled/disabled badge, version, source.
Uses HermesYAML.parseNestedYAML / stripYAMLQuotes from ScarfCore
(already public).
- Scarf iOS/Profiles/ProfilesView.swift — `hermes profile list` text
parser with active-profile highlighting from
`~/.hermes/active_profile`. Defensively handles both Rich box-drawn
table output and plain-text fallback.
ScarfGoTabRoot's System tab gains an "Inspect" section with the three
new NavigationLinks. None are capability-gated — the underlying
list verbs exist on both v0.11 and v0.12, so the read views work
against either Hermes version without surprises.
Tests: 215 ScarfCore tests pass; both Mac and iOS schemes build clean.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Mac-only Phase G surfaces. Three additions:
Cron — `--workdir` flag (v0.12+):
- HermesCronJob carries `workdir: String?` and `contextFrom: [String]?`
fields (the latter is read-only from CLI today; YAML-only chaining).
- FormState.workdir; CronJobEditor adds an absolute-path field;
CronViewModel.createJob/updateJob forward `--workdir` when set,
omit it when blank so v0.11 hosts (which don't know the flag) keep
working unchanged.
Platforms — Microsoft Teams + Yuanbao (v0.12+):
- KnownPlatforms gains the two new platform identifiers + icons.
- PlatformsView adds inline read-only setup panels for each since the
full setup flow lives outside Scarf (OAuth dance for Yuanbao, plugin
install for Teams). Both panels surface the type, the recommended
setup command, and the current configured/connected status the
existing connectivity probe already understands.
Kanban — read-only list (v0.12+):
- HermesKanbanTask Sendable Codable model mirroring
`_task_to_dict` in hermes_cli/kanban.py.
- KanbanViewModel polls `hermes kanban list --json` every 5s while the
view is foregrounded; status filter dropdown maps to `--status`.
Empty list and "no matching tasks" text outputs both render the
empty state cleanly.
- KanbanView: page header + status badges + meta chips
(id/assignee/workspace/skills) per row. No create/claim/dispatch UI
— multi-profile collaboration was reverted upstream while the
design is reworked, so v2.6 ships read-only and defers the editor
to v2.7+.
- AppCoordinator.SidebarSection.kanban + ContentView routing.
SidebarView's capability-aware `sections` filters out the row when
`HermesCapabilities.hasKanban` is false.
Tests: 215 ScarfCore tests pass; both Mac and iOS schemes build clean.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Surfaces the v0.12 config knobs that landed without their own dedicated
UI elsewhere:
- prompt_caching.cache_ttl picker (5m default, 1h opt-in) — reduces
cache writes on long agent loops with stable system prompts.
- redaction.enabled toggle — Hermes flipped this off by default in
v0.12 because the substitution corrupted patches; security-sensitive
users can flip it back on here.
- agent.runtime_metadata_footer toggle — opt-in compact footer on each
final reply (provider/model/cost/turn count).
- TTS provider list gains "piper" — native local TTS engine new in
v0.12.
- Terminal backend list gains "vercel" — Vercel Sandbox backend for
execute_code/terminal added in v0.12.
The new "Caching & Redaction" section in AdvancedTab is gated on
HermesCapabilities.hasPromptCacheTTL — pre-v0.12 hosts don't see
toggles that would write keys Hermes ignores. The Piper + Vercel
options ride along unconditionally because Hermes silently accepts
unknown values and falls back to safe defaults.
Model + parser:
- HermesConfig grows three optional scalar fields (cacheTTL: String,
redactionEnabled: Bool, runtimeMetadataFooter: Bool). All three
have init defaults so existing call sites — including
HermesConfig.empty — keep compiling.
- Both YAML readers (HermesFileService for Mac, HermesConfig+YAML for
the package) now parse the new keys with v0.12-defaults.
Tests: 215 ScarfCore tests pass; both Mac and iOS schemes build clean.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Hermes v0.12 added three skills surfaces Scarf can now reach:
- Direct-URL install: `hermes skills install <https://...>` lets users
pull a one-off skill without going through a registry. Mac SkillsView
grew an "Install from URL…" toolbar button (capability-gated on
HermesCapabilities.hasSkillURLInstall) opening a sheet with the URL
field plus optional --category / --name overrides.
- Reload: `hermes skills audit` rescans `~/.hermes/skills/` and refreshes
the agent's view of available skills without restarting. Wired to a
"Reload" toolbar button next to the install button on Mac.
- Enabled state: skills.disabled in config.yaml is now read at scan time
(SkillsViewModel.readDisabledSkillNames). Disabled skills render
strikethrough + an "OFF" pill on Mac and iOS rows so users see what
Hermes won't load. iOS detail view explains the state in plain text.
- Curator pin badge: pinned-skill names from
`~/.hermes/skills/.curator_state` (SkillsViewModel.readPinnedSkillNames)
surface as a pin glyph on each row. Mac sidebar + iOS list both show
it; iOS detail view explains "pinned by curator — won't auto-archive."
Model + scanner:
- HermesSkill gains `enabled: Bool` (default true) and `pinned: Bool`
(default false). Both default to backwards-compatible values so
unmodified call sites keep compiling.
- SkillsScanner.scan now takes optional `disabledNames` and
`pinnedNames` sets and applies them per skill at scan time.
- SkillsViewModel.load auto-fetches both sets internally so Mac/iOS
callers don't have to plumb curator state manually; an opt-in
`pinnedNames` override is available for the Curator screen which
has a fresher snapshot in hand.
Tests: 215 ScarfCore tests pass; both Mac and iOS schemes build clean.
Note: the disable-toggle path (writing the array back into
config.yaml) is deferred to v2.7 — Hermes ships
`hermes skills config` as an interactive verb only, and we'd rather
read accurately than risk clobbering the user's list with a
half-tested write path.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Hermes v0.12 ships an autonomous Curator that prunes / consolidates
agent-created skills on a 7-day cycle. This phase brings that surface
into Scarf so users can see status, trigger runs, pin protected skills,
and restore archived ones.
Pipeline:
- HermesCuratorStatus + HermesCuratorSkillRow: Sendable value types for
parsed status + per-skill leaderboard rows.
- HermesCuratorStatusParser: pure text parser for `hermes curator status`
stdout (no `--json` flag exists upstream). Tolerates Hermes's
whitespace-padded leaderboard layout (`activity= 0` with N spaces
between `=` and the value) by slicing between known key positions
rather than splitting on whitespace. State-file JSON overrides
text-parsed values for last_run_at / last_run_summary /
last_report_path because the file carries full ISO timestamps the
text output may have rounded.
- CuratorViewModel: @Observable @MainActor, drives the CLI verbs
(status / run / pause / resume / pin / unpin / restore) via
transport.runProcess so it works equally over local and Citadel SSH.
- HermesPathSet: adds curatorLogsDir + curatorStateFile (the latter
is `.curator_state` with no extension despite holding JSON).
Mac:
- Features/Curator/Views/CuratorView.swift — page-header + status card
+ skill counts + pinned chips + 3 leaderboard tables (least recent,
most active, least active) with inline pin toggles and a
per-skill counter chip row. "Run Now" button + a kebab menu for
Pause/Resume + Restore Archived.
- Features/Curator/Views/CuratorRestoreSheet.swift — name-entry sheet
for `hermes curator restore <skill>`. Free-form text field; Hermes
doesn't ship a `curator list-archived` yet so we don't synthesize a
picker.
- Sidebar: AppCoordinator + SidebarView gain a `.curator` case under
Interact (between Memory and Skills); the row is filtered out by
SidebarView's capability-aware `sections` computed property when
`HermesCapabilities.hasCurator` is false. ContentView routes
`.curator` to CuratorView. Pre-v0.12 hosts see the v0.11 sidebar
unchanged.
iOS:
- Scarf iOS/Curator/CuratorView.swift — read-mostly List with the same
status / skill counts / pinned / leaderboards + inline pin toggles.
Run Now / Pause / Resume actions in the section footer.
- ScarfGoTabRoot's System tab gains a Curator NavigationLink under
Features, gated on `hasCurator`. Uses a stable
`systemTabContextID` so the SSH transport pool reuses the cached
Citadel connection keyed by that id.
Tests: 6 new parser tests (215 total, all green). Locks the empty-state
output captured from a real v0.12.0 install + paused-state + state-file
override + multi-word-name-row parsing. Both Mac and iOS schemes build
clean.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Hermes v0.12 advertises `prompt_capabilities.image = true` and accepts
image content blocks in `session/prompt`. This wires a producer flow on
both targets so users can attach images alongside text and have them
routed to the vision-capable model automatically.
Pipeline:
- ChatImageAttachment: Sendable value type holding base64 payload +
thumbnail, MIME type, source filename, and approximate byte count.
- ImageEncoder: detached-only Sendable service that downsamples to
Anthropic's 1568px long-edge cap, JPEG-encodes at q=0.85, and
produces a small inline thumbnail for composer chips. Cross-platform
(NSImage on Mac, UIImage on iOS, JPEG-passthrough on Linux/CI).
- ACPClient.sendPrompt(sessionId:text:images:) overload emits a content
array `[{type: "text"...}, {type: "image", data, mimeType}]` matching
the wire shape in hermes-agent/acp_adapter/server.py. The
zero-arg-images convenience overload preserves the v0.11 wire shape
for any unmodified callers.
Mac UI:
- RichChatInputBar grew an `attachments: [ChatImageAttachment]` state
array, a paperclip button (NSOpenPanel multi-pick), drag-drop and
paste handlers, and a horizontal preview chip strip. The "send"
callback's signature is `(String, [ChatImageAttachment]) -> Void`
threaded through RichChatView -> ChatTranscriptPane -> ChatView ->
ChatViewModel.sendText(text, images:). Image-only prompts are
permitted ("describe this") once at least one attachment is queued.
iOS UI:
- ChatView's composer adopts a paperclip + PhotosPicker flow with the
same chip strip and 5-attachment cap. Attachments live on
ChatController so they survive across PhotosPicker presentations.
loadTransferable(type: Data.self) feeds raw bytes into the same
ImageEncoder; encode work runs detached so MainActor stays
responsive on cellular.
Capability gating:
- Both composers hide the entire attachment surface when
HermesCapabilities.hasACPImagePrompts is false (pre-v0.12 hosts).
No paperclip button, no drop target, no paste accept — the input bar
is byte-for-byte the v0.11 surface against an older Hermes.
Tests: 209 ScarfCore tests pass; both Mac and iOS schemes build clean.
The encoder's pixel work is hard to unit-test at the package level
(no NSImage/UIImage in plain Swift CI) — manual end-to-end testing
is the verification path here.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds the five v0.12 inference providers to ModelCatalogService.overlayOnlyProviders
so the model picker reaches them. IDs match HERMES_OVERLAYS verbatim:
- gmi → GMI Cloud (api_key)
- azure-foundry → Azure AI Foundry (api_key)
- lmstudio → LM Studio (api_key, promoted from custom-endpoint alias)
- minimax-oauth → MiniMax (OAuth, oauth_external)
- tencent-tokenhub → Tencent TokenHub (api_key)
Auxiliary tasks: drop the `flush_memories` row (Hermes removed it
entirely in v0.12) and add `auxiliary.curator` so users can configure
the model the autonomous curator's review fork uses. The Curator row is
gated on HermesCapabilities.hasCuratorAux, so v0.11 hosts don't see a
control that writes a key Hermes ignores. AuxiliarySettings, the YAML
parser, and HealthViewModel's Tool Gateway breakdown are all updated.
Side fixes:
- CredentialPoolsGatingTests was missing `import ScarfCore` after
ModelCatalogService moved to the package (broke the test target's
compile against pure-Mac scarf).
- Promoted `ModelCatalogService.overlayOnlyProviders` to public so the
new `v012OverlayProvidersCarryCorrectAuthTypes` lock-in test can
reach it.
Tests: 14 ToolGateway tests pass; 209 ScarfCore tests pass; both Mac
and iOS schemes build clean.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Introduces `HermesCapabilities` (parsed from `hermes --version`) and a
per-server `HermesCapabilitiesStore` injected into Mac `ContextBoundRoot`
and iOS `ScarfGoTabRoot` via `.environment(_:)` and `.hermesCapabilities`.
Subsequent v0.12-targeted UI (Curator, Kanban, ACP image input,
auxiliary.curator, prompt cache TTL, etc.) can branch on these flags so
older Hermes installs degrade silently instead of throwing on unknown CLI
subcommands.
Adds `curatorReportJSON` / `curatorReportMD` paths to `HermesPathSet`.
Bumps the Hermes version target in CLAUDE.md from v2026.4.23 (v0.11.0) to
v2026.4.30 (v0.12.0) and lists the v0.12 surfaces Scarf will consume.
Side fixes:
- `M5FeatureVMTests.ScriptedTransport` was missing
`cachedSnapshotPath` after that property was added in 7b864d7;
added `URL? { nil }` stub.
- `M0dViewModelsTests` referenced `.degraded(reason:)` after the case
gained `hint` + `cause`; updated.
- `RemoteBackupService.zipDirectory` and `RemoteRestoreService.unzipArchive`
used `Foundation.Process` unconditionally, breaking the iOS build
(Process is unavailable on iOS). Wrapped in `#if !os(iOS)` with iOS
stubs that throw — the backup/restore flow is Mac-only by design.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The "Project-local Hermes home shadowing global setup" banner has a
"Copy fix command" button that produced a one-liner the user could
paste on the remote. The old command only `cp`'d the project's
`auth.json` into the global `~/.hermes/`; it never touched the
project-local `.hermes/` directory. Hermes' CLI binds to the
*closest* `.hermes/` as `$HERMES_HOME`, so the directory still being
there meant it still shadowed — the detector's
`fileExists(<project>/.hermes)` correctly kept returning true and
the warning didn't go away after the user "fixed" it. They got
stuck.
Fix: rename the project-local `.hermes/` to
`.hermes.scarf-bak.<UTC-stamp>/` after the auth copy. Hermes scans
for a directory literally named `.hermes`, so the rename is enough
to stop binding without losing user data — `state.db`, sessions,
skills all survive untouched in the renamed folder. The user can
inspect / delete the `.bak` later when confident. `mv` over
`rm -rf` because a project's shadow can hold uncommitted session
history; deletion would be unrecoverable, the rename is reversible.
Also removes the `if shadow.hasAuthJSON` gate around the "Copy fix
command" button — a state-only shadow (no creds, just `state.db`)
still binds as `$HERMES_HOME` and needs the same rename to clear
the warning. The button now always shows; the help-tooltip text
branches on `hasAuthJSON` to describe what the command will do.
Help-text now spells out the rename so the user knows where their
data went before they paste anything.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds an end-to-end "back up this server's full Hermes state" flow
with a verifiable archive format and a matching restore that pushes
it onto a fresh droplet. Tested against a 570 MB local Hermes home
+ 5 projects, then iterated against a real DigitalOcean droplet.
Architecture
- `.scarfbackup` is a ZIP containing `manifest.json` (schema v1,
source server + hermes version + per-tarball SHA-256), one
`hermes.tar.gz` (gzipped tar of `~/.hermes/`), and one
`projects/<id>.tar.gz` per registered project. Streams via
`tar -czf - …` over SSH; never buffers a full archive in memory.
- New `streamRawBytes(executable:args:)` on `ServerTransport`
(Local + SSH impls) yields binary `Data` chunks. `streamLines`
splits on `\n` and would corrupt tar output — needed a
binary-safe sibling.
- `RemoteBackupService` runs preflight (resolves $HOME, probes
hermes version, enumerates projects via the existing
`ProjectDashboardService`, sizes each via `du -sb`, checks for
`sqlite3`), optionally runs `PRAGMA wal_checkpoint(TRUNCATE)`
to quiesce state.db, streams each tarball with incremental
SHA-256, then ZIP-bundles via `/usr/bin/zip`. Atomic
temp-then-rename so a partial archive never appears at the
user-chosen destination.
- `RemoteRestoreService` unzips into a temp dir, validates the
manifest's `kind` magic + `schemaVersion`, hash-verifies every
inner tarball BEFORE pushing any bytes to the target, then
streams each tarball into `tar -xzf - -C …` over SSH stdin.
Post-restore: rewrites `~/.hermes/scarf/projects.json` with
source→target path mappings via a small `python3 -c` script,
and pauses every cron job (`enabled: false`) so restored jobs
don't surprise-fire on a fresh droplet.
Defaults + safety
- Excluded from the backup unless explicitly opted in:
`auth.json` (provider creds), `mcp-tokens/` (per-host OAuth),
`logs/`. Always excluded: `state.db-{wal,shm}`,
`gateway_state.json`, and standard project junk
(`node_modules`, `.venv`, `.git/objects`, `__pycache__`,
`.next`, `dist`).
- Manifest records `options.includeAuth/includeMcpTokens/
includeLogs/checkpointedWAL` honestly so restore can warn
the user about what they'll need to re-establish manually.
- All paths are tilde-expanded against the resolved remote
`$HOME` before being passed to `tar`/`sqlite3`.
`tar -C '~/projects'` would otherwise fail with
"No such file or directory" because `shellQuote` wraps the
path in single quotes and tar doesn't expand tildes itself.
UI
- Per-row ellipsis menu on `ManageServersView` consolidates
Back Up… / Restore from Backup… / Diagnostics… / Remove…
Keeps the row visually clean as actions grow. Local server
gets Back Up + Restore (no Remove or Diagnostics).
- `BackupServerSheet` walks loading → ready (size + project
list + auth/logs toggles) → running (byte-counter progress
per stage) → done (Show in Finder) | failed (Try again).
- `RestoreServerSheet` walks awaitingFile → inspecting →
ready (source-vs-target preview, projects-root chooser,
cron-pause toggle, "auth was excluded" notes) → running →
done | failed.
- Both view models use a `WeakBox` two-step capture pattern so
the @Sendable progress callback hops back into MainActor
without the Swift 6 var-self warning on nested closures.
Cleanup folded in
- Drops two no-op `await`s on sync `startReaders()` in
`ProcessACPChannel` (warning surfaced after the Phase 1 ACP
changes; cleanest to fix in the same Transport-layer touch).
Verified
- Local round-trip via a Swift CLI harness:
preflight → backup → unzip listing matches manifest →
on-disk SHA-256 matches manifest claim for every tarball.
- Real DigitalOcean droplet: backup completes after the
tilde-expansion fix; restore preserves projects + sessions.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
A vanished or misconfigured remote surfaced as an opaque 30s
"ACP request 'initialize' timed out" because the channel's EOF
fired with no exit code or stderr context, and `sh -c` on the
remote couldn't find pipx-installed `hermes` on PATH. This makes
remote failure modes immediately legible and adds a recovery path
for the server registry itself.
- `ACPClientError.processTerminated` now carries exit code + stderr
tail; `performDisconnectCleanup` reads them from the channel
before failing pending requests, and `ACPErrorHint.classify`
recognises Connection refused, Operation timed out, Permission
denied (publickey), Host key verification failed, Could not
resolve hostname, and exit 127 / command not found.
- `ProcessACPChannel.terminationHandler` closes the stdout read
end the moment the OS reaps the child so disconnect cleanup
fires within ~1s instead of waiting on `availableData`.
`lastExitCode` reads `Process.terminationStatus` directly to
avoid an actor-handshake race.
- `SSHTransport.makeProcess` / `streamLines` switch from `sh -c`
to `bash -lc` so non-interactive SSH shells source the user's
profile and pick up pipx (`~/.local/bin`), Linuxbrew, asdf,
and conda PATH entries.
- New `ServerRegistry.exportFile()` / `importEntries(from:)` with
a `.scarfservers` JSON envelope (schema v1, dedupe by UUID,
default-server flag preserved). UI in `ManageServersView`'s
header menu surfaces Export… / Import… via NSSave/OpenPanel.
No secrets travel — `identityFile` is a path string and SSH
keys live in `~/.ssh/`, not in `servers.json`.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Replace the gh-pages root placeholder with a real landing page that
sells both apps. Sources live at site/landing/ and publish through a
new scripts/site.sh that mirrors scripts/catalog.sh and scripts/wiki.sh
(check / build / preview / serve / publish, two-pass secret-scan, only
touches root files + assets/ on gh-pages so appcast.xml and templates/
stay disjoint).
Page is rust-palette tokens mapped from ScarfDesign, semantic HTML,
SEO + AEO infra (OpenGraph, Twitter cards, JSON-LD SoftwareApplication
+ MobileApplication + FAQPage, llms.txt, sitemap, manifest), 12-entry
FAQ, light/dark via prefers-color-scheme + manual toggle that swaps
both site chrome and screenshot variants. tools/og-image.html renders
the 1200x630 OG / 1200x600 Twitter cards via headless Chromium.
Real captures from the live Mac app (9 surfaces x light + dark) +
existing ScarfGo screenshots round out the imagery.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Three feature batches that were in progress on chat-resilience —
all aligned with v2.5.2's remote-context theme.
## Chat-start model preflight
When a chat-start hits a server whose config.yaml has no
model.default / model.provider, the upstream provider returns an
opaque "Model parameter is required" 400 only AFTER the user types
a prompt and hits send. New ModelPreflight in ScarfCore catches the
missing keys before any ACP work; ChatView presents the existing
ModelPickerSheet via a thin ChatModelPreflightSheet wrapper so the
picker / validation / Nous-catalog branch stay single-sourced.
ChatViewModel persists the selection via `hermes config set` and
replays the original startACPSession arguments — the chat the user
originally opened lands without re-clicking the project row.
## Nous Portal live catalog
NousModelCatalogService fetches `GET /v1/models` from
inference-api.nousresearch.com using the bearer token in
`auth.json`, caches to `~/.hermes/scarf/nous_models_cache.json`
(new path on HermesPathSet) with a 24h TTL. Picker's nous-overlay
detail switches from a free-form TextField to a real model list,
with a "Custom…" escape hatch (nousManualEntry) for IDs not yet in
the API response.
## Remote-aware admin sheets (mirror of #54's pattern)
The Add Project sheet got context-aware Verify in v2.5.1 (#54);
this batch extends the same shape to three more sheets:
- Profiles: remote import/export. ProfilesView gains
showRemoteImportSheet + pendingRemoteExport state; reuses the
same path-input + verify + run-via-hermes pattern from
AddProjectSheet. Drives `hermes profile import <zip>` /
`hermes profile export <name> <zip>` over SSH.
- Backup restore (Settings → Advanced): pickLocalBackupZip + new
RemoteBackupPathSheet so the Restore action picks a local zip
on local contexts and verifies a remote path on remote contexts.
- Template install destination: TemplateInstallSheet's parent-
directory picker now branches on context. ParentDirectoryStep
with browseLocalDirectory + verifyRemotePath + RemoteVerification
— same UX vocabulary as AddProjectSheet, applied to where the
template gets installed.
Plus a `runHermesWithStdin` helper on HermesFileService for the
profile import flow (passing zip bytes through stdin rather than
landing them on the remote disk first), and ProjectTemplateInstaller
gains a remote-path-aware code path for the install destination.
## Localizations
Localizable.xcstrings adds strings for all the new copy across
seven supported locales (en, zh-Hans, de, fr, es, ja, pt-BR).
The keyboard accessory dismiss button added in #51 was placed at
the trailing edge of the keyboard toolbar (Spacer before Button),
which sits directly above the trailing-edge send button in the
composer below. Two near-identical-shape controls visually stack
on the right edge of the screen, confusing users about which is
which.
Move the Spacer() to AFTER the Button so the chevron lives at the
leading edge of the keyboard accessory bar — visually separated
from the send button below, and matches the iOS convention (Notes,
Mail, Reminders all put accessory dismiss on the leading side).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The 3-pane layout (264px sessions list + transcript + 320px inspector)
ate ~584px of horizontal space on every chat window — squeezing the
actual transcript on smaller windows AND keeping the "No tool selected"
empty-state visible even when irrelevant. User reported that as
"reasoning, in/out, hard to read because of the tool selected box
taking so much space".
Add toolbar toggles + Settings parity to hide either side pane:
- Two new @AppStorage keys in ChatDensitySettings:
scarf.chat.showSessionsList (default true)
scarf.chat.showInspector (default true)
- ChatView toolbar gains two buttons next to the View picker:
sidebar.left toggles the sessions list, sidebar.right toggles the
inspector. Both highlight in accent color when visible. Hidden when
in terminal mode (the 3-pane layout doesn't apply there).
- RichChatView body conditionally renders each side pane and its
divider, with .transition(.move + .opacity) and a 180ms easeInOut
animation so the transcript reflows smoothly rather than snapping.
- Auto-show inspector when a tool card is focused so a click never
silently dies — onChange of focusedToolCallId flips
showInspector back on if it was off. The slide-in animation
covers the visual transition.
- DisplayTab → Chat density gains parity Toggle rows for "Sessions
list" and "Tool inspector" — same group as the existing density
pickers from #47/#48 so the settings home is consistent.
Defaults match today's behavior so existing users see no change
until they opt out.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Reported on iOS: dashboard shows "Connection issue / Citadel.SSH
Client.CommandFailed error 1", memory files (USER.md, SOUL.md) load
fine but Sessions / Activity / Tool Calls all show 0. The snapshot
operation that pulls ~/.hermes/state.db over SFTP via `sqlite3
.backup` was failing on the remote, but the iOS user got zero
actionable context.
Two latent bugs in CitadelServerTransport.asyncSnapshotSQLite —
both fixed in v2.5.0 for asyncRunProcess but missed on this path:
1. `executeCommand` throws CommandFailed on non-zero exit AND
discards the captured stderr buffer. So when sqlite3 is missing
(slim Docker images, statically-linked installs) or state.db
doesn't exist, the user only saw "error 1" and a generic
connection-issue banner with no remediation.
2. No `PATH=...` prefix. asyncRunProcess inline-prepends
`PATH="$HOME/.local/bin:/opt/homebrew/bin:/usr/local/bin:$PATH"`
so bare command resolution works on Citadel's stripped-PATH
exec channel; the snapshot path didn't, so any sqlite3 install
outside /usr/bin failed at exit 127 ("command not found").
Mirror the asyncRunProcess hardening on the snapshot path:
- Prepend the same PATH prefix so sqlite3 resolves on hosts where
it lives at /usr/local/bin or /opt/homebrew/bin.
- Drive `executeCommandStream` instead of `executeCommand`.
Capture stdout + stderr regardless of exit code.
- On non-zero exit, throw an NSError carrying the real stderr (or
stdout if stderr is empty — sqlite3 sometimes errors via stdout
depending on the remote shell). HermesDataService.humanize
already keys off "sqlite3: command not found" /
"permission denied" / "no such file" substrings, so once the
real message reaches it the dashboard banner becomes actionable
("sqlite3 is not installed on <host>. Install with apt install
sqlite3..." instead of the generic CommandFailed error).
- When the stream itself fails to start (network/auth-level), throw
with a "Failed to start snapshot stream" message so the connect-
level error path is distinguishable from the remote-exec failure.
iOS-only — Mac path was already correct.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Two views called ModelCatalogService.loadProviders() synchronously
from .onAppear on the MainActor:
- ModelPickerSheet (Settings → Model)
- AddCredentialSheet (Credential Pools → +)
loadProviders() walks loadCatalog() → transport.readFile() of
~/.hermes/models_dev_cache.json — a multi-megabyte JSON with ~1500
models across ~110 providers. On a remote SSH context that's a
synchronous SSH file read on the main thread; the user's reported
1–2 minute UI freeze on first open is exactly that. Even on local
contexts the JSONDecoder pass on the main thread is a noticeable
hiccup. Direct violation of CLAUDE.md's rule against sync I/O on
@MainActor.
Compound case: ModelPickerSheet.loadModelsForSelection() did the
same sync read every time the user clicked a different provider in
the picker — re-froze the UI per click.
Fix:
- Add async wrappers on the service:
loadProvidersAsync() -> [HermesProviderInfo]
loadModelsAsync(for:) -> [HermesModelInfo]
Each await Task.detached { sync method }.value. Existing sync
methods stay for tests and any non-View consumers.
- ModelPickerSheet: replace .onAppear with .task; await both async
calls. Same conversion for loadModelsForSelection() — renamed to
loadModelsForSelectionAsync() and called from the provider-list
selection binding via Task { ... }. Subscription state load also
routed through Task.detached since it's another auth.json read
that's tiny on local but SSH-backed on remote.
- AddCredentialSheet (CredentialPoolsView): same .onAppear → .task
conversion with isLoadingProviders @State driving an overlay
ProgressView "Loading providers..." while the read is in flight.
No behavior or data-shape change; pure I/O dispatch fix.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Three related fixes for the "I authed Nous but Scarf doesn't see it" bug:
1. `hasAnyAICredential()` (HermesFileService) only probed the
`credential_pool.<provider>` shape in auth.json. OAuth-authed providers
land under `providers.<name>.access_token` instead — Nous, Spotify, GH
Copilot ACP, Qwen, Gemini all use that path. The chat banner kept
showing "No AI provider credentials" even after a successful Nous
sign-in. Now we probe both shapes; refresh-only entries (pre-mint
OAuth flows) also count.
2. `CredentialPoolsViewModel` decoded only `credential_pool.*` and
ignored `providers.*` entirely. New `oauthProviders` array surfaces
them in a parallel "OAuth providers" section above the rotation
pools — read-only, with token tail, expiry badge, portal URL, and
"managed by `hermes auth add`" footnote so users know where the
write path lives.
3. New `ProjectHermesShadowDetector` (ScarfCore) probes each registered
project for a `<project>/.hermes/` directory. Hermes' CLI binds to
the closest `.hermes/` as `$HERMES_HOME` when run from inside such a
project — `hermes auth add nous` lands in the project's auth.json
instead of `~/.hermes/auth.json` and Scarf's global probes never
see it. Surfaced as a yellow Dashboard banner listing affected
projects with badges for `auth.json` / `state.db` presence and a
"Copy fix command" button that emits a one-liner consolidating
auth.json into the global home. Read-only — no auto-migration; the
user decides what to keep.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Same root cause as the connection-pill fix in 511726e: Hermes v0.11+
doesn't materialize config.yaml until the user changes a setting from
defaults, so a healthy fresh install was reporting "12/14 passing"
forever even though everything that mattered worked.
Probe.Status becomes tri-state (.pass / .fail / .skipped). The shell
script emits SKIP for the "config.yaml absent" branch (Hermes creates
it lazily); only "exists but unreadable" still emits FAIL. The view
renders .skipped with a grey info-circle and excludes those probes
from the summary's denominator — "12/12 passing (2 optional skipped)"
instead of the misleading "12/14."
Probe titles relabeled to "config.yaml readable (optional)" and
"config.yaml content (optional)" so users see the file is not
load-bearing at a glance. The failure hint for the genuine
permission-denied case explicitly notes that absence is fine.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Brings iOS chat to parity with Mac's reconnect behavior so a session
survives phone-sleep, network handoffs, and SSH socket drops without
losing the agent's work — Hermes already persists messages to state.db
in real-time, the iOS app just had no resync path.
Core changes (shared between Mac and iOS via ScarfCore):
- ServerTransport.cachedSnapshotPath: fall back to the cached state.db
snapshot when a fresh pull fails. HermesDataService surfaces this via
isUsingStaleSnapshot + lastSnapshotMtime so views can render "Last
updated X ago." Default opt-in via refresh(forceFresh: false); chat
history reload passes forceFresh: true to refuse stale data.
- HermesDataService.fetchMessages(sessionId:limit:before:): bounded
pagination by id desc. Legacy unbounded overload deprecated. New
HistoryPageSize constants centralize the budget.
- RichChatViewModel.loadEarlier(): pages back through the current
session via oldestLoadedMessageID + hasMoreHistory.
iOS-only:
- ChatController gains the Mac reconnect machinery: 5-attempt
exponential backoff (1→16s) via session/resume → session/load,
reconcileWithDB on success, "Resynced N new messages" toast.
startACPEventLoop + startHealthMonitor extracted as helpers.
- New NetworkReachabilityService (NWPathMonitor singleton). Suspends
reconnect attempts while offline; kicks a fresh cycle on link-up.
- ScarfGoCoordinator + ScarfGoTabRoot funnel scenePhase transitions to
ChatController.handleScenePhase. On .active we verify channel
health and reconnect if dead.
- Draft persistence: UserDefaults keyed by (serverID, sessionID)
survives force-quit. 7-day janitor at app launch.
- Connection-state banner: .reconnecting and .offline render slim
ScarfDesign-tinted strips above the message list. .failed keeps
using the existing full-screen overlay.
Bonus fix:
- ConnectionStatusViewModel tier-2 probe now checks state.db instead
of config.yaml. Hermes v0.11+ doesn't materialize config.yaml until
the user changes a setting, so a freshly-installed working Hermes
was being marked "degraded — config missing" indefinitely. state.db
is the file Scarf actually depends on.
Out of scope (deferred): APNs push notifications, BGTaskScheduler-
based extended-background keepalive, offline write queue.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@cmalpass's April 25 follow-up on #19: diagnostics reported "sqlite3
not installed or on system PATH" while sqlite3 was actually installed
and Hermes was using it fine. Same false-negative class the `hermes`
probe pre-fix had — a bare `command -v sqlite3` in the non-login SSH
shell misses installs at /opt/homebrew/bin or /usr/local/bin when
the user's PATH export lives in .zprofile (the typical Homebrew
setup). The hermes probe was upgraded to source rc files + walk a
candidate list; sqlite3 wasn't.
Mirror the same pattern:
- Move the sqlite3 detection AFTER the rc-source loop so the login
PATH is in scope.
- Add a standard-location fallback list:
/usr/bin/sqlite3, /usr/local/bin/sqlite3,
/opt/homebrew/bin/sqlite3, /opt/local/bin/sqlite3.
- Use the resolved sqlite3 binary explicitly in the
sqlite3CanOpenStateDB probe so it doesn't re-fail-by-PATH when the
binary is at e.g. /opt/homebrew/bin. Falls back to bare `sqlite3`
so the FAIL detail line still carries the real error.
Hermes non-login probe stays as-is — that semantic ("is hermes on
the un-enriched PATH?") is meaningful and we don't want to muddle it.
Failure-hint copy on sqlite3Installed updated to spell out the new
fallback behavior so users who still see FAIL get accurate guidance
(install via package manager, OR symlink an existing binary into a
location the probe checks).
Closes the third and last open layer of #19. Layer 1 (104-byte
ControlMaster path) was fixed in v2.0.2; layer 2 (pill / diagnostics
disagreement) was fixed in v2.5.1 (#44). Ships in v2.5.2.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
App Store Connect feedback: "Cancel button not working" on the
"Connect to Hermes" onboarding screen.
Confirmed root cause in RootModel.cancelOnboarding:
state = servers.isEmpty
? .onboarding(forNewServer: ServerID())
: .serverList
When the user has zero configured servers (the first-run case),
the conditional re-presented a fresh onboarding view. The button
fired, the state mutated, but the visible result was "tap Cancel,
get an identical screen" — indistinguishable from a dead button.
The defensive intent ("don't strand the user on an empty server
list") was reasonable, but the UX-as-shipped is worse than the
strand it tried to prevent — first-run TestFlight users see a
seemingly broken app.
Fix at the right layer: don't show Cancel when there's nowhere
to go.
- New `canCancel: Bool` parameter on OnboardingRootView (default
true). When false, the leading toolbar slot omits the Cancel
button entirely.
- RootView passes `canCancel: !model.servers.isEmpty`.
- RootModel.cancelOnboarding simplified — drops the defensive
`.isEmpty` re-loop branch, asserts the invariant in debug, and
in release still routes to `.serverList` (which renders an
empty-state with the "+ Add server" toolbar button) rather than
re-presenting onboarding.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Issue #44: pill stuck on "Connected — can't read Hermes state" while
Run Diagnostics shows 14/14 passing. Both code paths probe the same
question (`[ -r ~/.hermes/config.yaml ]`) yet disagreed.
Root cause: the pill called `transport.runProcess(executable:
"/bin/sh", args: ["-c", script])` which routes through
SSHTransport.remotePathArg quoting. That quoting double-quotes every
argument to rewrite `~/` → `$HOME/`, mangling multi-line shell
scripts containing `"$VAR"` references and nested quotes — the
remote received a scrambled `if`-test and `$H/config.yaml` evaluated
to `"/config.yaml"` (or worse), so tier-2 always read as failed.
`RemoteDiagnosticsViewModel` already documented this exact bug and
worked around it locally: invoke `/usr/bin/ssh ... -- /bin/sh -s`
directly and pipe the script via stdin so it travels as opaque
bytes. The pill never got the same treatment, hence the silent
disagreement. The #53 granular-cause script I added a few commits
back made the mangling worse — more $VARs, more `[ ! -e ]` tests,
more nested quoting, all things that increase the runProcess
quoting attack surface.
Move the diagnostics workaround into shared ScarfCore code as
`SSHScriptRunner.run(script:context:timeout:)`. Both the pill probe
and the diagnostics view now use it, so they always see the same
remote shell state. macOS-only via `#if os(macOS)` (Foundation.Process
isn't on iOS); iOS callers never reach this surface anyway —
ScarfGo uses Citadel-based SSH transports for its own flows.
Other tidy-ups:
- `ConnectionStatusViewModel` no longer holds a `transport` instance
— the field was only used by the now-replaced runProcess path.
- `RemoteDiagnosticsViewModel` loses ~120 lines of duplicated
`runOverSSH` / `runLocally` / `controlDirPath` helpers; calls into
`SSHScriptRunner.run` directly.
Risk: low. The SSH path is the same shape that's been shipping in
the diagnostics view since #19. The pill's 15s heartbeat gains a
small forking-an-ssh-process overhead vs the ControlMaster-
multiplexed runProcess, which is invisible at that cadence and
amortized by ssh's own ControlMaster (the `-o ControlMaster=auto`
options match SSHTransport's, so the multiplex socket is shared).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Pre-fix `AddProjectSheet` always rendered a Browse button backed by
NSOpenPanel — a Mac-local Finder dialog. On a remote SSH server
context, users would pick a Mac path (`/Users/alan/code/...`), the
path would land in the projects registry as the project's "remote"
working directory, and tool calls would fail at runtime because
that path doesn't exist on the Linux server.
Tier-1 fix:
- Pass active ServerContext into AddProjectSheet (was context-blind).
- Local context: Browse button unchanged. Pixel-identical to today.
- Remote context: hide Browse, surface a hint "Path on <server> —
must already exist on the server", add a Verify button that runs
context.makeTransport().stat(path) over the existing SSH transport
and renders inline:
spinner → checking
green ✓ → directory exists
yellow ⚠ → missing / file-not-dir / unreadable
- Path field's onChange resets stale verification so users don't see
a green check for a path they've since edited.
Tier 2 (full remote SFTP-backed picker that lets users navigate the
remote filesystem) is deferred — separate larger feature, ~200-300
lines and its own UX. Tier 1 unblocks remote project creation now,
which was the blocking bug.
Other 5 NSOpenPanel call sites audited — `TemplateInstallSheet:423`
likely has the same class of bug for template install destinations
on remote contexts; flagged in the issue body for a follow-up. The
other 4 (template-file picker, key-file picker, etc.) all pick
Mac-local artifacts and are correct as-is.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Pre-fix the connection-status pill collapsed every config.yaml read
failure to "Connected — can't read Hermes state", forcing users into
the heavy 14-probe Remote Diagnostics sheet to learn why. Multiple
distinct causes (Hermes not installed, not yet set up, permission
denied, profile mismatch) all read identically.
Probe script now emits granular `TIER2:1:<cause>` codes:
- no-home: ~/.hermes itself missing
- missing: config.yaml absent (typically pre-`hermes setup`)
- perm: file exists but unreadable by the SSH user
- profile:<name>: config missing AND ~/.hermes/active_profile points
at a non-default profile, so Scarf is reading the wrong directory
Status.degraded now carries (reason, hint, cause) instead of just a
short reason. The pill label shows the specific reason
("Hermes profile coder is active", "Hermes hasn't been set up yet",
etc.); clicking opens an inline popover with:
- A one-paragraph actionable hint
- A "Run diagnostics" button (existing path) and a "Retry" button
- For the profile case: a copy-paste affordance for
`hermes profile use default` to revert
Backwards-compatible: a remote that emits the legacy binary
`TIER2:1` parses to `.unknown` with the prior generic copy. No probe
script breakage on older Hermes installs.
Cross-link with #50 (local profile awareness) — this fix surfaces
the profile-mismatch class of bug for remote contexts. A proper
remote-side profile fix (HermesPathSet.defaultRemoteHome respecting
active_profile) is filed separately.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Reddit-reported friction: every iOS device needed its own SSH key
because Scarf hardcoded
kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly +
kSecAttrSynchronizable=false on every Keychain write. Pairing iPhone
+ iPad meant onboarding twice and editing authorized_keys per device.
Add an opt-in toggle in System tab → Security:
- New SSHKeyICloudPreference (UserDefaults wrapper, default false so
existing installs see no change on update).
- KeychainSSHKeyStore.writeBundle now consults the preference: when
on, items use kSecAttrAccessibleAfterFirstUnlock (no ThisDeviceOnly
suffix — required for iCloud Keychain sync) +
kSecAttrSynchronizable=true.
- All read / list / delete queries unconditionally pass
kSecAttrSynchronizable=kSecAttrSynchronizableAny so they match
items regardless of sync state. Without this a flipped write would
orphan items at the next read.
- Public migrateAllItems(toICloudSync:) reads every stored bundle,
deletes with Any, re-saves with target attributes. Idempotent.
System tab Security section toggle:
- Live migration on flip with a "Updating Keychain..." progress row.
- Failure path reverts the toggle + surfaces the error inline rather
than silently leaving the state inconsistent.
- Footer copy explains the tradeoff (E2EE via iCloud Keychain;
Advanced Data Protection keeps encryption keys on device).
Out of scope: per-server-key sync override (M9 multi-server keys
all sync or none); in-app key export.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Pre-fix the iOS composer's TextField had no keyboard dismissal:
no @FocusState, no scrollDismissesKeyboard, no keyboard accessory.
With axis: .vertical + submitLabel: .send the Return key inserts a
newline rather than committing, so once the keyboard rose it stayed
up — hiding the top-trailing toolbar button on small phones.
Three additive changes:
- @FocusState private var composerFocused on ChatView, bound to the
TextField via .focused($composerFocused).
- .scrollDismissesKeyboard(.interactively) on the message list
ScrollView so dragging the messages downward collapses the keyboard
with the gesture (the standard iOS chat pattern the reporter
explicitly named — "swipe away").
- ToolbarItemGroup(placement: .keyboard) accessory with a
keyboard.chevron.compact.down "Done" button so dismissal is also
available without a scrollable area (e.g. fresh empty-state chat
before any messages exist).
ScarfGo iOS only. Mac unaffected.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Hermes v0.11's `hermes profile` feature gives each profile its own
HERMES_HOME directory: the default profile is ~/.hermes, named
profiles live at ~/.hermes/profiles/<name>/. Each has its own
state.db, sessions/, config.yaml, .env, memories/, cron/, etc.
The active profile is recorded in ~/.hermes/active_profile.
Pre-fix Scarf hardcoded ~/.hermes and ignored active_profile, so
`hermes profile use coder` followed by a Scarf relaunch left Scarf
reading the wrong state.db — the new profile's chat sessions
silently never appeared.
Add HermesProfileResolver in ScarfCore that reads active_profile
and returns the effective home path. HermesPathSet.defaultLocalHome
becomes a static var backed by the resolver; every derived path
(stateDB, sessionsDir, configYAML, memoriesDir, cron paths, plugins,
gateway state, auth.json, etc.) automatically follows the active
profile through the existing `home + suffix` plumbing — no
downstream call sites need to change.
Resolver semantics:
- Absent / empty / "default" file → ~/.hermes (today's behavior)
- Valid profile name pointing to an existing dir → that dir
- Invalid name OR missing target → fall back to ~/.hermes with a
one-line os.Logger warning (so worst case is "Scarf shows what
it always showed")
Validation regex mirrors Hermes's hermes_cli/profiles.py exactly
([a-z0-9][a-z0-9_-]{0,63}). 5-second cache via OSAllocatedUnfairLock
keeps hot-path filesystem hits negligible.
SessionInfoBar gains a leftmost profile chip when not "default" so
users can see which profile Scarf is reading from. Tooltip explains
how to switch (`hermes profile use <name>` + relaunch).
Out of scope (deferred):
- In-app profile picker that writes to active_profile. Switching
mid-session is messy (open ACP processes are bound to whichever
HERMES_HOME spawned them); the reporter's "switch + restart" flow
is what we fix here.
- Remote SSH profile awareness. defaultRemoteHome stays "~/.hermes"
— remote profile selection is a separate, larger feature needing
its own UI.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The privacy policy claimed "the macOS app is sandboxed where possible" and
that uninstall removes "~/Library/Containers/com.scarf". Both wrong:
- Per scarf/CLAUDE.md "Sandbox disabled. Scarf needs to read ~/.hermes/
directly." Scarf cannot ship App-Sandboxed because it needs direct
filesystem access to ~/.hermes/ and the ability to spawn the hermes CLI
— both forbidden by the App Sandbox.
- ~/Library/Containers/com.scarf doesn't exist for an unsandboxed app;
data lives at ~/Library/Caches/scarf/, ~/Library/Preferences/com.scarf.app.plist,
and ~/Library/Application Support/com.scarf/.
Replaced both with accurate text. Also clarified that ScarfGo on iOS DOES
run inside the standard iOS sandbox — no special entitlements beyond
Keychain. The wiki mirror at .wiki-worktree/Privacy-Policy.md got the same
fix in the corresponding wiki audit commit.
Caught during the v2.5 wiki audit pass. Will re-publish to gh-pages in
v2.5.1 alongside other queued doc updates.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Three Scarf-local @AppStorage-backed preferences in
Settings → Display → Chat density. All defaults match today's UI;
existing users see no change until they opt in.
- Tool calls: Full card (today) / Compact chip / Hidden
- Compact: one-line tappable chip per call (icon + name + status
dot). Tap focuses the call so the right-pane inspector opens
with full args + result, same as today's inline expand.
- Hidden: per-call rows skipped entirely. The MessageGroupView
toolSummary pill ("Used 5 tools (3 read, 2 edit)") becomes
the only chrome AND becomes tappable — clicking focuses the
first call so per-call duration / exit code remain reachable
via the inspector. Pill is now shown for any call count > 0
in hidden mode (was > 1) so the inspector path is always
available. Issue #47.
- Reasoning: Disclosure box (today) / Inline (italic) / Hidden
- Inline: italic foregroundFaint caption inline above the reply
with a 9pt brain prefix. No box, no border. Same data, far
less vertical space.
- Hidden: reasoning text not rendered. Per-message tokenCount
(which the disclosure label was duplicating) stays in the
metadataFooter so token telemetry isn't lost. Issue #48.
- Chat font size: 85%–130% slider (5% step) applied via
.environment(\.dynamicTypeSize, ...) on RichChatView's root,
scaling message list / input bar / session info bar / inspector
pane together. Reset button restores 100%. Issue #48.
Telemetry preservation (the user-stated constraint):
- Per-turn stopwatch, per-message tokenCount, finish reason, and
message timestamp remain in the bubble metadataFooter in every
mode.
- SessionInfoBar input/output/reasoning tokens, cost USD, model,
project, git branch, and started-at relative time are unchanged
by every density setting.
- Per-call duration + exit code stay reachable via the inspector
pane in compact and hidden modes.
Out of scope (called out in the plan):
- Context-fill widget — Hermes v0.11 doesn't expose context_used
/ context_total per session. Approximating from messages.tokenCount
+ a static window table would be wrong-on-purpose; defer until
Hermes ships the canonical field.
- iOS — ScarfGo already renders both surfaces compactly. Both
issues reference Mac.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
ScarfGo's chat is a separate rendering path: LazyVStack +
ForEach(controller.vm.messages) with a private MessageBubble struct
(not the shared MessageGroupView/RichMessageBubble used on Mac). The
Mac fix's Equatable conformances therefore didn't propagate.
Without short-circuiting, every visible bubble re-evaluates body on
each streamed ACP chunk because the @Observable VM's `messages`
mutation invalidates anyone reading it — and each bubble's
`ChatContentFormatter.segments` + `AttributedString(markdown:)` are
both O(content) per render. LazyVStack already keeps off-screen
bubbles dormant on iOS, but the 5–10 visible bubbles re-parsing on
every chunk is enough to bog down a long turn on phone hardware.
Add Equatable to MessageBubble (id-keyed, with content/reasoning/
toolCalls.count compared only for the streaming bubble id==0) and
apply .equatable() at the ForEach call site. Settled bubbles short-
circuit body re-eval; the streaming bubble still redraws per chunk.
Note: the trailing-group patch helper (Mac fix part 2) already
benefits iOS as a side effect — buildMessageGroups() is no longer
called per chunk, and even though iOS doesn't read messageGroups
directly, the elided rebuild is still wasted work avoided.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Long chats progressively bog down and eventually crash because every
streamed ACP token triggers a full messageGroups rebuild plus a body
re-evaluation of every MessageGroupView and RichMessageBubble — even
the n-1 settled groups that haven't changed. Three changes cap per-chunk
work at "patch the trailing group + re-render the streaming bubble":
- MessageGroupView and RichMessageBubble are now Equatable, applied
via .equatable() in the ForEach. Settled groups (no streaming
message inside) short-circuit body re-evaluation entirely; the
streaming group compares content/reasoning/toolCalls.count so it
still redraws on every chunk.
- RichChatViewModel.upsertStreamingMessage no longer calls
buildMessageGroups() per chunk. New patchTrailingGroupForStreaming
mutates only the trailing group's assistant entry in place. The 9
other call sites of buildMessageGroups() are untouched — they cover
structural events (user message, tool-call complete, finalize,
session resume) where group boundaries can actually change, and a
full rebuild is correct there.
- MessageGroup.toolKindCounts is now a model property (was a
MessageGroupView computed prop that re-walked O(m × k) per body
render). Lives behind the Equatable short-circuit.
- ToolCallCard.formatJSON cached via .task(id: call.callId) so JSON
pretty-printing runs once per card lifetime instead of on every
expand/collapse + every neighbour's re-render. Seeded with raw
arguments to avoid a first-frame empty-text flicker.
- ToolResultContent.lines/preview cached via .task(id: content) — the
prior pair of computed properties split content on \n twice per
render, expensive on long command/file output.
Skipped from the original plan: the per-message parse cache
(rendered moot once Equatable already short-circuits settled bubbles)
and the LazyVStack switch (deferred — RichChatMessageList comments
flag scroll-anchor regression risk; revisit separately if needed).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Add codesign --verify --strict --deep + spctl --assess on the extracted
distribution zip inside build_variant() so any seal regression introduced
by ditto / staple / future pipeline tweaks fails the release before users
see "damaged" errors. Document the non-destructive recovery path in
README and explicitly warn against `xattr -rc` and
`codesign --force --deep --sign -` (issue #49 — both corrupt
Sparkle.framework's nested XPC service / Updater.app signatures even
when the outer app remains intact).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
GitHub's README content column is ~770px wide. 180px x 5 + spacing
overflowed and wrapped 4+1 (the System tab dropped to its own line),
breaking the gallery's "thumbnail strip" reading. 140px x 5 lands at
~700px including spacing, comfortably within the column.
No content change to the screenshots or paths — just the width attr.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Five 1284x2778 simulator captures from the iPhone 17 Pro Max stock
sim, dropped in at assets/screenshots/scarfgo-*.png. The README
gallery is HTML inside the existing Markdown — five thumbnails at
180px wide, centered, each wrapped in an <a href> pointing back at
the same file so a click opens the full-resolution PNG via GitHub's
asset viewer (the closest thing the README format supports to a
lightbox).
Order matches the user flow: Servers list -> Chat with Hermes ->
Project dashboard (Site Status Checker template, dogfooding the
catalog) -> Skills browser -> System tab. One italic caption
underneath labels the screens in order.
3.4 MB total. iPhone 17 Pro Max is the canonical capture device
for v2.5; the App Store listing will use the same shots once they
need cropping/framing for Apple's screenshot specs.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The previous commit replaced icon.png on disk with the rust v2.5
artwork, but GitHub's raw-asset CDN was still serving the cached
purple PNG to README viewers (~5 min TTL — but in practice longer
under sustained traffic). Renaming the asset forces a fresh fetch
on every README render, which is the reliable cache-bust.
icon-v2.5.png is bit-identical to the prior icon.png (md5 match
against the Mac app icon set's 512x512). The version in the
filename is intentional — when v2.6 ships with a different icon,
we'll cycle to icon-v2.6.png and the same cache-bust applies.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
icon.png at the repo root drives the centered hero block on the GitHub
README. It was still the pre-rust design from v2.0; replaced with the
rust ScarfDesign 512x512 sourced from the Mac app icon set so the
home page matches the in-app branding now that v2.5.0 has shipped.
Also bumps the source resolution from 256x256 to 512x512 — the README
displays it at 128x128, so retina + HiDPI displays now render crisply
without losing the asset's intent.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Locks the `scarf mobile` target to iPhone before TestFlight submission:
- TARGETED_DEVICE_FAMILY 1,2 -> 1 (iPhone only)
- SUPPORTED_PLATFORMS constrained to iphoneos + iphonesimulator
- SUPPORTS_MACCATALYST = NO
- SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = NO
- SUPPORTS_XR_DESIGNED_FOR_IPHONE_IPAD = NO
Applied symmetrically to Debug + Release configs. iPad layout via
.tabViewStyle(.sidebarAdaptable) hasn't been smoke-tested and was
explicitly out of scope for v2.5; flipping the device-family flag
prevents Apple's review tooling from picking up an unsupported
device class. Mac and visionOS are similarly excluded — ScarfGo is
an iPhone-only companion in v2.5; the iPad / Catalyst story is its
own future release.
Both targets build green.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
README: strip the "Previously, in 2.3" subsection per release direction —
the home page is now a single-version forward-looking surface with prior
releases linked off to the wiki Release Notes Index. Promote the
ScarfGo TestFlight callout to its own subsection with the public link
(testflight.apple.com/join/qCrRpcTz) embedded inline. Add a
"Connect ScarfGo to your Hermes server" five-step walkthrough between
What's New and Multi-server, mirroring OnboardingRootView's state
machine so users can follow it cold without opening the wiki first.
RELEASE_NOTES: extend the Under-the-hood section with the iOS-side
maintenance work that landed in the last 48h — Citadel
executeCommandStream rewrite (preserves stdout on non-zero remote
exit; was eating Skills hub Browse output), inline PATH=
prepend on every iOS runProcess (Citadel's raw exec channel doesn't
source shell rc files), fd-leak cleanup across the three transports
+ ProcessACPChannel, ServerLiveStatus 10/30/60/120/300s exponential
backoff for unreachable remotes, and the print() -> os.Logger sweep.
APP_STORE_METADATA.md: full App Store Connect copy ready for paste —
app name, subtitle, promotional text (153/170 chars), 2873/4000-char
description in three paragraphs (what / features / privacy),
brand-safe keywords (85/100 chars; no competitor product names),
support / marketing / privacy URLs, category, age rating,
1150/4000-char "What's New" text. Screenshots flagged as out-of-scope
for this prep pass — user captures from the simulator before App
Store submission. TestFlight checklist remains the canonical doc for
the in-flight beta submission.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Apple's TestFlight upload validator rejected v2.5 with two errors;
fixing both for the next archive.
1. **Invalid large app icon** (alpha channel).
`AppIcon.appiconset/AW Mac OS Applications-macOS-Default-1024x1024@1x.png`
was RGBA — Apple rejects any 1024×1024 marketing icon with an alpha
channel. Composited the icon onto a solid white background via
PIL and resaved as RGB PNG. `sips -g hasAlpha` now reports `no`.
The file's design is solid edge-to-edge, so the white-fill is
invisible — no visual change.
2. **Invalid Code Signing Entitlements**
(`com.apple.developer.icloud-container-environment` empty string).
`Scarf_iOS.entitlements` had `com.apple.developer.icloud-services
= [CloudKit]` + `com.apple.developer.icloud-container-identifiers
= []`. Xcode's signing phase synthesises
`com.apple.developer.icloud-container-environment` from this combo,
and with no container identifier the value lands as empty — which
Apple's validator rejects.
Per the privacy policy I drafted in v2.5 ("no iCloud Keychain
sync, no cloud accounts") Scarf doesn't actually use iCloud, so
removing the iCloud entries is the correct fix. Dropped both
`com.apple.developer.icloud-services` and
`com.apple.developer.icloud-container-identifiers`. Kept
`aps-environment = development` — push capability stays declared
but gated off via `NotificationRouter.apnsEnabled = false` until
the cert + Hermes-side sender land.
iOS scheme builds clean post-fix.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Pre-release maintenance pass picked up across both targets while
debugging the iOS Browse fix:
- LocalTransport / SSHTransport: close the parent's copy of every
pipe write end after spawn so EOF reaches the reader once the child
exits, and explicitly close read ends after draining. Was leaking
one fd per `runProcess`/`streamLines` invocation under load.
- ProcessACPChannel: also close stdout/stderr write-end fds on
channel teardown — same pattern, ACP sessions can churn on long
reconnect loops.
- HermesDataService / HermesLogService / ProjectDashboardService:
replace remaining `print("[Scarf] ...")` debug statements with
os.Logger calls (subsystem="com.scarf"), matching the global rule
that production code uses Logger and `print()` is reserved for
previews + test helpers.
- ProjectDashboardService / IOSCronViewModel: drop redundant
`fileExists` guards before `createDirectory` — the operation is
already mkdir -p across every transport, so the extra round-trip
was pure latency on remote hosts.
- scarfApp.swift: server-status sidebar probe now uses an exponential
backoff (10s → 30s → 60s → 120s → 300s) when consecutive probes
fail, resetting on the first full success. Previously a registered
remote going unreachable hammered pgrep + gateway_state.json every
10s indefinitely; now offline servers settle to a 5-min cadence
while live servers stay snappy.
- Localizable.xcstrings: routine .strings catalog refresh — stale
entries for removed UI strings, picked up new "Stored under
quick_commands:..." subtitle wording.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Two related fixes that together restore Skills hub Browse / Search on
iOS over Citadel SSH.
CitadelServerTransport.asyncRunProcess was using `executeCommand`,
which throws `CommandFailed` and discards the captured ByteBuffer when
the remote process exits non-zero. `hermes skills browse` happens to
print its full table and then exit non-zero on some hosts, so iOS got
nothing while Mac (Foundation Process) got the full output with
exitCode=1. Drive `executeCommandStream` directly so stdout + stderr
are drained regardless of outcome, then catch `SSHClient.CommandFailed`
to recover the actual exit code. Network/channel-level failures still
report -1 so callers can distinguish them from a clean non-zero remote
exit.
Citadel's raw exec channel also doesn't source the user's shell rc
files, so non-interactive sessions land with a stripped PATH (typically
just /usr/bin:/bin). pipx installs `hermes` at `~/.local/bin/hermes`,
and many of hermes's sub-tools (git, curl, python) live in homebrew
prefixes that the remote sshd would otherwise add via login-shell init.
Mac's OpenSSH sshd handles this transparently; Citadel does not. Inline
PATH=$HOME/.local/bin:/opt/homebrew/bin:/usr/local/bin:$PATH on every
runProcess invocation so bare `hermes` resolves AND any subprocess it
spawns can still find its tools.
SkillsViewModel.finishBrowse now surfaces the actual stderr/stdout
snippet when the CLI exits non-zero, instead of a canned "Browse failed"
banner. ANSI-stripped + box-drawing-stripped so the message stays
readable in the one-line banner. Made diagnosing the underlying PATH
issue much easier and is a net UX improvement going forward.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Sweeps the rust ScarfDesign page background onto the screens that
were still rendering against the iOS default: Skills/Hub, Skills/Updates,
all three project sub-views, and Skill Detail. Lists adopt
.scrollContentBackground(.hidden) + ScarfColor.backgroundPrimary, with
.listRowBackground(ScarfColor.backgroundSecondary) on rows so the
Mac-style elevated-card density carries through.
Adds a "Switch server" toolbar button to Dashboard's top-right, threaded
through ScarfGoTabRoot from the connected-server host. One tap soft-
disconnects and returns to the server list — same code path the System
tab already exposes, just reachable without first navigating away from
Dashboard.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
iOS now uses ScarfColor.backgroundPrimary throughout instead of the
default iOS systemGroupedBackground. List-based screens add
.scrollContentBackground(.hidden) + the rust background underneath;
list rows use ScarfColor.backgroundSecondary as their card surface.
Applied to: Projects, Memory, Cron, Settings, Skills/Installed, and
the Servers root.
Dashboard rewritten in Mac-style cards (no more native iOS list):
- ScrollView + VStack with rust background
- Activity stat grid (2-col LazyVGrid) with bordered rust-tinted
cards: Sessions / Messages / Tool Calls / Tokens (with in/out sub-
label).
- Recent sessions card — bordered, ScarfColor.backgroundSecondary,
inline session rows with 1px dividers, "See all" nav to Sessions
sub-tab.
- Error banner styled with ScarfColor.warning tinted card per Mac.
- Sessions sub-tab keeps a List view but renders against rust
background with ScarfColor.backgroundSecondary row backgrounds.
Chat redesigned to match the Mac chat reference:
- User bubble: rust accent fill with ScarfColor.onAccent text and
uneven rounded corners (top/bottom-leading + top-trailing 14px;
bottom-trailing 4px) — visually pinches to the sender side, same
as Mac.
- Assistant bubble: rust gradient sparkles avatar tile (24x24)
alongside a ScarfColor.backgroundSecondary bordered card.
- ToolCallCard: kind-tinted border + uppercase tracked label
(READ/EDIT/EXECUTE/FETCH/BROWSER) using ScarfColor.success/info/
warning/Tool.web/Tool.search; expanded JSON in a bordered
ScarfColor.backgroundSecondary panel.
- ReasoningDisclosure: warning-tinted card with REASONING uppercase
label.
Both Mac (scarf) and iOS (scarf mobile) schemes build green.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
AccentColor.colorset repointed to BrandRust hex (light + dark) so the
tab bar, every .tint, every default button, and every navigation
accent across all 5 tabs read rust automatically. Single-line fix,
biggest visible change.
ScarfDesign now imported across all 27 iOS view files. Color sweep
applies the same patterns as the Mac side, with two iOS-specific
deviations documented in CLAUDE.md:
- ScarfPageHeader is NOT retrofitted onto iOS tab roots. iOS uses
.navigationTitle(...) + .navigationBarTitleDisplayMode(.large) as
its native page-header pattern; stacking ScarfPageHeader on top
creates double titles. ScarfPageHeader is reserved for sub-views
without a native large-title bar.
- Only .borderedProminent → ScarfPrimaryButton. .bordered and .plain
stay native because .bordered is the iOS convention for non-primary
buttons and inherits rust through AccentColor automatically.
Dynamic Type policy (locked + documented in CLAUDE.md): preserve
.font(.headline)/.body/.caption semantic tokens for body copy, list
rows, error messages, and chat content (anything read for content).
Use ScarfFont only for status badges, chip labels, intentional fixed-
size display elements. Mass-swapping ScarfFont on iOS would regress
accessibility for users on .accessibility2 / .xSmall.
Files touched (27 view files + AccentColor + CLAUDE.md):
- Color sweep: .foregroundStyle(.secondary) → ScarfColor.foregroundMuted,
Color(.secondarySystemBackground) → ScarfColor.backgroundSecondary,
status colors (.orange/.green/.red) → ScarfColor.warning/success/danger
in: Dashboard, Skills (root + Installed + Hub + Updates + Detail),
Projects (root + Detail + Sessions + Site + 8 widgets), Memory
(List + Editor), Cron, Settings (root + Editor), Servers, Chat
(root + Picker + Slash browser), Onboarding.
- Primary button swap (5 files): Chat, Projects/Sessions, Skills/
Updates, Skills/Hub, Onboarding.
- CLAUDE.md: appended "iOS Dynamic Type policy" + "iOS page chrome"
guidance under the existing Design System section.
Both Mac (scarf) and iOS (scarf mobile) schemes build green.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Sessions list (264 px) | transcript (flex) | inspector (320 px) per
design/static-site/ui-kit/Chat.jsx and the ScarfChatView reference.
Built over the real ChatViewModel + RichChatViewModel — live ACP
streaming pipeline untouched.
HermesToolCall gains optional duration / exitCode / startedAt fields
(backwards-compatible, nil defaults; not Codable). RichChatViewModel
populates them on ACP toolCallStart / toolCallUpdate; mutates the
streaming entry before finalize so the persisted call carries
telemetry. Sessions loaded from state.db gracefully render "—" when
nil.
ChatViewModel gains focusedToolCallId + a focusedToolCall computed
helper. ToolCallCard takes onFocus / isFocused — single click both
focuses the inspector and toggles inline expansion (two paths to the
same data per locked decision). Border weight + tint bump signal the
focused card.
Sessions pane: project filter (matches Sessions feature semantics),
search field, project chips per row, right-click rename + delete via
hermes sessions rename / delete --yes. Recent-sessions limit bumped
10 -> 50 so the project filter has data. loadRecentSessions commits
all four observables in a single MainActor batch to eliminate the
brief flash on session switch. ChatView toolbar's redundant Session
menu trimmed (left pane is canonical).
ChatTranscriptPane wraps existing SessionInfoBar + RichChatMessageList
+ RichChatInputBar without owning new state. RichChatView body becomes
a fixed 3-pane HStack — ViewThatFits was downgrading to transcript-only
when transcript content widened mid-load.
Inspector: STATUS / ARGUMENTS / TELEMETRY / PERMISSIONS in the Details
tab; STDOUT in dark mono panel under Output; full JSON envelope under
Raw. Footer Re-run is stubbed (TODO: /retry path); Copy puts the raw
JSON envelope on the clipboard.
ProjectSlashCommandsView: empty-state ContentUnavailableView now
centers in the full pane via .frame(maxWidth/maxHeight: .infinity).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Add a typed design-system package (Packages/ScarfDesign) with rust-tone
color tokens, type scale, spacing/radius tokens, ScarfPageHeader and
component primitives (ScarfCard, ScarfBadge, ScarfTextField,
ScarfSectionHeader, ScarfDivider, four button styles). Both Mac and iOS
targets `import ScarfDesign`.
Sidebar redesigned per design/static-site/ui-kit/Sidebar.jsx — glassy
translucent background, 224 px width, app-icon header with server pill,
custom tokenized rows with rust accent-tint when active, footer with
live Hermes-running indicator (wired to ServerLiveStatusRegistry).
14 mockup-backed feature screens redesigned: Settings, Dashboard,
Sessions, Memory, Chat (visual sweep), Activity, Cron, Insights,
MCPServers, Health, Logs, Tools (full); Projects light-touch.
Non-mockup features inherit rust through AccentColor.colorset repoint.
Mac AppIcon.appiconset replaced with the rust set. AccentColor.colorset
repointed to BrandRust hex (light + dark variants).
Visual sweep: every multi-button page-header / action-bar cluster now
wraps in .fixedSize(horizontal: true, vertical: false) so labels can't
wrap letter-by-letter at narrow widths (regression seen on the MCP
detail pane with 4 buttons).
Follow-ups landed:
- Sidebar Hermes-running probe wired to per-window
ServerLiveStatusRegistry (no more placeholder green).
- Sessions: today filter predicate (isDateInToday(startedAt)); pill
count reflects real count. Starred stays a no-op pending an upstream
pinned/starred field on HermesSession.
- Dashboard: Recent activity column rendered alongside Recent sessions
in a ViewThatFits 2-col grid. Populated from
HermesDataService.fetchRecentToolCalls(limit:) flattened to
ActivityEntry. ActivityEntry gains a public memberwise init.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Cleanup pass: HermesFileService.loadSkills() was duplicating walk
logic that the new ScarfCore SkillsScanner now owns. Replaced the
~38-line implementation with a one-line delegation.
Removed:
- HermesFileService.loadSkills() walk body (38 lines).
- HermesFileService.parseSkillFrontmatter (24 lines, supersedes by
SkillFrontmatterParser.parseV011Fields).
- HermesFileService.parseSkillRequiredConfig (24 lines, superseded by
SkillFrontmatterParser.parseRequiredConfig).
The remaining HermesFileService surface (loadSkillContent,
saveSkillContent, isValidSkillPath) is unchanged — those are Mac-
target-specific guards on file paths that don't fit ScarfCore.
Tab enum audit: searched for orphan `.memory` / `.more` references
under Scarf iOS/. None found — the worktree refactor cleanly
migrated every selectedTab assignment to the new 5-tab vocabulary.
Verified: ScarfCore 197 tests + 28 catalog tests + Mac + iOS builds
all green (Phase F gate).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Re-port the four v0.11 iOS Skills features that lived in the now-
deleted Skills/SkillsListView.swift into the new Installed/SkillDetailView
+ Skills/SkillsView surfaces.
iOS Components/FlowLayout.swift (NEW, promoted helper):
- 50-line struct conforming to SwiftUI's Layout protocol; wraps
subviews onto multiple lines on overflow. Built-in API, no third-
party dep. Originally inline in the deleted SkillsListView; promoted
so multiple iOS views can share without duplicating ~30 lines.
iOS Skills/Installed/SkillDetailView.swift (extend):
- design-md npx prereq banner: yellow "Prerequisite missing" section
triggered by .task(id: skill.id) probing `which npx` via
SkillPrereqService when the selected skill is design-md.
- Spotify info row: green "Authentication" section pointing users at
the Mac sheet or shell for OAuth — phone OAuth flows are deferred
in v2.5.
- SKILL.md frontmatter chip rows: three sections (Allowed tools /
Related skills / Dependencies) using a chipRow helper backed by
the shared FlowLayout. Each section hides itself when its
HermesSkill field is nil — old skills without v0.11 frontmatter
show none of these.
iOS Skills/SkillsView.swift (extend):
- "What's New" pill at the top of the tab (above the sub-tab
picker). Driven by SkillSnapshotService.diff() against the per-
server last-seen snapshot. First-load primes silently so users
don't see "everything is new!" noise on day one.
- Recomputes on .task and .refreshable.
- "Seen" button persists the current set + dismisses.
Verified: iOS build succeeds. The chip-row data path is now
end-to-end (SkillsScanner → HermesSkill → SkillDetailView chips)
and the snapshot pill matches the Mac SkillsView placement.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Post-merge follow-up: the new SkillsScanner constructed HermesSkill
with `requiredConfig` only — leaving `allowedTools` / `relatedSkills`
/ `dependencies` (added in my v0.11 Phase 3.3) as nil. Detail-view
chip rows would render empty.
SkillFrontmatterParser:
- New `parseV011Fields(_:) -> (allowedTools:, relatedSkills:,
dependencies:)` reader. Reuses HermesYAML.parseNestedYAML to
extract the three lists from the SKILL.md frontmatter region
between `---` markers. Returns nil-everything when the file has
no frontmatter or the fields are absent / empty — chip rows hide.
- Existing `parseRequiredConfig(_:)` unchanged.
SkillsScanner:
- Reads `<skill>/SKILL.md` opportunistically (after the
`<skill>/skill.yaml` read), parses v0.11 frontmatter, passes
the three optional arrays into the HermesSkill constructor.
- Old skills without SKILL.md or without frontmatter keep nil and
scan keeps working.
Tests:
- 5 new SkillFrontmatterParserTests cases covering happy path,
partial fields, no frontmatter, empty fields, empty input.
- 10 total tests for the parser; all green.
Verified: ScarfCore builds clean. The chip-row data path is now
end-to-end (scan → HermesSkill → detail view) for both Mac and iOS.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Brings the major iOS UI refactor into scarf-mobile-development on top
of the v0.11 work that landed after the merge base (commit 6808adf).
Reconciled in this merge:
- iOS Chat/ChatView.swift — auto-merged. Their project-chat handoff
(lines 75-148: pendingProjectChat consumer + consumePendingProjectChat
helper) sits cleanly alongside my v0.11 chat additions at lines 350+
(slash command chip + browser sheet), 500+ (/steer toast), 700+
(per-turn stopwatch + git branch chip).
- Mac Features/Skills/Views/SkillsView.swift — manual resolution.
Took their async-wrap of viewModel.load() (the new ScarfCore
SkillsViewModel.load is async) AND kept my v0.11 modifiers
(designMdNpxStatus probe + recomputeSnapshotDiff + .onChange + .task)
+ helpers (recomputeSnapshotDiff, whatsNewPill).
- M5FeatureVMTests.swift — auto-merged. Their 3-line rename of
IOSSkillsViewModel → SkillsViewModel is in a different region from
my Phase 1.10 slash-command tests.
- iOS Skills/SkillsListView.swift — resolved as DELETE (their
refactor replaces it with Skills/Installed/SkillDetailView and
Skills/SkillsView). My v0.11 features there (Spotify info row,
design-md banner, frontmatter chips, What's New pill) get re-ported
to the new files in follow-up commits.
- ScarfCore IOSSkillsViewModel.swift — resolved as DELETE (replaced
by the shared SkillsViewModel in ScarfCore). My parseFrontmatter
function relocates to SkillFrontmatterParser via Phase C.
- ProjectSlashCommandsViewModel.swift — git's location-conflict
heuristic moved my Mac VM into ScarfCore (because the parent dir
was renamed). Manually relocated back to scarf/scarf/Features/Projects/ViewModels/
where it belongs (the file imports ScarfCore as a dependency, can't
live inside it).
Wholesale-accepted (no overlap with v0.11):
- ScarfCore: SkillsScanner, SkillFrontmatterParser, HermesSkillsHubParser,
SkillsViewModel, ProjectSessionsViewModel + new tests.
- iOS Projects/ feature (NEW): ProjectsListView, ProjectDetailView,
ProjectSessionsView_iOS, ProjectSiteView, Widgets/ subdir.
- iOS Skills/ refactor (NEW): SkillsView (3-sub-tab switcher),
Hub/HubBrowseView, Installed/{InstalledSkillsListView, SkillDetailView,
SkillEditorSheet}, Updates/UpdatesView.
- ScarfGoCoordinator: pendingProjectChat, startChatInProject(path:).
- ScarfGoTabRoot: 5-tab nav (Dashboard / Projects / Chat / Skills /
System) replacing the old Chat / Dashboard / Memory / More.
Verified: ScarfCore + Mac + iOS schemes all build clean on first try
post-merge. Phase C/D/E follow-up commits will:
1. Extend SkillsScanner so HermesSkill.allowedTools / relatedSkills /
dependencies populate (currently nil because the new scanner only
parses skill.yaml's required_config).
2. Port my v0.11 iOS Skills features into the new SkillDetailView /
SkillsView (Spotify info row, design-md npx banner, frontmatter
chips, What's New pill).
3. Clean up Mac dead code (HermesFileService.parseSkillFrontmatter,
parseSkillRequiredConfig — superseded by SkillsScanner /
SkillFrontmatterParser).
Major iOS UI refactor that brings ScarfGo to feature parity with the
Mac app for Projects + Skills, on top of a ScarfCore consolidation
that unifies the view-model + scanner/parser layer between platforms.
Layout (ScarfGoTabRoot):
- Old: Chat / Dashboard / Memory / More (4 tabs).
- New: Dashboard / Projects / Chat / Skills / System (5 tabs, Chat
centered). Memory + Cron + Settings consolidate under System.
Projects (NEW iOS feature):
- ProjectsListView, ProjectDetailView, ProjectSessionsView_iOS,
ProjectSiteView.
- Widgets/ subdir: 7 widget views (Chart, List, Progress, Stat,
Table, Text, Webview) + WidgetHelpers + DashboardWidgetsView.
- Tied to chat via ScarfGoCoordinator.startChatInProject() which
sets pendingProjectChat + flips selectedTab to .chat.
Skills (NEW iOS surface):
- SkillsView is a 3-sub-tab switcher (Installed / Browse Hub / Updates).
- Installed/: InstalledSkillsListView, SkillDetailView,
SkillEditorSheet.
- Hub/HubBrowseView for the skills hub catalog.
- Updates/UpdatesView for hermes skills check / update.
ScarfCore consolidation:
- SkillsViewModel and ProjectSessionsViewModel lift from Mac target
into ScarfCore so iOS and Mac share one type.
- New SkillsScanner walks ~/.hermes/skills/ once for both platforms
via the supplied transport.
- New SkillFrontmatterParser handles required_config: parsing.
- New HermesSkillsHubParser for the hub catalog format.
- Tests for both new parsers.
Mac touchpoints:
- Features/Skills/Views/SkillsView.swift: .onAppear wraps the now-
async load() in a Task.
- Old Mac-target SkillsViewModel and ProjectSessionsViewModel
deleted (replaced by ScarfCore).
Coordinator + chat:
- ScarfGoCoordinator gains pendingProjectChat: String?
+ startChatInProject(path:) helper.
- iOS ChatView consumes pendingProjectChat (mirrors the existing
pendingResumeSessionID pattern); resolves path → ProjectEntry via
registry, falls back to a synthesized entry on miss.
Tests:
- M5FeatureVMTests renames 3 IOSSkillsViewModel references to the
shared SkillsViewModel.
- New SkillFrontmatterParserTests + SkillsHubParserTests.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Mac shipped the toolbar Reset button in Phase 5; iOS gets it in the
final verification pass for parity.
iOS MemoryListView:
- Toolbar button (counterclockwise icon) opens a destructive
confirmation dialog matching the Mac copy.
- resetMemory() shells out via context.makeTransport().runProcess,
using the same PATH-prefix trick IOSSettingsViewModel.saveValue
uses so non-interactive remote shells find hermes in ~/.local/bin
/ /opt/homebrew/bin / ~/.hermes/bin.
- Success and failure both surface alerts (success message
confirms the wipe; failure surfaces stderr+stdout combined).
Verified: iOS build clean.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Phase 8 of the v2.5 plan — fold the Hermes v2026.4.23 integration into
the existing v2.5 release artifacts rather than creating a v2.6 set.
releases/v2.5.0/RELEASE_NOTES.md:
- Lead paragraph extended to mention slash commands, chat parity,
Spotify, design-md.
- Six new sections: Portable project-scoped slash commands, Hermes
v2026.4.23 chat parity, Spotify + design-md skill onboarding,
SKILL.md frontmatter chips, "What's New" pill, state.db deltas,
hermes memory reset.
- All inserted before the existing "Mac global Sessions" section so
the Hermes-v0.11 work reads as the headline alongside ScarfGo.
README.md:
- "What's New in 2.5" lead bullets gain slash commands, Hermes v0.11
chat parity, Spotify+design-md, SKILL.md chips, snapshot pill.
- Test count bumped 163 → 179.
- Requirements: Hermes recommended bumped from v0.10.0+ to v0.11.0+
with feature attribution.
- Compatibility table: v0.11.0 row added as the current target;
v0.10.0 row demoted to "Tool Gateway introduced".
- Targeting paragraph rewritten for v2.5/v0.11.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adopt the lowest-risk new CLI subcommand from Hermes v2026.4.23 —
`hermes memory reset --yes` — and document the deferred ones for
v2.6. Wholesale plugin/profile/webhook/logs adoption is forward-
compatible work the existing services don't block on; deferring
keeps v2.5 scope tight.
MemoryView:
- Toolbar button "Reset memory…" with .arrow.counterclockwise icon.
- Confirmation dialog explaining the destructive semantics (no undo,
wipes both MEMORY.md and USER.md). Routes through
context.runHermes(["memory", "reset", "--yes"]); on non-zero exit
shows the stderr in an alert. Refreshes the on-screen content on
success.
CLAUDE.md:
- "Hermes Version" section now leads with v2026.4.23 (v0.11.0) and
enumerates the v2.5-adopted features (slash steer, state.db
deltas, new skills, frontmatter chips, memory reset) with file
pointers. v2.6-deferred CLIs (plugins / profile / webhook /
insights / logs) are flagged so future bandwidth knows where to
pick up.
Verified: Mac build clean.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Hermes v2026.4.23 added two columns to state.db:
- messages.reasoning_content — newer richer reasoning channel some
providers emit alongside the legacy messages.reasoning blob.
- sessions.api_call_count — distinct from tool_call_count; counts
per-turn API round-trips so the user can see the cost breakdown.
ScarfCore models:
- HermesMessage gains reasoningContent: String? + computed
preferredReasoning + updated hasReasoning to consider both
channels.
- HermesSession gains apiCallCount: Int (default 0 for old hosts).
ScarfCore HermesDataService:
- hasV011Schema flag detects both new columns via PRAGMA
table_info; only flips true when BOTH are present (partial
migrations stay on the v0.7 path to avoid runtime "no such
column" errors).
- sessionColumns / messageColumns / searchMessages SELECT lists
conditionally append the new columns.
- sessionFromRow / messageFromRow read them defensively (column
index 20 / 11 respectively when v0.11 schema is on).
UI surfacing:
- Mac SessionDetailView shows "<N> API" label next to msgs/tools
when apiCallCount > 0.
- Mac Dashboard SessionRow + iOS Dashboard sessionRow add a
network-icon chip with the API call count.
- Mac RichMessageBubble + iOS MessageBubble switch to
message.preferredReasoning for the disclosure body.
Verified: ScarfCore + Mac + iOS build. 179/179 ScarfCore tests pass
unchanged (existing tests didn't construct sessions/messages with
the new fields; defaults preserve behaviour).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Per-server snapshot of skill signatures so the Skills tab can show
"2 new, 4 updated since you last looked" — same pattern Hermes's
`hermes skills update` CLI shows on the host.
ScarfCore SkillSnapshotService:
- [skillId: signature] map, signature is `<fileCount>:<sorted-files>`.
New / removed / files-changed all show up as a delta.
- diff(against:) returns SkillSnapshotDiff with counts + a label
string for the pill.
- markSeen(_:) persists the current set.
- Backend abstraction: file-based on Mac, UserDefaults on iOS,
in-memory for tests.
- previousSnapshotEmpty silently primes first-load so users don't
see "everything is new!" noise.
Mac SkillsView:
- whatsNewPill(diff:) tinted pill at the top with "Mark as seen".
- recomputeSnapshotDiff() on .task and on totalSkillCount change.
iOS SkillsListView:
- Same pill rendered as a Section row with "Seen" button.
- Recompute on .task + .refreshable.
Verified: Mac + iOS builds clean.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Hermes v2026.4.23 SKILL.md files carry richer YAML frontmatter:
allowed_tools, related_skills, dependencies. Surface them as chip
rows in the skill detail view on both platforms.
ScarfCore HermesSkill:
- Three new optional fields: allowedTools, relatedSkills,
dependencies. Default-nil so older skills (no SKILL.md, or
SKILL.md without these fields) load unchanged.
Mac HermesFileService.parseSkillFrontmatter:
- Reads `<skill>/SKILL.md`, splits at `---` markers, parses the
frontmatter via HermesYAML.parseNestedYAML, and extracts the three
list fields. Tuple-of-optionals return; nil-everything when the
file is absent or has no frontmatter.
iOS IOSSkillsViewModel.parseFrontmatter:
- Mirror with the iOS transport (over SFTP). Same parser, same
return shape.
Mac SkillsView:
- skillChipSection(title:items:) helper renders a labelled chip
row. Three rows added between the existing missing-config /
Spotify / npx surfaces and the file list — only shown when the
corresponding field is non-empty.
iOS SkillDetailView:
- chipRow(_:) helper using a small in-file FlowLayout (built-in
Layout protocol, no third-party dep) so the chips wrap onto
multiple lines on iPhone-narrow screens. Three sections matching
Mac.
Verified: ScarfCore + Mac + iOS builds clean.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
design-md (Hermes v2026.4.23) requires `npx` (Node.js 18+) on the
host to invoke `npx @google/design.md`. Probe the host's PATH when
the skill is selected; surface a yellow banner with an install hint
when missing.
ScarfCore SkillPrereqService:
- probe(binary:installHint:) async -> Status — runs `/usr/bin/env
which <binary>` via the transport with a 4s timeout. Returns
.present / .missing(hint) / .unknown(reason).
- installHints table for npx / node / gws / ffmpeg with terse
per-OS install guidance. Skills can pass custom hints if their
install path is more involved.
Mac SkillsView:
- @State designMdNpxStatus + .onChange(of: selectedSkill.name)
triggers the probe whenever the user lands on the design-md skill.
Banner renders only on .missing — present and unknown cases stay
silent (avoids false-alarm noise on transient SSH errors).
iOS SkillDetailView:
- @State npxStatus + .task(id: skill.id) per-skill probe.
- Same banner with the same hint copy; no install button (user is
already on iPhone, fixing the host needs a shell anyway).
Verified: ScarfCore + Mac + iOS builds clean.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Hermes v2026.4.23 ships a `spotify` skill that needs OAuth via
`hermes auth spotify`. Mirror the v2.3 Nous Portal in-app sign-in
pattern so users don't have to drop to a shell.
Mac (full sign-in flow):
- SpotifyAuthFlow.swift in Core/Services — @Observable @MainActor,
five-state machine (idle → starting → waitingForApproval(URL) →
verifying → success | failure). Spawns `hermes auth spotify` via
the transport, regex-detects the
`https://accounts.spotify.com/authorize?...` URL on stdout/stderr,
auto-opens it via NSWorkspace, and on subprocess exit polls
`~/.hermes/auth.json` to confirm `providers.spotify.access_token`
actually landed (exit code alone isn't proof).
- SpotifySignInSheet.swift in Features/Skills/Views — five sub-views
matching the state machine (starting / waiting / verifying /
success / failure with retry). Auto-dismisses 1.2s after success.
Mirrors NousSignInSheet shape.
- SkillsView surfaces a "Sign in to Spotify" row in the skill detail
pane when the selected skill is the spotify one.
iOS (read-only documentation):
- SkillsListView's SkillDetailView gains a "Authentication" section
on the spotify skill explaining that OAuth needs to happen from
Mac (or a shell). The credential lands in ~/.hermes/auth.json and
ScarfGo picks it up automatically once the agent uses the skill.
Editor sheet UX deferred to v2.6 — multi-line OAuth flows on iPhone
are a separate UX problem.
Verified: Mac + iOS builds clean.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Hermes v2026.4.23's TUI shows the project's current git branch as a
sidebar pill. Mirror it in the chat header on both platforms.
ScarfCore GitBranchService:
- branch(at projectPath: String) async -> String? — runs
`git -C <path> rev-parse --abbrev-ref HEAD` via the transport
(works on local + remote SSH projects). Returns nil for
non-git dirs, missing git, detached HEAD, or transport errors.
No throwing — chat header omits the chip on any failure.
Mac:
- ChatViewModel.currentGitBranch populated alongside currentProjectPath
in startACPSession's resolution branch.
- SessionInfoBar gains gitBranch: String? — renders a tinted
`arrow.triangle.branch` chip after the project chip when set.
- RichChatView wires chatViewModel.currentGitBranch through.
iOS:
- ChatController.currentGitBranch on the same lifecycle hooks
(resetAndStartInProject + startResuming + cleared on
resetAndStartNewSession).
- projectContextBar renders the chip inline next to the project
name.
Verified: ScarfCore + Mac + iOS builds clean.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Hermes v2026.4.23's TUI rewrite added 1-9 numbered shortcuts on
approval prompts so power users approve/deny without reaching for
the mouse. Mirror the pattern in Scarf:
Mac PermissionApprovalView:
- Each option button gets a "1. ", "2. ", … prefix on its label.
- New private View extension `applyingNumberShortcut(index:)` binds
the digit `idx + 1` (no modifiers) via .keyboardShortcut. Capped
at 9; extra options stay tappable but unbound.
iOS PermissionSheet:
- Each row gets a monospaced "1." / "2." prefix as a hierarchy hint.
- No keyboard binding (phones don't have hardware keyboards), but
the numbering matches the Mac pattern so users transitioning
between platforms see the same visual structure.
Verified: Mac + iOS builds clean.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Wall-clock duration of each agent turn renders as a compact pill in
the message metadata footer (Mac) / below the bubble (iOS). Mirrors
the per-turn stopwatch Hermes v2026.4.23's TUI rewrite ships.
ScarfCore RichChatViewModel:
- currentTurnStart: Date? captured in addUserMessage when entering a
fresh turn (skipped for /steer-style mid-run sends so the duration
reflects the FULL turn).
- turnDurations: [Int: TimeInterval] keyed by finalised assistant
message id; populated in finalizeStreamingMessage and cleared on
reset().
- formatTurnDuration(_:) static — "0.8s" / "4.2s" / "1m 12s".
Mac:
- RichMessageBubble gains turnDuration: TimeInterval?; renders via
formatTurnDuration in the existing metadata footer.
- RichChatMessageList + MessageGroupView thread the durations dict
through; RichChatView wires richChat.turnDurations.
iOS:
- MessageBubble gains turnDuration parameter; renders below the
bubble for assistant messages only.
- ChatView's ForEach passes controller.vm.turnDuration(forMessageId:).
Verified: Mac + iOS builds clean. Resumed sessions (loaded from
state.db) show no pill — turnDurations only populates for live ACP
turns, which is the correct behaviour.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Hermes v2026.4.23 introduces /steer — mid-run guidance the agent
applies after the next tool call without interrupting the current
turn. Surface it as a first-class slash command in both Mac and iOS
chat menus with non-interruptive send semantics.
ScarfCore RichChatViewModel:
- nonInterruptiveCommands static (currently just /steer) merged
into availableCommands at the end of the menu.
- HermesSlashCommand.Source.acpNonInterruptive case carries the
flag through to the menu UI.
- transientHint: String? property for short-lived composer toasts.
- isNonInterruptiveSlash(_ text: String) -> Bool helper for the
send paths to detect /steer-shaped invocations.
Mac ChatViewModel.sendViaACP:
- /steer-shaped sends skip the "Agent working..." status update
(the agent is already on its current turn) and set a 4-second
transientHint "Guidance queued — applies after the next tool call."
Mac RichChatView:
- New steeringToast() above the input bar renders the hint when
set; tinted pill with arrow icon, opacity transition.
iOS ChatController.send + ChatView:
- Same isNonInterruptiveSlash check surfaces the toast above the
composer; auto-clears via the same 4s Task pattern.
- steeringToast() helper view in ChatView.
Verified: ScarfCore + Mac + iOS builds clean.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Slash commands now travel with .scarftemplate bundles. Schema bumps
to v3 when a manifest declares contents.slashCommands; v1/v2 bundles
keep parsing unchanged.
Swift side:
- TemplateContents gains slashCommands: [String]? — names only.
Bundle layout: slash-commands/<name>.md at the root.
- ProjectTemplateService.buildInstallPlan copies each claimed name
into <projectDir>/.scarf/slash-commands/<name>.md.
- ProjectTemplateService.verifyClaims cross-checks: each name must
pass ProjectSlashCommand.validateName, the file must exist, and
the bundle can't contain unclaimed slash-commands/ files.
- TemplateLock gains slashCommandFiles: [String]? (relative to
project root). The uninstaller's existing tracked-file logic
removes them; user-authored slash commands in the same dir
survive (they're not in the lock).
- ProjectTemplateExporter scans <project>/.scarf/slash-commands/ on
export and copies each .md into the bundle root, populating the
manifest contents claim. SchemaVersion bumps to 3 only when slash
commands are present.
Python catalog validator (tools/build-catalog.py):
- SUPPORTED_SCHEMA_VERSIONS gains 3.
- SLASH_COMMAND_NAME_RE mirrors the Swift validation pattern.
- _validate_contents_claim picks up slashCommands: rejects malformed
names, missing files, and unclaimed extras with the same error
shapes the Swift verifier uses.
Tests:
- 4 new test_build_catalog cases. 28/28 catalog tests pass.
- ProjectTemplateTests literal updated for the new TemplateContents
field.
Verified: Mac + iOS builds succeed.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Read-only surface in iOS for browsing project-scoped slash commands.
Editing on phones is its own UX problem (multi-line markdown +
keyboard ergonomics) — Mac stays the canonical authoring surface
in v2.5; iOS browses + invokes.
When a project chat has at least one slash command loaded,
projectContextBar grows a tinted "<N> slash" chip on the right side.
Tapping opens ProjectSlashCommandsBrowser:
- List of every command with /<name>, description, argument hint,
optional model-override badge.
- Tap a row → CommandDetailSheet with the full prompt-template body
rendered in a monospaced block (text-selection enabled), plus
metadata rows for argumentHint / model / tags.
- Footer points authors back to Mac for editing.
Verified: iOS build succeeds.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds a fourth per-project tab on Mac (alongside Dashboard / Site /
Sessions) for managing project-scoped slash commands. The whole
authoring story lives here: list, add, edit, duplicate, delete, with
a live-preview pane that expands {{argument}} substitutions against a
sample-arg field so authors see exactly what Hermes will receive.
- ProjectSlashCommandsViewModel — @Observable @MainActor, owns the
commands list + editor draft + dirty-tracking. Routes through
ScarfCore's ProjectSlashCommandService for all I/O. Save validates
name shape + collision detection before writing; rename cleans up
the previous file.
- ProjectSlashCommandsView — list with content menu (Edit/Duplicate/
Delete), empty state with CTA, error banner for transient failures.
- SlashCommandEditorSheet — HSplitView with form on the left
(identity / optional / monospaced body editor) and live preview on
the right (sample-argument field + expanded prompt). Save disabled
until name + description + body are non-empty.
- DashboardTab gains .slashCommands case alongside dashboard / site /
sessions; visibleTabs filter unchanged so it always shows for any
selected project.
iOS gets a read-only browser in the next commit (Phase 1.7) — phone
keyboards aren't great for multi-line markdown editing.
Verified: Mac build succeeds.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The chat layer client-side-expands /<name> args, but the agent still
needs to know what commands exist so it can answer "what slash
commands does this project have?" and recognise the
<!-- scarf-slash:<name> --> marker prepended to expanded prompts.
ProjectContextBlock.renderMinimalBlock(...) gains an optional
slashCommandNames parameter; when non-empty, a new "Project slash
commands" bullet lists the names as backticked /<name> entries.
Mac's ProjectAgentContextService.renderBlock(for:) reads the names
via ProjectSlashCommandService.loadCommands(at:).map(\.name) and
emits the same bullet, keeping Mac and iOS block output aligned
where the content overlaps.
iOS chat resetAndStartInProject splits the slash-command load into a
synchronous read on a detached task BEFORE writing the block —
needed because the block has to land on disk before `hermes acp`
boots, and the async load that populates the chat menu would lose
the race.
Verified: ScarfCore, Mac, iOS all build clean.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Net-new Scarf primitive — Hermes has no project-scoped slash command
concept. Commands live at <project>/.scarf/slash-commands/<name>.md as
Markdown files with YAML frontmatter; Scarf intercepts the chat slash
menu, expands {{argument}} substitution client-side, and sends the
expanded prompt as a normal user message. Works uniformly on Mac + iOS,
local + remote SSH, against any Hermes version (no upstream dep).
Lands the model + service + chat wiring; editor UI (Mac), read-only
browser (iOS), AGENTS.md block extension, .scarftemplate format
extension, and tests follow in subsequent commits.
What this commit ships:
- ScarfCore Models/ProjectSlashCommand.swift — Sendable struct
carrying name + description + argumentHint? + model? + tags? + body
+ sourcePath. Validates name shape (lowercase, hyphens, starts with
letter, ≤64 chars).
- ScarfCore Services/ProjectSlashCommandService.swift — transport-
based loadCommands(at:), loadCommand(at:), save(_:at:),
delete(named:at:), expand(_:withArgument:). Markdown-with-
frontmatter parser reuses HermesYAML so no new dep. Substitution
supports `{{argument}}` and `{{argument | default: "..."}}`.
- HermesSlashCommand.Source gains .projectScoped (full payload looked
up in RichChatViewModel by name) and .acpNonInterruptive (reserved
for /steer in Phase 2.1).
- RichChatViewModel.projectScopedCommands + projectScopedCommand(named:)
+ loadProjectScopedCommands(at:); availableCommands precedence is
ACP > project-scoped > quick_commands, all de-duped by name.
- Mac ChatViewModel: expandIfProjectScoped(_:) helper called in
sendViaACP; loads commands when currentProjectPath is set in
startACPSession's resolution branch.
- iOS ChatController: same pattern in send(); loads commands in both
resetAndStartInProject and startResuming(sessionID:); resume now
resolves both path AND name so we can read the slash-commands dir.
Verified: ScarfCore + Mac + iOS all build clean.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Drops "Previously, in 1.6 / 2.0 / 2.1 / 2.2" so the README's release
history is just the lead (2.5) + one-level-back (2.3). Earlier history
moves to the wiki's Release-Notes-Index, which is the canonical place
for full version history anyway.
New "What's New in 2.5" section leads with ScarfGo public TestFlight,
the Mac Sessions parity (filter + badges), human-readable cron
schedules, and the under-the-hood consolidation in ScarfCore.
Requirements section gains an iOS row pointing at the ScarfGo wiki
page for installation; the Hermes recommended-version bumps from
v0.9.0+ to v0.10.0+ to match the v2.3 floor.
No iOS-specific install instructions in the README — the TestFlight
URL gets added later in Phase G once Apple's Beta Review issues it.
For now, the link points at the wiki where the URL will land.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Authored locally (not pushed). Phase D of the v2.5 release plan needs:
- A privacy policy at a stable URL before App Store Connect lets you
submit for Beta App Review.
- A pre-flight checklist so the Xcode + App Store Connect dance
doesn't lose state.
`scarf/docs/PRIVACY_POLICY.md` — minimal, accurate. The apps don't
collect data on developer-controlled servers (no analytics, no
telemetry, no ads, no IDFA). Covers SSH credentials, Hermes state
cache, the project + attribution sidecars, the network connections the
apps make. Ready to host on gh-pages at /privacy/ when the user opts to
push it.
`releases/v2.5.0/TESTFLIGHT_CHECKLIST.md` — step-by-step from Apple
Developer Program prerequisites through Beta Review submission, with a
beta-description copy block, "What to test" copy, and a rollback note.
Explicitly calls out NOT bumping versions manually (release.sh does it
in Phase G) and NOT enabling Push Notifications until APNs cert +
sender land together.
Both files stay local until the user pushes them — the checklist is
the user's reference, the privacy policy gets copied into the
gh-pages worktree when ready to submit.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Authored before `release.sh` so it gets included in the version-bump
commit auto-generated by the script in Phase G.
Highlights: ScarfGo iOS public TestFlight, Mac Sessions project filter
+ badges (parity with ScarfGo's Sessions tab), human-readable cron
schedules cross-platform, shared-services refactor, silent-failure
hardening on the iOS lifecycle, test-suite consolidation that fixes the
cross-suite factory races we hit during pre-release verification.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The Mac global Sessions feature rendered all sessions with no project
context. ScarfGo's new Sessions tab added a project filter Menu and
badge chips on each row in v2.5 — bring the same to Mac so v2.5 lands
as a user-visible upgrade on both platforms, not just iOS.
Changes:
- `SessionsViewModel`: load `~/.hermes/scarf/session_project_map.json`
+ the project registry off the main actor (single batched read,
matches the iOS Dashboard pattern). Exposes `sessionProjectNames`,
`allProjects`, `projectFilter`, `filteredSessions`, and
`projectName(for:)`.
- `SessionsView`: filter bar above the list (shown only when at least
one project is registered) with a Menu listing "All projects",
"Unattributed", and each registered project. An xmark button clears
the filter. The right side shows "X of Y shown" so the filter's
effect is obvious.
- `SessionRow` (shared with Dashboard): gains an optional
`projectName: String?` parameter that renders a tinted folder chip
alongside the relative date when set.
Both services already lived in ScarfCore (moved there in v2.5's iOS
work), so this is pure UI consumption — no new shared logic.
Verified: Mac build succeeds.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Push Notifications capability is disabled in the iOS target, so the
APNS code path can't fire today — but the `SCARF_PENDING_PERMISSION`
category was registered unconditionally, exposing the stub-only
`APPROVE_PERMISSION` / `DENY_PERMISSION` action handlers as a route iOS
could surface action buttons on if a notification ever slipped through.
Add `NotificationRouter.apnsEnabled` (=`false`) and gate
`registerCategories()` behind it. While `false`, the category is
explicitly cleared so iOS has no path to route a tap to the stubs. The
gate is the single switch — flipping it requires the capability +
sender + real handler implementations to all land together.
Verified: iOS build succeeds.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Bundled because the fixes are coherent — they all add the same
mechanism (`lastError` + `os.Logger`) to the same model.
A.3 — Distinguish "no servers" from "Keychain unreachable":
- `RootModel.connect(to:)` previously used `try?` on `keyStore.load(for:)`.
A biometric cancel or device-locked Keychain read returned nil → the
app dropped the user into fresh onboarding, destroying the existing
server's host/user/port. Now we catch the throw, log via os.Logger,
set `lastError`, and stay on `.serverList`. The user sees a banner +
Dismiss button instead of being kicked back to onboarding.
- `RootModel.load()` now logs the corrupted-blob path via os.Logger and
sets `lastError` before falling through to onboarding (recovery is
necessary, but the user gets context now).
A.4 — Surface delete failures in `forget()` and `disconnect()`:
- Both used `try?` on every store delete. On partial failure the
in-memory dict was wiped while orphan Keychain entries lingered.
Now each delete is `do/catch` with logging, failures collected into
`lastError`. The in-memory state is reloaded from disk so it tracks
what's actually persisted (covers the partial-failure case).
ServerListView gains an inline error banner above the list that reads
`model.lastError`, with a Dismiss button calling `clearLastError()`.
Verified: iOS build succeeds.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
ChatController.resetAndStartInProject swallowed the SFTP write of the
Scarf-managed AGENTS.md block via `try?` inside `Task.detached`. On
failure (permission denied, SFTP error, malformed path) the user saw no
feedback while the UI continued claiming the session was project-scoped
— but the agent never received the project context, leading to silently
degraded chat quality.
Replace the `try? + fire-and-forget` with a `Result`-returning detached
task. On `.failure`, log the underlying error via `os.Logger` and route
it to the existing ACP error banner (`acpError` / `acpErrorHint` /
`acpErrorDetails`) with a friendly "Project context not written — agent
will proceed without it" payload. Session still starts; only the
context-augmentation step is reported as missing.
The session-attribution write at the same flow stays fire-and-forget by
design — `SessionAttributionService.persist` already logs failures
internally, and a missed attribution is purely cosmetic (Dashboard
project-badge cosmetics, not chat function). Replaced the comment to
make that intent explicit so future readers don't accidentally "fix"
it by promoting attribution failures to the chat banner.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Pre-release verification surfaced 9 failures in `swift test` driven by two
issues — both fixed without changing production behaviour.
1. M3TransportTests + M5FeatureVMTests both held `.serialized` internally
but ran in parallel with each other, racing on
`ServerContext.sshTransportFactory` (a `nonisolated(unsafe)` static).
Tried `@TaskLocal` first; reverted because production hot paths
dispatch through `Task.detached` which severs TaskLocal inheritance.
Final fix: move M3's three factory-injection tests + two
HermesLogService tests + the `ScriptedTransport` test double into
M5FeatureVMTests, the canonical factory-touching suite. M3 keeps its
`.serialized` suite trait for the remaining (non-factory) tests, but
the cross-suite race is gone because there's now exactly one suite
that mutates the static.
2. `loadProviders()` returns the 6 hardcoded Hermes overlays (Nous Portal,
Codex, Qwen, Gemini CLI, Copilot ACP, Arcee) on top of any models.dev
catalog hits — added in v2.3 so the picker doesn't go dark when the
cache is missing. `modelCatalogHandlesMissingAndMalformedFiles`
asserted `.isEmpty`, which had been correct before that change.
`modelCatalogLoadsSyntheticJSON` asserted `count == 2`, which was the
catalog-only count. Both updated: the missing/malformed test now
asserts the result is non-empty + every entry is `isOverlay`; the
synthetic-JSON test filters `!isOverlay` before counting.
Verified: 163 tests across 12 suites pass on three consecutive runs.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Pass-2 feedback bundled into one architectural commit:
1. **Project indicator moved out of the nav-bar principal slot.** The
iPhone nav bar's .principal area gets squeezed to icon-only when
adjacent toolbar buttons exist — the result was a folder icon with
no project-name text, which is worse than no indicator at all. New
`projectContextBar` renders a full-width tinted strip BELOW the
nav bar when a session is project-attributed: "Project chat"
caption + folder icon + full project name. Scrolls away with the
message list. Pattern cribbed from Slack's channel-topic header
and Apple Mail's sender strip.
2. **Dashboard split into Overview + Sessions sub-tabs.** Segmented
picker at the top. Overview = stats + 5 most-recent sessions for
at-a-glance; Sessions = the deeper 25-session list with a project
filter. `See all` button on Overview's Recent Sessions header
switches tabs. Addresses pass-2 complaint: "The dashboard might
need tabs to break it down better."
3. **Project filter on the Sessions sub-tab.** Menu picker (scales
to N projects; segmented doesn't). "All projects" clears; each
project entry filters to sessions attributed there. Uses the same
attribution map loaded once in `IOSDashboardViewModel.load()`, so
filtering is an O(n) in-memory pass over 25 sessions — no extra
SFTP traffic. Addresses pass-2 complaint: "we should add a filter
to the sessions selector in the dash to see by project."
4. **`IOSDashboardViewModel` exposes the wider surface:**
- `allSessions` (25-session window, feeds the Sessions tab)
- `allProjects` (project registry, drives the filter menu)
- `sessions(filteredBy: String?)` helper — accepts a project name
(nil = all), returns filtered subset.
Mac parity note from the earlier commit message still stands — Mac's
global Sessions list doesn't currently filter by project either.
That's a parallel post-TestFlight followup.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Pass-2 observations:
1. Resumed sessions from Dashboard loaded into chat but showed no
message history.
2. On sessions WITH a project badge, the chat nav-bar chip rendered
the folder icon but no project name.
**Root cause for (1)** — not actually an iOS bug. ACP-native sessions
(the kind ScarfGo starts) don't persist their transcript to the
client-visible `state.db` — only CLI/terminal sessions leave
history there. Confirmed by direct SQLite inspection: the session
IDs in Dashboard's Recent Sessions show `message_count = 0`; the
sessions with lots of messages are all older CLI sessions. The Mac
has this same limitation — just less visible because Mac's Sessions
list surfaces CLI sessions preferentially.
What we fix on the UX side: a friendlier empty state when a resumed
session has no persisted transcript. Replaces the blank canvas with
an icon + "Session resumed" + explanatory caption ("Hermes has the
context for this session, but the transcript isn't cached locally.
Send a message to continue.") Nudges the user toward the right
mental model instead of leaving them wondering why their history
vanished. Gated on `sessionId != nil` so fresh-chat empty state
stays the same.
**Root cause for (2)** — `ProjectEntry.name` shouldn't be empty, but
a defensive treatment avoids ever surfacing a folder-only chip on
edge cases (registry race, partial JSON decode). startResuming now:
- Clears `currentProjectName` eagerly at the start of the resume
flow so a lingering name from a prior session doesn't flash onto
the new header.
- Treats empty strings as nil when the lookup returns one.
And the toolbar renderer adds a `!projectName.isEmpty` guard so an
unexpected empty string never produces an icon-only chip.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Pass-2 observed a spurious
"The operation couldn't be completed. (Swift.CancellationError error 1)"
banner appearing even after the resumed session loaded cleanly.
Root cause: when ChatController.startResuming tears down a prior live
session via `await stop()`, the in-flight event-task awaits throw
CancellationError as they unwind — that's how Swift concurrency
cooperatively cancels. That error then propagated through
recordACPFailure to the visible banner, even though nothing actually
failed.
Filter CancellationError (and the URL-loading equivalent,
NSURLErrorCancelled) out at the recordACPFailure boundary. Real
errors still flow through to the banner with hints + stderr details.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Pass-2 UX feedback: "When selecting a per-project chat, we should
update the chat interface to show that we are 'in a project' — and
label them in the sessions list so the user can see the session
and understand what project it belongs to."
Two related changes:
**In-chat indicator** — ChatController gains `currentProjectName`,
set by `resetAndStartInProject` (direct: we have the ProjectEntry)
and by `startResuming` (resolved via SessionAttributionService +
project registry lookup). ChatView's toolbar uses a `.principal`
ToolbarItem with a VStack: "Chat" title on top, `Label(name, systemImage: "folder.fill")`
subtitle underneath when attributed. Mirrors Mac's SessionInfoBar
project-chip pattern but fits the iOS nav-bar real estate instead
of eating a full-width horizontal row.
**Dashboard row labels** — `IOSDashboardViewModel.load()` now does
one additional SFTP read per refresh: pulls the session→project
sidecar + project registry, maps session id → project display name
into `sessionProjectNames`. Row renders a small tinted folder
capsule when attributed. Batched so row renders are O(1) dict
lookups — no extra SFTP traffic per cell. Silent on failure
(attribution is cosmetic).
Not in scope for this commit: Mac's global Sessions list doesn't
currently show project attribution either — that gap exists on
both platforms, but wiring Mac's ProjectsSidebar + SessionsView
for per-row labels is a bigger surgery. Scoped as a post-TestFlight
followup.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Pass-2 observation: "When selecting a previous session from the
dashboard, the chat opens, loads, but starts fresh — we should
load the session with previous work like we do on the mac..."
The Mac's resume path does two things: (a) call session/resume on
ACPClient to re-bind Hermes to the session id, and (b) call
`richChatViewModel.loadSessionHistory(sessionId:acpSessionId:)` to
pull the persisted transcript out of state.db and populate the
message list. ScarfGo only did (a) — the ACP channel was wired up
correctly, but there was no SQLite read, so the UI showed an empty
bubble list until the user sent their first new prompt.
Added the loadSessionHistory call right after setSessionId in
ChatController.startResuming. It internally calls `dataService.refresh()`
first so the snapshot reflects whatever Hermes wrote between the
Dashboard's last SQLite pull and the resume tap. The acpSessionId
param is nil when resume preserved the id (no origin-vs-ACP split
needed) and set to the resolved id otherwise so the CLI + ACP
message streams can be merged chronologically — same behaviour the
Mac gets.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Pass-2 observation: "when a user switches away from chat and comes
back, there is a loading time — should we keep it open so there
isn't a reload needed?"
Removed the .onDisappear { controller.stop() } hook. TabView unmounts
tab content on switch (disappear fires), but @State keeps the
ChatController alive — so dropping the SSH exec channel + re-
opening on next appear was costing a ~1-2s reconnect every time
the user bounced Dashboard → Chat → Memory → Chat.
Cleanup still happens correctly because ChatController's lifetime
is tied to ChatView's parent (ScarfGoTabRoot). When the user
Disconnects/Forgets from the More tab, RootModel flips out of
.connected, the whole tab root unmounts, and the controller + its
ACPClient tear down via .deinit. Background termination is handled
by iOS naturally.
A comment in the file documents why we no longer tear down on
.onDisappear — easy to re-add if a future iPad / multi-window
variant wants explicit idle-pause behaviour.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Pass-2 turned up a ghost-message UX bug we missed in pass-1: every
"Thinking…" reasoning disclosure had an empty gray bubble next to
it. Happens because assistant messages exist momentarily in a
reasoning-only state (chunks of thinking text arrive before any
primary content), and the bubble path always rendered its padded
background regardless of content.
Gate the bubble render on non-empty content for assistant messages.
User bubbles still always render (the user explicitly submitted
content and saw it land — suppressing it on trim-empty would be
surprising). `trimmingCharacters(in: .whitespacesAndNewlines)` so
purely-whitespace assistant frames also don't render a bubble.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Ships the iOS-side scaffolding so a future Hermes push sender can
light ScarfGo up with no client-side surgery. Keeps the Push
Notifications capability in the Xcode target OFF until:
1. Apple Developer Program enrollment + APNs auth key are set up
(out of scope until TestFlight).
2. Hermes gains a `hermes register-device` endpoint + per-event
sender (new cron job result, new pending permission). Upstream
work, hasn't been specced.
What's now in the tree, ready to flip on:
- `Notifications/APNSTokenStore.swift` — actor-backed singleton that
captures the device-token hex string from a successful remote
registration. Logs for now (no server to POST to yet); has a TODO
marker at the spot where the real HTTPS POST will land.
- `Notifications/NotificationRouter.swift` — UNUserNotificationCenter
delegate that handles:
- foreground presentation (always show banner + sound);
- default tap → route to Chat tab with resume sessionID if
included in the payload (via the existing ScarfGoCoordinator);
- `APPROVE_PERMISSION` / `DENY_PERMISSION` action buttons on
notifications in the `SCARF_PENDING_PERMISSION` category, with
Face ID / passcode required (`.authenticationRequired`). Action
handlers log today; the real one-shot ACPClient respond-and-die
flow is scoped out until the sender pipe exists.
- Local-notification plumbing: `registerCategories()` +
`setUpOnLaunch()` (requests .alert/.sound/.badge permission).
- `registerForRemoteNotifications` deliberately commented out.
Turning it on without the capability surfaces as runtime
"no valid aps-environment entitlement string found" — waiting
keeps logs clean.
Wired at ScarfIOSApp launch via a `.task` on RootView — harmless on
denial, authorization dialog only shows once. ScarfGoTabRoot sets
the router's `coordinator` weak ref on appear so notification-taps
can cross-tab route. When the capability ships, the remaining work
is one call (`UIApplication.shared.registerForRemoteNotifications()`)
inside `setUpOnLaunch`'s `granted` branch + the AppDelegate hooks for
token delivery + a sign-in style payload build in APNSTokenStore.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Pass-1 feedback: "Settings loads, but no fields are editable." By-
design read-only in M6, but the on-the-go story is weaker without
at least the core model / approval-mode / display toggles editable.
Not a generic YAML round-trip editor — that was ruled out in the
original iOS plan because comment/order preservation requires
Hermes-side changes or a significant YAML library. Instead:
- Curated v1 list of 7 editable keys: model.default, model.provider,
approvals.mode, agent.max_turns, display.show_cost / show_reasoning
/ streaming. Covers ~80% of actual "I want to change this right
now while I'm away from my Mac" scenarios.
- IOSSettingsViewModel.saveValue(key:value:) shells out to
`hermes config set <key> <value>` over the SSH transport's
runProcess, reusing the same PATH-prefix trick we added in pass-1
for hermes acp so the remote shell finds hermes even in non-
interactive mode. Hermes owns the YAML round-trip; Scarf just
picks the value.
- SettingEditorSheet renders the right control per key: Toggle
(booleans), segmented Picker (approval mode), Stepper (max_turns),
TextField (model / provider / timezone). One sheet, four kinds
of input, driven by a `SettingSpec.Kind` enum.
- SettingsView gets a "Quick edits" section at the top that lists
the 7 keys with their current parsed values + an edit affordance.
The existing 10+ read-only sections stay unchanged — editing stays
scoped to the keys we curated.
- On save, the VM calls `load()` again so the parsed config (and
therefore the Quick-edits labels + the read-only sections below)
reflects the new value immediately.
- Errors from `hermes config set` (non-zero exit) surface inline on
the sheet via SettingsSaveError.commandFailed.errorDescription,
carrying stderr/stdout combined so the user sees what the remote
complained about. Sheet stays open on error for retry.
ScarfGo builds green. Mac Settings is unaffected — this feature is
iOS-only (Mac has its own richer editors via HermesFileService).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
ScarfGo now supports the Mac app's project-chat flow end-to-end.
Tapping + in Chat opens a sheet with two options:
1. Quick chat — cwd = $HOME (previous default).
2. In project… — pick from the remote Hermes's project registry,
spawn hermes acp with cwd = project.path, record the attribution.
Shared infrastructure for the SFTP parity (so Mac + ScarfGo use the
exact same record types + persistence logic):
- SessionProjectMap — moved from scarf/scarf/Core/Models/ to
ScarfCore. Public struct. Mac consumer unchanged (imports it via
ScarfCore now).
- SessionAttributionService — moved from Mac target to ScarfCore.
Was already transport-backed, so the port is straight lift-and-
shift: made public, added #if canImport(os) guards around the
Logger imports for Linux CI. Mac ChatViewModel and ProjectSessions
VM still call it the same way.
- ProjectContextBlock — new ScarfCore-level primitive that owns the
marker-splice logic for the Scarf-managed region of AGENTS.md:
- applyBlock(_:to:) — pure text splice with 3-case handling.
- writeBlock(_:forProjectAt:context:) — transport-backed write.
- renderMinimalBlock(projectName:projectPath:) — iOS-side block
composer (no template-manifest or cron-attribution fields — iOS
doesn't yet surface those concepts; markers + identity headers
match Mac output byte-for-byte so a project scaffolded on iOS
round-trips cleanly through the Mac).
Mac's ProjectAgentContextService stays in place — still the richer
block renderer (template manifest + cron jobs) — but it now forwards
beginMarker/endMarker/applyBlock to ProjectContextBlock so both
platforms share invariant strings and splice logic. Duplicate
implementations were a recipe for drift.
ScarfGo side:
- Chat/ProjectPickerSheet.swift — two-section sheet (Quick chat /
In project…). Loads the project list over SFTP via
ProjectDashboardService (already transport-backed, works on iOS).
Archived projects hidden (matching Mac sidebar behaviour).
- ChatController.resetAndStartInProject(_:) — stops the current
session, writes the minimal context block to <project>/AGENTS.md
over SFTP, spawns hermes acp with cwd = project.path, records the
attribution via SessionAttributionService. Non-fatal on block-
write failure (chat still starts).
- ChatController.startInternal(...) — refactored to take an optional
projectPath + projectName, so the regular start() and the new
project path share one ACP setup path. Attribution write happens
after newSession returns and the sessionId is known.
Project chip in the chat nav bar is deferred — on-the-go users know
they just picked a project in the sheet, the chip is polish we can
add post-TestFlight. Both schemes build green.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Pass-1 showed Dashboard's Recent Sessions list as a read-only
marquee — tapping a row did nothing. The natural user expectation
is "take me back to that conversation." Users were opening a new
chat every time, defeating the point of having a phone client for
an already-running agent.
Added a tiny cross-tab coordinator (ScarfGoCoordinator) modeled on
the Mac app's AppCoordinator pattern:
- `@Observable` carrier, injected via `.environment` at ScarfGoTabRoot.
- `selectedTab` drives TabView selection (bound with `.tag` on each
tab).
- `pendingResumeSessionID` is set by Dashboard row taps; consumed
by ChatView in `.task` / `.onChange` and cleared immediately so
later neutral tab switches don't accidentally re-resume.
ChatController gets a new `startResuming(sessionID:)` entry point
that mirrors `start()` but calls `session/resume` (falling back to
`session/load` if the remote Hermes is < 0.9.x). The rest of the
session lifecycle is identical so the event stream + error banner +
PATH wrap all stay in force.
Dashboard Recent Sessions rows now wrap in Button with `.buttonStyle(.plain)`
and fire `coordinator?.resumeSession(session.id)` on tap.
First usable on-the-go workflow: tap app icon → pick server → tap
Dashboard → see recent sessions → tap one → land directly back in
that conversation, full transcript loaded. No new-chat ceremony.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Full ConnectedServerRegistry was scoped out of this phase — SwiftUI
view lifecycle already tears down transports via .onDisappear when
ScarfGoTabRoot unmounts on state transition to .serverList. Adding
a formal registry that tracks every active transport per ServerID
is complexity without proven UX payoff right now (can revisit post
pass-2 if users hit stale-connection bugs).
One real cleanup we should always do on soft disconnect: invalidate
the shared UserHomeCache entry for the server we're leaving. The
cache lives forever otherwise, and a hypothetical scenario where
the remote user's home directory changes between sessions would
surface as SFTP paths resolving to the wrong directory. Rare, but
free to fix.
`RootModel.softDisconnect()` now calls the new static
`ServerContext.invalidateCachedHome(forServerID:)` before flipping
state to `.serverList`. Static form is a convenience for callers
that have the ServerID in hand but not a full ServerContext (avoids
forcing a round-trip through config store just to rebuild the
context we're already discarding).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
ScarfGo now boots into a list of configured servers instead of the
single-server Dashboard. Each row renders nickname + user@host:port,
taps to connect, swipes to forget. A "+" toolbar button re-enters
onboarding for a new server. Fresh install → straight to onboarding.
RootModel state machine redesigned around the multi-server world:
- `.loading` → `.serverList` when listAll() returns 1+ servers.
- `.loading` → `.onboarding(forNewServer:)` on fresh install.
- `.serverList` → `.onboarding(newID)` via "+" button.
- `.serverList` → `.connected(id, config, key)` via row tap.
- `.connected(id)` → `.serverList` via soft Disconnect (keeps creds).
- `.connected(id)` → `.serverList|.onboarding` via Forget (wipes id).
- `.onboarding` → `.connected(newID, …)` on completion.
Published `servers: [ServerID: IOSServerConfig]` on the RootModel so
ServerListView renders reactively without re-querying stores on
every re-render. `refreshServers()` is the `.task` hook; `forget()`
wipes a single id + refreshes.
OnboardingViewModel gains an optional `targetServerID` so its final
save lands in `keyStore.save(_:for:)` / `configStore.save(_🆔)`
instead of the singleton shims. Nil falls back to the old singleton
path for any remaining callers (tests, previews).
OnboardingRootView accepts `targetServerID` + a new `onCancel`
closure. The toolbar now shows Cancel so users can back out without
leaving half-written credentials; Cancel hides on the final
.connected step so you can't race-cancel a just-saved server.
ScarfGoTabRoot takes the server's ServerID as the context id so the
CitadelServerTransport pool caches per-server (two active servers →
two connection holders, no SSH channel contention). Splits the v1
onDisconnect into two callbacks:
- onSoftDisconnect: close transport, return to server list, keep creds.
- onForget: wipe this server's creds + return to server list (or
onboarding if empty).
MoreTab renders both Disconnect and Forget rows in distinct sections
with explicit footers.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Pass-1 revealed that iOS should hold more than one server (users
want to hop between a home server and a work server from a single
app). Storage was the first block: v1 stored exactly one config
under a fixed key and one Keychain item under account "primary".
Extend both stores with ID-keyed methods while keeping the v1
singleton API for back-compat during the transition:
- IOSServerConfigStore: add listAll, load(id:), save(_🆔),
delete(id:). Singleton load/save/delete now operates on the
"primary" entry (lowest UUID by string sort) — deterministic, no
surprise mutation of other servers when a singleton caller saves.
- SSHKeyStore: same treatment. Keychain accounts for v2 entries are
`"server-key:<UUID>"`.
Migration is one-shot and embedded in `listAll()` on both stores:
- UserDefaults: if the v1 key `com.scarf.ios.primary-server-config.v1`
is present AND v2 key `com.scarf.ios.servers.v2` is empty, load
the v1 config, insert under a fresh ServerID in v2, delete v1.
Idempotent — no-op once v1 is gone.
- Keychain: if no `server-key:*` accounts exist AND the legacy
`"primary"` account does, copy the bundle to a fresh ServerID
slot and delete the legacy item.
Both migrations preserve the v1 single-server experience: a user
who updates the app without re-onboarding still sees exactly one
configured server on first launch of the new version, with the
same SSH key and the same host details. No data loss.
InMemory stores updated to match (dictionary-keyed internally).
Mac + iOS schemes both build clean; ScarfCore swift build green.
Callers (RootModel, OnboardingViewModel, ChatController,
ScarfIOSApp transport factory) still use the singleton API and
will migrate to ID-keyed in 3.2-3.5.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
.medium is neither/nor — too tall to peek, too short to commit to.
Research recommends custom detents calibrated per sheet.
- Permission sheet: `[.height(220), .large]`. 220pt shows the prompt
+ first ~3 options without forcing the user to drag; `large` is
there for edge-case prompts with many options.
- Cron editor: `[.large]` only. Cron editing is a focused task with
a ~6-field form; peek detent is a distraction.
`.presentationDragIndicator(.visible)` on both so users know they
can drag the sheet without having to try + fail first.
No other sheets in the app today. The Forget-server confirmation
uses confirmationDialog (system-owned — no detents needed).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Bundles M8 items 2.4, 2.5, 2.6, 2.7 because they all touch ChatView
and together make the conversation readable on a phone:
2.4 — fenced code blocks (```…```) now render in a horizontally-
scrollable monospaced block inside the bubble. Collapsed to 240pt
max height with Expand/Collapse + a copy button; long shell
one-liners / JSON / stack traces stay one line each instead of
soft-wrapping into unreadable 4-line columns. New
`ChatContentFormatter.segments(for:)` splits the message body into
alternating `.text` (routed through AttributedString markdown) and
`.code` (routed to the new CodeBlockView). Deliberately simple
parser — handles the common fence shape, leaves inline backticks
to AttributedString, and falls back to plain text on unterminated
fences so nothing is ever silently swallowed.
2.5 — tool-call cards were already collapsed-by-default via a chevron
toggle. No structural change needed for M8; leaving the existing
ToolCallCard in place.
2.6 — replace the manual `onChange → proxy.scrollTo("bottom")`
pattern with iOS 17+ `.defaultScrollAnchor(.bottom)` plus iOS 18's
`.defaultScrollAnchor(.bottom, for: .sizeChanges)`. Native scroll-
pin fights the user's own scroll-up gesture less (the manual pattern
yanked you back to the bottom if a chunk arrived mid-read).
"New messages" pill for upward scroll-break deferred — needs a bit
of ScrollPosition state we don't plumb yet.
2.7 — `.contextMenu` on every message bubble with Copy + Share
(via ShareLink). User + assistant bubbles both. Code blocks get
their own copy button in the header. Regenerate intentionally
omitted — ACP has no native re-prompt primitive and implementing
one would be non-trivial session-state surgery.
Both schemes build.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Apple's default List styling targets Reading/Notes-style apps:
~60pt rows, 10pt inter-row spacing, big vertical padding on
grouped cells. ScarfGo's lists (Memory, Cron, Skills, More,
Dashboard recent sessions) lean information-dense — devs want to
see 4-6 items per screen, not 2.
Two tokens in Scarf iOS/App/Theme/ListDensity.swift:
- `.scarfGoCompactListRow()` — 6pt vertical listRowInsets (down
from default ~12pt), explicit `.frame(minHeight: 44)` to preserve
the Apple HIG tap target, and `.contentShape(Rectangle())` so
rows can shrink below 44pt visually while keeping the full-row
hit area. ~48pt rows end up net, vs. ~60pt default.
- `.scarfGoListDensity()` — `.listRowSpacing(0)` kills inter-row
gaps on the whole List, `.defaultMinListRowHeight(36)` sets the
floor for rows that want to go smaller (e.g. `LabeledContent`).
Applied to Memory, Cron, Skills, Dashboard, MoreTab. No visual
change to Chat (it's not a List — different density patterns for
M8 items 2.4–2.7). Research-backed: Fantastical / GitHub Mobile /
Mona for Mastodon use similar spacing.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Pass-1 loudest UX complaint — "I don't see any navigation" — was
rooted in the Dashboard-as-hub pattern. Chat/Memory/Cron/Skills/
Settings lived as a NavigationLink section halfway down a scrolling
List, below the stats + recent sessions. Users had to scroll to
find any feature. That was the right shape for a very-early MVP
but the wrong shape for a companion app whose primary tab should
be Chat.
New `ScarfGoTabRoot` renders a 4-tab TabView at the scene root:
- **Chat** — primary tab. Tapping the app opens straight into it.
- **Dashboard** — stats + recent sessions (stripped of Surfaces /
Connected-to / Disconnect, which now live in More).
- **Memory** — MEMORY.md + USER.md + SOUL.md, unchanged.
- **More** — bucket for Cron / Skills / Settings plus the
destructive Forget-this-server action. Also shows the host /
user / port info as a read-only section.
Uses iOS 18's `.tabViewStyle(.sidebarAdaptable)` so the same tree
degrades to a bottom tab bar on iPhone and renders as a native
sidebar on iPadOS / macCatalyst if we add those targets later — no
UI code change required. Matches the M8 density research's sidebar
recommendation.
Each tab owns its own NavigationStack so push navigation (Cron
editor, Memory detail, chat session list) stays scoped to that tab
and doesn't bleed across.
DashboardView is now simpler: just stats + recent sessions. The
Forget confirmation + Disconnect button moved wholesale to
MoreTab inside ScarfGoTabRoot.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
ScarfGo is a developer tool that benefits from tighter defaults
than Apple's spacious baseline, but shouldn't lock out users who
need accessibility sizes. `.dynamicTypeSize(.xSmall ... .accessibility2)`
at the WindowGroup gives both: compact-first layout, still scalable
to ~XL accessibility for low-vision users.
Going past .accessibility2 collapses multi-column rows and forces
text truncation in ScarfGo's dense list layouts — not a win for
anyone. Matches Use-Your-Loaf's "Restricting Dynamic Type Sizes"
guidance from the M8 density research.
One-line change ahead of the TabView migration (2.2) so every
subsequent UX-density decision factors in the clamped range.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Pass-1 demonstrated the bug end-to-end: user saved provider nous
+ model claude-haiku-4-5-20251001 (an Anthropic name Nous Portal
doesn't serve). Scarf accepted the save, wrote config.yaml, and
Hermes surfaced the failure six hours later as HTTP 404. Catch at
save time.
New ModelCatalogService.validateModel(_:for:) returns one of:
- .valid — model is in the provider's catalog, or the provider is
overlay-only (Nous Portal / OpenAI Codex / Qwen OAuth etc. — those
don't mirror to models.dev, so any non-empty string is
provisionally accepted; runtime errors still surface via the chat
error banner from M7 #2).
- .unknownProvider(providerID:) — no catalog entry at all; save
with an advisory. Usually means offline / missing local cache.
- .invalid(providerName:suggestions:) — block the save, offer up to
5 close-by models as "did you mean…". Prefix-match on first 3
chars; falls through to newest-5 when no prefix hits.
Mac ModelPickerSheet.submitSelection now routes through the
validator before onSelect. On .invalid it raises a .alert(item:)
with the suggestion list; user picks "Pick from catalog" (drops
out of custom mode) or "Edit" (keep the typed value to fix).
5 unit tests cover the happy path, unknown-provider branch, overlay-
only bypass, invalid-with-suggestions (using the exact pass-1 pair),
and empty input.
ScarfGo's scoped-settings editor (Phase 4.3) will reuse the same
validator when it lands.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Pass-1 rightly called out that rendering "0 */6 * * *" and ISO 8601
timestamps directly to users is user-hostile — cron syntax is a
devops lingua franca, not a user-facing idiom, and the iOS list
is where the problem is most visible.
New `CronScheduleFormatter` in ScarfCore pattern-matches common
cron shapes into English phrases:
- Named macros (@hourly, @daily, @weekly, @monthly, @yearly).
- Every N minutes (`*/5 * * * *` → "Every 5 minutes").
- Every hour on minute M (`30 * * * *` → "Every hour at :30").
- Every N hours at M (`0 */6 * * *` → "Every 6 hours").
- Daily at H:MM (`0 9 * * *` → "Daily at 9 AM").
- Weekdays / weekends / single-weekday at H:MM.
- Monthly on day D at H:MM.
- User-set `display` label (non-cron string) wins — preserves any
descriptive name the user typed via `hermes cron set-display`.
- Anything unrecognised falls back to the raw expression so no
info is ever hidden. 17-test pattern table covers every branch.
Sibling `formatNextRun(iso:)` parses Hermes's ISO-8601 `next_run_at`
field (handling both with-fractional-seconds and without) and
renders `"in 4 hours"` / `"tomorrow at 9 AM"` via Foundation's
`.relative(presentation: .numeric)`. Falls back to the raw string
if parsing fails so we never blank out useful info.
Applied to:
- ScarfGo `CronListView.CronRow` — human schedule + relative next-run.
- Mac `CronView` — row subtitle + detail-panel "Schedule" label +
"Next run" / "Last run" Labels.
Both schemes build green. 17/17 new formatter tests pass.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Pass-1 complaints:
- Typing near the bottom of MEMORY.md → keyboard covered the cursor,
user lost track of where they were editing (M7 #9).
- Tapping Save → "Saved" pill was never visible because it sat at
.bottom with a fixed 16pt padding, behind the still-raised keyboard
(M7 #10).
Fixes:
- `.scrollDismissesKeyboard(.interactively)` on the TextEditor so
scrolling the editor drags the keyboard down smoothly.
- Move the error banner + Saved pill into `.safeAreaInset(edge: .bottom)`
so SwiftUI draws them above whatever is presenting the keyboard.
The pill is now a full-width material strip (easier to hit/notice)
instead of a floating capsule.
- Saved pill holds for 2.5s (up from 1.5s — the old timer was too
tight to read mid-thought).
- Any in-flight hide task is cancelled when a new save lands, so
rapid-fire saves don't produce stacked fade timers.
No Mac equivalent needed — Mac memory editor is a separate
MemoryView with different layout and a non-mobile keyboard concern.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Pass-1 found that SFTP failures (initially the tilde-expansion bug,
but the same pattern applies to any transport error) silently
returned nil from `ServerContext.readText`, which the Memory editor
interpreted as "empty file." The user stared at a blank TextEditor
with no clue the connection had failed.
Two-part fix:
1. Add `readTextThrowing(_:)` on ServerContext that separates three
outcomes:
- `.some(content)` — file read succeeded.
- `.none` — file is genuinely absent (fileExists probe returned
false).
- throws — transport error (SSH down, SFTP timeout, auth failure,
non-UTF-8 data).
The existing nil-returning `readText(_:)` stays around for callers
that genuinely can't distinguish ("probably there, probably not")
— now implemented as a `try?` on the throwing variant so behavior
doesn't drift.
2. IOSMemoryViewModel.load uses the throwing variant. `.success(nil)`
is still treated as "first-time empty" (no lastError). `.failure`
populates `lastError` with a human message citing the underlying
transport error's localizedDescription so the Memory editor can
render it inline (it already had the error-banner view; just
needed the VM to actually set the string).
Also fixes a pre-existing stale test reference in M0dViewModelsTests
(`vm.entries` → `vm.toolMessages`) — ActivityViewModel's property
name drifted during the earlier rebase; the test was left broken.
Unrelated cron-delete test failure noted for separate follow-up.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Pass-1 hit HTTP 404 from Nous Portal (misconfigured model), the
agent reported it via ACP stderr + stopReason="error", and ScarfGo
showed nothing — users saw only the perpetual working spinner. Mac
had an errorBanner for this pattern; ScarfGo didn't.
Promotes the error-banner state and helpers from Mac's ChatViewModel
(Mac target) into RichChatViewModel (ScarfCore) so both apps share:
- `acpError`, `acpErrorHint`, `acpErrorDetails` — the banner triplet.
- `clearACPErrorState()` — called on reset() and addUserMessage()
so stale errors don't linger across prompts.
- `recordACPFailure(_:client:)` — populate triplet from a thrown
error + stderr tail, using the existing `ACPErrorHint.classify`.
- `recordPromptStopFailure(stopReason:client:)` — populate triplet
from a non-retryable ACP `promptComplete` stopReason. Provides a
fallback hint per stopReason when classify doesn't match.
- `acpStderrProvider: () async -> String` — closure the controller
sets once so `handlePromptComplete` (called from the event stream)
can pull recent stderr without the VM holding a direct ACPClient
reference.
Mac ChatViewModel's local triplet becomes forwarding properties to
richChatViewModel.* — call sites (~15 in ChatViewModel) stay
unchanged. `recordACPFailure` + `clearACPErrorState` become one-line
forwarders.
ScarfGo ChatView gains an `errorBanner` modeled on the Mac one:
- Orange triangle + hint + raw error
- Expand/collapse "Details" button showing stderr tail (monospaced,
scrollable, max ~140pt tall)
- Copy-all button via `UIPasteboard.general.string` (Mac uses
NSPasteboard; same structure otherwise)
- Rendered above the message list so it's always visible
ChatController wires `acpStderrProvider` to
`{ await client?.recentStderr ?? "" }` before the handshake and
calls `recordACPFailure` on ACP client start / newSession /
sendPrompt failure paths. `handlePromptComplete` already handles
the common provider-404 case via `recordPromptStopFailureUsingProvider`.
Both schemes build green.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Pass-1 showed the "Agent is working…" spinner persisting long after
the reply had landed in the message list — Hermes delays the ACP
`promptComplete` event while it does auxiliary post-work (title
generation, usage accounting). Spinner stuck ~minute+ on a 2-second
response.
Fix without touching the ACP state machine: derive two computed
properties from existing signals in RichChatViewModel:
- `isGenerating`: agent is working AND we don't yet have a finalized
assistant reply on the message list. Drives the prominent spinner.
- `isPostProcessing`: agent is working AND the user CAN see the
reply. Drives a subtle "Finishing up…" pill instead of the big
spinner. When `promptComplete` finally arrives, `isAgentWorking`
flips false and both derived props go quiet.
`isAgentWorking` remains the canonical ACP-level flag (kept public
for any consumer that really wants the raw value), just no longer
the signal for visible "spinner now" UX.
Applied to:
- ScarfGo ChatView.swift — primary spinner + post-processing pill.
- Mac RichChatView.swift — SessionInfoBar + RichChatMessageList now
take `isGenerating` instead of `isAgentWorking`. Same UX win for
the macOS app (pass-1 finding was cross-platform, just surfaced
first on iOS).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
ChatController already transitioned through a `.connecting` state
between tap-Chat and first-message-ready (ACP initialize + session/new
take ~0.5–1.5 s on a warm network), but there was no visible UI
— the screen stayed on the idle layout with a disabled composer.
Users interpreted the silence as a frozen app (pass-1 M7 #3).
Adds a `.regularMaterial` overlay with a large ProgressView +
"Connecting to <nickname>…" text, rendered whenever
`controller.state == .connecting`. Disappears automatically when
state flips to `.ready` (normal path) or `.failed` (handoff to the
existing errorOverlay).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Pass-1 found that "Disconnect" was actually a factory reset —
wiping both Keychain SSH key and UserDefaults config, forcing
full re-onboarding (including re-generating a key and appending
it to authorized_keys on the remote).
Interim fix ahead of M9 multi-server work:
- Relabel button "Forget this server".
- Keep destructive role.
- Gate tap on a confirmationDialog so users see exactly what gets
wiped and can back out.
- Add a footer explaining the authorized_keys consequence so the
user isn't surprised by a failed reconnect later.
Behaviour is unchanged (still wipes both stores). M9 introduces
the proper split: soft Disconnect (closes live transport, keeps
credentials) vs. hard Forget (this behaviour).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Four fixes surfaced during the 2026-04-24 pass-1 smoke test of the
iOS companion against a local Hermes host. All discovered while
collaboratively driving the Simulator + tailing os.Logger.
1. ACPClient+iOS.swift — ACP exec command prepends common install
paths to PATH. SSH RFC 4254 exec uses a non-interactive shell
whose PATH is sshd's default (`/usr/bin:/bin:/usr/sbin:/sbin`);
`.zshrc` doesn't source, so `~/.local/bin/hermes` (pipx default)
was invisible and the agent died with "command not found: hermes".
Mirrors HermesPathSet.hermesBinaryCandidates (the Mac-side local
probe list) inline in the exec command.
2. CitadelServerTransport.swift — SFTP tilde expansion. Every
Memory/Cron/Skills/Settings read used paths like
`~/.hermes/memories/MEMORY.md`. SFTP treats `~` as a literal
character, not a home-dir alias — so every read silently returned
nil and the UIs showed "empty file" instead of the real content.
Added a per-connection cached `resolveHome()` + a `resolveSFTPPath`
helper applied to every SFTP entry point (readFile / writeFile /
fileExists / stat / listDirectory / createDirectory / removeFile).
This was the single biggest blocker on pass-1.
3. IOSMemoryViewModel.swift + MemoryListView.swift — SOUL.md added
as a third Memory row. SOUL.md lives in the Personalities feature
on Mac; folding it into Memory on iOS matches the on-the-go scope
(all agent prompt inputs in one place). Uses the existing
`HermesPathSet.soulMD` path; no new plumbing.
4. project.pbxproj — bundle id rename for ScarfGo branding:
- CFBundleDisplayName: "Scarf Mobile" -> "ScarfGo"
- PRODUCT_BUNDLE_IDENTIFIER: com.scarf-mobile.app -> com.scarfgo.app
Xcode target name stays "scarf mobile" internally (rename surgery
isn't worth the PBX churn). Home-screen label + bundle id now
match the product name.
Both schemes build green. Phase 1 starter commit — per-item M7
fixes follow in subsequent commits.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Brings the iOS companion branch current with main's v2.2.0, v2.2.1,
and v2.3.0 landings — templates + configuration + catalog (v2.2),
projects folder hierarchy + per-project Sessions sidecar + AGENTS.md
context block + Tool Gateway + Nous Portal OAuth + hermes dashboard
webview (v2.3), and credential-pool OAuth expiry + Nous agent-key
rotation (post-v2.3).
Resolutions:
- ScarfCore Models (HermesConfig, ProjectDashboard, HermesPathSet) —
forward-ported Tool Gateway's platformToolsets, project-registry v2
folder/archived fields, and sessionProjectMap path into the moved
ScarfCore copies. Deleted the old Mac-target paths.
- ScarfCore ModelCatalogService — merged main's overlay-only provider
support (Nous Portal + OpenAI Codex + Qwen OAuth + …) so iOS and
macOS pickers see the same provider list. Widened HermesProviderInfo
/ HermesProviderOverlay APIs to public.
- ScarfCore ProjectsViewModel — layered main's v2.3 registry verbs
(moveProject / renameProject / archive / unarchive / folders) onto
the M0d-extracted VM, keeping public surface for the Mac target.
- ScarfCore ConnectionStatusViewModel / RichChatViewModel — widened
`private(set)` to `public private(set)` so Mac views can read
status, lastSuccess, acp*Tokens, originSessionId, acpCommands,
quickCommands.
- ScarfCore HermesConfig+YAML — added platform_toolsets parsing to
the iOS YAML path so config.yaml round-trips the same as macOS.
- RichChatViewModel quick-commands — inlined the Mac-target's
QuickCommandsViewModel.loadQuickCommands into ScarfCore using the
existing HermesYAML parser, removing the cross-module dependency.
- HealthViewModel — took main's Tool Gateway + hermes-dashboard
webview sections wholesale; file stays macOS-only.
- ChatView auto-merge — confirmed resume-session fix (5ae8db2) is
present; made the PendingPermission.id extension public to satisfy
Identifiable conformance across module boundary.
- ProjectSessionsViewModel — moved back to the Mac target since it
depends on SessionAttributionService (also Mac-target). Defer the
iOS SFTP parity of attribution to M7.
- LocalTransport.runProcess + SSHTransport.runLocal — wrapped the
Process body in `#if !os(iOS)` with an explicit throw on iOS so
ScarfCore compiles under the iOS SDK. iOS uses
CitadelServerTransport (ScarfIOS) as the real implementation.
- CitadelServerTransport — updated `sftp.remove(atPath:)` to
`sftp.remove(at:)` for the current Citadel API shape.
Cross-module imports: added `import ScarfCore` to 25 Mac-target files
that consumed ScarfCore types (13 v2.3 additions + 12 post-merge
errors caught by MemberImportVisibility: Settings tabs, SidebarView,
MCPServerEditorView, TemplateExportSheet, tests).
Version lockstep: bumped `scarf mobile` target to
MARKETING_VERSION=2.3.0, CURRENT_PROJECT_VERSION=25 to match main.
Builds green for both schemes:
- swift build (ScarfCore standalone)
- xcodebuild scarf -destination platform=macOS
- xcodebuild 'scarf mobile' -destination generic/platform=iOS
Deferred to M7 (iOS SFTP parity):
- NousSubscriptionService auth.json reader
- ProjectAgentContextService AGENTS.md write-before-chat
- SessionAttributionService session_project_map.json read/watch
All currently Mac-target-gated; iOS still builds without them.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
auth.json entries now carry expires_at_ms / expires_at and (for
Nous) agent_key_obtained_at. Decode the new fields, add an
expiryBadge helper, and render a red "expired" / orange "expires
in Nd" pill when a credential is past or within 7 days of expiring.
Nous entries also get a muted "agent key · Nh ago" line so manual
rotations are visibly confirmed.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Hermes v0.10.x ships a local web dashboard launchable via `hermes
dashboard` on port 9119. Scarf now detects it via a 3s
`/api/status` probe and offers Launch / Stop / Open-in-Browser
controls on the Health tab. Local contexts only — the dashboard
binds 127.0.0.1 and remote tunneling is deferred.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
v2.3 now lands two themes together: Projects Grow Up (existing) and
Hermes v0.10.0 Tool Gateway support (new, just merged on the feature
branch). The release notes and the repo README's "What's New" section
are updated to reflect both.
Release notes:
- Headline intro rewritten to frame both themes as the v2.3 story.
- New "Tool Gateway — Nous Portal support" section between "Icon
tweak" and "Migrating from 2.2.x": picker overlay merge surfacing 6
previously-invisible providers, in-app device-code sign-in sheet,
per-task Nous routing in the Auxiliary tab, Health card, Credential
Pools dead-end fix + auth-type gating, Messaging Gateway rename.
- "Under the hood" gains the Tool Gateway services paragraph
(NousSubscriptionService, NousAuthFlow, NousSignInSheet,
CredentialPoolsOAuthGate) + the PYTHONUNBUFFERED=1 subprocess-env
fix note. Test count bumped from 93 → 120 (14 new tests in
ToolGatewayTests, NousAuthFlowParserTests, CredentialPoolsGatingTests).
- "Migrating from 2.2.x" gains a Hermes version paragraph spelling
out that v0.10.0 is required for the Tool Gateway features (rest
of 2.3 works on earlier Hermes, just without Nous in the picker
or subscription data in Health).
- "Documentation" section lists the new Hermes Version Compatibility
+ Core Services wiki updates that accompany this release.
README:
- v2.3 "What's New" bullet list gains a Tool Gateway bullet
positioned between the chat-indicator bullet and the window-layout
bullet.
- Trailing "See the full release notes" line expanded to reference
the Hermes Version Compatibility wiki page so users on Hermes v0.9
know why they don't see Nous in their picker.
Companion wiki update already pushed in 741b253 on the wiki repo
(Hermes-Version-Compatibility, Core-Services, Home).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The sign-in sheet was stuck on the "Contacting Nous Portal…" spinner
even though hermes was running correctly. Root cause: Python
block-buffers stdout when it's a pipe instead of a TTY, and
`hermes auth add nous` enters a 15-minute polling loop after printing
the device-code block without ever calling `input()` — so nothing
flushes the buffer. Our readability handler never receives the URL +
user_code lines.
PKCE doesn't hit this because hermes calls `input("Authorization
code: ")`, which flushes stdout before blocking. Device-code has no
equivalent trigger.
Setting PYTHONUNBUFFERED=1 in the subprocess environment forces
line-buffered stdout for the duration of the flow — the device-code
block surfaces immediately, our regex extracts the URL and code, and
the sheet transitions into the waitingForApproval state as intended.
Local-only fix; remote SSH contexts get the remote's login env
untouched (the user's remote shell config owns buffering behavior
there).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The Tool Gateway feature shipped the Nous Portal provider in Scarf's
picker, a subscription-state detector, and a per-task aux toggle — but
there was no way to actually sign in. `hermes auth` in a terminal took
six steps, and Credential Pools' "Start OAuth" button silently stalled
for `nous` because it tried to run the PKCE flow against a device-code
provider.
Changes:
- NousAuthFlow: new @Observable MainActor service that spawns
`hermes auth add nous --no-browser`, parses the device-code block
(verification_uri_complete + user_code) with two line-anchored
regexes, opens the verification URL via NSWorkspace.shared.open,
and confirms success by re-reading auth.json via
NousSubscriptionService. Detects the `subscription_required`
failure and extracts the billing URL so the UI can offer a
Subscribe link.
- NousSignInSheet: four-state sheet (starting / waitingForApproval /
success / failure). Shows the user code in a large monospaced
badge with Copy + re-open-browser affordances, auto-dismisses
1.2s after success, Subscribe + Try again + Copy error buttons
on failure.
- Wired three entry points (per user-approved plan):
1. ModelPickerSheet's Nous Portal subscription summary — replaces
the stale "Run hermes auth" caption with a primary
"Sign in to Nous Portal" button.
2. AuxiliaryTab's per-task Nous toggle — inline "Sign in first"
button when not subscribed, instead of a dead-end caption.
3. Credential Pools "Add Credential" sheet — when provider is
`nous`, replaces the broken Start OAuth button with
"Sign in to Nous Portal".
- CredentialPoolsOAuthGate: testable helper that routes provider IDs
to the right OAuth flow based on the overlay table. Closes the
silent-fail dead-end for openai-codex, qwen-oauth,
google-gemini-cli, and copilot-acp too — disables the generic
button with an inline "run hermes auth add <provider> in a
terminal" hint. PKCE providers (anthropic, etc.) and unknown
providers still pass through as `.ok` — this gate is strictly
additive.
Tests: 14 new tests across two suites (NousAuthFlowParserTests,
CredentialPoolsGatingTests). Full suite 120/120 green on top of
v2.3.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Hermes v0.10.0 (v2026.4.16) introduces the Tool Gateway — paid Nous
Portal subscribers route web search, image generation, TTS, and browser
automation through their subscription without separate API keys.
- ModelCatalogService merges HERMES_OVERLAYS on top of the models.dev
cache, surfacing 6 overlay-only providers (Nous Portal, OpenAI Codex,
Qwen OAuth, Google Gemini CLI, GitHub Copilot ACP, Arcee) that were
previously invisible in Scarf's picker. Subscription-gated providers
sort first.
- NousSubscriptionService reads ~/.hermes/auth.json -> providers.nous
to detect subscription state. Read-only; Hermes owns the write path.
- ModelPickerSheet renders a "Subscription" pill, auth-type-aware
instructions, and free-form model-ID entry for overlay providers
(no models.dev catalog for them).
- AuxiliaryTab gains a per-task "Nous Portal" toggle that flips
auxiliary.<task>.provider between "nous" and "auto". Hermes derives
gateway routing from provider selection; there's no separate
use_gateway key in the source.
- HermesConfig + HermesFileService parse platform_toolsets.
- HealthViewModel adds a synthetic "Tool Gateway" section showing
subscription state, platform_toolsets, and which aux tasks are
routed through Nous.
- Gateway -> Messaging Gateway rename (sidebar, dashboard card, menu
bar, log-source filter, Settings/Agent/Gateway section header) to
disambiguate from the new Tool Gateway.
- CLAUDE.md bumped to Hermes v0.10.0 (v2026.4.16) with a
keep-overlayOnlyProviders-in-sync reminder.
- 13 new tests covering overlay merge, subscription detection, and
platform_toolsets parsing; full suite (106 tests, 19 suites) green
on top of v2.3 projects branch.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Brings in 17 commits delivering the full v2.3 scope:
- Projects sidebar hierarchy: folders, rename, archive/unarchive,
fuzzy search (⌘F), ⌘1–⌘9 keyboard jumps. Registry schema v2
(optional folder + archived fields); backward-compatible with
v2.2.1 readers.
- Per-project Sessions tab alongside Dashboard / Site. "New Chat"
spawns hermes acp with the project's directory as cwd and
attributes the resulting session via a Scarf-owned sidecar at
~/.hermes/scarf/session_project_map.json (Hermes's state.db has
no cwd column, so Scarf owns the mapping).
- Agent context injection: ProjectAgentContextService writes a
Scarf-managed block into <project>/AGENTS.md between
<!-- scarf-project:begin/end --> markers. Hermes auto-reads
AGENTS.md at session boot, so the agent now actually knows the
project name, dashboard path, template id, configuration field
NAMES (secret-safe — never values), registered cron jobs, and
uninstall-manifest presence. Template-author content outside
the markers is preserved byte-identical across refreshes.
- Chat indicator: folder chip in SessionInfoBar + "Chat ·
<ProjectName>" nav title when scoped. Resumed project-
attributed sessions automatically re-surface the indicator via
the attribution lookup at resume time.
- Window-layout cleanup: .windowResizability(.contentMinSize) +
idealHeight caps on Chat/Sessions subtrees so the window stops
growing past the screen when switching to content-heavy
sections. Pre-existing issue surfaced by the new per-project
surfaces.
22 new Swift tests across ProjectRegistryMigrationTests (7),
ProjectsViewModelTests (7), SessionAttributionServiceTests (7),
and ProjectAgentContextServiceTests (13) — total suite size is
now 93/93.
Release notes at releases/v2.3.0/RELEASE_NOTES.md (9.4 KB). README
"What's New in 2.3" block prepended; prior v2.2 block demoted to
"Previously, in 2.2."
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Prep commit for the v2.3 release. Covers the 16 feature + fix
commits landed on the v2.3-projects branch:
- releases/v2.3.0/RELEASE_NOTES.md — new file. release.sh picks
this up automatically as the GitHub release body at tag time.
Sections: sidebar grows up (folders/rename/archive/search/
keyboard jumps), per-project Sessions tab + sidecar, the
AGENTS.md marker-block injection (with the invariants —
secret-safe, idempotent, bounded, non-fatal, bare-project
friendly — called out explicitly), chat-UI project awareness
(folder chip + nav title), window-layout cleanup, under-the-
hood (new services, 22 new tests), migration, thanks.
- README.md — "What's New in 2.3" block at the top; demotes
the prior 2.2 block to "Previously, in 2.2" (condensed to the
four most user-facing points since the full 2.2 notes live at
the release link).
- Localizable.xcstrings — Xcode auto-regen from the new string
literals introduced across the v2.3 feature commits (folder
chip tooltip, Sessions tab header, etc.). Riding along.
93/93 Swift tests still pass. No code change here — pure docs.
Wiki Home + Release-Notes-Index updates land as a separate
wiki commit after the release is cut (standard post-release
chore per CLAUDE.md).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Clicking a session in the Projects Sessions tab routed to the
Chat section (correct — we want interactive resume, not the
read-only Sessions browser), but the session didn't actually
load and the project chip didn't appear. Root cause: ChatView
only observed `coordinator.pendingProjectChat` (for new chats),
not `selectedSessionId` (for resumes). Setting the id had no
effect because no consumer existed on the Chat side.
Every other session-click site in Scarf routes to `.sessions`,
and SessionsView consumes selectedSessionId at its `.task` +
clears it. Projects is the exception — the whole point of the
per-project Sessions tab is to resume chats interactively rather
than browse them, so we route to `.chat`. That routing was right;
the Chat side just needed to grow the symmetrical consumer.
This commit adds two handoff paths in ChatView (mirrors the
existing `pendingProjectChat` pattern):
- `.task` picks up a selectedSessionId that was set before
ChatView mounted (cold-launch handoff from Projects).
- `.onChange(of: coord.selectedSessionId)` picks up mid-session
navigation (user clicks a session while already in Chat).
Both call `viewModel.resumeSession(id)` then clear the coordinator
field. The project chip rendering + navTitle update then happen
automatically inside ChatViewModel.resumeSession ->
startACPSession, which already looks up attribution via
SessionAttributionService.projectPath(for: resolvedSessionId) —
that plumbing was in from Part B. The bug was entirely in the
trigger, not the side-effect.
`else if` between pendingProjectChat and selectedSessionId makes
precedence explicit — new-chat wins over resume if both are
somehow set. In practice only one is ever populated per
navigation, but the explicit ordering avoids surprise.
No race with SessionsView's own consumer: `coordinator.selectedSection`
ensures only one view is rendering at a time, and both consumers
clear the field on consume.
93/93 Swift tests still pass. No test change — this is a view-
wiring integration fix.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Sessions tab was showing "This project has N attributed sessions,
but none are in the recent history. They may have been deleted
from Hermes." on projects with valid sidecar entries and actual
sessions present in state.db. Root cause: the VM never opened
the DB handle.
`HermesDataService` is an actor with a lazily-initialised SQLite
pointer. Every query method short-circuits to `[]` when
`db == nil`. Callers have to open/refresh the handle explicitly
— InsightsViewModel does it (line 106), ActivityViewModel does
it (line 60). ProjectSessionsViewModel was constructed fresh
per project, never inherited a shared service, and never called
refresh() itself, so fetchSessions returned empty on every load
and the filter against the (correctly-populated) sidecar map
produced zero matches. The empty-state message ("may have been
deleted") fired on that false-negative.
The data was fine all along: sqlite3 ~/.hermes/state.db confirmed
both attributed sessions with source='acp', parent_session_id
IS NULL — they pass fetchSessions's WHERE clause cleanly. The
sidecar mappings were correct. The file watcher was firing. The
only missing piece was the DB-open precondition.
Fix: `_ = await dataService.refresh()` before fetchSessions,
mirroring the pattern used by every other feature VM that
consumes HermesDataService. Also adds a `close()` on the VM + an
onDisappear handler on the view, so the handle doesn't dangle
once the tab isn't visible — same cleanup ActivityView has.
This is NOT forward-only. Existing sidecar entries that
currently show the misleading empty-state will surface
correctly as soon as users rebuild — no data migration, no
re-create-the-chat, no backfill. The bug was "couldn't read what
was already there," not "lost old data."
93/93 Swift tests still pass. No test change — the fix is an
integration-level call-ordering detail that isn't meaningfully
testable without mocking HermesDataService (overkill for a
two-line fix).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Three doc updates covering the AGENTS.md context-injection
pattern introduced in the previous commit.
CLAUDE.md — new "Project-scoped chat + Scarf-managed AGENTS.md
context (v2.3)" subsection under Project Templates. Covers:
- The session-project sidecar at ~/.hermes/scarf/session_project_map.json
(why it exists, what manages it)
- How Hermes picks up project context: cwd-based auto-load of the
first matching context file (priority order, 20KB cap)
- Exact marker format and block shape
- Invariants that future edits must preserve: secret-safe,
idempotent, bounded-region, non-fatal, refresh-before-session-start
ordering
- Template-author contract: leave the region alone, put
instructions below
- Known caveat: parent-directory `.hermes.md` shadowing (deferred
to v2.4)
scarf-template-author SKILL.md — new pitfall bullet in the
"Common pitfalls" checklist telling scaffolding agents to
preserve the `<!-- scarf-project -->` region and put template-
specific instructions below it. Rebuilt the bundle so installs
from the catalog pick up the guidance; regenerated catalog.json.
Wiki update (Project-Templates page) lands next via scripts/wiki.sh.
93/93 Swift + 24/24 Python tests pass.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Hermes has no native "project" concept and the ACP wire protocol
drops extra params at `session/new`. But Hermes DOES auto-read
AGENTS.md from the session's cwd at startup (research confirmed:
priority order `.hermes.md` → `HERMES.md` → AGENTS.md → CLAUDE.md
→ .cursorrules; 20KB cap; first match wins). So the agent-
awareness path is file-based, not protocol-based.
This commit adds `ProjectAgentContextService` — a one-job service
that writes a Scarf-managed block into `<project>/AGENTS.md`
between `<!-- scarf-project:begin -->` and `<!-- scarf-project:end -->`
markers. Same pattern as the v2.2 memory-block appendix: bounded,
self-declaring, re-generable, safe on hand-authored content
outside the markers.
## Block contents
- Project name (from registry)
- Project directory path
- Dashboard.json path
- Template id + version (when template-installed)
- Configuration field NAMES with type hints — never VALUES.
Secrets always render as `field_key (secret — name only, value
stored in Keychain)`. Config.json values never appear in the
block, so the injected context is safe to drop into any agent
regardless of what's in Keychain.
- Registered cron jobs attributed to this project (matched via
the `[tmpl:<id>] …` prefix convention)
- Uninstall manifest reference (when `.scarf/template.lock.json`
exists)
- A note to the agent: cwd is the project dir, respect template
content below the block.
## Integration point
`ChatViewModel.startACPSession(resume:projectPath:)` refreshes
the block BEFORE `client.start()` — Hermes reads AGENTS.md
during session boot, so it has to land on disk first. `try?`
with a warning log: a failed refresh doesn't block the chat,
the session just starts without the extra context.
## Idempotency + safety
- Two consecutive refreshes produce byte-identical output
- Hand-edits outside the markers survive every refresh
- Empty project dir → AGENTS.md created with just the block
- Existing AGENTS.md without markers → block prepended; rest
preserved below
- Orphaned begin-marker (no end) → treated as "no block
present," new block prepended, orphan left in place (likely
hand-typed, not a Scarf corruption)
## Tests
13 new tests in ProjectAgentContextServiceTests:
- applyBlock pure-text transform: prepend / replace / idempotency
/ empty input / orphaned-marker fallback
- renderBlock content: identity fields, template presence, config
field names (and CRITICALLY: no values leak for secret fields)
- refresh end-to-end on isolated temp dirs: file creation, user
content preservation, idempotency across runs, stale-block
rewrite
93/93 Swift tests pass (was 80; +13 new).
## Deferred
TERMINAL_CWD env-var plumbing in ACPClient was scoped in the plan
but skipped — ACPClient.start() doesn't know the cwd at launch
(it's per-session), and plumbing it would restructure the actor's
lifecycle. Hermes already receives the cwd via ACP's `session/new`
params and uses it for context-file discovery there, so
TERMINAL_CWD is belt-and-suspenders we can add later without
breaking anything.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds a visible cue telling the user when their chat is scoped to
a Scarf project. Two surfaces:
- **SessionInfoBar** gets a folder-fill icon + project name chip at
the start of the bar (before the working dot + title). Rendered
with `.tint` foregroundStyle so it's visually anchored as the
first piece of context. Hidden for non-project chats — the bar
looks identical to v2.2.1 when projectName is nil.
- **Navigation title** becomes `Chat · <ProjectName>` when scoped,
stays as plain `Chat` otherwise. Matches macOS conventions for
"subject — detail" titles.
ChatViewModel gains two `@Observable` properties:
- `currentProjectPath: String?` — absolute path, source of truth
for attribution lookups
- `currentProjectName: String?` — resolved via the projects
registry at session-start; stored to avoid disk reads on every
render. Falls back to the raw path (rather than nil) when a
session's attribution points at a project no longer in the
registry — the user still sees *something* rather than silently
losing the indicator.
Both are populated in `startACPSession(resume:projectPath:)` from
two sources:
1. If the caller passed `projectPath` — fresh project-chat case
2. Otherwise, SessionAttributionService.projectPath(for:
resolvedSessionId) — resumed-session case. Means clicking an
old project-attributed session from ANY surface (the project's
Sessions tab, the global Resume menu) re-surfaces the
indicator.
When the user starts a non-project session, both fields reset to
nil explicitly so the indicator doesn't leak between chats.
Files:
- ChatViewModel.swift — new properties + resolve logic
- SessionInfoBar.swift — new `projectName: String?` parameter +
chip rendering
- RichChatView.swift — passes chatViewModel.currentProjectName
through to SessionInfoBar
- ChatView.swift — navTitle reflects the active project
80/80 Swift tests still pass. Visual change only; no test change.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
ProjectSessionsView's `.onChange(of: fileWatcher.lastChangeDate)`
was silently never firing when a new chat attributed a session to
a project — the sidecar was written correctly, the session was in
state.db correctly, attribution IDs matched exactly, but the per-
project Sessions list didn't auto-refresh.
Root cause: HermesFileWatcher.watchedCorePaths was missing
`paths.sessionProjectMap` (`~/.hermes/scarf/session_project_map.json`,
introduced in the v2.3 feature commit). Since the watcher didn't
observe that file, writes from SessionAttributionService.persist
produced no `lastChangeDate` change, the VM's onChange never ran,
and the Sessions tab stayed empty until the user navigated away
and back (triggering .task(id: project.id) to re-fire).
One-line fix: add the sidecar to the watched-paths array.
Now the flow works end-to-end:
1. User clicks "New Chat" on a project
2. ChatViewModel starts ACP session with cwd=project.path
3. SessionAttributionService.attribute writes the sidecar
4. HermesFileWatcher detects the change, bumps lastChangeDate
5. ProjectSessionsView's onChange fires, VM reloads, new session
appears in the list immediately
80/80 tests still pass. No test change needed — the sidecar's
direct tests are in SessionAttributionServiceTests; this is a
file-watching integration fix.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Prior commits tried to solve the "window grows whenever Chat or
Sessions is selected" bug by wrapping NavigationSplitView's detail
slot with an explicit frame (`205bb2c`). That broke the HSplitView
layout in Projects — the project list column, dashboard header,
tab bar, and Sessions-tab header all vanished. Scarf's convention
(PlatformsView.swift:12 calls it out explicitly) is to apply
size constraints on individual HSplitView columns, never on an
outer wrapper.
This commit:
- Reverts the broken ContentView.swift outer frame from `205bb2c`.
NavigationSplitView.detail goes back to its v2.2.1 shape.
- Caps the subtrees whose natural ideal heights are what was
actually pushing the window past the screen:
- RichChatView: `.frame(minHeight: 0, idealHeight: 500, maxHeight: .infinity)`
on the outer VStack. The message list uses a plain VStack
(deliberately, to dodge the LazyVStack whitespace bug — see
RichChatMessageList.swift:13-24), so its natural ideal grows
with every message. Capping idealHeight at 500 gives the
window a screen-safe starting size without limiting how tall
the view can flex when the user drags the window bigger.
- ProjectSessionsView: same treatment with `idealHeight: 400`.
Replaces the earlier `.frame(maxWidth: .infinity, maxHeight:
.infinity)` which set MAX but didn't influence what got
reported upward as ideal.
- Xcode regenerated Localizable.xcstrings during builds; riding
along.
`.frame(idealHeight:)` is the specific SwiftUI knob that overrides
a child's reported ideal on the way up — `maxHeight: .infinity`
alone doesn't. With `.windowResizability(.contentMinSize)` (still
in scarfApp, left alone), the window sizes itself to the reported
ideal on open and respects user drags above the content min. With
a screen-safe ideal, the window opens at a usable size and never
pushes past the desktop.
User-verified: window behaves correctly across section switches,
resize persists, chat input bar always visible.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Prior fixes (4baa3d4, 9aad905, d968878) narrowed the root cause
but didn't fully close the loop. Both the Chat section and the
v2.3 per-project Sessions tab were still growing the window past
the screen — the chat input bar ended up below the visible
desktop edge, unreachable.
Why the previous fixes weren't enough:
- Adding `.frame(maxHeight: .infinity)` on ChatView /
ProjectSessionsView / dashboardArea told each view to FILL the
space they were offered, but didn't cap what they reported UP
the tree as their intrinsic ideal.
- `.windowResizability(.contentMinSize)` at the WindowGroup
level used the content's minimum size as the window's min
floor — and with VStack-based layouts (RichChatMessageList
materialises every message in a plain VStack to avoid
LazyVStack's whitespace bug), the minimum bubbles up as
~messages-total-height, which exceeds the screen on long
sessions.
This commit pins the NavigationSplitView.detail slot's reported
frame explicitly. The detail column now reports:
- minWidth/minHeight: 500×300 — big enough for toolbars + chat
input to always fit, small enough to work on any Mac screen
- idealWidth/idealHeight: 900×600 — reasonable first-launch size
that fits under `.contentMinSize`'s floor without pushing past
the screen
- maxWidth/maxHeight: infinity — user-resizable, no ceiling
With this bound intercepting the size-reporting chain,
NavigationSplitView's ideal becomes 500×300 ± idealWidth/Height
regardless of what ChatView or ProjectSessionsView's children
want internally. The window's content-derived minimum stays
bounded to a sensible value. Views still fill the offered space
because their `.frame(maxHeight: .infinity)` modifiers continue
to claim whatever the detail column hands them.
This is a window-layout-level fix that sits above the per-view
clamps in earlier commits — those stay in as defensive intra-
view layout, and the new frame here handles the outer coupling
to the window.
80/80 Swift tests still pass.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Root cause of the "window grows whenever I switch to Chat / the
v2.3 Sessions tab" bug. Prior commits (4baa3d4 sessions-tab
clamp, 9aad905 chat+projects detail-area clamp) were defensive
but not sufficient — with the actual window policy treating
content's ideal height as a BINDING (not a minimum), those
clamps only kept things inside the view, not inside the window.
scarfApp's WindowGroup had .defaultSize(width: 1100, height: 700)
but no explicit .windowResizability(...) modifier. On macOS, a
non-Settings WindowGroup defaults to .automatic, which evaluates
to .contentSize — meaning every layout pass rebinds the window to
the currently-displayed detail view's ideal height. Explains
every symptom:
- Switching to Chat / Sessions grows the window to content size
- User drag-to-resize snaps back on next layout
- Sections with ScrollView-bounded content (Dashboard, Insights)
"work" because their ideal height is their visible slot
- Resize while in a bounded section looks sticky because the
rebind target doesn't push back
- Coming back to Chat reasserts the bind and the window grows
again — sometimes past the screen
Switched to .windowResizability(.contentMinSize). Content's ideal
height is now a minimum FLOOR — user resize works freely, the
window persists across section switches, and it still can't
shrink below a section's minimum render (so tool bars, input
fields, etc. stay visible).
Pre-existing pre-v2.3 bug; v2.3's new content-heavy surfaces
(per-project Sessions list) just made it much more obvious. The
earlier clamp commits stay in — they're still correct for
intra-view layout, just not the window-level fix.
80/80 Swift tests still pass. No test change; behavior is
platform-layout-policy level.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Two sibling fixes to the one landed in 4baa3d4 (Sessions tab
height clamp). User reported that both the Chat section and the
per-project Sessions tab expanded the window height past the
screen once their content grew intrinsically.
Root cause is the same for both: the outer VStack at the top of
each view had no `.frame(maxHeight: .infinity)`. When
NavigationSplitView's detail slot renders one of these, SwiftUI
asks the child for its ideal height. Without a clamp, a tall
enough child (RichChatView's message list; a long attributed-
sessions list; a dashboard with a text widget containing a long
README block) bubbles its intrinsic size all the way up and
macOS grows the window to fit.
ChatView: add `.frame(maxWidth: .infinity, maxHeight: .infinity)`
to the outer VStack in `body`. Pre-existing issue that predated
v2.3 — it just happened to be masked by the chat area having
enough give until now. Surfaced as the user exercised the
section more during v2.3 testing.
ProjectsView: add the same modifier to the "dashboard is loaded"
VStack branch in `dashboardArea`. The ContentUnavailableView
branches (no dashboard / no projects / no selection) don't need
it — ContentUnavailableView self-clamps.
Both the widgetsTab (ScrollView) and the siteTab (explicit
maxHeight) were already fine. The sessions tab picked up its
fix in 4baa3d4. These two commits together cover every surface
that lives in the detail column.
80/80 Swift tests still pass. Visual-only fix; no test change.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The new Sessions tab's outer VStack had no maxHeight constraint.
Its inner `List(sessions) { … }` uses intrinsic content size — which
grows with the row count — and with enough sessions the enclosing
VStack would push the project window past the bottom of the screen.
Fixed by adding `.frame(maxWidth: .infinity, maxHeight: .infinity)`
to the outer VStack in `ProjectSessionsView.body`, matching the
pattern `siteTab` uses for its webview. Now the List fills the
available tab area and scrolls internally as expected.
Other v2.3 tabs already self-constrain (`widgetsTab` via ScrollView,
`siteTab` via explicit maxHeight). This brings Sessions in line.
80/80 Swift tests still pass. Visual-only fix; no test change.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Third and final v2.3 commit. Adds the Sessions tab alongside
Dashboard and Site, and introduces the attribution sidecar that
makes per-project session filtering possible without any upstream
Hermes change.
## Sidecar
Hermes's state.db has no cwd column on sessions — the cwd passed
to `hermes acp` at session create is ephemeral from its side.
Scarf now records session_id → project_path in
~/.hermes/scarf/session_project_map.json, owned end-to-end by
Scarf. Written atomically on session creation; read by the per-
project Sessions tab. Missing file = empty map; corrupt file =
empty map (logged warning, no crash). Forward-only attribution:
only sessions Scarf starts with a project context get mapped; CLI-
started sessions still surface in the global Sessions sidebar
unchanged.
New pieces:
- Core/Models/SessionProjectMap.swift — Codable sidecar shape
(mappings dict + updatedAt timestamp).
- Core/Services/SessionAttributionService.swift — load /
attribute / forget / reverse-lookup, all idempotent, all going
through atomic write.
- HermesPathSet.sessionProjectMap — canonical path resolution.
## Chat plumbing
ChatViewModel.startNewSession and the private startACPSession gain
an optional projectPath parameter. When non-nil it overrides the
default cwd = context.resolvedUserHome() and, on successful session
creation, SessionAttributionService.attribute is called.
Default-nil call sites keep v2.2 behavior exactly — terminal-mode
chats and the global "New Chat" button are unaffected.
## Coordinator handoff
AppCoordinator gains pendingProjectChat: String?. The per-project
Sessions tab sets it + switches selectedSection = .chat. ChatView
observes it (.task cold-launch + .onChange live), consumes the
path by calling startNewSession(projectPath:), and clears the
field. Clean separation: the Projects feature never reaches into
ChatViewModel directly.
## UI
- New DashboardTab.sessions case in ProjectsView. Tab bar now
always renders when a dashboard is loaded (was gated on
siteWidget before); .site still filters out when there's no
webview widget.
- ProjectSessionsView — per-project session list with a "New Chat"
button. Empty-state hint distinguishes "no attributions yet" from
"stale sidecar entries". Reuses HermesDataService.fetchSessions
and filters by the attribution map.
- ProjectSessionRow — local row view independent of the global
sessions sidebar so the two can evolve separately.
## Tests
SessionAttributionServiceTests (7 tests):
- Missing file → empty map
- attribute writes + persists via fresh service instance
- attribute is idempotent (same pair twice doesn't bump timestamp)
- re-attribute changes mapping (session moves between projects)
- reverse lookup returns all + distinguishes by project
- forget removes mapping, is idempotent on missing sessions
- Corrupted JSON → empty map, no crash
80/80 Swift tests pass (was 73; 7 new). 24/24 Python tests still
pass. Both prep + feature commits stand independently; commit 3
depends on commit 1 (folder/archive fields) and commit 2 (sidebar
UI) only for the full flow to work end-to-end.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Second of three v2.3 commits. Replaces the flat projects sidebar
with a hierarchical view that honors the folder + archived fields
introduced in commit 1.
ProjectsView's inline 70-line `projectList` becomes a one-call
invocation of a new extracted `ProjectsSidebar` view. The parent
keeps all sheet state (add / rename / move / uninstall / remove-
from-list confirmation); the sidebar routes user intent up via
closures. That separation means future sidebar changes (drag-
and-drop, tags, color labels from the roadmap) don't need to
touch ProjectsView's sheet wiring.
ProjectsSidebar.swift renders, top to bottom:
- Search field (filters by name / path / folder label, live)
- Top-level projects (folder is nil or empty, not archived)
- One DisclosureGroup per folder, alphabetically sorted, expanded
by default on first render; collapsed state persists per view
instance. Newly-created folders auto-expand so moves are
visibly reflected.
- An "Archived (N)" DisclosureGroup at the bottom, surfaced only
when the Show Archived toggle in the bottom bar is on. Archived
rows render at 0.7 opacity for a subtle visual cue.
Bottom bar gains a Show Archived toggle next to the existing +
button, using the archivebox SF Symbol (filled when on).
Context menu gets three new entries alongside the existing ones:
- Rename… — opens RenameProjectSheet with duplicate-name +
empty-name validation.
- Move to Folder… — opens MoveToFolderSheet with current folder
pre-selected; picker lists Top Level, existing folders, and a
"New folder…" option that gates on a text field.
- Archive / Unarchive — flips the archived bit via the VM.
Both new sheets live as standalone files (RenameProjectSheet,
MoveToFolderSheet) for reuse — the wiki doesn't need updating; these
are pure UI refinements.
Selection binding round-trips through `viewModel.selectedProject`
unchanged, so the existing dashboard / Site tab routing is
unaffected. Sidebar matches use localizedCaseInsensitiveCompare
so folder labels and project names sort the way users expect in
non-English locales.
73/73 Swift tests still pass (no new tests in this commit — the
VM verbs already exercised in ProjectsViewModelTests; the UI is
visual and will be validated by the manual smoke test at the end
of the branch).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
First of three v2.3 commits. Adds the data model + view-model plumbing
for folder grouping and soft-archive; no UI changes yet (sidebar still
renders a flat list).
ProjectEntry gains two optional fields:
- `folder: String?` — opaque single-level label for sidebar grouping;
nil means top-level. Custom Codable decodeIfPresent so v2.2 registry
files parse cleanly.
- `archived: Bool` — soft-delete flag; defaults to false via custom
decoder. Archived projects stay on disk and in the registry; the
v2.3 sidebar just hides them unless Show Archived is toggled on.
Custom encode(to:) omits both fields when they're at their default
values. Keeps registry files clean for the common (top-level,
unarchived) case and means v2.2 Scarf still loads a v2.3-written
registry of projects that never used the new features — forward +
backward compat by construction.
ProjectsViewModel grows four verbs:
- moveProject(_:toFolder:) — update the folder assignment
- renameProject(_:to:) — rename with duplicate-name + empty-name
rejection; preserves selection across the rename so the user
stays on the same project
- archiveProject(_:) — sets archived=true, clears selection if the
archived project was selected (avoids lingering on a hidden view)
- unarchiveProject(_:) — sets archived=false; does NOT re-select
(unhiding ≠ focusing)
- `folders: [String]` computed property — distinct folder labels,
sorted, for the sidebar + move-to-folder sheet
Two new test suites:
- ProjectRegistryMigrationTests: round-trips v2.2 → v2.3 and back,
asserts encoder cleanliness (defaults omitted), identity stability
under folder / archive changes.
- ProjectsViewModelTests: verbs hit the real ~/.hermes/scarf/projects.json
via TestRegistryLock for cross-suite serialization. Covers happy
paths, duplicate / empty-name rename rejection, and folder dedup.
73/73 Swift tests pass (was 58; 15 new). No behavior change on v2.2
registry files yet — the sidebar UI lands in commit 2.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Captures the backlog discussed during v2.3 planning so future
sessions can pick up items without re-deriving the terrain:
- v2.3 (planned, in this branch): folders + rename/archive/search
+ per-project Sessions tab via a sidecar attribution file.
- v2.4+: per-project activity feed, token rollup, cron filter,
desktop notifications — all "filter existing data via the
sidecar" work, unblocked once v2.3 ships.
- v2.5+: platform bets (Hermes upstream sessions.cwd column,
per-project memory slice, per-project skills namespace,
cross-project meta-dashboards, project backup/restore).
- Continuous polish: drag-and-drop, tags, favorites, recents,
color labels, starter dashboards, opportunistic backfill.
- Known research gaps to chase when relevant.
No code change; pure docs. Commits to the feature branch
because the v2.3 planning context originated there; lands on
main with the merge.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Exported from Apple Configurator / Icon Composer with the macOS
naming template instead of the iOS one (rose from having the wrong
template selected in the asset-set's original export). The actual
PNG contents match the sizes the macOS AppIcon expects at every
1x/2x density; Contents.json reorders to reference the new names.
No visual change for users — the Finder / Dock / about-box icon
render identically because the rendered pixels are unchanged at
each size. File replacement is purely naming / organizational.
Uploaded as a prep commit on the v2.3-projects feature branch
since the icon tweak was sitting in the working tree and
shipping it separately from the feature work would require an
extra release cycle for no benefit.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Covers the four commits landed since v2.2.0:
- New catalog template: awizemann/template-author (scaffolding skill)
- Config sheet fix: EnumControl always uses Menu picker, not Segmented
(the long-option-label overflow that clipped the form)
- Config sheet fix: maxWidth constraint on inner VStacks so descriptions
with unbreakable tokens wrap cleanly
- SKILL.md authoring guidance: prefer markdown link syntax over raw URLs
- Devops: scripts/catalog.sh accepts git worktrees
release.sh picks up this file as the GitHub release body.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The Configuration sheet's clipping bug persisted after the earlier
VStack maxWidth fix (d616935) and the user's Part-C manifest
rewrite to use [label](url) markdown. Re-diagnosed: the actual
overflow source was EnumControl's `.pickerStyle(.segmented)` branch,
active when options.count ≤ 4.
Segmented pickers on macOS size to the intrinsic width of all their
labels concatenated. They refuse offered width constraints, refuse
to wrap, refuse to truncate. A schema with three long labels like
"Claude Opus 4 (Recommended - Most Capable)" produced a ~650pt
segmented picker that pushed the fieldRow past the sheet's 560pt
viewport. No amount of .frame(maxWidth: .infinity) on parent
containers can rein in a segmented picker — the picker ignores
them.
Fix: remove the segmented branch. Always use the default Menu
picker (dropdown). Dropdowns respect offered width and surface long
labels in the popup list, so the sheet can't overflow regardless of
label length or option count.
Loses the segmented look for short-enum cases like a 3-option
"Daily / Weekly / Monthly" picker — compactness traded for
correctness. If a future template author wants segmented rendering
for a specific short-label enum, we can add a manifest hint
(e.g., "uiHint": "segmented") that explicitly opts in; not worth
the machinery until there's demand.
58/58 Swift tests still pass. No schema changes, no migration.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Pairs with the config-sheet wrap fix in d616935. Even though the
Configuration sheet now renders raw URLs correctly, markdown link
syntax reads cleaner in the form — the visible text is the label,
not the URL. Teaching this in SKILL.md prevents the scaffolding
skill from generating schemas that look worse than they could.
Additions to SKILL.md:
- New "Writing good descriptions" subsection under Config Schema
Design. Good/bad examples side by side; rule of thumb to wrap
long unbreakable strings (URLs, paths) in markdown links or
inline code.
- New item in the Common Pitfalls checklist: "No raw URLs in
field descriptions."
Bundle rebuilt, catalog.json regenerated. 24/24 Python tests
still pass; Python validator treats descriptions as opaque strings
so no validator changes needed.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The Configuration sheet rendered field labels chopped on the left
and description URLs spilling off the right whenever a schema
description contained a raw `https://…` URL. Root cause is layout:
SwiftUI's inline-markdown renderer turns the URL into an
unbreakable AttributedString link token, and without an explicit
maxWidth constraint on the sheet's inner VStack, width resolution
went bottom-up — the description's ideal width became the URL's
character length, the VStack matched it, the ScrollView's content
exceeded the sheet's `.frame(minWidth: 560)` viewport, the window
clipped the grown sheet, and the center-aligned result cut off
both sides.
Added `.frame(maxWidth: .infinity, alignment: .leading)` in two
places:
- TemplateConfigSheet's inner VStack inside the ScrollView +
the fieldRow VStack.
- TemplateInstallSheet's main-preview VStack inside its
ScrollView — same pattern, same failure mode for raw URLs in
cron prompts or README blocks (the disclosure-group inner
ScrollViews already had the modifier).
With the constraint, the description's
`.fixedSize(horizontal: false, vertical: true)` wraps at
whitespace boundaries as intended. The URL stays on its own line,
still clickable, still showing the full href. Long paths and
other unbreakable tokens render the same way.
Found while rendering a user-authored schema with two raw URLs
in descriptions. SKILL.md gets a paired update (separate commit)
teaching authors to prefer `[link text](https://…)` markdown
syntax so the visible description stays short even when the href
is long.
58/58 Swift tests pass.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
A new .scarftemplate in the public catalog whose only content is
a Hermes skill that teaches an agent how to scaffold a new
Scarf-compatible project — dashboard, optional configuration
schema, optional cron job, AGENTS.md — from a short conversational
interview. Scaffolded projects are usable locally and cleanly
exportable as .scarftemplate bundles later.
The skill itself (~400 lines of structured markdown at
skills/scarf-template-author/SKILL.md) covers:
- When to invoke vs. when to answer inline
- The on-disk project shape Scarf expects
- A 5-question interview flow
- Full widget catalog (all 7 widget types) with JSON shapes
- Config schema design + hard invariants (no defaults on secrets,
`contents.config` must match field count, etc.)
- Cron-job design including the {{PROJECT_DIR}} gotcha
- Step-by-step file writing (dashboard, manifest, AGENTS.md, README)
- Testing + catalog validation instructions
- Common pitfalls + source-of-truth references
Delivered as a .scarftemplate so the install flow's normal
safeguards apply: preview sheet shows one project + one skill
+ zero cron jobs + no config step, uninstall drops both the
project dir and the namespaced skill folder via the existing
lock-file mechanism.
Scope per user sign-off: blank-slate / fully conversational for
v1. Pre-baked archetypes (`monitor`, `dev-dashboard`, etc.) are
deferred to v1.1 pending real usage data on what shapes users
actually ask for.
New Swift test exercises the bundle through the installer's
plan builder — asserts manifest shape, that the skill lands at
~/.hermes/skills/templates/awizemann-template-author/scarf-template-author/SKILL.md,
and that no-config templates correctly skip the manifest cache.
58/58 Swift tests pass; 24/24 Python tests pass.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The v2.2.0 templates/config/catalog feature (introduced on main after
M0 branched) added 18 Mac-target files that reference types now living
in ScarfCore — ServerContext, ProjectEntry, ProjectDashboardService,
etc. After rebasing scarf-mobile-development onto main, those files
need `import ScarfCore` the same way the M0a/M0c/M0d extractions
added it to the ~100 pre-existing Mac files.
Unblocks Xcode compile of the scarf (Mac) target on this branch; no
behavior change.
https://claude.ai/code/session_019yMRP6mwZWfzVrPTqevx2y
Ports the Mac app's YAML parser into ScarfCore, unlocking iOS
Settings. Adds Cron editing (add / delete / toggle / edit). Settings
stays read-only this phase (writes need a round-trip-preserving YAML
writer — out of scope). App Store submission deferred to a later
task per the brief.
## ScarfCore — YAML infrastructure
Packages/ScarfCore/Sources/ScarfCore/Parsing/HermesYAML.swift:
- ParsedYAML struct (values / lists / maps)
- HermesYAML.parseNestedYAML(_:) — indent-based block parser
- HermesYAML.stripYAMLQuotes(_:) — single-layer quote stripping
Lifted verbatim from HermesFileService.parseNestedYAML/stripYAMLQuotes
and hoisted into a standalone namespace. Scope unchanged: the subset
Hermes's config.yaml actually uses (block nesting, scalars, bullet
lists, nested maps). NOT full YAML-spec compliance.
Packages/ScarfCore/Sources/ScarfCore/Parsing/HermesConfig+YAML.swift:
- HermesConfig.init(yaml:) — ports HermesFileService.parseConfig
one-for-one. Every default, every key, every legacy fallback
(platforms.slack.* vs slack.*, command_allowlist vs permanent_
allowlist, etc.) matches the Mac implementation.
- Forgiving: malformed YAML produces partial state + defaults
rather than throwing. Callers surface the raw text so users can
diagnose parse failures on their own.
## ScarfCore — Cron editing (write paths)
Packages/ScarfCore/Sources/ScarfCore/ViewModels/IOSCronViewModel.swift:
- toggleEnabled(id:)
- delete(id:)
- upsert(_:)
All funnel through private saveJobs(_:) which encodes the full
CronJobsFile (.prettyPrinted + .sortedKeys), writes atomically via
transport.writeFile (Data.write-atomic from M5). Creates the cron/
directory on fresh installs.
Models/HermesCronJob.swift — both HermesCronJob and CronJobsFile
gained real public memberwise inits (Swift's synthesis was
suppressed by the hand-written Codable; first draft hacked around
this with JSON round-trips). Also HermesCronJob.withEnabled(_:)
does clean field passthrough instead of encode→mutate→decode.
## ScarfCore — iOS Settings VM
Packages/ScarfCore/Sources/ScarfCore/ViewModels/IOSSettingsViewModel.swift:
- Reads ~/.hermes/config.yaml via ServerContext.readText
- Parses with HermesConfig(yaml:)
- Surfaces both parsed config and rawYAML
- M6 read-only by design — config.yaml needs round-trip-preserving
YAML serialization (comments, key order, whitespace) for safe
edits; option (a) hand-write one, (b) YAML library dep, (c)
delegate to `hermes config set` via ACP. Defer.
## iOS app
Scarf iOS/Settings/SettingsView.swift:
- Read-only browser grouped into 10 sections matching the Mac
app's tabs. DisclosureGroup at the bottom reveals raw YAML
source for diagnostics.
Scarf iOS/Cron/CronListView.swift rewritten:
- Toggle-enabled circle (tap to flip, saves atomically)
- Swipe-to-delete
- "+" toolbar for new job → editor sheet
- Row-tap opens editor with existing fields populated
New CronEditorView form:
- Name, Prompt, Enabled toggle
- Schedule: kind picker (cron/interval/once), display, expression
(for cron), run_at (for once)
- Optional model + comma-separated skills + delivery route
- Preserves runtime fields (nextRunAt, lastRunAt,
deliveryFailures, etc.) when editing existing jobs — no reset
Dashboard's Surfaces section gains a 5th row: Settings.
## Test-suite reorganization (real bug caught)
swift-testing's `.serialized` trait serializes WITHIN one @Suite, not
across suites. Shipping M6 revealed a 3-way race on
`ServerContext.sshTransportFactory`:
- M5's `.serialized` suite sets factory, runs, restores.
- M6's `.serialized` suite did the same in parallel — clobbered.
- M0b's non-serialized `serverContextMakeTransportDispatches`
asserted the DEFAULT factory (nil) returned SSHTransport —
saw whichever factory was temporarily installed.
Fix: one serialization domain for everything that touches the
factory. Move cron-editing + settings-load M6 tests into M5's
serialized suite. M0b's factory-dependent assertion (SSHTransport
fallback) also moves to the M5 serialized suite with an explicit
`factory = nil` reset for race-freedom. Pure YAML/config/memberwise
tests stay in the new plain (non-serialized) M6ConfigCronTests
suite — they never touch globals.
## Test results: 108 → 134 passing on Linux
19 new in M6ConfigCronTests:
- YAML parser: scalars, bullets, nested maps, comments, quotes,
inline {} / []
- HermesConfig.init(yaml:): empty → defaults, model + agent,
display, security + blocklist domains, slack legacy fallback,
auxiliary (3 populated + 2 defaulted), permanent_allowlist vs
command_allowlist, quoted strings
- Memberwise inits for HermesCronJob, withEnabled(_:),
CronJobsFile, CronSchedule
7 new in M5FeatureVMTests (.serialized):
- defaultFactoryProducesSSHTransportForRemoteContext (moved +
hardened with explicit factory reset)
- cronUpsertCreatesFileFromScratch, cronToggleEnabledPersists,
cronDeleteRemovesJob, cronUpsertReplacesMatchingId,
cronPreservesRuntimeFieldsAcrossReloads
- settingsLoadsFromConfigYAML, settingsSurfacesMissingFile
## Manual validation needed on Mac
1. Xcode compile clean.
2. Settings: confirm every section populates from your real
~/.hermes/config.yaml. Tap "View source" disclosure, verify raw
text matches the remote file.
3. Cron: toggle-enabled survives refresh + relaunch. Swipe-delete
works. "+" creates jobs; round-trip name/prompt/schedule/skills.
Edit preserves runtime state.
4. Skills: unchanged from M5 (still browse-only, deferred).
Updated scarf/docs/IOS_PORT_PLAN.md with M6's shipped state, the
YAML-parser scope ceiling, the Settings-edit deferral rationale, and
the cross-suite serialization rule for future test authors.
https://claude.ai/code/session_019yMRP6mwZWfzVrPTqevx2y
Fleshes out the iOS app from "Chat + placeholder Dashboard" into a
real on-the-go Hermes companion: Chat now renders tool calls + tool
results + permission sheets + markdown + chain-of-thought, and the
Dashboard gains three new feature surfaces.
## Chat polish
scarf/Scarf iOS/Chat/ChatView.swift — several new small SwiftUI
view types:
- ToolCallCard: expandable card for each HermesToolCall on an
assistant message. Tool-kind icon in the header (from
HermesToolCall.toolKind.icon), arguments summary collapsed,
full JSON on tap.
- ToolResultRow: compact "Tool output" disclosure for messages
with role == "tool", shown indented beneath the preceding
assistant bubble.
- PermissionSheet: SwiftUI .sheet(item:) presentation of
RichChatViewModel.pendingPermission. Tapping an option
dispatches ChatController.respondToPermission → ACPClient.
- ReasoningDisclosure: DisclosureGroup for HermesMessage.reasoning,
collapsed by default so chatty thinkers don't dominate scroll.
MessageBubble now renders assistant content through
AttributedString(markdown: options: .inlineOnlyPreservingWhitespace).
User messages stay plain Text (no reason to parse what the user
just typed). Unknown markdown falls through as literal text — worst
case, no formatting.
ChatController gains respondToPermission(requestId:optionId:) that
forwards to ACPClient and clears vm.pendingPermission on the
MainActor.
## New feature surfaces
### Memory (read + edit)
ScarfCore/ViewModels/IOSMemoryViewModel.swift:
- Kind enum (.memory / .user) → maps to paths.memoryMD / .userMD
- text (mutable) + originalText (pristine) + hasUnsavedChanges
- load() / save() / revert()
- async file I/O via ServerContext.readText / writeText — run on
a detached task so the MainActor doesn't hang on remote SFTP
scarf/Scarf iOS/Memory/:
- MemoryListView: two-row NavigationLink (MEMORY.md, USER.md)
- MemoryEditorView: TextEditor bound to vm.text, toolbar Save +
Revert, "Saved" bottom toast on success.
### Cron (read-only)
ScarfCore/ViewModels/IOSCronViewModel.swift:
- Loads ~/.hermes/cron/jobs.json via transport.readFile + decodes
into CronJobsFile (Codable, shipped in M0a)
- Missing file = empty list (no error — common on fresh installs)
- Sort: enabled-first, then nextRunAt ascending, disabled last
- Surfaces decode errors via lastError
scarf/Scarf iOS/Cron/CronListView.swift:
- Row: state-icon + name + schedule.display + next-run-at.
- Detail: prompt, schedule, state, delivery route (via
job.deliveryDisplay), skills, model.
Editing is deferred — needs atomic jobs.json rewrites. Shipped the
read path so users can at least audit their cron config on the go.
### Skills (read-only)
ScarfCore/ViewModels/IOSSkillsViewModel.swift:
- Scans ~/.hermes/skills/<category>/<name>/ via transport.listDirectory
+ transport.stat for directory-ness
- Filters dotfiles. Skips empty categories. Swallows per-category
listing errors (permissions etc.) rather than failing the whole
load.
- requiredConfig stays empty — YAML frontmatter parsing deferred
(would need a parser in ScarfCore; see M5 plan note).
scarf/Scarf iOS/Skills/SkillsListView.swift:
- Grouped by category, tap → SkillDetailView (path + file list).
## Supporting tweaks
- RichChatViewModel.PendingPermission: fields + public init promoted
from `let`/internal to `public let` / `public init(...)` so
PermissionSheet can read title/kind/options and tests can construct
one directly.
- LocalTransport.writeFile refactored to use Data.write(options: .atomic)
instead of FileManager.replaceItemAt. replaceItemAt is Apple-only;
Linux swift-corelibs doesn't fully implement it, which was breaking
the M5 save-path tests on Linux CI. Data.write(atomic) is cross-
platform and has identical semantics (temp-file + rename). Also
auto-creates the parent directory if missing, folding in the one
bit of the old logic that wasn't atomicity-related.
- DashboardView: single Chat Section → "Surfaces" Section with four
NavigationLinks (Chat / Memory / Cron / Skills).
## Tests (ScarfCoreTests/M5FeatureVMTests, 10 new)
.serialized suite — tests install a `withLocalTransportFactory`
helper that swaps ServerContext.sshTransportFactory to produce a
LocalTransport against real tmp files (so .ssh contexts in the
test resolve to local FS paths). Restored in defer. Serialized
because the factory is a static.
- memoryLoadsEmptyWhenFileMissing
- memoryRoundTripsFileContent — seed file → load → edit → save
→ reload via fresh VM → confirm persistence
- memoryRevertRestoresOriginal
- memoryKindPathRouting — pin .memory → memoryMD etc.
- cronEmptyWhenJobsFileMissing — missing file is not an error
- cronLoadsAndSortsJobs — 3-job fixture, verify sort:
enabled-before-disabled and
nextRunAt-ascending within
- cronSurfacesDecodeErrors — garbage jobs.json
- skillsEmptyWhenDirMissing
- skillsScansCategoryAndSkillStructure — 2 categories, dotfile
filter check
- skillsSkipsEmptyCategories
- pendingPermissionMemberwise — SQLite3-gated (RichChatViewModel
is gated)
**108 / 108 passing on Linux** (98 → 108).
## Manual validation needed on Mac
1. Xcode compile clean against M5 source additions.
2. Chat: trigger a tool call + a permission request. Verify cards
render, options dispatch, markdown looks right.
3. Memory: edit MEMORY.md on phone → save → confirm via `cat` on
the remote.
4. Cron: existing jobs show sorted + detail view useful.
5. Skills: browse matches `ls ~/.hermes/skills/<cat>/<name>/`.
Updated scarf/docs/IOS_PORT_PLAN.md with M5's scope, rationale
for the LocalTransport.writeFile refactor (Linux CI), and the M6
Settings-blocker (needs YAML parser port).
https://claude.ai/code/session_019yMRP6mwZWfzVrPTqevx2y
First real interactive iOS feature. Streams JSON-RPC over a
Citadel 8-bit-safe exec channel to a remote `hermes acp` process.
Reuses ScarfCore's `RichChatViewModel` state machine (from M0d)
+ `ACPClient` (from M1) unchanged — the only new code is the iOS-
specific channel + factory + SwiftUI view.
## SSHExecACPChannel
Packages/ScarfIOS/Sources/ScarfIOS/SSHExecACPChannel.swift
(iOS counterpart to Mac's ProcessACPChannel)
Uses Citadel's `SSHClient.withExec(_:perform:)`:
- RFC 4254 exec channel, no PTY, binary-clean stdin/stdout for
JSON-RPC bytes.
- Bidirectional: `TTYStdinWriter` for our `send(_:)` writes,
`TTYOutput` stream for stdout/stderr.
- withExec's closure-scoped lifecycle handled by running it in
a detached Task. A per-actor pending-waiters queue lets the
first `send(_:)` block until the writer is handed over (one-
time RTT); subsequent sends are instant.
- `close()` cancels the Task, which drops the `withExec`
closure, which triggers Citadel to close the SSH channel.
Clean teardown.
- Line framing via `Data` accumulators for stdout + stderr
separately — Citadel yields bytes in arbitrary chunk sizes,
we only push complete (newline-terminated) lines into the
ACPChannel streams.
## ACPClient+iOS
Packages/ScarfIOS/Sources/ScarfIOS/ACPClient+iOS.swift
(Sibling to Mac's ACPClient+Mac.swift)
Exposes `ACPClient.forIOSApp(context:keyProvider:)`. Opens a
dedicated `SSHClient` per ACP session — NOT reusing the
`CitadelServerTransport` client. Rationale: ACP sessions can
run for minutes/hours of streaming chat, and OpenSSH caps
concurrent channels per connection at ~10. Two separate
connections (transport + ACP) stay well under.
SSH auth: ed25519 via the Keychain-stored bundle, same
`SSHAuthenticationMethod.ed25519(...)` path as
CitadelServerTransport.
## iOS Chat view
scarf/Scarf iOS/Chat/ChatView.swift + embedded ChatController
(@Observable @MainActor). Minimal v1 UX:
- Three-state lifecycle: .connecting / .ready / .failed(reason)
- Auto-scrolling message list
- SwiftUI composer (multi-line TextField + Send button)
- Toolbar "+" for a fresh session (stop → reset → start)
- Message bubble (user: accent; agent: secondary background)
Deferred to M5: tool-call cards, permission request sheets,
markdown rendering, voice.
scarf/Scarf iOS/Dashboard/DashboardView.swift gains a
NavigationLink into Chat.
## Small public-API tweak
`RichChatViewModel.sessionId` promoted from `private(set)` to
`public private(set)` — ChatController reads it to route
`sendPrompt`. Same pattern as earlier M3 public-nits patches.
## Tests: 2 new in M4ACPIOSTests (now 98/98 on Linux)
Deliberately focused — M1's 10-test MockACPChannel suite already
covers the full ACPClient state machine. These two pin the
patterns iOS's new SSHExecACPChannel exercises:
- streamingPromptDeliversChunksAndCompletes: full handshake +
session/new + streamed agent_message_chunk notifications +
session/prompt response. Verifies chunks arrive as
.messageChunk events and prompt resolves with correct usage
tokens.
- permissionRequestYieldsEventAndRespondSends: remote
session/request_permission request → .permissionRequest
event → respondToPermission writes correct JSON back on the
channel with matching id + outcome.
Running `docker run --rm -v $PWD/Packages/ScarfCore:/work
-w /work swift:6.0 swift test` now reports 98 / 98.
## Manual validation needed on Mac
1. Xcode compile of scarf mobile target against the merged
pbxproj (target reconciliation shipped in the previous commit
on this branch).
2. Chat end-to-end against a real Hermes host. From Dashboard,
tap Chat → type "hello" → streaming response. Test "+" for
new session. Verify no leaked SSH connections across
Disconnect + re-onboard.
3. If your Hermes enables tools: verify tool_call_update
notifications come through (won't render with fancy cards
yet — that's M5 polish).
Updated scarf/docs/IOS_PORT_PLAN.md with M4's shipped state, the
"two separate SSH clients" rule, and the M5 polish backlog
(tool cards, permissions, markdown, voice).
https://claude.ai/code/session_019yMRP6mwZWfzVrPTqevx2y
Three-way reconciliation of:
- my M2/M3 source tree at scarf/scarf-ios/
- Alan's Xcode-created target with folder scarf/Scarf iOS/ and
target name `scarf mobile` (bundle com.scarf-mobile.app)
- the Mac `scarf` target that already had ScarfCore wired in
Alan created the iOS target on the unrelated `template-configuration`
branch (commit b289a83). I pulled only the iOS-specific bits
(Scarf iOS/ folder, Scarf iOS{Tests,UITests}/, and the pbxproj
additions), leaving the template-config work alone.
## Source tree: one folder, no duplicates
Before:
- scarf/scarf-ios/{App,Onboarding,Dashboard,Assets.xcassets,SETUP.md}
- scarf/Scarf iOS/{ContentView,Item,Scarf_iOSApp}.swift (Xcode defaults)
- scarf/Scarf iOS/Assets.xcassets/ (empty stubs)
After:
- scarf/Scarf iOS/{App,Onboarding,Dashboard,Assets.xcassets,
Info.plist,Scarf_iOS.entitlements}
- scarf/Scarf iOSTests/Scarf_iOSTests.swift (placeholder)
- scarf/Scarf iOSUITests/Scarf_iOSUITests*.swift (placeholder)
Deleted:
- scarf/Scarf iOS/ContentView.swift (my App/ScarfIOSApp supersedes)
- scarf/Scarf iOS/Item.swift (Xcode's SwiftData boilerplate,
unused)
- scarf/Scarf iOS/Scarf_iOSApp.swift (my App/ScarfIOSApp supersedes)
- scarf/Scarf iOS/Assets.xcassets/ (replaced with pre-built
1024 icon + Scarf-teal accent)
- scarf/scarf-ios/ (emptied + removed)
Moved:
- scarf/scarf-ios/SETUP.md → scarf/docs/iOS-SETUP.md
Docs belong in docs/ now that the target exists; the old
walkthrough is rewritten as a "project layout reference"
post-setup — the step-by-step target-creation instructions
are moot.
## pbxproj: three-way merge, no conflicts
Used git merge-file between:
base = main's pbxproj (before M0a + b289a83)
ours = M3 branch's pbxproj (has ScarfCore wired to Mac target)
theirs = b289a83 (has iOS target additions)
Merge produced zero conflict markers (658 → 1074 lines, +~416
from Alan's target additions and my M0a ScarfCore wiring). Hand-
added on top:
- 53SCARFIOS0020: new XCLocalSwiftPackageReference pointing at
Packages/ScarfIOS
- 53SCARFIOS0001: XCSwiftPackageProductDependency for ScarfIOS
product
- 53SCARFCORE0002: second XCSwiftPackageProductDependency for
ScarfCore (separate nav reference from the Mac target's so
Xcode can track per-target memberships cleanly)
- 53SCARFCORE0011 + 53SCARFIOS0010: PBXBuildFile entries linking
both frameworks into the iOS target's Frameworks build phase
- packageReferences on the project: added 53SCARFIOS0020
- scarf mobile target's packageProductDependencies: added
53SCARFCORE0002 + 53SCARFIOS0001
- scarf mobile target's Frameworks build phase files: added
53SCARFCORE0011 + 53SCARFIOS0010
Mac target (scarf) is unchanged structurally — still wires
53SCARFCORE0001 as before.
## docs/iOS-SETUP.md rewrite
Rewrote from "how to create the target" to "project layout
reference + troubleshooting". Key sections:
- Target settings (name, bundle ID, deployment, team, Swift
language version)
- SPM dependency map (ScarfCore on both; ScarfIOS on iOS only)
- Entitlements audit: called out that Xcode defaults set
CloudKit + APNs entitlements that we don't actually use in
M2-M4. Keeping them for now since they're no-ops; flagged
for M6 polish.
- Milestone coverage table
- Troubleshooting entries for the 4 most likely build failures
(Citadel resolution, `Cannot find Process`, stale auth variant
name, Keychain access)
## Sanity check
Linux `swift test` on ScarfCore still 96/96 passing. The Xcode
build will need Mac verification.
https://claude.ai/code/session_019yMRP6mwZWfzVrPTqevx2y
Q: Should I just create an iOS target in the current scarf project?
Would that be easier?
A: Yes — single scarf.xcodeproj with two targets (scarf + scarf-ios)
is objectively easier than a separate scarf-ios.xcodeproj.
The original conservative recommendation (separate xcodeproj) was
rooted in my not wanting to hand-edit pbxproj. But you're the one
clicking through Xcode's UI to create the target, not me — Xcode
handles multi-target multi-platform projects natively, with zero
risk to the existing Mac target.
Rewrote SETUP.md to describe the single-project flow:
- `File → New → Target` inside the existing project (not a new
project file).
- Both targets share the same SPM package references — ScarfCore
is already there for Mac, you just add it + ScarfIOS to the
scarf-ios target via General → Frameworks.
- One Xcode window, one scheme switcher, unified signing/team
settings.
Also threaded in M3-specific smoke-test steps (connect to a real
host → see Dashboard load via Citadel SFTP snapshot) and added a
post-M3 troubleshooting entry for the `Cannot find 'Process' in
scope` error — it should never appear now that makeProcess is
`#if !os(iOS)`-guarded, but if it does it's a leaked Mac-only file
in the scarf-ios target membership.
Milestone status table in SETUP.md updated to reflect M3 shipped.
https://claude.ai/code/session_019yMRP6mwZWfzVrPTqevx2y
Three things this phase ships:
## 1. Critical iOS-compile fix (latent from M0b)
`ServerTransport.makeProcess(...) -> Process` was iOS-unavailable at
compile time but my Linux CI didn't catch it (swift-corelibs-foundation
has Process; Apple iOS does not). Without this fix, the first ⌘B on
the iOS target would fail with "Cannot find 'Process' in scope".
Wrapped `makeProcess` with `#if !os(iOS)` on:
- the ServerTransport protocol requirement
- LocalTransport's impl
- SSHTransport's impl
Every current caller of makeProcess is already Mac-target-only
(ACPClient+Mac.swift, OAuthFlowController.swift) so no code changes
needed outside ScarfCore.
## 2. New platform-neutral streamLines(_:args:)
`AsyncThrowingStream<String, Error>` on the protocol, one stdout
line per element, newline-framed. Stream finishes on EOF + throws
`TransportError.commandFailed` on non-zero exit.
Impls:
- LocalTransport: Task.detached → Process + Pipe → line-framing
loop → exit check. iOS returns an empty stream (iOS doesn't run
LocalTransport at runtime).
- SSHTransport: same pattern, wrapped in `ssh -T host -- sh -c`.
iOS gets the empty-stream stub.
- CitadelServerTransport: empty stream for M3; M4 wires it to
Citadel's raw exec channel for iOS log tailing + chat.
HermesLogService refactored to use transport.streamLines() for the
remote tail path. The old `remoteTailProcess: Process?` +
`fileHandle: FileHandle?` state collapses into a single
`remoteTailTask: Task<Void, Never>?`. Parsed-line ring buffer is
drained synchronously by readNewLines() — semantically identical
on Mac, and newly works on iOS (when Citadel wires streamLines
in M4+).
## 3. CitadelServerTransport (the meat of M3)
Full `ServerTransport` conformance in ScarfIOS:
- File I/O: SFTP via SSHClient.openSFTP()
- runProcess: SSHClient.executeCommand(_:) with 2>&1 folding
- snapshotSQLite: remote `sqlite3 .backup` then SFTP-download
to <Caches>/scarf/snapshots/<id>/state.db
- fileExists/stat: SFTPClient.getAttributes
- listDirectory: SFTPClient.listDirectory with . / .. stripped
- createDirectory: mkdir -p semantics (walks each component,
ignores existing-dir errors)
- removeFile: SFTPClient.remove, idempotent on missing
- watchPaths: 3s polling on stat mtime (same shape as Mac
SSHTransport's remote-watch fallback)
- streamLines: empty stream for M3 (see above)
Maintains a single long-lived SSH + SFTP connection per transport
instance via a nested ConnectionHolder actor. Lazy-init on first
use, reconnect on failure. Blocks the caller thread via
DispatchSemaphore to bridge Citadel's async API to
ServerTransport's sync protocol — same pattern the Mac SSHTransport
uses.
## ScarfCore transport-factory injection
New `ServerContext.sshTransportFactory: SSHTransportFactory?`
static. When non-nil, `makeTransport()` routes `.ssh` contexts
through it instead of constructing SSHTransport directly.
scarfApp.init() on iOS wires this:
ServerContext.sshTransportFactory = { id, cfg, name in
CitadelServerTransport(
contextID: id, config: cfg, displayName: name,
keyProvider: { try await KeychainSSHKeyStore().load() ?? ... }
)
}
Mac leaves it nil; default SSHTransport path unchanged.
## iOS Dashboard — real data
New IOSDashboardViewModel in ScarfIOS. Unlike Mac's DashboardViewModel
(uses HermesFileService, still Mac-only), this reads session stats +
recent sessions only — enough for a real iOS Dashboard, none of the
config.yaml / gateway-state / pgrep checks the Mac dashboard shows.
DashboardView on iOS now renders actual data: session count, message
count, tool calls, token totals (input/output/reasoning with K/M
formatting), and the last 5 sessions with their source icons +
relative start times. Pull-to-refresh triggers vm.refresh(). Error
banner with Retry on snapshot/open failures.
## Public API nits (uncovered by the Dashboard work)
HermesDataService.SessionStats member fields + .empty static were
internal-by-default (nested in a public type, sed missed them).
Promoted to public. `lastOpenError` promoted to public private(set).
## Tests — 8 new in M3TransportTests, @Suite(.serialized)
- LocalTransport.streamLines yields one line per newline, drops
partial trailing content, surfaces non-zero exit as
TransportError.commandFailed.
- ServerContext.sshTransportFactory override applies for .ssh,
ignored for .local, nil-falls-back-to-SSHTransport.
- HermesLogService remote-tail pumps scripted streamLines output
through to readNewLines() ring buffer.
- HermesLogService.readLastLines uses runProcess one-shot, as
documented.
Real bug caught in dev: first pass of this test suite had two tests
setting ServerContext.sshTransportFactory + defer-restoring. Swift-
Testing runs in parallel by default — the two tests raced, producing
"entries[2].message is 'z' not 'boom'". Fixed with
@Suite(.serialized) + a note in the suite header explaining why.
Running `docker run --rm -v $PWD/scarf/Packages/ScarfCore:/work -w /work
swift:6.0 swift test` now reports 96 / 96 passing (88 pre-M3 + 8 new).
## Manual validation needed on Mac
1. iOS build with the new protocol guards. ⌘B on iOS simulator —
should compile cleanly. If `Cannot find 'Process' in scope`
still appears anywhere, grep for any unguarded `Process\(\)`.
2. Dashboard end-to-end against a real Hermes host: iPhone
simulator, public key in remote authorized_keys, onboarding →
Dashboard → should see session stats fetched via Citadel SFTP +
exec. Pull-to-refresh should re-snapshot.
3. SQLite snapshot file appears under `<Caches>/scarf/snapshots/
<id>/state.db` and HermesDataService opens it read-only.
Updated scarf/docs/IOS_PORT_PLAN.md with M3's shipped scope, the
streamLines adoption rule, and the "CitadelServerTransport.streamLines
is a stub (M3)" / "M4 wires real streaming" cross-reference.
https://claude.ai/code/session_019yMRP6mwZWfzVrPTqevx2y
Two follow-ups per review:
## Citadel: current stable
Citadel is at 0.12.1, not 0.9.x as I'd been writing against. Bumped
the pin from `from: "0.7.0"` to `.upToNextMinor(from: "0.12.0")`
— tight because Citadel's pre-1.0 authentication-method variants
have shifted between minor releases (0.7 → 0.9 → 0.12), so
explicit bump-and-review is safer than letting the version float.
Downloaded Citadel 0.12.1's source and verified every API call in
CitadelSSHService against it:
- SSHAuthenticationMethod.ed25519(username:, privateKey:) ✓
- SSHClientSettings(host:, authenticationMethod:, hostKeyValidator:) ✓
- SSHHostKeyValidator.acceptAnything() ✓
- SSHClient.connect(to: settings) ✓
- client.executeCommand(_:) -> ByteBuffer ✓
- client.close() async throws ✓
Dropped the "FIXME — may need adjustment" disclaimer in the file
header; replaced with a "verified against 0.12.1" note that says
re-verify if the pin bumps to 0.13+. Same change in SETUP.md
troubleshooting.
## Assets.xcassets (app icon + accent color)
scarf/scarf-ios/Assets.xcassets/ now exists with:
- AppIcon.appiconset/
AppIcon-1024.png (1024×1024, copied from the Mac app's
icon set — same art)
Contents.json (idiom: universal, platform: ios,
size: 1024x1024 — iOS 14+ renders all
smaller sizes from this automatically)
- AccentColor.colorset/
Contents.json (Scarf teal: sRGB 0.227/0.525/0.722
light, 0.400/0.690/0.902 dark)
- Contents.json (root, empty — just version metadata)
SETUP.md updated:
- Instructs Alan to delete Xcode's scaffolded Assets.xcassets AND
import ours, not the other way around.
- Notes the accent color values so a different palette choice is
a one-file edit.
- Removes the obsolete "drop your icon asset" step.
No functional code changes; tests still 88/88 on Linux.
https://claude.ai/code/session_019yMRP6mwZWfzVrPTqevx2y
First iOS phase. Delivers all the code needed to build + TestFlight a
functional v1 iOS app (onboarding with SSH-key generate / import +
real Citadel-backed connection test; persistent Keychain key +
UserDefaults server config; placeholder Dashboard) — but NOT the
scarf-ios.xcodeproj. Creating that from scratch by hand is too risky
without an iOS SDK to build against, so Alan creates it in Xcode's UI
following scarf/scarf-ios/SETUP.md (~5 minutes, one-time).
## ScarfCore additions (all Linux-testable)
Packages/ScarfCore/Sources/ScarfCore/Security/:
- SSHKey.swift — SSHKeyBundle + SSHKeyStore protocol
+ InMemorySSHKeyStore test actor
- IOSServerConfig.swift — IOSServerConfig + store protocol + mock;
toServerContext(id:) bridges to the
existing ServerContext so all ScarfCore
services work against an iOS config
- OnboardingState.swift — OnboardingStep enum + pure validators
(host, port, PEM shape, public-key parse)
- SSHConnectionTester.swift — protocol + error enum + mock
- OnboardingViewModel.swift — @Observable @MainActor state machine,
fully dependency-injected (key store /
config store / tester / generator closure)
## New Packages/ScarfIOS local SPM package
Depends on ScarfCore + Citadel (from: "0.7.0").
- KeychainSSHKeyStore.swift — real iOS Keychain storage
(kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly, no iCloud
sync). Gated on canImport(Security) for Linux skip.
- UserDefaultsIOSServerConfigStore.swift — JSON-encoded single-key
persistence of IOSServerConfig.
- Ed25519KeyGenerator.swift — CryptoKit-backed Ed25519 minting.
Emits standard OpenSSH public-key lines (authorized_keys-ready).
Stores the private half in a compact SCARF ED25519 PRIVATE KEY
PEM shape that CitadelSSHService decodes back into a
Curve25519.Signing.PrivateKey. Non-interop with OpenSSH's
`BEGIN OPENSSH PRIVATE KEY` envelope — export flow for sharing
keys is deferred to a later phase.
- CitadelSSHService.swift — SSHConnectionTester conformance +
key-generation wrapper. Runs `echo scarf-ok` over a one-shot
Citadel exec for the onboarding connection test. One FIXME on
buildClientSettings because Citadel 0.7→0.9 shifted the
`.ed25519(...)` authentication-method variant name; every other
line is Citadel-version-independent. Gated on
canImport(Citadel) && canImport(CryptoKit).
## scarf/scarf-ios/ app source tree
- App/ScarfIOSApp.swift — @main, RootModel routes to
onboarding or dashboard based on
stored state.
- Onboarding/OnboardingRootView.swift — 8 sub-views, one per
OnboardingStep. Validated
server-details form, key-source
picker, generate / show-public
/ import / test / retry /
connected.
- Dashboard/DashboardView.swift — M2 placeholder: connected host
details + Disconnect button.
M3 replaces with real data.
## scarf/scarf-ios/SETUP.md
Step-by-step Xcode project creation:
- iOS 18 / iPhone-only / team 3Q6X2L86C4 / Bundle ID
com.scarf.scarf-ios / Swift 5 language mode.
- Wire Packages/ScarfCore + Packages/ScarfIOS (Citadel resolves
transitively).
- Replace Xcode's default scaffolded files with this source tree.
- Smoke-test procedure (simulator → physical iPhone).
- TestFlight upload steps.
- Troubleshooting for the known Citadel-variant-name drift.
## Test coverage (Linux, `swift test`)
M2OnboardingTests, 26 new tests (ScarfCore):
- SSHKeyBundle memberwise + display fingerprint
- InMemorySSHKeyStore + InMemoryIOSServerConfigStore round-trips
- IOSServerConfig.toServerContext bridging (with + without
remoteHome override)
- All OnboardingLogic validators (empty / whitespace / port range /
legacy-RSA rejection / public-key line parser)
- MockSSHConnectionTester scripting (success + failure)
- 10 OnboardingViewModel end-to-end paths: happy-path
save-and-test, invalid-host blocks advance, connection-failure
routes to .testFailed (and crucially does NOT save config),
retry-after-failure-works, import-happy, import-rejects-bad-PEM,
reset clears all state
ScarfIOSSmokeTests, 3 tests (Apple-only, won't run on Linux):
- Ed25519KeyGenerator bundle shape + base64 wire format
- OpenSSH public-key line byte-length pinned at 51 bytes
- Corrupted PEM rejection on round-trip decode
Running
docker run --rm -v $PWD/scarf/Packages/ScarfCore:/work -w /work swift:6.0 swift test
reports **88 / 88 passing** (62 pre-M2 + 26 new).
## Real bug caught in development
First pass of OnboardingViewModel had `confirmPublicKeyAdded()` set
`isWorking=true`, then call `runConnectionTest()` which bailed on
`!isWorking` — meaning the connection probe never ran and the config
was never saved. Caught by the end-to-end test. Fixed by extracting
the shared probe body into `performConnectionTest()` and letting
both entry points own their own `isWorking` transition.
## Manual validation still needed on Mac
1. Xcode project creation per SETUP.md — confirm the resulting
project builds cleanly.
2. Citadel 0.9.x authentication-method variant — verify the one
FIXME line in buildClientSettings.
3. End-to-end onboarding: simulator against `localhost:22` (or a
test host), then TestFlight → physical iPhone → real SSH host
with the shown public key in authorized_keys.
Updated scarf/docs/IOS_PORT_PLAN.md with M2's shipped scope, the
scope decision about NOT generating the xcodeproj, and the list of
rules M3+ can rely on (Citadel transport dispatch, ChannelFactory
hook, single-server invariant).
https://claude.ai/code/session_019yMRP6mwZWfzVrPTqevx2y
Introduces the key architectural abstraction that lets iOS share the
ACP state machine with Mac in M4+. ACPClient no longer touches
`Process`, `Pipe`, file descriptors, or SSH sessions directly — it
reads / writes line-oriented JSON-RPC through an `ACPChannel`.
New in ScarfCore/ACP/:
- ACPChannel.swift (protocol + ACPChannelError enum)
- ProcessACPChannel.swift (Mac + Linux; `#if !os(iOS)` guard —
iOS can't spawn subprocesses). Wraps the Process + Pipe +
raw POSIX write(2) code that used to live inline inside
ACPClient: SIGPIPE-ignore, partial-write loops, EPIPE →
`.writeEndClosed`, graceful SIGINT + 2s SIGKILL watchdog.
Uses `canImport(Darwin)` / `canImport(Glibc)` for the
platform-specific `write(2)` binding.
- ACPClient.swift (moved from scarf/Core/Services and refactored).
Process/Pipe/stdinFd/Darwin.write state replaced with a single
`channel: any ACPChannel` reference. Construction takes a
`ChannelFactory = @Sendable (ServerContext) async throws -> any ACPChannel`
closure — Mac wires ProcessACPChannel, iOS will wire a Citadel
SSHExecACPChannel in M4.
Mac-side glue (stays in main target):
- scarf/Core/Services/ACPClient+Mac.swift (new) carries the
`ACPClient.forMacApp(context:)` factory. Internally spawns
`hermes acp` locally or `ssh -T host -- hermes acp` remotely
via SSHTransport.makeProcess, passing the enriched shell env
(local: full PATH + credentials; remote: just SSH_AUTH_SOCK
+ SSH_AGENT_PID) with TERM stripped. Behaviour identical to
pre-M1.
- ChatViewModel updated at 3 sites from `ACPClient(context:)`
to `ACPClient.forMacApp(context:)`.
Public API change callers need to know about:
- `ACPClient.respondToPermission(requestId:optionId:)` is now
`async`. ChatViewModel already `await`ed it, so that upgrade
is a no-op; no other callers.
Also deleted scarf/Core/Services/ACPClient.swift (605 lines;
replaced by ScarfCore version).
Test coverage (M1ACPTests, 10 tests):
Using a MockACPChannel actor to script JSON-RPC deterministically,
not a real subprocess:
- ACPChannel protocol (mock send/receive, write-after-close,
error descriptions).
- ACPClient initial state.
- start() sends initialize and flips isConnected on reply.
- RPC error reply surfaces as ACPClientError.rpcError.
- Mid-flight channel close → pending request resolves with
.processTerminated, isConnected flips false.
- session/update notification routes into the `events` stream
as .messageChunk.
- Stderr lines feed the recentStderr ring buffer.
- ACPErrorHint.classify across credential / missing-binary /
rate-limit / unknown cases.
`swift test` on Linux now reports 62 / 62 passing.
Updated scarf/docs/IOS_PORT_PLAN.md with M1's shipped state, the
behavior-preservation rationale for the Mac factory, and the
iOS hook point M2–M4 will plug into.
https://claude.ai/code/session_019yMRP6mwZWfzVrPTqevx2y
Two bugs caught by a post-M0d audit, both of which would have bitten
users before any test exercised them on Mac:
1. GatewayViewModel.swift lost its `import ScarfCore` during the
M0d revert (when I moved it back to the Mac target after finding it
wasn't portable). The file references ServerContext everywhere and
wouldn't compile in Xcode without the import. Added back.
2. SSHTransport.sshSubprocessEnvironment() regressed in M0b.
The original Mac code ran HermesFileService.enrichedEnvironment(),
which tries `zsh -l -i` (login + interactive, with prompt-framework
defangs) FIRST, then falls back to `zsh -l`. Most users with
1Password / Secretive / manual ssh-add export SSH_AUTH_SOCK from
their `.zshrc` (interactive shell init), NOT `.zprofile`. My M0b
replacement used `zsh -l` only — so it would have silently failed
to find their ssh-agent socket, and SSH auth would break with
"Permission denied" (exit 255) for everyone who set up their
agent the normal way.
Fix is a dependency-inversion injection point instead of a local
shell probe: SSHTransport.environmentEnricher is a `(@Sendable () ->
[String: String])?` static that the Mac target wires at launch to
HermesFileService.enrichedEnvironment(). Same exact code path
executed as before M0b; no duplication; iOS leaves it `nil` and
falls back to ProcessInfo.processInfo.environment (Citadel will
own the SSH agent on iOS in M4+, not the login shell). Tests can
set a stub closure.
scarfApp.init() now sets `SSHTransport.environmentEnricher = {
HermesFileService.enrichedEnvironment() }` right before the
existing warm-up Task.
Test coverage: M0b suite gains `sshTransportEnvironmentEnricherInjection`,
which pins the injection-point shape so a future refactor can't
silently drop it.
Audit results (for confidence before M1):
- Exhaustive grep of every moved type across main target → 0 files
reference ScarfCore types without `import ScarfCore` (after the
GatewayVM fix).
- `scarf.xcodeproj/project.pbxproj` has no stale path references
(PBXFileSystemSynchronizedRootGroup auto-discovers).
- `xcshareddata/xcschemes/*.xcscheme` has no stale path references.
- `.build/` correctly gitignored.
- Zero leftover temp scripts / `.orig` / `.bak` files.
`swift test`: 52 / 52 passing on Linux.
https://claude.ai/code/session_019yMRP6mwZWfzVrPTqevx2y
Fourth and final M0 sub-PR. Wraps up the ScarfCore extraction with the
ViewModels that have no dependency on Mac-target services or AppKit.
Views deliberately stay in the Mac target — see plan for rationale.
Moved (6 VMs):
ActivityViewModel.swift — HermesDataService consumer, SQLite3-gated
ConnectionStatusViewModel.swift — @MainActor heartbeat for remote SSH
InsightsViewModel.swift — HermesDataService aggregator, SQLite3-gated
(+ InsightsPeriod, ModelUsage, PlatformUsage,
ToolUsage, NotableSession types; exports
free functions formatDuration/formatTokens)
LogsViewModel.swift — HermesLogService consumer, fully portable
(+ nested LogFile / LogComponent enums)
ProjectsViewModel.swift — ProjectDashboardService wrapper, portable
RichChatViewModel.swift — ~700 lines of ACP-event + message-group
handling, SQLite3-gated
(+ ChatDisplayMode, MessageGroup types)
Reverted in-flight:
GatewayViewModel.swift — my audit missed that it calls
`context.runHermes(...)`, a Mac-target-only extension. Not portable
without moving HermesFileService too. Left in the Mac target.
Platform guards applied:
- `#if canImport(SQLite3)` wraps entire files for ActivityVM, InsightsVM,
and RichChatVM (they transitively depend on HermesDataService).
- `#if canImport(Darwin)` around LocalizedStringResource displayName
in LogsViewModel's nested LogFile and LogComponent enums.
- `#if canImport(os)` around the unused Logger in
ConnectionStatusViewModel (kept the field for future use).
Swift 6 / Observation notes:
- `import Observation` explicitly added to each @Observable file.
Mac target gets Observation via SwiftUI; ScarfCore doesn't import
SwiftUI, so it needs the explicit module import. Observation ships
in the Swift 5.9+ standard library on every platform.
- Nested enums' `var id: String { rawValue }` had to be manually
promoted to `public var id` since my sed only touches 4-space-indent
declarations and the nested enum's members are at 8-space indent.
- Two accidentally-publicized function-local `let` variables in
InsightsViewModel reverted back to internal.
- Sed adjustment: an earlier pattern was producing `@Observable public`
which is a Swift syntax error. Fixed post-hoc by stripping the
stray trailing `public` after the attribute; noted in the plan file
as a checklist item for M1+ sed work.
Consumer import sweeps:
4 Mac-target files gained `import ScarfCore` for the moved VM types:
ContentView.swift, ChatView.swift, RichChatView.swift, and
ConnectionStatusPill.swift.
Test coverage (M0dViewModelsTests): 14 new tests.
- ConnectionStatusViewModel: local-always-connected, remote idle-start,
Status Equatable pinning.
- LogsViewModel: init defaults, filteredEntries across level / search /
component filters, nested enum Identifiable ids and loggerPrefix.
- ProjectsViewModel: .local context binding.
- (SQLite3-gated, Apple-only):
ActivityVM construction, InsightsVM period defaults and sinceDate
ordering, ChatDisplayMode case coverage, RichChatVM empty-state
invariants, MessageGroup derived properties.
Running `docker run --rm -v $PWD/scarf/Packages/ScarfCore:/work -w /work
swift:6.0 swift test` now reports 51 / 51 passing on Linux
(M0a 16 + M0b 18 + M0c 8 + M0d 9 + smoke 1 − 5 SQLite3-gated).
Apple-target CI should see 56 / 56 with the 5 gated tests added in.
Updated scarf/docs/IOS_PORT_PLAN.md with M0d's shipped state, the
Views-stay-Mac-only scope decision, and the sed-gotcha checklist
future phases should watch for.
https://claude.ai/code/session_019yMRP6mwZWfzVrPTqevx2y
Third of four M0 sub-PRs. Moves the four Services that have no dependency
on Mac-target code or AppKit into ScarfCore, so the Mac + (future) iOS
targets can share them.
Files moved (4):
scarf/Core/Services/HermesDataService.swift (658 lines, SQLite reader + SnapshotCoordinator actor)
scarf/Core/Services/HermesLogService.swift (log tail + parse, LogEntry + LogLevel)
scarf/Core/Services/ModelCatalogService.swift (models.dev JSON reader, HermesModelInfo + HermesProviderInfo)
scarf/Core/Services/ProjectDashboardService.swift (per-project dashboard I/O)
Not moved, with reason:
HermesFileService.swift — carries the big shell-enrichment logic; a
later phase can port once iOS has a clearer env story for ACP spawns.
HermesEnvService.swift — depends on HermesFileService.
HermesFileWatcher.swift — depends on HermesFileService.
ACPClient.swift — M1's job (the ACPChannel refactor).
UpdaterService.swift — wraps Sparkle, stays Mac-only forever.
Platform guards:
HermesDataService.swift is wrapped in `#if canImport(SQLite3) ... #endif`
for the whole file. SQLite3 isn't a system module on Linux
swift-corelibs-foundation. Apple platforms compile unchanged. Linux
builds skip the file entirely; nothing in ScarfCore references
HermesDataService from outside the file, so there's no downstream
fallout.
ModelCatalogService `import os` / Logger definition / call site all
guarded with `#if canImport(os)`. Linux gets silent logging.
HermesLogService + ProjectDashboardService use only Foundation —
no guards needed.
Other fixes:
- Features/Settings/Views/Components/ModelPickerSheet.swift (the one
remaining consumer) gains `import ScarfCore`.
- Self-referential `import ScarfCore` stripped from each moved file.
Test coverage: 8 new tests in ScarfCoreTests/M0cServicesTests.swift:
- HermesLogService.parseLine exercised via readLastLines on a real
tmp file with three formats — v0.9.0+ with session tag, older
without, and garbage fallback. Pins CLAUDE.md's optional-session-tag
invariant.
- LogLevel SwiftUI colour strings pinned.
- HermesModelInfo.contextDisplay across 1M / 200K / 500 / nil cases;
costDisplay with and without costs.
- ModelCatalogService load path end-to-end against a synthetic
models_dev_cache.json lookalike — providers sorted, models
filtered, provider(for:) resolves both full-scan and slash-prefixed
IDs.
- Malformed + missing catalog files return empty, no crash.
- ProjectDashboardService round-trips ProjectRegistry + reads a
synthetic .scarf/dashboard.json.
Running `docker run --rm -v $PWD/scarf/Packages/ScarfCore:/work -w /work
swift:6.0 swift test` now reports 42 / 42 passing (M0a 16 + M0b 18 +
M0c 8).
Updated scarf/docs/IOS_PORT_PLAN.md progress log with the shipped M0c
state and the SQLite3-gating pattern future phases should reuse.
https://claude.ai/code/session_019yMRP6mwZWfzVrPTqevx2y
Second of four M0 sub-PRs. Moves the remaining cross-cutting
infrastructure — the ServerTransport protocol and its two implementations
(LocalTransport, SSHTransport), plus ServerContext and its helpers —
into ScarfCore so both Mac and (future) iOS targets share one codebase.
Files moved (5):
- scarf/Core/Transport/ServerTransport.swift (+ FileStat, ProcessResult, WatchEvent)
- scarf/Core/Transport/LocalTransport.swift
- scarf/Core/Transport/SSHTransport.swift
- scarf/Core/Transport/TransportErrors.swift
- scarf/Core/Models/ServerContext.swift (+ SSHConfig, ServerKind, ServerID, UserHomeCache)
Split out of ServerContext.swift into a new Mac-target sibling file
scarf/Core/Models/ServerContext+Mac.swift:
- runHermes(_:timeout:stdin:) — depends on HermesFileService
- openInLocalEditor(_:) — depends on AppKit.NSWorkspace
These methods can't live in ScarfCore itself because ScarfCore must not
depend on main-target services or AppKit. iOS will provide a sibling
ServerContext+iOS.swift in M2+.
Removed: scarf/Core/Models/HermesPaths+Deprecated.swift.
Zero callers in-tree; its only justification was that ServerContext
used to be in the Mac target. With ServerContext in ScarfCore now,
the deprecated forwarders are both unreachable AND dead code.
Breaking the ScarfCore → main-target circular dep in SSHTransport:
The old SSHTransport.sshSubprocessEnvironment() called
HermesFileService.enrichedEnvironment() to harvest SSH_AUTH_SOCK from
the user's login shell. Replaced with a local #if os(macOS) helper
SSHTransport.macLoginShellSSHAgent() that probes /bin/zsh for only
the two SSH agent vars (no PATH/credentials — that's still in
HermesFileService for ACP subprocess use). Behavior-identical on
macOS; no-op on iOS/Linux.
Platform guards added in ScarfCore (runtime targets still macOS/iOS):
- `#if canImport(os)` around os.Logger (definition + every call site,
except the large Darwin-dependent ensureControlDir block).
- `#if canImport(Darwin)` around LocalTransport.watchPaths (FSEvents)
and SSHTransport.ensureControlDir (Darwin.stat/lstat). Linux gets
a no-op empty stream and a best-effort FileManager.createDirectory
fallback — neither is exercised at runtime on Linux, only compiled.
- `#if canImport(SwiftUI)` around ServerContext's EnvironmentKey.
- `#if canImport(AppKit)` inside the new ServerContext+Mac.swift
extension.
Bug fixed: M0a's sed transform accidentally added `public` to protocol
requirements in ServerTransport.swift, e.g. `public nonisolated var
contextID: ServerID { get }`. Swift forbids access modifiers on
protocol requirements — stripped.
54 additional consumer files in the Mac target gained `import ScarfCore`.
Test coverage: 18 new tests in ScarfCoreTests/M0bTransportTests.swift.
Runs on Linux via
docker run --rm -v $PWD/scarf/Packages/ScarfCore:/work -w /work swift:6.0 swift test
Total suite: 34 / 34 passing (M0a's 16 + M0b's 18).
Updated scarf/docs/IOS_PORT_PLAN.md progress log with the shipped M0b
state and the Platform-guard patterns future phases should reuse.
https://claude.ai/code/session_019yMRP6mwZWfzVrPTqevx2y
The initial M0a commit was incomplete: .gitignore's `Packages/` rule
(meant for the legacy pre-Xcode-14 SwiftPM checkout dir) silently
swallowed three new files that SHOULD have been committed:
- scarf/Packages/ScarfCore/Package.swift
- scarf/Packages/ScarfCore/Sources/ScarfCore/Models/HermesConstants.swift
- scarf/Packages/ScarfCore/Tests/ScarfCoreTests/ScarfCoreSmokeTests.swift
The 12 moved models slipped through because `git mv` preserves tracking
across gitignored destinations, but new files in that tree did not.
Fix: add `!scarf/Packages/` override so our local SPM package is always
tracked; keep the top-level `Packages/` ignore for the historical case.
Also verified M0a builds + tests green on Linux via
`docker run --rm -v $PWD/scarf/Packages/ScarfCore:/work -w /work swift:6.0 swift test`.
To make that work, two small, Apple-platform-preserving guards:
- `sqliteTransient` in HermesConstants.swift wrapped in
`#if canImport(SQLite3)` — SQLite3 is not a system module on Linux
swift-corelibs-foundation. Apple builds compile unchanged.
- `ToolKind.displayName` and `MCPTransport.displayName` wrapped in
`#if canImport(Darwin)` — `LocalizedStringResource` is Apple-only.
Apple builds compile unchanged.
Additionally:
- Package.swift pinned to Swift 5 language mode, matching the Mac app's
`SWIFT_VERSION = 5.0`. Two types (`ACPEvent.availableCommands` and
`ACPToolCallEvent.rawInput`) claim `Sendable` while carrying
`[String: Any]` — strict Swift 6 rejects that. Comment in Package.swift
flags this for a future typed-payloads cleanup + bump to `.v6`.
- ScarfCoreSmokeTests now contains 16 tests exercising every M0a
`public init` so parameter drift fails CI instead of a reviewer.
- IOS_PORT_PLAN.md updated with what actually shipped, the Linux-CI
guards + patterns future phases should reuse, and the Sendable
follow-up flagged under "Rules next phases can rely on".
Test results (Linux, Swift 6.0.3):
Suite M0aPublicInitTests: 15 tests passed
Suite ScarfCoreSmokeTests: 1 test passed
Total: 16 / 16 passed
https://claude.ai/code/session_019yMRP6mwZWfzVrPTqevx2y
First of four M0 sub-PRs that carve a platform-neutral ScarfCore package
out of the Mac app, in preparation for an iOS target. This PR is
Mac-only — no iOS target yet, no behavior changes expected.
What moves to ScarfCore:
- 13 leaf model files (HermesSession, HermesMessage, HermesConfig and
its 19 nested Settings structs, HermesCronJob, HermesMCPServer,
HermesSkill, HermesSlashCommand, HermesTool + KnownPlatforms,
HermesPathSet, MCPServerPreset, ProjectDashboard family, ACPMessages).
- Portable half of HermesConstants.swift (sqliteTransient, QueryDefaults,
FileSizeUnit). The deprecated HermesPaths enum stays in main target
as HermesPaths+Deprecated.swift since it references ServerContext.
What stays in the Mac target:
- ServerContext.swift (moves in M0b alongside Transport — depends on
LocalTransport/SSHTransport + HermesFileService).
- HermesPaths+Deprecated.swift (dead forwarders, zero callers in-tree;
kept for safety until M0b can clean them up).
Mechanics:
- New Packages/ScarfCore/Package.swift targeting macOS 14 / iOS 18,
Swift 6 language mode.
- Every moved type and member marked public; explicit public memberwise
init added to every struct (Swift's synthesized memberwise init is
internal and would break cross-module construction).
- Xcode project references the package via XCLocalSwiftPackageReference
and links ScarfCore into the scarf target.
- 49 consumer files get `import ScarfCore` added.
See scarf/docs/IOS_PORT_PLAN.md for the full multi-phase plan, locked
decisions (iOS 18, iPhone only, no APNs v1), and the M0b–M6 roadmap.
Manual verification checklist:
- Open scarf.xcodeproj in Xcode and build the scarf scheme — should
resolve the local package and compile with no new errors.
- Run scarfTests — should pass (tests don't touch moved types).
- Smoke-run the app: Dashboard, Sessions, Chat, Memory should render
with identical data to pre-PR.
https://claude.ai/code/session_019yMRP6mwZWfzVrPTqevx2y
`need_ghpages` was testing `[[ -d "$GHPAGES_DIR/.git" ]]` — "is .git
a directory?". That's true for a regular clone but FALSE for a
`git worktree add` worktree, where `.git` is a pointer file (contains
`gitdir: …/main-repo/.git/worktrees/<name>`) rather than the
directory itself. `release.sh` creates the gh-pages worktree as
part of its flow; after release the worktree persists with a
`.git` file but `catalog.sh publish` would then refuse to run
because of the dir-only check.
Switched to `-e` (exists, either file or directory). Updated the
surrounding comment so the next poor soul doesn't delete the
worktree on the script's own (wrong) advice.
Caught when publishing the v2.2.0 template catalog — error told
the user to re-create a worktree that was already there and valid.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Brings in 22 commits delivering the full v2.2.0 scope:
- Project Templates: .scarftemplate bundle format (install, uninstall,
export, URL router) + install preview sheet + cross-agent AGENTS.md
- Template Configuration (schemaVersion 2): typed schema with 7 field
types, Keychain-backed secrets, Configure step in install flow,
post-install Configuration editor, model recommendations
- Template Catalog: gh-pages site generated from templates/<author>/<name>/,
stdlib-only Python validator mirroring Swift invariants, PR CI gate,
install-URL hosting from raw main
- Example template: awizemann/site-status-checker (config + cron + Site
tab webview updates)
- Site tab: webview widget in any dashboard exposes a second tab
- UX: Remove from List vs. Uninstall Template clarification, preserved-
files banner, Run Now no longer blocks on long agent runs, markdown
in install sheet, install-time {{PROJECT_DIR}} token substitution
Release notes at releases/v2.2.0/RELEASE_NOTES.md (94 lines).
Wiki page at https://github.com/awizemann/scarf/wiki/Project-Templates.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The pre-existing release notes and README "What's New in 2.2" block
only covered the original Project Templates feature. This expands
both to reflect everything that's actually shipping in 2.2:
- Template Configuration (schemaVersion 2): typed schema, 7 field
types, Keychain-backed secrets, configure step in install flow,
post-install Configuration editor, model recommendations.
- Template Catalog: gh-pages site with live dashboard previews,
stdlib-only Python validator mirroring Swift invariants, PR CI
gate, install-URL hosting from raw main.
- Example template `awizemann/site-status-checker` exercising every
v2.2 surface — config form, cron, Site tab webview, dashboard
updates.
- Site tab — a webview widget in any dashboard exposes a second
tab next to Dashboard, rendering a live URL.
- UX clarifications: Remove from List (keep files) vs. Uninstall
Template (remove installed files), preserved-files banner on
uninstall success, Run Now no longer blocks on long agent runs.
- Install-time {{PROJECT_DIR}} / {{TEMPLATE_ID}} / {{TEMPLATE_SLUG}}
token substitution in cron prompts.
Release-notes link + wiki link surfaced at the bottom of the README
block so readers have a jump to full details.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Two independent fixes that both blocked the "install → Run Now → see
the Site tab render" loop.
1. CronViewModel.runNow stopped blocking on `cron tick`. Previously
the UI waited up to 60 s on the tick before deciding whether the
job succeeded, so any agent run that did real work (an LLM call +
a few HTTP GETs + a file write = easily 90 s+) surfaced a false
"Run failed" toast while the job kept running in the background.
Dashboard updates landed minutes later, confusing the user.
New shape: show "Agent started — dashboard will update when it
finishes" the instant `cron run` queues the job, then call `cron
tick` with a 300 s timeout to force execution. Tick failures are
logged but don't overwrite the started-toast — HermesFileWatcher
picks up the dashboard.json rewrite automatically when the agent
finishes.
2. site-status-checker's webview widget pointed at
`github.com/awizemann/scarf/tree/main/templates/awizemann/...`.
The templates/ path only exists on project-sharing, not main, so
GitHub returned 404 in the Site tab until the first cron run
replaced the URL with the user's configured site. Switched the
placeholder to `awizemann.github.io/scarf/` which always renders.
Bundle + catalog rebuilt against the updated dashboard.json.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Three UX changes addressing user feedback that "Remove from Scarf" and
"Uninstall Template…" looked interchangeable, and that users were
surprised when uninstall left the project folder behind.
- Rename sidebar menu entries:
"Uninstall Template…" → "Uninstall Template (remove installed files)…"
"Remove from Scarf" → "Remove from List (keep files)…"
The expanded labels carry the scope difference at the point of click.
- Add a confirmation dialog for Remove from List. The sidebar's "-"
button and the context-menu entry both route through it. Dialog copy
explicitly spells out "Nothing on disk is touched — the folder, cron
job, skills, and memory block all stay. To actually remove installed
files, use 'Uninstall Template…' instead." Sidebar "-" also gains a
help tooltip saying the same thing.
- Post-uninstall preserved-files banner. When the uninstaller keeps
the project directory (because the cron wrote a status-log.md or the
user dropped files in there), the success view now shows an orange
banner listing up to 8 preserved paths with a "+N more…" tail, plus
a one-line explanation and a pointer to delete the folder from
Finder if the user doesn't want those files. VM captures the
preservation shape before nil'ing `plan` on success.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
A Scarf project dashboard that includes at least one webview widget
automatically exposes a Site tab next to the Dashboard tab. Adding a
"Live Site Preview" section with a webview widget gives this template
that tab out of the box.
The cron job + AGENTS.md now tell the agent to rewrite the webview's
`url` field to the first entry in `values.sites` on each run, so the
Site tab renders whatever the user actually configured instead of the
GitHub placeholder. If `values.sites` is empty, the webview URL is
left untouched.
Swift example test updated to assert 4 sections (was 3) plus the new
webview widget's presence + title; bundle + catalog rebuilt.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Two fixes chained from manually testing site-status-checker v1.1.0.
---
Cron Run now was a no-op when the Hermes gateway scheduler wasn't
already running. `hermes cron run <id>` only marks a job as due on
the next scheduler tick — it doesn't execute. During dev or right
after install (gateway stopped, as the logs the user pasted showed),
the user's click resulted in nothing happening: job queued, tick
never comes, zero agent sessions, zero output, dashboard never
updates. Exactly the failure mode they hit.
Fix: CronViewModel.runNow now calls `hermes cron run <id>` followed
by `hermes cron tick` after a short delay. `tick` runs all due jobs
once and exits — so the just-queued job actually executes, and
exits cleanly whether the scheduler is running or not. Redundant
(not duplicative) when the gateway is live. The user sees a status
message whether it succeeded or failed instead of silent nothing.
---
Markdown rendering in install-sheet screens. Template READMEs,
manifest descriptions, field help text, and cron prompts all
reasonably contain markdown — but the install preview sheet was
rendering everything as plain text, so `[Create one](https://…)`
would appear verbatim instead of as a link, `# Site Status Checker`
as a literal pound sign, etc.
New Features/Templates/Views/TemplateMarkdown.swift — a tiny,
dependency-free markdown renderer scoped to what template authors
actually write:
- Headings (#..######) → larger bold Text with vertical spacing
- Bullet and numbered lists → hanging-indent rows with •/1. prefix
- Fenced code blocks (```) → monospaced with quaternary background
- Paragraphs → regular Text, with inline formatting via SwiftUI's
built-in AttributedString(markdown:) so **bold**, *italic*,
`code`, and [links](urls) work
- Blank lines separate blocks
Two entry points: `TemplateMarkdown.render(_ source:)` returns a
View for multi-block content (README preview), and
`TemplateMarkdown.inlineText(_ source:)` returns a Text for
one-line strings where block structure doesn't apply (field
descriptions, manifest tagline).
Wired into:
- TemplateInstallSheet.readmeSection — was plain Text(readme), now
renders the full README with structure.
- TemplateInstallSheet.manifestHeader description — inline-only
(taglines rarely have block structure).
- TemplateInstallSheet.cronSection — new DisclosureGroup per cron
job exposes the full prompt with markdown rendering. Users can
now verify what the installer will register with Hermes before
clicking Install. {{PROJECT_DIR}} / {{TEMPLATE_ID}} tokens show
unresolved here; they get substituted when the installer calls
hermes cron create.
- TemplateConfigSheet field descriptions — inline markdown so
`[Create a token](https://...)`-style links render as real links.
Not a full CommonMark implementation — no tables, no blockquotes,
no images, no HTML passthrough. Those can evolve as templates need
them. Safe with untrusted input: never executes scripts or renders
raw HTML.
Scope stays tight: 57/57 Swift tests + 24/24 Python tests still pass.
No new tests for the markdown helper itself — rendering is visual,
hard to unit-test meaningfully without snapshot-testing infra, and
the surface is small enough that changes would be caught by the
visual regression of any template install.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Hermes doesn't set a working directory when firing cron jobs, so any
relative path in a template's cron prompt (`.scarf/config.json`,
`status-log.md`, etc.) resolves against whatever dir Hermes happens
to be in — NOT the installed project. Practical effect: site-status-
checker's cron job fires, agent runs with relative paths, finds
nothing to read, silently bails. User sees "Run now" complete with
zero output and nothing updated on disk.
Fix: the installer now substitutes template-author placeholders in
cron prompts at install time, before calling `hermes cron create`.
The registered cron job carries a fully-qualified, CWD-independent
prompt.
Supported tokens (deliberately few — each is part of the template
format contract from now on):
- `{{PROJECT_DIR}}` — absolute path of the installed project dir.
The one that was motivating this fix; required for any cron prompt
that reads or writes project files.
- `{{TEMPLATE_ID}}` — the `owner/name` from the manifest, for
templates that want to tag delivery payloads or log lines.
- `{{TEMPLATE_SLUG}}` — the sanitised slug used by the installer for
dir name + skills namespace, for templates that want to reference
their skills install path.
Implemented as a static `ProjectTemplateInstaller.substituteCronTokens`
so it's testable as a pure function. Unsupported placeholders pass
through verbatim — template authors notice in testing that their
token didn't get replaced and either use a supported one or file
a request.
Site Status Checker v1.1.0 updated to use the tokens:
- cron/jobs.json prompt now opens with "Run the site status check
for the Scarf project at {{PROJECT_DIR}}" and references
{{PROJECT_DIR}}/.scarf/config.json, {{PROJECT_DIR}}/status-log.md,
and {{PROJECT_DIR}}/.scarf/dashboard.json explicitly.
- AGENTS.md gains a note explaining that the cron-registered prompt
carries absolute paths (installer substitutes at install time),
while interactive-chat agents can keep using relative paths.
- bundle rebuilt, catalog regenerated.
templates/CONTRIBUTING.md documents the three supported tokens under
the cron/jobs.json bullet so future authors don't have to discover
this by hitting the same CWD bug.
Tests:
- ProjectTemplateExampleTemplateTests.siteStatusCheckerParsesAndPlans
extended to assert the bundled prompt contains {{PROJECT_DIR}}
UNRESOLVED. If someone accidentally bakes an absolute path into
the template (their install dir), every user of that template
would get the wrong path — this test catches that.
- Four new substitution tests in ProjectTemplateInstallerTests:
resolves PROJECT_DIR / resolves ID + SLUG / leaves unknown tokens
untouched / substitutes repeated occurrences. All go through the
static helper directly; no install round-trip needed.
57/57 Swift tests + 24/24 Python tests pass. Catalog check clean.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The template's dashboard shipped with two hardcoded example URLs
(https://example.com + https://example.org) baked into a "Configured
Sites" list widget, and the widget title still said "from sites.txt"
— stale from the v1.0.0 layout before we moved to config.json.
After the v1.1.0 configure-on-install flow lands, the user fills in a
real sites list through the Configure form (which correctly lands in
`.scarf/config.json` — the editor modal confirms that), but the
dashboard still rendered the baked-in example URLs. The agent would
overwrite them on the first cron run, but until then the dashboard
misrepresents reality.
Two orthogonal paths to fix this — populate the dashboard's items
from config.json at install time (requires Scarf-side template-value
interpolation, which is a v2.3.1 feature), or ship a dashboard that
clearly advertises "nothing has run yet." Taking the second path for
v1.1.0: replace the example URLs with a single placeholder row with
status "pending" pointing the user at running the check. The agent
replaces the row with real data on the first cron run.
Also: widget title fixed ("Watched Sites (populated after first run)"
instead of the stale sites.txt reference), top-of-dashboard description
updated, and the Quick Start text now mentions the Configuration
button as the way to set sites, not the long-gone sites.txt.
Bundle + catalog rebuilt; ProjectTemplateExampleTemplateTests still
passes (it asserts against cron prompt + schema shape, not dashboard
content, so the dashboard edit doesn't affect it).
---
Secondary fix: test deflake from the saveRegistry throw change.
Making saveRegistry throw exposed a pre-existing parallel-test race:
three suites (ProjectTemplateInstallerTests,
ProjectTemplateUninstallerTests, ProjectTemplateConfigInstallTests)
all write to the real `~/.hermes/scarf/projects.json`. Swift Testing's
`.serialized` trait only serializes within a single suite — multiple
suites still run in parallel. Before, writes silently failed on the
racing-loser side and tests passed by accident; now the loser's test
throws "couldn't be saved in the folder 'scarf'".
Added TestRegistryLock — a module-level NSLock that all three suites'
snapshotRegistry/restoreRegistry helpers share. acquireAndSnapshot()
locks + reads; restore(_:) writes + unlocks. The paired
snapshot-in-test-body / defer-restore pattern keeps acquire + release
balanced. Replaced the three per-suite copies of the helpers with
thin delegates to the shared lock.
Verified by running the full test suite 3 consecutive times: 53/53
tests pass each run, no flakes.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Two bugs chained into the observed "install completed but project
didn't show up" report. Either one would have been enough on its own;
both are here so both are fixed.
Primary bug: TemplateConfigSheet's Cancel + Continue buttons each
called `@Environment(\.dismiss)` after their state-update callbacks.
That was fine when the sheet is presented standalone (the post-install
Configuration button uses it this way and wants dismissal), but Phase C
also INLINED the same view inside TemplateInstallSheet.configureView
for the install flow's .awaitingConfig stage — there's no intermediate
.sheet() presenter there, so `dismiss()` resolved to the OUTER install
sheet. Clicking Continue → configure form's `onCommit` fired
`installerViewModel.submitConfig(values:)` which advanced stage to
.planned, then the dismiss() closed the whole install sheet before
the preview ever rendered. install() was never called.
Fix: remove both dismiss() calls from TemplateConfigSheet. Dismissal
is now the caller's responsibility. ConfigEditorSheet (standalone
mode) already calls `dismiss()` inside its own onCancel closure and
lets the .succeeded state's Done button handle commit-dismissal, so
nothing breaks there. The install flow's state machine advances to
the preview stage where the existing Install/Cancel buttons drive
everything from there.
Secondary bug (latent, same class): ProjectDashboardService.saveRegistry
swallowed both directory-creation and file-write errors with `try?`.
If the `~/.hermes/scarf/` dir creation or projects.json write ever
failed for any reason (permissions, readonly filesystem, sandbox),
the installer's registerProject returned a valid-looking ProjectEntry
while the registry on disk never received the row. Same symptom
surface as the primary bug: install "succeeds," project invisible.
Fix: saveRegistry now throws. Updated all four callers:
- ProjectTemplateInstaller.registerProject: `try` — a registry
write failure aborts install with a user-visible failure screen.
This is the critical path; silent success on a destructive op is
the exact failure mode we want to eliminate.
- ProjectTemplateUninstaller: `do/catch` + logger.warning — we're at
the final step of uninstall after every other side effect has
already completed (files removed, skills removed, cron removed,
memory stripped, Keychain cleared). Leaving a stale registry row
pointing at a deleted project is cosmetic and easy to fix from
the sidebar minus button.
- ProjectsViewModel.addProject + removeProject: `do/catch` +
logger.error. The VM doesn't currently have a surface for
user-visible errors (no toast/alert on this view), but the
failure now at least lands in the unified log instead of
disappearing. Proper in-UI error surface is tracked as follow-up.
- ProjectDashboardService.loadRegistry: switched its stale `print`
to `logger.error` while I was in the file.
Tests: added TemplateInstallerViewModelTests suite (3 tests) covering
the install VM's configure-step state transitions:
- submitConfigStashesValuesAndTransitionsToPlanned — .awaitingConfig
→ .planned + configValues stash on the plan. The exact transition
that the dismiss() bug tore down mid-flight.
- cancelConfigReturnsToAwaitingParentDirectory — back-button behaviour
with plan preserved so re-entry doesn't re-run buildPlan.
- submitConfigNoOpWhenPlanIsNil — defensive guard.
These won't catch a view-level regression (Swift Testing doesn't do
UI tests in this project), but they lock in the VM state-machine
contract so the next refactor can't silently break submitConfig or
cancelConfig without failing CI.
53/53 Swift tests + 24/24 Python tests + catalog validator clean.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
First real exercise of the v2.3 configuration feature. The template no
longer asks the agent to bootstrap sites.txt on first run — instead,
users enter their list of URLs through the Configure form during
install, and change them later via the dashboard's Configuration
button. This makes the template a complete round-trip test of the
new feature end-to-end.
Schema (manifest.config.schema):
- `sites` — list<string>, required, 1–25 items, default two example
URLs. This is the list the cron job hits.
- `timeout_seconds` — number, 1–60, default 10. Per-URL HTTP timeout.
- `modelRecommendation.preferred = claude-haiku-4` — rationale: simple
tool-use task, Haiku is cost-effective for daily cron.
Manifest bumped: schemaVersion 1 → 2, version 1.0.0 → 1.1.0,
minScarfVersion 2.2.0 → 2.3.0, contents.config = 2.
AGENTS.md rewritten for the config-driven flow:
- Reads values from `.scarf/config.json` at run time (values.sites +
values.timeout_seconds). No more sites.txt bootstrap.
- "Add a site" / "Remove a site" no longer mean the agent edits a
file — they mean "open the Configuration button on the dashboard."
The agent points the user there rather than trying to mutate
config.json itself. A future Scarf release may expose a tool for
agents to write config programmatically; until then, config is
strictly a user action.
- First-run bootstrap now only creates status-log.md (if absent).
README.md rewritten to walk users through the new form-based flow,
explain the Configuration button, and document the model
recommendation. Uninstall instructions point at the right-click
Uninstall Template action rather than manual steps.
Cron prompt updated to reference config.json (values.sites,
values.timeout_seconds) instead of sites.txt.
ProjectTemplateExampleTemplateTests.siteStatusCheckerParsesAndPlans
extended with v2-specific assertions: manifest.schemaVersion == 2,
contents.config == 2, schema.fields.count == 2, per-field
constraints (sites type/itemType/minItems/maxItems, timeout
min/max), modelRecommendation.preferred, plan.configSchema +
plan.manifestCachePath are populated, plan.projectFiles includes
both config.json + manifest.json destinations. Cron-prompt assertion
swapped from sites.txt to config.json/values.sites.
Three suites that touch ~/.hermes/scarf/projects.json now carry
.serialized — the new Phase B install-with-config tests stressed the
parallel-execution race in the snapshot/restore helpers. Serializing
within each suite deflakes without any architectural change.
Swift 50/50, Python 24/24, catalog validator accepts the upgraded
bundle. Site detail page now has manifest.json for renderConfigSchema
to pick up.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
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).
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>
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>
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>
CLAUDE.md's release-notes convention says "write them to
releases/v<version>/RELEASE_NOTES.md BEFORE running the script" — but
the script's git-clean preflight rejected any working-tree state
including that exact file as untracked. Chicken-and-egg: you couldn't
follow the documented flow.
Preflight now whitelists releases/v<VERSION>/RELEASE_NOTES.md as the one
allowed untracked path. Everything else still fails the check.
Caught while running v1.6.2.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Stops the release script's git-clean preflight from tripping on the
zips + appcast-entry.xml that every release run produces under
releases/v<VERSION>/. GitHub Releases hosts the actual downloads; there's
no reason to commit ~30 MB of binaries per release into git history.
RELEASE_NOTES.md stays tracked — it's committed as part of the version
bump by the release script.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Adds a Releases section so future Claude sessions (and teammates) don't
have to rediscover the release workflow. Documents:
- The single entry point: `./scripts/release.sh <ver> [--draft]`
- What the script does end-to-end
- The release notes convention (write them before running)
- A handful of canonical prompts the user can type
- Pointers to deeper prerequisite docs (README, script header)
Deliberately brief — detail lives in README and the personal auto-memory
at reference_release_process.md. CLAUDE.md's job here is just to make
the entry point discoverable on session start.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
The orange "No AI provider credentials detected" banner was firing on the
Chat tab whenever no session was selected, even for users whose
credentials were configured and working. The banner only disappeared
when a session started — not because credentials were actually found,
but because the banner's `!hasActiveProcess` gate flipped to false once
ACP launched.
Root cause: `HermesFileService.hasAnyAICredential()` inspected only the
shell environment and `~/.hermes/.env`, while Hermes itself resolves
credentials from two additional places Scarf had never learned about:
- `~/.hermes/auth.json` — the Credential Pools file written by the
Configure → Credential Pools UI (the blessed v1.6 flow)
- `~/.hermes/config.yaml` — embedded `api_key:` under auxiliary.<task>
and delegation
The preflight now checks all four locations. For auth.json we parse the
JSON and look for any `credential_pool.<provider>[*].access_token` that
is non-empty. For config.yaml we line-scan for `api_key:` leaves with a
non-empty value, matching the defensive style of the existing .env
scanner (no YAML parser needed in a nonisolated function).
Also updated the banner subtitle to point users at Credential Pools
before .env, since the former is the blessed in-app flow.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Each release now produces two distribution zips:
- Scarf-vX.X.X-Universal.zip (arm64 + x86_64, recommended)
- Scarf-vX.X.X-ARM64.zip (arm64 only, ~14% smaller)
Both are independently archived, exported with Developer ID, notarized,
and stapled via a new build_variant helper. The appcast still points at
the Universal zip since it works on all supported macs; ARM64 is an
alternative manual download for Apple Silicon users who want the smaller
file.
README updated to list both variants.
Prompted by the v1.6.1 release shipping only Universal; the ARM64 zip
for v1.6.1 was produced ad-hoc and uploaded to the existing release.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Drafts skip the appcast push and main tag, so a draft release won't
show up in users' Sparkle update feed and v1.6.0 stays "latest" until
explicitly promoted. The signed appcast entry is saved to the release
dir for later manual promotion.
Also adds release notes file convention: releases/v<VERSION>/RELEASE_NOTES.md
is auto-included in the version-bump commit and used as the GitHub
release body.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Xcode exports the bundle as scarf.app because PRODUCT_NAME = $TARGET_NAME
and the target is lowercase "scarf". Users expect Scarf.app in their
/Applications folder. Renaming the bundle wrapper preserves the
signature (codesign signs contents, not the wrapper directory name).
Caught during a build+sign+verify dry run before the first notarized
release.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
A fresh-install user reported Scarf chat only worked while `hermes chat`
was also running in Terminal. ACP connected successfully but sending a
message errored. `~/.hermes/logs/errors.log` showed the real cause:
RuntimeError: No Anthropic credentials found. Set ANTHROPIC_TOKEN or
ANTHROPIC_API_KEY, run 'claude setup-token', or authenticate with
'claude /login'.
The terminal workaround masked the bug because the terminal-launched
`hermes` inherits the user's shell env (ANTHROPIC_* exports, Keychain
session) while a Finder/Dock-launched Scarf subprocess does not.
Scarf's previous PATH-only enrichment (commit b2a29ab) fixed binary
discovery but not credential propagation.
Five changes:
1. Propagate credential env vars from the login shell.
HermesFileService.enrichedEnvironment() now harvests a conservative
allowlist of AI-provider keys (ANTHROPIC_API_KEY/TOKEN/BASE_URL,
OPENAI_*, OPENROUTER_*, GEMINI/GOOGLE/GROQ/MISTRAL/XAI API keys,
CLAUDE_CODE_OAUTH_TOKEN) alongside PATH. Uses one `zsh` probe with
null-delimited `printf` so values with newlines survive, cached for
the process lifetime.
2. Two-attempt shell probe catches nvm/asdf/mise PATH.
Previous `zsh -l` missed `.zshrc`-exported PATH (nvm). New probe
first tries `zsh -l -i` (login + interactive, sources .zshrc) with
prompt frameworks defanged (TERM=dumb, empty PS1/PROMPT,
POWERLEVEL9K_INSTANT_PROMPT=off, STARSHIP_DISABLE=1,
ZSH_DISABLE_COMPFIX=true) and a 5s timeout; falls back to `zsh -l`
with 3s; finally to hardcoded defaults.
3. Resolve `hermes` binary across install locations.
HermesPaths.hermesBinary is now computed, walking pipx
(~/.local/bin), Apple Silicon brew (/opt/homebrew/bin), Intel brew
/ manual (/usr/local/bin), and ~/.hermes/bin. Returns the first
executable match or the pipx default for "Expected at …"
diagnostics. All 10+ callsites (ACPClient, scarfApp, Health /
Gateway / Tools / Sessions / QuickCommands / Personalities /
Settings / WhatsAppSetup / OAuthFlow / CredentialPools
ViewModels) auto-migrate with zero edits.
HermesFileService.hermesBinaryPath() shares the same candidate
list as the source of truth.
4. Surface the real failure in the chat UI.
ACPClient keeps a 50-line ring buffer of subprocess stderr
(previously only sent to os_log). New ACPErrorHint.classify pattern-
matches the common fresh-install failures — "No credentials found",
"No such file or directory: 'npx'", rate-limit — and returns a short
human hint. ChatView gains an errorBanner between toolbar and chat
area showing the hint + raw message + a "Show details" disclosure
with the stderr tail in a selectable monospaced view, plus a
clipboard-copy button.
5. Preflight credential check.
HermesFileService.hasAnyAICredential() scans the enriched env and
~/.hermes/.env for any known provider key. ChatViewModel exposes
`missingCredentials`; the banner becomes a pre-emptive warning
("No AI provider credentials detected — add ANTHROPIC_API_KEY to
~/.hermes/.env or your shell profile") before the user even hits
Send. HermesFileWatcher already watches ~/.hermes/.env, so edits
re-trigger preflight automatically.
Incidental cleanup: recordACPFailure(_:client:context:) folds the
per-site `logger.error` calls, removing three `_ = msg` suppressions.
Dead `enrichedPath` alias removed.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Adds Sparkle 2 auto-updates and a local release script that produces
signed, notarized, stapled builds for GitHub distribution. App Store
submission was rejected because Scarf spawns the user-installed hermes
binary and reads ~/.hermes/ directly — both forbidden by App Sandbox —
so we commit to the GitHub-release path properly.
- Sparkle SPM dep wired into the app target (link-only; hardened-runtime
entitlement disable-library-validation lets Sparkle load at runtime).
- Tracked Info.plist with SUFeedURL, SUPublicEDKey, and daily check
interval; replaces the auto-generated plist so Sparkle keys live in
version control rather than pbxproj INFOPLIST_KEY_* noise.
- UpdaterService wraps SPUStandardUpdaterController and is injected via
.environment(). Menu bar, standard app menu (CommandGroup after
.appInfo), and a new Updates section in Settings → General each call
updater.checkForUpdates().
- scripts/release.sh runs the full pipeline: version bump → universal
archive → Developer ID export → notarytool submit (keychain profile
scarf-notary) → staple → appcast EdDSA sign → gh-pages push → gh
release → tag. scripts/ExportOptions.plist pins manual Developer ID
signing for team 3Q6X2L86C4.
- README: removes the right-click-Open workaround (notarized builds
don't need it), notes Sparkle, adds a Releases section describing
the pipeline and signing prerequisites.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Reorganize the Features section to match the app's sidebar (Monitor, Interact,
Configure, Manage, Project Dashboards, System) so readers find features the
same way they find them in the app. Add a "What's New in 1.6" callout with
links to the release notes.
Binaries: ARM64 (15 MB) and Universal (19 MB). Both signed with the Apple
Development identity (Team 3Q6X2L86C4). Universal contains both arm64 and
x86_64 slices verified with lipo.
Major expansion of Scarf's Hermes platform coverage. Settings is now a 10-tab
layout exposing ~60 previously hidden config fields. A new "Configure" sidebar
section groups per-platform setup, personality management, quick commands,
credential pools, plugins, webhooks, and profile switching.
## Highlights
- **Platforms feature** — Native GUI setup for all 13 messaging platforms
(Telegram, Discord, Slack, WhatsApp, Signal, Email, Matrix, Mattermost,
Feishu, iMessage, Home Assistant, Webhook, CLI). Per-platform forms write
credentials to ~/.hermes/.env and behavior toggles to ~/.hermes/config.yaml.
WhatsApp and Signal use an inline SwiftTerm terminal for QR/link pairing.
- **Credential Pools** — Provider-aware add/remove with proper type handling.
OAuth flow uses Process + pipes to extract the authorization URL, open the
browser explicitly, and accept the code via a form field. Fixes the Anthropic
OAuth failure where the code had nowhere to be entered.
- **Model Picker** — Hierarchical provider -> model picker backed by
~/.hermes/models_dev_cache.json (111 providers, every major model). Used in
Settings -> General and Delegation. "Custom..." escape hatch for unlisted IDs.
- **Settings as tabs** — 10 tabs (General, Display, Agent, Terminal, Browser,
Voice, Memory, Aux Models, Security, Advanced). HermesConfig grew from 32 to
~90 fields via grouped sub-structs. All new fields round-trip through
`hermes config set`.
- **Extended existing features** — Cron (create/edit/pause/resume/run-now/
delete), Skills (Browse Hub + Updates tabs), Health (run `hermes dump` and
`hermes debug share` with confirmation dialog), Sessions (rename/delete/
export/export-all).
## Bug fixes
- Tools platform picker showed only CLI (was reading a nonexistent
`platform_toolsets:` YAML section). Now enumerates KnownPlatforms.all with
live connectivity dots from gateway_state.json.
- Credentials add with --api-key was triggering OAuth for providers like
Anthropic because --type was missing. Now always passes --type api-key.
- Remove-by-index used 0-based indexing; hermes CLI expects 1-based. Fixed.
- Various CLI parser fragility issues (plugins, profiles, skills hub, webhooks)
replaced with structured file reads or proper box-drawn table parsers.
## New core services
- HermesEnvService — reads/writes ~/.hermes/.env atomically, preserves
comments, commented-out keys get enabled in-place on save, values with
spaces/specials get quoted, unset commented out (non-destructive).
- ModelCatalogService — decodes the models.dev cache into typed providers and
models with context/cost/release-date metadata.
- OAuthFlowController — manages the OAuth Process subprocess: extracts the
auth URL via regex, opens the browser, pipes the code back via stdin,
detects success/failure markers in output.
## New sidebar structure
Monitor / Projects / Interact / **Configure (new)** / Manage
The Configure section gathers the setup-style features that used to require
the CLI: Platforms, Personalities, Quick Commands, Credential Pools, Plugins,
Webhooks, Profiles.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Two related bugs surfaced when testing MCP servers that spawn npx, node,
python, etc. from Homebrew/nvm/asdf/mise installs.
1. MCP test reported success even when the connection failed.
`hermes mcp test <server>` exits 0 even when the inner connection
fails — it prints the error to stdout instead. Scarf trusted the
exit code and rendered a green checkmark while the output said
"✗ Connection failed: [Errno 2] No such file or directory: 'npx'".
Fix: also scan output for ✗, "Connection failed", "No such file or
directory", and "Error:" markers.
2. .app launches start with a minimal PATH that excludes Homebrew.
When Scarf is launched from Finder/Dock, ProcessInfo's PATH is
`/usr/bin:/bin:/usr/sbin:/sbin` — no /opt/homebrew/bin, no
/usr/local/bin, no nvm/asdf/mise shims. Hermes inherits this and
can't find npx/node/python when spawning MCP server subprocesses.
Fix: query the user's login shell PATH once via `/bin/zsh -lc 'echo
$PATH'`, cache it on HermesFileService, and inject it into both
`runHermesCLI` and the ACP subprocess. Falls back to a sane default
covering both Apple Silicon and Intel Homebrew if zsh query fails.
Bumps version to 1.5.8 (build 10). Includes signed Universal + ARM64
binaries.
Fixes a bug where adding a second MCP server caused the first to disappear
from the list view, and any args containing YAML reserved characters (e.g.
"@modelcontextprotocol/server-fetch") corrupted the config file.
Three root causes in HermesFileService MCP YAML patching:
1. extractMCPBlock extended through trailing comments to EOF when
mcp_servers was the last top-level key in config.yaml. Trailing
comments became part of the "block", so subsequent inserts landed
at end-of-file rather than inside the entry.
2. patchMCPServerField's entry boundary similarly absorbed trailing
blanks/comments, making the entry "own" everything until the next
sibling — or until EOF for the last entry.
3. yamlScalar did not quote values starting with YAML reserved
indicators (@ * & ? | > ! % , [ ] { } < ` ' "). Args like
"@modelcontextprotocol/server-fetch" were written bare, producing
invalid YAML that broke subsequent reads/writes.
Fix: trim trailing blanks/comments off both the block and the entry
in the locator/extractor; quote any scalar starting with a reserved
first character.
Bumps version to 1.5.7 (build 9). Includes signed Universal + ARM64
binaries.
Note: users with an already-corrupted ~/.hermes/config.yaml from the
1.5.6 bug should manually clean up their mcp_servers block (delete the
orphan args at end of file) before upgrading. New writes will be clean.
The first 1.5.6 zips contained `linker-signed` bundles with no Sealed
Resources, plus a stray nested scarf.app from a case-insensitive cp.
macOS Gatekeeper rejected the ARM64 download as "damaged"; the
Universal one ran only because the user had already trusted it.
Now both bundles are properly ad-hoc-signed (`Sealed Resources
version=2`) with the hardened runtime preserved. Sizes dropped
significantly (Universal 33MB→16MB, ARM64 27MB→13MB) because the
nested junk is gone.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Includes the MCP Servers management UI shipped in 219bca2:
- Add via curated presets (GitHub, Linear, Notion, Sentry, Stripe, …)
or fully custom (stdio command + args, or HTTP URL with bearer auth)
- Per-server detail view: enable/disable, env + headers editor,
tool include/exclude filters, resources/prompts toggles, request
and connect timeouts, OAuth token detection + clearing
- One-click "Test Connection" runs `hermes mcp test` and surfaces
the discovered tool list
- Gateway-restart banner after config changes that need a reload
README updated with the MCP Servers section, the new MCPServers/
feature module entry, and the `hermes mcp` + `mcp-tokens/` entries
in the Data Sources table.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Ship Hermes v0.9.0 compatibility plus new features (log component
filter, session pill, Fast Mode, Backup/Restore, iMessage, /compress,
Discord threads). README lists both universal and ARM64 downloads.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- Log parser: session-ID tag in v0.9.0 log format is now an optional
capture group; session pill renders inline and tap-filters the view.
- Logs: component filter (Gateway/Agent/Tools/CLI/Cron) and bounded
logger column with middle truncation.
- Gateway stop: uses `hermes gateway stop` CLI (v0.9.0's launchctl
bootout fix) with SIGTERM as fallback.
- HermesConfig: new keys for Fast Mode (service_tier), gateway notify
interval, force IPv4, context engine, interim assistant messages,
and Honcho eager init (camelCase per PR #6995).
- Settings: new Performance, Network, Advanced, and Backup & Restore
sections that call `hermes backup` / `hermes import` off the main
actor; robust zip-path extraction via regex.
- Platforms: iMessage (BlueBubbles) added to KnownPlatforms and
icon map.
- Cron: Discord thread delivery (`discord:chat:thread`) renders as
"Discord thread X in Y".
- Chat: `/compress <focus>` button appears when ACP advertises the
command; optional focus sheet sends through existing prompt path.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Add cumulative token tracking from ACP prompt results with fallback
display when DB has no data yet. Improve scroll-to-bottom reliability
with an external trigger for "Return to Active Session" and onAppear
auto-scroll. Show per-session cost in the dashboard session list.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Synchronous Process.run()/waitUntilExit() calls on the main thread blocked
SwiftUI's render loop, causing toggle controls to appear as solid blue
rectangles instead of proper switches. All hermes subprocess and file I/O
calls are now async via Task.detached, toggle uses optimistic state update
for immediate visual feedback, and pipe file handles are properly closed.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Sessions were silently dying and losing chat history because:
- Pipe write errors (EPIPE) were completely undetected — broken pipe
writes via Task.detached { handle.write() } failed silently, leaving
the app unaware the subprocess had crashed
- Reconnection fell back to newSession() when loadSession() failed,
creating a blank session and permanently losing all conversation context
- No message reconciliation after reconnect — DB-persisted messages
were never re-fetched, so the UI stayed stale/incomplete
- Keepalive sent bare "\n" which caused json.loads("") parse errors
in the ACP library every 30 seconds, destabilizing the connection
- TERM=xterm-256color was set on a pipe-based subprocess, risking
terminal escape sequence pollution in the JSON-RPC stream
Fixes:
- Replace FileHandle.write() with POSIX Darwin.write() + SIGPIPE
suppression for immediate broken-pipe detection at all write sites
- Send valid JSON-RPC notification {"jsonrpc":"2.0","method":"$/ping"}
as keepalive instead of bare newlines
- Never fall back to newSession() during reconnection — try
resumeSession then loadSession, fail visibly if both fail
- Add reconcileWithDB() to merge DB-persisted messages with local
state after successful reconnection
- Finalize streaming messages immediately on disconnect so partial
content is preserved before reconnection begins
- Use SIGINT instead of SIGTERM for graceful Python subprocess shutdown
- Remove TERM env var from ACP subprocess environment
- Consolidate disconnect cleanup into single idempotent method
- Add isHandlingDisconnect guard against double-handling
- Increase reconnect attempts from 3 to 5 with capped backoff
- Add "Reconnect" button to toolbar error state
Also: bump version to 1.5.1, set deployment target to macOS 14.6
(Sonoma), and update README with rich chat/ACP features, process
controls, skill editing, and corrected system requirements.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Implement a rich chat interface powered by the Hermes ACP (Agent
Communication Protocol) over JSON-RPC stdio pipes, with comprehensive
connection stability:
- ACPClient actor: manages hermes acp subprocess lifecycle, JSON-RPC
transport, event streaming via AsyncStream, and session management
- ACPMessages: full event parsing for message chunks, thought chunks,
tool calls, permission requests, and prompt completion
- RichChatViewModel: streaming message display with live updates,
tool result rendering, and message grouping
- ChatViewModel: ACP session orchestration, auto-start on first
message, and terminal mode fallback
Connection stability fixes:
- Non-blocking pipe writes via Task.detached to prevent actor deadlock
- Read loop cleanup (handleReadLoopEnded) finishes event stream and
fails pending requests on EOF instead of hanging silently
- 30s request timeouts on control messages via watchdog Task pattern
- Keepalive: writes \n to stdin every 30s to detect dead processes
via EPIPE before the next user action
- Health monitor: polls process.isRunning every 5s as belt-and-suspenders
- Auto-reconnect: retries up to 3 times with exponential backoff
(1s/2s/4s), restores session, only shows error after all retries fail
- connectionLost event displays system message in chat on failure
- Proper stderr pipe management: stored task reference, closed in stop()
- Idempotent cleanup across handleReadLoopEnded, handleTermination,
and handleConnectionDied via actor serialization and nil guards
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Replace inline AttributedString(markdown:) in RichMessageBubble with
the shared MarkdownContentView for consistent styled rendering of
headers, lists, blockquotes, and inline formatting in chat messages.
Code blocks continue to use CodeBlockView with its copy button.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Add rich chat interface with iMessage-style message bubbles, terminal
toggle, session info bar, code block rendering with copy button, and
tool call cards. Supports both terminal and rich chat display modes.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Add tool result display to the Activity detail pane. When selecting a
tool call, the inspector now shows Arguments → Output → Assistant
Message, giving full visibility into what was requested, what came back,
and how the assistant interpreted it.
- Add fetchToolResult(callId:) query to HermesDataService
- Fetch tool result on entry selection in ActivityViewModel
- Display output in styled monospaced box in detail pane
- Render assistant message with MarkdownContentView
Closes#12
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
The config.yaml uses YAML empty string literal (provider: '') which the
parser reads as the literal string '' rather than an empty string. Strip
surrounding quotes before checking so '' and "" are treated as empty.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Add a custom MarkdownContentView that renders markdown with visual
styling — large headers, styled code blocks with language labels,
bullet and numbered lists, blockquotes with colored borders, and
horizontal rules. YAML frontmatter in skill files is hidden.
Markdown rendering added to:
- Memory view (MEMORY.md, USER.md) with live preview in editor
- Skills view (.md files) with new edit/save capability
- Session messages (assistant responses)
- Dashboard text widgets
Other changes:
- Shared MarkdownRenderer utility for inline formatting
- Split-pane editors (raw markdown left, live preview right)
- saveSkillContent() in HermesFileService with path validation
- Line breaks preserved in non-markdown content (Key: Value format)
Closes#11
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- Add hermesPID() and stopHermes() to HermesFileService for process
signal management via SIGTERM
- Add process control bar to Health view with running status, PID
display, and Start/Stop/Restart buttons
- Add Start/Stop/Restart Hermes quick actions to menu bar
- Start launches gateway, stop sends SIGTERM, restart combines both
Closes#10
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Introduce a new structured chat view as an alternative to the SwiftTerm
terminal. Users can switch between raw terminal and rich chat modes via a
segmented picker in the toolbar. The rich view polls state.db for messages
and renders them as conversation bubbles with markdown, code blocks,
expandable tool call cards, reasoning sections, and a live session info bar
showing tokens, cost, and model. The terminal process stays alive in both
modes — in rich mode it runs hidden while user input from the text field is
piped to its stdin.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- Auto-detect v0.7.0 database schema with backward compat for older DBs
- Surface reasoning tokens, actual cost, and billing provider from sessions
- Display model reasoning/thinking content in session message bubbles
- Add cost tracking to Dashboard, Insights, and session detail views
- Fix FTS5 search crash on dotted terms (e.g., "config.yaml", "v0.7.0")
- Add missing platforms: Home Assistant, Webhook, Matrix
- Consolidate platform icon mapping into shared KnownPlatforms.icon(for:)
- Map execute_code tool to ToolKind.execute
- Add Settings UI for reasoning effort, approval mode, show cost
- Show memory provider warning when external provider (Honcho) is active
- Replace fragile manual HermesSession init with withTitle() helper
- De-duplicate formatTokens utility function
- Bump version to 1.4.0
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
All builds were reporting version 1.0 because the Xcode project version
was never updated from its default. Fixes#5, fixes#7.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Dashboards with a webview widget now show a tab bar: Dashboard tab
renders all normal widgets, Site tab displays the web content
full-canvas with even margins. Cleaner UX than the split layout.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
New widget type that renders any URL (local dev servers, HTML reports)
directly in the dashboard via WKWebView. Sections with webviews
automatically split layout: grid widgets left, webview right.
Configurable height, non-persistent data store, navigation error logging.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- Replace try? with do/catch and [Scarf] error logging in all service-layer
JSON decoding, file writes, and directory creation
- Extract sqliteTransient constant replacing raw unsafeBitCast(-1, ...) pattern
- Add QueryDefaults and FileSizeUnit enums for all magic numbers
- Guard HOME env var with NSHomeDirectory() fallback instead of force-unwrap
- Add path traversal validation to loadSkillContent()
- Add SessionStats.empty and use it across all initialization sites
- Replace KnownPlatforms array indexing with named .cli constant
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-02 03:15:03 -04:00
628 changed files with 120981 additions and 3079 deletions
All app UI uses the typed token bundle in [scarf/Packages/ScarfDesign/](scarf/Packages/ScarfDesign/) — both the `scarf` and `scarf mobile` targets `import ScarfDesign`. Reach for these tokens before inventing new colors, fonts, or spacings.
- **Colors**: `ScarfColor.accent`, `.foregroundPrimary/Muted/Faint`, `.backgroundPrimary/Secondary/Tertiary`, `.border/.borderStrong`, `.success/.danger/.warning/.info`, `.Tool.bash/edit/search/web/think`. All resolve from `ScarfBrand.xcassets` and adapt light/dark automatically.
- **Typography**: `.scarfStyle(.title2)`, `.scarfStyle(.body)`, `.scarfStyle(.captionUppercase)`, etc. Use these instead of `.font(.system(...))`. Eleven preset styles cover the type scale.
- **Spacing / radius / shadow**: `ScarfSpace.s1...s10` (4/8/12/16/20/24/32/40), `ScarfRadius.sm/md/lg/xl/xxl/pill`, `.scarfShadow(.sm/.md/.lg/.xl)`. Hardcoded `.padding(12)` or `cornerRadius: 8` is a code smell — convert.
- **App icon + accent**: `Assets.xcassets/AppIcon.appiconset/` is the rust set; `Assets.xcassets/AccentColor.colorset` resolves `Color.accentColor` to rust so any unmigrated SwiftUI control still tints correctly.
- **Reference**: full screen mockups live at `design/static-site/ui-kit/*.jsx` (open `design/static-site/index.html` in a browser). The `ScarfChatView.ChatRootView` reference component in the package is a 3-pane chat redesign target — usable for previews but not yet swapped into the live chat (the existing `RichChatView` machinery still owns the real ACP pipeline).
- **Don't**: introduce purple/violet tones (we shifted to rust); use yellow `#F0AD4E` for success (that's `.warning` — `.success` is green); bypass the type scale with `.font(.system(size: 13.5))`; ship terminal/syntax-highlight palettes through ScarfColor (those are content semantics, keep them inline).
### iOS Dynamic Type policy
iOS users can scale text via Settings → Accessibility → Display & Text Size. ScarfFont uses fixed point sizes; adopting it blanket on iOS would regress accessibility on `.accessibility2` / `.xSmall` users. iOS-specific rule:
- **Use `ScarfFont` only for**: status badges, chip labels, intentional-display elements (e.g., onboarding step titles, header chrome that's meant to be a fixed visual size).
- **Keep `.font(.headline)` / `.body` / `.caption` semantic tokens for**: list-row primary + secondary text, body copy, error messages, chat content — anything the user reads.
Decision tree per text element: "is this read for content?" → semantic token. "Is this chrome / a label / a badge?" → ScarfFont.
Mac doesn't have this constraint and adopts ScarfFont everywhere. The iOS app already clamps Dynamic Type at the scene root (`ScarfIOSApp.swift`: `.dynamicTypeSize(.xSmall ... .accessibility2)`) — keep that.
### iOS page chrome
Don't retrofit `ScarfPageHeader` over iOS tab roots. iOS uses `.navigationTitle(...)` + `.navigationBarTitleDisplayMode(.large)` as its native page-header pattern; stacking ScarfPageHeader on top creates double titles. Use ScarfPageHeader only on iOS sub-views without a native large-title bar (rare).
iOS button styling: only swap `.borderedProminent` → `ScarfPrimaryButton`. **Leave `.bordered` native** — it's the iOS convention and inherits rust through `AccentColor.colorset` automatically. Same for `.plain` (used as compact tap targets in lists).
Shipped via a single local script. **Never run manual `xcodebuild archive` / `notarytool` / `gh release create` steps — use the script so nothing is skipped or misordered.**
```bash
./scripts/release.sh <version> # full release: notarize → appcast → gh-pages → tag
./scripts/release.sh <version> --draft # draft: everything builds + notarizes, but appcast/tag are skipped
```
The script bumps version, archives Universal (arm64 + x86_64) + ARM64-only variants, signs with Developer ID, notarizes via `xcrun notarytool` (keychain profile `scarf-notary`), staples, EdDSA-signs the appcast entry with Sparkle's key, pushes the appcast to `gh-pages`, and creates a GitHub release with both zips attached. Draft mode stops after the release is uploaded so the current version stays "latest" until explicitly promoted.
**Release notes convention:** write them to `releases/v<version>/RELEASE_NOTES.md` BEFORE running the script — it's auto-included in the version-bump commit and used as the GitHub release body. If absent, a placeholder is used.
**Canonical prompts (any of these trigger the flow):**
- "Release v1.6.2" — full release
- "Release v1.6.2 as draft" — draft mode
- "Prepare v1.6.2 release notes from recent commits, then release" — generate notes first, then run
**Prerequisites (one-time, already set up on Alan's machine):** Developer ID Application cert in login Keychain (team `3Q6X2L86C4`), notarytool keychain profile `scarf-notary`, Sparkle EdDSA private key in Keychain item `https://sparkle-project.org`, `gh-pages` branch + GitHub Pages enabled. See the header of [scripts/release.sh](scripts/release.sh) and the Releases section in [README.md](README.md) for details.
## Wiki
Public documentation lives in the GitHub wiki at https://github.com/awizemann/scarf/wiki. The wiki is a separate git repo cloned to `.wiki-worktree/` in the repo root (gitignored, sibling to `.gh-pages-worktree/`). Internal dev notes stay in `scarf/docs/`; the wiki is for public-facing reference.
**Update the wiki when:**
- A new feature module is added under `scarf/scarf/scarf/Features/` → extend the relevant User Guide page.
- A new core service is added under `Core/Services/` → extend `Core-Services.md`.
- Architecture changes (AppCoordinator, transport, MVVM-F rule, sandbox) → `Architecture-Overview.md` + the specific sub-page.
- Hermes version bumps in this file → `Hermes-Version-Compatibility.md`.
-`scripts/release.sh` completes a full (non-draft) release → bump latest-version on `Home.md` + append to `Release-Notes-Index.md`.
./scripts/wiki.sh push # runs secret-scan again, then push
```
**Never** commit API keys, tokens, `.env` files, private keys, or real hostnames/IPs to the wiki. The script's two-pass secret-scan blocks common token patterns and a user-maintained blocklist at `scripts/wiki-blocklist.txt` (gitignored). Do not bypass without explicit approval. Full workflow on the wiki itself at `.wiki-worktree/Wiki-Maintenance.md`.
## Hermes Version
Targets Hermes v2026.4.30 (v0.12.0). Log lines may carry an optional `[session_id]` tag between the level and logger name — `HermesLogService.parseLine` treats the session tag as an optional capture group, so older untagged lines still parse.
**Capability gating.** Scarf detects the target's Hermes version once per server connection via [HermesCapabilities](scarf/Packages/ScarfCore/Sources/ScarfCore/Services/HermesCapabilities.swift) (`hermes --version` → semver + `YYYY.M.D` parse). The resulting `HermesCapabilitiesStore` is injected on `ContextBoundRoot` (Mac) and `ScarfGoTabRoot` (iOS) via `.environment(_:)` and `.hermesCapabilities(_:)`; UI that depends on a v0.12+ surface (Curator, Kanban, ACP image input, `auxiliary.curator`, `prompt_caching.cache_ttl`, Piper TTS, Vercel terminal) reads it through the typed environment key. Pre-v0.12 hosts gracefully hide the new affordances rather than throwing on unknown CLI subcommands. Add a new flag at the top of `HermesCapabilities` whenever Scarf gains a release-gated UI surface.
- **Autonomous Curator** — `hermes curator` self-prunes / -consolidates the skill library on a 7-day cycle. Reports land at `~/.hermes/logs/curator/run.json` + `REPORT.md`; paths exposed via `HermesPathSet.curatorLogsDir` (`logs/curator`) + `curatorStateFile` (`skills/.curator_state`), with the per-cycle `run.json` / `REPORT.md` resolved at runtime from the `last_report_path` field on the state file. Surfaced in Scarf as a dedicated "Curator" sidebar item under Interact (between Memory and Skills) on Mac, plus a read-mostly iOS panel with Run Now / Pause / Resume actions and inline pin toggles; both gated on `HermesCapabilities.hasCurator`.
- **5 new inference providers** — GMI Cloud, Azure AI Foundry, LM Studio (upgraded to first-class), MiniMax OAuth, Tencent Tokenhub. Mirrored in `ModelCatalogService.overlayOnlyProviders`; the model picker reaches all of them automatically.
- **`flush_memories` aux task removed (server side)** — `auxiliary.flush_memories` is gone from v0.12 Hermes config but remains alive on pre-v0.12 hosts. Scarf preserves `AuxiliarySettings.flushMemories: AuxiliaryModel`, the YAML reader still emits an `aux("flush_memories")` row, and `AuxiliaryTab` only renders the row when `HermesCapabilities.hasFlushMemoriesAux` is `true` (inverse semantics — pre-v0.12 only). v0.12 users never see the row; v0.11 users keep their edit surface.
- **`auxiliary.curator` aux task added** — Curator's review model is configurable independently of the main model. Surfaced in `Settings → Auxiliary` next to the other aux rows.
- **Multimodal ACP `session/prompt`** — ACP advertises and forwards image content blocks. Scarf chat composers (Mac drag/drop + paste; iOS PhotosPicker) attach images that flow through `ACPClient.sendPrompt(sessionId:text:images:)` as `[{"type":"text","text":...}, {"type":"image","data":"<base64>","mimeType":"image/jpeg"}]` — wire shape matches `acp.schema.ImageContentBlock`. `ImageEncoder` downsamples to 1568px long-edge JPEG q=0.85 detached (never blocks MainActor). Gated on `HermesCapabilities.hasACPImagePrompts`.
- **Skills surface:** `hermes skills install <https-url>` direct-URL install (SkillsView "Install from URL…" toolbar button), reload via `hermes skills audit` (Skills "Reload" button — equivalent to the `/reload-skills` slash command for non-ACP contexts), enabled/disabled state read from `skills.disabled` in config.yaml (rendered as strikethrough + "OFF" pill), Curator pin badge from `~/.hermes/skills/.curator_state` (rendered as a pin glyph). The disable-toggle write path is deferred to v2.7 — Hermes only exposes `hermes skills config` as an interactive verb, and Scarf prefers reading accurately to risking a clobbered list.
- **Two new gateway platforms:** Microsoft Teams (19th, plugin-shipped) + Tencent 元宝 / Yuanbao (18th, native). Surfaced in the Mac Platforms tab.
- **Cron upgrades:** per-job `--workdir <abs-path>` (project-aware cwd that pulls AGENTS.md / CLAUDE.md / .cursorrules) is exposed in the editor sheet, gated on `HermesCapabilities.hasCronWorkdir` so pre-v0.12 hosts don't see the field (and a defensive override in `CronView` strips the value before calling `createJob`/`updateJob` even if it was hydrated from a pre-existing job). Pass an empty string on edit to clear an existing workdir, mirroring the `--script` shape. Hermes also added a `context_from` field for chaining cron outputs but only via YAML so far — Scarf reads it (HermesCronJob.contextFrom) but doesn't write it.
- **Settings deltas:** `prompt_caching.cache_ttl` (5m/1h picker), `redaction.enabled` toggle (off-by-default in v0.12 — toggle restores it), `agent.runtime_metadata_footer` toggle, Piper added to TTS provider list, `vercel` added to terminal backend list.
- **Bundled plugins:** Spotify, Google Meet, Langfuse observability, hermes-achievements (visible in Plugins tab).
- **iOS catch-up (Phase H):** read-only Webhooks / Plugins / Profiles tabs (`Scarf iOS/Webhooks/WebhooksView.swift`, `Plugins/PluginsView.swift`, `Profiles/ProfilesView.swift`) parity-match the Mac surfaces but skip mutating CLI verbs. `Scarf iOS/Components/HermesVersionBanner.swift` nudges pre-v0.12 hosts to upgrade (renders only when the connected target is below v0.12).
- **`hermes memory` providers:** honcho, openviking, mem0, hindsight, holographic, retaindb, byterover. `Settings → Memory` lists all providers in the picker; the existing "Run `hermes memory setup` in Terminal" hint stays — `hermes memory setup` is interactive (asks for tokens) so an in-app shellout would surface a frozen UI.
- **Schema is unchanged from v0.11** — same state.db columns (`messages.reasoning_content`, `sessions.api_call_count` introduced in v0.11 remain). No migration needed.
**v2026.4.23 (v0.11.0)** added (historical context, still consumed by Scarf when running against a pre-v0.12 host):
-`/steer <prompt>` — non-interruptive mid-run guidance slash command. Surfaced in Scarf chat menus via `RichChatViewModel.nonInterruptiveCommands`; `ChatViewModel.sendViaACP` (Mac) and `ChatController.send` (iOS) skip the "Agent working…" status flip and show a transient toast instead.
- New CLI subcommands: `hermes plugins` / `profile` / `webhook` / `insights` / `logs` / `memory reset` / `completion` / `dashboard`. Scarf v2.5 adopts **`hermes memory reset`** (toolbar button on MemoryView with destructive confirmation). The other CLIs are documented here for v2.6 — Scarf still reads `~/.hermes/plugins/`, `~/.hermes/profiles/` etc directly today; switching those paths to the canonical CLI is a forward-compatible change to make when bandwidth permits.
- New state.db columns: `messages.reasoning_content` + `sessions.api_call_count`. `HermesDataService.detectSchema` flips `hasV011Schema` only when both are present (partial migrations stay on v0.7 path). Surfaced as the "API" chip on session rows + a network-icon counter in DashboardView. `HermesMessage.preferredReasoning` picks the newer column when both reasoning channels are populated.
- New skills: `design-md` (Google's DESIGN.md authoring; needs `npx`/Node 18+ on host — checked via `SkillPrereqService` and surfaced as a yellow banner) and `spotify` (OAuth via `hermes auth spotify` — driven by `SpotifyAuthFlow` + `SpotifySignInSheet`, mirroring v2.3 Nous Portal pattern).
- SKILL.md frontmatter gains `allowed_tools` / `related_skills` / `dependencies` lists. `HermesSkill` carries them as optional fields; `SkillsView` (Mac) + `SkillDetailView` (iOS) render them as chip rows when populated.
v0.10.0 introduced the **Tool Gateway** — paid Nous Portal subscribers route web search, image generation, TTS, and browser automation through their subscription without separate API keys. In Scarf:
- **Provider picker** ([ModelCatalogService.swift](scarf/scarf/Core/Services/ModelCatalogService.swift)) merges Hermes's `HERMES_OVERLAYS` so Nous Portal and other overlay-only providers (OpenAI Codex, Qwen OAuth, Google Gemini CLI, GitHub Copilot ACP, Arcee) appear alongside the models.dev catalog. Subscription-gated providers sort first and render a "Subscription" pill.
- **Per-task routing** (Auxiliary tab) toggles `auxiliary.<task>.provider` between `nous` and `auto`. Hermes derives gateway routing from provider selection — there is no separate `use_gateway` key.
- **Health surface** ([HealthViewModel.swift](scarf/scarf/Features/Health/ViewModels/HealthViewModel.swift)) adds a synthetic "Tool Gateway" section showing subscription state + `platform_toolsets` mappings + which aux tasks are routed through Nous.
- **Scarf's existing `Gateway` feature is renamed to "Messaging Gateway"** everywhere user-facing to disambiguate from the new Tool Gateway. The `SidebarSection.gateway` enum case and `gateway_state.json` / `gateway.log` paths are unchanged (not user-facing strings).
**Keep `ModelCatalogService.overlayOnlyProviders` in sync** with `HERMES_OVERLAYS` in `~/.hermes/hermes-agent/hermes_cli/providers.py`. When Hermes adds a new overlay-only provider, mirror the entry (display name, base URL, auth type, subscription-gated flag, doc URL) or the picker won't reach it.
## Project Templates
Scarf ships a `.scarftemplate` format (v1 as of 2.2.0) for sharing pre-packaged projects across users and machines. A bundle is a zip containing:
-`AGENTS.md` — required; the [Linux Foundation cross-agent instructions standard](https://agents.md/) — every template is agent-portable out of the box
-`dashboard.json` — copied to `<project>/.scarf/dashboard.json`
-`skills/<name>/…` — optional; installed to `~/.hermes/skills/templates/<slug>/` (namespaced so uninstall is `rm -rf` on one folder)
-`cron/jobs.json` — optional; registered via `hermes cron create` with a `[tmpl:<id>] …` name prefix and immediately paused
-`memory/append.md` — optional; appended to `~/.hermes/memories/MEMORY.md` between `<!-- scarf-template:<id>:begin/end -->` markers
Key services: [ProjectTemplateService.swift](scarf/scarf/Core/Services/ProjectTemplateService.swift) (inspect + validate + plan), [ProjectTemplateInstaller.swift](scarf/scarf/Core/Services/ProjectTemplateInstaller.swift) (execute a plan), [ProjectTemplateExporter.swift](scarf/scarf/Core/Services/ProjectTemplateExporter.swift) (build a bundle from a project), [ProjectTemplateUninstaller.swift](scarf/scarf/Core/Services/ProjectTemplateUninstaller.swift) (reverse an install using the lock file). UI in [Features/Templates/](scarf/scarf/Features/Templates/). The `scarf://install?url=<https URL>` deep link + `file://` URLs for `.scarftemplate` files are handled by [TemplateURLRouter.swift](scarf/scarf/Core/Services/TemplateURLRouter.swift) and `onOpenURL` in `scarfApp.swift`. A `<project>/.scarf/template.lock.json` uninstall manifest is written after every install and drives the uninstall flow.
**Uninstall semantics:** driven by the lock file. Only files listed in `lock.projectFiles` are removed from the project dir; user-added files (e.g. a `sites.txt` created on first run) are preserved. If every file in the dir was installed by the template, the dir is removed too; otherwise the dir stays with just the user's files. Skills namespace is always removed wholesale (it's isolated). Cron jobs are removed via `hermes cron remove <id>` after resolving each lock-recorded name. Memory block is stripped between the `begin`/`end` markers, leaving the rest of MEMORY.md intact. No "undo" — uninstall is destructive; to re-install, run the install flow again. Uninstall UI lives on the project-list context menu and the dashboard header (only shown when the selected project has a lock file).
**Never** let a template write to `config.yaml`, `auth.json`, sessions, or any credential path — the v1 installer refuses. If you extend the format, treat the preview sheet as load-bearing: the user's only trust boundary is that the sheet is honest about everything that's about to be written.
Templates can declare a typed configuration schema in `template.json`'s new `config` block. The installer renders a **Configure** step between the parent-directory pick and the preview sheet; values land at `<project>/.scarf/config.json` (non-secret) and in the login Keychain (secret). A post-install **Configuration** button on the dashboard header (shown when `<project>/.scarf/manifest.json` exists) opens the same form pre-filled for editing.
Supported field types: `string`, `text`, `number`, `bool`, `enum` (with `options: [{value, label}]`), `list` (itemType `"string"` only in v1), `secret`. Type-specific constraints (`pattern`, `min`/`max`, `minLength`/`maxLength`, `minItems`/`maxItems`) are optional. `secret` fields **must not** declare a `default` — the validator refuses.
Key services: [TemplateConfig.swift](scarf/scarf/Core/Models/TemplateConfig.swift) (schema + value models + Keychain ref helpers), [ProjectConfigKeychain.swift](scarf/scarf/Core/Services/ProjectConfigKeychain.swift) (thin `SecItemAdd`/`Copy`/`Delete` wrapper; the only Keychain user in Scarf today), [ProjectConfigService.swift](scarf/scarf/Core/Services/ProjectConfigService.swift) (load/save config.json, resolve secrets, cache manifest, validate schema + values). UI in [Features/Templates/ViewModels/TemplateConfigViewModel.swift](scarf/scarf/Features/Templates/ViewModels/TemplateConfigViewModel.swift) + [Features/Templates/Views/TemplateConfigSheet.swift](scarf/scarf/Features/Templates/Views/TemplateConfigSheet.swift).
**Secret storage.** Keychain service name is `com.scarf.template.<slug>`, account is `<fieldKey>:<project-path-hash-short>`. The path-hash suffix means two installs of the same template in different dirs don't collide on Keychain entries. Values in `config.json` are `"keychain://service/account"` URIs — never plaintext. The bytes hit the Keychain only on form commit, so cancelling never leaves orphan entries.
**Uninstall.**`TemplateLock` v2 gains `config_keychain_items` and `config_fields` arrays. The uninstaller iterates each URI through `SecItemDelete` before removing the lock file. Absent items (user hand-cleaned) are no-ops.
**Exporter.** Carries the *schema* from `<project>/.scarf/manifest.json` through into exported bundles, never values. Exporting never leaks anyone's secrets. `schemaVersion` bumps to 2 only when a schema is forwarded; schema-less exports stay at 1.
**Catalog site.** [tools/build-catalog.py](tools/build-catalog.py) mirrors the Swift schema validator. Each v2 template's `template.json` is copied into `.gh-pages-worktree/templates/<slug>/manifest.json` and the site's `widgets.js` calls `ScarfWidgets.renderConfigSchema` to display the schema on the detail page (display-only — the form lives in-app).
**Schema is Swift-primary.** If `TemplateConfigField.FieldType` gains a new case, update in order: `TemplateConfig.swift` (model + validation), `tools/build-catalog.py` (`SUPPORTED_CONFIG_FIELD_TYPES` + type-specific rules), `widgets.js` (`summariseConstraint`), `TemplateConfigSheet.swift` (new control subview), tests on both sides. Schema drift between validator + installer is the kind of bug users only notice after shipping.
v2.3 adds a per-project Sessions tab and a "New Chat" button that spawns `hermes acp` with `cwd = project.path`. Session-to-project attribution is persisted in a Scarf-owned sidecar at `~/.hermes/scarf/session_project_map.json` — the ACP wire protocol has no project-metadata hook (extra params are silently dropped), and `state.db` has no cwd column, so the sidecar is Scarf's source of truth for "which project does this session belong to?" Managed by [SessionAttributionService.swift](scarf/scarf/Core/Services/SessionAttributionService.swift); read by the per-project [ProjectSessionsView.swift](scarf/scarf/Features/Projects/Views/ProjectSessionsView.swift).
**Giving the agent project awareness.** Hermes auto-reads a context file from the session's cwd at startup — priority order `.hermes.md` → `HERMES.md` → `AGENTS.md` → `CLAUDE.md` → `.cursorrules`, first match wins, 20KB cap. We lean on that by writing a Scarf-managed block into `<project>/AGENTS.md` before opening the session. Service: [ProjectAgentContextService.swift](scarf/scarf/Core/Services/ProjectAgentContextService.swift). Block shape:
```
<!-- scarf-project:begin -->
## Scarf project context
_Auto-generated by Scarf — do not edit between the begin/end markers._
You are operating inside a Scarf project named **"<Project Name>"**. …
- **Project directory:** `<absolute path>`
- **Dashboard:** `<path>/.scarf/dashboard.json`
- **Template:** `<author/id>` v<version> <!-- template-installed only -->
- **Configuration fields:** `field_a`, `field_b (secret — name only, value stored in Keychain)`
- **Uninstall manifest:** `<path>/.scarf/template.lock.json` <!-- when present -->
Any content below this block is template- or user-authored; preserve and defer to it.
<!-- scarf-project:end -->
```
**Invariants.**
- **Secret-safe.** Block surfaces field NAMES, never VALUES. A project with a Keychain-stored secret shows `api_token (secret — name only, …)`; the Keychain ref URI and any plaintext value never appear. Auditable by `refreshListsFieldNamesNotValues` in `ProjectAgentContextServiceTests`.
- **Idempotent.** Two refreshes with unchanged state produce byte-identical output. The write is skipped entirely when no delta, avoiding file-watcher churn.
- **Bounded.** Everything outside the markers is preserved on every refresh. Template-author AGENTS.md content lives safely below the block.
- **Non-fatal.** `ChatViewModel.startACPSession` calls refresh with `try?` + log — a failed write doesn't block the chat from starting; worst case is the session loses project awareness.
- **Refresh timing.** Called BEFORE `client.start()` so the block lands before Hermes's session-boot context scan. Skipping this ordering = the agent sees stale context from the previous refresh (or nothing, on fresh projects).
**Template-author contract.** A template shipped via the catalog should include an `AGENTS.md` with the template's operational instructions. Authors leave the `<!-- scarf-project -->` region alone — Scarf populates it at chat-start time. Everything below is template-owned and preserved.
**Known caveat.** If any parent directory of the project contains `.hermes.md` or `HERMES.md`, those shadow the project's `AGENTS.md` (higher in Hermes's priority order). No fix in v2.3 — deferred to v2.4 pending user input on how to handle authored `.hermes.md` files.
## Template Catalog
Shipped community templates live at `templates/<author>/<name>/` (one level down — `templates/CONTRIBUTING.md` explains the submission flow for authors). The catalog site is generated from this directory and served at `awizemann.github.io/scarf/templates/` alongside the Sparkle appcast — the two coexist on the `gh-pages` branch but touch completely disjoint paths.
Pipeline:
- **Validator + regenerator:** [tools/build-catalog.py](tools/build-catalog.py) is stdlib-only Python (3.9+). It walks `templates/*/*/`, validates every `.scarftemplate` against its manifest claim (mirrors the Swift `ProjectTemplateService.verifyClaims` invariants), enforces a 5 MB bundle-size cap, scans for high-confidence secret patterns, checks `staging/` matches the built bundle byte-for-byte, and emits `templates/catalog.json`. Tested by [tools/test_build_catalog.py](tools/test_build_catalog.py) — 16 tests covering every validation path.
- **Wrapper:** [scripts/catalog.sh](scripts/catalog.sh) mirrors the `scripts/wiki.sh` shape with `check / build / preview / serve / publish` subcommands. `publish` runs a second-pass secret-scan against the rendered site before committing + pushing `gh-pages`.
- **Site source:** `site/index.html.tmpl` + `site/template.html.tmpl` are `{{TOKEN}}`-substitution templates. `site/widgets.js` (~300 lines of vanilla JS) is the dogfood — renders a `ProjectDashboard` JSON into HTML using the same widget vocabulary the Swift app uses, so each template's detail page shows a live preview of its post-install dashboard.
- **Install-URL hosting:** raw-served from `main` at `https://raw.githubusercontent.com/awizemann/scarf/main/templates/<author>/<name>/<name>.scarftemplate`. No per-template Releases ceremony.
- **CI gate:** [.github/workflows/validate-template-pr.yml](.github/workflows/validate-template-pr.yml) runs the Python validator + its own test suite on every PR that touches `templates/`, the validator, or its tests. Failures post a comment on the PR with the last 3 KB of the validator log.
Same cadence as `scripts/release.sh` (manual, auditable, no auto-deploy). Runs stay isolated: release.sh only touches `appcast.xml` on gh-pages; catalog.sh only touches `templates/` on gh-pages. Never push catalog output on a release cadence or vice versa.
**Schema is Swift-primary.** When `ProjectDashboardWidget.type` gains a new case or `ProjectTemplateManifest` adds a field, update Swift first, then mirror into `tools/build-catalog.py` (`SUPPORTED_WIDGET_TYPES`, `_validate_manifest`, `_validate_contents_claim`) so the web validator stays honest. The Python test suite's real-bundle test catches drift on the example template but not on the full widget vocabulary — add a synthetic fixture to `test_build_catalog.py` for any new widget type.
- The app only reads from `~/.hermes/state.db` (never writes). Memory files are the exception.
- Swift 6 strict concurrency: `@MainActor` default isolation, `nonisolated` for service methods.
## Documentation
Public docs live in the [GitHub wiki](https://github.com/awizemann/scarf/wiki). Small fixes (typos, clarifications) can be made via the "Edit" button on any wiki page — you need push access to the main repo. For larger changes, clone the wiki locally (`git clone git@github.com:awizemann/scarf.wiki.git`) or open an issue describing the proposed change.
## Adding a Language
Scarf ships with English + Simplified Chinese, German, French, Spanish, Japanese, and Brazilian Portuguese. To add another locale (or improve an existing one):
1.**Fork** the repo and create a branch.
2.**Add the locale to `knownRegions`** in `scarf/scarf.xcodeproj/project.pbxproj` — follow the existing list (e.g. add `it` after `"pt-BR"`).
3.**Drop a new JSON file at `tools/translations/<locale>.json`** — copy an existing one (say `tools/translations/es.json`) as a starting point. Each entry maps the English source string to your translation. Keys you omit fall back to English at runtime — do that for proper nouns (Scarf, Hermes, Anthropic, OAuth, SSH, …) and for anything technical that shouldn't translate.
4.**Preserve format specifiers exactly**: `%@`, `%lld`, `%d`, positional `%1$@` / `%2$lld`, etc. If word order needs to change in your language, use positional forms (`%1$@ … %2$@`).
5.**Add your locale to `tools/merge-translations.py`'s `LOCALES` list** and run `python3 tools/merge-translations.py` — this writes your translations into `scarf/scarf/Localizable.xcstrings`.
6.**Translate `scarf/scarf/InfoPlist.xcstrings`** (the macOS microphone-permission prompt) for your locale. Add a new `stringUnit` under `localizations`.
7.**Build** (`xcodebuild -project scarf/scarf.xcodeproj -scheme scarf build`) and **sanity-check in Xcode**: Scheme → Run → App Language → your locale. Walk the main views (Dashboard, Chat, Settings) and look for clipping or obvious leaks.
8.**Open a PR** including the new JSON file, the updated catalog, and the pbxproj / script changes. Mention which routes you spot-checked.
AI translation is fine for the first pass — it's how the initial six locales landed. Native-speaker review improves quality and is always welcome, either as a follow-up PR or as review comments on the initial one.
See [scarf/docs/I18N.md](scarf/docs/I18N.md) for deeper context on the String Catalog setup and which strings are intentionally kept verbatim.
<em>Available in English, 简体中文, Deutsch, Français, Español, 日本語, and Português (Brasil).</em>
<br><br>
<a href="https://www.buymeacoffee.com/awizemann"><img src="https://cdn.buymeacoffee.com/buttons/v2/default-yellow.png" alt="Buy Me a Coffee" height="28"></a>
</p>
## What's New in 2.6
### Hermes v2026.4.30 (v0.12.0) catch-up
The largest single Hermes update Scarf has had to follow since v0.10's Tool Gateway. Every new surface is **capability-gated** through `HermesCapabilities` (parses `hermes --version` once per server) — on a v0.11 host, Scarf 2.6 looks identical to Scarf 2.5.2 and the new affordances are hidden.
- **Autonomous Curator (Mac sidebar + iOS panel).** `hermes curator` self-prunes / -consolidates the skill library on a 7-day cycle. Status panel, **Run Now / Pause / Resume** actions, three leaderboards (least-recently-active / most-active / least-active) with activity / use / view / patch counters, inline pin toggles, restore-archived sheet. Last-run REPORT.md renders inline. New "Curator" sidebar item under Interact (between Memory and Skills); ScarfGo gets a Curator nav row under System.
- **Multimodal image input in chat.** Drag/drop, paste, or NSOpenPanel multi-pick on Mac; PhotosPicker on iOS (up to 5 images per message). `ImageEncoder` downsamples to 1568px long-edge JPEG q=0.85, **detached only** so encoding never blocks MainActor. Hermes routes the prompt to a vision-capable model automatically. Image-only sends are valid — vision models accept "describe this" with no caption.
- **5 new inference providers** in the model picker — GMI Cloud, Azure AI Foundry, LM Studio (now first-class), MiniMax (OAuth), Tencent TokenHub. Provider IDs match `HERMES_OVERLAYS` in `hermes_cli/providers.py` exactly.
- **Microsoft Teams + Yuanbao** as the 18th and 19th gateway platforms in the Platforms tab.
- **Read-only Kanban view (Mac).** Paginated table over `hermes kanban list --json` filtered by status, with status badges, meta chips (id / assignee / workspace / skills), and 5s polling while foregrounded. Create / claim / dispatch UI is deferred until upstream stabilizes the multi-profile collab layer (which was reverted in v0.12).
- **Skills v0.12 surface.** Direct-URL install (`hermes skills install <https-url>`) via a new "Install from URL…" toolbar button on Mac; reload via `hermes skills audit`; `skills.disabled` rendered as strikethrough + an "OFF" pill on Mac and iOS rows; Curator pin badge from `~/.hermes/skills/.curator_state` surfaced as a pin glyph.
- **Cron — `--workdir` field (Mac).** Inject `AGENTS.md` / `CLAUDE.md` / `.cursorrules` from a working directory and pin cwd for terminal/file/code_exec tools. Scarf's CronJobEditor adds the field; both create and edit paths forward the flag.
- **Settings deltas.** New **Caching & Redaction** section under Advanced — prompt cache TTL picker (5m / 1h), redact-secrets-in-patches toggle (now off by default on v0.12; flip back on here), runtime metadata footer toggle. TTS provider list gains **piper** (native local TTS); terminal backend list gains **vercel** (Vercel Sandbox).
- **`auxiliary.curator` aux task.** Curator's review fork can run on a separate model from the main agent. `auxiliary.flush_memories` was removed in v0.12 — Scarf preserves the row on v0.11 hosts (inverse gate) and hides it on v0.12.
- **ScarfGo catch-up.** Read-only Webhooks / Plugins / Profiles tabs parity-match the Mac surfaces (no mutating CLI verbs on the phone). Yellow Hermes-version banner nudges pre-v0.12 hosts to upgrade; renders only when the connected target is below v0.12.
### Chat fixes (post-merge round)
A focused pass over GitHub issue triage:
- **Typing lag in the chat composer ([#67](https://github.com/awizemann/scarf/issues/67))** — `RichChatInputBar.updateMenuState()` was firing on every keystroke and writing two state vars per `.onChange`, tripping SwiftUI's "action tried to update multiple times per frame" warning. Composer now coalesces writes, short-circuits when the slash menu can't apply, and watches `commands.count` instead of allocating `commands.map(\.id)` per keystroke.
- **Chat font-size slider now actually scales rich chat content ([#68](https://github.com/awizemann/scarf/issues/68))** — `\.dynamicTypeSize` couldn't reach the fixed-point ScarfFont tokens. New `\.chatFontScale` env value plumbed through bubbles, markdown, and code blocks.
- **Placeholder ghosting on first keystroke ([#65](https://github.com/awizemann/scarf/issues/65))** — `TextEditor`'s NSTextView surfaces a typed glyph one frame before the SwiftUI binding propagates. Pinned an opaque background behind the placeholder rect; switched the conditional to `.opacity(...)` for view-tree stability.
- **Draft text leaked between conversations ([#62](https://github.com/awizemann/scarf/issues/62))** — composer `@State` survived session switches because the surrounding view tree was structurally identical. Bound `RichChatInputBar`'s identity to `richChat.sessionId`.
- **Sent message rendered blank after navigating away ([#63](https://github.com/awizemann/scarf/issues/63))** — `loadSessionHistory` atomically replaced messages from a state.db that hadn't yet flushed the user's row. New per-session pending-user-messages cache survives `reset()` and re-injects entries until the DB catches up.
- **Background completion notifications ([#64](https://github.com/awizemann/scarf/issues/64))** — new `ChatNotificationService` fires a local UNUserNotificationCenter banner when a prompt completes while Scarf isn't the foreground app. Settings → Display → Feedback → "Notify when Hermes finishes" toggle, default on.
- **Per-message TTS playback ([#66](https://github.com/awizemann/scarf/issues/66))** — small speaker glyph on each settled assistant bubble. Tap to read aloud through `AVSpeechSynthesizer` with the user's macOS Spoken Content default voice.
See the full [v2.6.0 release notes](https://github.com/awizemann/scarf/releases/tag/v2.6.0).
**Previous releases:** see the [Release Notes Index](https://github.com/awizemann/scarf/wiki/Release-Notes-Index) on the wiki for v2.5, v2.3, v2.2, v2.0, v1.6, and earlier.
## ScarfGo — the iPhone companion
Same Hermes server you've been running on your Mac — reachable from your phone over SSH. Multi-server, project-scoped chat, session resume, memory editor, cron list, skills tree, settings (read), all native iOS. Pure-Swift SSH (Citadel under the hood — no `ssh` binary needed on iOS). Per-project chat writes the same Scarf-managed `AGENTS.md` block the Mac app does, so the agent boots with the same project context regardless of which client opened the session.
**[Join the public TestFlight](https://testflight.apple.com/join/qCrRpcTz)** — the link is live now but only accepts new beta testers once Apple's Beta Review approves the first build. If you hit a "not accepting testers" splash, bookmark it and try again in 24–48h.
<a href="assets/screenshots/scarfgo-system.png"><img src="assets/screenshots/scarfgo-system.png" alt="ScarfGo — System tab" width="140"></a>
</p>
<p align="center"><sub><em>Tap any thumbnail to view full size. Servers list · Chat · Project dashboard (Site Status Checker template) · Skills browser · System tab.</em></sub></p>
See the [ScarfGo wiki page](https://github.com/awizemann/scarf/wiki/ScarfGo) for the full feature tour, [ScarfGo Onboarding](https://github.com/awizemann/scarf/wiki/ScarfGo-Onboarding) for the SSH-key setup walkthrough, and [Platform Differences](https://github.com/awizemann/scarf/wiki/Platform-Differences) for what is and isn't shared between Mac and iOS.
## Connect ScarfGo to your Hermes server
ScarfGo speaks SSH directly — no companion service, no developer-controlled server in between. Onboarding takes about a minute:
1.**Install via TestFlight.** Open the [public TestFlight link](https://testflight.apple.com/join/qCrRpcTz) on your phone, accept the invite, install ScarfGo from TestFlight (just like any other beta).
2.**Tap Add Server.** Enter the host (IP or DNS), SSH user, port (default 22), and an optional nickname. Same details you'd type into `ssh user@host`.
3.**Generate Key.** ScarfGo creates a fresh Ed25519 keypair on the device. The private half lives in the iOS Keychain (`kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly`) and is excluded from iCloud sync — it never leaves the phone.
4.**Add the public key to your Hermes host.** Tap **Copy public key**, then on the host run:
```bash
cat >> ~/.ssh/authorized_keys <<'EOF'
<paste the line ScarfGo showed you>
EOF
chmod 600 ~/.ssh/authorized_keys
```
This is its own line per device — the convention any second SSH client uses. Mac (Scarf) keeps using your existing ssh-agent / `~/.ssh/config` and is unaffected.
5. **Tap Test connection.** ScarfGo opens an SSH session, probes for the `hermes` binary, and saves the server on success. If it can't find `hermes`, see the [troubleshooting section](https://github.com/awizemann/scarf/wiki/ScarfGo-Onboarding#troubleshooting) — it's almost always a `PATH` quirk on non-interactive SSH.
Done. Open the Dashboard tab and tap any session to resume it; tap the **+** in Chat to start a new project-scoped session.
## Multi-server, one window per server
Scarf 2.0 is a multi-window app. Each window is bound to exactly one Hermes server — your local `~/.hermes/` is synthesized automatically, and you can add remotes via **File → Open Server…** → **Add Server** (host, user, port, optional identity file). Open a second window for a different server and the two run side-by-side with independent state.
Remote Hermes is reached over system SSH — the same `~/.ssh/config`, ssh-agent, ProxyJump, and ControlMaster pooling your terminal uses. File I/O flows through `scp`/`sftp`; SQLite is served from atomic `sqlite3 .backup` snapshots cached under `~/Library/Caches/scarf/snapshots/<server-id>/`; chat (ACP) tunnels as `ssh -T host -- hermes acp` with JSON-RPC over stdio end-to-end. Everything in the feature list below works against remote identically to local.
### Remote setup requirements
The remote host must have:
1. **SSH access** — key-based auth via your local ssh-agent. Scarf never prompts for passphrases; run `ssh-add` once in Terminal before connecting.
2. **`sqlite3`** on the remote `$PATH` — needed for the atomic DB snapshots. Install on the remote with `apt install sqlite3` (Ubuntu/Debian), `yum install sqlite` (RHEL/Fedora), or `apk add sqlite` (Alpine).
3. **`pgrep`** on the remote `$PATH` — used by the Dashboard "is Hermes running" check. Standard on every distro; install `procps` if missing.
4. **`~/.hermes/` readable by the SSH user**. When Hermes runs as a separate user (systemd service, Docker container), the SSH user needs read access to `config.yaml` and `state.db`. Either (a) SSH as the Hermes user, (b) `chmod` Hermes's home to be group-readable and add your SSH user to that group, or (c) set the **Hermes data directory** field when adding the server to point at the right location (e.g. `/var/lib/hermes/.hermes`).
### Troubleshooting remote connections
If the connection pill is green but the Dashboard shows "Stopped", "unknown", or empty values, the SSH user can't read the Hermes state files. Open **Manage Servers → 🩺 Run Diagnostics** (or click the yellow "Can't read Hermes state" pill in the toolbar). The diagnostics sheet runs fourteen checks in one SSH session — connectivity, `sqlite3` presence, read access to `config.yaml` and `state.db`, the effective non-login `$PATH` — and tells you exactly which one fails and why, with remediation hints for each. Use the **Copy Full Report** button to paste the full output into a bug report.
For the common "Hermes isn't at the default path" case (systemd services, Docker), **Test Connection** in the Add Server sheet now probes `/var/lib/hermes/.hermes`, `/opt/hermes/.hermes`, `/home/hermes/.hermes`, and `/root/.hermes` when it can't find `state.db` at `~/.hermes/`, and offers a one-click fill if it finds any of them.
## Features
- **Dashboard** — System health, token usage, recent sessions with live refresh
- **Insights** — Usage analytics with token breakdown, model/platform stats, top tools bar chart, activity heatmaps, notable sessions, and time period filtering (7/30/90 days or all time)
- **Sessions Browser** — Full conversation history with message rendering, tool call inspection, full-text search, rename, delete, and JSONL export
- **Activity Feed** — Recent tool execution log with filtering by kind and session, detail inspector with pretty-printed arguments
- **Live Chat** — Embedded terminal running `hermes chat` with full ANSI color and Rich formatting via [SwiftTerm](https://github.com/migueldeicaza/SwiftTerm), session persistence across navigation, resume/continue previous sessions, and voice mode controls
- **Memory Viewer/Editor** — View and edit Hermes's MEMORY.md and USER.md with live file-watcher refresh
- **Skills Browser** — Browse all installed skills by category with file content viewer and file switcher
- **Tools Manager** — Enable/disable toolsets per platform (CLI, Telegram, Discord, etc.) with toggle switches, MCP server status
Scarf mirrors Hermes's surface areathrough a sidebar-based UI. Sections below map 1:1 to the app's sidebar.
### Monitor
- **Dashboard** — System health, token usage, cost tracking, recent sessions with live refresh
- **Insights** — Usage analytics with token breakdown (including reasoning tokens), cost tracking, model/platform stats, top tools bar chart, activity heatmaps, notable sessions, and time period filtering (7/30/90 days or all time)
- **Sessions Browser** — Full conversation history with message rendering, model reasoning/thinking display, tool call inspection, full-text search, rename, delete, and JSONL export. Subagent sessions are filtered from the main list and accessible via parent session drill-down
- **Activity Feed** — Recent tool execution log with filtering by kind and session, detail inspector with pretty-printed arguments and tool output display
### Interact
- **Live Chat** — Two modes: **Rich Chat** streams responses in real-time via the Agent Client Protocol (ACP) with iMessage-style bubbles, markdown rendering, tool call visualization, thinking/reasoning display, permission request dialogs, and a one-click `/compress` focus sheet (when Hermes advertises the command); **Terminal** runs `hermes chat` with full ANSI color and Rich formatting via [SwiftTerm](https://github.com/migueldeicaza/SwiftTerm). Both modes support session persistence, resume/continue previous sessions, auto-reconnection with session recovery, and voice mode controls
- **Memory Viewer/Editor** — View and edit Hermes's MEMORY.md and USER.md with live file-watcher refresh, external memory provider awareness (Honcho, Supermemory, etc.), and profile-scoped memory support with profile picker
- **Skills Browser** — Browse installed skills by category with file content viewer and required config warnings. **New in 1.6:** Browse the Skills Hub, search by registry (official, skills.sh, well-known, GitHub, ClawHub, LobeHub), install, check for updates, and uninstall — all from the app
### Configure *(new in 1.6)*
- **Platforms** — Native GUI setup for all 13 messaging platforms (Telegram, Discord, Slack, WhatsApp, Signal, Email, Matrix, Mattermost, Feishu, iMessage, Home Assistant, Webhook, CLI). Per-platform forms write credentials to `~/.hermes/.env` and behavior toggles to `~/.hermes/config.yaml`. WhatsApp and Signal pairing use an inline SwiftTerm terminal for QR scan and signal-cli daemon management
- **Personalities** — List defined personalities, pick the active one, and edit `SOUL.md` inline with markdown preview
- **Quick Commands** — Editor for custom `/command_name` shell shortcuts with dangerous-pattern detection (`rm -rf`, `mkfs`, etc.)
- **Credential Pools** — Per-provider credential rotation with a fixed OAuth flow (URL extraction + browser open + code paste) and proper `--type api-key` handling. API keys never stored in UI state — only last-4 preview. Strategy picker (fill_first / round_robin / least_used / random)
- **Plugins** — Install via Git URL or `owner/repo`, update, remove, enable/disable. Reads `~/.hermes/plugins/` directly for reliable state
- **Webhooks** — Create, list, test-fire, and remove webhook subscriptions. Detects the "platform not enabled" state and links to gateway setup
- **Profiles** — Switch between multiple isolated Hermes instances. Create, rename, delete, export (zip), import. Safe-switch warning reminds users to restart Scarf after activating a different profile
### Manage
- **Tools** — Enable/disable toolsets per platform with a connectivity-aware platform menu (green/orange/grey/red dots for connected/configured/offline/error). **Fixed in 1.6:** all 13 platforms now appear (was previously stuck on CLI)
- **MCP Servers** — Manage Model Context Protocol servers Hermes connects to. Add via curated presets (GitHub, Linear, Notion, Sentry, Stripe, and more) or fully custom (stdio command + args, or HTTP URL with optional bearer auth). Per-server detail view with enable/disable toggle, environment variable + header editor, tool-include/exclude filters, resources/prompts toggles, request and connect timeouts, OAuth token detection + clearing, and one-click "Test Connection" that runs `hermes mcp test` and surfaces the discovered tool list. Gateway-restart banner appears after config changes that require a reload
- **Gateway Control** — Start/stop/restart the messaging gateway, view platform connection status, manage user pairing (approve/revoke)
- **Cron Manager** — View scheduled jobs, their status, prompts, and output
- **Log Viewer** — Real-time log tailing with level filtering and text search
- **Project Dashboards** — Custom, agent-generated dashboards for any project. Define stat boxes, charts, tables, progress bars, checklists, and rich text in a simple JSON file — Scarf renders them with live refresh. Let your Hermes agent build and maintain project-specific visualizations automatically
- **Settings** — Structured config editor for all Hermes settings
- **Cron Manager** — View scheduled jobs with pre-run scripts, delivery failure tracking, timeout info, and `[SILENT]` job indicators. **New in 1.6:** full write support — create, edit, pause, resume, run-now, and delete jobs from the app
- **Health** — Component-level status and diagnostics. **New in 1.6:** inline "Run Dump" and "Share Debug Report" buttons (the latter with an upload-confirmation dialog before sending to Nous support)
- **Log Viewer** — Real-time log tailing for agent.log, errors.log, and gateway.log with level filtering, component filter (Gateway / Agent / Tools / CLI / Cron), clickable session-ID pills that filter to a single session, and text search
- **Settings** — **Restructured in 1.6** into a 10-tab layout: General, Display, Agent, Terminal, Browser, Voice, Memory, Aux Models, Security, Advanced. Exposes ~60 previously hidden config fields including all 8 auxiliary model tasks, container limits, full TTS/STT provider settings, human-delay simulation, compression thresholds, logging rotation, checkpoints, website blocklist, Tirith sandbox, and delegation. One-click **Backup & Restore** via `hermes backup` / `hermes import`. Model picker replaces the old free-text model field, backed by the models.dev cache (111 providers, all major models) with a "Custom…" escape hatch
### Project Dashboards
Custom, agent-generated dashboards for any project. Define stat boxes, charts, tables, progress bars, checklists, rich text, and embedded web views in a simple JSON file — Scarf renders them with live refresh. Let your Hermes agent build and maintain project-specific visualizations automatically. See [Project Dashboards](#project-dashboards-1) below for the full schema.
### System
- **Hermes Process Control** — Start, stop, and restart the Hermes agent directly from Scarf
- **Menu Bar** — Status icon showing Hermes running state with quick actions
## Requirements
- macOS 26.2+
- Xcode 26.3+
- [Hermes agent](https://github.com/hermes-ai/hermes-agent) v0.6.0+ installed at `~/.hermes/`
- macOS 14.6+ (Sonoma) for Scarf
- iOS 18.0+ for [ScarfGo](https://github.com/awizemann/scarf/wiki/ScarfGo) (the iPhone companion, public TestFlight from v2.5)
- Xcode 16.0+ to build from source
- [Hermes agent](https://github.com/hermes-ai/hermes-agent) v0.6.0+ installed at `~/.hermes/` on each target host (v0.12.0+ recommended for full v2.6 feature support — autonomous Curator, multimodal image input, 5 new providers, Microsoft Teams + Yuanbao gateways, Kanban, Skills v0.12 surface, cron `--workdir`, prompt-cache TTL, Piper TTS, Vercel terminal)
- For remote servers: SSH access (key-based), `sqlite3` on the remote (for atomic DB snapshots), and the `hermes` CLI resolvable from the remote user's `PATH` or at a path you specify per server. ScarfGo requires the same on every Hermes host it connects to.
### Compatibility
Scarf reads Hermes's SQLite database (schema v6) and parses CLI output from `hermes status`, `hermes doctor`, `hermes tools`, `hermes sessions`, `hermes gateway`, and `hermes pairing`. Tested and verified against:
Scarf reads Hermes's SQLite database and parses CLI output from `hermes status`, `hermes doctor`, `hermes tools`, `hermes sessions`, `hermes gateway`, and `hermes pairing`. Automatic schema detection provides backward compatibility with older databases while supporting new features in newer Hermes versions.
| v0.12.0 (2026-04-30) | **Verified — current target (recommended for full v2.6 feature support)** |
Scarf 2.6 targets Hermes v0.12.0 for the autonomous Curator, multimodal ACP image content blocks, the 5 new inference providers, Microsoft Teams + Yuanbao gateways, the read-only Kanban view, the Skills v0.12 surface (URL install / reload / disable badges / curator pin), cron `--workdir`, `auxiliary.curator`, `prompt_caching.cache_ttl`, the redaction toggle, the runtime metadata footer, Piper TTS, and the Vercel terminal backend. Every v0.12 surface is **capability-gated** — Scarf detects the host's Hermes version once per server connection (`hermes --version` → semver + `YYYY.M.D` parse) and hides v0.12-only UI on older hosts. v0.11.0 hosts keep the full v2.5 surface (`/steer`, `messages.reasoning_content`, `sessions.api_call_count`, design-md/spotify skills, SKILL.md frontmatter chips, `hermes memory reset`). Earlier Hermes versions remain supported for monitoring, sessions, file-based features, and ACP chat; new behavior degrades gracefully on older agents.
If a Hermes update changes the database schema or CLI output format, Scarf may need to be updated. Check the [Health](#features) view for compatibility warnings.
@@ -55,11 +187,29 @@ If a Hermes update changes the database schema or CLI output format, Scarf may n
### Pre-built Binary (no Xcode required)
Download the latest universal binary (Apple Silicon + Intel) from [Releases](https://github.com/awizemann/scarf/releases):
Download the latest build from [Releases](https://github.com/awizemann/scarf/releases):
1. Download `Scarf-vX.X.X-Universal.zip`
2. Unzip and drag **Scarf.app** to Applications
3. On first launch, right-click and choose **Open** (or go to System Settings → Privacy & Security → Open Anyway)
- `Scarf-vX.X.X-Universal.zip` — Apple Silicon + Intel (recommended)
- `Scarf-vX.X.X-ARM64.zip` — Apple Silicon only (smaller download)
1. Unzip and drag **Scarf.app** to Applications
2. Launch normally — builds are Developer ID signed and notarized, so Gatekeeper accepts them on first launch
Scarf checks for updates automatically on launch via [Sparkle](https://sparkle-project.org) and daily thereafter. You can disable automatic checks or trigger a manual check from **Settings → General → Updates** or the menu bar icon.
#### "Scarf.app is damaged" on first launch
If Gatekeeper rejects the app on first launch (occasionally happens on macOS 14+ for zip-distributed apps depending on extraction tool + quarantine state), the bundle itself is fine — every release is verified to pass `codesign --verify --strict --deep` and `spctl --assess --type execute` before it ships. The fix is to **only remove the quarantine attribute**, never strip all xattrs or re-sign:
**Do not run `xattr -rc /Applications/Scarf.app`** — it strips codesign-related extended attributes and can break the bundle's seal. **Do not run `codesign --force --deep --sign - /Applications/Scarf.app`** — `--deep` ad-hoc re-signing is incompatible with Sparkle.framework's nested XPC services and `Updater.app` sub-bundle, and will corrupt the framework signature even if the outer app appears intact afterward. If a clean re-download + `xattr -d com.apple.quarantine` doesn't resolve the issue, please open an issue with `codesign --verify --verbose=4 --strict /Applications/Scarf.app` output captured **before** any mitigation attempts.
### Build from Source
@@ -90,10 +240,11 @@ scarf/
Sessions/ Conversation browser with rename, delete, export
Activity/ Tool execution feed with inspector
Projects/ Agent-generated project dashboards with widget rendering
Chat/ Embedded terminal via SwiftTerm with voice controls
Chat/ Rich ACP chat and embedded terminal with voice controls
Memory/ Memory viewer and editor
Skills/ Skill browser by category
Tools/ Toolset management per platform
MCPServers/ MCP server registry, presets, OAuth, tool filters, test runner
Gateway/ Messaging gateway control and pairing
Cron/ Scheduled job viewer
Logs/ Real-time log viewer
@@ -114,11 +265,14 @@ Scarf reads Hermes data directly from `~/.hermes/`:
@@ -129,6 +283,7 @@ The app opens `state.db` in read-only mode to avoid WAL contention with Hermes.
| Package | Purpose |
|---------|---------|
| [SwiftTerm](https://github.com/migueldeicaza/SwiftTerm) | Terminal emulator for the Chat feature |
| [Sparkle](https://github.com/sparkle-project/Sparkle) | Auto-updates from the GitHub-hosted appcast |
Everything else uses system frameworks: SQLite3 C API, Foundation JSON, AttributedString markdown, SwiftUI Charts, GCD file watching.
@@ -136,7 +291,7 @@ Everything else uses system frameworks: SQLite3 C API, Foundation JSON, Attribut
Scarf watches `~/.hermes/` for file changes and queries the SQLite database for sessions, messages, and analytics. Views refresh automatically when Hermes writes new data.
The Chat tab spawns `hermes chat` as a subprocess in a pseudo-terminal, giving you the full interactive CLI experience with proper ANSI rendering. Sessions persist across navigation — switch tabs and come back without losing your conversation.
The Chat tab has two modes. **Rich Chat** communicates with Hermes via the Agent Client Protocol (ACP) — a JSON-RPC connection over stdio — streaming responses in real-time with automatic reconnection and session recovery on connection loss. **Terminal** mode spawns `hermes chat` in a pseudo-terminal for the full interactive CLI experience with proper ANSI rendering. Sessions persist across navigation in both modes — switch tabs and come back without losing your conversation.
Management actions (renaming sessions, toggling tools, editing memory) call the Hermes CLI or write directly to the appropriate files, keeping Scarf and Hermes in sync.
@@ -144,7 +299,7 @@ The app sandbox is disabled because Scarf needs direct access to `~/.hermes/` an
## Project Dashboards
Project Dashboards turn Scarf into a customizable monitoring hub for all your projects. You define a simple JSON file in your project folder describing what to display — stat boxes, charts, tables, progress bars, checklists, and rich text — and Scarf renders it as a live-updating dashboard. Your Hermes agent can generate and maintain these dashboards automatically.
Project Dashboards turn Scarf into a customizable monitoring hub for all your projects. You define a simple JSON file in your project folder describing what to display — stat boxes, charts, tables, progress bars, checklists, rich text, and embedded web views — and Scarf renders it as a live-updating dashboard. Your Hermes agent can generate and maintain these dashboards automatically.
### What You Can Build
@@ -153,6 +308,7 @@ Project Dashboards turn Scarf into a customizable monitoring hub for all your pr
The `webview` widget embeds a live web browser directly in your dashboard — perfect for displaying local dev servers, HTML reports, or any web-based tool your agent generates.
When a dashboard includes a webview widget, Scarf adds a tabbed interface: **Dashboard** shows your normal widgets, **Site** shows the web content full-canvas with clean margins — using the entire available space in the app. This gives you the best of both worlds: compact metrics at a glance, and a full embedded browser when you need it.
```json
{
"type": "webview",
"title": "Project Report",
"url": "http://localhost:8000/dashboard",
"height": 500
}
```
- `url`: Any URL — typically a local server (`http://localhost:...`) or file path
- `height`: Height in points when displayed as an inline widget card (default: 400). The Site tab always uses full available space regardless of this setting.
@@ -236,7 +409,7 @@ Select your project in the Projects sidebar — the dashboard renders immediatel
The real power is letting your Hermes agent build and update dashboards automatically. Add instructions like this to your agent's context:
> Analyze this project and create a `.scarf/dashboard.json` dashboard with relevant metrics and status. Use stat widgets for key numbers, charts for trends, tables for structured data, and lists for task tracking. Register the project in `~/.hermes/scarf/projects.json` if not already registered.
> Analyze this project and create a `.scarf/dashboard.json` dashboard with relevant metrics and status. Use stat widgets for key numbers, charts for trends, tables for structured data, lists for task tracking, and a webview widget if the project has a local web server or HTML reports. Register the project in `~/.hermes/scarf/projects.json` if not already registered.
Your agent can update the dashboard as part of cron jobs, after builds, or whenever project state changes. Since Scarf watches the file, updates appear in real-time.
@@ -260,6 +433,30 @@ Your agent can update the dashboard as part of cron jobs, after builds, or whene
Each section defines a grid with 1–4 columns. Widgets flow left-to-right, wrapping to new rows. See [DASHBOARD_SCHEMA.md](scarf/docs/DASHBOARD_SCHEMA.md) for the full schema reference with examples of every widget type.
## Releases
Scarf ships through GitHub releases — the App Store is not supported because Scarf spawns the user-installed `hermes` binary and reads `~/.hermes/` directly, both of which App Sandbox forbids.
Each release goes through a single local script: [scripts/release.sh](scripts/release.sh). The script archives a universal binary, signs it with the Developer ID Application cert, submits to `notarytool`, staples the ticket, produces the distribution zip, signs an appcast entry with Sparkle's EdDSA key, pushes an updated `appcast.xml` to the `gh-pages` branch, creates the GitHub release, and tags `main`.
The Sparkle appcast is served from [awizemann.github.io/scarf/appcast.xml](https://awizemann.github.io/scarf/appcast.xml).
Signing prerequisites (one-time):
- `Developer ID Application` certificate in the login Keychain
- `scarf-notary` keychain profile registered via `xcrun notarytool store-credentials`
- Sparkle EdDSA private key in Keychain item `https://sparkle-project.org` (back this up — without it, shipped apps can never receive updates)
## Template Catalog
Community-contributed Scarf project templates live under [`templates/`](templates/) in this repo and are browsable at **[awizemann.github.io/scarf/templates/](https://awizemann.github.io/scarf/templates/)** with live dashboard previews and one-click `scarf://install?url=…` links.
- **Install from the web** — click "Install with Scarf" on any template's detail page; the app takes over from there.
- **Install from a local file** — Scarf → Projects → Templates → Install from File…, or double-click any `.scarftemplate` in Finder.
- **Author a template** — see [`templates/CONTRIBUTING.md`](templates/CONTRIBUTING.md) for the full walkthrough. Fork, drop a template under `templates/<your-github-handle>/<your-name>/`, open a PR; CI validates the bundle automatically.
The catalog's site is a static HTML + vanilla JS build generated by [`tools/build-catalog.py`](tools/build-catalog.py) and driven by [`scripts/catalog.sh`](scripts/catalog.sh) (check / build / preview / publish). Appcast and main landing page are independent — updating the catalog never disturbs Sparkle.
## Contributing
Contributions are welcome. Please open an issue to discuss what you'd like to change before submitting a PR.
@@ -270,6 +467,8 @@ Contributions are welcome. Please open an issue to discuss what you'd like to ch
4. Push to the branch (`git push origin feature/my-feature`)
5. Open a Pull Request
For template submissions, see [`templates/CONTRIBUTING.md`](templates/CONTRIBUTING.md) — same flow, with a catalog-specific checklist + automated CI validation.
## Support
If you find Scarf useful, consider buying me a coffee.
<pclass="tagline">A native macOS & iOS companion for the Hermes AI agent — calm, confident, and rust-warm. This site documents the palette, type, components, and screens.</p>
<p>Click through every screen — Dashboard, Sessions, Insights, Projects, Chat, Settings, Tools, MCP servers, Cron, Logs, Memory, Activity, Health and more. Faithful to the real Scarf macOS app, with a working sidebar and the rust palette throughout.</p>
<divstyle="color:var(--fg-muted);font-size:14px;max-width:380px">A native macOS GUI for the Hermes AI agent. Full visibility into what an autonomous agent is doing, when, and what it creates.</div>
<divclass="mono"style="margin-top:4px">brand: white silk on lavender → magenta gradient</div>
The <spanclass="scarf-code"style="font-family:var(--font-mono);font-size:12px;background:rgba(0,0,0,.05);padding:1px 5px;border-radius:4px">daily-summary</span> job ran 14 minutes ago and completed successfully.
<divclass="label">Body · sentence case, calm and direct</div>
<divstyle="font-size:17px;font-weight:600">Hermes actually knows what project it's in</div>
<divstyle="font-size:15px;color:var(--fg-muted)">Every project-scoped chat gets a Scarf-managed block auto-injected into the project's <spanclass="scarf-code"style="font-family:var(--font-mono);font-size:13px">AGENTS.md</span> before the session starts.</div>
<divstyle="font-size:14px">Ask the agent <em>"what project am I in?"</em> and it answers with the project name, directory, template id, and registered cron jobs.</div>
<divstyle="font-size:12px;color:var(--fg-muted)">headline 17 · subhead 15 · body 14 · caption 12 — same rhythm as SwiftUI's text styles</div>
{id:'s1',title:'Cron diagnostics',project:'scarf',preview:'The daily-summary job ran 14 minutes ago…',time:'14m',model:'sonnet-4.5',unread:0,pinned:true,status:'live'},
{id:'s2',title:'Release notes draft',project:'hermes-blog',preview:'Pulled the merged PRs from this week…',time:'42m',model:'haiku-4.5',unread:2,status:'idle'},
{id:'s3',title:'PR review summary',project:'hermes-blog',preview:'Three PRs are ready for review.',time:'2h',model:'sonnet-4.5',status:'idle'},
{id:'s5',title:'Memory layout question',project:'scarf',preview:'The shared memory keys live at…',time:'yesterday',model:'sonnet-4.5',status:'idle'},
{id:'s6',title:'Catalog publish flow',project:'hermes-blog',preview:'Walked through the .scarftemplate bundle…',time:'yesterday',model:'sonnet-4.5',status:'idle'},
{id:'s7',title:'SSH tunnel debug',project:'scarf-remote',preview:'Connection drops after ~90s of idle…',time:'Mon',model:'sonnet-4.5',status:'error'},
A high-fidelity React recreation of the Scarf macOS app, built against the codebase at `awizemann/scarf` (SwiftUI). It mirrors the real navigation hierarchy from `SidebarView.swift` and the visual rhythm of the actual SwiftUI views (`Dashboard`, `RichChat`, `Sessions`, `Projects`, `Insights`, etc.).
This kit is **cosmetic** — it gets the visuals exactly right but doesn't replicate the Swift business logic. Use it as a starting point for new flows, mocks, or marketing screenshots.
- **Icons** — Lucide for the web. SF Symbols aren't redistributable; Lucide is the closest stroked-line set. Documented in `/README.md` → ICONOGRAPHY.
- **Fonts** — system stack first, then Inter (display/text) and JetBrains Mono (mono) loaded from Google Fonts. On macOS users will see SF Pro / SF Mono.
- **Window chrome** — three traffic-light dots painted by hand. The starter `macos-window.jsx` was tried first but its sidebar slot didn't match Scarf's layout, so the chrome is inlined in `index.html`.
## What's intentionally left blank
The placeholder view wired to every sidebar item that isn't one of the five built screens — Activity, Memory, Skills, Platforms, Personalities, Quick Commands, Credentials, Plugins, Webhooks, Profiles, Tools, MCP Servers, Gateway, Cron, Health, Logs, Settings. Each lands on a polite `EmptyState` so navigation is still satisfying. Build any of them by following `Sessions.jsx` (table view) or `Projects.jsx` (card grid) — Scarf is consistent enough that those two patterns cover almost every CRUD pane.
{id:'read_file',kind:'read',source:'built-in',server:'—',enabled:true,calls7d:1284,lastUsed:'2m ago',policy:'auto',desc:'Read a file from disk by path. Honors hidden-file setting.'},
{id:'write_file',kind:'edit',source:'built-in',server:'—',enabled:true,calls7d:412,lastUsed:'14m ago',policy:'approve-write',desc:'Write content to a file, creating parent directories as needed.'},
{id:'apply_patch',kind:'edit',source:'built-in',server:'—',enabled:true,calls7d:348,lastUsed:'14m ago',policy:'approve-write',desc:'Apply a unified-diff patch to existing files.'},
{id:'list_files',kind:'read',source:'built-in',server:'—',enabled:true,calls7d:928,lastUsed:'32m ago',policy:'auto',desc:'List entries in a directory, optionally recursive.'},
{id:'execute',kind:'execute',source:'built-in',server:'—',enabled:true,calls7d:661,lastUsed:'14m ago',policy:'approve-exec',desc:'Run a shell command. Subject to gateway approval policy.'},
{id:'web_fetch',kind:'fetch',source:'built-in',server:'—',enabled:true,calls7d:184,lastUsed:'1h ago',policy:'auto',desc:'Fetch a URL and return the extracted text.'},
{id:'web_search',kind:'fetch',source:'built-in',server:'—',enabled:true,calls7d:92,lastUsed:'3h ago',policy:'auto',desc:'Search the public web. Returns top 10 results.'},
{id:'browser_navigate',kind:'browser',source:'built-in',server:'—',enabled:false,calls7d:0,lastUsed:'never',policy:'approve-all',desc:'Drive a Chromium instance for live page interaction.'},
// MCP
{id:'github__list_issues',kind:'mcp',source:'mcp',server:'github',enabled:true,calls7d:84,lastUsed:'42m ago',policy:'auto',desc:'List issues for a GitHub repository the user has access to.'},
{id:'github__create_pr',kind:'mcp',source:'mcp',server:'github',enabled:true,calls7d:12,lastUsed:'yesterday',policy:'approve-write',desc:'Open a pull request from a branch.'},
{id:'linear__list_issues',kind:'mcp',source:'mcp',server:'linear',enabled:true,calls7d:38,lastUsed:'2h ago',policy:'auto',desc:'Query Linear issues with filters.'},
{id:'slack__send_message',kind:'mcp',source:'mcp',server:'slack',enabled:false,calls7d:0,lastUsed:'never',policy:'approve-all',desc:'Post a message to a Slack channel as the connected user.'},
{id:'postgres__query',kind:'mcp',source:'mcp',server:'postgres-prod',enabled:true,calls7d:14,lastUsed:'4h ago',policy:'approve-write',desc:'Run read-only SQL against the configured database.'},
<ContentHeadertitle={item.label}subtitle={`This view isn't fleshed out in the UI kit yet.`}/>
<EmptyStateicon={item.icon}
title={`${item.label}`}
body={`The Scarf app exposes a dedicated ${item.label} pane here. The kit ships a faithful Dashboard, Sessions, Insights, Projects, and Chat — wire ${item.label} the same way against your data.`}
Scarf now ships with [Sparkle](https://sparkle-project.org). On launch (and daily thereafter) it checks an EdDSA-signed appcast at [awizemann.github.io/scarf/appcast.xml](https://awizemann.github.io/scarf/appcast.xml). When a new version is available you'll get an in-app update prompt — no more manually downloading zips and dragging into Applications.
You can disable automatic checks or trigger a manual one from **Settings → General → Updates**, the menu bar icon, or the **Scarf → Check for Updates…** menu item.
### Notarized & Developer ID signed
This is the first release that's properly Developer ID signed and notarized by Apple. Gatekeeper accepts it on first launch — no more right-click → Open dance, no more "Scarf cannot be opened because the developer cannot be verified" warnings.
### Fixes
- Chat works correctly when no terminal hermes session is running, and surfaces the real error when it can't reach the agent (b6df…)
### Under the hood
- Tracked `Info.plist` (replacing auto-generation) so signing-relevant keys live in version control
- New `UpdaterService` wraps Sparkle and is injected via SwiftUI `.environment()`
**Migrating from 1.6.0:** unzip and replace your existing `Scarf.app` in `/Applications`. After this release, future updates install in-place via Sparkle.
- **No more bogus "missing credentials" banner on Chat.** The orange "No AI provider credentials detected" warning was firing on the Chat tab whenever no session was selected, even for users whose credentials were configured and working. Root cause: the preflight check only inspected `~/.hermes/.env` and shell environment variables, missing the Credential Pools file at `~/.hermes/auth.json` (the in-app flow introduced in 1.6.0) and `api_key:` fields in `config.yaml`. The check now covers all four locations Hermes itself reads at runtime, so if you've added credentials via **Configure → Credential Pools**, the warning stays hidden.
### Polish
- Banner subtitle updated to point users at the in-app Credential Pools flow first, rather than prescribing `.env` edits.
---
**Upgrading from 1.6.1:** Sparkle will offer the update automatically. You can also trigger a check via **Scarf → Check for Updates…** or the menu bar icon.
Scarf now manages **multiple Hermes installations** — your local `~/.hermes/` plus any number of remote Hermes instances reached over SSH. Every feature that worked on your Mac now works against a Linux server, a Mac mini on the network, or whatever other host has Hermes installed.
This is a major version bump because the entire service layer was rewritten around a `ServerContext` + `ServerTransport` abstraction, and because the window model changed from single-window-single-server to multi-window-one-server-per-window.
### Multi-server
- **Manage Servers** sheet lets you add, rename, and remove remote servers. Each entry is an SSH target (`user@host`, port, optional identity file, optional `remoteHome` override if your install isn't at `~/.hermes/`).
- Each window is bound to exactly one server. Open a second window via **File → Open Server** → pick a different server, and the two run side-by-side with independent state — chat, dashboards, activity, sessions, the lot.
- The menu bar status icon shows a summary across all registered servers (green hare = any Hermes running anywhere).
- Window-state restoration: quit + relaunch re-opens every window you had open, each reconnected to its bound server.
### Remote over SSH
- **ControlMaster connection pooling** — after the first auth, each remote primitive is a ~5ms tunnel call. Uses the system `ssh`, `scp`, `sftp` so your `~/.ssh/config`, ssh-agent, 1Password/Secretive SSH agents, and ProxyJump all work unchanged.
- **DB access via atomic snapshots** — Scarf runs `sqlite3 .backup` on the remote (WAL-safe, won't corrupt), flips the snapshot out of WAL mode, and pulls it down with `scp`. Snapshots are cached under `~/Library/Caches/scarf/snapshots/<server-id>/` and re-pulled when the file watcher sees a change on the remote's `state.db`.
- **ACP chat over SSH** — the Agent Client Protocol tunnel runs `ssh -T host -- hermes acp`. JSON-RPC over stdio travels end-to-end unmodified, so Rich Chat, streaming, tool calls, permission dialogs, and compression all work against the remote agent identically to local.
- **File watcher** — local uses FSEvents (instant); remote polls `stat` mtime every 3s with ControlMaster keeping the cost bounded. Views auto-refresh on any tick.
- **Cleanup on server-remove** — deleting a remote closes its ControlMaster socket (`ssh -O exit`), prunes its snapshot cache, and invalidates any process-wide caches keyed to its ID. App launch also sweeps orphaned snapshot dirs whose UUIDs are no longer in the registry.
### Chat UX overhaul
All of these were visible bugs during remote dogfooding and are now fixed on both local and remote:
- **No more white-screen flash** on the first message of a session. `RichChatView` used to swap `ContentUnavailableView` out for the message list, which tore down and recreated the entire ScrollView hierarchy. The empty state now lives inside the ScrollView itself.
- **No more scroll-jumping to whitespace** at stream start/finish. Replaced six racing `onChange`-driven scroll calls with SwiftUI's built-in `.defaultScrollAnchor(.bottom)`, which is implemented inside the layout pass and doesn't overshoot LazyVStack content.
- **Resuming a session on a remote now shows its full history.** The DB snapshot is refreshed on session-load — previously it was pulled once on first open and never again, so any messages the remote wrote since launch were invisible.
- **"Continue from last session" surfaces errors** instead of silently doing nothing when SSH is down.
- **Typing into a blank Chat always creates a new session.** Previously it auto-resumed the most recently active session in the DB, which often picked up a cron-spawned session that Hermes had already garbage-collected — producing a silent prompt failure.
- **Failed prompts now explain themselves.** When the agent returns `stopReason: "refusal"`, `"error"`, or `"max_tokens"` with no assistant output, a system message appears under your prompt explaining what happened. No more spinning "Agent working…" forever.
### Correctness — remote SQLite
- The WAL-error spam (`cannot open file at line 51044 of [f0ca7bba1c] — os_unix.c:51044: (2) open(/Users/…/state.db-wal) - No such file or directory`) is gone. `sqlite3 .backup` preserves the source DB's journal mode; the scp'd copy used to try to open a WAL sidecar that doesn't exist. The snapshot script now runs `PRAGMA journal_mode=DELETE` after `.backup` on the remote, and Scarf opens remote snapshots with `file:…?immutable=1` as defense-in-depth.
- **Concurrent snapshot dedupe** — a new `SnapshotCoordinator` actor makes sure that when Dashboard + Sessions + Activity all ask for a fresh snapshot at the same moment (e.g. on a file-watcher tick), only one SSH backup runs; the other callers await the in-flight pull and share the result.
### Under the hood
- New `ServerContext` value type flows through `.environment()` to every view and ViewModel. Every file and process operation routes through `context.makeTransport()` — `LocalTransport` (`FileManager`, `Process`, FSEvents) or `SSHTransport` (ssh, scp, sftp, mtime polling). The protocol is small enough that each transport is ~400 lines.
- Swift 6 complete-concurrency sweep: ~230 warnings reduced to 1. `ServerContext`, `HermesPathSet`, `ServerTransport`, all service inits, and every value-type accessor are explicitly `nonisolated`. Hand-written `Codable` conformances for the nine types whose synthesized conformances were inferred `@MainActor` by Swift 6's default-isolation rule (`ACPRequest`, `ACPRawMessage`, `GatewayState`, `PlatformState`, `HermesCronJob`, `CronSchedule`, `CronJobsFile`, `AuthFile`, `AuthEntry`).
- ACP cwd now comes from the *remote*`$HOME`, probed once on first connect and cached per server. Previously it passed your local Mac's home path to the ACP adapter, which only worked by coincidence when the remote username matched.
### Compatibility
Hermes v0.10.0 is now verified alongside v0.6–v0.9. Scarf builds its session/message `SELECT` columns based on an additive schema detection (`hasV07Schema`), so newer Hermes versions with extra columns don't break queries.
### Migration from 1.6.x
- Sparkle will offer the update automatically. Trigger manually via **Scarf → Check for Updates…** or the menu bar.
- Your local server is synthesized automatically — existing 1.6.x users see "Local" in the server list with no setup needed.
-`servers.json` is created on first add-remote. Location: `~/Library/Application Support/scarf/servers.json`.
- Nothing you configured in 1.6.x (OAuth tokens, credential pools, cron jobs, MCP servers, platform setup) is touched. Those live in `~/.hermes/` and remain the source of truth.
### Known limitations
- Remote file watching is 3s mtime polling (vs. FSEvents on local). If you need sub-second updates on a remote, that's a followup.
- The `session/load` ACP call against an already-deleted session returns success-with-no-body from the Hermes adapter — Scarf now detects the resulting `stopReason: "refusal"` and surfaces it, but the underlying Hermes behavior is an upstream-adapter bug that should also get a proper error response.
Hotfix for [#19](https://github.com/awizemann/scarf/issues/19) and the related reports from the first day of v2.0: users' remote SSH connections would show a green "Connected" pill but every view (Dashboard, Sessions, Activity, Chat) read as empty / "not running" / "not configured". Three distinct environments reported it — Docker Hermes on a LAN, homelab VM over Tailscale, Ubuntu VPS — and every one was a silent file-access failure on the remote that Scarf wasn't surfacing.
### Errors no longer disappear
Every remote read (`config.yaml`, `gateway_state.json`, `state.db`, `pgrep`) used to silently substitute an empty value on *any* failure — permission denied, missing file, `sqlite3` not installed, connection drop — they all looked identical to the UI. Now:
- Each failure logs a specific warning via `os.Logger` (visible in Console.app under subsystem `com.scarf`).
- The Dashboard shows an orange banner above the stats with the exact error (e.g. "Permission denied reading `~/.hermes/state.db`") and a **Run Diagnostics…** button.
-`HermesDataService` exposes a `lastOpenError` so views can explain *why* state.db couldn't be opened, rather than just rendering zeros.
- Routine "file doesn't exist" cases (optional `skill.yaml` metadata, `gateway_state.json` before Hermes starts, `memories/USER.md` on fresh installs) are detected and **not** logged as warnings — only real errors (permission denied, connection drops, `sqlite3` missing) hit the log. Prevents Console from filling with false-positive noise when directory walks encounter optional files.
### New Remote Diagnostics sheet
Accessible from **Manage Servers → 🩺** per-server button, or by clicking the orange connection pill when Scarf can see the server but can't read Hermes state. Runs fourteen checks in a single SSH session and shows pass/fail for each, plus a targeted hint per failure:
- SSH connectivity and auth
- Remote user identity and `$HOME` resolution
-`~/.hermes` directory existence and readability
-`config.yaml` readable (existence *and* actual read access — the old probe only checked existence)
-`state.db` readable
-`sqlite3` installed on the remote (required for the atomic snapshot Scarf pulls)
-`sqlite3` can actually open `state.db`
-`hermes` binary on the non-login `$PATH` (what runtime uses)
-`hermes` binary on the login `$PATH` (what the Test Connection probe uses)
-`pgrep` available (for the "is Hermes running" check)
One **Copy Full Report** button dumps every check as plain text for bug reports, and a raw-output disclosure panel shows the exact stdout/stderr the remote returned whenever any probe fails — so transport-level problems are self-diagnosing.
The diagnostics script is piped to `/bin/sh -s` on stdin rather than passed as `sh -c <script>` argv. The latter was getting split line-by-line by the remote's login shell (newlines parsed as command separators), which stranded variables set on line 1 in an ephemeral `sh` subprocess that exited before line 2 could use them. Stdin-piping runs the whole script in one `sh` process with variable scope preserved.
### Connection pill gains a "degraded" state
The pill used to be green as long as SSH connected; now after connectivity passes it runs a second-tier check (`test -r $HOME/.hermes/config.yaml`). If that fails, the pill turns **orange** with "Connected — can't read Hermes state" and clicking it opens Remote Diagnostics directly. This is the exact symptom mode in #19, and it's now one click away from a specific answer.
The pill's visual also got a pass: the colored dot is replaced with a state-specific SF Symbol (`checkmark.circle.fill` / `stethoscope` / `arrow.triangle.2.circlepath` / `exclamationmark.triangle.fill`), which reads more like a clickable toolbar tool and doubles as the status signal. No custom pill background anymore — the toolbar's native `.principal` bezel is the frame.
### Auto-suggest the correct `remoteHome` during Add Server
When Test Connection can't find `state.db` at the configured (or default) path, it now also probes the common alternate locations — `/var/lib/hermes/.hermes`, `/opt/hermes/.hermes`, `/home/hermes/.hermes`, `/root/.hermes` — and offers a one-click "Use this" fill if it finds one. Removes the need to know that systemd-installed Hermes lives at `/var/lib/hermes/.hermes` by convention.
### Clearer copy for the `remoteHome` field
The Add Server sheet field is now labeled "Hermes data directory" with a description explaining when you'd override it (systemd service installs, Docker sidecars) and noting that Test Connection auto-suggests.
### README has a new "Remote setup requirements" section
Four concrete prerequisites (SSH, `sqlite3`, `pgrep`, read access to `~/.hermes`) and a troubleshooting paragraph pointing at Remote Diagnostics.
### Migrating from 2.0.0
Sparkle will offer the update automatically. Settings and server list are preserved verbatim — this is purely additive (new diagnostics surface, new error banners, auto-suggest in Test Connection). If you were affected by #19, run Remote Diagnostics after updating; the sheet should pinpoint the specific file access issue and suggest a fix.
### Under the hood
- New types: `RemoteDiagnosticsViewModel`, `RemoteDiagnosticsView`. Both are local to Scarf; no new transport protocol.
-`HermesFileService` gains `loadConfigResult()`, `loadGatewayStateResult()`, `hermesPIDResult()`, `readFileResult()`, `readFileDataResult()` — Result-returning variants that preserve the error. Legacy `loadConfig()` etc. still exist as thin forwarders for callers that don't need diagnostics.
-`HermesDataService.open()` records `lastOpenError` with humanized hints for "sqlite3 not installed", "permission denied", and "file not found" — the three failure modes that produce 90% of issue #19 symptoms.
-`ConnectionStatusViewModel` status enum gains `.degraded(reason:)` between `.connected` and `.error`.
-`TestConnectionProbe` result enum gains `suggestedRemoteHome: String?` carrying any alternate-location hit.
### Known follow-ups (not in 2.0.1)
-`TestConnectionProbe` uses a direct-argv ssh invocation that's functionally correct but fragile (works by accident when split across the login shell). Should be ported to the stdin-pipe pattern the diagnostics sheet now uses.
- Remaining `try?`-swallowed read paths beyond the four Dashboard-surfacing ones — Cron, Memory, Skills, MCP Servers, Platforms still silently render empty on read errors. Same fix pattern applies, low priority.
-`hermesBinaryHint` is only populated when the user clicks Test Connection; if they skip it, ACP chat and CLI calls fall back to bare `hermes` which requires it on the non-interactive PATH (rarely true for `~/.local/bin` installs). The connection-pill's second-tier probe could auto-populate this.
- Docker-host support: when users SSH to a Docker host, `pgrep` and `~/.hermes/` on the host don't see what's inside the container. Needs a `docker exec` wrapping option per server.
The actual root cause of [#19](https://github.com/awizemann/scarf/issues/19), found and patched by Scarf's first external contributor. v2.0.1 added the diagnostics UI assuming file-perm root cause; v2.0.2 fixes the underlying bug for everyone, regardless of perms.
### macOS Unix domain socket path limit (the real #19)
OpenSSH's ControlMaster multiplexes our bursty stat/cat/cp traffic over one TCP session per host. The socket path is bound by `bind(2)` to a Unix domain socket — and macOS' `sun_path` is **104 bytes including the NUL terminator**.
Scarf's old socket path was `~/Library/Caches/scarf/ssh/<%C>` where `%C` is OpenSSH's 64-char SHA1 hash of `(local user, host, port, remote user)`. For a username like `alex.maksimchuk`, the full path landed at **105 bytes** — one byte over the limit. ssh exited 255 with `unix_listener: path "..." too long for Unix domain socket`. Our `LogLevel=QUIET` flag (set so ACP's line-delimited JSON stays binary-clean) suppressed the diagnostic, and the user just saw "Remote command exited 255" — which the UI rendered as the silent empty-data state every reporter in #19 described.
Per-user uid suffix keeps two local users' sockets from colliding in the shared `/tmp`, and 0700 perms on the dir keep them inaccessible to other users.
**Massive thanks to Alex Maksimchuk ([@aliatx2017](https://github.com/aliatx2017)) — Scarf's first external PR contributor — for diagnosing and patching this in [#20](https://github.com/awizemann/scarf/pull/20).** That diagnosis only happened because Alex bothered to read the codebase, reproduce against multiple usernames including a Termux/Android instance, and walk back from the cryptic exit code to the actual `bind()` failure. This release wouldn't exist without that work.
### Hardening on top of the fix
Three additions on top of Alex's patch, layered in via separate commits to keep the original change reviewable:
- **Defensive ownership check on the socket dir.** `/tmp` is world-writable, so a malicious local user could pre-create `/tmp/scarf-ssh-<uid>` and trick Scarf into using a hostile directory (we'd silently fail to chmod it back to 0700, since we wouldn't own it). `ensureControlDir` now uses POSIX `mkdir(0700)` (atomic, sets perms at create time) and on `EEXIST` runs `lstat` to verify the entry is a directory we own with mode 0700 — symlink → refuse, wrong owner → refuse + log to `os.Logger`, wrong mode → repair. Closes the `/tmp` pre-creation hole that's the standard concern for any per-user `/tmp` path.
- **Launch-time sweep of stale sockets.** `ServerRegistry.sweepOrphanCaches` already prunes orphaned snapshot directories on launch; it now also removes ControlMaster socket files older than 30 minutes. Socket basenames are `%C` hashes (not ServerIDs), so we can't keep "still registered" sockets the way the snapshot sweep does — but `ControlPersist` is 600s, so anything older than 30 minutes is guaranteed to be a dead orphan from a crashed master, an unclean app exit, or a server removed while another Scarf instance was holding the dir. Keeps `/tmp/scarf-ssh-<uid>/` from accumulating indefinitely until reboot, while leaving any concurrent Scarf instance's live sockets untouched.
- **Regression test for the path-length invariant.** `scarfTests` was a stub — it now has two tests: one asserting `controlDirPath().utf8.count + 1 + 64 + 1 ≤ 104` (would have caught the original #19 bug in CI), one asserting the path includes the current uid (pins the per-user-isolation invariant against a future "simplification" that drops it).
### v2.0.1 diagnostics work is still useful
The diagnostics sheet, orange "degraded" pill, dashboard error banner, and `remoteHome` auto-suggest from v2.0.1 all still ship — they just turn out not to have been the right diagnosis for the original three reporters. They remain valuable for the *other* connection-failure modes they were designed to surface (missing `sqlite3` on the remote, real permission errors, container/host visibility gaps, custom Hermes data directories). If you upgrade to v2.0.2 and *still* see incomplete data, run Remote Diagnostics from **Manage Servers → 🩺** and the sheet will tell you why.
### Migrating from 2.0.0 / 2.0.1 / draft 2.0.1
Sparkle will offer the update automatically. Settings and server list are preserved verbatim. The first time v2.0.2 connects to a remote, it'll create `/tmp/scarf-ssh-<uid>/` with mode 0700; the old `~/Library/Caches/scarf/ssh/` directory becomes unused (you can delete it manually, or leave it — macOS will sweep it eventually).
The previous v2.0.1 draft download remains available for anyone who already grabbed it — it's still a valid build with the diagnostics work. v2.0.2 is the recommended upgrade path.
### Reporters of #19
@cmalpass, @flyespresso, @maikokan — please grab v2.0.2 and confirm the dashboard populates without needing to run Remote Diagnostics first. If it still doesn't, the diagnostics sheet should now have a much better chance of pinpointing what's left.
Scarf now speaks seven languages and has a proper slash-command menu in the chat. The language work closes [#13](https://github.com/awizemann/scarf/issues/13) and opens the door for community contributions of additional locales.
### Multi-language support
The UI is now fully translated to **Simplified Chinese, German, French, Spanish, Japanese, and Brazilian Portuguese** on top of the existing English. Scarf respects the system language by default; override per-app from **System Settings → Language & Region → Apps → Scarf**.
- **644 source strings** catalogued. **583 translated per locale** — the remaining ~60 are deliberate fall-throughs to English: proper nouns (Scarf, Hermes, OAuth, MCP, SSH), brand names (Docker, Daytona, Singularity, BlueBubbles), format-only tokens (`%lld`, `·`, `•`), and config-literal placeholders (`my_server`, `npx`, `sk-…`).
- **Locale-aware number and date formatting.** Previous builds hardcoded POSIX-style decimal separators (`$12.34`) and English unit names (`"MB"`, `"K"`, `"M"`). Currency now routes through `.formatted(.currency(code: "USD"))`, byte sizes through `.byteCount(style: .file)`, token counts through `.notation(.compactName)`, and the day-of-week chart through `Calendar.current.shortWeekdaySymbols` — so German users see `15,2 MB`, Japanese users see `15.5万 tokens`, and the activity heatmap starts on the locale's first weekday.
- **Microphone permission prompt localized** — the system dialog that appears the first time you enable voice chat now reads in the user's language.
#### How the translation work shipped
Three stacked PRs to keep each piece independently reviewable, all AI-translated with the bar explicitly set low so native speakers can iterate:
1.**[#22](https://github.com/awizemann/scarf/pull/22) — String Catalog infrastructure.** Added `Localizable.xcstrings` + `InfoPlist.xcstrings`, expanded `knownRegions` with the six new locales, and fixed the locale-aware number formatters mentioned above. No user-visible English-locale change; the groundwork only.
2.**[#24](https://github.com/awizemann/scarf/pull/24) — Audit burn-down.** Swept the codebase for "silently un-localizable" patterns that look fine in Xcode's catalog but leak English at runtime: `Text(cond ? "A" : "B")` routes through the String overload instead of `LocalizedStringKey`, as do `Label(stringVar, systemImage:)`, `.help(stringVar)`, and composite format strings with translatable text suffixes. ~40 sites refactored, covering Chat voice/TTS toggles, Logs pickers, Insights period + day names, MCPServer test result, Profiles, SignalSetup, QuickCommands, ConnectionStatusPill. Without this PR the translations would have landed but ~40 visible strings would still have rendered in English.
3.**[#25](https://github.com/awizemann/scarf/pull/25) — Translations + contributor path.** The six locale JSONs + a 90-line merge script + a "Adding a Language" section in `CONTRIBUTING.md`. The sidebar and Settings tab bar fix also shipped here after smoke-testing revealed they were still missed — `Label(section.rawValue, …)` goes to the String overload just like the audit cases.
#### Contributing a new language
Per-locale source of truth lives in [`tools/translations/<locale>.json`](https://github.com/awizemann/scarf/tree/main/tools/translations). Each entry is a plain `{ "English": "Translation" }` map — keys you omit fall through to English at runtime. Workflow is: fork, drop a JSON, run `python3 tools/merge-translations.py`, open a PR. The full bar is documented in [CONTRIBUTING.md → Adding a Language](https://github.com/awizemann/scarf/blob/main/CONTRIBUTING.md#adding-a-language).
Native-speaker review of the initial six locales is welcome — AI translation gets us most of the way, but idiom and tone are better with someone who actually uses the language. Post a PR against the relevant `<locale>.json` and it'll land as a follow-up.
### Chat slash-command menu
Type `/` in Rich Chat and a floating menu appears above the input with every command the connected agent has advertised via ACP's `available_commands_update`, plus any user-defined `quick_commands:` from `~/.hermes/config.yaml`. ↑/↓ to navigate, Tab or Enter to complete, Esc to dismiss. Commands with argument hints (e.g. `/compress <topic>`) insert a trailing space so you can start typing the argument immediately.
The filter uses pure-prefix match and re-renders on every query — the old menu had a description-fallback filter and a cached child view that together pinned `/help` on-screen regardless of what you typed. The dedicated `/compress` button is hidden once the menu has more than one command; it only surfaces when `/compress` is the single advertised slash command, preserving the v2.0 one-click compression flow for that case.
### Chat UX polish
- **Auto-scroll on send and on completion.** `.defaultScrollAnchor(.bottom)` handles slow streaming fine, but rapid slash-command responses (common once the menu lands) outran the anchor and left the reply off-screen. Now the list explicitly scrolls to the latest message when you submit and again when the prompt finishes.
- **Loading state.** `ChatViewModel.isPreparingSession` is true during Starting / Creating / Loading / Reconnecting. While true, the message list swaps its empty-state placeholder for a spinner — non-blocking, just a view inside the ScrollView.
- **Empty-state centering.** The "Start a new session or resume an existing one" placeholder was positioned with a fixed `.padding(.vertical, 80)` that looked wrong at extreme window sizes. Replaced with Spacers inside `.containerRelativeFrame(.vertical)` so it sits in the true vertical center of the chat pane.
- **Session-load whitespace bug.** Opening a session used to render a blank viewport you'd have to scroll up from — the fix was `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.
### Under the hood
- **String Catalog build pipeline.** `SWIFT_EMIT_LOC_STRINGS` + `STRING_CATALOG_GENERATE_SYMBOLS` are enabled; keys extract automatically on IDE build. Headless builds use `xcrun xcstringstool sync` to merge the per-source `.stringsdata` files into the catalog (wrapped by [`tools/merge-translations.py`](https://github.com/awizemann/scarf/blob/main/tools/merge-translations.py) when applying JSON translations).
- **New docs.** [`scarf/docs/I18N.md`](https://github.com/awizemann/scarf/blob/main/scarf/docs/I18N.md) covers the catalog setup, the patterns that silently bypass localization (and their fixes), and which strings are intentionally kept verbatim. Anyone adding UI copy should read the "Guardrails when writing new UI code" section to avoid re-introducing the leaks #24 cleaned up.
### Migrating from 2.0.x
Sparkle will offer the update automatically. No config migration needed. The first launch after update picks up the system locale — if you want English even on a non-English macOS, set **System Settings → Language & Region → Apps → Scarf → English**.
### Thanks
- [Onion3](https://github.com/Onion3) for filing [#13](https://github.com/awizemann/scarf/issues/13) back in April. The single-locale ask turned into a six-locale rollout.
- Future translators: if you spot a weird AI translation in your language, open a PR against `tools/translations/<locale>.json`. The bar is explicitly low — we'd rather have a 95%-correct translation shipped and iterated on than hold everything for perfection.
Scarf projects can now travel. This release introduces **Project Templates** — a shareable `.scarftemplate` bundle format that packages a project's dashboard, agent instructions, skills, cron jobs, and a typed configuration schema into a single file anyone can install with one click. Bundles are agent-portable by design: every template ships with a cross-agent [`AGENTS.md`](https://agents.md/) so the instructions work natively in Claude Code, Cursor, Codex, Aider, Jules, Copilot, Zed, and every other agent that reads the Linux Foundation standard.
This is also the first release to ship a public **template catalog website** — a static site generated from `templates/<author>/<name>/` in this repo, previewed at [awizemann.github.io/scarf/templates/](https://awizemann.github.io/scarf/templates/), with a CI-enforced validator for community submissions.
### Project Templates
- **Bundle format: `.scarftemplate`.** A zip carrying a `template.json` manifest, the project's dashboard, a required `AGENTS.md` (the [Linux Foundation cross-agent instructions standard](https://agents.md/) — reads natively in Claude Code, Cursor, Codex, Aider, Jules, Copilot, Zed, and more), a README shown in the installer, optional per-agent instruction shims (`CLAUDE.md`, `GEMINI.md`, `.cursorrules`, `.github/copilot-instructions.md`), optional namespaced skills, optional cron job definitions, and an optional memory appendix.
- **Install preview sheet.** Before anything touches disk, Scarf shows you the exact project directory that will be created, every file inside it, every skill that will be namespaced under `~/.hermes/skills/templates/<slug>/`, every cron job that will be registered (always paused — you enable each one manually), and a live diff of the memory appendix against your existing `MEMORY.md`. Markdown fields — the README, field descriptions, cron prompts — render inline. The manifest's content claim is cross-checked against the actual zip entries so a bundle can't hide files from the preview.
- **`scarf://install?url=…` deep links.** Register Scarf as the handler for the `scarf` URL scheme so a future catalog site can link one-click installs straight into the app. Only `https://` payloads are accepted; `file://`, `javascript:`, and `http://` are refused on principle. A 50 MB size cap keeps a malicious link from exhausting disk. The URL never auto-installs — the preview sheet is always user-confirmed.
- **Install-time token substitution.** Template authors use `{{PROJECT_DIR}}`, `{{TEMPLATE_ID}}`, and `{{TEMPLATE_SLUG}}` placeholders in cron prompts; the installer resolves them to absolute paths at install time so the registered cron job works regardless of where Hermes sets CWD.
- **Export any project as a template.** Select a project, open the new Templates menu in the Projects toolbar, fill in a handful of fields (id, name, version, description, optional author + category + tags), tick the skills and cron jobs you want to include, optionally drop in a memory snippet, and save. The exporter carries the authored configuration schema forward but **never** the user's values — exports are safe on projects with live config.
- **No-overwrite, reversible by design.** Installed templates drop a `<project>/.scarf/template.lock.json` recording exactly what they wrote — every project file, skill path, cron job name, memory block id, and Keychain reference. Installing the same template id twice is refused at the preview step so you don't accidentally double-append to `MEMORY.md`.
- **Safe globals.** Skills install to `~/.hermes/skills/templates/<slug>/<skill-name>/` so they never collide with your own skills. Cron jobs are prefixed with `[tmpl:<id>]` and start paused. The installer **never** touches `~/.hermes/config.yaml`, `auth.json`, sessions, or any credential-bearing path.
### Template Configuration (schemaVersion 2)
Templates can now declare a typed configuration schema that drives a form step during install — no more "edit a `sites.txt` file to get started."
- **Typed field vocabulary.** Seven field types: `string`, `text` (multiline), `number` (with `min`/`max`), `bool`, `enum` (with `{value, label}` options), `list` (of strings, with `minItems`/`maxItems`), and `secret` (routed to the macOS Keychain). Constraints per type — `pattern` for regex, `minLength`/`maxLength` for text, etc. — are enforced at install and at edit time.
- **Configure step in the install flow.** If the template declares a schema, a **Configure** screen is inserted between "pick parent directory" and the preview sheet. Non-secret values land in `<project>/.scarf/config.json`; secrets land in the macOS Keychain with a service name of `com.scarf.template.<slug>` and an account keyed to the project-directory hash (so two installs of the same template in different dirs don't collide on Keychain entries).
- **Post-install Configuration editor.** A slider icon in the dashboard header opens the same form pre-filled with the current values. Change a site, rotate a token, toggle a feature — the cron job picks up the new values on its next run. Secrets are never echoed back ("Saved in Keychain — leave empty to keep the stored value").
- **Model recommendations.** Templates can suggest a preferred model (`claude-sonnet-4.5`, `claude-haiku-4`, `gpt-4.1`, etc.) with a rationale. Scarf surfaces the recommendation in the configure sheet without auto-switching your active model — always your call.
- **Secrets are tracked in the lock file.** Uninstalling a template runs `SecItemDelete` on every Keychain ref recorded at install, so a full clean-up leaves nothing behind. Absent entries (user already cleaned them) are no-ops.
### Template Catalog
A Sparkle-style pipeline for community-contributed templates, living on the same `gh-pages` branch as the auto-update feed.
- **Static site.** [awizemann.github.io/scarf/templates/](https://awizemann.github.io/scarf/templates/) — generated from every `templates/<author>/<name>/` directory. Each template gets a detail page showing the README, a live preview of the post-install dashboard, and the configuration schema rendered with human-readable constraint summaries. One-click install via the `scarf://install?url=…` button.
- **Stdlib-only Python validator.** `tools/build-catalog.py` is a no-external-dependencies Python script that mirrors the Swift-side schema and validation invariants (supported widget types, supported field types, `contents` claim verification, secret-with-default rejection, bundle-size cap, high-confidence secret patterns). Run it locally with `./scripts/catalog.sh check` before submitting a PR.
- **CI gate on PRs.** [`.github/workflows/validate-template-pr.yml`](https://github.com/awizemann/scarf/blob/main/.github/workflows/validate-template-pr.yml) runs the validator + its 24-test suite on every PR touching `templates/`, the validator itself, or its tests. Failing PRs get an inline comment with the last 3 KB of the validator output; passing PRs get a tailored checklist naming the specific template directory being changed.
- **Install-URL hosting.** Bundles are raw-served from `main` at `https://raw.githubusercontent.com/awizemann/scarf/main/templates/<author>/<name>/<name>.scarftemplate`. No per-template GitHub Releases ceremony.
- **Dogfood: the site uses Scarf's dashboard format.** `site/widgets.js` is ~300 lines of vanilla JS that renders a `ProjectDashboard` JSON using the same widget vocabulary the app uses, so each detail page's "live preview" is the actual dashboard the user will get.
### Example template: `awizemann/site-status-checker`
Ships as the first catalog entry and exercises every v2.2 surface. [See it in the catalog →](https://awizemann.github.io/scarf/templates/awizemann-site-status-checker/)
- Configure step asks for a list of URLs and a per-URL timeout.
- A paused cron job runs daily at 09:00 (editable from the Cron sidebar), does HTTP GETs with 3-redirect follow, writes a timestamped results table to `status-log.md`, updates the dashboard's Sites Up / Sites Down / Last Checked stat widgets plus the Watched Sites list, and rewrites the Site tab's webview URL to the first configured site.
- Works in any agent — the `AGENTS.md` is the single source of truth; no per-agent shim needed.
### Site tab
A dashboard with at least one `webview` widget now exposes a **Site** tab next to Dashboard. Useful for templates that watch something renderable (a site, a preview endpoint, a Grafana panel). The `site-status-checker` example rewrites the webview URL to the first configured site on every cron run, so the tab stays in sync with live config.
### Using templates
- **Install from file:** Projects → Templates → *Install from File…*, pick a `.scarftemplate` from disk.
- **Install from URL:** Projects → Templates → *Install from URL…*, paste an https URL.
- **Install from the web:** click any `scarf://install?url=…` link in a browser.
- **Export:** select a project → Projects → Templates → *Export "<name>" as Template…*, fill the form, save.
- **Edit config post-install:** slider icon in the dashboard header.
- **Uninstall:** right-click the project in the sidebar → *Uninstall Template (remove installed files)…*, or click the uninstall icon in the dashboard header. The preview sheet lists every file, cron job, Keychain secret, and memory block that will be removed, plus every user-created file that will be preserved.
### UX clarifications
- **Remove from List vs. Uninstall Template.** Sidebar context-menu labels clarified so you can see at a glance whether a click is destructive. *Remove from List (keep files)…* is registry-only — nothing on disk is touched, cron jobs stay, Keychain secrets stay. A confirmation dialog spells this out before the click lands. *Uninstall Template (remove installed files)…* is the full, lock-driven cleanup.
- **Post-uninstall "folder kept" banner.** When the uninstaller preserves the project directory because the cron wrote a `status-log.md` (or the user dropped files in there), the success view now explicitly lists the preserved paths with a pointer to delete the folder from Finder if desired.
- **Run Now no longer blocks on agent runs.** The Cron sidebar's Run Now button used to show a "Run failed" toast whenever an agent job ran longer than 60 s — even when the job was finishing correctly in the background. Run Now now shows "Agent started — dashboard will update when it finishes" immediately and the dashboard watcher picks up the completed state when it lands (timeout bumped to 300 s for the catch-stuck-process case).
### Uninstall
- **One-click uninstall** driven by `template.lock.json`. The preview sheet lists every file, cron job, Keychain ref, and memory block that will be removed, and every user-created file that will be preserved.
- **User content is never removed.** Files you (or the agent) added to the project dir after install — like a `sites.txt` or `status-log.md` — are detected and listed as "keep" in the preview. The project directory itself is removed only if nothing user-owned is left inside.
- **Clean global state.** The isolated `~/.hermes/skills/templates/<slug>/` namespace is removed wholesale. Tagged cron jobs are removed via `hermes cron remove`. Every recorded Keychain ref is cleared via `SecItemDelete`. The memory block between the `<!-- scarf-template:<id>:begin/end -->` markers is stripped, leaving the rest of MEMORY.md intact. The project registry entry is removed last.
- **No undo.** Uninstall is destructive — to reinstall, run the install flow again.
### Under the hood
- New models in `Core/Models/ProjectTemplate.swift` (manifest, inspection, install plan, lock file v2) and `Core/Models/TemplateConfig.swift` (schema + typed values + Keychain ref model).
-`Core/Services/ProjectTemplateService.swift` unzips, parses, and validates; `ProjectTemplateInstaller.swift` executes the plan with preflight + fail-fast semantics; `ProjectTemplateUninstaller.swift` reverses an install driven by the lock file; `ProjectTemplateExporter.swift` builds bundles from a live project + selections.
-`Core/Services/ProjectConfigService.swift` owns load/save/validation of `<project>/.scarf/config.json` + secret resolution; `Core/Services/ProjectConfigKeychain.swift` is the thin `SecItemAdd`/`Copy`/`Delete` wrapper (the only Keychain consumer in Scarf today).
-`Core/Services/TemplateURLRouter.swift` is the process-wide landing pad for `scarf://` URLs so a cold-launch browser click still reaches the install sheet.
- New Swift Testing suites covering 57 tests across the service / installer / uninstaller / exporter / config / Keychain / URL-router paths.
- New Python validator (`tools/build-catalog.py`) + test suite (`tools/test_build_catalog.py`, 24 tests) mirrors the Swift invariants for the CI gate and the site generator. Schema is Swift-primary — additions go to Swift first, Python mirrors.
-`scripts/catalog.sh` wraps the validator with `check / build / preview / serve / publish` subcommands that parallel the `scripts/release.sh` shape.
### Migrating from 2.1.x
Sparkle will offer the update automatically. No config migration needed. Existing projects are untouched — templates are additive. If you had a v2.2.0-dev install of the earlier `project-templates` branch, uninstall and reinstall any previously-installed templates to pick up the schema-version-2 lock file.
Thanks to everyone who tested drafts of the install flow, caught the "Run Now blocks on agent" bug, and pushed on the Remove-vs-Uninstall UX until it was clear. A 2.3 follow-up will extend the catalog validator to enforce per-field-type constraints at PR-time (currently enforced on install but not at submission).
A patch release covering Template Configuration rendering fixes reported against v2.2.0, plus a new catalog template that packages a Hermes skill for scaffolding new Scarf projects.
### Configuration sheet — no more clipping
Two independent rendering fixes to the post-install Configuration editor and the install-flow Configure step:
- **Enum fields with long option labels.** An enum with three or four options whose labels exceeded ~20 characters — e.g. a Claude-model picker with labels like *"Claude Opus 4 (Recommended - Most Capable)"* — rendered as a segmented picker that sized to the intrinsic width of all labels concatenated. On macOS, `.pickerStyle(.segmented)` refuses to respect offered width, refuses to wrap, refuses to truncate. The result was a ~650pt picker that overflowed the sheet's 560pt viewport and clipped the entire form on both sides. Enum fields now always render as a dropdown Menu picker, which surfaces long labels in the popup list and respects the parent's offered width regardless of option count or label length.
- **Descriptions with unbreakable content.** Field descriptions rendered via inline AttributedString markdown can contain tokens SwiftUI's `Text` refuses to break mid-token (raw URLs, long paths). Added `.frame(maxWidth: .infinity, alignment: .leading)` on the sheet's inner VStack and on each field row as a secondary constraint, so description text wraps at whitespace boundaries instead of expanding the sheet width. Applied the same modifier to `TemplateInstallSheet`'s main preview VStack for symmetry — installs with README blocks or cron prompts containing long URLs now wrap cleanly too.
### New catalog entry — `awizemann/template-author`
A `.scarftemplate` whose only content is a Hermes skill (`scarf-template-author`) plus a minimal dashboard that points users at it. Installing the template drops the skill at `~/.hermes/skills/templates/awizemann-template-author/scarf-template-author/SKILL.md`, discoverable by Claude Code, Cursor, Codex, Aider, and every other agent that reads the standard `~/.hermes/skills/` directory.
The skill teaches agents how to scaffold a new Scarf-compatible project through a short interview — purpose, data source, cadence, widgets, config, secrets — then write `<project>/.scarf/dashboard.json`, `<project>/.scarf/manifest.json`, `<project>/AGENTS.md`, and `<project>/README.md`. Scaffolded projects are usable locally and cleanly exportable as `.scarftemplate` bundles via Scarf's Export flow later. [Catalog detail page →](https://awizemann.github.io/scarf/templates/awizemann-template-author/)
v1 is fully conversational / blank-slate. Pre-baked archetypes (monitor, dev-dashboard, personal-log) are deferred to a future release pending real usage data.
### Authoring guidance — SKILL.md
The `scarf-template-author` skill now tells scaffolding agents to prefer markdown link syntax (`[label](https://…)`) over raw URLs in schema field descriptions. Raw URLs work now (v2.2.1's description wrap fix above handles them gracefully), but `[Anthropic console](https://console.anthropic.com)` reads cleaner in the form than a dumped URL. Same rule extended to long paths or other unbreakable strings — wrap in inline code if they have to appear verbatim, prefer markdown links otherwise.
### Under the hood
- **`scripts/catalog.sh publish` fix.** The pre-flight `need_ghpages` check tested `[[ -d "$GHPAGES_DIR/.git" ]]` — "is `.git` a directory?" — which is true for a regular clone but false for a `git worktree add` worktree (where `.git` is a pointer file). `release.sh` creates and leaves the gh-pages worktree around, so after any release the subsequent catalog-publish call was rejected with a misleading "run `git worktree add`" error on a worktree that was already there and valid. Switched to `-e` (exists, either file or directory). Unblocks publishing the catalog immediately after a release.
### Migrating from 2.2.0
Sparkle will offer the update automatically. No config migration needed. Existing template installs are untouched.
If you've already installed `awizemann/template-author` from a pre-release build, no action needed — the catalog and bundle content are forward-compatible.
Two themes land together in this release. The projects sidebar stops being a flat list and becomes a workspace — folders, rename + archive + search + keyboard jumps, a per-project Sessions tab, and every project-scoped chat now automatically carries Scarf-managed context into the agent itself. And Scarf catches up to **Hermes v0.10.0's Tool Gateway**: paid Nous Portal subscribers can now route web search, image generation, TTS, and browser automation through their subscription without separate API keys — and they can sign in entirely from Scarf, no terminal needed.
### Projects sidebar grows up
- **Folders.** Group related projects with folders. Right-click any project → *Move to Folder…* — pick an existing folder or create a new one on the fly. Folders are soft: any folder name that isn't referenced by at least one project just disappears, so there's no "empty folder" state to clean up.
- **Rename** a project from the context menu. Preserves everything else — the path, folder assignment, archive flag, and any running cron attribution stay intact. Rejects duplicate names + empty input with an inline warning.
- **Archive / Unarchive.** Hide projects you don't actively use without deleting anything. The sidebar's bottom bar gains a Show Archived toggle so they're one click away when you need them.
- **Search.** ⌘F focuses a filter field at the top of the sidebar. Fuzzy-matches on name, path, and folder label, live as you type.
- **Keyboard jumps.** ⌘1 through ⌘9 jump to the first nine top-level projects. Pairs cleanly with Scarf's existing window-level shortcuts.
Registry migration is non-destructive — `~/.hermes/scarf/projects.json` gains two optional fields (`folder`, `archived`), and a file written by v2.3 is still parseable by v2.2.1 (unknown-keys are ignored), so downgrade works if you ever need it.
### Per-project Sessions tab
Every project now has a **Sessions** tab alongside Dashboard and Site. It shows chats attributed to this specific project — the sidecar at `~/.hermes/scarf/session_project_map.json` maintains the session-to-project mapping (Hermes's `state.db` has no column for this, so Scarf owns the record).
- **New Chat** — spawns `hermes acp` with the project's directory as the session's working directory, attributes the resulting session to the project, and takes you straight into the chat view.
- **Click any listed session to resume it** in the Chat tab; the project indicator comes along automatically.
- Forward-only attribution: sessions you've already started via the CLI or via the global Chat sidebar section continue to live in the global Sessions view unchanged; they simply aren't attributed to any project.
File descriptors are released cleanly on tab-disappear, matching Scarf's other Hermes-DB-reading VMs.
### Agent context injection via AGENTS.md
The architectural headline of this release. Hermes has no native "project" concept and ACP's wire protocol drops extra session params. But Hermes DOES auto-read `AGENTS.md` from the session's cwd at startup (priority: `.hermes.md` → `HERMES.md` → `AGENTS.md` → `CLAUDE.md` → `.cursorrules`, first match wins, 20KB cap). So Scarf leans on that.
Every time you start a project-scoped chat, Scarf writes a managed block into `<project>/AGENTS.md`:
```
<!-- scarf-project:begin -->
## Scarf project context
You are operating inside a Scarf project named "<Project Name>". …
- Project directory: …
- Dashboard: …
- Template: <id> v<version>
- Configuration fields: field_a, api_token (secret — name only, value stored in Keychain)
Ask a fresh chat *"what project am I in?"* and the agent answers with the project name, dashboard path, template id, and current cron schedule — pulled from the block Hermes injected into its system prompt automatically.
**Invariants the block guarantees:**
- **Secret-safe.** Surfaces config field *names* with type hints; never values. A project whose config.json has Keychain-ref URIs renders the fields as `api_token (secret — name only, value stored in Keychain)`. Keychain URIs and plaintext values never appear in the block. Locked in by an explicit test (`refreshListsFieldNamesNotValues`).
- **Idempotent.** Two consecutive refreshes with unchanged state produce byte-identical output. The write is skipped entirely when no delta — no unnecessary file-watcher churn.
- **Bounded.** Everything outside the `<!-- scarf-project -->` markers is preserved across every refresh. Template-author AGENTS.md content lives safely below the block; hand-edits are never clobbered.
- **Non-fatal.** A failed block refresh doesn't block the chat from starting — logged + the session proceeds without the extra context.
- **Bare-project friendly.** Projects without an AGENTS.md (plain directories added via the + button) get one created with just the block. Agent awareness works even without template scaffolding.
**Template-author contract:** leave the `<!-- scarf-project -->` region alone in your bundled `AGENTS.md`. Put template-specific instructions below it so they're preserved across refreshes. The `scarf-template-author` scaffolding skill already teaches this pattern to future agents doing project scaffolding.
**Known caveat:** if any parent directory of your project contains a `.hermes.md` or `HERMES.md`, that file takes priority over the project's AGENTS.md in Hermes's discovery order — the Scarf block gets shadowed. No fix in 2.3 — planned for 2.4 pending design input on handling authored `.hermes.md` files.
### Chat UI — project awareness everywhere
Once the cwd, attribution, and AGENTS.md pieces land, the UI follows:
- **Folder chip in `SessionInfoBar`** at the start of the bar (before the working dot + title) shows the active project name with a folder icon.
- **Resumed sessions keep the indicator.** Whether you click a session in the project's Sessions tab or come in from a future deep-link, the attribution is looked up at resume time and the chip renders from the same state.
### Window-layout fixes
A pre-existing issue — untracked until v2.3's heavier Chat/Sessions content exposed it — where the window grew past the screen when you switched to content-heavy sections. Fixed by:
- Setting `WindowGroup.windowResizability(.contentMinSize)` so the window's floor (not ceiling) is derived from content.
- Capping `idealHeight` on `RichChatView` and `ProjectSessionsView` so their plain-VStack children (deliberate choice to dodge a LazyVStack whitespace bug) don't report screen-exceeding ideals upward through `NavigationSplitView.detail`.
Window now stays at a user-draggable size and persists across section switches.
### Under the hood
- New models: `SessionProjectMap` — `~/.hermes/scarf/session_project_map.json` serialization (`SessionAttributionService` manages it).
- New services: `SessionAttributionService` (reads + writes the sidecar), `ProjectAgentContextService` (writes the AGENTS.md marker block, tests cover prepend/replace/idempotency/secret-redaction).
- New view models: `ProjectSessionsViewModel` (per-project session list with attribution filter), `ChatViewModel` gains `currentProjectPath` + `currentProjectName`.
-`HermesFileWatcher` now watches the attribution sidecar — file-system events propagate through the VMs as they do for every other Scarf-written file.
- **Tool Gateway services.** `NousSubscriptionService` reads `~/.hermes/auth.json` to detect the subscription state. `NousAuthFlow` spawns `hermes auth add nous --no-browser` (with `PYTHONUNBUFFERED=1` so the device-code block surfaces immediately — Python block-buffers otherwise), parses the verification URL + user code with two line-anchored regexes, auto-opens the approval page via `NSWorkspace`, and confirms success by re-reading `auth.json`. `NousSignInSheet` drives the four-state UI (starting / waiting-for-approval / success / failure-with-billing-link). `CredentialPoolsOAuthGate` is the testable helper that routes providers to the right OAuth flow based on their overlay auth-type.
- **Catalog overlay merge.** `ModelCatalogService` gains a static `overlayOnlyProviders` table mirroring the 6 entries from `HERMES_OVERLAYS` in `hermes-agent/hermes_cli/providers.py`. `HermesProviderInfo` carries `isOverlay` and `subscriptionGated` flags so the picker can render them distinctly.
- **Config parsing.** `HermesConfig` gains `platformToolsets: [String: [String]]`; `HermesFileService` parses the `platform_toolsets.<platform>` block from `config.yaml` as written by `hermes setup tools`.
- **36 new Swift tests** across `ProjectRegistryMigrationTests`, `ProjectsViewModelTests`, `SessionAttributionServiceTests`, `ProjectAgentContextServiceTests` (22 for v2.3 projects work) + `ToolGatewayTests`, `NousAuthFlowParserTests`, `CredentialPoolsGatingTests` (14 for Tool Gateway). Total: 120 tests, all green against v2.3-projects + Tool Gateway combined.
### Icon tweak
App icon files renamed from iOS-template suffixes to macOS-native filenames + paired `Contents.json` update. Pure naming; no visual change at any rendered size.
### Tool Gateway — Nous Portal support
Hermes v0.10.0 introduced a **Tool Gateway**: paid [Nous Portal](https://portal.nousresearch.com) subscribers route web search (Firecrawl), image generation (FAL / FLUX 2 Pro), text-to-speech (OpenAI TTS), and browser automation (Browser Use) through their subscription. No separate API keys, no credential pool juggling. Scarf 2.3 surfaces the whole flow natively.
- **Nous Portal appears in the model picker.** Our picker used to read only the models.dev cache, which doesn't list Nous — so it was invisible. Scarf now merges Hermes's `HERMES_OVERLAYS` table on top of the cache, surfacing **six previously-hidden providers**: Nous Portal, OpenAI Codex, Qwen OAuth, Google Gemini CLI, GitHub Copilot ACP, and Arcee. Subscription-gated providers sort first, with a **Subscription** pill so they're visually distinct from BYO-key providers.
- **In-app sign-in.** Click *Sign in to Nous Portal* in the picker (or in the Auxiliary tab's fallback, or Credential Pools for the `nous` provider) and Scarf runs the device-code flow: opens the approval page in your browser, shows the device code in a large monospaced badge you can copy, and auto-detects success by re-reading `~/.hermes/auth.json`. No six-step terminal dance. Subscription-required failures surface a **Subscribe** button that opens the portal's billing page directly.
- **Per-task gateway routing.** The Auxiliary tab's 8 sub-model tasks (vision, web_extract, compression, session_search, skills_hub, approval, mcp, flush_memories) each gain a "Nous Portal" toggle. Enabling it flips `auxiliary.<task>.provider` to `nous` — Hermes derives gateway routing from that, no separate `use_gateway` key needed.
- **Health surface.** A new **Tool Gateway** card in Health shows subscription state, `platform_toolsets` wiring, and which aux tasks are currently routed through Nous.
- **Credential Pools dead-end fixed.** Before: selecting `nous` in the Add Credential sheet and clicking *Start OAuth* silently stalled (the PKCE URL regex never matched the device-code output). Now the sheet detects Nous and routes to the dedicated sign-in flow. For the other non-PKCE providers (OpenAI Codex, Qwen OAuth, Google Gemini CLI, GitHub Copilot ACP), the button disables with an inline hint pointing to `hermes auth add <provider>` — no more silent failures. PKCE providers (Anthropic, etc.) behave exactly as before.
- **Messaging Gateway rename.** Scarf's pre-existing "Gateway" section (Slack / Discord / inbound messaging) is renamed throughout to **Messaging Gateway** to disambiguate from the new Tool Gateway. Same feature, clearer name. Sidebar, dashboard card, menu-bar status, log-source filter, and Settings → Agent section header all updated. Internal enum cases and file paths (`gateway_state.json`, `gateway.log`) are unchanged.
If you don't use Hermes v0.10.0 or don't have a Nous subscription, nothing in your flow changes — the Tool Gateway surface only activates when it's relevant. Sign-in state reads `~/.hermes/auth.json` in read-only mode; Scarf never writes to the credential file.
### Migrating from 2.2.x
Sparkle will offer the update automatically. No config migration needed. Existing template installs are untouched — the v2.3 additions (folders, archive, sidecar) are purely additive; a v2.2.1 projects.json loads cleanly.
If you had any chat sessions attributed to projects in a pre-release v2.3 build, the forward-only attribution model means those sidecar entries surface correctly in the new Sessions tab on first launch.
**Hermes version.** The Tool Gateway features target [Hermes v0.10.0](https://github.com/NousResearch/hermes-agent/releases/tag/v2026.4.16) or newer. If you're on v0.9.0 the rest of Scarf 2.3 works, but Nous Portal won't appear in the picker (it's sourced from `HERMES_OVERLAYS` in v0.10.0+) and the Tool Gateway card won't have subscription data to show. Updating Hermes is `pipx upgrade hermes-agent` or the equivalent for your install method.
### Documentation
- **[Project Templates wiki page](https://github.com/awizemann/scarf/wiki/Project-Templates)** — gained a "How the agent sees the project" section covering the AGENTS.md injection pattern.
- **[Hermes Version Compatibility](https://github.com/awizemann/scarf/wiki/Hermes-Version-Compatibility)** — bumped recommended minimum to v0.10.0, new subsection covering Tool Gateway feature gating.
- **[Core Services](https://github.com/awizemann/scarf/wiki/Core-Services)** — new rows for `NousSubscriptionService` and `NousAuthFlow`, updated `ModelCatalogService` entry noting overlay merge.
- **Root `CLAUDE.md`** — new subsection "Project-scoped chat + Scarf-managed AGENTS.md context (v2.3)" under Project Templates, plus the Tool Gateway subsection under Hermes Version covering the overlay table and per-task gateway contract.
- **`scarf-template-author` skill** — pitfall bullet added so future scaffolding agents preserve the marker region when authoring new templates.
### Thanks
Thanks to the users who exercised this release through several layout iterations, caught the `fetchSessions` short-circuit on a fresh VM, and pushed on the "agent doesn't know what project it's in" question until the AGENTS.md mechanism clicked. Several of these fixes are small on their own but add up to a much tighter per-project workflow.
Single source of truth for every field you paste into App Store Connect → My Apps → ScarfGo. TestFlight-specific fields (Beta App Description, "What to test") live in [TESTFLIGHT_CHECKLIST.md](TESTFLIGHT_CHECKLIST.md). This file covers the full App Store listing for when ScarfGo graduates from TestFlight to the public store.
All character counts are pre-counted against Apple's published limits. Counts include trailing punctuation but exclude the leading `> ` Markdown blockquote markers.
## App information (set once, persists across builds)
## Per-version metadata (resubmit on each App Store release)
### Promotional text (max 170 chars, editable without resubmission)
```
Manage your Hermes AI agent from your phone. Connect to any SSH-reachable Hermes host, run sessions, edit memory, browse cron jobs, resume conversations.
```
_153 / 170 chars._
### Description (max 4000 chars)
```
ScarfGo is the iPhone companion to Scarf, the open-source macOS GUI for the Hermes AI agent. It connects from your phone to a Hermes server you operate — your Mac, a home Linux box, a cloud VM, anything reachable over SSH — and lets you run sessions, browse memory, manage cron jobs, and resume conversations on the go.
A fully native iOS app, not a web view or a remote desktop. ScarfGo speaks SSH directly using a pure-Swift implementation, reads Hermes state via SFTP and SQLite snapshots, and streams real-time agent output over the Agent Client Protocol on a long-lived SSH exec channel. Every byte stays between your device and the Hermes host you configured.
What you can do:
• Multi-server. Configure as many Hermes hosts as you like and switch between them with a tap. Soft Disconnect keeps your credentials cached; Forget wipes a server end-to-end.
• Dashboard. Stats and the 25 most recent sessions, with project badges so you can tell at a glance which work is which.
• Project-scoped chat. Pick a project from your registry and ScarfGo writes the same Scarf-managed AGENTS.md context block the Mac app does, so the agent boots with the right project context. The resulting session is attributed correctly across both clients.
• Session resume. Tap any row on the Dashboard to open that session's transcript in Chat. CLI-started sessions hydrate from the Hermes state database; ACP sessions show an empty-state because Hermes does not persist ACP transcripts to the database.
• Memory editor. Read and edit MEMORY.md and USER.md with a Saved indicator that survives keyboard dismissal and a one-tap Revert.
• Cron list. Human-readable schedules ("Every 6 hours", "Weekdays at 09:00") instead of raw cron expressions, plus a relative next-run estimate. Read-only in this release; editing comes in a future update.
• Skills browser. Read-only category tree with the SKILL.md frontmatter chips (allowed tools, related skills, dependencies) the Mac app shows.
• Settings viewer. Read-only inspection of your config.yaml. Edit values from the Mac app or a remote shell.
Privacy. ScarfGo does not collect, transmit, or store your data on any server controlled by the developer. There are no analytics, no telemetry, no ad identifiers. SSH keys are generated on-device and stored in the iOS Keychain with the ThisDeviceOnly attribute, so they are unreachable while the device is locked and never sync to iCloud. The complete privacy policy lives at awizemann.github.io/scarf/privacy.
Open-source under the MIT license. Source, issue tracker, and contributor docs at github.com/awizemann/scarf. Bug reports tagged component:scarfgo go straight to the developer.
Requirements. iOS 18.0 or later. An SSH-reachable Hermes server (Hermes v0.10.0 or later recommended; full v0.11.0 features supported). Your phone needs to reach that server on the network — same Wi-Fi, VPN, Tailscale, or any port-forwarded address SSH can dial.
```
_2873 / 4000 chars._
### Keywords (max 100 chars, comma-separated, no spaces between terms)
Brand-safe — no competitor product names. Apple flags trademarks like "Claude" or "OpenAI" as unauthorized brand use during review even when they appear as descriptive context.
### What's New text (max 4000 chars)
For v2.5.0 — first public App Store release. Trimmed from `RELEASE_NOTES.md`'s ScarfGo section to fit the iOS audience.
```
First public release of ScarfGo, the iPhone companion to the Scarf macOS app.
What's in this release:
• Multi-server. Configure multiple Hermes hosts and switch between them with a tap.
• Dashboard. Sessions, messages, and tool-call counts, plus the 25 most recent sessions with project badges and a project filter.
• Chat. Streamed agent responses over SSH with tool-call disclosure groups, code blocks, and project-scoped session start.
• Session resume. Tap any session on the Dashboard to open it in Chat.
• Memory editor. Read and edit MEMORY.md and USER.md with on-device save indication and one-tap Revert.
• Cron list. Human-readable schedules ("Every 6 hours", "Weekdays at 09:00") with relative next-run.
• Skills browser. Read-only category tree with SKILL.md frontmatter chips.
• Settings viewer. Read-only inspection of config.yaml. Edit values from the Mac app.
Known limitations in v1: no push notifications (the skeleton is in the binary, gated behind an internal flag pending Apple Developer Program enrollment and an APNs key); no in-app config editor; no template install UI; English only. iPad layout works via the system sidebar adaptive style but has not been polished — feedback welcome via TestFlight.
Privacy. No analytics, no telemetry, no developer-controlled servers. Read the full policy at awizemann.github.io/scarf/privacy.
```
_1150 / 4000 chars._
### Build (autopopulated)
Apple fills this in once the binary uploads + processes. The same build that went through TestFlight Beta Review is the one you ship to the public store.
### Version
Marketing version: `2.5.0` — the same number `release.sh` will write to `MARKETING_VERSION` for the macOS Scarf release. Keeping the iOS + Mac versions in lockstep is the convention this project uses.
---
## Build artifact
### App icon (1024×1024)
```
scarf/Scarf iOS/Assets.xcassets/AppIcon.appiconset/AW Mac OS Applications-macOS-Default-1024x1024@1x.png
```
The full appiconset is in repo and the Xcode target references it via `AppIcon`. App Store Connect pulls the 1024 from the binary on upload — no separate upload step.
### Screenshots
**Required for the public App Store, NOT required for TestFlight.** Scope deliberately excluded from this prep pass — capture from the simulator before flipping the App Store listing live. Apple requires:
- iPhone 6.7" (e.g. iPhone 16 Pro Max) — at least 5, up to 10
- iPhone 6.5" (e.g. iPhone 14 Plus) — at least 5, up to 10
- iPhone 5.5" (e.g. iPhone 8 Plus) — at least 5, up to 10
- iPad — only if you flip the iPad flag in the target. Skip for v2.5.
Suggested screen captures (rough order):
1. Dashboard with stats + recent sessions list
2. Chat in mid-stream with a tool-call disclosure expanded
3. Project picker sheet
4. Sessions tab with project filter active
5. Memory editor with Saved indicator
6. Skills detail with frontmatter chips visible
7. Server list (showing multi-server)
8. Onboarding step 5 (public-key display)
### App preview video (optional)
Skip for v1. Apple will accept the listing without it.
Cross-reference [TESTFLIGHT_CHECKLIST.md](TESTFLIGHT_CHECKLIST.md). Once Apple's Beta Review approves the first build, the public TestFlight URL `https://testflight.apple.com/join/qCrRpcTz` accepts new joiners. Until then the link 404s with a "not accepting testers" splash.
## Public App Store submission flow (after TestFlight stabilizes)
1. App Store Connect → My Apps → ScarfGo → App Store tab → iOS App.
2. Paste every field above into the matching form.
3. Set the build to the same one that's been on TestFlight (Apple lets you reuse a TestFlight build verbatim — no re-upload).
4. Submit for review. Apple's standard App Review queue (separate from Beta Review) is typically 24–72h. Watch your inbox for "We have a question" emails and reply via App Store Connect's review-team chat.
5. On approval, choose "Manually release this version" so you can announce on a schedule.
## Update cadence
The same `releases/v<VERSION>/` directory pattern this file lives in is the canonical staging area for every future iOS release. When v2.6 (or whatever ships next) bumps the iOS app, copy this file forward and update:
- **What's New text** — what changed since the last App Store release.
- Everything else above stays unless you're changing categories, support URL, or privacy stance.
The Mac `release.sh` does not yet drive the iOS release — that's a separate Xcode Archive + App Store Connect upload. See `TESTFLIGHT_CHECKLIST.md` Phase 4 for the archive flow.
The big one for 2.5: **ScarfGo, the iPhone companion**, ships in public TestFlight. Same Hermes server you've been running on your Mac — now reachable from your phone over SSH. Dashboard, chat, memory, cron, skills, settings (read), all of it. On the Mac side, the global Sessions list grows up alongside the iOS work — project filter, project badges on each row. **Plus**: full Hermes v2026.4.23 chat parity (`/steer`, per-turn stopwatch, numbered approvals, git branch chip), portable project-scoped slash commands that ship with `.scarftemplate` bundles, in-app Spotify OAuth, and design-md prereq checks.
### ScarfGo iOS companion (public TestFlight)
ScarfGo is a fully native iOS app — not a web view, not a remote desktop. It speaks SSH (Citadel under the hood), reads your Hermes state directly from your Mac (or wherever Hermes is running), and lets you tap a session to resume it from where you left off. Per-project chat works end-to-end: pick a project on your phone, the agent gets the same Scarf-managed `AGENTS.md` context block the Mac writes, and the resulting session shows up correctly attributed in the Dashboard's Sessions tab.
What's in the first public TestFlight build:
- **Multi-server.** Configure as many Hermes hosts as you want, switch between them from a single sidebar-adaptable tab root. Soft disconnect keeps your credentials; "Forget" wipes a server end-to-end (Keychain + UserDefaults).
- **Dashboard.** Stats + the 25 most recent sessions; an Overview tab and a Sessions tab with a project filter Menu.
- **Chat.** Full ACP (Agent Client Protocol) over SSH — streamed responses, tool-call disclosure groups, code blocks with horizontal scroll, "Connecting…" → "Ready" lifecycle, error banner with copy-to-clipboard for non-retryable failures.
- **Project-scoped chat.** "+ In project…" sheet picks from your project registry over SFTP. Writes the Scarf-managed `AGENTS.md` block before spawning `hermes acp` so the agent boots with project context. Records the resulting session ID in the attribution sidecar so the Mac picks it up.
- **Session resume.** Tap any session on the Dashboard → opens Chat with `loadSession`. Older CLI-started sessions hydrate from `state.db`; newer ACP sessions show an empty-state explaining the agent has the context but the local transcript isn't cached.
- **Memory editor.** Read + edit `MEMORY.md` and `USER.md`, with a "Saved" pill that survives keyboard dismissal and a Revert button.
- **Cron list.** Read-only for now, but with **human-readable schedules** ("Every 6 hours", "Weekdays at 09:00") instead of raw `0 */6 * * *`. Mac gets the same formatter.
- **Skills + Settings.** Read-only. Skills shows category structure; Settings shows your `config.yaml` for inspection (no editor in 2.5).
- **iOS 18+.** Dynamic Type clamp at the scene root, sidebar-adaptable TabView, scoped sheet detents, scroll anchoring, content-aware empty states throughout.
**TestFlight invite:** see the [ScarfGo wiki page](https://github.com/awizemann/scarf/wiki/ScarfGo) for the public link + onboarding walkthrough.
### Portable project-scoped slash commands
A net-new Scarf primitive (Hermes has no project-scoped slash command concept — Scarf invents the format and intercepts the chat menu client-side). Author reusable prompt templates as Markdown files at `<project>/.scarf/slash-commands/<name>.md` with YAML frontmatter (name, description, argumentHint, optional model override, tags). Invoke as `/<name> [args]` from chat — Scarf substitutes `{{argument}}` (and `{{argument | default: "..."}}`) in the body and sends the expanded prompt to Hermes; the agent never sees the slash. Works uniformly on Mac + iOS, local + remote SSH, against any Hermes version.
- **Mac authoring tab.** Per-project view gains a Slash Commands tab alongside Dashboard / Site / Sessions. List, add, edit, duplicate, delete; live preview pane shows the expanded prompt with a sample-argument field so authors see exactly what Hermes will receive.
- **iOS read-only browser.** ScarfGo's chat project context bar grows a `<N> slash` chip when the project has slash commands; tap to browse them in a sheet. Multi-line markdown editing is a phone keyboard's nightmare, so v2.5 keeps Mac as the canonical editor; iOS catches up in v2.6+.
- **AGENTS.md block extension.** The Scarf-managed project context block now lists available commands so the agent can answer "what slash commands does this project have?" and recognise the `<!-- scarf-slash:<name> -->` marker prepended to expanded prompts.
- **`.scarftemplate` format extension** (schemaVersion 3). Templates ship slash commands by including `slash-commands/<name>.md` files at the bundle root and listing them in `manifest.contents.slashCommands`. The installer copies them to the project's `.scarf/slash-commands/` dir; the lock file tracks them for clean uninstall (user-authored commands in the same dir survive uninstall).
- **Catalog validator** (`tools/build-catalog.py`) mirrors the Swift verifier. Schema version bumps to 3 only when the bundle ships slash commands; v1/v2 templates stay byte-compatible.
### Hermes v2026.4.23 chat parity
Scarf 2.5 mirrors the chat-surface features Hermes's TUI rewrite shipped this week:
- **`/steer <prompt>`** — non-interruptive mid-run guidance. Surfaces in the slash menu as a special command; sending it doesn't flip the "Agent working…" indicator (the agent's still on its current turn) and shows a transient toast above the composer: "Guidance queued — applies after the next tool call."
- **Per-turn stopwatch** — wall-clock duration of each completed assistant turn renders as a compact pill (`4.2s` / `1m 12s`) on the bubble's metadata footer (Mac) or below the bubble (iOS). Resumed sessions loaded from `state.db` show no pill (timing is captured live only).
- **Numbered keyboard shortcuts on permission sheet** — Mac approval sheet binds 1–9 to the option buttons (visible "1. " / "2. " prefixes). Power users approve / deny without reaching for the mouse. iOS shows the same numbered hints as a hierarchy cue without the keyboard binding.
- **Git branch indicator** — the chat header shows the project's current git branch as a tinted chip alongside the project name (e.g. `📂 myproject · main`). One SSH `git rev-parse --abbrev-ref HEAD` call per session start; nil-out gracefully on non-git dirs / missing git / SSH errors.
### Spotify + design-md skill onboarding
Hermes v2026.4.23 added two new skills. Scarf surfaces them properly:
- **Spotify (`spotify`)** — needs OAuth via `hermes auth spotify`. Mac ships a dedicated Sign-in sheet (mirroring the v2.3 Nous Portal pattern): runs the subprocess, regex-detects the `accounts.spotify.com/authorize?...` URL, auto-opens it in your browser, polls `~/.hermes/auth.json` after subprocess exit to confirm the token landed. Five-state machine (starting → waiting → verifying → success / failure) with retry. iOS surfaces a documentation row noting OAuth needs to happen from Mac or a shell — phone OAuth flows are their own UX problem.
- **design-md (`design-md`)** — requires `npx` (Node.js 18+) on the host. New `SkillPrereqService.probe(binary:)` runs `which npx` over the transport on skill detail appear; on miss, both Mac and iOS render a yellow banner with an install hint (per-OS).
### SKILL.md frontmatter chips
Hermes v2026.4.23 SKILL.md files carry richer YAML frontmatter (`allowed_tools`, `related_skills`, `dependencies`). Scarf parses it on both platforms (Mac via `HermesFileService.parseSkillFrontmatter`, iOS via `IOSSkillsViewModel.parseFrontmatter`) and renders chip rows in the skill detail view. Old skills without these fields stay nil and the rows hide themselves.
### "What's New" pill on Skills tab
Per-server snapshot of `[skillId: signature]` (file count + sorted file names). When the snapshot changes between visits, both Skills views render a tinted pill at the top: "2 new, 4 updated since you last looked." Tap "Mark as seen" to update the snapshot. First-time loads silently prime so users don't see "everything is new!" noise on a fresh install. Persisted to `~/Library/Application Support/com.scarf/skill-snapshots/<serverID>.json` (Mac) / `UserDefaults` (iOS).
### state.db deltas (Hermes v0.11)
-`messages.reasoning_content` — newer richer reasoning channel some providers emit alongside the legacy `reasoning` blob. UI prefers the new column when both are populated (`HermesMessage.preferredReasoning`).
-`sessions.api_call_count` — distinct from `tool_call_count`; counts per-turn API round-trips. Surfaced as the "API" label on Mac SessionDetailView and as a network-icon chip on Mac/iOS Dashboard session rows.
`HermesDataService.hasV011Schema` only flips true when both columns are present (partial migrations stay on the v0.7 path to avoid runtime errors). Older Hermes hosts keep working unchanged.
### `hermes memory reset` toolbar action
New toolbar button on Mac MemoryView — "Reset memory…" with destructive confirmation dialog. Routes through `hermes memory reset --yes`; refreshes the on-screen content on success, surfaces stderr in an alert on failure. Other v0.11 CLIs (`plugins`, `profile`, `webhook`, `insights`, `logs`) are documented in `CLAUDE.md` for future v2.6 adoption — Scarf still reads the underlying files directly today, which keeps working.
### Mac global Sessions: project filter + badges
The per-project Sessions tab shipped in 2.3, but the global Sessions feature still rendered every session as a flat list with no project context. 2.5 closes the gap:
- **Filter Menu** above the list: All projects / Unattributed / one entry per registered project. An xmark button clears the filter; the right side shows "X of Y shown".
- **Project badge** on each row — small tinted folder chip with the project name. Same visual language ScarfGo uses on its Dashboard.
- Logic comes from the same `SessionAttributionService` + `ProjectDashboardService` ScarfGo consumes, both in ScarfCore. Single source of truth across platforms.
### Human-readable cron schedules everywhere
Pre-2.5, both Mac and iOS rendered cron jobs as `0 */6 * * *` raw. The new `CronScheduleFormatter` in ScarfCore translates the common shapes to plain English (every-N-minutes, every-N-hours, daily-at-H, weekdays-at-H, the `@hourly`/`@daily`/`@weekly`/`@monthly` macros) and falls back to the raw expression for anything custom. Both apps consume it.
### Under the hood
- **Shared services.** `SessionAttributionService`, `ProjectContextBlock`, and `CronScheduleFormatter` moved into ScarfCore; both apps consume them via their respective transports (`SSHTransport` on Mac, `CitadelServerTransport` on iOS).
- **`RichChatViewModel`** carries the ACP error triplet (`acpError`, `acpErrorHint`, `acpErrorDetails`) for both platforms — Mac's `ChatViewModel` now delegates instead of duplicating.
- **Test reliability.** Cross-suite races on `ServerContext.sshTransportFactory` resolved by consolidating every factory-touching test into a single `.serialized` suite. 163 tests across 12 suites, three consecutive green runs.
- **Surface silent failures.** Several `try?` swallows in iOS lifecycle code now surface to the user — Keychain unlock errors no longer dump people back into onboarding, partial Forget operations report what failed, project-context-block writes that fail surface a banner instead of silently degrading agent context.
- **iOS exec channel hardening.** `CitadelServerTransport.runProcess` was wrapping Citadel's `executeCommand`, which throws `CommandFailed` on non-zero exit and discards the captured stdout buffer in the throw path. `hermes skills browse` happens to print its full table and *then* exit non-zero on some hosts, so iOS got nothing while Mac (Foundation `Process`) got the full output with `exitCode=1`. v2.5 drives `executeCommandStream` directly, drains stdout + stderr regardless of outcome, and recovers the actual exit code from the `CommandFailed` catch. Same channel now also inline-prepends `PATH="$HOME/.local/bin:/opt/homebrew/bin:/usr/local/bin:$PATH"` on every invocation — Citadel's raw exec channel doesn't source the user's shell rc files, so non-interactive sessions land with a stripped `PATH` (`/usr/bin:/bin`) and pipx's default install dir is invisible. Mac's OpenSSH sshd handles this transparently; we now match.
- **fd-leak cleanup.** `LocalTransport` / `SSHTransport` / `ProcessACPChannel` all close the parent's copy of every pipe write end after spawn so EOF reaches the reader once the child exits, plus close read ends after draining. Was leaking one fd per `runProcess` / `streamLines` / ACP turn under load.
- **Status-poll backoff.** `ServerLiveStatus` now uses 10s → 30s → 60s → 120s → 300s exponential backoff on consecutive probe failures, resetting on the first full success. Previously a registered remote going unreachable hammered `pgrep` + `gateway_state.json` every 10s indefinitely; offline servers now settle to a 5-minute cadence while live ones stay snappy.
- **Logger conversion.** Remaining `print("[Scarf] …")` debug statements in `HermesDataService`, `HermesLogService`, and `ProjectDashboardService` swap to `os.Logger` calls (subsystem `com.scarf`), matching the global rule that production code uses `Logger` and `print()` is reserved for previews + test helpers.
### Notes for users running 2.3
No data migrations needed. Server configs, Keychain entries, project registries, session attribution sidecar — all forward-compatible. The only invariant change is iOS-only: `ScarfGo.servers.v1` UserDefaults key migrates to `com.scarf.ios.servers.v2` on first launch, and Keychain accounts move from `"primary"` to `"server-key:<UUID>"`. One-shot, idempotent — re-running 2.3 after 2.5 ran would just see the v2 data.
Push notifications stay disabled in this build. The skeleton (NotificationRouter, category registration, action handlers) is in place behind `apnsEnabled = false` for when Hermes ships a push sender + we get an APNs cert.
Pre-flight steps to take ScarfGo to public TestFlight. Order matters — Apple review wants the privacy URL to resolve at submission time, and the build needs to upload before review can start.
## 0. Apple Developer Program prerequisites
- Apple Developer Program enrollment active (team `3Q6X2L86C4`).
- iOS Distribution certificate in login Keychain (`Apple Distribution: Alan Wizemann`).
- App Store provisioning profile for the iOS bundle ID (auto-managed in Xcode is fine).
- App Store Connect access for the team.
## 1. Privacy policy live
- [ ] Copy `scarf/docs/PRIVACY_POLICY.md` content into `.gh-pages-worktree/privacy/index.html` (wrap in minimal HTML, or leave as Markdown if GitHub Pages renders Markdown — GitHub Pages with Jekyll does).
- [ ] Verify https://awizemann.github.io/scarf/privacy/ resolves (give it ~1 min after push).
The privacy URL is required by App Store Connect before submitting for Beta App Review. Without it the submission button is disabled.
## 2. Xcode target configuration
Open `scarf/scarf.xcodeproj`, select the `scarf mobile` target.
- [ ] Signing & Capabilities → "Automatically manage signing" ON, team set to `3Q6X2L86C4`.
- [ ] Capabilities present: Keychain Sharing only. **Push Notifications stays OFF** — `NotificationRouter.apnsEnabled = false` and the entitlement is absent. Match the two: enable both later together.
- [ ] Info.plist sanity:
- Bundle Identifier matches App Store Connect record.
-`NSAppTransportSecurity` allows the SSH ports the app dials? — N/A for SSH (raw TCP); ATS only governs HTTPS. Skip.
## 3. Version bump
The version bump runs automatically via `./scripts/release.sh 2.5.0` in Phase G. Do NOT bump `MARKETING_VERSION` / `CURRENT_PROJECT_VERSION` manually before that — the script writes the version commit and reads `CURRENT_PROJECT_VERSION` to compute the next build number.
v2.5.0 — first public TestFlight build of ScarfGo. Try connecting to a
Hermes host (you'll need an SSH-reachable Hermes install). Test:
- Onboarding + Add a second server
- Project-scoped chat
- Session resume from Dashboard
- Sessions tab project filter
- Forget a server / re-onboard
Known limitations: no push, no in-app Settings editor, English only.
Report issues via TestFlight feedback.
```
### Beta description copy
> ScarfGo is the iOS companion to Scarf, the Mac client for the Hermes AI agent. Connect to a Hermes server you operate (Mac, Linux, or any SSH-reachable host) and run sessions, browse memory, manage cron jobs, and resume conversations from your phone. All data stays between your device and your Hermes host — no developer servers in between.
## 6. Submit for Beta App Review
- [ ] TestFlight tab → External Testers → Add a public group called "Public Beta".
- [ ] Add the new build to the group.
- [ ] Click **Submit for Review**.
- [ ] Apple's Beta Review queue is typically 24-48h.
## 7. After approval
- [ ] Apple issues a public TestFlight URL (`https://testflight.apple.com/join/XXXXXX`).
- [ ] Record the URL — needed in Phases E (wiki ScarfGo page) and F (README v2.5 section).
- [ ] **DO NOT** publicize it yet. Update wiki + README in branches first; the user (Alan) decides when to push live.
## Rollback
If a build breaks on TestFlight:
- [ ] Disable the build in App Store Connect → TestFlight → Builds → Expire.
- [ ] Fix the bug, archive a new build with the same `MARKETING_VERSION` (Apple requires the build number — `CURRENT_PROJECT_VERSION` — to monotonically increase).
- [ ] Upload + add to Public Beta group + submit if Apple flagged the prior build for re-review.
## Open items / future TestFlight builds
- **Push notifications** — flip `NotificationRouter.apnsEnabled = true` simultaneously with: enabling the Push Notifications capability, generating an APNs auth key, deploying the Hermes-side push sender. Stops being a no-op only when all three exist.
- **iPad support** — `.tabViewStyle(.sidebarAdaptable)` is wired but iPad layout hasn't been smoke-tested. Probably free, but verify before flipping the iPad flag in the target.
- **Localization** — English only for v1. Mac ships 7 languages; iOS strings are extracted but no translations.
A patch release that bundles every issue reported against 2.5.0 plus a couple of TestFlight-driven iOS fixes. No data migrations needed — drop-in replacement for 2.5.0 on Mac, drop-in TestFlight build on iOS.
### Bug fixes
#### Mac
- **[#49](https://github.com/awizemann/scarf/issues/49) — macOS 26 "Scarf.app is damaged" recovery path.** Verified the shipped 2.5.0 bundles pass `codesign --verify --strict --deep` and `spctl --assess` on macOS 26.4.1; the user-facing "damaged" symptom in some reports turned out to be self-inflicted by destructive recovery commands. Added a [Troubleshooting section](https://github.com/awizemann/scarf/blob/main/README.md) to the README documenting the **non-destructive** fix path (`xattr -d com.apple.quarantine` only — never `xattr -rc` or `codesign --force --deep --sign -`). Hardened the release pipeline: every variant zip now goes through `codesign --verify --strict --deep` + `spctl --assess` after the final `ditto`, so any future regression in the shipped artifact fails the release before a user sees it.
- **[#46](https://github.com/awizemann/scarf/issues/46) — chat performance: long sessions no longer bog down or crash.** Long chats were doing O(n) work per streamed token because every chunk rebuilt the full message-group array AND every `MessageGroupView` / `RichMessageBubble` re-evaluated its body. Three changes cap per-chunk work at O(1) for settled groups:
-`MessageGroupView` and `RichMessageBubble` are now `Equatable` with `.equatable()` short-circuit. Settled bubbles skip body re-eval entirely while the streaming bubble still redraws.
-`RichChatViewModel.upsertStreamingMessage` patches the trailing group in place via a new `patchTrailingGroupForStreaming(...)` instead of running `buildMessageGroups()` per chunk.
-`MessageGroup.toolKindCounts` moved to the model (was an `O(m × k)` computed property re-running on every render). `ToolCallCard.formatJSON` cached via `.task(id: callId)`. `ToolResultContent.lines` cached on content change.
CPU during streaming on a 500-message session drops from sustained 100%+ to ~30–50% on representative hardware.
- **[#50](https://github.com/awizemann/scarf/issues/50) — Hermes v0.11 profile awareness.** Hermes v0.11 stores each profile in its own `~/.hermes/profiles/<name>/` directory with its own `state.db`, `sessions/`, `config.yaml`, `memories/`, etc. Pre-fix Scarf hardcoded `~/.hermes` and ignored `~/.hermes/active_profile`, so `hermes profile use coder` followed by a Scarf relaunch silently read the wrong DB — sessions, memory, cron all coming from the default profile. New `HermesProfileResolver` reads `active_profile` and resolves the effective home path; `HermesPathSet.defaultLocalHome` consults it, so every derived path automatically follows the active profile. SessionInfoBar gains a profile chip when not on the default so users can see which profile Scarf is reading from.
- **[#53](https://github.com/awizemann/scarf/issues/53) — granular reasons on the "Connected — can't read Hermes state" pill.** Tier 2 of the connection probe now distinguishes config.yaml-missing / `~/.hermes`-missing / permission-denied / Hermes-profile-active and surfaces a pill popover with the specific reason + an actionable hint + Run Diagnostics / Retry buttons. Profile case includes a copy-paste `hermes profile use default` affordance.
- **[#44](https://github.com/awizemann/scarf/issues/44) — pill and Run Diagnostics no longer disagree.** A long-standing latent bug surfaced by Tailscale Mac-to-Mac users: the pill probe and the diagnostics view ran the same `[ -r ~/.hermes/config.yaml ]` check but went through different transport paths — `transport.runProcess` for the pill (which `remotePathArg`-quotes every argument and mangled the multi-line script) vs raw `/usr/bin/ssh ... -- /bin/sh -s` for diagnostics. Result: 14/14 diagnostics passing while the pill stayed stuck on "can't read Hermes state". Extracted the diagnostics workaround into a shared `SSHScriptRunner` in ScarfCore; both probes now use it. Side benefit: the granular #53 probe script (more `$VAR`s and nested quotes) is robust against the same class of bug going forward.
- **[#54](https://github.com/awizemann/scarf/issues/54) — Add Project on remote server contexts.** The Add Project sheet always rendered a Browse button backed by `NSOpenPanel` (a Mac-local file dialog). On a remote SSH context the user picked a Mac path, the path landed in the projects registry as the project's "remote" working directory, and tool calls failed at runtime because that path doesn't exist on the Linux server. Tier-1 fix: sheet is now context-aware — local context keeps Browse unchanged; remote context hides Browse, shows a `"Path on <server> — must already exist on the server"` hint, and adds a Verify button that runs `transport.stat(path)` and renders inline ✓ / ⚠. A full SFTP-backed remote picker remains a deferred feature.
#### ScarfGo (iOS)
- **[#46](https://github.com/awizemann/scarf/issues/46) — same O(n)-per-token fix on iOS.** ScarfGo uses a different chat path (`LazyVStack` directly over `controller.vm.messages`, not message groups) so the Mac fix's `Equatable` conformances didn't propagate. Added an iOS-equivalent `MessageBubble: Equatable` with `.equatable()` at the `ForEach` call site — settled bubbles short-circuit body re-eval while the streaming bubble still redraws.
- **[#51](https://github.com/awizemann/scarf/issues/51) — keyboard now dismissable.** Pre-fix the chat composer's `TextField` had no `@FocusState`, no `.scrollDismissesKeyboard`, and no keyboard accessory toolbar; with `axis: .vertical` + `.submitLabel(.send)` the Return key inserts a newline rather than submitting. Once the keyboard rose it stuck — hiding the system tab bar (which iOS auto-hides while a keyboard is up) and trapping users in the Chat tab. Added two redundant dismissal paths: `.scrollDismissesKeyboard(.interactively)` on the message list (drag messages downward to collapse) AND a `keyboard.chevron.compact.down` button in the keyboard accessory toolbar. Tab bar reappears on dismiss → users can switch tabs again.
- **[#55](https://github.com/awizemann/scarf/issues/55) — first-run Cancel button no longer looks broken.** TestFlight feedback: the "Connect to Hermes" onboarding's Cancel button appeared dead. Root cause: `RootModel.cancelOnboarding` had a defensive `servers.isEmpty` branch that re-presented a fresh onboarding view when there was nothing to fall back to, making the button fire correctly but visually do nothing. The fix is at the right layer: `OnboardingRootView` now takes a `canCancel: Bool` parameter and hides the Cancel button entirely when there's no server list to return to.
### New features (Mac)
- **Chat density preferences ([#47](https://github.com/awizemann/scarf/issues/47) + [#48](https://github.com/awizemann/scarf/issues/48)).** New section in **Settings → Display → Chat density**. All defaults match today's UI exactly so existing users see no change until they opt in.
- **Tool calls**: Full card (default) / Compact chip / Hidden. Compact renders each call as a single-line tappable chip — kind icon + function name + status dot — that opens the right-pane inspector with the same details the inline expand shows. Hidden skips per-call rows; the always-visible group summary pill ("Used 5 tools (3 read, 2 edit)") becomes tappable so the inspector pane is still one click away.
- **Reasoning**: Disclosure box (default) / Inline (italic) / Hidden. Inline collapses the yellow disclosure to italic faded caption text inline above the reply with a small brain prefix — same data, far less vertical space. Hidden skips reasoning entirely.
- **Chat font size**: 85% to 130% slider (5% step). Applied at the chat root via `.environment(\.dynamicTypeSize, ...)` so message list, input bar, session info bar, and inspector pane all scale together.
All density toggles preserve existing telemetry surfaces — per-turn stopwatch, per-message tokens, finish reason, and timestamp stay in the bubble metadata footer; SessionInfoBar input/output/reasoning tokens, USD cost, model, project, git branch, and started-at relative time are unaffected by every density setting.
### New features (ScarfGo iOS)
- **iCloud Keychain sync for SSH keys ([#52](https://github.com/awizemann/scarf/issues/52)).** Reddit-reported friction: every iOS device needed its own SSH key. Pairing iPhone + iPad meant onboarding twice and editing `authorized_keys` per device. New opt-in toggle in **System → Security**: when enabled, the SSH key bundle is stored with `kSecAttrAccessibleAfterFirstUnlock` + `kSecAttrSynchronizable=true` so iCloud Keychain picks it up on every signed-in device. Default off (preserves today's behavior on update). Toggling triggers a one-shot migration that re-saves all stored keys with the target attributes; failure reverts the toggle and surfaces the error inline. With Advanced Data Protection enabled, the encryption keys never leave your devices.
### Documentation + tooling
- **Privacy / sandboxing claim corrected.** Previous CLAUDE.md / README implied Scarf ran sandboxed; it doesn't (and can't, given that it spawns the user-installed `hermes` binary and reads `~/.hermes/` directly). Documentation now reflects the actual posture.
- **Release pipeline hardened.** `scripts/release.sh` now extracts each variant's distribution zip and runs `codesign --verify --strict --deep` + `spctl --assess --type execute` on the extracted bundle as a final gate. Catches any future regression in the shipped artifact pre-ship rather than via user reports.
### Notes for users running 2.5.0
No data migrations needed. Server configs, Keychain entries, project registries, session attribution sidecar — all forward-compatible. The iCloud Keychain sync toggle defaults to off, so existing iOS users keep their device-local keys until they opt in.
A patch with one substantial new feature (**iOS chat resilience** — reconnect, cached snapshot fallback, history paging) plus a stack of fixes for issues reported against 2.5.1 and earlier. Drop-in replacement for 2.5.1 on Mac; drop-in TestFlight build on iOS. No data migrations.
### iOS chat resilience
ScarfGo now survives phone-sleep, network handoffs, and SSH socket drops without losing the agent's work. Hermes was already persisting messages to `state.db` in real-time; iOS just had no resync path.
- **5-attempt exponential reconnect** (1s → 2s → 4s → 8s → 16s) via `session/resume` with `session/load` fallback. Reconciles with `state.db` on success and surfaces a *"Resynced N new messages"* toast when the agent kept working through the disconnect.
- **`NetworkReachabilityService`** (NWPathMonitor singleton): suspends reconnect attempts while offline and kicks a fresh cycle on link-up. Two new banner states above the message list — `.reconnecting` and `.offline` — render as slim ScarfDesign-tinted strips so the user always knows what the chat is doing.
- **Scene-phase awareness**: returning to foreground triggers a channel-health check; if dead, the reconnect cycle starts immediately rather than waiting for the next interaction.
`ServerTransport.cachedSnapshotPath` lets `HermesDataService` fall back to the previously-pulled `state.db` snapshot when a fresh pull fails. `isUsingStaleSnapshot` + `lastSnapshotMtime` surface to views so they render *"Last updated X ago."* Chat-history reload still passes `forceFresh: true` to refuse stale data; everything else (Dashboard, Sessions list, Activity) gets read-while-disconnected for free.
### Bounded message-history paging
`HermesDataService.fetchMessages(sessionId:limit:before:)` paginates by id desc with centralized `HistoryPageSize` constants. `RichChatViewModel.loadEarlier()` walks back through long sessions via `oldestLoadedMessageID` + `hasMoreHistory`. Legacy unbounded overload deprecated.
### Bug fixes
#### Mac
- **[#46](https://github.com/awizemann/scarf/issues/46) — chat O(n)-per-token bog-down (already shipped in 2.5.1 for the trailing-group patch; this release retains the fix and pairs with the new history paging so chats with thousands of messages stay smooth).**
- **[#19](https://github.com/awizemann/scarf/issues/19) layer-3 — sqlite3 false-negative in diagnostics.** Already in v2.5.1; kept here.
- **[#44](https://github.com/awizemann/scarf/issues/44) — pill / diagnostics agreement** via shared `SSHScriptRunner`. From v2.5.1; the tier-2 probe now also checks `state.db` (not just `config.yaml`) so a healthy fresh install reports green.
- **[#59](https://github.com/awizemann/scarf/issues/59) — Settings → Model and Credential Pools no longer freeze.** Both views called `ModelCatalogService.loadProviders()` synchronously from `.onAppear` on the MainActor; on a remote SSH context that's a multi-megabyte SSH file read on the main thread, freezing the UI for 1–2 minutes. New `loadProvidersAsync()` / `loadModelsAsync(for:)` wrappers dispatch off the main thread; both views now use `.task` + `await` with a `ProgressView("Loading providers…")` overlay. Per-provider switching in the picker is also async now, so clicking a different provider doesn't re-freeze the UI.
- **Diagnostics tri-state.** Hermes v0.11+ doesn't materialize `config.yaml` until the user changes a setting from defaults — so the diagnostics view was reporting *"12/14 passing"* on healthy fresh installs. The probe now distinguishes `.pass` / `.fail` / `.skipped`; a missing `config.yaml` emits SKIP and is excluded from the summary's denominator. Reads as *"12/12 passing (2 optional skipped)"* instead of the misleading 12/14.
- **Credentials: OAuth providers visible.** `hasAnyAICredential()` only probed `credential_pool.<provider>` in `auth.json`; OAuth-authed providers land under `providers.<name>.access_token` (Nous, Spotify, GH Copilot ACP, Qwen, Gemini all use that path). The chat banner kept showing *"No AI provider credentials"* even after a successful Nous sign-in. Now both shapes count. Credential Pools view gains a parallel "OAuth providers" section listing OAuth-authed providers with token tail, expiry badge, and portal URL.
- **Project-shadowed Hermes detection.** New `ProjectHermesShadowDetector` (ScarfCore) probes each registered project at chat-start; if a `.hermes/` dir or `hermes.yaml` is found inside the project, the user gets a banner explaining that project-local Hermes config will shadow the server-level one (a quiet failure mode for users who didn't realize Hermes prefers project-local config).
- **[#58](https://github.com/awizemann/scarf/issues/58) — Mac chat side panes are hideable.** Two toolbar buttons next to the View picker (`sidebar.left` / `sidebar.right`) toggle the sessions list and tool inspector with a slide animation; both default visible (today's behavior). Clicking a tool card auto-shows the inspector if hidden so the click never silently dies. Settings → Display → Chat density gains parity Toggle rows.
#### ScarfGo (iOS)
- **[#56](https://github.com/awizemann/scarf/issues/56) — *"Citadel.SSHClient.CommandFailed error 1"* on dashboard.** `asyncSnapshotSQLite` was missed during the v2.5.0 Citadel hardening — used raw `executeCommand` (which discards stderr on non-zero exit) and didn't prepend the Citadel-friendly `PATH=$HOME/.local/bin:/opt/homebrew/bin:/usr/local/bin:$PATH`. Now uses `executeCommandStream` and the same PATH prefix. `HermesDataService.humanize` already translates `sqlite3: command not found` / `permission denied` / `no such file` into actionable user copy — the bug was that the snapshot path never fed it real stderr.
- **[#57](https://github.com/awizemann/scarf/issues/57) — keyboard-dismiss chevron over send button.** The keyboard accessory dismiss button added in v2.5.1 (#51) was placed at the trailing edge of the keyboard toolbar, directly above the trailing-edge send button. Moved to the leading edge — matches the iOS convention (Notes, Mail, Reminders).
### New features (Mac)
- **Chat-start model preflight ([commit](https://github.com/awizemann/scarf/commit/2aab9da)).** Catches a missing `model.default` / `model.provider` in `config.yaml`*before* the ACP session starts. Pre-fix the user typed a prompt, hit send, and got an opaque *"Model parameter is required"* HTTP 400 from the upstream provider. Now `ChatModelPreflightSheet` wraps the existing model picker so the same selection / validation / Nous-catalog branch is single-sourced; the chat the user originally opened lands without re-clicking the project row.
- **Nous Portal live model catalog.** `NousModelCatalogService` fetches `GET /v1/models` from `inference-api.nousresearch.com` using the bearer token in `auth.json`. Cached at `~/.hermes/scarf/nous_models_cache.json` with a 24h TTL. The picker's nous-overlay detail view switches from a free-form TextField to a real model list, with a *"Custom…"* escape hatch for IDs not yet in the API response.
- **Remote-aware admin sheets.** Three sheets gained the same context-aware Verify pattern that Add Project got in v2.5.1 (#54):
- **Profiles → Import / Export.** Buttons that drive `hermes profile import <zip>` / `hermes profile export <name> <zip>` over SSH. Local context picks via `NSOpenPanel`; remote context shows a path-input + Verify button.
- **Settings → Advanced → Restore.** Pick a local backup zip OR enter+verify a remote path.
- **Templates → Install destination.** The parent-directory step in the install sheet branches on context — local Browse, or remote text-input + Verify.
### Translations
`Localizable.xcstrings` adds strings for all the new copy across the seven supported locales (English, Simplified Chinese, German, French, Spanish, Japanese, Brazilian Portuguese).
### Notes for users running 2.5.1
No data migrations needed. `~/.hermes/scarf/nous_models_cache.json` is created lazily on first use of the Nous picker; everything else is forward-compatible with existing config / Keychain / project registries.
A major release tracking **Hermes v2026.4.30 (v0.12.0)** — the largest single Hermes update Scarf has had to follow since v0.10's Tool Gateway. Headline additions: the autonomous **Curator**, **multimodal image input** in chat, **5 new inference providers**, **Microsoft Teams + Yuanbao** gateway platforms, a **read-only Kanban** view, and ScarfGo gains read-only Webhooks/Plugins/Profiles plus a Hermes-version banner.
Pre-v0.12 Hermes hosts are fully supported. Every new surface is gated on a runtime capability detector (`hermes --version` → semver), so users on older Hermes installs see the v2.5 surface unchanged. UI doesn't appear until the underlying CLI subcommand exists.
### Curator (Mac + iOS)
Hermes v0.12's autonomous skill curator prunes / consolidates / archives agent-created skills on a 7-day schedule. Scarf adds a dedicated **Curator** sidebar item under Interact (Mac) and a Curator nav row under the System tab (iOS).
- **Status panel** — enabled/paused/disabled badge, last-run timestamp, last summary, run count, scheduling cadence (interval / stale-after / archive-after).
- **Run Now** button triggers `hermes curator run`; pause/resume from the kebab menu.
- **Three leaderboards** — least-recently-active, most-active, least-active. Each row carries activity / use / view / patch counters and an inline pin toggle.
- **Pin / unpin** — pinned skills are protected from auto-archive and rewrites. State pulled from `~/.hermes/skills/.curator_state` and surfaced as a pin glyph everywhere skills appear (Curator screen, Skills sidebar/list, SkillDetailView).
- **Restore archived** sheet calls `hermes curator restore <name>` to bring a previously-archived skill back.
- **Last report Markdown** — when present, the previous run's REPORT.md renders inline in mono.
Capability-gated; sidebar item disappears on pre-v0.12 hosts.
### Multimodal image input in chat (Mac + iOS)
Hermes v0.12 advertises `prompt_capabilities.image = true` on ACP and accepts image content blocks in `session/prompt`. Scarf wires the producer side on both targets:
- **Mac**: paperclip toolbar button on the chat composer opens NSOpenPanel multi-pick. Drag-and-drop and paste also work — drop an image (or a Finder file URL) onto the composer and it attaches. Capability-gated; the entire attachment surface is hidden on pre-v0.12 hosts.
- **iOS**: paperclip button opens PhotosPicker (multi-select up to 5 photos). Same byte-for-byte capability gate.
- **ImageEncoder** downsamples to 1568px long-edge (Anthropic's recommended ceiling) at JPEG q=0.85, so a 12 MP screenshot lands under ~300 KB on the wire. Detached only — never blocks MainActor.
- **Image-only sends are valid** — once at least one attachment is queued, the send button enables even with empty text. Vision models accept "describe this" with no caption.
- **Per-attachment chips** above the input field with thumbnail + filename tooltip + X to remove. 5-image-per-message cap; total payload stays under ~2 MB so cellular sends don't time out.
Hermes routes the resulting prompt to a vision-capable model automatically — no extra Scarf-side work to pick the right aux model.
### 5 new inference providers (Mac + iOS)
Five overlay-only providers added to `ModelCatalogService.overlayOnlyProviders`. The model picker reaches all of them; provider IDs match `HERMES_OVERLAYS` in `hermes_cli/providers.py` exactly so a typo here doesn't strand users with an unreachable provider.
- **Tencent TokenHub** (api_key) — base URL resolved from `TOKENHUB_BASE_URL`
### `auxiliary.curator` aux task (Mac)
Hermes removed `auxiliary.flush_memories` entirely in v0.12 (the underlying memory pipeline was rewritten) and added `auxiliary.curator` so the curator's review fork can run on a separate model from the main agent. Settings → Auxiliary now surfaces a Curator row when the active host is v0.12+ (gated on `HermesCapabilities.hasCuratorAux`); the obsolete Flush Memories panel is gone.
The Tool Gateway health view in HealthView lost the flushMemories-routes-through-Nous row and gained a curator row, matching the new aux task list.
### Skills v0.12 surface (Mac + iOS)
Three new capabilities Scarf can now reach:
- **Direct-URL install** — `hermes skills install <https-url>` lets users pull a one-off skill without going through a registry. Mac SkillsView gains an "Install from URL…" toolbar button (capability-gated) opening a sheet with the URL field plus optional `--category` / `--name` overrides.
- **Reload** — `hermes skills audit` rescans the skills directory and refreshes the agent's view without a session restart. Wired to a "Reload" toolbar button next to the install button on Mac.
- **Enabled / disabled state** — `skills.disabled` in config.yaml is read at scan time. Disabled skills render strikethrough + an "OFF" pill on Mac and iOS rows; iOS detail view explains the state in plain text.
- **Curator pin badge** — pinned-skill names from `~/.hermes/skills/.curator_state` surface as a pin glyph on each row across Mac sidebar and iOS list, plus an explanatory chip on iOS detail view.
The disable-toggle write path is deferred to v2.7 — Hermes only exposes `hermes skills config` as an interactive verb today, and we'd rather read accurately than risk clobbering the user's list with a half-tested write.
### Cron — `--workdir` flag (Mac)
Hermes v0.12 cron jobs accept `--workdir <absolute-path>` to inject AGENTS.md / CLAUDE.md / .cursorrules from that directory and pin cwd for terminal/file/code_exec tools. Scarf's CronJobEditor now has a Workdir field; both create and edit paths forward the flag. Existing v0.11 jobs keep the no-cwd behaviour by leaving the field blank.
The `context_from` chaining field is read-only from Scarf this round (Hermes hasn't exposed a `--context-from` CLI flag yet, only YAML).
### Microsoft Teams + Yuanbao (Mac)
Two new gateway platforms. Microsoft Teams (the 19th platform) ships as a plugin; Yuanbao 元宝 (the 18th) is a native gateway adapter. Both surface in the Platforms tab with read-only setup panels — the OAuth dance for Yuanbao and the plugin install for Teams happen outside Scarf.
### Read-only Kanban (Mac)
Hermes v0.12 ships a SQLite-backed multi-tenant task board with a full CLI (`hermes kanban create / list / claim / dispatch / …`). The multi-profile *collaboration* layer was reverted upstream while the design is reworked, so v2.6 ships a **read-only** Kanban view: paginated table of `hermes kanban list --json` filtered by status, with status badges, meta chips (id / assignee / workspace / skills), and per-row metadata. 5-second polling while the view is foregrounded; suspended on disappear.
Create / claim / dispatch UI is deferred until upstream stabilizes — building the editor now would risk rework on a quarter-out timeline.
### Settings deltas (Mac)
A new **Caching & Redaction** section under Settings → Advanced with three v0.12 knobs (gated on capability):
- **Prompt cache TTL** picker — 5m default / 1h opt-in. Reduces cache writes on long agent loops with stable system prompts.
- **Redact secrets in patches** toggle — Hermes flipped this off by default in v0.12 because the substitution corrupted patches; security-sensitive users can flip it back on here.
- **Runtime metadata footer** toggle — opt-in compact footer on each final reply (provider/model/cost/turn count).
TTS provider list gains **piper** (native local TTS engine new in v0.12). Terminal backend list gains **vercel** (Vercel Sandbox backend for execute_code/terminal). Both ride along unconditionally — Hermes silently falls back when an older host doesn't recognize the value.
Three new System-tab nav rows in ScarfGo, all read-only:
- **Webhooks** — list of `hermes webhook list` output with description / deliver / events / route per row. "Platform not enabled" detection so a freshly-installed Hermes shows setup guidance instead of error noise.
- **Plugins** — filesystem-first scan over `~/.hermes/plugins/` with manifest reads (plugin.json or plugin.yaml). Enabled/disabled badge, version, source, path.
- **Profiles** — `hermes profile list` with active-profile highlighting from `~/.hermes/active_profile`. Tolerant of both Rich box-drawn and plain-text outputs.
None of the three are capability-gated — the underlying list verbs work on both v0.11 and v0.12. Create / edit / delete remain Mac-only since they touch enough state we keep them off the phone.
### Hermes-version banner (iOS)
Yellow banner at the top of the Dashboard tab when the active server is pre-v0.12. Lists the v0.12 capabilities the user is missing out on (curator, multimodal image input, new providers); one-tap session-dismiss; reappears on next app open. Hidden entirely on v0.12+ hosts.
### Internal — version-aware capability detection
The foundation of every gated surface above:
-`HermesCapabilities` value type parses `Hermes Agent v0.12.0 (2026.4.30)` from `hermes --version` output. Exposes booleans for each release-gated UI surface (`hasCurator`, `hasACPImagePrompts`, `hasKanban`, `hasOneShot`, `hasSkillURLInstall`, `hasFallbackCommand`, `hasUpdateCheck`, `hasPiperTTS`, `hasVercelTerminal`, `hasCuratorAux`, `hasTeamsPlatform`, `hasYuanbaoPlatform`, `hasCronWorkdir`, `hasPromptCacheTTL`, `hasRedactionToggle`, `hasFlushMemoriesAux`).
-`HermesCapabilitiesStore` (`@Observable @MainActor`) caches per-server capabilities. Injected on `ContextBoundRoot` (Mac) and `ScarfGoTabRoot` (iOS) via `.environment(_:)` and `.hermesCapabilities(_:)`.
- 12 parser tests + 6 curator-output parser tests lock the v0.12 / v0.11 / fallback flag matrices.
- **Typing lag in the chat composer (#67)** — `RichChatInputBar.updateMenuState()` ran on every keystroke and unconditionally wrote both `showMenu` and `selectedIndex`, tripping SwiftUI's "action tried to update multiple times per frame" warning and stalling input. Composer now coalesces writes to deltas, short-circuits when not in slash mode (the common case), and watches `commands.count` instead of re-allocating `commands.map(\.id)` per keystroke.
- **Chat font-size slider had no visible effect (#68)** — `RichChatView` only set `\.dynamicTypeSize`, but `ScarfFont` tokens are fixed-point (`Font.system(size: 14, …)`) so dynamic type didn't reach bubble text, reasoning, tool chips, code blocks, or markdown headings. New `\.chatFontScale` env value plumbed through `RichMessageBubble`, `MarkdownContentView`, and `CodeBlockView`; `ChatFontScale.{body, caption, captionStrong, caption2, mono, monoSmall, codeBlock, codeInline}(_:)` helpers mirror the ScarfFont base sizes so 100% is byte-for-byte identical to today's UI.
- **Placeholder ghosting on first keystroke (#65)** — `TextEditor`'s NSTextView surfaces a typed glyph one frame before the SwiftUI binding propagates, so the bare `if text.isEmpty` overlay rendered the translucent placeholder text on top of the just-typed character. Pinned an opaque background behind the placeholder rect and switched the conditional to `.opacity(...)` so the view tree stays stable per keystroke.
- **Draft text leaked between conversations (#62)** — composer `@State` survived session switches because the surrounding view tree was structurally identical. Bound `RichChatInputBar`'s identity to `richChat.sessionId` so SwiftUI rebuilds the view (and its `@State`) on session change. Stable fallback string for the "no session selected" window — `UUID()` would have minted a new id per body re-eval and trashed the composer mid-typing.
- **Sent message rendered blank after navigating away (#63)** — when a user sent a prompt and immediately resumed a different session before Hermes flushed the row to state.db, `resumeSession`'s `reset()` cleared `messages` and `loadSessionHistory` then read an as-yet-empty DB. New per-session pending-user-messages cache survives `reset()` and re-injects still-pending entries on load; entries clear themselves as soon as a matching DB row catches up.
- **No completion notification (#64)** — sending a long prompt and switching to other work required polling the chat to know when the response landed. New `ChatNotificationService` fires a local `UNUserNotificationCenter` banner on prompt completion when Scarf isn't the foreground app. Settings → Display → Feedback → "Notify when Hermes finishes" toggle, default on.
- **Per-message TTS playback (#66)** — small speaker glyph in each settled assistant bubble's metadata footer; uses `AVSpeechSynthesizer` with the user's macOS Spoken Content default voice, picks up offline. Markdown control characters stripped before speech. The deeper Settings → Voice provider integration (Edge / ElevenLabs / OpenAI / NeuTTS / Piper) is queued as a v2.7 follow-up.
- **ACP control-message timeout under gateway concurrency (#61)** — bumped 30s → 60s. State.db lock contention on a healthy host clears in seconds, but the previous 30s watchdog tripped under realistic gateway+ACP concurrency (Discord sync / skill registration / cron scheduling holding write locks during ACP `initialize` / `session/new` / `session/load`). 60s gives lock resolution headroom while still surfacing genuinely broken transports.
#### Pre-merge
- **Test target compile** — `M5FeatureVMTests.ScriptedTransport` had drifted off the `ServerTransport` protocol after `cachedSnapshotPath` landed in v2.5.2; added the missing stub. `M0dViewModelsTests` got the `ConnectionStatusViewModel.Status.degraded` argument-name update. `CredentialPoolsGatingTests` got the missing `import ScarfCore`. The full `swift test` suite now runs (and passes — 215 tests across 17 suites).
- **iOS package compile** — `RemoteBackupService.zipDirectory` and `RemoteRestoreService.unzipArchive` used `Foundation.Process` unconditionally, breaking the iOS build entirely (Process is unavailable on the iOS SDK). Wrapped in `#if !os(iOS)` with iOS stubs that throw — backup/restore is Mac-only by design.
### Hermes version
Targets Hermes **v2026.4.30 (v0.12.0)**. v2026.4.23 (v0.11.0) hosts continue to work — every v0.12 surface is gated on capability detection, so Scarf v2.6 against v0.11 looks identical to Scarf v2.5.2 against v0.11. Update Hermes (`hermes update`) to unlock the new surfaces.
### Compatibility
- macOS 14+ (unchanged)
- iOS 17+ (unchanged)
- Hermes v0.11+ for the v2.5 surface; v0.12+ for the new features above.
A patch release that ships **template discoverability**, **cron observability**, and an **end-to-end UI test harness** that locks the new install path against regression. No breaking changes; every Hermes capability target is unchanged from 2.6.0.
### In-app Template Catalog
The catalog is no longer web-only. **Templates → Browse Catalog…** opens a sheet that fetches the live catalog from `awizemann.github.io/scarf/templates/`, renders one row per published template with name + version + tags, and one-click installs through the existing flow. Search filters across name / description / tags; the category picker constrains to whatever categories the loaded catalog actually carries.
- **Install-state badges** — each row shows "Installed v1.2.0" (green) or "Update v1.3.0" (amber) when the catalog version is newer than what's in `~/.hermes/scarf/projects.json`. Update is "uninstall + reinstall" today; in-place upgrade is on the v3 backlog.
- **24h cache** at `~/.hermes/scarf/catalog_cache.json` so opening the sheet repeatedly doesn't re-hit the network. Refresh icon force-fetches.
- **Bundled fallback** — fresh-install / offline users still see the official templates as a hardcoded list. Network failures serve stale cache with a "refresh failed" hint.
- **Catalog-schema decoder fault tolerance** — one malformed entry on the live catalog can't bring down the whole list. The bad row is dropped with a logged warning; the rest survive.
### HackerNews Daily Digest template
First template added under the new dogfooding-templates loop. Configurable `min_score`, `max_items`, `topics`; one daily-at-08:00 cron job (paused on install) that pulls the HN Firebase API, filters, and prepends a markdown digest to the project's `digest.md`. No API keys required. Live at the catalog URL above.
Cron rows now surface the same OAuth-refresh-revoked recovery flow as Chat instead of a generic red dot, plus three previously-missing observability cues:
- **OAuth re-auth.** `ACPErrorHint.classify` runs on `job.lastError`; when it returns `oauthRefreshRevoked(provider)` the detail pane shows the human-readable hint + a **Re-authenticate** button that drops the user into Credential Pools — same wiring ChatView's banner uses. Unrecognized errors fall back to the legacy red `lastError` text.
- **Running indicator.** The row dot turns blue + pulses when `state == "running"` (precedence over disabled / error / success); the detail header gains a "running…" badge next to active/paused. No new polling — `HermesFileWatcher.lastChangeDate` already drives `CronViewModel.load()`.
- **Last run output.** Collapsible panel replacing the inline log: a one-line summary (`<timestamp> — ok|error|running…`) always visible, full monospaced terminal-style scroll on expand, auto-scrolls to bottom when new runs land.
Also fixes a pre-existing bug in `HermesFileService.loadCronOutput` that returned the wrong file under Hermes's per-job-id output nesting.
### Layer B install-drive XCUITest harness
The dogfooding-templates initiative ships its first end-to-end UI test that drives the install pipeline:
```
Launch with --scarf-test-mode → Sidebar → Projects → Install sheet
(via --scarf-test-install-url launch arg) → Configure → Open Project
Runs ~30 s green on the dev Mac, validates 9 assertion points across the user journey. Covers the new accessibility identifiers wired in this release: `templateConfig.commitButton`, `projects.row.<name>`, `sidebar.section.<rawValue>`, `projects.contextMenu.uninstallTemplate`, `templateUninstall.confirmRemove`, `templateInstall.success.openProject`, `templateUninstall.success.done`. The `--scarf-test-install-url` launch arg + `TestModeFlags.isTestMode` gating lets XCUITest skip SwiftUI Menu / NSToolbarItem accessibility-bridging quirks that otherwise block toolbar-menu driving.
Wiki [Test-Harness](https://github.com/awizemann/scarf/wiki/Test-Harness) documents how to extend the harness for the next template.
### Sentinel-marker test isolation (incident-response hardening)
`SCARF_HERMES_HOME` override now requires the path to contain a `.scarf-test-home-marker` file to activate. Without the marker, production code falls through to the user's real `~/.hermes/`. Lands belt-and-braces protection for cases where a test crashes mid-teardown leaving the env var set, an env var inherits from a parent shell, or a misconfigured launchctl plist exports the variable. The override remains the seam every E2E test relies on; the marker file ensures it can't accidentally pivot a non-test process off the user's data.
### Chat fixes
- **OAuth refresh-revoked surface.** Chat-side error banner now classifies the message via `ACPErrorHint.classify` and offers an in-app **Re-authenticate** button that routes through Credential Pools (#65). Same primitive the new cron banner reuses.
- **Placeholder ghosting fix.** TextEditor's placeholder now clips to the editor's bounds and clears on focus instead of bleeding past the cursor area when the user types fast (#67).
### Profile chip + structured logs
- **Active-profile chip in the sidebar header.** Click → routes to Profiles. Local contexts only (remote SSH would mislead).
- **Switch & Relaunch** flow now writes `~/.hermes/active_profile` and relaunches Scarf in a single click instead of asking the user to quit+reopen.
- Profile-resolver logs are now structured (key=value form) so `log show … | grep ProfileResolver` can pull "which profile did Scarf resolve to and why" out of support requests.
### Swift 6 cleanup
-`MessageSpeechService` — drop `@preconcurrency` on the AVSpeechSynthesizerDelegate conformance now that the protocol's Sendable annotations are upstreamed.
-`ChatView` — `RichChatViewModel.PendingPermission: @retroactive Identifiable`. Quiets the Swift 6 compiler so downstream breakage would be loud if ScarfCore ever adds the conformance upstream.
-`CredentialPoolsView` — `.help(Text(verbatim:))` so backticks render literally instead of being treated as markdown inline-code.
- **Cron-job-uninstall by name is ambiguous** when two projects share the same template id. The Layer B test surfaced this — manifests as: the test passes, but if you've manually installed the same template before running the test, your real cron job can disappear. Recovery is `hermes cron create`. Fix is queued: store cron-job IDs in `<project>/.scarf/template.lock.json` at install time and resolve by ID at uninstall time.
- **Full-suite parallel test runs intermittently hang** — pre-existing flaky test infrastructure unrelated to this release. Individual suites all pass; the hang only manifests on `xcodebuild test` with everything concurrent. The sentinel-marker hardening prevents user-data damage from any race.
### Compatibility
- **Hermes target unchanged from 2.6.0**: v2026.4.30 (v0.12.0). Pre-v0.12 Hermes hosts continue to work — no new capability gates added in this release.
- **Min macOS unchanged**: 14.6.
- **No schema changes** to anything in `~/.hermes/`. The two new Scarf-owned files (`scarf/catalog_cache.json` and the template-installer's `.scarf-test-home-marker` for tests) are additive.
returnClassification(hint:"SSH rejected the key. Make sure the right identity file is selected and that ssh-agent has the key loaded — open Terminal and run `ssh-add -l`.")
||haystack.contains("REMOTE HOST IDENTIFICATION HAS CHANGED"){
returnClassification(hint:"The remote host's SSH key changed. If you just rebuilt the droplet, remove the old entry with `ssh-keygen -R <host>`, then try again.")
}
ifhaystack.contains("Could not resolve hostname")
||haystack.contains("Name or service not known"){
returnClassification(hint:"Couldn't resolve the host name. Check the host in this server's settings.")
}
ifhaystack.localizedCaseInsensitiveContains("command not found")
||haystack.contains("hermes: not found")
||haystack.contains("exit 127"){
returnClassification(hint:"The remote shell couldn't find `hermes`. Either install Hermes on the remote (`pipx install hermes-agent`) or set an absolute binary path in this server's settings.")
returnClassification(hint:"Hermes can't find your AI provider credentials. Set `ANTHROPIC_API_KEY` (or similar) in `~/.hermes/.env` or your shell profile, then restart Scarf.")
}
ifletmatch=haystack.range(of:#"No such file or directory:\s*'([^']+)'"#,
returnClassification(hint:"Hermes couldn't find `\(name)` on PATH. If you use nvm/asdf/mise, make sure it's exported in `~/.zprofile` (not only `~/.zshrc`), then restart Scarf.")
}
returnClassification(hint:"Hermes couldn't find a required binary on PATH. Check that your shell's PATH is exported in `~/.zprofile`, then restart Scarf.")
Some files were not shown because too many files have changed in this diff
Show More
Reference in New Issue
Block a user
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.