Compare commits

..

143 Commits

Author SHA1 Message Date
Alan Wizemann 5498a08b11 chore: Bump version to 2.3.0 2026-04-24 03:16:36 +02:00
Alan Wizemann a864c9af02 chore(l10n): Xcode auto-extracted new Tool Gateway strings 2026-04-24 03:15:06 +02:00
Alan Wizemann ec506d4652 docs(v2.3): add Tool Gateway + Nous Portal sign-in to release notes + README
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>
2026-04-24 03:08:57 +02:00
Alan Wizemann 38226fea2c fix(nous): force PYTHONUNBUFFERED=1 so device-code output surfaces
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>
2026-04-24 02:54:22 +02:00
Alan Wizemann 257772e2d1 feat(nous): in-app sign-in + credential pools auth-type gating
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>
2026-04-24 02:49:08 +02:00
Alan Wizemann 115bc16b14 feat: Nous Portal + Tool Gateway support for Hermes v0.10.0
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>
2026-04-24 01:59:21 +02:00
Alan Wizemann eda5e467f9 Merge branch 'v2.3-projects': v2.3 — Projects Grow Up
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>
2026-04-24 01:49:56 +02:00
Alan Wizemann 9127aef682 docs: v2.3.0 release notes + README What's New
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>
2026-04-24 01:45:06 +02:00
Alan Wizemann 5ae8db25c3 fix(chat): resume session on coordinator.selectedSessionId, not just pendingProjectChat
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>
2026-04-24 01:34:19 +02:00
Alan Wizemann fb833d4a0a fix(projects): open HermesDataService before filtering sessions
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>
2026-04-24 01:27:06 +02:00
Alan Wizemann 7656ad8052 docs(v2.3): document how agents see Scarf projects
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>
2026-04-24 01:09:49 +02:00
Alan Wizemann 5b1481f33f feat(projects): Scarf-managed project-context block in AGENTS.md
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>
2026-04-24 01:05:15 +02:00
Alan Wizemann e4920538d2 feat(chat): show active-project indicator in SessionInfoBar + nav title
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>
2026-04-24 01:00:07 +02:00
Alan Wizemann 5340e70dd3 fix(projects): watch session-project-map so Sessions tab refreshes
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>
2026-04-24 00:58:07 +02:00
Alan Wizemann 7ad78a5492 fix(layout): cap RichChatView/ProjectSessions idealHeight; revert broken detail wrap
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>
2026-04-24 00:57:15 +02:00
Alan Wizemann 205bb2c56e fix(window): pin detail column's reported frame so Chat/Sessions stop resizing window
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>
2026-04-24 00:17:08 +02:00
Alan Wizemann d9688781ee fix(app): windowResizability(.contentMinSize) so window stops auto-resizing
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>
2026-04-24 00:06:57 +02:00
Alan Wizemann 9aad9051c4 fix(chat,projects): clamp detail-column views so they don't grow the window
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>
2026-04-24 00:00:19 +02:00
Alan Wizemann 4baa3d4d28 fix(projects): clamp Sessions tab height so it doesn't push the window
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>
2026-04-23 23:55:10 +02:00
Alan Wizemann 799cdb19e1 feat(projects): per-project Sessions tab + sidecar attribution
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>
2026-04-23 23:14:33 +02:00
Alan Wizemann 585d035fe8 feat(projects): folder hierarchy + rename/archive/search in the sidebar
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>
2026-04-23 23:00:21 +02:00
Alan Wizemann f1e8f3070f feat(projects): registry schema v2 — folder + archived fields
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>
2026-04-23 22:57:02 +02:00
Alan Wizemann f366057cfd docs(roadmap): add Projects System Evolution section
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>
2026-04-23 22:52:25 +02:00
Alan Wizemann fd0d923c0b chore(assets): switch AppIcon set to macOS-native filenames
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>
2026-04-23 22:52:12 +02:00
Alan Wizemann 3c2d11470f chore: Bump version to 2.2.1 2026-04-23 22:05:50 +02:00
Alan Wizemann dcd2f8f04b docs: v2.2.1 release notes
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>
2026-04-23 22:04:31 +02:00
Alan Wizemann ef3ddcdd7a fix(config-sheet): EnumControl always uses Menu picker, never Segmented
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>
2026-04-23 21:56:36 +02:00
Alan Wizemann 5e207f760d docs(skill): warn authors against raw URLs in field descriptions
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>
2026-04-23 21:43:45 +02:00
Alan Wizemann d616935296 fix(config-sheet): wrap wide schema descriptions instead of clipping
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>
2026-04-23 21:43:36 +02:00
Alan Wizemann ea4032766b feat(templates): ship awizemann/template-author skill bundle
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>
2026-04-23 19:41:50 +02:00
Alan Wizemann 3e0d2db4c7 fix(catalog): accept git worktrees for gh-pages check
`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>
2026-04-23 18:37:31 +02:00
Alan Wizemann 2b25a9da71 chore: Bump version to 2.2.0 2026-04-23 18:25:18 +02:00
Alan Wizemann 5fb9620631 Merge branch 'project-sharing': v2.2.0 — templates + configuration + catalog
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>
2026-04-23 18:20:07 +02:00
Alan Wizemann de5b278da4 docs: expand v2.2.0 release notes + README for full 2.2 scope
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>
2026-04-23 18:12:37 +02:00
Alan Wizemann fb7a80f191 fix: Run Now agent-run timing + non-404 webview placeholder
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>
2026-04-23 17:14:29 +02:00
Alan Wizemann 18640293f7 fix(projects): clarify remove-vs-uninstall UX
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>
2026-04-23 17:14:29 +02:00
Alan Wizemann 19750597cd feat(site-status-checker): add Live Site Preview webview for Site tab
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>
2026-04-23 17:14:29 +02:00
Alan Wizemann 69e9cc6c7b fix(cron): Run now now actually runs + markdown rendering in install sheet
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>
2026-04-23 17:14:29 +02:00
Alan Wizemann 03bf5262bb feat(templates): install-time {{PROJECT_DIR}} substitution in cron prompts
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>
2026-04-23 17:14:29 +02:00
Alan Wizemann 3af99d9d9c fix(templates): site-status-checker dashboard no longer lies before first run
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>
2026-04-23 17:14:29 +02:00
Alan Wizemann 3bd95de8f4 fix(config): install sheet silently closed after Continue in config step
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>
2026-04-23 17:14:29 +02:00
Alan Wizemann 81e8da91d6 feat(templates): upgrade site-status-checker to v1.1.0 with config schema
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>
2026-04-23 17:14:29 +02:00
Alan Wizemann bb750e237e docs: CLAUDE.md — add Template Configuration section
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>
2026-04-23 17:14:29 +02:00
Alan Wizemann 68f6b98fcf feat(catalog-config): mirror manifest v2 schema in validator + site
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>
2026-04-23 17:14:29 +02:00
Alan Wizemann f8c086ee7a feat(config): configure-step UI + post-install Configuration editor
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>
2026-04-23 17:14:29 +02:00
Alan Wizemann eb34aec1f1 feat(config): template-config UI forms (configure sheet + editor)
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).
2026-04-23 17:14:22 +02:00
Alan Wizemann 97e9beea5f refactor(settings): remove unused providers list
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>
2026-04-23 02:37:50 +02:00
Alan Wizemann 7a99547b22 fix: address code-review findings from Apr 22 commits
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>
2026-04-23 02:37:50 +02:00
Alan Wizemann 64b7d3beaf feat(config): manifest schemaVersion 2 + installer/uninstaller/exporter wiring
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>
2026-04-23 01:27:26 +02:00
Alan Wizemann 385c3a2e4d feat(config): template-config models + Keychain wrapper + ProjectConfigService
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>
2026-04-23 00:56:34 +02:00
Alan Wizemann e76fbf9937 chore: audit follow-ups from plan review
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>
2026-04-23 00:35:46 +02:00
Alan Wizemann c9b8da9ec5 feat(ci): validate template submissions on PR + tailored checklist
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>
2026-04-23 00:35:46 +02:00
Alan Wizemann 6175bee27d feat(site): dogfood the Scarf dashboard format as the catalog website
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>
2026-04-23 00:35:46 +02:00
Alan Wizemann 11732baa3c feat(catalog): stdlib-only Python validator + regenerator for templates/
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>
2026-04-23 00:35:46 +02:00
Alan Wizemann d8a0a89db2 feat(templates): promote examples/ to templates/<author>/<name>/ catalog layout
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>
2026-04-23 00:35:46 +02:00
Alan Wizemann 38c075d61d docs: ship site-status-checker example template + v2.2.0 release notes
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>
2026-04-23 00:35:46 +02:00
Alan Wizemann c800b93804 feat: project templates v1 (install + uninstall + export + URL handler)
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>
2026-04-23 00:35:46 +02:00
Alan Wizemann 7311320bfd Merge pull request #30 from awizemann/claude/issue-26-default-server
Let users pick the default server opened on launch (#26)
2026-04-22 22:52:33 +01:00
Alan Wizemann 4663697942 Merge pull request #29 from awizemann/claude/issue-26-sidebar-width
Persist sidebar width across launches (#26)
2026-04-22 22:44:57 +01:00
Claude 41635955b0 feat: let users pick the default server opened on launch (#26)
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
2026-04-22 11:00:32 +00:00
Claude 1989feee22 feat: persist sidebar width across launches (#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
2026-04-22 10:58:34 +00:00
Alan Wizemann 8773254d11 chore: accept safe parts of Xcode recommended-settings migration
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>
2026-04-20 19:27:55 -07:00
Alan Wizemann a1aa653a33 chore: Bump version to 2.1.0 2026-04-20 18:46:47 -07:00
Alan Wizemann e256196397 chore: commit shared Xcode scheme
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>
2026-04-20 18:46:36 -07:00
Alan Wizemann 50880efe81 docs: prep v2.1.0 release notes + README language badge
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>
2026-04-20 18:39:51 -07:00
Alan Wizemann b1bc7e8494 Merge pull request #25 from awizemann/translations-initial
feat(i18n): initial translations for 6 languages + contributor workflow
2026-04-20 18:37:06 -07:00
Alan Wizemann f47034d4ad fix(i18n): localize sidebar, settings tabs, and settings section titles
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>
2026-04-21 03:32:32 +02:00
Alan Wizemann 1726a613a5 feat(i18n): add translations for zh-Hans, de, fr, es, ja, pt-BR
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>
2026-04-20 18:16:41 -07:00
Alan Wizemann de34a80807 Merge pull request #24 from awizemann/multi-language
feat(i18n): close silently un-localizable sites (Phase 1b)
2026-04-20 17:52:18 -07:00
Alan Wizemann d9a25b3997 Merge pull request #22 from awizemann/claude/pedantic-kare-1edf13
feat(i18n): enable String Catalog + locale-aware numeric formatters
2026-04-20 17:51:09 -07:00
Alan Wizemann b40182f2da feat(i18n): close silently un-localizable sites from the audit
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>
2026-04-20 17:40:56 -07:00
Alan Wizemann 6817c95681 chore(i18n): sync catalog after rebasing onto chat slash-menu work
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>
2026-04-20 17:25:16 -07:00
Alan Wizemann 89748fdfee feat(i18n): enable String Catalog + locale-aware numeric formatters
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>
2026-04-20 17:24:29 -07:00
Alan Wizemann c8208dedb1 fix(chat): slash-menu filter, auto-scroll on send/complete, loading state
- 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>
2026-04-20 17:15:46 -07:00
Alan Wizemann a68e0c5f42 feat(chat): slash-command menu + scroll/layout fixes
- 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>
2026-04-20 16:35:53 -07:00
Alan Wizemann 0384c6ef17 chore: Bump version to 2.0.2 2026-04-20 15:46:07 -07:00
Alan Wizemann f36fb55ebe test(ssh): regression tests for ControlPath socket-limit invariants
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.
2026-04-20 15:45:29 -07:00
Alan Wizemann 1823160546 fix(ssh): defensive ControlPath dir + sweep stale sockets
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.
2026-04-20 15:45:20 -07:00
Alan Wizemann d2a447fcc4 docs: add GitHub wiki + scripts/wiki.sh helper with secret-scan
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>
2026-04-20 15:32:47 -07:00
Alan Wizemann 76bfeb34d4 chore: Bump version to 2.0.1 2026-04-20 15:32:47 -07:00
Alan Wizemann 85a4ec0e14 Merge pull request #20 from aliatx2017/fix/controlpath-too-long
fix: ControlPath too long for Unix socket on macOS
2026-04-20 15:08:40 -07:00
Alan Wizemann 1453c7a841 Merge fix/issue-19-ssh-diagnostics into main — v2.0.1 hotfix
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.
2026-04-20 14:27:11 -07:00
Alan Wizemann bd21a539e6 docs: update v2.0.1 release notes for diagnostics fixes + pill UX
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>
2026-04-20 14:26:33 -07:00
Alan Wizemann d3055702ef fix: connection pill — revert to .principal, swap dot for state SF Symbol
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>
2026-04-20 14:22:35 -07:00
Alan Wizemann ee1d705abc fix: move connection pill off .principal to drop the emphasis bezel
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>
2026-04-20 14:16:54 -07:00
Alan Wizemann 8e3dafe4c6 fix: remove the pill's own capsule background
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>
2026-04-20 14:14:12 -07:00
Alan Wizemann c51241dc72 fix: diagnostics script — pipe to sh via stdin, not sh -c argv
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>
2026-04-20 14:04:32 -07:00
Alan Wizemann ec03627bcd fix: diagnostics sheet — bypass transport.runProcess for shell script
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>
2026-04-20 13:56:09 -07:00
Alan Wizemann f8069a4481 fix: don't log 'No such file' as warnings on remote reads
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>
2026-04-20 13:52:28 -07:00
Alan Wizemann 110170d6e9 fix: v2.0.1 — surface remote SSH file-access errors (closes #19)
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>
2026-04-20 13:40:35 -07:00
Alex Maksimchuk 1293cfa23b fix: use short ControlPath to avoid Unix socket limit on macOS
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.
2026-04-19 23:03:28 -05:00
Alan Wizemann ba8bf14ff0 chore: Bump version to 2.0.0 2026-04-19 13:07:49 -07:00
Alan Wizemann 4212200dca Merge remote-servers into main — v2.0 release
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.
2026-04-19 13:07:22 -07:00
Alan Wizemann 5920923d92 feat: v2.0 — correctness + UX polish on multi-server + remote SSH
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>
2026-04-19 13:02:40 -07:00
Alan Wizemann 00ca7229df feat: multi-window + remote SSH server support (Phases 0-4)
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>
2026-04-18 17:42:17 -07:00
Alan Wizemann 679dedf132 fix: release script — push main + tag before gh release create
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>
2026-04-17 17:24:31 -07:00
Alan Wizemann 12610faba0 chore: Bump version to 1.6.2 2026-04-17 17:18:33 -07:00
Alan Wizemann 73b44202ba fix: release script preflight allows pre-written RELEASE_NOTES.md
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>
2026-04-17 17:18:23 -07:00
Alan Wizemann eed55cbb0f chore: Ignore release-artifact binaries
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>
2026-04-17 17:16:10 -07:00
Alan Wizemann 14c97bee62 docs: CLAUDE.md — document the release flow + canonical prompts
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>
2026-04-17 17:13:15 -07:00
Alan Wizemann 8d3fe70e2c fix: Chat tab false-positive "no credentials" warning before session pick
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>
2026-04-17 17:10:51 -07:00
Alan Wizemann da88c98c7a feat: release script builds Universal + ARM64 variants
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>
2026-04-16 20:10:17 -07:00
Alan Wizemann b7ad01f9da chore: Bump version to 1.6.1 2026-04-16 19:04:48 -07:00
Alan Wizemann 868e61979e chore: release script supports --draft + RELEASE_NOTES.md
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>
2026-04-16 19:04:27 -07:00
Alan Wizemann 9bdd928469 fix: release script — rename scarf.app → Scarf.app after export
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>
2026-04-16 18:59:41 -07:00
Alan Wizemann 75e489e39c fix: chat works without a terminal hermes session; surface the real error when it doesn't
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>
2026-04-16 18:49:44 -07:00
Alan Wizemann 41ea3aeb83 feat: Sparkle auto-updates + Developer ID notarization pipeline
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>
2026-04-16 18:42:20 -07:00
Alan Wizemann eb39dcfa61 docs: Restructure README + add v1.6.0 release binaries
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.
2026-04-16 15:51:28 -07:00
Alan Wizemann 93ee194ba0 chore: Bump version to 1.6.0 2026-04-16 15:39:41 -07:00
Alan Wizemann b6d9113579 feat: Settings tabs, Platforms, Credential Pools, Model Picker, and Configure sidebar
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>
2026-04-16 15:39:07 -07:00
Alan Wizemann b2a29ab68d fix: MCP test failures hidden as success; brew/nvm binaries not on PATH
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.
2026-04-16 07:51:32 -07:00
Alan Wizemann 117a0ee9dd fix: MCP Servers — preserve all entries when patching config.yaml
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.
2026-04-16 07:38:49 -07:00
Alan Wizemann 61d59ba0e4 chore: Re-sign 1.5.6 release binaries
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>
2026-04-16 07:13:55 -07:00
Alan Wizemann 0a584f6722 chore: Bump version to 1.5.6 and add release binaries
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>
2026-04-16 06:45:19 -07:00
Alan Wizemann 219bca264e feat: Add MCP Servers management UI
Full MCP server lifecycle: add (stdio + HTTP), edit, remove, test,
enable/disable. YAML config patching for args, env, headers, tool
filters, timeouts. OAuth token detection + deletion. Preset picker
for common MCP servers. Gateway restart banner after config changes.

New sidebar section "MCP Servers" under Manage.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-15 19:53:55 -07:00
Alan Wizemann c7e6a809ed chore: Bump version to 1.5.5 and add release binaries
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>
2026-04-15 11:32:16 -07:00
Alan Wizemann c5d6116f99 feat: Add Hermes v0.9.0 compatibility and new feature surfaces
- 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>
2026-04-14 16:59:46 -07:00
Alan Wizemann 8672ed1e6c chore: Bump version to 1.5.2 and add universal release binary
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-10 02:53:21 -04:00
Alan Wizemann 46468890d5 feat: Track ACP token usage, improve chat scroll behavior, and show session costs
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>
2026-04-10 00:15:44 -04:00
Alan Wizemann cd503378e2 fix: Move Tools subprocess calls off main thread to fix toggle rendering
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>
2026-04-09 23:16:52 -04:00
Alan Wizemann 86762eab6d fix: Harden ACP session stability and recover messages on reconnection
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>
2026-04-09 13:13:34 -04:00
Alan Wizemann a7fd193770 feat: Add ACP real-time chat with stable connection management
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>
2026-04-09 04:33:03 -04:00
Alan Wizemann 521c6d63fc refactor: Use MarkdownContentView in rich chat bubbles
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>
2026-04-09 00:13:17 -04:00
Alan Wizemann 66d04d838d Merge branch 'chat-interface' into main
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>
2026-04-09 00:09:31 -04:00
Alan Wizemann ad30c0a943 feat: Show tool output in Activity inspector (#12)
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>
2026-04-08 23:52:42 -04:00
Alan Wizemann 44afa8f53b fix: Hide false external memory provider warning on fresh installs
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>
2026-04-08 23:40:35 -04:00
Alan Wizemann 481b937c33 feat: Add rich markdown rendering and skill editing (#11)
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>
2026-04-08 23:30:44 -04:00
Alan Wizemann 790efb585b feat: Add Hermes process start/stop/restart controls (#10)
- 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>
2026-04-08 22:48:53 -04:00
Alan Wizemann 3acf95a824 feat: Add Hermes v0.8.0 compatibility and fix Tools tab toggling (#9)
Hermes v0.8.0 support:
- Filter subagent sessions from main list with parent session drill-down
- Add agent.log support (new default log file)
- Add Feishu and Mattermost platforms
- Add Google AI Studio, xAI, Ollama Cloud providers
- Expand cron job model (pre-run scripts, delivery tracking, timeouts, SILENT)
- Add Docker env, command allowlist, and memory profile to config
- Add profile-scoped memory with profile picker
- Add browser backend picker and credential removal to Settings
- Add skills required config warnings
- Consolidate platform icon resolution to single source of truth
- Filter Insights queries to exclude subagent sessions

Bug fix:
- Fix Tools tab phantom toggling when switching platforms (#9)
  - Add .id() to tool list for proper SwiftUI view identity
  - Replace ambiguous plain buttons with segmented Picker

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-08 22:12:13 -04:00
Alan Wizemann 7d69c82c2b Add rich chat interface with iMessage-style bubbles and terminal toggle
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>
2026-04-06 12:33:55 -04:00
Alan Wizemann ae2872e08f Add Hermes v0.7.0 compatibility: reasoning tokens, cost tracking, schema detection
- 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>
2026-04-04 21:46:03 -04:00
Alan Wizemann 303f4502dd Fix version reporting: update MARKETING_VERSION to 1.3.0
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>
2026-04-03 13:50:57 -04:00
Alan Wizemann 815c9dcbcd Merge pull request #6 from awizemann/code-quality
Code quality improvements and webview dashboard widget
2026-04-02 12:04:16 -04:00
Alan Wizemann ef53ac1c93 Replace webview split layout with tabbed Dashboard/Site interface
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>
2026-04-02 12:03:50 -04:00
Alan Wizemann 2a3e8b1422 Add webview widget for embedded web browser in project dashboards
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>
2026-04-02 10:29:05 -04:00
Alan Wizemann 563f5a702c Improve code quality: error logging, constants, path validation, safe defaults
- 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
Alan Wizemann c7f3ca9be3 Merge feature/project-dashboards into main 2026-04-01 01:30:55 -04:00
Alan Wizemann dbaadb8037 Add Project Dashboards feature with agent-generated widgets
Introduces a new Projects section that renders custom dashboards from
JSON files in project directories. Supports 7 widget types (stat,
progress, text, table, chart, list) with live file-watching refresh.
Includes project registry, SwiftUI Charts integration, schema docs,
and comprehensive README documentation.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-01 00:48:13 -04:00
Alan Wizemann ce001fe202 Add left padding to terminal view in chat interface
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-31 22:28:46 -04:00
Alan Wizemann a329eca419 Merge branch 'development' 2026-03-31 15:30:51 -04:00
Alan Wizemann 528de938c5 Add pre-built binary install instructions to README
Universal binary (arm64 + x86_64) available on Releases page.
Updated Building section to Install with download + build options.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-31 15:30:50 -04:00
Alan Wizemann 4f791d491e Merge pull request #4 from awizemann/development
Config Editor and Voice Fixes
2026-03-31 14:35:33 -04:00
Alan Wizemann dd79891874 Replace read-only Settings with structured config editor
Settings view now has editable form controls organized by section:

Model: editable model name field, provider dropdown picker
Display: personality picker (parsed from config), streaming/reasoning/verbose toggles
Terminal: backend picker (local/docker/singularity/modal/daytona/ssh), max turns stepper
Voice: auto TTS toggle, silence threshold stepper
Memory: enabled toggle, char limit steppers, nudge interval stepper

All changes write via `hermes config set key value` CLI with save
confirmation feedback. Open in Editor button launches the raw YAML
in the default text editor. Paths and raw config sections retained.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-31 14:23:56 -04:00
287 changed files with 67716 additions and 1037 deletions
@@ -0,0 +1,42 @@
<!--
Use this template when submitting a new Scarf project template or updating
an existing one. For regular code/docs PRs, delete this template and write
your own summary.
Switch to this template by adding `?template=template-submission.md` to the
compare URL, or let GitHub pick it up automatically when you touch files
under templates/.
-->
## What's in this PR
- [ ] New template: `templates/<your-handle>/<your-template-name>/`
- [ ] Update to existing template: `templates/<author>/<name>/` (which one and why)
## One-line pitch
_What does this template do for its installers? Two sentences max._
## Checklist
- [ ] I wrote this template, or have the author's explicit permission to submit it.
- [ ] `AGENTS.md` is present and tells any cross-agent what the project does and how to run it.
- [ ] `README.md` includes install, customize, and uninstall instructions.
- [ ] The bundle's `template.json` `contents` claim matches what's actually in the zip.
- [ ] Cron jobs (if any) ship paused and use self-contained prompts.
- [ ] No secrets in any file (API keys, tokens, hostnames, IPs, credentials).
- [ ] No writes to `config.yaml`, `auth.json`, or credential paths — v1 installer will refuse.
- [ ] `python3 tools/build-catalog.py --check` passes locally.
- [ ] I installed + uninstalled this template on my machine and verified the `AGENTS.md` contract works end-to-end.
- [ ] I did **not** edit `templates/catalog.json` — the maintainer regenerates it post-merge.
## Testing notes
_What did you run, what did you see? Paste the log output of the cron job
firing once, or the chat transcript of asking the agent to do the main
thing. Reviewers don't have your machine — show, don't tell._
## Screenshots (optional)
_Drop screenshots of the installed dashboard, or the catalog detail page
rendered locally (`./scripts/catalog.sh preview && open /tmp/scarf-catalog-preview/templates/<slug>/index.html`)._
@@ -0,0 +1,74 @@
# Validates `.scarftemplate` bundles on PRs that touch templates/.
#
# Mirrors the invariants `ProjectTemplateService.verifyClaims` enforces at
# install time. Runs the same Python script the maintainer uses locally
# (tools/build-catalog.py --check) so a bundle can't reach main unless the
# validator is happy.
#
# Also runs tools/test_build_catalog.py so drift between the validator and
# its own test suite is caught on the same PR.
name: Validate template submissions
on:
pull_request:
paths:
- 'templates/**'
- 'tools/build-catalog.py'
- 'tools/test_build_catalog.py'
- '.github/workflows/validate-template-pr.yml'
permissions:
contents: read
pull-requests: write
jobs:
validate:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
with:
# Full clone so we can diff against the PR base and scope
# --only to just the changed templates if we want to later.
fetch-depth: 0
- name: Set up Python
uses: actions/setup-python@v5
with:
# The validator is stdlib-only and tested against 3.9+ (the
# system Python on current macOS, what most maintainers run
# locally). CI uses 3.11 for faster cold-cache times on
# GitHub Actions runners — same stdlib APIs, same code paths.
python-version: '3.11'
- name: Run validator unit tests
run: python3 tools/test_build_catalog.py -v
- name: Validate every template
id: validate
run: |
set -o pipefail
python3 tools/build-catalog.py --check 2>&1 | tee /tmp/validator.log
- name: Post failure comment
if: failure() && steps.validate.outcome == 'failure'
uses: actions/github-script@v7
with:
script: |
const fs = require('fs');
let body = '## Template validation failed\n\n';
try {
const log = fs.readFileSync('/tmp/validator.log', 'utf8');
body += '```\n' + log.slice(-3000) + '\n```\n';
} catch (e) {
body += 'See the failed job log for details.\n';
}
body += '\nFix the issues above and push again — the check reruns automatically.\n';
body += '\nLocal reproduction: `python3 tools/build-catalog.py --check`\n';
await github.rest.issues.createComment({
issue_number: context.issue.number,
owner: context.repo.owner,
repo: context.repo.repo,
body,
});
+13
View File
@@ -1,5 +1,7 @@
# Xcode
build/
.gh-pages-worktree/
.wiki-worktree/
DerivedData/
*.pbxuser
!default.pbxuser
@@ -43,3 +45,14 @@ Package.resolved
# Claude Code
.claude/
scarf/standards/backups/
# Scarf project dashboards (user-specific)
.scarf/
# Release artifacts — GitHub Releases hosts the binaries; no need to bloat git
# history. RELEASE_NOTES.md stays tracked (committed with the version bump).
releases/v*/*.zip
releases/v*/appcast-entry.xml
# Wiki helper: personal patterns (hostnames, IPs) blocked from the wiki push.
scripts/wiki-blocklist.txt
+172
View File
@@ -38,3 +38,175 @@ scarf/scarf/ Xcode project root (PBXFileSystemSynchronizedRootGroup
```bash
xcodebuild -project scarf/scarf.xcodeproj -scheme scarf -configuration Debug build
```
## Releases
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`.
- Keyboard shortcut or sidebar section changes → `Keyboard-Shortcuts.md` / `Sidebar-and-Navigation.md`.
**Skip for:** bug fixes with no user-observable change, pure refactors, typos, test-only changes, internal cleanups.
```bash
./scripts/wiki.sh pull # always first
# edit .wiki-worktree/*.md with normal tools
./scripts/wiki.sh commit "docs: describe X" # runs secret-scan
./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 v0.10.0 (v2026.4.16). 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.
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.
- **Subscription detection** ([NousSubscriptionService.swift](scarf/scarf/Core/Services/NousSubscriptionService.swift)) reads `~/.hermes/auth.json``providers.nous`. Read-only; Hermes owns the write path.
- **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:
- `template.json` — manifest (id, name, version, `contents` claim)
- `README.md` — shown in the install preview sheet
- `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`
- `instructions/…` — optional per-agent shims (`CLAUDE.md`, `GEMINI.md`, `.cursorrules`, `.github/copilot-instructions.md`)
- `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.
### Template configuration (v2.3, schemaVersion 2)
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.
Manifest shape:
```json
{
"schemaVersion": 2,
"contents": { "dashboard": true, "agentsMd": true, "config": 2 },
"config": {
"schema": [
{"key": "site_url", "type": "string", "label": "Site URL", "required": true},
{"key": "api_token", "type": "secret", "label": "API Token", "required": true}
],
"modelRecommendation": {
"preferred": "claude-sonnet-4.5",
"rationale": "Tool-heavy workload — reasoning helps."
}
}
}
```
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.
### Project-scoped chat + Scarf-managed AGENTS.md context (v2.3)
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)`
- **Registered cron jobs:** `[tmpl:<id>] <name>` — schedule …, currently paused|enabled
- **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.
Maintainer workflow on merge to main:
```bash
./scripts/catalog.sh build # regenerate templates/catalog.json + .gh-pages-worktree/templates/
./scripts/catalog.sh publish # secret-scan rendered output + commit + push gh-pages
```
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.
+21
View File
@@ -33,6 +33,27 @@ Rules:
- 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.
## Reporting Issues
Open an issue with:
+314 -23
View File
@@ -10,47 +10,168 @@
</p>
<p align="center">
<img src="https://img.shields.io/badge/macOS-26.2+-blue" alt="macOS">
<img src="https://img.shields.io/badge/macOS-14.6+%20Sonoma-blue" alt="macOS">
<img src="https://img.shields.io/badge/Swift-6-orange" alt="Swift">
<img src="https://img.shields.io/badge/license-MIT-green" alt="License">
<br>
<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.3
- **Projects sidebar grows up** — group projects into folders, rename / archive / unarchive in place, filter the list with ⌘F, jump to the first nine with ⌘1–⌘9. Archived projects hide by default; a toggle in the bottom bar surfaces them. Non-destructive on the v2.2 registry file — downgrade stays clean.
- **Per-project Sessions tab** — alongside Dashboard and Site. Shows chats attributed to the project, with a **New Chat** button that spawns `hermes acp` with the project's directory as the session cwd and attributes the result via a Scarf-owned sidecar (`~/.hermes/scarf/session_project_map.json`). Click any listed session to resume it with project context automatically restored.
- **Agent actually knows what project it's in** — the architectural headline. Every project-scoped chat gets a Scarf-managed block auto-injected into the project's `AGENTS.md` before the session starts. Hermes reads AGENTS.md from the session's cwd at startup and picks up the block as part of its system prompt. Ask the agent *"what project am I in?"* and it answers with the project name, directory, template id + version, configuration field names, and registered cron jobs — pulled from the injected block. Secret-safe (field names only, never values), idempotent, bounded to `<!-- scarf-project:begin/end -->` markers so template-author content outside the block is preserved across refreshes.
- **Project indicator in Chat** — folder chip in `SessionInfoBar` and `Chat · <ProjectName>` in the nav title when you're in a project-scoped chat. Resumed sessions keep the indicator by looking up the attribution sidecar at resume time.
- **Tool Gateway — Nous Portal support** — Hermes v0.10.0 introduced subscription-routed tools (web search, image gen, TTS, browser automation). Scarf 2.3 merges Hermes's provider-overlay table into the model picker so **Nous Portal + 5 other previously-invisible providers** now appear, and ships a dedicated **Sign in to Nous Portal** sheet that runs the device-code flow end-to-end in-app — no terminal. Each of the 8 auxiliary sub-model tasks gets a per-task Nous toggle, a Tool Gateway card lands in Health, and Credential Pools' silent-fail dead-end for device-code providers is closed. Scarf's existing messaging-gateway section is renamed **Messaging Gateway** to disambiguate from the new Tool Gateway.
- **Window-layout cleanup** — switching to Chat or a Sessions tab no longer grows the window past the screen. `.windowResizability(.contentMinSize)` + targeted `idealHeight` caps keep the window's floor at a sensible content minimum while letting users freely drag larger or smaller.
See the full [v2.3.0 release notes](https://github.com/awizemann/scarf/releases/tag/v2.3.0), the [Project Templates wiki page](https://github.com/awizemann/scarf/wiki/Project-Templates), and the [Hermes Version Compatibility page](https://github.com/awizemann/scarf/wiki/Hermes-Version-Compatibility) for the Tool Gateway's Hermes v0.10.0 requirement.
### Previously, in 2.2
- **Project Templates** — Scarf projects can now travel. Package a project's dashboard, agent instructions, skills, cron jobs, and a typed configuration schema into a `.scarftemplate` bundle, hand it to anyone, and they install it in one click. Every bundle ships with a cross-agent `AGENTS.md` ([agents.md](https://agents.md/) standard) so the instructions work in Claude Code, Cursor, Codex, Aider, and the 20+ other agents that read it natively. Browser-based one-click install via `scarf://install?url=…` deep links. Export / Install from File / Install from URL live under the **Templates** menu in the Projects toolbar.
- **Typed configuration with Keychain-backed secrets** — templates declare a schema with seven field types (`string`, `text`, `number`, `bool`, `enum`, `list`, `secret`). A **Configure** step in the install flow renders the form, routes secrets to the macOS Keychain, and drops non-secret values into `<project>/.scarf/config.json`.
- **Public template catalog** — [awizemann.github.io/scarf/templates/](https://awizemann.github.io/scarf/templates/) with live dashboard previews + schema rendering. CI-enforced Python validator mirrors the Swift-side invariants on every PR.
- **Safe-by-design** — skills namespaced, cron jobs tagged and paused-on-install, lock-file-driven uninstall, exports carry schema but never values.
See the [v2.2.0 release notes](https://github.com/awizemann/scarf/releases/tag/v2.2.0) for the full 2.2 series.
### Previously, in 2.1
- **Seven languages** — Full UI translations for Simplified Chinese, German, French, Spanish, Japanese, and Brazilian Portuguese on top of English. Scarf respects the system language by default; override per-app via **System Settings → Language & Region → Apps → Scarf**. Contributor workflow for adding more locales is documented in [CONTRIBUTING.md → Adding a Language](CONTRIBUTING.md#adding-a-language).
- **Locale-aware number formatting** — Currency, byte sizes, compact token counts (`15K`, `1.5M`), and day-of-week charts now follow the user's locale instead of POSIX / English defaults.
- **Chat slash-command menu** — Type `/` in Rich Chat to browse every command the agent has advertised plus any user-defined `quick_commands:` from config.yaml. ↑/↓ to navigate, Tab/Enter to complete, Esc to dismiss.
- **Chat polish** — Auto-scroll on send and on prompt completion, a non-blocking loading spinner during session reconnects, properly centered empty state, and the long-standing "session loads with whitespace" bug fixed (LazyVStack → VStack in the message list).
See the full [v2.1.0 release notes](https://github.com/awizemann/scarf/releases/tag/v2.1.0).
### Previously, in 2.0
- **Multi-server** — Manage multiple Hermes installations (local + any number of remotes) from one app. Each window binds to one server; open them side-by-side.
- **Remote Hermes over SSH** — Every feature that worked against your local `~/.hermes/` now works against a remote host. File I/O routes through `scp`/`sftp`; chat ACP runs over `ssh -T`; SQLite is served from atomic `.backup` snapshots pulled on file-watcher ticks.
- **Chat UX overhaul** — No more white-screen flash on first message, no more scroll jumping into whitespace during streaming, failed prompts explain themselves instead of silently spinning forever.
- **Correctness pass** — Fixed remote WAL error spam, stale-snapshot session resume, auto-resume of dead cron sessions, 230+ Swift 6 concurrency warnings.
See the [v2.0.0 release notes](https://github.com/awizemann/scarf/releases/tag/v2.0.0) for the full 2.0 series.
### Previously, in 1.6
- **Platforms** — Native GUI setup for all 13 messaging platforms, no more hand-editing `.env`
- **Credential Pools** — Fixed OAuth flow and API-key handling; pick providers from a catalog
- **Model Picker** — Hierarchical browser backed by the 111-provider models.dev cache
- **Settings tabs** — 10 organized tabs covering ~60 previously hidden config fields
- **Configure sidebar** — Personalities, Quick Commands, Plugins, Webhooks, Profiles
See the [v1.6.0 release notes](https://github.com/awizemann/scarf/releases/tag/v1.6.0) for the full 1.6 series.
## 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 area through 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
- **Settings** — Configuration display with raw YAML viewer and Finder path links
- **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)
- Xcode 16.0+
- [Hermes agent](https://github.com/hermes-ai/hermes-agent) v0.6.0+ installed at `~/.hermes/` on each target host (v0.9.0+ recommended for full feature support)
- 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.
### 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.
| Hermes Version | Status |
|----------------|--------|
| v0.6.0 (2026-03-30) | Verified |
| v0.6.0 (2026-03-31, latest) | Verified |
| v0.7.0 (2026-04-03) | Verified |
| v0.8.0 (2026-04-08) | Verified |
| v0.9.0 (2026-04-13) | Verified |
| v0.10.0 (2026-04-18) | Verified (recommended for full 2.0 feature support) |
Scarf 2.0 targets Hermes v0.10.0 for the ACP session/fork/list/resume capabilities used by remote chat. Earlier Hermes versions remain supported for monitoring, sessions, and file-based features; ACP-specific behavior may gracefully degrade 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.
## Building
## Install
### Pre-built Binary (no Xcode required)
Download the latest build from [Releases](https://github.com/awizemann/scarf/releases):
- `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.
### Build from Source
```bash
git clone https://github.com/awizemann/scarf.git
@@ -61,7 +182,7 @@ open scarf.xcodeproj
Or from the command line:
```bash
xcodebuild -project scarf/scarf.xcodeproj -scheme scarf -configuration Debug build
xcodebuild -project scarf/scarf.xcodeproj -scheme scarf -configuration Release -arch arm64 -arch x86_64 ONLY_ACTIVE_ARCH=NO build
```
## Architecture
@@ -78,14 +199,16 @@ scarf/
Insights/ Usage analytics and activity patterns
Sessions/ Conversation browser with rename, delete, export
Activity/ Tool execution feed with inspector
Chat/ Embedded terminal via SwiftTerm with voice controls
Projects/ Agent-generated project dashboards with widget rendering
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
Settings/ Configuration display
Settings/ Structured config editor
Navigation/ AppCoordinator + SidebarView
```
@@ -102,11 +225,16 @@ Scarf reads Hermes data directly from `~/.hermes/`:
| `logs/*.log` | Text | Read-only |
| `gateway_state.json` | JSON | Read-only |
| `skills/` | Directory tree | Read-only |
| `hermes acp` | ACP subprocess (JSON-RPC stdio) | Real-time chat |
| `hermes chat` | Terminal subprocess | Interactive |
| `hermes tools` | CLI commands | Enable/Disable |
| `hermes sessions` | CLI commands | Rename/Delete/Export |
| `hermes gateway` | CLI commands | Start/Stop/Restart |
| `hermes pairing` | CLI commands | Approve/Revoke |
| `hermes mcp` | CLI commands | Add/Remove/Test MCP servers |
| `mcp-tokens/*.json` | JSON (per-server OAuth) | Detect/Delete |
| `.scarf/dashboard.json` | JSON (per-project) | Read-only |
| `scarf/projects.json` | JSON (registry) | Read/Write |
The app opens `state.db` in read-only mode to avoid WAL contention with Hermes. Management actions (tool toggles, session rename/delete/export) go through the Hermes CLI.
@@ -115,19 +243,180 @@ 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, GCD file watching.
Everything else uses system frameworks: SQLite3 C API, Foundation JSON, AttributedString markdown, SwiftUI Charts, GCD file watching.
## How It Works
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.
The app sandbox is disabled because Scarf needs direct access to `~/.hermes/` and the ability to spawn the Hermes binary.
## 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, 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
- **Development dashboards** — test coverage, build status, open issues, sprint progress
- **Data project trackers** — pipeline metrics, data quality scores, processing throughput
- **Deployment monitors** — deploy history tables, uptime stats, error rate charts
- **Research dashboards** — experiment results, key findings, paper status checklists
- **Agent activity views** — cron job results, content generation stats, task completion rates
- **Embedded web apps** — local dev servers, HTML reports, Grafana dashboards, any web-based tool your agent generates
- **Any project status** — if your agent can measure it, Scarf can display it
### Quick Start
**1. Create the dashboard file**
Create `.scarf/dashboard.json` in any project folder:
```json
{
"version": 1,
"title": "My Project",
"description": "Project status at a glance",
"sections": [
{
"title": "Overview",
"columns": 3,
"widgets": [
{
"type": "stat",
"title": "Test Coverage",
"value": "87%",
"icon": "checkmark.shield",
"color": "green",
"subtitle": "+2.1% this week"
},
{
"type": "progress",
"title": "Sprint Progress",
"value": 0.73,
"label": "73% complete",
"color": "blue"
},
{
"type": "list",
"title": "Tasks",
"items": [
{ "text": "Write unit tests", "status": "done" },
{ "text": "Update API docs", "status": "active" },
{ "text": "Deploy to prod", "status": "pending" }
]
}
]
}
]
}
```
**2. Register your project**
In Scarf, go to **Projects** in the sidebar and click the **+** button to add your project folder. Or have your agent add it directly to the registry at `~/.hermes/scarf/projects.json`:
```json
{
"projects": [
{ "name": "my-project", "path": "/Users/you/Developer/my-project" }
]
}
```
**3. View in Scarf**
Select your project in the Projects sidebar — the dashboard renders immediately. Scarf watches the file for changes and refreshes automatically whenever the JSON is updated.
### Widget Types
| Type | Description | Key Fields |
|------|-------------|------------|
| `stat` | Key metric with large value display | `value`, `icon`, `color`, `subtitle` |
| `progress` | Progress bar with label | `value` (0.01.0), `label`, `color` |
| `text` | Rich text block | `content`, `format` ("markdown" or "plain") |
| `table` | Data table with headers | `columns`, `rows` |
| `chart` | Line, bar, or pie chart | `chartType`, `series` (each with `name`, `color`, `data`) |
| `list` | Checklist with status indicators | `items` (each with `text`, `status`: done/active/pending) |
| `webview` | Embedded web browser | `url`, `height` (default 400) |
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.
**Colors**: red, orange, yellow, green, blue, purple, pink, teal, indigo, mint, brown, gray
**Icons**: Any [SF Symbol](https://developer.apple.com/sf-symbols/) name (e.g., `checkmark.shield`, `cpu`, `doc.text`, `chart.bar`)
### Agent-Generated Dashboards
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, 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.
### Dashboard Schema Reference
```json
{
"version": 1,
"title": "Required — dashboard title",
"description": "Optional — subtitle text",
"updatedAt": "Optional — ISO 8601 timestamp",
"sections": [
{
"title": "Section Name",
"columns": 3,
"widgets": [{ "type": "...", "title": "..." }]
}
]
}
```
Each section defines a grid with 14 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.
@@ -138,6 +427,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.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+25
View File
@@ -0,0 +1,25 @@
## What's New in 1.6.1
### Auto-updates
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()`
- One-command release pipeline at [scripts/release.sh](https://github.com/awizemann/scarf/blob/main/scripts/release.sh) handles archive → sign → notarize → staple → appcast → GitHub release → tag
---
**Migrating from 1.6.0:** unzip and replace your existing `Scarf.app` in `/Applications`. After this release, future updates install in-place via Sparkle.
+13
View File
@@ -0,0 +1,13 @@
## What's New in 1.6.2
### Fixes
- **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.
+58
View File
@@ -0,0 +1,58 @@
## What's New in 2.0
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.6v0.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.
+68
View File
@@ -0,0 +1,68 @@
## What's New in 2.0.1
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.
+41
View File
@@ -0,0 +1,41 @@
## What's New in 2.0.2
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.
The fix is to use a much shorter path:
```swift
"/tmp/scarf-ssh-\(getuid())" // ~17 bytes + 64 hash + sep + NUL = ~83 bytes
```
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.
+52
View File
@@ -0,0 +1,52 @@
## What's New in 2.1.0
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.
+94
View File
@@ -0,0 +1,94 @@
## What's New in 2.2.0
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 "&lt;name&gt;" 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.
### Documentation
- [Project Templates wiki page](https://github.com/awizemann/scarf/wiki/Project-Templates) — installing, exporting, configuring, authoring, uninstalling.
- [Catalog site](https://awizemann.github.io/scarf/templates/) — the public catalog with live dashboard previews.
- [`templates/CONTRIBUTING.md`](https://github.com/awizemann/scarf/blob/main/templates/CONTRIBUTING.md) — how to submit a template via PR.
- [Architecture notes in root `CLAUDE.md`](https://github.com/awizemann/scarf/blob/main/CLAUDE.md#project-templates) — service-layer map, Keychain scheme, schema-drift discipline.
### Thanks
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).
+38
View File
@@ -0,0 +1,38 @@
## What's New in 2.2.1
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.
### Documentation
- [Project Templates wiki page](https://github.com/awizemann/scarf/wiki/Project-Templates) — installing, exporting, configuring, authoring, uninstalling.
- [Catalog site](https://awizemann.github.io/scarf/templates/) — two templates live: `awizemann/site-status-checker` and `awizemann/template-author`.
- [`templates/CONTRIBUTING.md`](https://github.com/awizemann/scarf/blob/main/templates/CONTRIBUTING.md) — how to submit a template via PR.
+124
View File
@@ -0,0 +1,124 @@
## What's New in 2.3.0
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)
- Registered cron jobs: [tmpl:<id>] <name> — schedule …
<!-- scarf-project:end -->
```
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.
- **Navigation title** reads `Chat · <ProjectName>` when scoped, plain `Chat` otherwise — macOS `Subject — Detail` convention.
- **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.
- `ProjectsViewModel` gains `moveProject / renameProject / archiveProject / unarchiveProject / folders` — rename preserves selection; archive clears it; reorders driven by `localizedCaseInsensitiveCompare` for locale-aware ordering.
- **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.
+169
View File
@@ -0,0 +1,169 @@
# Scarf Project Dashboard Schema
Scarf can render project dashboards from a JSON file. Place a `dashboard.json` file at `.scarf/dashboard.json` in your project root, and register the project in Scarf.
## Registration
Projects are registered in `~/.hermes/scarf/projects.json`:
```json
{
"projects": [
{ "name": "my-project", "path": "/path/to/my-project" }
]
}
```
You can also add projects from the Scarf UI via the Projects section.
## Dashboard File
Create `.scarf/dashboard.json` in your project root:
```json
{
"version": 1,
"title": "My Project",
"description": "Optional description",
"updatedAt": "2026-03-31T14:00:00Z",
"sections": [
{
"title": "Section Name",
"columns": 3,
"widgets": []
}
]
}
```
## Widget Types
### stat — Key metric display
```json
{
"type": "stat",
"title": "Test Coverage",
"value": "87.3%",
"icon": "checkmark.shield",
"color": "green",
"subtitle": "+2.1% from last week"
}
```
- `value`: String or number
- `icon`: SF Symbol name (optional)
- `color`: red, orange, yellow, green, blue, purple, pink, teal, indigo, mint, brown, gray (optional)
- `subtitle`: Secondary text (optional)
### progress — Progress bar
```json
{
"type": "progress",
"title": "Sprint Progress",
"value": 0.73,
"label": "73% complete",
"color": "blue"
}
```
- `value`: Number between 0.0 and 1.0
- `label`: Text below the bar (optional)
- `color`: Named color (optional)
### text — Rich text block
```json
{
"type": "text",
"title": "Release Notes",
"content": "**v2.4.1** — Fixed auth timeout\n\n- Bug fix for session handling",
"format": "markdown"
}
```
- `content`: Text content
- `format`: "markdown" or "plain" (default: plain)
### table — Data table
```json
{
"type": "table",
"title": "Recent Deploys",
"columns": ["Date", "Env", "Status"],
"rows": [
["Mar 30", "prod", "success"],
["Mar 29", "staging", "success"]
]
}
```
### chart — Line, bar, or pie chart
```json
{
"type": "chart",
"title": "Tests Over Time",
"chartType": "line",
"series": [
{
"name": "Passing",
"color": "green",
"data": [
{ "x": "Mon", "y": 142 },
{ "x": "Tue", "y": 145 }
]
}
]
}
```
- `chartType`: "line", "bar", or "pie"
- `series[].color`: Named color (optional)
- For pie charts, each series becomes a slice
### list — Checklist
```json
{
"type": "list",
"title": "TODO Items",
"icon": "checklist",
"items": [
{ "text": "Write tests", "status": "done" },
{ "text": "Update docs", "status": "active" },
{ "text": "Deploy", "status": "pending" }
]
}
```
- `status`: "done" (checkmark), "active" (filled circle), "pending" (empty circle)
### webview — Embedded web browser
```json
{
"type": "webview",
"title": "Project Dashboard",
"url": "http://localhost:8000",
"height": 500
}
```
- `url`: Any URL — local servers, file paths, or remote pages
- `height`: Height in points (optional, default: 400)
When a dashboard includes a webview widget, Scarf adds a tabbed interface: **Dashboard** shows all normal widgets, **Site** displays the web content full-canvas. The webview widget is automatically filtered out of the Dashboard tab's grid layout.
## Agent Instructions
To have your Hermes agent generate a dashboard, include these instructions:
> Analyze the project and create a `.scarf/dashboard.json` file with relevant metrics,
> status indicators, and visualizations. Use the Scarf dashboard schema with sections
> containing stat, progress, text, table, chart, list, and webview widgets. Register the project
> in `~/.hermes/scarf/projects.json` if not already registered.
The agent can update the dashboard file at any time — Scarf watches for changes and re-renders automatically.
+84
View File
@@ -0,0 +1,84 @@
# Internationalization (i18n)
Scarf uses Apple's modern **String Catalog** workflow. Source strings are auto-extracted from `Text("…")` and `String(localized: …)` literals into [`scarf/scarf/Localizable.xcstrings`](../scarf/Localizable.xcstrings) at build time (when built in Xcode.app; `xcodebuild` alone emits per-source `.stringsdata` but does not merge back into the catalog). Info.plist keys are localized via [`scarf/scarf/InfoPlist.xcstrings`](../scarf/InfoPlist.xcstrings).
## Languages
| Locale | Status |
|---|---|
| `en` (English) | Base / source |
| `zh-Hans` (Simplified Chinese) | AI-translated, native-speaker review welcome |
| `de` (German) | AI-translated, native-speaker review welcome |
| `fr` (French) | AI-translated, native-speaker review welcome |
| `es` (Spanish) | AI-translated, native-speaker review welcome |
| `ja` (Japanese) | AI-translated, native-speaker review welcome |
| `pt-BR` (Portuguese, Brazil) | AI-translated, native-speaker review welcome |
Canadian French users are served by base `fr`. `fr-CA` will be added only if a concrete Québec-specific bug is reported.
### Translation workflow
Source-of-truth per locale lives in `tools/translations/<locale>.json` — a flat `{ "English": "Translation" }` map. The merge step writes those into `scarf/scarf/Localizable.xcstrings` via:
```bash
python3 tools/merge-translations.py
```
Keys absent from a locale file fall back to English at runtime — this is deliberate for proper nouns (Scarf, Hermes, Anthropic, OAuth, SSH…) and format-only strings (`%lld`, `%@ → %@`, `•`). Re-running the merge is idempotent; iterate on a JSON and re-merge.
Contributor path for new languages is documented in the repo root [CONTRIBUTING.md](../../CONTRIBUTING.md#adding-a-language).
## Adding a new language
1. Xcode → Project → Info → Localizations → `+` (add locale).
2. Ensure the locale code is also listed in `knownRegions` of `scarf.xcodeproj/project.pbxproj`.
3. Open `Localizable.xcstrings` in Xcode; the new locale appears as an empty column — translate or use Xcode's AI suggestions.
4. Repeat for `InfoPlist.xcstrings` (microphone usage, etc.).
5. Smoke-test via scheme language override (Edit Scheme → Run → App Language).
## Adding translations (AI-first workflow)
For the three supported non-English locales we use Xcode's built-in AI translation:
1. Open `Localizable.xcstrings` in Xcode.
2. Select untranslated rows for a locale → right-click → **Translate** (Xcode 26+ provides GPT-backed suggestions with context from the surrounding code comment).
3. Review each suggestion before marking **Translated**.
4. For terms that should NOT translate (proper nouns like *Scarf*, *Hermes*, *Anthropic*; env var names; file paths), wrap the source site in `Text(verbatim: "…")` so the key never hits the catalog.
## Guardrails when writing new UI code
`Text("literal")` auto-localizes. These patterns **silently leak English** and need explicit handling:
| Pattern | Fix |
|---|---|
| `Text(someStringVar)` | `Text(LocalizedStringResource("key"))` or pass a `LocalizedStringKey` down the view tree |
| `"Hello " + name` | `String(localized: "Hello \(name)")` |
| `String(format: "$%.2f", cost)` | `cost.formatted(.currency(code: "USD").precision(.fractionLength(2)))` |
| `String(format: "%.1f MB", size)` | `Int64(size).formatted(.byteCount(style: .file))` |
| `String(format: "%.1fM", n)` | `n.formatted(.number.notation(.compactName))` |
| Custom `DateFormatter` with fixed `dateFormat` | `date.formatted(.dateTime.month().day().year())` |
| `.help(stringVar)` | Compute a `LocalizedStringKey` or use `.help(Text(…))` |
| `Button(stringVar)` | `Button(LocalizedStringResource("key")) { … }` |
Strings that are **user data** (session titles, memory file contents, log lines, shell commands shown in UI, file paths) should pass through without localization — this happens naturally when the value is a `String` variable, since those overloads skip the catalog.
## Audit status
Phase 1b (the `multi-language` PR) closed every tracked site from the original audit:
- **Category A high-priority (ternary UI copy)** — converted to `Text`-ternary form so each branch routes through `LocalizedStringKey`.
- **Category A medium-priority (enum `.rawValue` displays)** — each enum now exposes `displayName: LocalizedStringResource` and call sites use it. `LogEntry.LogLevel` (technical jargon) stays verbatim.
- **Category A lower-priority (displayName passthroughs)** — wrapped with `Text(verbatim:)` for proper nouns / user data (`HermesToolPlatform`, `ServerRegistry.Entry`, `MCPServerPreset`). `MCPTransport.displayName` promoted to `LocalizedStringResource`.
- **Category B (composite format strings)** — migrated to `Text("\(arg) suffix")` with `LocalizedStringKey` or to `.percent` / `.currency` FormatStyle.
- **Category C (hard-coded day names)** — replaced with `Calendar.current.shortWeekdaySymbols`, re-indexed to match the existing Mon=0 data model.
- **Category D (`.help(stringVar)` sites)** — `ConnectionStatusPill` now returns `Text` from its `labelText` / `tooltipText` properties.
If you spot a new silently-un-localizable site during translation review, prefer the patterns in the table above over one-off workarounds.
### Non-blocking (intentional verbatim)
The following are correct as-is because they pass user data or machine-readable content through to the UI:
- Session titles, message content, memory / skill / YAML file contents, log lines, shell commands, file paths, session IDs, model IDs, credential sources, URL strings.
If we later need to badge these (e.g. "(empty)" placeholder), the badge itself becomes a localizable key while the data passthrough stays verbatim.
+53
View File
@@ -56,3 +56,56 @@ Rich usage analytics pulled from the sessions and messages SQLite tables:
### 10. Config Editor
- Structured form editor for config.yaml with validation
---
## Projects System Evolution (post-v2.2.1)
A parallel backlog specific to the Projects feature. Ordered by dependency: organization first, then per-project attribution via sidecar, then observability built on that attribution, then polish, then platform bets.
### Shipping in v2.3 (planned — plan file at `~/.claude/plans/`)
- **Folder hierarchy in the sidebar.** `ProjectEntry` gains optional `folder: String?`. `DisclosureGroup`-based sidebar.
- **Rename + archive + search.** Registry verbs + a fuzzy ⌘F search + soft-archive (`archived: Bool?`) with Show/Hide toggle.
- **⌘1–⌘9 project jumps.**
- **Per-project Sessions tab** alongside Dashboard / Site. Filters the global sessions list by a new `~/.hermes/scarf/session_project_map.json` sidecar that Scarf populates when it starts a chat with a project context.
- **New Chat button** on the Sessions tab — spawns `hermes acp` with `cwd = project.path` and attributes the resulting session in the sidecar.
### v2.4+ — per-project observability
Depends on v2.3's sidecar being stable. All features below are "filter the existing data by the sidecar's project mapping."
- **Per-project activity feed.** Extend `ActivityViewModel` with a `projectPath` filter that maps through the sidecar. Dashboard widget type `recent-activity`.
- **Per-project token / cost rollup.** `InsightsViewModel.computeAggregates()` already sums over sessions; add a project filter. Widget binding `project.tokens` exposes it to agent-driven dashboards.
- **Per-project cron-job filter.** Cron sidebar gains a project dropdown. Template-installed jobs already carry `[tmpl:<id>]` prefixes; match against installed template manifests to attribute.
- **Desktop notifications for cron completion.** When a project-attributed cron job finishes (success or failure), fire a `UNUserNotification`. Per-project mute.
### v2.5+ — platform bets
Bigger investments with longer arcs.
- **Hermes upstream: `sessions.cwd` column.** Propose adding a nullable `cwd` (or `workspace_id`) column to Hermes's sessions table, populated on session create. Scarf would prefer the canonical column when available and fall back to the sidecar for pre-upgrade sessions. Requires coordinated Hermes release; filed under platform bets because it cuts the sidecar's blind spot (CLI-started sessions never enter the sidecar today).
- **Per-project memory slice.** Hermes reads `MEMORY.md` from a known path. Explore whether Scarf can spawn `hermes acp` with an overridden memory path (per-project `<project>/.scarf/MEMORY.md`) so projects get isolated context. Needs a Hermes-side env var or flag.
- **Per-project skills namespace.** Today user-authored skills are flat under `~/.hermes/skills/`. A `~/.hermes/skills/project/<slug>/` namespace parallel to the existing `templates/` namespace would let users install skills *into* a project without a template. Uninstall = drop the folder.
- **Cross-project meta-dashboards.** A portfolio view that aggregates widgets from multiple projects — total token spend, combined activity feed, project-health matrix. Useful at 20+ projects.
- **Project backup / restore.** One-click zip of `<project>/` + sidecar entries + related Keychain secrets, restorable on another machine. Richer than the existing Export flow (which carries the template shape only).
### Continuous — UX polish
Small, shippable at any time. Each is a half-day-to-one-day item.
- **Drag-and-drop to reorder** projects within a folder and between folders. Would be the first use of `.onDrag`/`.onDrop` in the codebase; establishes the pattern.
- **Tags as a secondary axis.** Keep folders as primary, add multi-valued string tags + filter chips at the sidebar top. Decide only if folders feel insufficient after v2.3 lands.
- **Favorites / pin** — bubble a project to the top of its folder.
- **Recent projects collection** — auto-populated "Recents" row at the top of the sidebar.
- **Color labels or SF Symbol icons** per project (Finder-tag-style).
- **Project dashboard starter templates** — "blank", "monitor", "feed", "timeline" shapes when creating a bare project (distinct from `.scarftemplate` sharing flow).
- **Opportunistic session backfill.** When Scarf loads any session that isn't in the sidecar, peek at first tool call's `working_directory` or `cwd` hint; if it matches a registered project path, write a sidecar entry. Heuristic, not perfect — useful as an "it just works" improvement after v2.3 ships.
### Research / verification gaps
Noted during v2.3 planning; chase when relevant:
- `DisclosureGroup` inside `List(.sidebar)` on macOS — occasional animation glitches with many-rows-expanding. Early prototype will confirm before full commit.
- Concurrent sidecar writers from multiple Scarf windows on the same `~/.hermes` — atomic replace handles per-write; reload behavior may lag. Acceptable; revisit if users report stale attribution.
- Do Hermes sessions ever persist `cwd` anywhere in `state.db` today that we've missed? If so, we can skip the sidecar and use it directly. Worth a one-hour investigation before starting v2.4 observability work.
+68 -24
View File
@@ -8,6 +8,7 @@
/* Begin PBXBuildFile section */
53495AB62F7B992C00BD31AD /* SwiftTerm in Frameworks */ = {isa = PBXBuildFile; productRef = 53SWIFTTERM0001 /* SwiftTerm */; };
53SPARKLE00010 /* Sparkle in Frameworks */ = {isa = PBXBuildFile; productRef = 53SPARKLE00011 /* Sparkle */; };
/* End PBXBuildFile section */
/* Begin PBXContainerItemProxy section */
@@ -33,9 +34,22 @@
534959592F7B83B700BD31AD /* scarfUITests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = scarfUITests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
/* End PBXFileReference section */
/* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */
534959AA2F7B83B600BD31AD /* Exceptions for "scarf" folder in "scarf" target */ = {
isa = PBXFileSystemSynchronizedBuildFileExceptionSet;
membershipExceptions = (
Info.plist,
);
target = 5349593F2F7B83B600BD31AD /* scarf */;
};
/* End PBXFileSystemSynchronizedBuildFileExceptionSet section */
/* Begin PBXFileSystemSynchronizedRootGroup section */
534959422F7B83B600BD31AD /* scarf */ = {
isa = PBXFileSystemSynchronizedRootGroup;
exceptions = (
534959AA2F7B83B600BD31AD /* Exceptions for "scarf" folder in "scarf" target */,
);
path = scarf;
sourceTree = "<group>";
};
@@ -57,6 +71,7 @@
buildActionMask = 2147483647;
files = (
53495AB62F7B992C00BD31AD /* SwiftTerm in Frameworks */,
53SPARKLE00010 /* Sparkle in Frameworks */,
);
runOnlyForDeploymentPostprocessing = 0;
};
@@ -118,6 +133,7 @@
name = scarf;
packageProductDependencies = (
53SWIFTTERM0001 /* SwiftTerm */,
53SPARKLE00011 /* Sparkle */,
);
productName = scarf;
productReference = 534959402F7B83B600BD31AD /* scarf.app */;
@@ -198,11 +214,18 @@
knownRegions = (
en,
Base,
"zh-Hans",
de,
fr,
es,
ja,
"pt-BR",
);
mainGroup = 534959372F7B83B600BD31AD;
minimizedProjectReferenceProxies = 1;
packageReferences = (
53SWIFTTERM0002 /* XCRemoteSwiftPackageReference "SwiftTerm" */,
53SPARKLE00012 /* XCRemoteSwiftPackageReference "Sparkle" */,
);
preferredProjectObjectVersion = 77;
productRefGroup = 534959412F7B83B600BD31AD /* Products */;
@@ -283,6 +306,7 @@
buildSettings = {
ALWAYS_SEARCH_USER_PATHS = NO;
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
CLANG_ANALYZER_LOCALIZABILITY_NONLOCALIZED = YES;
CLANG_ANALYZER_NONNULL = YES;
CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
CLANG_CXX_LANGUAGE_STANDARD = "gnu++20";
@@ -312,6 +336,7 @@
CLANG_WARN_UNREACHABLE_CODE = YES;
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
COPY_PHASE_STRIP = NO;
DEAD_CODE_STRIPPING = YES;
DEBUG_INFORMATION_FORMAT = dwarf;
DEVELOPMENT_TEAM = 3Q6X2L86C4;
ENABLE_STRICT_OBJC_MSGSEND = YES;
@@ -337,6 +362,7 @@
MTL_FAST_MATH = YES;
ONLY_ACTIVE_ARCH = YES;
SDKROOT = macosx;
STRING_CATALOG_GENERATE_SYMBOLS = YES;
SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)";
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
};
@@ -347,6 +373,7 @@
buildSettings = {
ALWAYS_SEARCH_USER_PATHS = NO;
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
CLANG_ANALYZER_LOCALIZABILITY_NONLOCALIZED = YES;
CLANG_ANALYZER_NONNULL = YES;
CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
CLANG_CXX_LANGUAGE_STANDARD = "gnu++20";
@@ -376,6 +403,7 @@
CLANG_WARN_UNREACHABLE_CODE = YES;
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
COPY_PHASE_STRIP = NO;
DEAD_CODE_STRIPPING = YES;
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
DEVELOPMENT_TEAM = 3Q6X2L86C4;
ENABLE_NS_ASSERTIONS = NO;
@@ -394,6 +422,7 @@
MTL_ENABLE_DEBUG_INFO = NO;
MTL_FAST_MATH = YES;
SDKROOT = macosx;
STRING_CATALOG_GENERATE_SYMBOLS = YES;
SWIFT_COMPILATION_MODE = wholemodule;
};
name = Release;
@@ -407,22 +436,21 @@
CODE_SIGN_ENTITLEMENTS = scarf/scarf.entitlements;
CODE_SIGN_STYLE = Automatic;
COMBINE_HIDPI_IMAGES = YES;
CURRENT_PROJECT_VERSION = 1;
CURRENT_PROJECT_VERSION = 25;
DEAD_CODE_STRIPPING = YES;
DEVELOPMENT_TEAM = 3Q6X2L86C4;
ENABLE_APP_SANDBOX = NO;
ENABLE_HARDENED_RUNTIME = YES;
ENABLE_PREVIEWS = YES;
GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_KEY_CFBundleDisplayName = Scarf;
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.utilities";
INFOPLIST_KEY_NSHumanReadableCopyright = "";
INFOPLIST_KEY_NSMicrophoneUsageDescription = "Scarf uses the microphone for Hermes voice chat.";
GENERATE_INFOPLIST_FILE = NO;
INFOPLIST_FILE = scarf/Info.plist;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/../Frameworks",
);
MARKETING_VERSION = 1.0;
PRODUCT_BUNDLE_IDENTIFIER = com.scarf;
MACOSX_DEPLOYMENT_TARGET = 14.6;
MARKETING_VERSION = 2.3.0;
PRODUCT_BUNDLE_IDENTIFIER = com.scarf.app;
PRODUCT_NAME = "$(TARGET_NAME)";
REGISTER_APP_GROUPS = YES;
STRING_CATALOG_GENERATE_SYMBOLS = YES;
@@ -443,22 +471,21 @@
CODE_SIGN_ENTITLEMENTS = scarf/scarf.entitlements;
CODE_SIGN_STYLE = Automatic;
COMBINE_HIDPI_IMAGES = YES;
CURRENT_PROJECT_VERSION = 1;
CURRENT_PROJECT_VERSION = 25;
DEAD_CODE_STRIPPING = YES;
DEVELOPMENT_TEAM = 3Q6X2L86C4;
ENABLE_APP_SANDBOX = NO;
ENABLE_HARDENED_RUNTIME = YES;
ENABLE_PREVIEWS = YES;
GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_KEY_CFBundleDisplayName = Scarf;
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.utilities";
INFOPLIST_KEY_NSHumanReadableCopyright = "";
INFOPLIST_KEY_NSMicrophoneUsageDescription = "Scarf uses the microphone for Hermes voice chat.";
GENERATE_INFOPLIST_FILE = NO;
INFOPLIST_FILE = scarf/Info.plist;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/../Frameworks",
);
MARKETING_VERSION = 1.0;
PRODUCT_BUNDLE_IDENTIFIER = com.scarf;
MACOSX_DEPLOYMENT_TARGET = 14.6;
MARKETING_VERSION = 2.3.0;
PRODUCT_BUNDLE_IDENTIFIER = com.scarf.app;
PRODUCT_NAME = "$(TARGET_NAME)";
REGISTER_APP_GROUPS = YES;
STRING_CATALOG_GENERATE_SYMBOLS = YES;
@@ -475,11 +502,12 @@
buildSettings = {
BUNDLE_LOADER = "$(TEST_HOST)";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
CURRENT_PROJECT_VERSION = 25;
DEAD_CODE_STRIPPING = YES;
DEVELOPMENT_TEAM = 3Q6X2L86C4;
GENERATE_INFOPLIST_FILE = YES;
MACOSX_DEPLOYMENT_TARGET = 26.2;
MARKETING_VERSION = 1.0;
MARKETING_VERSION = 2.3.0;
PRODUCT_BUNDLE_IDENTIFIER = com.scarfTests;
PRODUCT_NAME = "$(TARGET_NAME)";
STRING_CATALOG_GENERATE_SYMBOLS = NO;
@@ -496,11 +524,12 @@
buildSettings = {
BUNDLE_LOADER = "$(TEST_HOST)";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
CURRENT_PROJECT_VERSION = 25;
DEAD_CODE_STRIPPING = YES;
DEVELOPMENT_TEAM = 3Q6X2L86C4;
GENERATE_INFOPLIST_FILE = YES;
MACOSX_DEPLOYMENT_TARGET = 26.2;
MARKETING_VERSION = 1.0;
MARKETING_VERSION = 2.3.0;
PRODUCT_BUNDLE_IDENTIFIER = com.scarfTests;
PRODUCT_NAME = "$(TARGET_NAME)";
STRING_CATALOG_GENERATE_SYMBOLS = NO;
@@ -516,10 +545,11 @@
isa = XCBuildConfiguration;
buildSettings = {
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
CURRENT_PROJECT_VERSION = 25;
DEAD_CODE_STRIPPING = YES;
DEVELOPMENT_TEAM = 3Q6X2L86C4;
GENERATE_INFOPLIST_FILE = YES;
MARKETING_VERSION = 1.0;
MARKETING_VERSION = 2.3.0;
PRODUCT_BUNDLE_IDENTIFIER = com.scarfUITests;
PRODUCT_NAME = "$(TARGET_NAME)";
STRING_CATALOG_GENERATE_SYMBOLS = NO;
@@ -535,10 +565,11 @@
isa = XCBuildConfiguration;
buildSettings = {
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
CURRENT_PROJECT_VERSION = 25;
DEAD_CODE_STRIPPING = YES;
DEVELOPMENT_TEAM = 3Q6X2L86C4;
GENERATE_INFOPLIST_FILE = YES;
MARKETING_VERSION = 1.0;
MARKETING_VERSION = 2.3.0;
PRODUCT_BUNDLE_IDENTIFIER = com.scarfUITests;
PRODUCT_NAME = "$(TARGET_NAME)";
STRING_CATALOG_GENERATE_SYMBOLS = NO;
@@ -592,6 +623,14 @@
/* End XCConfigurationList section */
/* Begin XCRemoteSwiftPackageReference section */
53SPARKLE00012 /* XCRemoteSwiftPackageReference "Sparkle" */ = {
isa = XCRemoteSwiftPackageReference;
repositoryURL = "https://github.com/sparkle-project/Sparkle";
requirement = {
kind = upToNextMajorVersion;
minimumVersion = 2.6.0;
};
};
53SWIFTTERM0002 /* XCRemoteSwiftPackageReference "SwiftTerm" */ = {
isa = XCRemoteSwiftPackageReference;
repositoryURL = "https://github.com/migueldeicaza/SwiftTerm.git";
@@ -603,6 +642,11 @@
/* End XCRemoteSwiftPackageReference section */
/* Begin XCSwiftPackageProductDependency section */
53SPARKLE00011 /* Sparkle */ = {
isa = XCSwiftPackageProductDependency;
package = 53SPARKLE00012 /* XCRemoteSwiftPackageReference "Sparkle" */;
productName = Sparkle;
};
53SWIFTTERM0001 /* SwiftTerm */ = {
isa = XCSwiftPackageProductDependency;
package = 53SWIFTTERM0002 /* XCRemoteSwiftPackageReference "SwiftTerm" */;
@@ -0,0 +1,100 @@
<?xml version="1.0" encoding="UTF-8"?>
<Scheme
LastUpgradeVersion = "2620"
version = "1.7">
<BuildAction
parallelizeBuildables = "YES"
buildImplicitDependencies = "YES"
buildArchitectures = "Automatic">
<BuildActionEntries>
<BuildActionEntry
buildForTesting = "YES"
buildForRunning = "YES"
buildForProfiling = "YES"
buildForArchiving = "YES"
buildForAnalyzing = "YES">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "5349593F2F7B83B600BD31AD"
BuildableName = "scarf.app"
BlueprintName = "scarf"
ReferencedContainer = "container:scarf.xcodeproj">
</BuildableReference>
</BuildActionEntry>
</BuildActionEntries>
</BuildAction>
<TestAction
buildConfiguration = "Debug"
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
shouldUseLaunchSchemeArgsEnv = "YES"
shouldAutocreateTestPlan = "YES">
<Testables>
<TestableReference
skipped = "NO">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "5349594E2F7B83B700BD31AD"
BuildableName = "scarfTests.xctest"
BlueprintName = "scarfTests"
ReferencedContainer = "container:scarf.xcodeproj">
</BuildableReference>
</TestableReference>
<TestableReference
skipped = "NO">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "534959582F7B83B700BD31AD"
BuildableName = "scarfUITests.xctest"
BlueprintName = "scarfUITests"
ReferencedContainer = "container:scarf.xcodeproj">
</BuildableReference>
</TestableReference>
</Testables>
</TestAction>
<LaunchAction
buildConfiguration = "Debug"
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
launchStyle = "0"
useCustomWorkingDirectory = "NO"
ignoresPersistentStateOnLaunch = "NO"
debugDocumentVersioning = "YES"
debugServiceExtension = "internal"
allowLocationSimulation = "YES">
<BuildableProductRunnable
runnableDebuggingMode = "0">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "5349593F2F7B83B600BD31AD"
BuildableName = "scarf.app"
BlueprintName = "scarf"
ReferencedContainer = "container:scarf.xcodeproj">
</BuildableReference>
</BuildableProductRunnable>
</LaunchAction>
<ProfileAction
buildConfiguration = "Release"
shouldUseLaunchSchemeArgsEnv = "YES"
savedToolIdentifier = ""
useCustomWorkingDirectory = "NO"
debugDocumentVersioning = "YES">
<BuildableProductRunnable
runnableDebuggingMode = "0">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "5349593F2F7B83B600BD31AD"
BuildableName = "scarf.app"
BlueprintName = "scarf"
ReferencedContainer = "container:scarf.xcodeproj">
</BuildableReference>
</BuildableProductRunnable>
</ProfileAction>
<AnalyzeAction
buildConfiguration = "Debug">
</AnalyzeAction>
<ArchiveAction
buildConfiguration = "Release"
revealArchiveInOrganizer = "YES">
</ArchiveAction>
</Scheme>
Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.4 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 73 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 962 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 274 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.9 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 55 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 201 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 864 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 195 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 742 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 742 KiB

@@ -1,61 +1,61 @@
{
"images" : [
{
"filename" : "AW Mac OS Applications-iOS-Default-16x16@1x.png",
"filename" : "AW Mac OS Applications-macOS-Default-16x16@1x.png",
"idiom" : "mac",
"scale" : "1x",
"size" : "16x16"
},
{
"filename" : "AW Mac OS Applications-iOS-Default-16x16@2x.png",
"filename" : "AW Mac OS Applications-macOS-Default-16x16@2x.png",
"idiom" : "mac",
"scale" : "2x",
"size" : "16x16"
},
{
"filename" : "AW Mac OS Applications-iOS-Default-32x32@1x.png",
"filename" : "AW Mac OS Applications-macOS-Default-32x32@1x.png",
"idiom" : "mac",
"scale" : "1x",
"size" : "32x32"
},
{
"filename" : "AW Mac OS Applications-iOS-Default-32x32@2x.png",
"filename" : "AW Mac OS Applications-macOS-Default-32x32@2x.png",
"idiom" : "mac",
"scale" : "2x",
"size" : "32x32"
},
{
"filename" : "AW Mac OS Applications-iOS-Default-128x128@1x.png",
"filename" : "AW Mac OS Applications-macOS-Default-128x128@1x.png",
"idiom" : "mac",
"scale" : "1x",
"size" : "128x128"
},
{
"filename" : "AW Mac OS Applications-iOS-Default-128x128@2x.png",
"filename" : "AW Mac OS Applications-macOS-Default-128x128@2x.png",
"idiom" : "mac",
"scale" : "2x",
"size" : "128x128"
},
{
"filename" : "AW Mac OS Applications-iOS-Default-256x256@1x.png",
"filename" : "AW Mac OS Applications-macOS-Default-256x256@1x.png",
"idiom" : "mac",
"scale" : "1x",
"size" : "256x256"
},
{
"filename" : "AW Mac OS Applications-iOS-Default-512x512@1x 1.png",
"filename" : "AW Mac OS Applications-macOS-Default-256x256@2x 1.png",
"idiom" : "mac",
"scale" : "2x",
"size" : "256x256"
},
{
"filename" : "AW Mac OS Applications-iOS-Default-512x512@1x.png",
"filename" : "AW Mac OS Applications-macOS-Default-512x512@1x.png",
"idiom" : "mac",
"scale" : "1x",
"size" : "512x512"
},
{
"filename" : "AW Mac OS Applications-iOS-Default-1024x1024@1x.png",
"filename" : "AW Mac OS Applications-macOS-Default-1024x1024@1x.png",
"idiom" : "mac",
"scale" : "2x",
"size" : "512x512"
+61 -26
View File
@@ -2,44 +2,79 @@ import SwiftUI
struct ContentView: View {
@Environment(AppCoordinator.self) private var coordinator
@Environment(\.serverContext) private var serverContext
/// Per-window connection status. Constructed from the window's
/// `serverContext` once; lifetime matches the window.
@State private var connectionStatus: ConnectionStatusViewModel
init() {
_connectionStatus = State(initialValue: ConnectionStatusViewModel(context: .local))
}
var body: some View {
NavigationSplitView {
SidebarView()
.navigationSplitViewColumnWidth(min: 180, ideal: 240, max: 360)
} detail: {
detailView
.toolbar {
ToolbarItem(placement: .navigation) {
ServerSwitcherToolbar()
}
if serverContext.isRemote {
// `.principal` centers the pill in the toolbar
// the native emphasis bezel is the intended frame;
// the pill's own visual content (icon + label, no
// background) sits inside it in balance.
ToolbarItem(placement: .principal) {
ConnectionStatusPill(status: connectionStatus)
}
}
}
.onAppear {
// The actual context is injected via @Environment, which
// isn't available in `init`. Rebuild the monitor here
// the first time we know the real context. Safe to call
// repeatedly; `startMonitoring()` cancels + restarts.
if connectionStatus.context.id != serverContext.id {
connectionStatus = ConnectionStatusViewModel(context: serverContext)
}
connectionStatus.startMonitoring()
}
.onDisappear { connectionStatus.stopMonitoring() }
}
}
@ViewBuilder
private var detailView: some View {
// Each routed view receives the window's `serverContext` in its
// init so its `@State` ViewModel is constructed bound to the right
// server. This is what makes multi-window work without it,
// every window's VMs default-construct with `.local` even though
// the surrounding env has the right context.
switch coordinator.selectedSection {
case .dashboard:
DashboardView()
case .insights:
InsightsView()
case .sessions:
SessionsView()
case .activity:
ActivityView()
case .chat:
ChatView()
case .memory:
MemoryView()
case .skills:
SkillsView()
case .tools:
ToolsView()
case .gateway:
GatewayView()
case .cron:
CronView()
case .health:
HealthView()
case .logs:
LogsView()
case .settings:
SettingsView()
case .dashboard: DashboardView(context: serverContext)
case .insights: InsightsView(context: serverContext)
case .sessions: SessionsView(context: serverContext)
case .activity: ActivityView(context: serverContext)
case .projects: ProjectsView(context: serverContext)
case .chat: ChatView()
case .memory: MemoryView(context: serverContext)
case .skills: SkillsView(context: serverContext)
case .platforms: PlatformsView(context: serverContext)
case .personalities: PersonalitiesView(context: serverContext)
case .quickCommands: QuickCommandsView(context: serverContext)
case .credentialPools: CredentialPoolsView(context: serverContext)
case .plugins: PluginsView(context: serverContext)
case .webhooks: WebhooksView(context: serverContext)
case .profiles: ProfilesView(context: serverContext)
case .tools: ToolsView(context: serverContext)
case .mcpServers: MCPServersView(context: serverContext)
case .gateway: GatewayView(context: serverContext)
case .cron: CronView(context: serverContext)
case .health: HealthView(context: serverContext)
case .logs: LogsView(context: serverContext)
case .settings: SettingsView(context: serverContext)
}
}
}
+290
View File
@@ -0,0 +1,290 @@
import Foundation
// MARK: - JSON-RPC Transport
// Hand-written `encode(to:)` / `init(from:)` with explicit `nonisolated` so
// Swift 6's default-isolation doesn't synthesize a MainActor-isolated
// conformance which would prevent these payloads from being encoded or
// decoded inside `ACPClient`'s actor context (the JSON-RPC read/write loop).
// The member list must stay in sync with the stored properties above.
struct ACPRequest: Encodable, Sendable {
nonisolated let jsonrpc = "2.0"
nonisolated let id: Int
nonisolated let method: String
nonisolated let params: [String: AnyCodable]
enum CodingKeys: String, CodingKey { case jsonrpc, id, method, params }
nonisolated func encode(to encoder: any Encoder) throws {
var c = encoder.container(keyedBy: CodingKeys.self)
try c.encode(jsonrpc, forKey: .jsonrpc)
try c.encode(id, forKey: .id)
try c.encode(method, forKey: .method)
try c.encode(params, forKey: .params)
}
}
struct ACPRawMessage: Decodable, Sendable {
nonisolated let jsonrpc: String?
nonisolated let id: Int?
nonisolated let method: String?
nonisolated let result: AnyCodable?
nonisolated let error: ACPError?
nonisolated let params: AnyCodable?
nonisolated var isResponse: Bool { id != nil && method == nil }
nonisolated var isNotification: Bool { method != nil && id == nil }
nonisolated var isRequest: Bool { method != nil && id != nil }
enum CodingKeys: String, CodingKey { case jsonrpc, id, method, result, error, params }
nonisolated init(from decoder: any Decoder) throws {
let c = try decoder.container(keyedBy: CodingKeys.self)
self.jsonrpc = try c.decodeIfPresent(String.self, forKey: .jsonrpc)
self.id = try c.decodeIfPresent(Int.self, forKey: .id)
self.method = try c.decodeIfPresent(String.self, forKey: .method)
self.result = try c.decodeIfPresent(AnyCodable.self, forKey: .result)
self.error = try c.decodeIfPresent(ACPError.self, forKey: .error)
self.params = try c.decodeIfPresent(AnyCodable.self, forKey: .params)
}
}
struct ACPError: Decodable, Sendable {
nonisolated let code: Int
nonisolated let message: String
enum CodingKeys: String, CodingKey { case code, message }
nonisolated init(from decoder: any Decoder) throws {
let c = try decoder.container(keyedBy: CodingKeys.self)
self.code = try c.decode(Int.self, forKey: .code)
self.message = try c.decode(String.self, forKey: .message)
}
}
// MARK: - AnyCodable (for dynamic JSON)
struct AnyCodable: Codable, @unchecked Sendable {
nonisolated let value: Any
nonisolated init(_ value: Any) { self.value = value }
// NOT marked `nonisolated`: Swift's default-isolation treats writes to a
// `let value: Any` stored property as MainActor-isolated even when the
// property is declared nonisolated (Any can't be strictly Sendable, so
// the compiler can't prove the write is safe off-main). Leaving the
// init as default-isolated silences the mutation warnings; the Decodable
// conformance is still usable from ACPClient's nonisolated read loop
// because all callers are already @preconcurrency with respect to
// `AnyCodable` (it's @unchecked Sendable).
init(from decoder: any Decoder) throws {
let container = try decoder.singleValueContainer()
if container.decodeNil() {
value = NSNull()
} else if let bool = try? container.decode(Bool.self) {
value = bool
} else if let int = try? container.decode(Int.self) {
value = int
} else if let double = try? container.decode(Double.self) {
value = double
} else if let string = try? container.decode(String.self) {
value = string
} else if let array = try? container.decode([AnyCodable].self) {
value = array.map(\.value)
} else if let dict = try? container.decode([String: AnyCodable].self) {
value = dict.mapValues(\.value)
} else {
value = NSNull()
}
}
func encode(to encoder: any Encoder) throws {
var container = encoder.singleValueContainer()
switch value {
case is NSNull:
try container.encodeNil()
case let bool as Bool:
try container.encode(bool)
case let int as Int:
try container.encode(int)
case let double as Double:
try container.encode(double)
case let string as String:
try container.encode(string)
case let array as [Any]:
try container.encode(array.map { AnyCodable($0) })
case let dict as [String: Any]:
try container.encode(dict.mapValues { AnyCodable($0) })
default:
try container.encodeNil()
}
}
// MARK: - Accessors
nonisolated var stringValue: String? { value as? String }
nonisolated var intValue: Int? { value as? Int }
nonisolated var dictValue: [String: Any]? { value as? [String: Any] }
nonisolated var arrayValue: [Any]? { value as? [Any] }
}
// MARK: - ACP Events (parsed from session/update notifications)
enum ACPEvent: Sendable {
case messageChunk(sessionId: String, text: String)
case thoughtChunk(sessionId: String, text: String)
case toolCallStart(sessionId: String, call: ACPToolCallEvent)
case toolCallUpdate(sessionId: String, update: ACPToolCallUpdateEvent)
case permissionRequest(sessionId: String, requestId: Int, request: ACPPermissionRequestEvent)
case promptComplete(sessionId: String, response: ACPPromptResult)
case availableCommands(sessionId: String, commands: [[String: Any]])
case connectionLost(reason: String)
case unknown(sessionId: String, type: String)
}
struct ACPToolCallEvent: Sendable {
let toolCallId: String
let title: String
let kind: String
let status: String
let content: String
let rawInput: [String: Any]?
var functionName: String {
// title format is "functionName: summary" or just "functionName"
let parts = title.split(separator: ":", maxSplits: 1)
return String(parts.first ?? Substring(title)).trimmingCharacters(in: .whitespaces)
}
var argumentsSummary: String {
let parts = title.split(separator: ":", maxSplits: 1)
if parts.count > 1 {
return String(parts[1]).trimmingCharacters(in: .whitespaces)
}
return ""
}
var argumentsJSON: String {
guard let input = rawInput,
let data = try? JSONSerialization.data(withJSONObject: input),
let str = String(data: data, encoding: .utf8) else { return "{}" }
return str
}
}
struct ACPToolCallUpdateEvent: Sendable {
let toolCallId: String
let kind: String
let status: String
let content: String
let rawOutput: String?
}
struct ACPPermissionRequestEvent: Sendable {
let toolCallTitle: String
let toolCallKind: String
let options: [(optionId: String, name: String)]
}
struct ACPPromptResult: Sendable {
let stopReason: String
let inputTokens: Int
let outputTokens: Int
let thoughtTokens: Int
let cachedReadTokens: Int
}
// MARK: - Event Parsing
enum ACPEventParser {
nonisolated static func parse(notification: ACPRawMessage) -> ACPEvent? {
guard notification.method == "session/update",
let params = notification.params?.dictValue,
let sessionId = params["sessionId"] as? String,
let update = params["update"] as? [String: Any],
let updateType = update["sessionUpdate"] as? String else {
return nil
}
switch updateType {
case "agent_message_chunk":
let text = extractContentText(from: update)
return .messageChunk(sessionId: sessionId, text: text)
case "agent_thought_chunk":
let text = extractContentText(from: update)
return .thoughtChunk(sessionId: sessionId, text: text)
case "tool_call":
let event = ACPToolCallEvent(
toolCallId: update["toolCallId"] as? String ?? "",
title: update["title"] as? String ?? "",
kind: update["kind"] as? String ?? "other",
status: update["status"] as? String ?? "pending",
content: extractContentArrayText(from: update),
rawInput: update["rawInput"] as? [String: Any]
)
return .toolCallStart(sessionId: sessionId, call: event)
case "tool_call_update":
let event = ACPToolCallUpdateEvent(
toolCallId: update["toolCallId"] as? String ?? "",
kind: update["kind"] as? String ?? "other",
status: update["status"] as? String ?? "completed",
content: extractContentArrayText(from: update),
rawOutput: update["rawOutput"] as? String
)
return .toolCallUpdate(sessionId: sessionId, update: event)
case "available_commands_update":
let commands = update["availableCommands"] as? [[String: Any]] ?? []
return .availableCommands(sessionId: sessionId, commands: commands)
default:
return .unknown(sessionId: sessionId, type: updateType)
}
}
nonisolated static func parsePermissionRequest(_ message: ACPRawMessage) -> ACPEvent? {
guard message.method == "session/request_permission",
let params = message.params?.dictValue,
let sessionId = params["sessionId"] as? String,
let requestId = message.id else { return nil }
let toolCall = params["toolCall"] as? [String: Any] ?? [:]
let optionsRaw = params["options"] as? [[String: Any]] ?? []
let options = optionsRaw.compactMap { opt -> (optionId: String, name: String)? in
guard let id = opt["optionId"] as? String,
let name = opt["name"] as? String else { return nil }
return (optionId: id, name: name)
}
let event = ACPPermissionRequestEvent(
toolCallTitle: toolCall["title"] as? String ?? "",
toolCallKind: toolCall["kind"] as? String ?? "other",
options: options
)
return .permissionRequest(sessionId: sessionId, requestId: requestId, request: event)
}
// MARK: - Content Extraction
nonisolated private static func extractContentText(from update: [String: Any]) -> String {
if let content = update["content"] as? [String: Any],
let text = content["text"] as? String {
return text
}
return ""
}
nonisolated private static func extractContentArrayText(from update: [String: Any]) -> String {
if let contentArray = update["content"] as? [[String: Any]] {
return contentArray.compactMap { item -> String? in
guard let inner = item["content"] as? [String: Any] else { return nil }
return inner["text"] as? String
}.joined(separator: "\n")
}
return ""
}
}
+445 -12
View File
@@ -1,6 +1,304 @@
import Foundation
/// Settings for one of hermes's auxiliary model tasks (vision, compression, approvals, etc.).
/// Every auxiliary task follows the same provider/model/base_url/api_key/timeout pattern.
struct AuxiliaryModel: Sendable, Equatable {
var provider: String
var model: String
var baseURL: String
var apiKey: String
var timeout: Int
nonisolated static let empty = AuxiliaryModel(provider: "auto", model: "", baseURL: "", apiKey: "", timeout: 30)
}
/// Group of display-related settings mirroring the `display:` block in config.yaml.
struct DisplaySettings: Sendable, Equatable {
var skin: String
var compact: Bool
var resumeDisplay: String // "full" | "minimal"
var bellOnComplete: Bool
var inlineDiffs: Bool
var toolProgressCommand: Bool
var toolPreviewLength: Int
var busyInputMode: String // e.g. "interrupt"
nonisolated static let empty = DisplaySettings(
skin: "default",
compact: false,
resumeDisplay: "full",
bellOnComplete: false,
inlineDiffs: true,
toolProgressCommand: false,
toolPreviewLength: 0,
busyInputMode: "interrupt"
)
}
/// Container/terminal backend options. These map to `terminal.*` keys in config.yaml.
struct TerminalSettings: Sendable, Equatable {
var cwd: String
var timeout: Int
var envPassthrough: [String]
var persistentShell: Bool
var dockerImage: String
var dockerMountCwdToWorkspace: Bool
var dockerForwardEnv: [String]
var dockerVolumes: [String]
var containerCPU: Int // 0 = unlimited
var containerMemory: Int // MB, 0 = unlimited
var containerDisk: Int // MB, 0 = unlimited
var containerPersistent: Bool
var modalImage: String
var modalMode: String // "auto" | other
var daytonaImage: String
var singularityImage: String
nonisolated static let empty = TerminalSettings(
cwd: ".",
timeout: 180,
envPassthrough: [],
persistentShell: true,
dockerImage: "",
dockerMountCwdToWorkspace: false,
dockerForwardEnv: [],
dockerVolumes: [],
containerCPU: 0,
containerMemory: 0,
containerDisk: 0,
containerPersistent: false,
modalImage: "",
modalMode: "auto",
daytonaImage: "",
singularityImage: ""
)
}
/// Browser automation tuning (`browser.*`).
struct BrowserSettings: Sendable, Equatable {
var inactivityTimeout: Int
var commandTimeout: Int
var recordSessions: Bool
var allowPrivateURLs: Bool
var camofoxManagedPersistence: Bool
nonisolated static let empty = BrowserSettings(
inactivityTimeout: 120,
commandTimeout: 30,
recordSessions: false,
allowPrivateURLs: false,
camofoxManagedPersistence: false
)
}
/// Voice push-to-talk plus TTS/STT provider settings.
struct VoiceSettings: Sendable, Equatable {
var recordKey: String
var maxRecordingSeconds: Int
var silenceDuration: Double
// TTS
var ttsProvider: String
var ttsEdgeVoice: String
var ttsElevenLabsVoiceID: String
var ttsElevenLabsModelID: String
var ttsOpenAIModel: String
var ttsOpenAIVoice: String
var ttsNeuTTSModel: String
var ttsNeuTTSDevice: String
// STT
var sttEnabled: Bool
var sttProvider: String
var sttLocalModel: String
var sttLocalLanguage: String
var sttOpenAIModel: String
var sttMistralModel: String
nonisolated static let empty = VoiceSettings(
recordKey: "ctrl+b",
maxRecordingSeconds: 120,
silenceDuration: 3.0,
ttsProvider: "edge",
ttsEdgeVoice: "en-US-AriaNeural",
ttsElevenLabsVoiceID: "",
ttsElevenLabsModelID: "eleven_multilingual_v2",
ttsOpenAIModel: "gpt-4o-mini-tts",
ttsOpenAIVoice: "alloy",
ttsNeuTTSModel: "neuphonic/neutts-air-q4-gguf",
ttsNeuTTSDevice: "cpu",
sttEnabled: true,
sttProvider: "local",
sttLocalModel: "base",
sttLocalLanguage: "",
sttOpenAIModel: "whisper-1",
sttMistralModel: "voxtral-mini-latest"
)
}
/// Eight sub-models that share the same provider/model/base_url/api_key/timeout shape.
struct AuxiliarySettings: Sendable, Equatable {
var vision: AuxiliaryModel
var webExtract: AuxiliaryModel
var compression: AuxiliaryModel
var sessionSearch: AuxiliaryModel
var skillsHub: AuxiliaryModel
var approval: AuxiliaryModel
var mcp: AuxiliaryModel
var flushMemories: AuxiliaryModel
nonisolated static let empty = AuxiliarySettings(
vision: .empty,
webExtract: .empty,
compression: .empty,
sessionSearch: .empty,
skillsHub: .empty,
approval: .empty,
mcp: .empty,
flushMemories: .empty
)
}
/// Security/redaction/firewall config. Website blocklist is nested in YAML.
struct SecuritySettings: Sendable, Equatable {
var redactSecrets: Bool
var redactPII: Bool // from privacy.redact_pii
var tirithEnabled: Bool
var tirithPath: String
var tirithTimeout: Int
var tirithFailOpen: Bool
var blocklistEnabled: Bool
var blocklistDomains: [String]
nonisolated static let empty = SecuritySettings(
redactSecrets: true,
redactPII: false,
tirithEnabled: true,
tirithPath: "tirith",
tirithTimeout: 5,
tirithFailOpen: true,
blocklistEnabled: false,
blocklistDomains: []
)
}
/// Human-delay simulates realistic typing pace (`human_delay.*`).
struct HumanDelaySettings: Sendable, Equatable {
var mode: String // "off" | "natural" | "custom"
var minMS: Int
var maxMS: Int
nonisolated static let empty = HumanDelaySettings(mode: "off", minMS: 800, maxMS: 2500)
}
/// Compression / context routing.
struct CompressionSettings: Sendable, Equatable {
var enabled: Bool
var threshold: Double
var targetRatio: Double
var protectLastN: Int
nonisolated static let empty = CompressionSettings(enabled: true, threshold: 0.5, targetRatio: 0.2, protectLastN: 20)
}
struct CheckpointSettings: Sendable, Equatable {
var enabled: Bool
var maxSnapshots: Int
nonisolated static let empty = CheckpointSettings(enabled: true, maxSnapshots: 50)
}
struct LoggingSettings: Sendable, Equatable {
var level: String // DEBUG | INFO | WARNING | ERROR
var maxSizeMB: Int
var backupCount: Int
nonisolated static let empty = LoggingSettings(level: "INFO", maxSizeMB: 5, backupCount: 3)
}
struct DelegationSettings: Sendable, Equatable {
var model: String
var provider: String
var baseURL: String
var apiKey: String
var maxIterations: Int
nonisolated static let empty = DelegationSettings(model: "", provider: "", baseURL: "", apiKey: "", maxIterations: 50)
}
/// Discord-specific platform settings (`discord.*`). Other platforms currently have thinner schemas.
struct DiscordSettings: Sendable, Equatable {
var requireMention: Bool
var freeResponseChannels: String
var autoThread: Bool
var reactions: Bool
nonisolated static let empty = DiscordSettings(requireMention: true, freeResponseChannels: "", autoThread: true, reactions: true)
}
/// Telegram settings under `telegram.*` in config.yaml. Most Telegram tuning is
/// done via environment variables (`TELEGRAM_*`) this is the subset that lives
/// in the YAML.
struct TelegramSettings: Sendable, Equatable {
var requireMention: Bool
var reactions: Bool
nonisolated static let empty = TelegramSettings(requireMention: true, reactions: false)
}
/// Slack settings under `platforms.slack.*` (and a couple of top-level keys).
struct SlackSettings: Sendable, Equatable {
var replyToMode: String // "off" | "first" | "all"
var requireMention: Bool
var replyInThread: Bool
var replyBroadcast: Bool
nonisolated static let empty = SlackSettings(replyToMode: "first", requireMention: true, replyInThread: true, replyBroadcast: false)
}
/// Matrix settings under `matrix.*`.
struct MatrixSettings: Sendable, Equatable {
var requireMention: Bool
var autoThread: Bool
var dmMentionThreads: Bool
nonisolated static let empty = MatrixSettings(requireMention: true, autoThread: true, dmMentionThreads: false)
}
/// Mattermost settings. Mattermost is mostly driven by env vars; config.yaml
/// currently just exposes `group_sessions_per_user` at the top level, but we
/// reserve this struct for future expansion so the form has a stable type.
struct MattermostSettings: Sendable, Equatable {
var requireMention: Bool
var replyMode: String // "thread" | "off"
nonisolated static let empty = MattermostSettings(requireMention: true, replyMode: "off")
}
/// WhatsApp settings under `whatsapp.*`.
struct WhatsAppSettings: Sendable, Equatable {
var unauthorizedDMBehavior: String // "pair" | "ignore"
var replyPrefix: String
nonisolated static let empty = WhatsAppSettings(unauthorizedDMBehavior: "pair", replyPrefix: "")
}
/// Home Assistant filters under `platforms.homeassistant.extra`. Hermes ignores
/// every state change by default; users must opt-in via at least one filter.
struct HomeAssistantSettings: Sendable, Equatable {
var watchDomains: [String]
var watchEntities: [String]
var watchAll: Bool
var ignoreEntities: [String]
var cooldownSeconds: Int
nonisolated static let empty = HomeAssistantSettings(watchDomains: [], watchEntities: [], watchAll: false, ignoreEntities: [], cooldownSeconds: 30)
}
// MARK: - Root Config
struct HermesConfig: Sendable {
// Original fields preserved for zero breakage with existing call sites.
var model: String
var provider: String
var maxTurns: Int
@@ -14,8 +312,63 @@ struct HermesConfig: Sendable {
var showReasoning: Bool
var verbose: Bool
var autoTTS: Bool
var silenceThreshold: Int
var reasoningEffort: String
var showCost: Bool
var approvalMode: String
var browserBackend: String
var memoryProvider: String
var dockerEnv: [String: String]
var commandAllowlist: [String]
var memoryProfile: String
var serviceTier: String
var gatewayNotifyInterval: Int
var forceIPv4: Bool
var contextEngine: String
var interimAssistantMessages: Bool
var honchoInitOnSessionStart: Bool
static let empty = HermesConfig(
// Phase 1 additions
var timezone: String
var userProfileEnabled: Bool
var toolUseEnforcement: String // "auto" | "true" | "false" | comma list
var gatewayTimeout: Int
var approvalTimeout: Int
var fileReadMaxChars: Int
var cronWrapResponse: Bool
var prefillMessagesFile: String
var skillsExternalDirs: [String]
/// Per-platform toolset allowlists as written by `hermes setup tools` /
/// `hermes tools`. Keyed by platform (`cli`, `slack`, `discord`, ) to a
/// list of enabled toolset identifiers (`browser`, `messaging`,
/// `nous-tools`, ). Hermes v0.10.0 introduced the Tool Gateway; enabling
/// the `nous-tools` toolset here is how subscribers opt-in per-platform.
/// Scarf reads this for display; edits go through `hermes setup tools`
/// rather than direct YAML writes to preserve Hermes-side validation.
var platformToolsets: [String: [String]]
// Grouped blocks
var display: DisplaySettings
var terminal: TerminalSettings
var browser: BrowserSettings
var voice: VoiceSettings
var auxiliary: AuxiliarySettings
var security: SecuritySettings
var humanDelay: HumanDelaySettings
var compression: CompressionSettings
var checkpoints: CheckpointSettings
var logging: LoggingSettings
var delegation: DelegationSettings
var discord: DiscordSettings
var telegram: TelegramSettings
var slack: SlackSettings
var matrix: MatrixSettings
var mattermost: MattermostSettings
var whatsapp: WhatsAppSettings
var homeAssistant: HomeAssistantSettings
nonisolated static let empty = HermesConfig(
model: "unknown",
provider: "unknown",
maxTurns: 0,
@@ -28,17 +381,63 @@ struct HermesConfig: Sendable {
streaming: true,
showReasoning: false,
verbose: false,
autoTTS: true
autoTTS: true,
silenceThreshold: 200,
reasoningEffort: "medium",
showCost: false,
approvalMode: "manual",
browserBackend: "",
memoryProvider: "",
dockerEnv: [:],
commandAllowlist: [],
memoryProfile: "",
serviceTier: "normal",
gatewayNotifyInterval: 600,
forceIPv4: false,
contextEngine: "compressor",
interimAssistantMessages: true,
honchoInitOnSessionStart: false,
timezone: "",
userProfileEnabled: true,
toolUseEnforcement: "auto",
gatewayTimeout: 1800,
approvalTimeout: 60,
fileReadMaxChars: 100_000,
cronWrapResponse: true,
prefillMessagesFile: "",
skillsExternalDirs: [],
platformToolsets: [:],
display: .empty,
terminal: .empty,
browser: .empty,
voice: .empty,
auxiliary: .empty,
security: .empty,
humanDelay: .empty,
compression: .empty,
checkpoints: .empty,
logging: .empty,
delegation: .empty,
discord: .empty,
telegram: .empty,
slack: .empty,
matrix: .empty,
mattermost: .empty,
whatsapp: .empty,
homeAssistant: .empty
)
}
// Hand-written `init(from:)` so Swift 6 doesn't synthesize a
// MainActor-isolated Decodable conformance (which would fail to be used from
// `HermesFileService.loadGatewayState()`, a nonisolated method).
struct GatewayState: Sendable, Codable {
let pid: Int?
let kind: String?
let gatewayState: String?
let exitReason: String?
let platforms: [String: PlatformState]?
let updatedAt: String?
nonisolated let pid: Int?
nonisolated let kind: String?
nonisolated let gatewayState: String?
nonisolated let exitReason: String?
nonisolated let platforms: [String: PlatformState]?
nonisolated let updatedAt: String?
enum CodingKeys: String, CodingKey {
case pid, kind
@@ -48,16 +447,50 @@ struct GatewayState: Sendable, Codable {
case updatedAt = "updated_at"
}
var isRunning: Bool {
nonisolated init(from decoder: any Decoder) throws {
let c = try decoder.container(keyedBy: CodingKeys.self)
self.pid = try c.decodeIfPresent(Int.self, forKey: .pid)
self.kind = try c.decodeIfPresent(String.self, forKey: .kind)
self.gatewayState = try c.decodeIfPresent(String.self, forKey: .gatewayState)
self.exitReason = try c.decodeIfPresent(String.self, forKey: .exitReason)
self.platforms = try c.decodeIfPresent([String: PlatformState].self, forKey: .platforms)
self.updatedAt = try c.decodeIfPresent(String.self, forKey: .updatedAt)
}
nonisolated func encode(to encoder: any Encoder) throws {
var c = encoder.container(keyedBy: CodingKeys.self)
try c.encodeIfPresent(pid, forKey: .pid)
try c.encodeIfPresent(kind, forKey: .kind)
try c.encodeIfPresent(gatewayState, forKey: .gatewayState)
try c.encodeIfPresent(exitReason, forKey: .exitReason)
try c.encodeIfPresent(platforms, forKey: .platforms)
try c.encodeIfPresent(updatedAt, forKey: .updatedAt)
}
nonisolated var isRunning: Bool {
gatewayState == "running"
}
var statusText: String {
nonisolated var statusText: String {
gatewayState ?? "unknown"
}
}
struct PlatformState: Sendable, Codable {
let connected: Bool?
let error: String?
nonisolated let connected: Bool?
nonisolated let error: String?
enum CodingKeys: String, CodingKey { case connected, error }
nonisolated init(from decoder: any Decoder) throws {
let c = try decoder.container(keyedBy: CodingKeys.self)
self.connected = try c.decodeIfPresent(Bool.self, forKey: .connected)
self.error = try c.decodeIfPresent(String.self, forKey: .error)
}
nonisolated func encode(to encoder: any Encoder) throws {
var c = encoder.container(keyedBy: CodingKeys.self)
try c.encodeIfPresent(connected, forKey: .connected)
try c.encodeIfPresent(error, forKey: .error)
}
}
+89 -15
View File
@@ -1,19 +1,93 @@
import Foundation
import SQLite3
/// Deprecated module-level path statics. Preserved as thin forwarders to
/// `ServerContext.local.paths` so existing call sites continue to compile
/// while Phase 1 migrates them to a per-server `ServerContext`.
///
/// New code should accept a `ServerContext` and read `context.paths.<field>`.
enum HermesPaths: Sendable {
// Using ProcessInfo to avoid main-actor isolation issues with FileManager/NSHomeDirectory
nonisolated static let home: String = ProcessInfo.processInfo.environment["HOME"]! + "/.hermes"
nonisolated static let stateDB: String = home + "/state.db"
nonisolated static let configYAML: String = home + "/config.yaml"
nonisolated static let memoriesDir: String = home + "/memories"
nonisolated static let memoryMD: String = memoriesDir + "/MEMORY.md"
nonisolated static let userMD: String = memoriesDir + "/USER.md"
nonisolated static let sessionsDir: String = home + "/sessions"
nonisolated static let cronJobsJSON: String = home + "/cron/jobs.json"
nonisolated static let cronOutputDir: String = home + "/cron/output"
nonisolated static let gatewayStateJSON: String = home + "/gateway_state.json"
nonisolated static let skillsDir: String = home + "/skills"
nonisolated static let errorsLog: String = home + "/logs/errors.log"
nonisolated static let gatewayLog: String = home + "/logs/gateway.log"
nonisolated static let hermesBinary: String = ProcessInfo.processInfo.environment["HOME"]! + "/.local/bin/hermes"
@available(*, deprecated, message: "use ServerContext.paths.home")
nonisolated static var home: String { ServerContext.local.paths.home }
@available(*, deprecated, message: "use ServerContext.paths.stateDB")
nonisolated static var stateDB: String { ServerContext.local.paths.stateDB }
@available(*, deprecated, message: "use ServerContext.paths.configYAML")
nonisolated static var configYAML: String { ServerContext.local.paths.configYAML }
@available(*, deprecated, message: "use ServerContext.paths.memoriesDir")
nonisolated static var memoriesDir: String { ServerContext.local.paths.memoriesDir }
@available(*, deprecated, message: "use ServerContext.paths.memoryMD")
nonisolated static var memoryMD: String { ServerContext.local.paths.memoryMD }
@available(*, deprecated, message: "use ServerContext.paths.userMD")
nonisolated static var userMD: String { ServerContext.local.paths.userMD }
@available(*, deprecated, message: "use ServerContext.paths.sessionsDir")
nonisolated static var sessionsDir: String { ServerContext.local.paths.sessionsDir }
@available(*, deprecated, message: "use ServerContext.paths.cronJobsJSON")
nonisolated static var cronJobsJSON: String { ServerContext.local.paths.cronJobsJSON }
@available(*, deprecated, message: "use ServerContext.paths.cronOutputDir")
nonisolated static var cronOutputDir: String { ServerContext.local.paths.cronOutputDir }
@available(*, deprecated, message: "use ServerContext.paths.gatewayStateJSON")
nonisolated static var gatewayStateJSON: String { ServerContext.local.paths.gatewayStateJSON }
@available(*, deprecated, message: "use ServerContext.paths.skillsDir")
nonisolated static var skillsDir: String { ServerContext.local.paths.skillsDir }
@available(*, deprecated, message: "use ServerContext.paths.errorsLog")
nonisolated static var errorsLog: String { ServerContext.local.paths.errorsLog }
@available(*, deprecated, message: "use ServerContext.paths.agentLog")
nonisolated static var agentLog: String { ServerContext.local.paths.agentLog }
@available(*, deprecated, message: "use ServerContext.paths.gatewayLog")
nonisolated static var gatewayLog: String { ServerContext.local.paths.gatewayLog }
@available(*, deprecated, message: "use ServerContext.paths.scarfDir")
nonisolated static var scarfDir: String { ServerContext.local.paths.scarfDir }
@available(*, deprecated, message: "use ServerContext.paths.projectsRegistry")
nonisolated static var projectsRegistry: String { ServerContext.local.paths.projectsRegistry }
@available(*, deprecated, message: "use ServerContext.paths.mcpTokensDir")
nonisolated static var mcpTokensDir: String { ServerContext.local.paths.mcpTokensDir }
@available(*, deprecated, message: "use HermesPathSet.hermesBinaryCandidates")
nonisolated static var hermesBinaryCandidates: [String] {
HermesPathSet.hermesBinaryCandidates
}
@available(*, deprecated, message: "use ServerContext.paths.hermesBinary")
nonisolated static var hermesBinary: String { ServerContext.local.paths.hermesBinary }
}
// MARK: - SQLite Constants
/// SQLITE_TRANSIENT tells SQLite to make its own copy of bound string data.
/// The C macro is defined as ((sqlite3_destructor_type)-1) which can't be imported directly into Swift.
nonisolated let sqliteTransient = unsafeBitCast(-1, to: sqlite3_destructor_type.self)
// MARK: - Query Defaults
enum QueryDefaults: Sendable {
nonisolated static let sessionLimit = 100
nonisolated static let messageSearchLimit = 50
nonisolated static let toolCallLimit = 50
nonisolated static let sessionPreviewLimit = 10
nonisolated static let previewContentLength = 100
nonisolated static let logLineLimit = 200
nonisolated static let defaultSilenceThreshold = 200
}
// MARK: - File Size Formatting
enum FileSizeUnit: Sendable {
nonisolated static let kilobyte = 1_024.0
nonisolated static let megabyte = 1_048_576.0
}
+121 -20
View File
@@ -1,27 +1,82 @@
import Foundation
struct HermesCronJob: Identifiable, Sendable, Codable {
let id: String
let name: String
let prompt: String
let skills: [String]?
let model: String?
let schedule: CronSchedule
let enabled: Bool
let state: String
let deliver: String?
let nextRunAt: String?
let lastRunAt: String?
let lastError: String?
nonisolated let id: String
nonisolated let name: String
nonisolated let prompt: String
nonisolated let skills: [String]?
nonisolated let model: String?
nonisolated let schedule: CronSchedule
nonisolated let enabled: Bool
nonisolated let state: String
nonisolated let deliver: String?
nonisolated let nextRunAt: String?
nonisolated let lastRunAt: String?
nonisolated let lastError: String?
nonisolated let preRunScript: String?
nonisolated let deliveryFailures: Int?
nonisolated let lastDeliveryError: String?
nonisolated let timeoutType: String?
nonisolated let timeoutSeconds: Int?
nonisolated let silent: Bool?
enum CodingKeys: String, CodingKey {
case id, name, prompt, skills, model, schedule, enabled, state, deliver
case id, name, prompt, skills, model, schedule, enabled, state, deliver, silent
case nextRunAt = "next_run_at"
case lastRunAt = "last_run_at"
case lastError = "last_error"
case preRunScript = "pre_run_script"
case deliveryFailures = "delivery_failures"
case lastDeliveryError = "last_delivery_error"
case timeoutType = "timeout_type"
case timeoutSeconds = "timeout_seconds"
}
var stateIcon: String {
nonisolated init(from decoder: any Decoder) throws {
let c = try decoder.container(keyedBy: CodingKeys.self)
self.id = try c.decode(String.self, forKey: .id)
self.name = try c.decode(String.self, forKey: .name)
self.prompt = try c.decode(String.self, forKey: .prompt)
self.skills = try c.decodeIfPresent([String].self, forKey: .skills)
self.model = try c.decodeIfPresent(String.self, forKey: .model)
self.schedule = try c.decode(CronSchedule.self, forKey: .schedule)
self.enabled = try c.decode(Bool.self, forKey: .enabled)
self.state = try c.decode(String.self, forKey: .state)
self.deliver = try c.decodeIfPresent(String.self, forKey: .deliver)
self.nextRunAt = try c.decodeIfPresent(String.self, forKey: .nextRunAt)
self.lastRunAt = try c.decodeIfPresent(String.self, forKey: .lastRunAt)
self.lastError = try c.decodeIfPresent(String.self, forKey: .lastError)
self.preRunScript = try c.decodeIfPresent(String.self, forKey: .preRunScript)
self.deliveryFailures = try c.decodeIfPresent(Int.self, forKey: .deliveryFailures)
self.lastDeliveryError = try c.decodeIfPresent(String.self, forKey: .lastDeliveryError)
self.timeoutType = try c.decodeIfPresent(String.self, forKey: .timeoutType)
self.timeoutSeconds = try c.decodeIfPresent(Int.self, forKey: .timeoutSeconds)
self.silent = try c.decodeIfPresent(Bool.self, forKey: .silent)
}
nonisolated func encode(to encoder: any Encoder) throws {
var c = encoder.container(keyedBy: CodingKeys.self)
try c.encode(id, forKey: .id)
try c.encode(name, forKey: .name)
try c.encode(prompt, forKey: .prompt)
try c.encodeIfPresent(skills, forKey: .skills)
try c.encodeIfPresent(model, forKey: .model)
try c.encode(schedule, forKey: .schedule)
try c.encode(enabled, forKey: .enabled)
try c.encode(state, forKey: .state)
try c.encodeIfPresent(deliver, forKey: .deliver)
try c.encodeIfPresent(nextRunAt, forKey: .nextRunAt)
try c.encodeIfPresent(lastRunAt, forKey: .lastRunAt)
try c.encodeIfPresent(lastError, forKey: .lastError)
try c.encodeIfPresent(preRunScript, forKey: .preRunScript)
try c.encodeIfPresent(deliveryFailures, forKey: .deliveryFailures)
try c.encodeIfPresent(lastDeliveryError, forKey: .lastDeliveryError)
try c.encodeIfPresent(timeoutType, forKey: .timeoutType)
try c.encodeIfPresent(timeoutSeconds, forKey: .timeoutSeconds)
try c.encodeIfPresent(silent, forKey: .silent)
}
nonisolated var stateIcon: String {
switch state {
case "scheduled": return "clock"
case "running": return "play.circle"
@@ -30,13 +85,28 @@ struct HermesCronJob: Identifiable, Sendable, Codable {
default: return "questionmark.circle"
}
}
nonisolated var deliveryDisplay: String? {
guard let deliver, !deliver.isEmpty else { return nil }
// v0.9.0 extends Discord routing to threads: `discord:<chat>:<thread>`.
if deliver.hasPrefix("discord:") {
let parts = deliver.dropFirst("discord:".count).split(separator: ":", maxSplits: 1, omittingEmptySubsequences: false)
if parts.count == 2 {
return "Discord thread \(parts[1]) in \(parts[0])"
}
if parts.count == 1 {
return "Discord \(parts[0])"
}
}
return deliver
}
}
struct CronSchedule: Sendable, Codable {
let kind: String
let runAt: String?
let display: String?
let expression: String?
nonisolated let kind: String
nonisolated let runAt: String?
nonisolated let display: String?
nonisolated let expression: String?
enum CodingKeys: String, CodingKey {
case kind
@@ -44,14 +114,45 @@ struct CronSchedule: Sendable, Codable {
case display
case expression
}
nonisolated init(from decoder: any Decoder) throws {
let c = try decoder.container(keyedBy: CodingKeys.self)
self.kind = try c.decode(String.self, forKey: .kind)
self.runAt = try c.decodeIfPresent(String.self, forKey: .runAt)
self.display = try c.decodeIfPresent(String.self, forKey: .display)
self.expression = try c.decodeIfPresent(String.self, forKey: .expression)
}
nonisolated func encode(to encoder: any Encoder) throws {
var c = encoder.container(keyedBy: CodingKeys.self)
try c.encode(kind, forKey: .kind)
try c.encodeIfPresent(runAt, forKey: .runAt)
try c.encodeIfPresent(display, forKey: .display)
try c.encodeIfPresent(expression, forKey: .expression)
}
}
// Hand-written `init(from:)` / `encode(to:)` so Swift 6 doesn't synthesize a
// MainActor-isolated Codable conformance `HermesFileService.loadCronJobs`
// is nonisolated and needs to decode this from a background task.
struct CronJobsFile: Sendable, Codable {
let jobs: [HermesCronJob]
let updatedAt: String?
nonisolated let jobs: [HermesCronJob]
nonisolated let updatedAt: String?
enum CodingKeys: String, CodingKey {
case jobs
case updatedAt = "updated_at"
}
nonisolated init(from decoder: any Decoder) throws {
let c = try decoder.container(keyedBy: CodingKeys.self)
self.jobs = try c.decode([HermesCronJob].self, forKey: .jobs)
self.updatedAt = try c.decodeIfPresent(String.self, forKey: .updatedAt)
}
nonisolated func encode(to encoder: any Encoder) throws {
var c = encoder.container(keyedBy: CodingKeys.self)
try c.encode(jobs, forKey: .jobs)
try c.encodeIfPresent(updatedAt, forKey: .updatedAt)
}
}
@@ -0,0 +1,54 @@
import Foundation
enum MCPTransport: String, Sendable, Equatable, CaseIterable, Identifiable {
case stdio
case http
var id: String { rawValue }
var displayName: LocalizedStringResource {
switch self {
case .stdio: return "Local (stdio)"
case .http: return "Remote (HTTP)"
}
}
}
struct HermesMCPServer: Identifiable, Sendable, Equatable {
let name: String
let transport: MCPTransport
let command: String?
let args: [String]
let url: String?
let auth: String?
let env: [String: String]
let headers: [String: String]
let timeout: Int?
let connectTimeout: Int?
let enabled: Bool
let toolsInclude: [String]
let toolsExclude: [String]
let resourcesEnabled: Bool
let promptsEnabled: Bool
let hasOAuthToken: Bool
var id: String { name }
var summary: String {
switch transport {
case .stdio:
let argString = args.isEmpty ? "" : " " + args.joined(separator: " ")
return (command ?? "") + argString
case .http:
return url ?? ""
}
}
}
struct MCPTestResult: Sendable, Equatable {
let serverName: String
let succeeded: Bool
let output: String
let tools: [String]
let elapsed: TimeInterval
}
+14 -1
View File
@@ -11,10 +11,12 @@ struct HermesMessage: Identifiable, Sendable {
let timestamp: Date?
let tokenCount: Int?
let finishReason: String?
let reasoning: String?
var isUser: Bool { role == "user" }
var isAssistant: Bool { role == "assistant" }
var isToolResult: Bool { role == "tool" }
var hasReasoning: Bool { reasoning != nil && !(reasoning?.isEmpty ?? true) }
}
struct HermesToolCall: Identifiable, Sendable, Codable {
@@ -61,7 +63,7 @@ struct HermesToolCall: Identifiable, Sendable, Codable {
switch functionName {
case "read_file", "search_files", "vision_analyze": return .read
case "write_file", "patch": return .edit
case "terminal": return .execute
case "terminal", "execute_code": return .execute
case "web_search", "web_extract": return .fetch
case "browser_navigate", "browser_click", "browser_screenshot": return .browser
default: return .other
@@ -97,6 +99,17 @@ enum ToolKind: String, Sendable, CaseIterable {
case browser
case other
var displayName: LocalizedStringResource {
switch self {
case .read: return "Read"
case .edit: return "Edit"
case .execute: return "Execute"
case .fetch: return "Fetch"
case .browser: return "Browser"
case .other: return "Other"
}
}
var icon: String {
switch self {
case .read: return "doc.text.magnifyingglass"
+104
View File
@@ -0,0 +1,104 @@
import Foundation
/// The filesystem layout of a Hermes installation, parameterized by the
/// `home` directory. The same layout is used for local installations (where
/// `home` is an absolute macOS path like `/Users/alan/.hermes`) and for
/// remote installations reached over SSH (where `home` is a remote path like
/// `/home/deploy/.hermes` or an unexpanded `~/.hermes` that the remote shell
/// will resolve).
///
/// Every path that used to live as a module-level static on `HermesPaths` is
/// an instance property here. `ServerContext.paths` is the canonical way to
/// reach these values; the old `HermesPaths` statics are preserved as
/// deprecated forwarders so Phase 1 can migrate call sites incrementally.
struct HermesPathSet: Sendable, Hashable {
let home: String
/// `true` when this path set belongs to a remote installation. Affects
/// only `hermesBinary` resolution every other path is identical in
/// shape between local and remote.
let isRemote: Bool
/// Pre-resolved remote binary path (e.g. `/home/deploy/.local/bin/hermes`).
/// Populated by `SSHTransport` once `command -v hermes` has run on the
/// target host. Unused when `isRemote == false`.
let binaryHint: String?
// MARK: - Defaults
/// Absolute path to the local user's `~/.hermes` directory.
nonisolated static let defaultLocalHome: String = {
let user = ProcessInfo.processInfo.environment["HOME"] ?? NSHomeDirectory()
return user + "/.hermes"
}()
/// Default remote home when the user doesn't override it in `SSHConfig`.
/// We leave `~` unexpanded on purpose the remote shell resolves it.
nonisolated static let defaultRemoteHome: String = "~/.hermes"
// MARK: - Paths (mirror of the old HermesPaths layout)
nonisolated var stateDB: String { home + "/state.db" }
nonisolated var configYAML: String { home + "/config.yaml" }
nonisolated var envFile: String { home + "/.env" }
nonisolated var authJSON: String { home + "/auth.json" }
nonisolated var soulMD: String { home + "/SOUL.md" }
nonisolated var pluginsDir: String { home + "/plugins" }
nonisolated var memoriesDir: String { home + "/memories" }
nonisolated var memoryMD: String { memoriesDir + "/MEMORY.md" }
nonisolated var userMD: String { memoriesDir + "/USER.md" }
nonisolated var sessionsDir: String { home + "/sessions" }
nonisolated var cronJobsJSON: String { home + "/cron/jobs.json" }
nonisolated var cronOutputDir: String { home + "/cron/output" }
nonisolated var gatewayStateJSON: String { home + "/gateway_state.json" }
nonisolated var skillsDir: String { home + "/skills" }
nonisolated var errorsLog: String { home + "/logs/errors.log" }
nonisolated var agentLog: String { home + "/logs/agent.log" }
nonisolated var gatewayLog: String { home + "/logs/gateway.log" }
nonisolated var scarfDir: String { home + "/scarf" }
nonisolated var projectsRegistry: String { scarfDir + "/projects.json" }
/// Maps Hermes session IDs to the Scarf project path a chat was
/// started for. Written by `SessionAttributionService` when
/// Scarf spawns `hermes acp` with a project-scoped cwd; read by
/// the per-project Sessions tab (v2.3) to filter the session list
/// to just those attributed to a given project.
///
/// Scarf-owned Hermes never touches this file. Forward-only:
/// we only attribute sessions Scarf creates in a project context;
/// older / CLI-started sessions stay unattributed and surface in
/// the global Sessions sidebar unchanged.
nonisolated var sessionProjectMap: String { scarfDir + "/session_project_map.json" }
nonisolated var mcpTokensDir: String { home + "/mcp-tokens" }
// MARK: - Binary resolution
/// Install locations we probe for the local `hermes` binary, in priority
/// order. Checked on every access so a user installing via a different
/// method doesn't need to relaunch Scarf.
nonisolated static let hermesBinaryCandidates: [String] = {
let user = ProcessInfo.processInfo.environment["HOME"] ?? NSHomeDirectory()
return [
user + "/.local/bin/hermes", // pipx / pip --user (default)
"/opt/homebrew/bin/hermes", // Homebrew on Apple Silicon
"/usr/local/bin/hermes", // Homebrew on Intel / manual install
user + "/.hermes/bin/hermes" // Some self-install layouts
]
}()
/// Resolved path to the `hermes` executable for this installation.
///
/// Local: returns the first executable candidate, falling back to the
/// pipx default so error messages still make sense on a fresh machine.
///
/// Remote: returns `binaryHint` (populated at connect time) or bare
/// `"hermes"` as a last-resort default that relies on the remote `$PATH`.
nonisolated var hermesBinary: String {
if isRemote {
return binaryHint ?? "hermes"
}
for path in Self.hermesBinaryCandidates
where FileManager.default.isExecutableFile(atPath: path) {
return path
}
return Self.hermesBinaryCandidates[0]
}
}
+26 -9
View File
@@ -17,8 +17,18 @@ struct HermesSession: Identifiable, Sendable {
let cacheReadTokens: Int
let cacheWriteTokens: Int
let estimatedCostUSD: Double?
let reasoningTokens: Int
let actualCostUSD: Double?
let costStatus: String?
let billingProvider: String?
var totalTokens: Int { inputTokens + outputTokens }
var isSubagent: Bool { parentSessionId != nil }
var totalTokens: Int { inputTokens + outputTokens + reasoningTokens }
var displayCostUSD: Double? { actualCostUSD ?? estimatedCostUSD }
var costIsActual: Bool { actualCostUSD != nil }
var duration: TimeInterval? {
guard let start = startedAt, let end = endedAt else { return nil }
@@ -30,13 +40,20 @@ struct HermesSession: Identifiable, Sendable {
}
var sourceIcon: String {
switch source {
case "cli": return "terminal"
case "telegram": return "paperplane"
case "discord": return "bubble.left.and.bubble.right"
case "slack": return "number"
case "email": return "envelope"
default: return "bubble.left"
}
KnownPlatforms.icon(for: source)
}
func withTitle(_ newTitle: String) -> HermesSession {
HermesSession(
id: id, source: source, userId: userId, model: model,
title: newTitle, parentSessionId: parentSessionId,
startedAt: startedAt, endedAt: endedAt, endReason: endReason,
messageCount: messageCount, toolCallCount: toolCallCount,
inputTokens: inputTokens, outputTokens: outputTokens,
cacheReadTokens: cacheReadTokens, cacheWriteTokens: cacheWriteTokens,
estimatedCostUSD: estimatedCostUSD, reasoningTokens: reasoningTokens,
actualCostUSD: actualCostUSD, costStatus: costStatus,
billingProvider: billingProvider
)
}
}
@@ -12,4 +12,5 @@ struct HermesSkill: Identifiable, Sendable {
let category: String
let path: String
let files: [String]
let requiredConfig: [String]
}
@@ -0,0 +1,17 @@
import Foundation
/// A slash command available in chat. Sourced either from the ACP server
/// (`available_commands_update`) or from user-defined `quick_commands` in
/// `config.yaml`.
struct HermesSlashCommand: Identifiable, Sendable, Equatable {
enum Source: Sendable, Equatable {
case acp
case quickCommand
}
var id: String { name }
let name: String
let description: String
let argumentHint: String?
let source: Source
}
+27 -1
View File
@@ -16,13 +16,39 @@ struct HermesToolPlatform: Identifiable, Sendable {
}
enum KnownPlatforms {
static let cli = HermesToolPlatform(name: "cli", displayName: "CLI", icon: "terminal")
static let all: [HermesToolPlatform] = [
HermesToolPlatform(name: "cli", displayName: "CLI", icon: "terminal"),
cli,
HermesToolPlatform(name: "telegram", displayName: "Telegram", icon: "paperplane"),
HermesToolPlatform(name: "discord", displayName: "Discord", icon: "bubble.left.and.bubble.right"),
HermesToolPlatform(name: "slack", displayName: "Slack", icon: "number"),
HermesToolPlatform(name: "whatsapp", displayName: "WhatsApp", icon: "phone.bubble"),
HermesToolPlatform(name: "signal", displayName: "Signal", icon: "lock.shield"),
HermesToolPlatform(name: "email", displayName: "Email", icon: "envelope"),
HermesToolPlatform(name: "homeassistant", displayName: "Home Assistant", icon: "house"),
HermesToolPlatform(name: "webhook", displayName: "Webhook", icon: "arrow.up.right.square"),
HermesToolPlatform(name: "matrix", displayName: "Matrix", icon: "lock.rectangle.stack"),
HermesToolPlatform(name: "feishu", displayName: "Feishu", icon: "message.badge.circle"),
HermesToolPlatform(name: "mattermost", displayName: "Mattermost", icon: "bubble.left.and.exclamationmark.bubble.right"),
HermesToolPlatform(name: "imessage", displayName: "iMessage", icon: "message.fill"),
]
static func icon(for platform: String) -> String {
switch platform {
case "cli": return "terminal"
case "telegram": return "paperplane"
case "discord": return "bubble.left.and.bubble.right"
case "slack": return "number"
case "whatsapp": return "phone.bubble"
case "signal": return "lock.shield"
case "email": return "envelope"
case "homeassistant": return "house"
case "webhook": return "arrow.up.right.square"
case "matrix": return "lock.rectangle.stack"
case "feishu": return "message.badge.circle"
case "mattermost": return "bubble.left.and.exclamationmark.bubble.right"
case "imessage": return "message.fill"
default: return "bubble.left"
}
}
}
@@ -0,0 +1,174 @@
import Foundation
struct MCPServerPreset: Identifiable, Sendable, Equatable {
let id: String
let displayName: String
let description: String
let category: String
let iconSystemName: String
let transport: MCPTransport
let command: String?
let args: [String]
let url: String?
let auth: String?
let requiredEnvKeys: [String]
let optionalEnvKeys: [String]
let pathArgPrompt: String?
let docsURL: String
static let gallery: [MCPServerPreset] = [
MCPServerPreset(
id: "filesystem",
displayName: "Filesystem",
description: "Read and write files under a root directory you choose.",
category: "Built-in",
iconSystemName: "folder",
transport: .stdio,
command: "npx",
args: ["-y", "@modelcontextprotocol/server-filesystem"],
url: nil,
auth: nil,
requiredEnvKeys: [],
optionalEnvKeys: [],
pathArgPrompt: "Root directory (absolute path)",
docsURL: "https://github.com/modelcontextprotocol/servers/tree/main/src/filesystem"
),
MCPServerPreset(
id: "github",
displayName: "GitHub",
description: "Issues, pull requests, code search, and file operations via GitHub API.",
category: "Dev",
iconSystemName: "chevron.left.forwardslash.chevron.right",
transport: .stdio,
command: "npx",
args: ["-y", "@modelcontextprotocol/server-github"],
url: nil,
auth: nil,
requiredEnvKeys: ["GITHUB_PERSONAL_ACCESS_TOKEN"],
optionalEnvKeys: [],
pathArgPrompt: nil,
docsURL: "https://github.com/modelcontextprotocol/servers/tree/main/src/github"
),
MCPServerPreset(
id: "postgres",
displayName: "Postgres",
description: "Read-only SQL access against a Postgres database.",
category: "Data",
iconSystemName: "cylinder.split.1x2",
transport: .stdio,
command: "npx",
args: ["-y", "@modelcontextprotocol/server-postgres"],
url: nil,
auth: nil,
requiredEnvKeys: [],
optionalEnvKeys: [],
pathArgPrompt: "Connection URL (postgres://user:pass@host/db)",
docsURL: "https://github.com/modelcontextprotocol/servers/tree/main/src/postgres"
),
MCPServerPreset(
id: "slack",
displayName: "Slack",
description: "Read channels, post messages, and search your Slack workspace.",
category: "Productivity",
iconSystemName: "bubble.left.and.bubble.right",
transport: .stdio,
command: "npx",
args: ["-y", "@modelcontextprotocol/server-slack"],
url: nil,
auth: nil,
requiredEnvKeys: ["SLACK_BOT_TOKEN", "SLACK_TEAM_ID"],
optionalEnvKeys: [],
pathArgPrompt: nil,
docsURL: "https://github.com/modelcontextprotocol/servers/tree/main/src/slack"
),
MCPServerPreset(
id: "linear",
displayName: "Linear",
description: "Query and update Linear issues. Uses OAuth — no token needed.",
category: "Productivity",
iconSystemName: "list.bullet.rectangle",
transport: .http,
command: nil,
args: [],
url: "https://mcp.linear.app/sse",
auth: "oauth",
requiredEnvKeys: [],
optionalEnvKeys: [],
pathArgPrompt: nil,
docsURL: "https://linear.app/docs/mcp"
),
MCPServerPreset(
id: "sentry",
displayName: "Sentry",
description: "Investigate errors and performance issues from Sentry.",
category: "Dev",
iconSystemName: "exclamationmark.triangle",
transport: .stdio,
command: "npx",
args: ["-y", "@sentry/mcp-server"],
url: nil,
auth: nil,
requiredEnvKeys: ["SENTRY_AUTH_TOKEN", "SENTRY_ORG"],
optionalEnvKeys: [],
pathArgPrompt: nil,
docsURL: "https://docs.sentry.io/product/mcp/"
),
MCPServerPreset(
id: "puppeteer",
displayName: "Puppeteer",
description: "Headless browser automation — navigate pages, click, screenshot.",
category: "Automation",
iconSystemName: "safari",
transport: .stdio,
command: "npx",
args: ["-y", "@modelcontextprotocol/server-puppeteer"],
url: nil,
auth: nil,
requiredEnvKeys: [],
optionalEnvKeys: [],
pathArgPrompt: nil,
docsURL: "https://github.com/modelcontextprotocol/servers/tree/main/src/puppeteer"
),
MCPServerPreset(
id: "memory",
displayName: "Memory (Knowledge Graph)",
description: "Persistent knowledge graph of entities and relations across sessions.",
category: "Built-in",
iconSystemName: "brain",
transport: .stdio,
command: "npx",
args: ["-y", "@modelcontextprotocol/server-memory"],
url: nil,
auth: nil,
requiredEnvKeys: [],
optionalEnvKeys: ["MEMORY_FILE_PATH"],
pathArgPrompt: nil,
docsURL: "https://github.com/modelcontextprotocol/servers/tree/main/src/memory"
),
MCPServerPreset(
id: "fetch",
displayName: "Fetch",
description: "Retrieve and convert web pages to markdown.",
category: "Built-in",
iconSystemName: "arrow.down.circle",
transport: .stdio,
command: "npx",
args: ["-y", "@modelcontextprotocol/server-fetch"],
url: nil,
auth: nil,
requiredEnvKeys: [],
optionalEnvKeys: [],
pathArgPrompt: nil,
docsURL: "https://github.com/modelcontextprotocol/servers/tree/main/src/fetch"
)
]
static var categories: [String] {
var seen = Set<String>()
return gallery.compactMap { p in seen.insert(p.category).inserted ? p.category : nil }
}
static func byCategory(_ category: String) -> [MCPServerPreset] {
gallery.filter { $0.category == category }
}
}
@@ -0,0 +1,194 @@
import Foundation
// MARK: - Registry
struct ProjectRegistry: Codable, Sendable {
var projects: [ProjectEntry]
}
struct ProjectEntry: Codable, Sendable, Identifiable, Hashable {
var id: String { name }
let name: String
let path: String
/// Folder path for sidebar grouping. `nil` means top-level (no
/// folder). Introduced in v2.3 v2.2 registry files have no
/// `folder` key, which decodes cleanly as `nil` via
/// `decodeIfPresent` below.
///
/// We leave shape flexible: today this is treated as an opaque
/// single-level label (e.g. "Clients"), and the sidebar renders
/// one DisclosureGroup per distinct value. If nesting becomes a
/// requirement later, we can interpret the string as a slash-
/// separated path without a migration (old single-label values
/// still mean a top-level folder with that name).
var folder: String?
/// Soft-archive flag. Archived projects are hidden from the
/// sidebar by default; a Show Archived toggle surfaces them.
/// Non-destructive nothing is deleted on disk. Introduced in
/// v2.3; v2.2 registry files default to `false` via the custom
/// decoder below.
var archived: Bool
var dashboardPath: String { path + "/.scarf/dashboard.json" }
init(name: String, path: String, folder: String? = nil, archived: Bool = false) {
self.name = name
self.path = path
self.folder = folder
self.archived = archived
}
// MARK: - Codable (custom for backward compat)
private enum CodingKeys: String, CodingKey {
case name, path, folder, archived
}
init(from decoder: Decoder) throws {
let c = try decoder.container(keyedBy: CodingKeys.self)
self.name = try c.decode(String.self, forKey: .name)
self.path = try c.decode(String.self, forKey: .path)
// Both new fields: tolerate absence for v2.2-era registries.
self.folder = try c.decodeIfPresent(String.self, forKey: .folder)
self.archived = try c.decodeIfPresent(Bool.self, forKey: .archived) ?? false
}
func encode(to encoder: Encoder) throws {
var c = encoder.container(keyedBy: CodingKeys.self)
try c.encode(name, forKey: .name)
try c.encode(path, forKey: .path)
// Only emit optional fields when they carry meaning keeps
// registry files clean for the common (top-level, unarchived)
// case and means v2.2 Scarf can still load a v2.3-written
// registry of projects that never used the new features.
try c.encodeIfPresent(folder, forKey: .folder)
if archived {
try c.encode(archived, forKey: .archived)
}
}
}
// MARK: - Dashboard
struct ProjectDashboard: Codable, Sendable {
let version: Int
let title: String
let description: String?
let updatedAt: String?
let theme: DashboardTheme?
let sections: [DashboardSection]
}
struct DashboardTheme: Codable, Sendable {
let accent: String?
}
struct DashboardSection: Codable, Sendable, Identifiable {
var id: String { title }
let title: String
let columns: Int?
let widgets: [DashboardWidget]
var columnCount: Int { columns ?? 3 }
}
struct DashboardWidget: Codable, Sendable, Identifiable {
var id: String { type + ":" + title }
let type: String
let title: String
// Stat
let value: WidgetValue?
let icon: String?
let color: String?
let subtitle: String?
// Progress
let label: String?
// Text
let content: String?
let format: String?
// Table
let columns: [String]?
let rows: [[String]]?
// Chart
let chartType: String?
let xLabel: String?
let yLabel: String?
let series: [ChartSeries]?
// List
let items: [ListItem]?
// Webview
let url: String?
let height: Double?
}
// MARK: - Widget Value (String or Number)
enum WidgetValue: Codable, Sendable, Hashable {
case string(String)
case number(Double)
var displayString: String {
switch self {
case .string(let s): return s
case .number(let n):
return n.truncatingRemainder(dividingBy: 1) == 0
? Int(n).formatted(.number)
: n.formatted(.number.precision(.fractionLength(1)))
}
}
init(from decoder: Decoder) throws {
let container = try decoder.singleValueContainer()
if let d = try? container.decode(Double.self) {
self = .number(d)
} else if let s = try? container.decode(String.self) {
self = .string(s)
} else {
throw DecodingError.typeMismatch(
WidgetValue.self,
.init(codingPath: decoder.codingPath, debugDescription: "Expected String or Number")
)
}
}
func encode(to encoder: Encoder) throws {
var container = encoder.singleValueContainer()
switch self {
case .string(let s): try container.encode(s)
case .number(let n): try container.encode(n)
}
}
}
// MARK: - Chart Data
struct ChartSeries: Codable, Sendable, Identifiable {
var id: String { name }
let name: String
let color: String?
let data: [ChartDataPoint]
}
struct ChartDataPoint: Codable, Sendable, Identifiable {
var id: String { x }
let x: String
let y: Double
}
// MARK: - List Data
struct ListItem: Codable, Sendable, Identifiable {
var id: String { text }
let text: String
let status: String?
}
@@ -0,0 +1,335 @@
import Foundation
// MARK: - Manifest (what lives inside the .scarftemplate zip)
/// On-disk manifest for a Scarf project template. Shipped as `template.json`
/// at the root of a `.scarftemplate` (zip) bundle.
///
/// The `contents` block is a claim the author makes about what the bundle
/// ships; the installer verifies the claim against the actual unpacked files
/// before showing the preview sheet so a malicious bundle can't hide extra
/// files from the user.
struct ProjectTemplateManifest: Codable, Sendable, Equatable {
let schemaVersion: Int
let id: String
let name: String
let version: String
let minScarfVersion: String?
let minHermesVersion: String?
let author: TemplateAuthor?
let description: String
let category: String?
let tags: [String]?
let icon: String?
let screenshots: [String]?
let contents: TemplateContents
/// Optional configuration schema (added in manifest schemaVersion 2).
/// When present, the installer presents a form during install and
/// writes values to `<project>/.scarf/config.json` + the Keychain.
/// Schema-v1 manifests omit this field entirely Codable's
/// optional-field decoding keeps them working unchanged.
let config: TemplateConfigSchema?
/// Filesystem-safe slug derived from `id` (`"owner/name"` `"owner-name"`).
/// Used for the install directory name, skills namespace, and cron-job tag.
nonisolated var slug: String {
let ascii = id.unicodeScalars.map { scalar -> Character in
let c = Character(scalar)
if c.isLetter || c.isNumber || c == "-" || c == "_" { return c }
return "-"
}
let collapsed = String(ascii)
.split(separator: "-", omittingEmptySubsequences: true)
.joined(separator: "-")
return collapsed.isEmpty ? "template" : collapsed
}
}
struct TemplateAuthor: Codable, Sendable, Equatable {
let name: String
let url: String?
}
struct TemplateContents: Codable, Sendable, Equatable {
let dashboard: Bool
let agentsMd: Bool
let instructions: [String]?
let skills: [String]?
let cron: Int?
let memory: TemplateMemoryClaim?
/// Number of configuration fields the template ships (schemaVersion 2+).
/// Cross-checked against `manifest.config?.fields.count` by the
/// validator so a bundle can't hide a schema from the preview.
/// `nil` or `0` means schema-less (v1-compatible behaviour).
let config: Int?
}
struct TemplateMemoryClaim: Codable, Sendable, Equatable {
let append: Bool
}
// MARK: - Inspection (what we learn by unpacking the zip)
/// Result of unpacking a `.scarftemplate` into a temp directory and validating
/// it. Callers hand this to `buildInstallPlan` to produce the concrete
/// filesystem plan.
struct TemplateInspection: Sendable {
let manifest: ProjectTemplateManifest
/// Absolute path to the temp directory holding the unpacked bundle. The
/// installer reads files from here; the caller is responsible for
/// cleaning it up after install (or cancel).
let unpackedDir: String
/// Every file found in the unpacked dir, as paths relative to
/// `unpackedDir`. Verified against the manifest's `contents` claim.
let files: [String]
/// Parsed cron jobs (may be empty even if the manifest claims some
/// verification catches that mismatch).
let cronJobs: [TemplateCronJobSpec]
}
/// The subset of a Hermes cron job that a template can ship. Only the fields
/// the `hermes cron create` CLI accepts are included; runtime state
/// (`enabled`, `state`, `next_run_at`, ) is deliberately omitted so a
/// template can't arrive already-running.
struct TemplateCronJobSpec: Codable, Sendable, Equatable {
let name: String
let schedule: String
let prompt: String?
let deliver: String?
let skills: [String]?
let repeatCount: Int?
enum CodingKeys: String, CodingKey {
case name, schedule, prompt, deliver, skills
case repeatCount = "repeat"
}
}
// MARK: - Install Plan (the preview sheet reads this)
/// Concrete, reviewed-before-apply filesystem operations the installer will
/// perform. Every side effect the installer can cause is represented here so
/// the preview sheet is an honest accounting of what's about to happen.
struct TemplateInstallPlan: Sendable {
let manifest: ProjectTemplateManifest
let unpackedDir: String
/// Absolute path of the new project directory. Installer refuses if this
/// already exists.
let projectDir: String
/// Files that will be created under `projectDir`, keyed by relative path.
let projectFiles: [TemplateFileCopy]
/// Absolute path of the skills namespace dir
/// (`~/.hermes/skills/templates/<slug>/`). Created if skills are present.
let skillsNamespaceDir: String?
/// Files that will be created under the skills namespace dir.
let skillsFiles: [TemplateFileCopy]
/// Cron job definitions to register via `hermes cron create`. Each job's
/// name is already prefixed with the template tag. All will be paused
/// immediately after creation.
let cronJobs: [TemplateCronJobSpec]
/// Memory appendix text (already wrapped in begin/end markers). `nil`
/// means no memory write happens.
let memoryAppendix: String?
/// Target memory path (`~/.hermes/memories/MEMORY.md`). Only used when
/// `memoryAppendix` is non-nil.
let memoryPath: String
/// `ProjectEntry.name` that will be appended to the projects registry.
let projectRegistryName: String
/// Configuration schema declared by the template (manifest schemaVersion 2).
/// `nil` means the template is schema-less the installer skips the
/// config sheet and writes no `.scarf/config.json` or manifest cache.
let configSchema: TemplateConfigSchema?
/// Values the user entered in the configure sheet. Populated by the
/// VM just before `install()` runs; empty when `configSchema` is nil.
/// Secrets appear here as `.keychainRef(...)` the bytes themselves
/// were routed straight from the form field into the Keychain and
/// never held in memory past that point.
var configValues: [String: TemplateConfigValue]
/// Path at which the installer will stash a copy of `template.json`
/// so the post-install Configuration editor can render the form
/// offline. `nil` when `configSchema` is nil.
let manifestCachePath: String?
/// Convenience: total number of writes (files + cron jobs + optional
/// memory append + registry append + optional config.json + one
/// entry per secret written to the Keychain). Displayed in the
/// preview sheet.
nonisolated var totalWriteCount: Int {
let configFileCount = (configSchema?.isEmpty ?? true) ? 0 : 1
let secretCount = configValues.values.filter {
if case .keychainRef = $0 { return true } else { return false }
}.count
return projectFiles.count
+ skillsFiles.count
+ cronJobs.count
+ (memoryAppendix == nil ? 0 : 1)
+ 1 // registry entry
+ configFileCount
+ secretCount
}
}
/// A single file to copy from the unpacked bundle into a target directory.
struct TemplateFileCopy: Sendable, Equatable {
/// Path inside `unpackedDir`, e.g. `"AGENTS.md"` or
/// `"skills/timer/SKILL.md"`.
let sourceRelativePath: String
/// Absolute path where the file should land.
let destinationPath: String
}
// MARK: - Lock file (uninstall manifest, dropped into <project>/.scarf/)
/// Dropped at `<project>/.scarf/template.lock.json` after a successful
/// install. Records exactly what was written so a future "Uninstall Template"
/// action can reverse it without guessing.
struct TemplateLock: Codable, Sendable {
let templateId: String
let templateVersion: String
let templateName: String
let installedAt: String
let projectFiles: [String]
let skillsNamespaceDir: String?
let skillsFiles: [String]
let cronJobNames: [String]
let memoryBlockId: String?
/// Every `keychain://service/account` URI the installer stored in
/// the Keychain for this project's secret fields. Empty/nil for
/// schema-less (v1-style) installs. The uninstaller iterates this
/// list and calls `SecItemDelete` for each entry; absent on older
/// lock files so Codable's optional decoding keeps pre-2.3 installs
/// uninstallable.
let configKeychainItems: [String]?
/// Field keys the installer wrote to `<project>/.scarf/config.json`.
/// Informational the actual removal of config.json rides on
/// `projectFiles`. Optional for back-compat.
let configFields: [String]?
enum CodingKeys: String, CodingKey {
case templateId = "template_id"
case templateVersion = "template_version"
case templateName = "template_name"
case installedAt = "installed_at"
case projectFiles = "project_files"
case skillsNamespaceDir = "skills_namespace_dir"
case skillsFiles = "skills_files"
case cronJobNames = "cron_job_names"
case memoryBlockId = "memory_block_id"
case configKeychainItems = "config_keychain_items"
case configFields = "config_fields"
}
}
// MARK: - Uninstall Plan (the uninstall-preview sheet reads this)
/// Symmetric with `TemplateInstallPlan` but for removal. Built from the
/// `<project>/.scarf/template.lock.json` the installer wrote. The preview
/// sheet lists every path the uninstall would touch; the uninstaller
/// executes the listed ops and nothing else.
struct TemplateUninstallPlan: Sendable {
/// The parsed lock file that seeded this plan. Kept so the sheet can
/// display the template id, version, and install timestamp.
let lock: TemplateLock
/// The registry entry that will be removed on success.
let project: ProjectEntry
/// Lock-tracked files still present on disk that will be removed.
let projectFilesToRemove: [String]
/// Lock-tracked files that were already missing (e.g. user deleted them
/// after install). Shown in the sheet so the user isn't surprised that
/// a file isn't removed; uninstaller skips these.
let projectFilesAlreadyGone: [String]
/// User-added files/dirs in the project dir that are NOT in the lock.
/// These are preserved the sheet lists them so the user knows the
/// project dir stays if any exist.
let extraProjectEntries: [String]
/// If `true`, the project dir ends up empty after removal and will be
/// removed along with its files. `false` means user content lives in
/// the dir and we leave it.
let projectDirBecomesEmpty: Bool
/// Lock-recorded skills namespace dir. `nil` means the template never
/// installed skills. Uninstaller removes the entire dir recursively.
let skillsNamespaceDir: String?
/// Cron jobs that will be removed, as (id, name) pairs. Ids were looked
/// up at plan time by matching lock names against the live cron list.
let cronJobsToRemove: [(id: String, name: String)]
/// Names recorded in the lock that we couldn't find in the current cron
/// list (user-deleted, renamed, etc.). Shown in the sheet; skipped on
/// uninstall.
let cronJobsAlreadyGone: [String]
/// `true` if MEMORY.md still contains the template's begin/end markers
/// and those bytes will be stripped on uninstall. `false` means no
/// memory block was ever installed OR the user removed it by hand.
let memoryBlockPresent: Bool
/// Hermes-side path to MEMORY.md. Only touched when
/// `memoryBlockPresent` is true.
let memoryPath: String
nonisolated var totalRemoveCount: Int {
projectFilesToRemove.count
+ (skillsNamespaceDir == nil ? 0 : 1)
+ cronJobsToRemove.count
+ (memoryBlockPresent ? 1 : 0)
+ 1 // registry entry
}
}
// MARK: - Errors
enum ProjectTemplateError: LocalizedError, Sendable {
case unzipFailed(String)
case manifestMissing
case manifestParseFailed(String)
case unsupportedSchemaVersion(Int)
case requiredFileMissing(String)
case contentClaimMismatch(String)
case projectDirExists(String)
case conflictingFile(String)
case memoryBlockAlreadyExists(String)
case cronCreateFailed(job: String, output: String)
case unsafeZipEntry(String)
case lockFileMissing(String)
case lockFileParseFailed(String)
var errorDescription: String? {
switch self {
case .unzipFailed(let s):
return "Couldn't unpack template archive: \(s)"
case .manifestMissing:
return "Template is missing template.json at the archive root."
case .manifestParseFailed(let s):
return "Template manifest couldn't be parsed: \(s)"
case .unsupportedSchemaVersion(let v):
return "Template uses schemaVersion \(v), which this version of Scarf doesn't understand."
case .requiredFileMissing(let f):
return "Template is missing a required file: \(f)"
case .contentClaimMismatch(let s):
return "Template manifest doesn't match its contents: \(s)"
case .projectDirExists(let p):
return "A directory already exists at \(p). Refusing to overwrite — choose a different parent folder."
case .conflictingFile(let p):
return "An existing file would be overwritten at \(p). Refusing to clobber."
case .memoryBlockAlreadyExists(let id):
return "A memory block for template '\(id)' already exists in MEMORY.md. Remove it first or install a fresh copy."
case .cronCreateFailed(let job, let output):
return "Failed to register cron job '\(job)': \(output)"
case .unsafeZipEntry(let p):
return "Template archive contains an unsafe entry: \(p)"
case .lockFileMissing(let path):
return "No template.lock.json found at \(path). This project wasn't installed by Scarf's template system — remove it by hand."
case .lockFileParseFailed(let s):
return "Couldn't read template.lock.json: \(s)"
}
}
}
+253
View File
@@ -0,0 +1,253 @@
import Foundation
import SwiftUI
import AppKit
/// Stable identifier for a server entry in the user's registry. Backed by
/// `UUID` so it round-trips through `servers.json` and SwiftUI window-state
/// restoration without collisions.
typealias ServerID = UUID
/// Connection parameters for a remote Hermes installation reached over SSH.
/// All fields are optional except `host` unset values defer to the user's
/// `~/.ssh/config` and the OpenSSH defaults.
struct SSHConfig: Sendable, Hashable, Codable {
/// Hostname or `~/.ssh/config` alias.
var host: String
/// Remote username. `nil` defer to `~/.ssh/config` or the local user.
var user: String?
/// TCP port. `nil` 22 (or whatever `~/.ssh/config` says).
var port: Int?
/// Absolute path to a private key. `nil` defer to ssh-agent /
/// `~/.ssh/config` identity files.
var identityFile: String?
/// Override for the remote `$HOME/.hermes` directory. `nil` uses
/// `HermesPathSet.defaultRemoteHome` (`~/.hermes`, shell-expanded on the
/// remote side).
var remoteHome: String?
/// Resolved remote path to the `hermes` binary. Populated by
/// `SSHTransport` after the first `command -v hermes` probe; cached here
/// so subsequent calls skip the round trip.
var hermesBinaryHint: String?
}
/// Distinguishes a local installation (the user's own `~/.hermes`) from a
/// remote one reached over SSH. Service behavior is identical in shape but
/// dispatches to different I/O primitives in Phase 2.
enum ServerKind: Sendable, Hashable, Codable {
case local
case ssh(SSHConfig)
}
/// The per-server value that flows through `.environment` and gets handed to
/// every service and ViewModel in Phase 1. One `ServerContext` corresponds to
/// one Hermes installation; multi-window scenes in Phase 3 will construct
/// one per window.
///
/// **Why every member is `nonisolated`.** This file imports `AppKit`
/// (`NSWorkspace.shared.open` in `openInLocalEditor`), which under Swift 6's
/// upcoming default-isolation rules pulls the whole struct to `@MainActor`.
/// `ServerContext` is a plain `Sendable` value accessing `.local`, `.paths`,
/// `.isRemote`, or `makeTransport()` from a background actor must not trap
/// the caller into hopping MainActor. `nonisolated` on each member keeps
/// them callable from any context; the one MainActor-dependent method
/// (`openInLocalEditor`) lives in the extension below.
struct ServerContext: Sendable, Hashable, Identifiable {
let id: ServerID
var displayName: String
var kind: ServerKind
/// Path layout for this server. Cheap all path components are computed
/// on demand from `home`, no I/O.
nonisolated var paths: HermesPathSet {
switch kind {
case .local:
return HermesPathSet(
home: HermesPathSet.defaultLocalHome,
isRemote: false,
binaryHint: nil
)
case .ssh(let config):
return HermesPathSet(
home: config.remoteHome ?? HermesPathSet.defaultRemoteHome,
isRemote: true,
binaryHint: config.hermesBinaryHint
)
}
}
nonisolated var isRemote: Bool {
if case .ssh = kind { return true }
return false
}
/// Construct the `ServerTransport` for this context. Local contexts get
/// a `LocalTransport`; SSH contexts get an `SSHTransport` configured
/// from `SSHConfig`. Each call returns a fresh value transports are
/// cheap and stateless beyond disk caches.
nonisolated func makeTransport() -> any ServerTransport {
switch kind {
case .local:
return LocalTransport(contextID: id)
case .ssh(let config):
return SSHTransport(contextID: id, config: config, displayName: displayName)
}
}
// MARK: - Well-known singletons
/// Stable UUID for the built-in "this machine" entry. Hard-coded so the
/// local context has the same identity across launches, and so persisted
/// window-state restorations that reference it continue to resolve even
/// if `servers.json` hasn't been touched yet.
nonisolated private static let localID = ServerID(uuidString: "00000000-0000-0000-0000-000000000001")!
/// The default "this machine" context. Used everywhere in Phase 0/1 and
/// remains the fallback when no remote server is selected.
nonisolated static let local = ServerContext(
id: localID,
displayName: "Local",
kind: .local
)
}
// MARK: - Remote user-home resolution
/// Process-wide cache of each server's resolved user `$HOME`. Probed once per
/// `ServerID` via the transport, then memoized for the app's lifetime home
/// directories don't change under us, and the probe is a ~5ms SSH round-trip
/// with ControlMaster. Used by anything that needs to hand a working
/// directory to the ACP agent or the Hermes CLI on the correct host.
private actor UserHomeCache {
static let shared = UserHomeCache()
private var cache: [ServerID: String] = [:]
func resolve(for context: ServerContext) async -> String {
if let cached = cache[context.id] { return cached }
let resolved = await probe(context: context)
cache[context.id] = resolved
return resolved
}
func invalidate(contextID: ServerID) {
cache.removeValue(forKey: contextID)
}
private func probe(context: ServerContext) async -> String {
if !context.isRemote { return NSHomeDirectory() }
let transport = context.makeTransport()
let result = try? transport.runProcess(
executable: "/bin/sh",
args: ["-c", "echo $HOME"],
stdin: nil,
timeout: 10
)
let out = result?.stdoutString.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
// Fall back to `~` (unexpanded) so ACP at least gets a plausible cwd
// rather than a local Mac path. The remote side will expand it if
// passed through a shell; if not, failures are surfaced by ACP itself.
return out.isEmpty ? "~" : out
}
}
extension ServerContext {
/// Resolved absolute path to the user's home directory on the target host.
/// Local: `NSHomeDirectory()`. Remote: probed `$HOME` over SSH, cached.
/// Use this not `NSHomeDirectory()` whenever you're passing a `cwd`
/// or user path to a process that runs on the target host.
func resolvedUserHome() async -> String {
await UserHomeCache.shared.resolve(for: self)
}
/// Called when a server is removed from the registry, so the process-wide
/// caches keyed by `ServerID` don't hold stale entries forever.
static func invalidateCaches(for contextID: ServerID) async {
await UserHomeCache.shared.invalidate(contextID: contextID)
}
}
// MARK: - Convenience file I/O via the right transport
/// Centralized file I/O entry points for VMs that don't own a service. Every
/// call goes through the context's transport, so reads/writes hit the local
/// disk for `.local` and ssh/scp for `.ssh` automatically.
///
/// **Always** prefer `context.readText(...)` over `String(contentsOfFile: ...)`
/// when the path comes from `context.paths`. The Foundation file APIs are
/// LOCAL ONLY using them with a remote path silently returns nil because
/// the remote path doesn't exist on this Mac.
extension ServerContext {
/// Read a UTF-8 text file. `nil` on any error (missing, transport down,
/// invalid encoding).
nonisolated func readText(_ path: String) -> String? {
guard let data = try? makeTransport().readFile(path) else { return nil }
return String(data: data, encoding: .utf8)
}
/// Read raw bytes. `nil` on any error.
nonisolated func readData(_ path: String) -> Data? {
try? makeTransport().readFile(path)
}
/// Atomic write. Returns `true` on success, `false` on any error
/// (caller is expected to surface failures via UI when relevant).
@discardableResult
nonisolated func writeText(_ path: String, content: String) -> Bool {
guard let data = content.data(using: .utf8) else { return false }
do {
try makeTransport().writeFile(path, data: data)
return true
} catch {
return false
}
}
/// Existence check. Local: `FileManager`. Remote: `ssh test -e`.
nonisolated func fileExists(_ path: String) -> Bool {
makeTransport().fileExists(path)
}
/// File modification timestamp, or `nil` if the file doesn't exist.
nonisolated func modificationDate(_ path: String) -> Date? {
makeTransport().stat(path)?.mtime
}
/// Invoke the `hermes` CLI on this server and return its combined output
/// + exit code. Local: spawns the local binary via `Process`. Remote:
/// rounds through `ssh host hermes `. Use this from any VM that needs
/// to fire off a CLI command never spawn `hermes` via `Process()`
/// directly, because that path bypasses the transport for remote.
@discardableResult
nonisolated func runHermes(_ args: [String], timeout: TimeInterval = 60, stdin: String? = nil) -> (output: String, exitCode: Int32) {
let result = HermesFileService(context: self).runHermesCLI(args: args, timeout: timeout, stdinInput: stdin)
return (result.output, result.exitCode)
}
/// Reveal the file at `path` in the user's local editor (via
/// `NSWorkspace.open`). For remote contexts this is a no-op the
/// file doesn't exist on this Mac, so opening it would fail silently
/// or worse, open the wrong file from the local filesystem.
/// Returns `true` if opened, `false` if the call was skipped.
@discardableResult
func openInLocalEditor(_ path: String) -> Bool {
guard !isRemote else { return false }
NSWorkspace.shared.open(URL(fileURLWithPath: path))
return true
}
}
// MARK: - SwiftUI environment plumbing
/// `ServerContext` is a value type, so SwiftUI's `.environment(_:)` (which
/// requires an `@Observable` class) doesn't accept it directly. We expose it
/// through a custom `EnvironmentKey` views read it with
/// `@Environment(\.serverContext) private var serverContext`.
private struct ServerContextEnvironmentKey: EnvironmentKey {
static let defaultValue: ServerContext = .local
}
extension EnvironmentValues {
var serverContext: ServerContext {
get { self[ServerContextEnvironmentKey.self] }
set { self[ServerContextEnvironmentKey.self] = newValue }
}
}
@@ -0,0 +1,43 @@
import Foundation
/// Scarf-owned sidecar mapping Hermes session IDs to the Scarf
/// project path a chat was started for. Written on session create
/// when Scarf spawns `hermes acp` with a project-scoped cwd; read
/// by the per-project Sessions tab.
///
/// Hermes's own `state.db` has no `cwd` column on the sessions
/// table the cwd is passed at runtime via ACP but not persisted
/// on its side. This sidecar is how we recover the attribution
/// without requiring an upstream schema change.
///
/// Stored at `~/.hermes/scarf/session_project_map.json`. Forward-
/// compatible: if Hermes ever gains a canonical `cwd` column, Scarf
/// can prefer that and fall back to this file for pre-upgrade
/// sessions. Missing file empty map (nothing attributed yet).
struct SessionProjectMap: Codable, Sendable {
/// session-id absolute-project-path. Both strings are opaque
/// from this file's perspective; the service validates project
/// paths against the live registry when building the reverse
/// lookup used by the Sessions tab, so stale entries for
/// removed projects are ignored at read time without needing a
/// write-side cleanup.
var mappings: [String: String]
/// ISO-8601 timestamp of the most recent write. Informational
/// only not used for any decision logic. Useful when debugging
/// a stale sidecar ("when was this last updated?").
var updatedAt: String?
init(mappings: [String: String] = [:], updatedAt: String? = nil) {
self.mappings = mappings
self.updatedAt = updatedAt
}
/// Current time in ISO-8601 format, suitable for the
/// `updatedAt` field. Matches the format used elsewhere in
/// Scarf (e.g. `TemplateLock.installedAt`) so tooling that
/// greps across .json files sees consistent timestamps.
static func nowISO8601() -> String {
ISO8601DateFormatter().string(from: Date())
}
}
@@ -0,0 +1,278 @@
import Foundation
// MARK: - Schema (ships inside template.json as manifest.config)
/// Author-declared configuration schema for a template. Published as the
/// `config` block of `template.json` (manifest schemaVersion 2). Users fill
/// in values at install time via `TemplateConfigSheet`; values land in
/// `<project>/.scarf/config.json` with secrets resolved through the
/// macOS Keychain.
struct TemplateConfigSchema: Codable, Sendable, Equatable {
let fields: [TemplateConfigField]
let modelRecommendation: TemplateModelRecommendation?
enum CodingKeys: String, CodingKey {
case fields = "schema"
case modelRecommendation
}
nonisolated var isEmpty: Bool { fields.isEmpty }
/// Fast lookup by key. Validators guarantee keys are unique within a
/// schema at manifest-parse time, so this is safe.
nonisolated func field(for key: String) -> TemplateConfigField? {
fields.first { $0.key == key }
}
}
/// One configurable field the user fills in. Discriminated by `type`.
/// We keep one flat struct rather than an enum-associated-value encoding
/// so JSON reads cleanly as a record and authors can hand-edit manifests
/// without fighting Swift's `"case"` discriminator syntax.
struct TemplateConfigField: Codable, Sendable, Equatable, Identifiable {
nonisolated var id: String { key }
let key: String
let type: FieldType
let label: String
let description: String?
let required: Bool
let placeholder: String?
// Type-specific constraints all optional. The validator enforces
// only the ones that apply to `type`; extras are ignored.
let defaultValue: TemplateConfigValue?
let options: [EnumOption]? // type == .enum
let minLength: Int? // type == .string / .text
let maxLength: Int?
let pattern: String? // type == .string (regex)
let minNumber: Double? // type == .number
let maxNumber: Double?
let step: Double?
let itemType: String? // type == .list only "string" supported in v1
let minItems: Int?
let maxItems: Int?
enum CodingKeys: String, CodingKey {
case key, type, label, description, required, placeholder
case defaultValue = "default"
case options
case minLength, maxLength, pattern
case minNumber = "min"
case maxNumber = "max"
case step
case itemType, minItems, maxItems
}
enum FieldType: String, Codable, Sendable, Equatable {
case string
case text
case number
case bool
case `enum`
case list
case secret
}
/// One option of an `enum` field. `value` is what ends up in
/// `config.json`; `label` is the human-readable text shown in the UI.
struct EnumOption: Codable, Sendable, Equatable, Identifiable {
nonisolated var id: String { value }
let value: String
let label: String
}
}
/// Author's model-of-choice hint, shown in the install preview + on the
/// catalog detail page. Purely advisory Scarf never auto-switches the
/// active model. Individual cron jobs can override via
/// `HermesCronJob.model` if the author wants enforcement.
struct TemplateModelRecommendation: Codable, Sendable, Equatable {
let preferred: String
let rationale: String?
let alternatives: [String]?
}
// MARK: - Values (what lands in config.json and the Keychain)
/// One configured value. Secrets don't carry their raw bytes only a
/// Keychain reference of the form `"keychain://<service>/<account>"` so
/// serialising config.json to disk never leaks the secret into git or
/// into backups.
enum TemplateConfigValue: Codable, Sendable, Equatable {
case string(String)
case number(Double)
case bool(Bool)
case list([String])
case keychainRef(String)
/// Convenience: the string representation suitable for display or
/// for writing into a placeholder that the agent reads. Keychain
/// refs return the ref string, not the resolved secret callers
/// resolve through `ProjectConfigKeychain` explicitly when they
/// actually need the plaintext.
nonisolated var displayString: String {
switch self {
case .string(let s): return s
case .number(let n):
return n.truncatingRemainder(dividingBy: 1) == 0
? String(Int(n))
: String(n)
case .bool(let b): return b ? "true" : "false"
case .list(let items): return items.joined(separator: ", ")
case .keychainRef(let ref): return ref
}
}
init(from decoder: Decoder) throws {
let container = try decoder.singleValueContainer()
if let s = try? container.decode(String.self) {
// Preserve the keychain:// scheme so secrets round-trip as
// references, not as plaintext.
if s.hasPrefix("keychain://") {
self = .keychainRef(s)
} else {
self = .string(s)
}
} else if let b = try? container.decode(Bool.self) {
self = .bool(b)
} else if let n = try? container.decode(Double.self) {
self = .number(n)
} else if let arr = try? container.decode([String].self) {
self = .list(arr)
} else {
throw DecodingError.typeMismatch(
TemplateConfigValue.self,
.init(codingPath: decoder.codingPath,
debugDescription: "Expected String, Bool, Number, or [String]")
)
}
}
func encode(to encoder: Encoder) throws {
var container = encoder.singleValueContainer()
switch self {
case .string(let s): try container.encode(s)
case .number(let n): try container.encode(n)
case .bool(let b): try container.encode(b)
case .list(let items): try container.encode(items)
case .keychainRef(let ref): try container.encode(ref)
}
}
}
// MARK: - On-disk shape (what's in <project>/.scarf/config.json)
/// The JSON file the installer writes + the editor reads. Non-secret
/// values appear inline; secrets are `"keychain://<service>/<account>"`
/// references that `ProjectConfigService` resolves through the Keychain
/// on demand.
struct ProjectConfigFile: Codable, Sendable {
let schemaVersion: Int
let templateId: String
var values: [String: TemplateConfigValue]
let updatedAt: String
enum CodingKeys: String, CodingKey {
case schemaVersion
case templateId
case values
case updatedAt
}
}
// MARK: - Keychain reference helpers
/// One secret stored via `ProjectConfigKeychain`. We derive both halves
/// (service + account) from the template slug + project-path hash so two
/// installs of the same template in different dirs don't collide in the
/// login Keychain.
struct TemplateKeychainRef: Sendable, Equatable {
/// Macro service name, e.g. `com.scarf.template.awizemann-site-status-checker`.
let service: String
/// Account name: `<fieldKey>:<projectPathHashShort>`. The hash suffix
/// guarantees uniqueness across multiple installs of the same template.
let account: String
/// `"keychain://<service>/<account>"` what lands in `config.json`.
nonisolated var uri: String { "keychain://\(service)/\(account)" }
/// Parse a `keychain://` URI back into a ref. Returns `nil` when the
/// input isn't well-formed so callers can distinguish a missing ref
/// from a malformed one.
nonisolated static func parse(_ uri: String) -> TemplateKeychainRef? {
guard uri.hasPrefix("keychain://") else { return nil }
let rest = String(uri.dropFirst("keychain://".count))
guard let slash = rest.firstIndex(of: "/") else { return nil }
let service = String(rest[..<slash])
let account = String(rest[rest.index(after: slash)...])
guard !service.isEmpty, !account.isEmpty else { return nil }
return TemplateKeychainRef(service: service, account: account)
}
/// Build a ref from a template slug + field key + project path.
/// The hash suffix is a SHA-256-truncated-to-8-hex-chars fingerprint
/// of the absolute project path. Stable across launches, different
/// between `/Users/a/proj1` and `/Users/a/proj2`.
nonisolated static func make(
templateSlug: String,
fieldKey: String,
projectPath: String
) -> TemplateKeychainRef {
TemplateKeychainRef(
service: "com.scarf.template.\(templateSlug)",
account: "\(fieldKey):\(Self.shortHash(of: projectPath))"
)
}
nonisolated static func shortHash(of string: String) -> String {
// 8 hex chars is 32 bits of uniqueness plenty for
// distinguishing a handful of project dirs per template install.
let data = Data(string.utf8)
var hash: UInt32 = 0x811c9dc5
for byte in data {
hash ^= UInt32(byte)
hash &*= 0x01000193
}
return String(format: "%08x", hash)
}
}
// MARK: - Validation
/// One schema- or value-validation problem. Carries `fieldKey` so the
/// UI can surface the error inline with the field rather than at the
/// top of the form.
struct TemplateConfigValidationError: Error, Sendable, Equatable {
let fieldKey: String?
let message: String
}
enum TemplateConfigSchemaError: LocalizedError, Sendable {
case duplicateKey(String)
case unsupportedType(String)
case emptyEnumOptions(String)
case duplicateEnumValue(key: String, value: String)
case unsupportedListItemType(key: String, itemType: String)
case secretFieldHasDefault(String)
case emptyModelPreferred
var errorDescription: String? {
switch self {
case .duplicateKey(let k):
return "Config schema has duplicate key: \(k)"
case .unsupportedType(let t):
return "Config schema uses unsupported field type: \(t)"
case .emptyEnumOptions(let k):
return "Enum field '\(k)' must declare at least one option"
case .duplicateEnumValue(let k, let v):
return "Enum field '\(k)' has duplicate option value: \(v)"
case .unsupportedListItemType(let k, let t):
return "List field '\(k)' uses unsupported itemType '\(t)'. Only 'string' is supported in v1."
case .secretFieldHasDefault(let k):
return "Secret field '\(k)' cannot declare a default value — secrets belong only in the Keychain."
case .emptyModelPreferred:
return "modelRecommendation.preferred must be a non-empty model id."
}
}
}
@@ -0,0 +1,199 @@
import Foundation
import os
/// Persisted entry for a user-added server. `ServerContext` itself is a value
/// type we rebuild from these fields at runtime we persist the minimum that
/// uniquely identifies a connection, not the whole context struct, so future
/// fields we add to `ServerContext` don't force a migration.
struct ServerEntry: Identifiable, Codable, Hashable, Sendable {
var id: ServerID
var displayName: String
var kind: ServerKind
/// User preference: this server is the one Scarf opens into when a
/// fresh window has no prior binding (first launch or File New).
/// At most one entry should have this set `ServerRegistry` enforces
/// mutual exclusivity. If none do, Local is the implicit default.
var openOnLaunch: Bool = false
var context: ServerContext {
ServerContext(id: id, displayName: displayName, kind: kind)
}
}
/// On-disk envelope for `servers.json`. Schema-versioned so future changes
/// can migrate without losing data.
private struct RegistryFile: Codable {
var schemaVersion: Int
var entries: [ServerEntry]
}
/// App-scoped store for user-added servers. `local` is synthesized (not
/// persisted) and always appears first in `allContexts`. Remote entries are
/// loaded from `~/Library/Application Support/scarf/servers.json`.
///
/// Observable so SwiftUI views binding to `entries` redraw when a server is
/// added, renamed, or removed.
@Observable
@MainActor
final class ServerRegistry {
private static let logger = Logger(subsystem: "com.scarf", category: "ServerRegistry")
private static let currentSchemaVersion = 1
/// Remote (user-added) entries. Observable: views redraw on mutation.
private(set) var entries: [ServerEntry] = []
private let storeURL: URL
init() {
let support = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask).first
?? URL(fileURLWithPath: NSHomeDirectory() + "/Library/Application Support")
let dir = support.appendingPathComponent("scarf", isDirectory: true)
self.storeURL = dir.appendingPathComponent("servers.json")
load()
}
// MARK: - Lookup
/// The implicit local server plus every persisted remote entry, in list
/// order. Use this when populating UI like the toolbar switcher.
var allContexts: [ServerContext] {
[.local] + entries.map { $0.context }
}
/// Resolve an ID to a context, or `nil` if the entry no longer exists.
/// Used by the multi-window root to detect "this window points at a
/// server you've since removed" and show a dedicated empty state.
func context(for id: ServerID) -> ServerContext? {
if id == ServerContext.local.id { return .local }
if let entry = entries.first(where: { $0.id == id }) {
return entry.context
}
return nil
}
/// The server a fresh window should open into. Returns the ID of the
/// remote entry flagged `openOnLaunch`, or Local's ID if none is
/// flagged (or if the flagged entry was removed out from under us).
/// Consumed by the `WindowGroup`'s `defaultValue` closure.
var defaultServerID: ServerID {
entries.first(where: { $0.openOnLaunch })?.id ?? ServerContext.local.id
}
/// Flip the default server to `id`. Passing `ServerContext.local.id`
/// clears the flag on every remote entry, making Local the implicit
/// default. Passing an unknown ID is a no-op. Persisted on return.
///
/// Intentionally doesn't fire `onEntriesChanged` that hook means "the
/// set of servers changed" and drives the menu-bar fanout rebuild. A
/// default-flag flip doesn't change the set; SwiftUI views reading
/// `defaultServerID` redraw via `@Observable`'s tracking of `entries`.
func setDefaultServer(_ id: ServerID) {
var changed = false
for idx in entries.indices {
let shouldBeDefault = (entries[idx].id == id)
if entries[idx].openOnLaunch != shouldBeDefault {
entries[idx].openOnLaunch = shouldBeDefault
changed = true
}
}
if changed {
save()
}
}
// MARK: - Mutations
/// Optional callback fired whenever `entries` changes. The app wires
/// this to `ServerLiveStatusRegistry.rebuild()` so the menu-bar fanout
/// stays in sync without polling the entries array.
var onEntriesChanged: (() -> Void)?
@discardableResult
func addServer(displayName: String, config: SSHConfig) -> ServerEntry {
let entry = ServerEntry(
id: ServerID(),
displayName: displayName,
kind: .ssh(config)
)
entries.append(entry)
save()
onEntriesChanged?()
return entry
}
func updateServer(_ id: ServerID, displayName: String?, config: SSHConfig?) {
guard let idx = entries.firstIndex(where: { $0.id == id }) else { return }
if let name = displayName { entries[idx].displayName = name }
if let cfg = config { entries[idx].kind = .ssh(cfg) }
save()
onEntriesChanged?()
}
func removeServer(_ id: ServerID) {
// Grab the entry BEFORE removing it so we can tear down its transport
// state. Without this the user would leak a ControlMaster socket
// (~10min TTL) and a snapshot cache dir (indefinite) per removed
// server harmless individually, ugly at scale.
let removed = entries.first { $0.id == id }
entries.removeAll { $0.id == id }
save()
if let removed, case .ssh(let config) = removed.kind {
let transport = SSHTransport(contextID: id, config: config, displayName: removed.displayName)
transport.closeControlMaster()
}
SSHTransport.pruneSnapshotCache(for: id)
// Drop process-wide cache entries keyed on this ServerID so a future
// re-add with a colliding ID (theoretical UUIDs are random, but be
// defensive) doesn't serve stale data.
Task.detached { await ServerContext.invalidateCaches(for: id) }
onEntriesChanged?()
}
// MARK: - App-launch sweep
/// Remove snapshot cache directories whose UUID isn't in the current
/// registry. Handles the case where the user removed a server while the
/// app was closed we want the cache to converge to the registry's
/// state at launch rather than carrying forever.
func sweepOrphanCaches() {
var keep: Set<ServerID> = [ServerContext.local.id]
for entry in entries { keep.insert(entry.id) }
SSHTransport.sweepOrphanSnapshots(keeping: keep)
SSHTransport.sweepStaleControlSockets()
}
// MARK: - Persistence
private func load() {
guard FileManager.default.fileExists(atPath: storeURL.path) else {
entries = []
return
}
do {
let data = try Data(contentsOf: storeURL)
let file = try JSONDecoder().decode(RegistryFile.self, from: data)
entries = file.entries
} catch {
Self.logger.error("Failed to load servers.json: \(error.localizedDescription)")
entries = []
}
}
private func save() {
do {
try FileManager.default.createDirectory(
at: storeURL.deletingLastPathComponent(),
withIntermediateDirectories: true
)
let file = RegistryFile(schemaVersion: Self.currentSchemaVersion, entries: entries)
let encoder = JSONEncoder()
encoder.outputFormatting = [.prettyPrinted, .sortedKeys]
let data = try encoder.encode(file)
try data.write(to: storeURL, options: .atomic)
} catch {
Self.logger.error("Failed to save servers.json: \(error.localizedDescription)")
}
}
}
+604
View File
@@ -0,0 +1,604 @@
import Foundation
import os
/// Manages a `hermes acp` subprocess and communicates via JSON-RPC over stdio.
/// Provides an async event stream for real-time session updates.
actor ACPClient {
private let logger = Logger(subsystem: "com.scarf", category: "ACPClient")
private var process: Process?
private var stdinPipe: Pipe?
private var stdoutPipe: Pipe?
private var stderrPipe: Pipe?
private var stdinFd: Int32 = -1
private var nextRequestId = 1
private var pendingRequests: [Int: CheckedContinuation<AnyCodable?, Error>] = [:]
private var readTask: Task<Void, Never>?
private var stderrTask: Task<Void, Never>?
private var keepaliveTask: Task<Void, Never>?
private var eventContinuation: AsyncStream<ACPEvent>.Continuation?
private var _eventStream: AsyncStream<ACPEvent>?
private(set) var isConnected = false
private(set) var currentSessionId: String?
private(set) var statusMessage = ""
let context: ServerContext
private let transport: any ServerTransport
init(context: ServerContext = .local) {
self.context = context
self.transport = context.makeTransport()
}
/// Ring buffer of recent stderr lines from `hermes acp` used to attach
/// a diagnostic tail to user-visible errors. Capped to avoid unbounded
/// growth when the subprocess logs heavily.
private var stderrBuffer: [String] = []
private static let stderrBufferMaxLines = 50
/// Returns the last ~`stderrBufferMaxLines` stderr lines captured from the
/// `hermes acp` subprocess, joined by newlines.
var recentStderr: String {
stderrBuffer.joined(separator: "\n")
}
fileprivate func appendStderr(_ text: String) {
for line in text.split(separator: "\n", omittingEmptySubsequences: true) {
stderrBuffer.append(String(line))
}
if stderrBuffer.count > Self.stderrBufferMaxLines {
stderrBuffer.removeFirst(stderrBuffer.count - Self.stderrBufferMaxLines)
}
}
/// Check if the underlying process is still alive and connected.
var isHealthy: Bool {
guard isConnected, let process else { return false }
return process.isRunning
}
// MARK: - Event Stream
/// Access the event stream. Must call `start()` first.
var events: AsyncStream<ACPEvent> {
guard let stream = _eventStream else {
// Return an empty stream if not started
return AsyncStream { $0.finish() }
}
return stream
}
// MARK: - Lifecycle
func start() async throws {
guard process == nil else { return }
// Ignore SIGPIPE so broken-pipe writes return EPIPE instead of crashing
signal(SIGPIPE, SIG_IGN)
// Create the event stream BEFORE anything else so no events are lost
let (stream, continuation) = AsyncStream.makeStream(of: ACPEvent.self)
self._eventStream = stream
self.eventContinuation = continuation
// For local: Process is `hermes acp` directly.
// For remote: the transport returns a Process configured as
// `/usr/bin/ssh -T <opts> host -- <hermes> acp`. ACP's JSON-RPC
// over stdio works identically because `-T` keeps the ssh channel
// byte-clean and stdin/stdout travel end-to-end unmodified.
let proc = transport.makeProcess(
executable: context.paths.hermesBinary,
args: ["acp"]
)
let stdin = Pipe()
let stdout = Pipe()
let stderr = Pipe()
proc.standardInput = stdin
proc.standardOutput = stdout
proc.standardError = stderr
// ACP uses JSON-RPC over pipes do NOT set TERM to avoid terminal escape pollution.
if context.isRemote {
// Remote: this is the LOCAL ssh process spawning `ssh host
// hermes acp`. We don't forward our local PATH/credentials to
// the remote (hermes runs under the remote user's login env),
// but the ssh binary itself needs SSH_AUTH_SOCK to reach the
// local ssh-agent for key-based auth.
var env = ProcessInfo.processInfo.environment
let shellEnv = HermesFileService.enrichedEnvironment()
for key in ["SSH_AUTH_SOCK", "SSH_AGENT_PID"] {
if env[key] == nil, let v = shellEnv[key], !v.isEmpty {
env[key] = v
}
}
env.removeValue(forKey: "TERM")
proc.environment = env
} else {
// Local: enriched env so any tools hermes spawns (MCP servers,
// shell commands) can find brew/nvm/asdf binaries on PATH.
var env = HermesFileService.enrichedEnvironment()
env.removeValue(forKey: "TERM")
proc.environment = env
}
proc.terminationHandler = { [weak self] proc in
Task { await self?.handleTermination(exitCode: proc.terminationStatus) }
}
statusMessage = "Starting hermes acp..."
do {
try proc.run()
} catch {
statusMessage = "Failed to start: \(error.localizedDescription)"
logger.error("Failed to start hermes acp: \(error.localizedDescription)")
continuation.finish()
throw error
}
self.process = proc
self.stdinPipe = stdin
self.stdoutPipe = stdout
self.stderrPipe = stderr
self.stdinFd = stdin.fileHandleForWriting.fileDescriptor
self.isConnected = true
// Start reading stdout BEFORE sending initialize (so we catch the response)
startReadLoop(stdout: stdout, stderr: stderr)
logger.info("hermes acp process started (pid: \(proc.processIdentifier))")
statusMessage = "Initializing..."
// Initialize the ACP connection
let initParams: [String: AnyCodable] = [
"protocolVersion": AnyCodable(1),
"clientCapabilities": AnyCodable([String: Any]()),
"clientInfo": AnyCodable([
"name": "Scarf",
"version": "1.0"
] as [String: Any])
]
_ = try await sendRequest(method: "initialize", params: initParams)
statusMessage = "Connected"
logger.info("ACP connection initialized")
startKeepalive()
}
func stop() async {
readTask?.cancel()
readTask = nil
stderrTask?.cancel()
stderrTask = nil
keepaliveTask?.cancel()
keepaliveTask = nil
eventContinuation?.finish()
eventContinuation = nil
_eventStream = nil
for (_, continuation) in pendingRequests {
continuation.resume(throwing: CancellationError())
}
pendingRequests.removeAll()
// Close stdin first so the subprocess sees EOF and can shut down gracefully
stdinPipe?.fileHandleForWriting.closeFile()
if let process, process.isRunning {
// SIGINT for graceful Python shutdown (raises KeyboardInterrupt cleanly)
process.interrupt()
// Watchdog: force-kill if still running after 2 seconds
let watchdogProcess = process
Task.detached {
try? await Task.sleep(nanoseconds: 2_000_000_000)
if watchdogProcess.isRunning {
watchdogProcess.terminate()
}
}
}
stdinPipe?.fileHandleForReading.closeFile()
stdoutPipe?.fileHandleForReading.closeFile()
stderrPipe?.fileHandleForReading.closeFile()
process = nil
stdinPipe = nil
stdoutPipe = nil
stderrPipe = nil
stdinFd = -1
isConnected = false
currentSessionId = nil
statusMessage = "Disconnected"
logger.info("ACP client stopped")
}
// MARK: - Keepalive
private func startKeepalive() {
keepaliveTask = Task { [weak self] in
while !Task.isCancelled {
try? await Task.sleep(nanoseconds: 30_000_000_000) // 30 seconds
guard !Task.isCancelled else { break }
await self?.sendKeepalive()
}
}
}
/// Valid JSON-RPC notification used as a keepalive probe.
/// Sending bare newlines causes `json.loads("")` errors in the ACP library.
private static let keepalivePayload: Data = {
let json = #"{"jsonrpc":"2.0","method":"$/ping"}"# + "\n"
return Data(json.utf8)
}()
private func sendKeepalive() {
let fd = stdinFd
guard fd >= 0 else { return }
Task.detached { [weak self] in
let ok = Self.safeWrite(fd: fd, data: Self.keepalivePayload)
if !ok {
await self?.handleWriteFailed()
}
}
}
// MARK: - Session Management
func newSession(cwd: String) async throws -> String {
statusMessage = "Creating session..."
let params: [String: AnyCodable] = [
"cwd": AnyCodable(cwd),
"mcpServers": AnyCodable([Any]())
]
let result = try await sendRequest(method: "session/new", params: params)
guard let dict = result?.dictValue,
let sessionId = dict["sessionId"] as? String else {
throw ACPClientError.invalidResponse("Missing sessionId in session/new response")
}
currentSessionId = sessionId
statusMessage = "Session ready"
logger.info("Created new ACP session: \(sessionId)")
return sessionId
}
func loadSession(cwd: String, sessionId: String) async throws -> String {
statusMessage = "Loading session \(sessionId.prefix(12))..."
let params: [String: AnyCodable] = [
"cwd": AnyCodable(cwd),
"sessionId": AnyCodable(sessionId),
"mcpServers": AnyCodable([Any]())
]
let result = try await sendRequest(method: "session/load", params: params)
// ACP returns {} on success (no sessionId echoed), or an error if not found.
// If we got here without throwing, the session was loaded. Use the ID we sent.
let loadedId = (result?.dictValue?["sessionId"] as? String) ?? sessionId
currentSessionId = loadedId
statusMessage = "Session loaded"
logger.info("Loaded ACP session: \(loadedId)")
return loadedId
}
func resumeSession(cwd: String, sessionId: String) async throws -> String {
statusMessage = "Resuming session..."
let params: [String: AnyCodable] = [
"cwd": AnyCodable(cwd),
"sessionId": AnyCodable(sessionId),
"mcpServers": AnyCodable([Any]())
]
let result = try await sendRequest(method: "session/resume", params: params)
guard let dict = result?.dictValue,
let resumedId = dict["sessionId"] as? String else {
throw ACPClientError.invalidResponse("Missing sessionId in session/resume response")
}
currentSessionId = resumedId
statusMessage = "Session resumed"
logger.info("Resumed ACP session: \(resumedId)")
return resumedId
}
// MARK: - Messaging
func sendPrompt(sessionId: String, text: String) async throws -> ACPPromptResult {
statusMessage = "Sending prompt..."
let messageId = UUID().uuidString
let params: [String: AnyCodable] = [
"sessionId": AnyCodable(sessionId),
"messageId": AnyCodable(messageId),
"prompt": AnyCodable([
["type": "text", "text": text] as [String: Any]
] as [Any])
]
let result = try await sendRequest(method: "session/prompt", params: params)
let dict = result?.dictValue ?? [:]
let usage = dict["usage"] as? [String: Any] ?? [:]
statusMessage = "Ready"
return ACPPromptResult(
stopReason: dict["stopReason"] as? String ?? "end_turn",
inputTokens: usage["inputTokens"] as? Int ?? 0,
outputTokens: usage["outputTokens"] as? Int ?? 0,
thoughtTokens: usage["thoughtTokens"] as? Int ?? 0,
cachedReadTokens: usage["cachedReadTokens"] as? Int ?? 0
)
}
func cancel(sessionId: String) async throws {
let params: [String: AnyCodable] = [
"sessionId": AnyCodable(sessionId)
]
_ = try await sendRequest(method: "session/cancel", params: params)
statusMessage = "Cancelled"
}
func respondToPermission(requestId: Int, optionId: String) {
let response: [String: Any] = [
"jsonrpc": "2.0",
"id": requestId,
"result": [
"outcome": [
"kind": optionId == "deny" ? "rejected" : "allowed",
"optionId": optionId
] as [String: Any]
] as [String: Any]
]
writeJSON(response)
}
// MARK: - JSON-RPC Transport
private func sendRequest(method: String, params: [String: AnyCodable]) async throws -> AnyCodable? {
let requestId = nextRequestId
nextRequestId += 1
let request = ACPRequest(id: requestId, method: method, params: params)
guard let data = try? JSONEncoder().encode(request) else {
throw ACPClientError.encodingFailed
}
logger.debug("Sending: \(method) (id: \(requestId))")
// session/prompt streams events and can run for minutes no hard timeout.
// Control messages get a 30s watchdog.
let timeoutTask: Task<Void, Error>? = if method != "session/prompt" {
Task { [weak self] in
try await Task.sleep(nanoseconds: 30 * 1_000_000_000)
await self?.timeoutRequest(id: requestId, method: method)
}
} else {
nil
}
defer { timeoutTask?.cancel() }
let fd = stdinFd
return try await withCheckedThrowingContinuation { (continuation: CheckedContinuation<AnyCodable?, Error>) in
pendingRequests[requestId] = continuation
guard fd >= 0 else {
pendingRequests.removeValue(forKey: requestId)
continuation.resume(throwing: ACPClientError.notConnected)
return
}
var payload = data
payload.append(contentsOf: "\n".utf8)
// Write in a detached task to avoid blocking the actor's executor.
// The continuation is already stored; the response arrives via the read loop.
Task.detached { [weak self] in
let ok = Self.safeWrite(fd: fd, data: payload)
if !ok {
await self?.handleWriteFailedForRequest(id: requestId)
}
}
}
}
private func timeoutRequest(id: Int, method: String) {
guard let continuation = pendingRequests.removeValue(forKey: id) else { return }
logger.error("Request timed out: \(method) (id: \(id))")
statusMessage = "Request timed out"
continuation.resume(throwing: ACPClientError.requestTimeout(method: method))
}
private func writeJSON(_ dict: [String: Any]) {
let fd = stdinFd
guard fd >= 0,
let data = try? JSONSerialization.data(withJSONObject: dict) else { return }
var payload = data
payload.append(contentsOf: "\n".utf8)
Task.detached { [weak self] in
let ok = Self.safeWrite(fd: fd, data: payload)
if !ok {
await self?.handleWriteFailed()
}
}
}
// MARK: - Read Loop
private func startReadLoop(stdout: Pipe, stderr: Pipe) {
// Read stdout for JSON-RPC messages
readTask = Task.detached { [weak self] in
let handle = stdout.fileHandleForReading
var buffer = Data()
while !Task.isCancelled {
let chunk = handle.availableData
if chunk.isEmpty { break } // EOF
buffer.append(chunk)
while let newlineIndex = buffer.firstIndex(of: UInt8(ascii: "\n")) {
let lineData = Data(buffer[buffer.startIndex..<newlineIndex])
buffer = Data(buffer[buffer.index(after: newlineIndex)...])
guard !lineData.isEmpty else { continue }
if let lineStr = String(data: lineData, encoding: .utf8) {
self?.logger.debug("ACP recv: \(lineStr.prefix(200))")
}
do {
let message = try JSONDecoder().decode(ACPRawMessage.self, from: lineData)
await self?.handleMessage(message)
} catch {
self?.logger.warning("Failed to decode ACP message: \(error.localizedDescription)")
}
}
}
await self?.handleReadLoopEnded()
}
// Read stderr in background for diagnostic logging AND ring-buffer
// capture so we can attach a tail to user-visible errors.
stderrTask = Task.detached { [weak self] in
let handle = stderr.fileHandleForReading
while !Task.isCancelled {
let data = handle.availableData
if data.isEmpty { break }
if let text = String(data: data, encoding: .utf8)?.trimmingCharacters(in: .whitespacesAndNewlines),
!text.isEmpty {
self?.logger.info("ACP stderr: \(text.prefix(500))")
await self?.appendStderr(text)
}
}
}
}
private func handleMessage(_ message: ACPRawMessage) {
if message.isResponse {
if let requestId = message.id,
let continuation = pendingRequests.removeValue(forKey: requestId) {
if let error = message.error {
logger.error("ACP RPC error (id: \(requestId)): \(error.message)")
statusMessage = "Error: \(error.message)"
continuation.resume(throwing: ACPClientError.rpcError(code: error.code, message: error.message))
} else {
logger.debug("ACP response (id: \(requestId))")
continuation.resume(returning: message.result)
}
} else {
logger.warning("ACP response for unknown request id: \(message.id ?? -1)")
}
} else if message.isNotification {
if let event = ACPEventParser.parse(notification: message) {
logger.debug("ACP event: \(String(describing: event).prefix(100))")
eventContinuation?.yield(event)
}
} else if message.isRequest {
if message.method == "session/request_permission",
let event = ACPEventParser.parsePermissionRequest(message) {
statusMessage = "Permission required"
eventContinuation?.yield(event)
}
}
}
// MARK: - Disconnect Cleanup
/// Single idempotent cleanup path for all disconnect scenarios.
private func performDisconnectCleanup(reason: String) {
guard isConnected else { return }
logger.warning("ACP disconnecting: \(reason)")
isConnected = false
statusMessage = "Connection lost"
for (_, continuation) in pendingRequests {
continuation.resume(throwing: ACPClientError.processTerminated)
}
pendingRequests.removeAll()
eventContinuation?.finish()
eventContinuation = nil
}
private func handleReadLoopEnded() {
performDisconnectCleanup(reason: "read loop ended (EOF)")
}
private func handleTermination(exitCode: Int32) {
performDisconnectCleanup(reason: "process exited (\(exitCode))")
}
private func handleWriteFailed() {
performDisconnectCleanup(reason: "write failed (broken pipe)")
}
private func handleWriteFailedForRequest(id: Int) {
if let continuation = pendingRequests.removeValue(forKey: id) {
continuation.resume(throwing: ACPClientError.processTerminated)
}
performDisconnectCleanup(reason: "write failed (broken pipe)")
}
// MARK: - Safe POSIX Write
/// Write data to a file descriptor using POSIX write(), returning false on error.
/// Handles partial writes and returns false on EPIPE or other errors.
private static func safeWrite(fd: Int32, data: Data) -> Bool {
data.withUnsafeBytes { buf in
guard let base = buf.baseAddress else { return false }
var written = 0
let total = buf.count
while written < total {
let result = Darwin.write(fd, base.advanced(by: written), total - written)
if result <= 0 { return false }
written += result
}
return true
}
}
}
// MARK: - Errors
enum ACPClientError: Error, LocalizedError {
case notConnected
case encodingFailed
case invalidResponse(String)
case rpcError(code: Int, message: String)
case processTerminated
case requestTimeout(method: String)
var errorDescription: String? {
switch self {
case .notConnected: return "ACP client is not connected"
case .encodingFailed: return "Failed to encode JSON-RPC request"
case .invalidResponse(let msg): return "Invalid ACP response: \(msg)"
case .rpcError(let code, let msg): return "ACP error \(code): \(msg)"
case .processTerminated: return "ACP process terminated unexpectedly"
case .requestTimeout(let method): return "ACP request '\(method)' timed out"
}
}
}
/// Maps a raw error message (RPC message or captured stderr) to a short
/// human-readable hint for the chat UI. Pattern-matches the most common
/// fresh-install failure modes. Returns nil when no known pattern matches.
enum ACPErrorHint {
static func classify(errorMessage: String, stderrTail: String) -> String? {
let haystack = errorMessage + "\n" + stderrTail
if haystack.range(of: #"No\s+(Anthropic|OpenAI|OpenRouter|Gemini|Google|Groq|Mistral|XAI)?\s*credentials\s+found"#,
options: .regularExpression) != nil
|| haystack.contains("ANTHROPIC_API_KEY")
|| haystack.contains("ANTHROPIC_TOKEN")
|| haystack.contains("claude setup-token")
|| haystack.contains("claude /login") {
return "Hermes can't find your AI provider credentials. Set `ANTHROPIC_API_KEY` (or similar) in `~/.hermes/.env` or your shell profile, then restart Scarf."
}
if let match = haystack.range(of: #"No such file or directory:\s*'([^']+)'"#,
options: .regularExpression) {
let matched = String(haystack[match])
if let nameStart = matched.range(of: "'"),
let nameEnd = matched.range(of: "'", range: nameStart.upperBound..<matched.endIndex) {
let name = String(matched[nameStart.upperBound..<nameEnd.lowerBound])
return "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."
}
return "Hermes couldn't find a required binary on PATH. Check that your shell's PATH is exported in `~/.zprofile`, then restart Scarf."
}
if haystack.localizedCaseInsensitiveContains("rate limit")
|| haystack.localizedCaseInsensitiveContains("429") {
return "Your AI provider returned a rate-limit error. Try again in a moment."
}
return nil
}
}
+378 -89
View File
@@ -1,22 +1,151 @@
import Foundation
import SQLite3
import os
/// Dedupes concurrent `snapshotSQLite` calls for the same server. When the
/// file watcher ticks, Dashboard + Sessions + Activity (+ Chat's loadHistory)
/// can all ask for a fresh snapshot within the same millisecond without
/// coordination they each spawn their own `ssh host sqlite3 .backup; scp`
/// round-trip, three parallel backups of the same DB. Callers in flight for
/// the same `ServerID` await the first caller's Task and share its result.
actor SnapshotCoordinator {
static let shared = SnapshotCoordinator()
private var inFlight: [ServerID: Task<URL, Error>] = [:]
func snapshot(
remotePath: String,
contextID: ServerID,
transport: any ServerTransport
) async throws -> URL {
if let existing = inFlight[contextID] {
return try await existing.value
}
let task = Task<URL, Error> {
try transport.snapshotSQLite(remotePath: remotePath)
}
inFlight[contextID] = task
defer { inFlight[contextID] = nil }
return try await task.value
}
}
actor HermesDataService {
private var db: OpaquePointer?
private static let logger = Logger(subsystem: "com.scarf", category: "HermesDataService")
func open() -> Bool {
let path = HermesPaths.stateDB
guard FileManager.default.fileExists(atPath: path) else { return false }
let flags = SQLITE_OPEN_READONLY | SQLITE_OPEN_NOMUTEX
let result = sqlite3_open_v2(path, &db, flags, nil)
private var db: OpaquePointer?
private var hasV07Schema = false
/// Local filesystem path we last opened. For remote contexts this is
/// the cached snapshot under `~/Library/Caches/scarf/snapshots/<id>/`.
private var openedAtPath: String?
/// Last error from `open()` / `refresh()`, user-presentable. `nil` means
/// the last attempt succeeded. Views surface this when their own load
/// path fails, so the user sees "Permission denied reading state.db"
/// instead of an empty Dashboard with no explanation.
private(set) var lastOpenError: String?
let context: ServerContext
private let transport: any ServerTransport
init(context: ServerContext = .local) {
self.context = context
self.transport = context.makeTransport()
}
func open() async -> Bool {
if db != nil { return true }
let localPath: String
if context.isRemote {
// Pull a fresh snapshot from the remote host. Uses `sqlite3
// .backup` on the remote, which is WAL-safe; a plain cp would
// corrupt. Routed through SnapshotCoordinator so concurrent
// view models don't each spawn a parallel SSH backup for the
// same server.
do {
let url = try await SnapshotCoordinator.shared.snapshot(
remotePath: context.paths.stateDB,
contextID: context.id,
transport: transport
)
localPath = url.path
lastOpenError = nil
} catch {
lastOpenError = humanize(error)
Self.logger.warning("snapshotSQLite failed: \(error.localizedDescription, privacy: .public)")
return false
}
} else {
localPath = context.paths.stateDB
guard FileManager.default.fileExists(atPath: localPath) else {
lastOpenError = "Hermes state database not found at \(localPath)."
return false
}
}
// Remote snapshots are point-in-time copies that no one writes to;
// opening them with `immutable=1` tells SQLite to skip WAL/SHM and
// locking entirely, which is both faster and avoids spurious
// "unable to open database file" errors if the snapshot ever gets
// pulled mid-checkpoint. Local points at the live Hermes DB where
// the process already has WAL enabled in the header, so a plain
// readonly open is the right thing.
let flags: Int32
let openPath: String
if context.isRemote {
openPath = "file:\(localPath)?immutable=1"
flags = SQLITE_OPEN_READONLY | SQLITE_OPEN_NOMUTEX | SQLITE_OPEN_URI
} else {
openPath = localPath
flags = SQLITE_OPEN_READONLY | SQLITE_OPEN_NOMUTEX
}
let result = sqlite3_open_v2(openPath, &db, flags, nil)
guard result == SQLITE_OK else {
let msg: String
if let db {
msg = String(cString: sqlite3_errmsg(db))
} else {
msg = "sqlite3_open_v2 returned \(result)"
}
lastOpenError = "Couldn't open state.db: \(msg)"
Self.logger.warning("sqlite3_open_v2 failed (\(result)) at \(localPath, privacy: .public): \(msg, privacy: .public)")
db = nil
return false
}
sqlite3_exec(db, "PRAGMA journal_mode=WAL", nil, nil, nil)
openedAtPath = localPath
lastOpenError = nil
detectSchema()
return true
}
/// Turn a transport error into the one-line string Dashboard shows. Adds
/// hints for the common "sqlite3 not installed" and "permission denied"
/// cases so users know what to do.
private nonisolated func humanize(_ error: Error) -> String {
let desc = (error as? LocalizedError)?.errorDescription ?? error.localizedDescription
let lower = desc.lowercased()
if lower.contains("sqlite3: command not found") || lower.contains("sqlite3: not found") {
return "sqlite3 is not installed on \(context.displayName). Install it with `apt install sqlite3` (Ubuntu/Debian) or `yum install sqlite` (RHEL/Fedora)."
}
if lower.contains("permission denied") {
return "Permission denied reading Hermes state on \(context.displayName). The SSH user may not have read access to ~/.hermes/state.db — try Run Diagnostics."
}
if lower.contains("no such file") {
return "Hermes state not found at ~/.hermes on \(context.displayName). If Hermes is installed elsewhere, set its data directory in Manage Servers."
}
return desc
}
/// Force a fresh snapshot pull + reopen. Used on session-load and in
/// any path that needs the UI to reflect writes Hermes just made.
/// Without this, remote snapshots would be frozen at the first `open()`
/// for the app's lifetime new messages added to a resumed session
/// would never appear because the snapshot was pulled before they were
/// written. Local contexts pay essentially nothing: close+reopen on a
/// live DB is a no-op.
@discardableResult
func refresh() async -> Bool {
close()
return await open()
}
func close() {
if let db {
sqlite3_close(db)
@@ -24,17 +153,39 @@ actor HermesDataService {
db = nil
}
func fetchSessions(limit: Int = 100) -> [HermesSession] {
guard let db else { return [] }
let sql = """
SELECT id, source, user_id, model, title, parent_session_id,
started_at, ended_at, end_reason, message_count, tool_call_count,
input_tokens, output_tokens, cache_read_tokens, cache_write_tokens,
estimated_cost_usd
FROM sessions
ORDER BY started_at DESC
LIMIT ?
// MARK: - Schema Detection
private func detectSchema() {
guard let db else { return }
var stmt: OpaquePointer?
guard sqlite3_prepare_v2(db, "PRAGMA table_info(sessions)", -1, &stmt, nil) == SQLITE_OK else { return }
defer { sqlite3_finalize(stmt) }
while sqlite3_step(stmt) == SQLITE_ROW {
if let name = sqlite3_column_text(stmt, 1), String(cString: name) == "reasoning_tokens" {
hasV07Schema = true
return
}
}
}
// MARK: - Session Queries
private var sessionColumns: String {
var cols = """
id, source, user_id, model, title, parent_session_id,
started_at, ended_at, end_reason, message_count, tool_call_count,
input_tokens, output_tokens, cache_read_tokens, cache_write_tokens,
estimated_cost_usd
"""
if hasV07Schema {
cols += ", reasoning_tokens, actual_cost_usd, cost_status, billing_provider"
}
return cols
}
func fetchSessions(limit: Int = QueryDefaults.sessionLimit) -> [HermesSession] {
guard let db else { return [] }
let sql = "SELECT \(sessionColumns) FROM sessions WHERE parent_session_id IS NULL ORDER BY started_at DESC LIMIT ?"
var stmt: OpaquePointer?
guard sqlite3_prepare_v2(db, sql, -1, &stmt, nil) == SQLITE_OK else { return [] }
defer { sqlite3_finalize(stmt) }
@@ -47,19 +198,56 @@ actor HermesDataService {
return sessions
}
func fetchMessages(sessionId: String) -> [HermesMessage] {
func fetchSessionsInPeriod(since: Date) -> [HermesSession] {
guard let db else { return [] }
let sql = """
SELECT id, session_id, role, content, tool_call_id, tool_calls,
tool_name, timestamp, token_count, finish_reason
FROM messages
WHERE session_id = ?
ORDER BY timestamp ASC
"""
let sql = "SELECT \(sessionColumns) FROM sessions WHERE parent_session_id IS NULL AND started_at >= ? ORDER BY started_at DESC"
var stmt: OpaquePointer?
guard sqlite3_prepare_v2(db, sql, -1, &stmt, nil) == SQLITE_OK else { return [] }
defer { sqlite3_finalize(stmt) }
sqlite3_bind_text(stmt, 1, sessionId, -1, unsafeBitCast(-1, to: sqlite3_destructor_type.self))
sqlite3_bind_double(stmt, 1, since.timeIntervalSince1970)
var sessions: [HermesSession] = []
while sqlite3_step(stmt) == SQLITE_ROW {
sessions.append(sessionFromRow(stmt!))
}
return sessions
}
func fetchSubagentSessions(parentId: String) -> [HermesSession] {
guard let db else { return [] }
let sql = "SELECT \(sessionColumns) FROM sessions WHERE parent_session_id = ? ORDER BY started_at ASC"
var stmt: OpaquePointer?
guard sqlite3_prepare_v2(db, sql, -1, &stmt, nil) == SQLITE_OK else { return [] }
defer { sqlite3_finalize(stmt) }
sqlite3_bind_text(stmt, 1, parentId, -1, sqliteTransient)
var sessions: [HermesSession] = []
while sqlite3_step(stmt) == SQLITE_ROW {
sessions.append(sessionFromRow(stmt!))
}
return sessions
}
// MARK: - Message Queries
private var messageColumns: String {
var cols = """
id, session_id, role, content, tool_call_id, tool_calls,
tool_name, timestamp, token_count, finish_reason
"""
if hasV07Schema {
cols += ", reasoning"
}
return cols
}
func fetchMessages(sessionId: String) -> [HermesMessage] {
guard let db else { return [] }
let sql = "SELECT \(messageColumns) FROM messages WHERE session_id = ? ORDER BY timestamp ASC"
var stmt: OpaquePointer?
guard sqlite3_prepare_v2(db, sql, -1, &stmt, nil) == SQLITE_OK else { return [] }
defer { sqlite3_finalize(stmt) }
sqlite3_bind_text(stmt, 1, sessionId, -1, sqliteTransient)
var messages: [HermesMessage] = []
while sqlite3_step(stmt) == SQLITE_ROW {
@@ -68,11 +256,15 @@ actor HermesDataService {
return messages
}
func searchMessages(query: String, limit: Int = 50) -> [HermesMessage] {
func searchMessages(query: String, limit: Int = QueryDefaults.messageSearchLimit) -> [HermesMessage] {
guard let db else { return [] }
let sanitized = sanitizeFTSQuery(query)
guard !sanitized.isEmpty else { return [] }
let msgCols = hasV07Schema
? "m.id, m.session_id, m.role, m.content, m.tool_call_id, m.tool_calls, m.tool_name, m.timestamp, m.token_count, m.finish_reason, m.reasoning"
: "m.id, m.session_id, m.role, m.content, m.tool_call_id, m.tool_calls, m.tool_name, m.timestamp, m.token_count, m.finish_reason"
let sql = """
SELECT m.id, m.session_id, m.role, m.content, m.tool_call_id, m.tool_calls,
m.tool_name, m.timestamp, m.token_count, m.finish_reason
SELECT \(msgCols)
FROM messages_fts fts
JOIN messages m ON m.id = fts.rowid
WHERE messages_fts MATCH ?
@@ -82,7 +274,7 @@ actor HermesDataService {
var stmt: OpaquePointer?
guard sqlite3_prepare_v2(db, sql, -1, &stmt, nil) == SQLITE_OK else { return [] }
defer { sqlite3_finalize(stmt) }
sqlite3_bind_text(stmt, 1, query, -1, unsafeBitCast(-1, to: sqlite3_destructor_type.self))
sqlite3_bind_text(stmt, 1, sanitized, -1, sqliteTransient)
sqlite3_bind_int(stmt, 2, Int32(limit))
var messages: [HermesMessage] = []
@@ -92,11 +284,21 @@ actor HermesDataService {
return messages
}
func fetchRecentToolCalls(limit: Int = 50) -> [HermesMessage] {
func fetchToolResult(callId: String) -> String? {
guard let db else { return nil }
let sql = "SELECT content FROM messages WHERE role = 'tool' AND tool_call_id = ? LIMIT 1"
var stmt: OpaquePointer?
guard sqlite3_prepare_v2(db, sql, -1, &stmt, nil) == SQLITE_OK else { return nil }
defer { sqlite3_finalize(stmt) }
sqlite3_bind_text(stmt, 1, callId, -1, sqliteTransient)
guard sqlite3_step(stmt) == SQLITE_ROW else { return nil }
return columnText(stmt!, 0)
}
func fetchRecentToolCalls(limit: Int = QueryDefaults.toolCallLimit) -> [HermesMessage] {
guard let db else { return [] }
let sql = """
SELECT id, session_id, role, content, tool_call_id, tool_calls,
tool_name, timestamp, token_count, finish_reason
SELECT \(messageColumns)
FROM messages
WHERE tool_calls IS NOT NULL AND tool_calls != '[]' AND tool_calls != ''
ORDER BY timestamp DESC
@@ -114,10 +316,10 @@ actor HermesDataService {
return messages
}
func fetchSessionPreviews(limit: Int = 10) -> [String: String] {
func fetchSessionPreviews(limit: Int = QueryDefaults.sessionPreviewLimit) -> [String: String] {
guard let db else { return [:] }
let sql = """
SELECT m.session_id, substr(m.content, 1, 100)
SELECT m.session_id, substr(m.content, 1, \(QueryDefaults.previewContentLength))
FROM messages m
INNER JOIN (
SELECT session_id, MIN(id) as min_id
@@ -142,6 +344,83 @@ actor HermesDataService {
return previews
}
// MARK: - Single-Row Queries
struct MessageFingerprint: Equatable, Sendable {
let count: Int
let maxId: Int
let maxTimestamp: Double
static let empty = MessageFingerprint(count: 0, maxId: 0, maxTimestamp: 0)
}
func fetchMessageFingerprint(sessionId: String) -> MessageFingerprint {
guard let db else { return .empty }
let sql = "SELECT COUNT(*), COALESCE(MAX(id), 0), COALESCE(MAX(timestamp), 0) FROM messages WHERE session_id = ?"
var stmt: OpaquePointer?
guard sqlite3_prepare_v2(db, sql, -1, &stmt, nil) == SQLITE_OK else { return .empty }
defer { sqlite3_finalize(stmt) }
sqlite3_bind_text(stmt, 1, sessionId, -1, sqliteTransient)
guard sqlite3_step(stmt) == SQLITE_ROW else { return .empty }
return MessageFingerprint(
count: Int(sqlite3_column_int(stmt, 0)),
maxId: Int(sqlite3_column_int(stmt, 1)),
maxTimestamp: sqlite3_column_double(stmt, 2)
)
}
func fetchMessageCount(sessionId: String) -> Int {
guard let db else { return 0 }
let sql = "SELECT COUNT(*) FROM messages WHERE session_id = ?"
var stmt: OpaquePointer?
guard sqlite3_prepare_v2(db, sql, -1, &stmt, nil) == SQLITE_OK else { return 0 }
defer { sqlite3_finalize(stmt) }
sqlite3_bind_text(stmt, 1, sessionId, -1, sqliteTransient)
guard sqlite3_step(stmt) == SQLITE_ROW else { return 0 }
return Int(sqlite3_column_int(stmt, 0))
}
func fetchSession(id: String) -> HermesSession? {
guard let db else { return nil }
let sql = "SELECT \(sessionColumns) FROM sessions WHERE id = ? LIMIT 1"
var stmt: OpaquePointer?
guard sqlite3_prepare_v2(db, sql, -1, &stmt, nil) == SQLITE_OK else { return nil }
defer { sqlite3_finalize(stmt) }
sqlite3_bind_text(stmt, 1, id, -1, sqliteTransient)
guard sqlite3_step(stmt) == SQLITE_ROW else { return nil }
return sessionFromRow(stmt!)
}
func fetchMostRecentlyActiveSessionId() -> String? {
guard let db else { return nil }
let sql = "SELECT session_id FROM messages ORDER BY timestamp DESC LIMIT 1"
var stmt: OpaquePointer?
guard sqlite3_prepare_v2(db, sql, -1, &stmt, nil) == SQLITE_OK else { return nil }
defer { sqlite3_finalize(stmt) }
guard sqlite3_step(stmt) == SQLITE_ROW else { return nil }
return columnText(stmt!, 0)
}
func fetchMostRecentlyStartedSessionId(after: Date? = nil) -> String? {
guard let db else { return nil }
let sql: String
if after != nil {
sql = "SELECT id FROM sessions WHERE parent_session_id IS NULL AND started_at > ? ORDER BY started_at DESC LIMIT 1"
} else {
sql = "SELECT id FROM sessions WHERE parent_session_id IS NULL ORDER BY started_at DESC LIMIT 1"
}
var stmt: OpaquePointer?
guard sqlite3_prepare_v2(db, sql, -1, &stmt, nil) == SQLITE_OK else { return nil }
defer { sqlite3_finalize(stmt) }
if let after {
sqlite3_bind_double(stmt, 1, after.timeIntervalSince1970)
}
guard sqlite3_step(stmt) == SQLITE_ROW else { return nil }
return columnText(stmt!, 0)
}
// MARK: - Stats
struct SessionStats: Sendable {
let totalSessions: Int
let totalMessages: Int
@@ -149,71 +428,59 @@ actor HermesDataService {
let totalInputTokens: Int
let totalOutputTokens: Int
let totalCostUSD: Double
let totalReasoningTokens: Int
let totalActualCostUSD: Double
static let empty = SessionStats(
totalSessions: 0, totalMessages: 0, totalToolCalls: 0,
totalInputTokens: 0, totalOutputTokens: 0, totalCostUSD: 0,
totalReasoningTokens: 0, totalActualCostUSD: 0
)
}
func fetchStats() -> SessionStats {
guard let db else {
return SessionStats(totalSessions: 0, totalMessages: 0, totalToolCalls: 0,
totalInputTokens: 0, totalOutputTokens: 0, totalCostUSD: 0)
guard let db else { return .empty }
let sql: String
if hasV07Schema {
sql = """
SELECT COUNT(*), COALESCE(SUM(message_count),0), COALESCE(SUM(tool_call_count),0),
COALESCE(SUM(input_tokens),0), COALESCE(SUM(output_tokens),0),
COALESCE(SUM(estimated_cost_usd),0),
COALESCE(SUM(reasoning_tokens),0), COALESCE(SUM(actual_cost_usd),0)
FROM sessions
"""
} else {
sql = """
SELECT COUNT(*), COALESCE(SUM(message_count),0), COALESCE(SUM(tool_call_count),0),
COALESCE(SUM(input_tokens),0), COALESCE(SUM(output_tokens),0),
COALESCE(SUM(estimated_cost_usd),0)
FROM sessions
"""
}
let sql = """
SELECT COUNT(*), COALESCE(SUM(message_count),0), COALESCE(SUM(tool_call_count),0),
COALESCE(SUM(input_tokens),0), COALESCE(SUM(output_tokens),0),
COALESCE(SUM(estimated_cost_usd),0)
FROM sessions
"""
var stmt: OpaquePointer?
guard sqlite3_prepare_v2(db, sql, -1, &stmt, nil) == SQLITE_OK else {
return SessionStats(totalSessions: 0, totalMessages: 0, totalToolCalls: 0,
totalInputTokens: 0, totalOutputTokens: 0, totalCostUSD: 0)
}
guard sqlite3_prepare_v2(db, sql, -1, &stmt, nil) == SQLITE_OK else { return .empty }
defer { sqlite3_finalize(stmt) }
guard sqlite3_step(stmt) == SQLITE_ROW else {
return SessionStats(totalSessions: 0, totalMessages: 0, totalToolCalls: 0,
totalInputTokens: 0, totalOutputTokens: 0, totalCostUSD: 0)
}
guard sqlite3_step(stmt) == SQLITE_ROW else { return .empty }
return SessionStats(
totalSessions: Int(sqlite3_column_int(stmt, 0)),
totalMessages: Int(sqlite3_column_int(stmt, 1)),
totalToolCalls: Int(sqlite3_column_int(stmt, 2)),
totalInputTokens: Int(sqlite3_column_int(stmt, 3)),
totalOutputTokens: Int(sqlite3_column_int(stmt, 4)),
totalCostUSD: sqlite3_column_double(stmt, 5)
totalCostUSD: sqlite3_column_double(stmt, 5),
totalReasoningTokens: hasV07Schema ? Int(sqlite3_column_int(stmt, 6)) : 0,
totalActualCostUSD: hasV07Schema ? sqlite3_column_double(stmt, 7) : 0
)
}
// MARK: - Insights Queries
func fetchSessionsInPeriod(since: Date) -> [HermesSession] {
guard let db else { return [] }
let sql = """
SELECT id, source, user_id, model, title, parent_session_id,
started_at, ended_at, end_reason, message_count, tool_call_count,
input_tokens, output_tokens, cache_read_tokens, cache_write_tokens,
estimated_cost_usd
FROM sessions
WHERE started_at >= ?
ORDER BY started_at DESC
"""
var stmt: OpaquePointer?
guard sqlite3_prepare_v2(db, sql, -1, &stmt, nil) == SQLITE_OK else { return [] }
defer { sqlite3_finalize(stmt) }
sqlite3_bind_double(stmt, 1, since.timeIntervalSince1970)
var sessions: [HermesSession] = []
while sqlite3_step(stmt) == SQLITE_ROW {
sessions.append(sessionFromRow(stmt!))
}
return sessions
}
func fetchUserMessageCount(since: Date) -> Int {
guard let db else { return 0 }
let sql = """
SELECT COUNT(*) FROM messages m
JOIN sessions s ON m.session_id = s.id
WHERE m.role = 'user' AND s.started_at >= ?
WHERE m.role = 'user' AND s.parent_session_id IS NULL AND s.started_at >= ?
"""
var stmt: OpaquePointer?
guard sqlite3_prepare_v2(db, sql, -1, &stmt, nil) == SQLITE_OK else { return 0 }
@@ -229,7 +496,7 @@ actor HermesDataService {
SELECT m.tool_name, COUNT(*) as cnt
FROM messages m
JOIN sessions s ON m.session_id = s.id
WHERE m.tool_name IS NOT NULL AND m.tool_name <> '' AND s.started_at >= ?
WHERE m.tool_name IS NOT NULL AND m.tool_name <> '' AND s.parent_session_id IS NULL AND s.started_at >= ?
GROUP BY m.tool_name
ORDER BY cnt DESC
"""
@@ -250,7 +517,7 @@ actor HermesDataService {
func fetchSessionStartHours(since: Date) -> [Int: Int] {
guard let db else { return [:] }
let sql = """
SELECT started_at FROM sessions WHERE started_at >= ?
SELECT started_at FROM sessions WHERE parent_session_id IS NULL AND started_at >= ?
"""
var stmt: OpaquePointer?
guard sqlite3_prepare_v2(db, sql, -1, &stmt, nil) == SQLITE_OK else { return [:] }
@@ -271,7 +538,7 @@ actor HermesDataService {
func fetchSessionDaysOfWeek(since: Date) -> [Int: Int] {
guard let db else { return [:] }
let sql = """
SELECT started_at FROM sessions WHERE started_at >= ?
SELECT started_at FROM sessions WHERE parent_session_id IS NULL AND started_at >= ?
"""
var stmt: OpaquePointer?
guard sqlite3_prepare_v2(db, sql, -1, &stmt, nil) == SQLITE_OK else { return [:] }
@@ -290,11 +557,10 @@ actor HermesDataService {
}
func stateDBModificationDate() -> Date? {
let walPath = HermesPaths.stateDB + "-wal"
let dbPath = HermesPaths.stateDB
let fm = FileManager.default
let walDate = (try? fm.attributesOfItem(atPath: walPath))?[.modificationDate] as? Date
let dbDate = (try? fm.attributesOfItem(atPath: dbPath))?[.modificationDate] as? Date
// For remote contexts we stat the remote paths. For local it's the
// same FileManager lookup as before, just via the transport.
let walDate = transport.stat(context.paths.stateDB + "-wal")?.mtime
let dbDate = transport.stat(context.paths.stateDB)?.mtime
if let w = walDate, let d = dbDate {
return max(w, d)
}
@@ -320,7 +586,11 @@ actor HermesDataService {
outputTokens: Int(sqlite3_column_int(stmt, 12)),
cacheReadTokens: Int(sqlite3_column_int(stmt, 13)),
cacheWriteTokens: Int(sqlite3_column_int(stmt, 14)),
estimatedCostUSD: sqlite3_column_type(stmt, 15) != SQLITE_NULL ? sqlite3_column_double(stmt, 15) : nil
estimatedCostUSD: sqlite3_column_type(stmt, 15) != SQLITE_NULL ? sqlite3_column_double(stmt, 15) : nil,
reasoningTokens: hasV07Schema ? Int(sqlite3_column_int(stmt, 16)) : 0,
actualCostUSD: hasV07Schema && sqlite3_column_type(stmt, 17) != SQLITE_NULL ? sqlite3_column_double(stmt, 17) : nil,
costStatus: hasV07Schema ? columnOptionalText(stmt, 18) : nil,
billingProvider: hasV07Schema ? columnOptionalText(stmt, 19) : nil
)
}
@@ -337,14 +607,20 @@ actor HermesDataService {
toolName: columnOptionalText(stmt, 6),
timestamp: columnDate(stmt, 7),
tokenCount: sqlite3_column_type(stmt, 8) != SQLITE_NULL ? Int(sqlite3_column_int(stmt, 8)) : nil,
finishReason: columnOptionalText(stmt, 9)
finishReason: columnOptionalText(stmt, 9),
reasoning: hasV07Schema ? columnOptionalText(stmt, 10) : nil
)
}
private func parseToolCalls(_ json: String?) -> [HermesToolCall] {
guard let json, !json.isEmpty,
let data = json.data(using: .utf8) else { return [] }
return (try? JSONDecoder().decode([HermesToolCall].self, from: data)) ?? []
do {
return try JSONDecoder().decode([HermesToolCall].self, from: data)
} catch {
print("[Scarf] Failed to decode tool calls: \(error.localizedDescription)")
return []
}
}
private func columnText(_ stmt: OpaquePointer, _ col: Int32) -> String {
@@ -365,4 +641,17 @@ actor HermesDataService {
let value = sqlite3_column_double(stmt, col)
return Date(timeIntervalSince1970: value)
}
/// Wraps each whitespace-delimited token in double quotes to prevent FTS5 parse errors
/// on terms containing dots, hyphens, or FTS5 operators (e.g., "v0.7.0", "config.yaml").
private func sanitizeFTSQuery(_ raw: String) -> String {
raw.split(separator: " ")
.map { token in
let t = String(token)
let stripped = t.replacingOccurrences(of: "\"", with: "")
return stripped.isEmpty ? nil : "\"\(stripped)\""
}
.compactMap { $0 }
.joined(separator: " ")
}
}
@@ -0,0 +1,216 @@
import Foundation
import os
/// Read/write `~/.hermes/.env` while preserving comments, blank lines, and the
/// ordering of keys we don't touch.
///
/// Hermes treats `.env` as a traditional dotenv file: `KEY=value`, `#` comments,
/// and optional double-quoted values for strings with spaces or special chars.
/// We do NOT attempt to implement full shell-style escaping; the fields we write
/// from the GUI are bot tokens, user IDs, URLs, and on/off flags none of which
/// contain characters needing escaping beyond double-quoting.
///
/// Design choices:
/// - **Non-destructive "unset"**: clearing a field comments the line out rather
/// than deleting it, so users can restore a key by uncommenting without losing
/// their value.
/// - **Atomic write**: write to `.env.tmp`, then rename. Avoids a partially
/// written file if Scarf crashes mid-write.
/// - **Never logs values**: secrets flow through this service.
struct HermesEnvService: Sendable {
private let logger = Logger(subsystem: "com.scarf", category: "HermesEnvService")
/// Path to `~/.hermes/.env`. Kept configurable for tests.
let path: String
let transport: any ServerTransport
nonisolated init(context: ServerContext = .local) {
self.path = context.paths.envFile
self.transport = context.makeTransport()
}
/// Escape hatch for tests that want to point at a fixture path directly.
init(path: String) {
self.path = path
self.transport = LocalTransport()
}
/// Read the .env file into a `[key: value]` dict. Comments and commented-out
/// assignments are ignored. Missing file returns an empty dict.
func load() -> [String: String] {
guard let data = try? transport.readFile(path),
let content = String(data: data, encoding: .utf8) else {
return [:]
}
var result: [String: String] = [:]
for line in content.components(separatedBy: "\n") {
let trimmed = line.trimmingCharacters(in: .whitespaces)
// Skip blanks and comments. A line beginning with `#` is either a pure
// comment or a disabled assignment both should be treated as "unset".
if trimmed.isEmpty || trimmed.hasPrefix("#") { continue }
guard let eq = trimmed.firstIndex(of: "=") else { continue }
let key = String(trimmed[trimmed.startIndex..<eq]).trimmingCharacters(in: .whitespaces)
let raw = String(trimmed[trimmed.index(after: eq)...]).trimmingCharacters(in: .whitespaces)
result[key] = Self.stripEnvQuotes(raw)
}
return result
}
func get(_ key: String) -> String? {
load()[key]
}
/// Write/update a single key. Preserves the position of existing assignments
/// (even if they were commented out the new assignment replaces the comment
/// line in place). New keys are appended at the end.
@discardableResult
func set(_ key: String, value: String) -> Bool {
setMany([key: value])
}
/// Update multiple keys in one atomic rewrite. Use this when a form saves
/// several fields at once so the file doesn't get repeatedly rewritten.
///
/// Returns `true` on success, `false` if the atomic rewrite failed.
@discardableResult
func setMany(_ pairs: [String: String]) -> Bool {
var remaining = pairs
var lines: [String]
// Start from existing file contents, or a minimal header if creating new.
if let data = try? transport.readFile(path),
let content = String(data: data, encoding: .utf8) {
lines = content.components(separatedBy: "\n")
// Trim a single trailing empty line from splitting the final newline;
// we'll re-add it on write.
if lines.last == "" { lines.removeLast() }
} else {
lines = ["# Hermes Agent Environment Configuration"]
}
// First pass: update in-place (handles both live and commented-out lines).
for (idx, line) in lines.enumerated() {
guard let match = Self.extractKey(fromLine: line) else { continue }
if let newValue = remaining.removeValue(forKey: match.key) {
// A commented-out `# KEY=...` becomes a live `KEY=...` with the new value.
lines[idx] = Self.formatLine(key: match.key, value: newValue)
}
}
// Second pass: append any keys that didn't match an existing line.
if !remaining.isEmpty {
// Leave a blank line before appending new keys for visual separation.
if let last = lines.last, !last.isEmpty {
lines.append("")
}
for key in remaining.keys.sorted() {
lines.append(Self.formatLine(key: key, value: remaining[key]!))
}
}
return atomicWrite(lines.joined(separator: "\n") + "\n")
}
/// Comment out a key. The value is preserved so the user can restore by
/// uncommenting. If the key doesn't exist, this is a no-op.
@discardableResult
func unset(_ key: String) -> Bool {
guard let data = try? transport.readFile(path),
let content = String(data: data, encoding: .utf8) else {
return true
}
var lines = content.components(separatedBy: "\n")
if lines.last == "" { lines.removeLast() }
var changed = false
for (idx, line) in lines.enumerated() {
guard let match = Self.extractKey(fromLine: line), match.key == key else { continue }
// Skip lines that are already commented nothing to do.
if Self.isCommentedOutAssignment(line) { continue }
lines[idx] = "# " + line
changed = true
}
guard changed else { return true }
return atomicWrite(lines.joined(separator: "\n") + "\n")
}
// MARK: - Internals
/// Writes the entire file in one shot through the transport. For local
/// contexts this ends up doing the same atomic-rename dance as before
/// (via `LocalTransport.writeFile`). For remote contexts this goes
/// through `scp` + remote `mv`, still atomic from Hermes's point of
/// view.
private func atomicWrite(_ content: String) -> Bool {
guard let data = content.data(using: .utf8) else { return false }
do {
try transport.writeFile(path, data: data)
return true
} catch {
logger.error("Failed to write .env: \(error.localizedDescription)")
return false
}
}
/// Extract a key name and whether the line was active or commented-out.
/// Accepts both `KEY=value` and `# KEY=value` (any amount of whitespace after `#`).
private static func extractKey(fromLine line: String) -> (key: String, active: Bool)? {
var work = line.trimmingCharacters(in: .whitespaces)
var active = true
if work.hasPrefix("#") {
active = false
work = String(work.dropFirst()).trimmingCharacters(in: .whitespaces)
}
guard let eq = work.firstIndex(of: "=") else { return nil }
let key = String(work[work.startIndex..<eq]).trimmingCharacters(in: .whitespaces)
// Reject non-identifier looking keys to avoid matching prose in comments
// (e.g. "# This is a note about something = nice").
guard key.range(of: "^[A-Za-z_][A-Za-z0-9_]*$", options: .regularExpression) != nil else {
return nil
}
return (key, active)
}
private static func isCommentedOutAssignment(_ line: String) -> Bool {
guard let match = extractKey(fromLine: line) else { return false }
return !match.active
}
/// Format a single `KEY=value` line. Values containing whitespace or shell
/// metacharacters get double-quoted; simple tokens go in unquoted to match
/// hermes's own output style.
private static func formatLine(key: String, value: String) -> String {
if Self.needsQuoting(value) {
// Escape embedded backslashes and double quotes, then wrap.
let escaped = value
.replacingOccurrences(of: "\\", with: "\\\\")
.replacingOccurrences(of: "\"", with: "\\\"")
return "\(key)=\"\(escaped)\""
}
return "\(key)=\(value)"
}
private static func needsQuoting(_ value: String) -> Bool {
if value.isEmpty { return false }
// Whitespace, shell metacharacters, or quotes trigger quoting.
let metacharacters: Set<Character> = [" ", "\t", "#", "$", "`", "\"", "'", "\\", "(", ")", "{", "}", "[", "]", "|", "&", ";", "<", ">", "*", "?"]
return value.contains(where: { metacharacters.contains($0) })
}
/// Strip one layer of matched double or single quotes from a loaded value.
private static func stripEnvQuotes(_ s: String) -> String {
guard s.count >= 2 else { return s }
let first = s.first!
let last = s.last!
if (first == "\"" && last == "\"") || (first == "'" && last == "'") {
var inner = String(s.dropFirst().dropLast())
if first == "\"" {
inner = inner
.replacingOccurrences(of: "\\\"", with: "\"")
.replacingOccurrences(of: "\\\\", with: "\\")
}
return inner
}
return s
}
}
File diff suppressed because it is too large Load Diff
@@ -3,43 +3,112 @@ import Foundation
@Observable
final class HermesFileWatcher {
private(set) var lastChangeDate = Date()
private var sources: [DispatchSourceFileSystemObject] = []
private var coreSources: [DispatchSourceFileSystemObject] = []
private var projectSources: [DispatchSourceFileSystemObject] = []
private var timer: Timer?
/// Remote polling task. Non-nil only when `context.isRemote`. Cancelled
/// on `stopWatching()`.
private var remotePollTask: Task<Void, Never>?
let context: ServerContext
private let transport: any ServerTransport
nonisolated init(context: ServerContext = .local) {
self.context = context
self.transport = context.makeTransport()
}
/// Canonical list of paths we observe. Used for both FSEvents (local)
/// and mtime polling (remote).
private var watchedCorePaths: [String] {
let paths = context.paths
return [
paths.stateDB,
paths.stateDB + "-wal",
paths.configYAML,
paths.home + "/.env",
paths.memoryMD,
paths.userMD,
paths.cronJobsJSON,
paths.gatewayStateJSON,
paths.agentLog,
paths.errorsLog,
paths.gatewayLog,
paths.projectsRegistry,
// v2.3: sidecar attributing Hermes session IDs to Scarf project
// paths. Written by SessionAttributionService when a chat
// starts with a project context; read by
// ProjectSessionsViewModel to filter the session list. Without
// watching this file, the per-project Sessions tab would only
// pick up new sessions when the user re-entered the tab
// (triggering .task(id:) re-fire) switching directly back
// to the project's Sessions tab after a chat left the tab
// stale.
paths.sessionProjectMap,
paths.mcpTokensDir
]
}
func startWatching() {
let paths = [
HermesPaths.stateDB,
HermesPaths.stateDB + "-wal",
HermesPaths.configYAML,
HermesPaths.memoryMD,
HermesPaths.userMD,
HermesPaths.cronJobsJSON,
HermesPaths.gatewayStateJSON,
HermesPaths.errorsLog,
HermesPaths.gatewayLog
]
for path in paths {
watchFile(path)
if context.isRemote {
// FSEvents doesn't reach across SSH. Drive lastChangeDate off
// the transport's AsyncStream, which polls stat mtime on a
// shared ControlMaster channel (~5ms per tick).
let stream = transport.watchPaths(watchedCorePaths)
remotePollTask = Task { [weak self] in
for await _ in stream {
await MainActor.run { [weak self] in
self?.lastChangeDate = Date()
}
}
}
return
}
timer = Timer.scheduledTimer(withTimeInterval: 5.0, repeats: true) { [weak self] _ in
self?.lastChangeDate = Date()
for path in watchedCorePaths {
if let source = makeSource(for: path) {
coreSources.append(source)
}
}
// No heartbeat timer: every observing view runs its `.onChange`
// refresh whenever `lastChangeDate` ticks, so a 5s unconditional
// tick was triggering wasted reloads across many subscribers
// (Dashboard, Memory, Cron, Gateway, Platforms, Projects, Chat).
// FSEvents reliably fires on real changes; menu-bar Start/Stop
// touches `gateway_state.json` which the watcher catches.
}
func stopWatching() {
for source in sources {
for source in coreSources + projectSources {
source.cancel()
}
sources.removeAll()
coreSources.removeAll()
projectSources.removeAll()
timer?.invalidate()
timer = nil
remotePollTask?.cancel()
remotePollTask = nil
}
private func watchFile(_ path: String) {
func updateProjectWatches(_ dashboardPaths: [String]) {
// Remote contexts don't support per-project FSEvents watches today
// the shared mtime poll covers the core set. Adding per-project
// polling is a Phase 4 polish item.
guard !context.isRemote else { return }
for source in projectSources {
source.cancel()
}
projectSources.removeAll()
for path in dashboardPaths {
if let source = makeSource(for: path) {
projectSources.append(source)
}
}
}
private func makeSource(for path: String) -> DispatchSourceFileSystemObject? {
let fd = Darwin.open(path, O_EVTONLY)
guard fd >= 0 else { return }
guard fd >= 0 else { return nil }
let source = DispatchSource.makeFileSystemObjectSource(
fileDescriptor: fd,
@@ -53,7 +122,7 @@ final class HermesFileWatcher {
Darwin.close(fd)
}
source.resume()
sources.append(source)
return source
}
deinit {
@@ -4,6 +4,7 @@ struct LogEntry: Identifiable, Sendable {
let id: Int
let timestamp: String
let level: LogLevel
let sessionId: String?
let logger: String
let message: String
let raw: String
@@ -32,21 +33,79 @@ actor HermesLogService {
private var currentPath: String?
private var entryCounter = 0
/// Remote tailing state. When set, we're reading from `ssh host tail -F`
/// instead of a local file. Process stdout pipe drives `readNewLines()`;
/// process lifecycle is the actor's responsibility.
private var remoteTailProcess: Process?
private var remoteTailBuffer: String = ""
let context: ServerContext
private let transport: any ServerTransport
init(context: ServerContext = .local) {
self.context = context
self.transport = context.makeTransport()
}
func openLog(path: String) {
closeLog()
currentPath = path
fileHandle = FileHandle(forReadingAtPath: path)
if context.isRemote {
// Spawn `ssh host tail -F` and pipe stdout into our buffer. `-F`
// follows the file through rotations important for remote
// log rotation setups (logrotate).
let proc = transport.makeProcess(
executable: "/usr/bin/tail",
args: ["-n", String(QueryDefaults.logLineLimit), "-F", path]
)
let outPipe = Pipe()
proc.standardOutput = outPipe
proc.standardError = Pipe()
do {
try proc.run()
remoteTailProcess = proc
fileHandle = outPipe.fileHandleForReading
} catch {
print("[Scarf] Failed to start remote tail: \(error.localizedDescription)")
remoteTailProcess = nil
fileHandle = nil
}
} else {
fileHandle = FileHandle(forReadingAtPath: path)
}
}
func closeLog() {
try? fileHandle?.close()
do {
try fileHandle?.close()
} catch {
print("[Scarf] Failed to close log handle: \(error.localizedDescription)")
}
fileHandle = nil
currentPath = nil
if let proc = remoteTailProcess, proc.isRunning {
proc.terminate()
}
remoteTailProcess = nil
remoteTailBuffer = ""
}
func readLastLines(count: Int = 200) -> [LogEntry] {
guard let path = currentPath,
let data = FileManager.default.contents(atPath: path) else { return [] }
func readLastLines(count: Int = QueryDefaults.logLineLimit) -> [LogEntry] {
guard let path = currentPath else { return [] }
if context.isRemote {
// For the initial load we bypass the streaming tail and run a
// one-shot `tail -n <count>` for a clean bounded read.
let result = try? transport.runProcess(
executable: "/usr/bin/tail",
args: ["-n", String(count), path],
stdin: nil,
timeout: 30
)
let content = result?.stdoutString ?? ""
let lines = content.components(separatedBy: "\n").filter { !$0.isEmpty }
return lines.map { parseLine($0) }
}
guard let data = FileManager.default.contents(atPath: path) else { return [] }
let content = String(data: data, encoding: .utf8) ?? ""
let lines = content.components(separatedBy: "\n").filter { !$0.isEmpty }
let lastLines = Array(lines.suffix(count))
@@ -57,34 +116,57 @@ actor HermesLogService {
guard let handle = fileHandle else { return [] }
let data = handle.availableData
guard !data.isEmpty else { return [] }
let content = String(data: data, encoding: .utf8) ?? ""
let lines = content.components(separatedBy: "\n").filter { !$0.isEmpty }
let chunk = String(data: data, encoding: .utf8) ?? ""
if context.isRemote {
// Remote tail emits bytes as they arrive not line-aligned.
// Buffer partials across reads so we don't split a line mid-way.
remoteTailBuffer += chunk
guard let lastNewline = remoteTailBuffer.lastIndex(of: "\n") else {
return []
}
let complete = String(remoteTailBuffer[..<lastNewline])
remoteTailBuffer = String(remoteTailBuffer[remoteTailBuffer.index(after: lastNewline)...])
let lines = complete.components(separatedBy: "\n").filter { !$0.isEmpty }
return lines.map { parseLine($0) }
}
let lines = chunk.components(separatedBy: "\n").filter { !$0.isEmpty }
return lines.map { parseLine($0) }
}
func seekToEnd() {
fileHandle?.seekToEndOfFile()
// Only meaningful for local FileHandles remote tail starts at the
// end implicitly after `readLastLines` drained the initial load.
if !context.isRemote {
fileHandle?.seekToEndOfFile()
}
}
private func parseLine(_ line: String) -> LogEntry {
entryCounter += 1
// Format: YYYY-MM-DD HH:MM:SS,MMM LEVEL logger: message
let pattern = #"^(\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2},\d{3})\s+(DEBUG|INFO|WARNING|ERROR|CRITICAL)\s+(\S+?):\s+(.*)$"#
// Format (v0.9.0+): YYYY-MM-DD HH:MM:SS,MMM LEVEL [session_id] logger: message
// Session tag is optional earlier Hermes releases and out-of-session lines omit it.
let pattern = #"^(\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2},\d{3})\s+(DEBUG|INFO|WARNING|ERROR|CRITICAL)\s+(?:\[([^\]]+)\]\s+)?(\S+?):\s+(.*)$"#
if let regex = try? NSRegularExpression(pattern: pattern),
let match = regex.firstMatch(in: line, range: NSRange(line.startIndex..., in: line)) {
let timestamp = String(line[Range(match.range(at: 1), in: line)!])
let levelStr = String(line[Range(match.range(at: 2), in: line)!])
let logger = String(line[Range(match.range(at: 3), in: line)!])
let message = String(line[Range(match.range(at: 4), in: line)!])
let sessionId: String? = {
let range = match.range(at: 3)
guard range.location != NSNotFound, let r = Range(range, in: line) else { return nil }
return String(line[r])
}()
let logger = String(line[Range(match.range(at: 4), in: line)!])
let message = String(line[Range(match.range(at: 5), in: line)!])
return LogEntry(
id: entryCounter,
timestamp: timestamp,
level: LogEntry.LogLevel(rawValue: levelStr) ?? .info,
sessionId: sessionId,
logger: logger,
message: message,
raw: line
)
}
return LogEntry(id: entryCounter, timestamp: "", level: .info, logger: "", message: line, raw: line)
return LogEntry(id: entryCounter, timestamp: "", level: .info, sessionId: nil, logger: "", message: line, raw: line)
}
}
@@ -0,0 +1,361 @@
import Foundation
import os
/// A single model from the models.dev catalog shipped with hermes.
struct HermesModelInfo: Sendable, Identifiable, Hashable {
var id: String { providerID + ":" + modelID }
let providerID: String
let providerName: String
let modelID: String
let modelName: String
let contextWindow: Int?
let maxOutput: Int?
let costInput: Double? // USD per 1M input tokens
let costOutput: Double? // USD per 1M output tokens
let reasoning: Bool
let toolCall: Bool
let releaseDate: String?
/// Display-friendly cost string, or nil if cost is unknown.
var costDisplay: String? {
guard let input = costInput, let output = costOutput else { return nil }
let currency = FloatingPointFormatStyle<Double>.Currency.currency(code: "USD").precision(.fractionLength(2))
return "\(input.formatted(currency)) / \(output.formatted(currency))"
}
/// Display-friendly context window ("200K", "1M", etc.).
var contextDisplay: String? {
guard let ctx = contextWindow else { return nil }
if ctx >= 1_000_000 { return "\(ctx / 1_000_000)M" }
if ctx >= 1_000 { return "\(ctx / 1_000)K" }
return "\(ctx)"
}
}
/// Provider summary one row in the left column of the picker.
struct HermesProviderInfo: Sendable, Identifiable, Hashable {
var id: String { providerID }
let providerID: String
let providerName: String
let envVars: [String] // e.g. ["ANTHROPIC_API_KEY"]
let docURL: String?
let modelCount: Int
/// True when this provider is surfaced only by the Hermes overlay list
/// i.e. it has no entry in `models_dev_cache.json` and therefore no model
/// list from models.dev. The picker renders a different right-column
/// affordance in this case (subscription CTA or free-form model entry).
let isOverlay: Bool
/// True for providers whose tool access is gated on an active subscription
/// rather than a BYO API key. Nous Portal is the only such provider as of
/// hermes-agent v0.10.0.
let subscriptionGated: Bool
}
/// Reads the models.dev catalog that hermes caches at
/// `~/.hermes/models_dev_cache.json`. Offline-capable, fast enough to read per
/// call (~1500 models across ~110 providers).
///
/// We decode a trimmed subset so unknown fields don't break loading. Every
/// field we care about is optional on disk providers may omit cost, context
/// limits, etc.
struct ModelCatalogService: Sendable {
private let logger = Logger(subsystem: "com.scarf", category: "ModelCatalogService")
let path: String
let transport: any ServerTransport
nonisolated init(context: ServerContext = .local) {
self.path = context.paths.home + "/models_dev_cache.json"
self.transport = context.makeTransport()
}
/// Escape hatch for tests.
init(path: String) {
self.path = path
self.transport = LocalTransport()
}
/// All providers, sorted with subscription-gated providers first (Nous
/// Portal), then alphabetical by display name.
///
/// Merges two data sources:
/// 1. `~/.hermes/models_dev_cache.json` the models.dev mirror.
/// 2. ``Self/overlayOnlyProviders`` Hermes-injected providers that
/// aren't in the models.dev catalog (e.g. Nous Portal, OpenAI Codex).
/// Without this merge, those providers are invisible in Scarf's picker
/// even though `hermes model` on the CLI can reach them.
func loadProviders() -> [HermesProviderInfo] {
let catalog = loadCatalog() ?? [:]
var byID: [String: HermesProviderInfo] = [:]
for (id, p) in catalog {
byID[id] = HermesProviderInfo(
providerID: id,
providerName: p.name ?? id,
envVars: p.env ?? [],
docURL: p.doc,
modelCount: p.models?.count ?? 0,
isOverlay: false,
subscriptionGated: false
)
}
for (id, overlay) in Self.overlayOnlyProviders where byID[id] == nil {
byID[id] = HermesProviderInfo(
providerID: id,
providerName: overlay.displayName,
envVars: [],
docURL: overlay.docURL,
modelCount: 0,
isOverlay: true,
subscriptionGated: overlay.subscriptionGated
)
}
return byID.values.sorted { lhs, rhs in
if lhs.subscriptionGated != rhs.subscriptionGated {
return lhs.subscriptionGated
}
return lhs.providerName.localizedCaseInsensitiveCompare(rhs.providerName) == .orderedAscending
}
}
/// Overlay metadata for a provider that isn't in the models.dev catalog
/// Scarf needs to surface these so the picker matches `hermes model` on
/// the CLI.
func overlayMetadata(for providerID: String) -> HermesProviderOverlay? {
Self.overlayOnlyProviders[providerID]
}
/// Models for one provider, sorted by release date (newest first), then name.
func loadModels(for providerID: String) -> [HermesModelInfo] {
guard let catalog = loadCatalog(), let provider = catalog[providerID] else { return [] }
let providerName = provider.name ?? providerID
let models = (provider.models ?? [:]).map { (id, m) in
HermesModelInfo(
providerID: providerID,
providerName: providerName,
modelID: id,
modelName: m.name ?? id,
contextWindow: m.limit?.context,
maxOutput: m.limit?.output,
costInput: m.cost?.input,
costOutput: m.cost?.output,
reasoning: m.reasoning ?? false,
toolCall: m.tool_call ?? false,
releaseDate: m.release_date
)
}
return models.sorted { lhs, rhs in
// Newest-first by release date if both are known; otherwise fall
// back to alphabetical on display name.
if let lDate = lhs.releaseDate, let rDate = rhs.releaseDate, lDate != rDate {
return lDate > rDate
}
return lhs.modelName.localizedCaseInsensitiveCompare(rhs.modelName) == .orderedAscending
}
}
/// Find the provider that ships a given model ID. Useful for auto-syncing
/// provider when the user picks a model from a flat list or types one in.
func provider(for modelID: String) -> HermesProviderInfo? {
guard let catalog = loadCatalog() else { return nil }
for (providerID, p) in catalog {
if p.models?[modelID] != nil {
return HermesProviderInfo(
providerID: providerID,
providerName: p.name ?? providerID,
envVars: p.env ?? [],
docURL: p.doc,
modelCount: p.models?.count ?? 0,
isOverlay: false,
subscriptionGated: false
)
}
}
// Handle provider-prefixed IDs like "openai/gpt-4o" look up the
// prefix before the slash.
if let slash = modelID.firstIndex(of: "/") {
let prefix = String(modelID[modelID.startIndex..<slash])
if let p = catalog[prefix] {
return HermesProviderInfo(
providerID: prefix,
providerName: p.name ?? prefix,
envVars: p.env ?? [],
docURL: p.doc,
modelCount: p.models?.count ?? 0,
isOverlay: false,
subscriptionGated: false
)
}
}
return nil
}
/// Look up a provider by ID, falling back to overlays when the cache has
/// no entry. Use this when resolving a stored `model.provider` to display
/// metadata `nous` and other overlay-only IDs never appear in the
/// cache, so a plain catalog lookup returns nil for them.
func providerByID(_ providerID: String) -> HermesProviderInfo? {
if let catalog = loadCatalog(), let p = catalog[providerID] {
return HermesProviderInfo(
providerID: providerID,
providerName: p.name ?? providerID,
envVars: p.env ?? [],
docURL: p.doc,
modelCount: p.models?.count ?? 0,
isOverlay: false,
subscriptionGated: false
)
}
if let overlay = Self.overlayOnlyProviders[providerID] {
return HermesProviderInfo(
providerID: providerID,
providerName: overlay.displayName,
envVars: [],
docURL: overlay.docURL,
modelCount: 0,
isOverlay: true,
subscriptionGated: overlay.subscriptionGated
)
}
return nil
}
/// Look up a specific model by provider + ID. Returns nil if not in the
/// catalog (e.g., free-typed custom model).
func model(providerID: String, modelID: String) -> HermesModelInfo? {
guard let catalog = loadCatalog(),
let provider = catalog[providerID],
let raw = provider.models?[modelID] else { return nil }
return HermesModelInfo(
providerID: providerID,
providerName: provider.name ?? providerID,
modelID: modelID,
modelName: raw.name ?? modelID,
contextWindow: raw.limit?.context,
maxOutput: raw.limit?.output,
costInput: raw.cost?.input,
costOutput: raw.cost?.output,
reasoning: raw.reasoning ?? false,
toolCall: raw.tool_call ?? false,
releaseDate: raw.release_date
)
}
// MARK: - Decoding
private func loadCatalog() -> [String: ProviderEntry]? {
guard let data = try? transport.readFile(path) else {
return nil
}
do {
return try JSONDecoder().decode([String: ProviderEntry].self, from: data)
} catch {
logger.error("Failed to decode models_dev_cache.json: \(error.localizedDescription)")
return nil
}
}
// Trimmed representations we decode a subset of fields and tolerate
// anything new hermes adds later. `snake_case` field names match the file.
private struct ProviderEntry: Decodable {
let id: String?
let name: String?
let env: [String]?
let doc: String?
let models: [String: ModelEntry]?
}
private struct ModelEntry: Decodable {
let name: String?
let reasoning: Bool?
let tool_call: Bool?
let release_date: String?
let cost: CostEntry?
let limit: LimitEntry?
}
private struct CostEntry: Decodable {
let input: Double?
let output: Double?
}
private struct LimitEntry: Decodable {
let context: Int?
let output: Int?
}
// MARK: - Hermes overlay providers
/// The six providers Hermes surfaces via `hermes model` that have no
/// entry in `models_dev_cache.json` (models.dev doesn't mirror them).
/// Mirrors the overlay-only subset of `HERMES_OVERLAYS` in
/// `hermes-agent/hermes_cli/providers.py`. The other ~19 overlay entries
/// already ship in the cache and only add augmentation (base-URL
/// override, extra env vars) that Scarf doesn't currently display.
///
/// Keep this in sync with the Python side on Hermes version bumps.
static let overlayOnlyProviders: [String: HermesProviderOverlay] = [
"nous": HermesProviderOverlay(
displayName: "Nous Portal",
baseURL: "https://inference-api.nousresearch.com/v1",
authType: .oauthDeviceCode,
subscriptionGated: true,
docURL: "https://hermes-agent.nousresearch.com/docs/user-guide/setup/nous-portal"
),
"openai-codex": HermesProviderOverlay(
displayName: "OpenAI Codex",
baseURL: "https://chatgpt.com/backend-api/codex",
authType: .oauthExternal,
subscriptionGated: false,
docURL: nil
),
"qwen-oauth": HermesProviderOverlay(
displayName: "Qwen (OAuth)",
baseURL: "https://portal.qwen.ai/v1",
authType: .oauthExternal,
subscriptionGated: false,
docURL: nil
),
"google-gemini-cli": HermesProviderOverlay(
displayName: "Google Gemini CLI",
baseURL: "cloudcode-pa://google",
authType: .oauthExternal,
subscriptionGated: false,
docURL: nil
),
"copilot-acp": HermesProviderOverlay(
displayName: "GitHub Copilot ACP",
baseURL: "acp://copilot",
authType: .externalProcess,
subscriptionGated: false,
docURL: nil
),
"arcee": HermesProviderOverlay(
displayName: "Arcee",
baseURL: "https://api.arcee.ai/api/v1",
authType: .apiKey,
subscriptionGated: false,
docURL: nil
),
]
}
/// Scarf-side mirror of `HermesOverlay` from hermes-agent's
/// `hermes_cli/providers.py`. Describes a provider that isn't in the
/// models.dev catalog.
struct HermesProviderOverlay: Sendable {
let displayName: String
let baseURL: String?
let authType: AuthType
/// True for providers whose tool access is subscription-gated rather than
/// BYO-API-key. Nous Portal is the only `true` entry today.
let subscriptionGated: Bool
let docURL: String?
enum AuthType: String, Sendable {
case apiKey
case oauthDeviceCode
case oauthExternal
case externalProcess
}
}
@@ -0,0 +1,264 @@
import Foundation
import AppKit
import os
/// Drives `hermes auth add nous --no-browser` for Nous Portal sign-in.
///
/// Nous uses OAuth 2.0 device-code flow, not PKCE. Hermes prints the
/// verification URL + user code to stdout, then long-polls the token
/// endpoint every ~1s until the user approves in their browser (or the
/// device code expires, currently 15 minutes).
///
/// The controller:
///
/// 1. Spawns hermes via `context.makeTransport().makeProcess(...)`.
/// 2. Streams stdout, regex-extracts the `verification_uri_complete` and
/// `user_code` from the lines hermes prints (auth.py:3282-3286).
/// 3. Auto-opens the verification URL in the default browser and
/// transitions to `.waitingForApproval` so the sheet can show the code.
/// 4. On subprocess exit, confirms success by re-reading `auth.json` via
/// `NousSubscriptionService` hermes exit 0 alone isn't enough, we want
/// to see `providers.nous.access_token` actually landed.
/// 5. Detects the `subscription_required` failure (auth.py:3347-3356) and
/// surfaces the billing URL so the sheet can offer a Subscribe link.
///
/// The parser functions are `nonisolated static` so tests can feed fixture
/// buffers without standing up a real subprocess.
@Observable
@MainActor
final class NousAuthFlow {
enum State: Equatable {
case idle
case starting
case waitingForApproval(userCode: String, verificationURL: URL)
case success
case failure(reason: String, billingURL: URL?)
}
private(set) var state: State = .idle
/// Accumulated subprocess output. Surfaced in the failure UI so the user
/// can copy the tail for bug reports.
private(set) var output: String = ""
let context: ServerContext
private let subscriptionService: NousSubscriptionService
private let logger = Logger(subsystem: "com.scarf", category: "NousAuthFlow")
private var process: Process?
private var stdoutPipe: Pipe?
init(context: ServerContext = .local) {
self.context = context
self.subscriptionService = NousSubscriptionService(context: context)
}
// MARK: - Lifecycle
/// Start the sign-in flow. Any in-flight subprocess is terminated first.
/// Safe to call repeatedly (e.g. user hits "Try again").
func start() {
cancel()
output = ""
state = .starting
let proc = context.makeTransport().makeProcess(
executable: context.paths.hermesBinary,
args: ["auth", "add", "nous", "--no-browser"]
)
if !context.isRemote {
// Only enrich env locally remote ssh gets the remote login env
// naturally, and exporting our local keys into it would be wrong.
var env = HermesFileService.enrichedEnvironment()
// Python block-buffers stdout when it's a pipe (not a TTY). The
// device-code flow prints the verification URL + user code, then
// enters a ~15-minute polling loop that never hits `input()`
// so nothing flushes and our readability handler never sees the
// output. Users see the sheet spinning forever while hermes is
// actually waiting for approval.
//
// PKCE doesn't have this problem because `input("Authorization
// code: ")` flushes stdout before blocking, which is why
// OAuthFlowController works without this setting.
//
// PYTHONUNBUFFERED forces line-buffered stdout for the whole
// subprocess tiny perf cost, huge UX win for device-code.
env["PYTHONUNBUFFERED"] = "1"
proc.environment = env
}
let outPipe = Pipe()
// Merge stderr into stdout hermes prints the device-code block to
// stdout but may emit diagnostics on stderr; we want them interleaved
// in display order so the failure-tail UI reads naturally.
proc.standardOutput = outPipe
proc.standardError = outPipe
outPipe.fileHandleForReading.readabilityHandler = { [weak self] handle in
let data = handle.availableData
if data.isEmpty {
handle.readabilityHandler = nil
return
}
let chunk = String(data: data, encoding: .utf8) ?? ""
Task { @MainActor [weak self] in
self?.handleOutputChunk(chunk)
}
}
proc.terminationHandler = { [weak self] p in
let code = p.terminationStatus
Task { @MainActor [weak self] in
outPipe.fileHandleForReading.readabilityHandler = nil
self?.handleTermination(exitCode: code)
}
}
do {
try proc.run()
process = proc
stdoutPipe = outPipe
} catch {
logger.error("failed to spawn hermes: \(error.localizedDescription, privacy: .public)")
state = .failure(
reason: "Failed to start hermes: \(error.localizedDescription)",
billingURL: nil
)
}
}
/// Terminate the in-flight subprocess. Idempotent. Does NOT clear state
/// the sheet dismisses on cancel via its own binding, and re-opening
/// calls `start()` which does a fresh reset.
func cancel() {
stdoutPipe?.fileHandleForReading.readabilityHandler = nil
process?.terminate()
process = nil
stdoutPipe = nil
}
// MARK: - Output handling
private func handleOutputChunk(_ chunk: String) {
output += chunk
// Only transition into waiting while we're still in .starting once
// we've already emitted the URL + code, subsequent "Waiting for
// approval..." noise shouldn't re-fire NSWorkspace.open.
guard case .starting = state else { return }
if let result = Self.parseDeviceCode(from: output) {
state = .waitingForApproval(
userCode: result.userCode,
verificationURL: result.verificationURL
)
NSWorkspace.shared.open(result.verificationURL)
}
}
private func handleTermination(exitCode: Int32) {
// Subscription-required is a specific failure path that hermes
// signals both via an exit code and a unique billing-URL message.
// It overrides other checks because we want the Subscribe affordance
// in the UI regardless of exit code.
if let billing = Self.parseSubscriptionRequired(from: output) {
state = .failure(
reason: "Your Nous Portal account does not have an active subscription.",
billingURL: billing
)
return
}
if exitCode == 0 {
// Hermes claims success. Confirm by reading auth.json the
// authoritative signal is that providers.nous has an access token
// AND active_provider flipped to nous. Anything short of that is
// a silent failure on the hermes side.
let sub = subscriptionService.loadState()
if sub.subscribed {
state = .success
} else if sub.present {
state = .failure(
reason: "Signed in, but Nous isn't the active provider yet. Run `hermes model` and pick Nous Portal.",
billingURL: nil
)
} else {
state = .failure(
reason: "Sign-in finished without writing credentials. Try again, or run `hermes auth add nous` in a terminal to see full diagnostics.",
billingURL: nil
)
}
} else {
let tail = Self.lastLines(of: output, count: 8)
state = .failure(
reason: tail.isEmpty
? "hermes exited with code \(exitCode)"
: tail,
billingURL: nil
)
}
}
// MARK: - Parsers (pure, testable)
struct DeviceCodeResult: Equatable {
let verificationURL: URL
let userCode: String
}
/// Extract the device-code verification URL and user code from hermes's
/// output. Anchored on the exact shape hermes prints (auth.py:3282-3286):
///
/// To continue:
/// 1. Open: https://portal.nousresearch.com/device/XXXX-XXXX
/// 2. If prompted, enter code: XXXX-XXXX
///
/// Returns nil when either line is missing the sheet stays on the
/// `.starting` spinner until both are captured.
nonisolated static func parseDeviceCode(from text: String) -> DeviceCodeResult? {
let urlPattern = #"^\s*1\.\s*Open:\s*(https?://\S+)\s*$"#
let codePattern = #"^\s*2\.\s*If prompted, enter code:\s*(\S+)\s*$"#
guard
let urlString = firstCapture(in: text, pattern: urlPattern),
let userCode = firstCapture(in: text, pattern: codePattern),
let url = URL(string: urlString)
else {
return nil
}
return DeviceCodeResult(verificationURL: url, userCode: userCode)
}
/// Detect the subscription-required failure and extract the billing URL
/// hermes prints (auth.py:3347-3356). Scarf shows a "Subscribe" button
/// linking to this URL so the user can resolve the blocker without
/// hunting through logs.
nonisolated static func parseSubscriptionRequired(from text: String) -> URL? {
guard text.contains("Your Nous Portal account does not have an active subscription") else {
return nil
}
guard
let raw = firstCapture(in: text, pattern: #"Subscribe here:\s*(https?://\S+)"#),
let url = URL(string: raw)
else {
return nil
}
return url
}
private nonisolated static func firstCapture(in text: String, pattern: String) -> String? {
guard let regex = try? NSRegularExpression(pattern: pattern, options: [.anchorsMatchLines]) else {
return nil
}
let range = NSRange(text.startIndex..., in: text)
guard
let match = regex.firstMatch(in: text, range: range),
match.numberOfRanges >= 2,
let r = Range(match.range(at: 1), in: text)
else {
return nil
}
return String(text[r])
}
private nonisolated static func lastLines(of text: String, count: Int) -> String {
let lines = text.split(separator: "\n", omittingEmptySubsequences: false)
return lines.suffix(count).joined(separator: "\n")
.trimmingCharacters(in: .whitespacesAndNewlines)
}
}
@@ -0,0 +1,85 @@
import Foundation
import os
/// Snapshot of the user's Nous Portal subscription state, derived from the
/// `providers.nous` entry in `~/.hermes/auth.json`. Read-only Scarf never
/// writes the subscription record; `hermes model` + `hermes auth` own that
/// path.
struct NousSubscriptionState: Sendable, Hashable {
/// True when `providers.nous` exists and has a usable access token.
/// Mirrors the `nous_auth_present` field on
/// `NousSubscriptionFeatures` in `hermes_cli/nous_subscription.py`.
let present: Bool
/// True when the user's **active provider** is `nous`, i.e. they've not
/// just authed but selected it as the primary model provider. The Tool
/// Gateway only routes tools when this is true auth alone isn't enough.
let providerIsNous: Bool
/// Last update time for the auth record, if known. Useful in the Health
/// view to tell the user when their subscription state was last refreshed.
let updatedAt: Date?
static let absent = NousSubscriptionState(present: false, providerIsNous: false, updatedAt: nil)
/// Overall subscription active for Tool Gateway routing. Both halves have
/// to line up: auth record present *and* `nous` is the active provider.
/// Mirrors `NousSubscriptionFeatures.subscribed` on the Python side.
var subscribed: Bool { present && providerIsNous }
}
/// Reads `auth.json` to detect Nous Portal subscription state. Delegates file
/// I/O to the active `ServerTransport`, so remote installations work the same
/// as local ones.
///
/// The auth-record shape is defined by hermes-agent and is load-bearing. This
/// service parses a small, stable subset and tolerates anything new Hermes
/// adds we only rely on `providers.nous` being a dict with `access_token`
/// and `active_provider` being either `"nous"` or not.
struct NousSubscriptionService: Sendable {
private let logger = Logger(subsystem: "com.scarf", category: "NousSubscriptionService")
let authJSONPath: String
let transport: any ServerTransport
nonisolated init(context: ServerContext = .local) {
self.authJSONPath = context.paths.authJSON
self.transport = context.makeTransport()
}
/// Escape hatch for tests point at a fixture `auth.json` without
/// constructing a full `ServerContext`. Uses `LocalTransport` so the
/// fixture must live on the local filesystem.
init(path: String) {
self.authJSONPath = path
self.transport = LocalTransport()
}
/// Load the current subscription state. Returns ``NousSubscriptionState/absent``
/// on any read or parse failure callers treat "absent" and "can't
/// read" the same in UI (show a "not subscribed" CTA).
nonisolated func loadState() -> NousSubscriptionState {
guard let data = try? transport.readFile(authJSONPath) else {
return .absent
}
guard let root = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else {
logger.warning("auth.json is not a JSON object; assuming no Nous subscription")
return .absent
}
let providers = root["providers"] as? [String: Any] ?? [:]
let nous = providers["nous"] as? [String: Any]
let token = nous?["access_token"] as? String
let present = (token?.isEmpty == false)
let activeProvider = root["active_provider"] as? String
let providerIsNous = (activeProvider == "nous")
let updatedAt: Date? = {
guard let raw = root["updated_at"] as? String else { return nil }
return ISO8601DateFormatter().date(from: raw)
}()
return NousSubscriptionState(
present: present,
providerIsNous: providerIsNous,
updatedAt: updatedAt
)
}
}
@@ -0,0 +1,293 @@
import Foundation
import os
/// Writes a Scarf-managed marker block into `<project>/AGENTS.md` so
/// that Hermes which auto-reads `AGENTS.md` from the session's cwd
/// at startup has consistent project identity and metadata in every
/// project-scoped chat.
///
/// **Why this exists.** Hermes has no native "project" concept and ACP
/// passes only `(cwd, mcpServers)` at session create extra params
/// are silently dropped on Hermes's side. The documented hook for
/// giving the agent context when cwd is set programmatically is the
/// auto-load of `AGENTS.md` (or `.hermes.md` / `CLAUDE.md` /
/// `.cursorrules`, in that priority) from the cwd. Scarf owns a
/// managed region of the project's AGENTS.md; template-author content
/// lives outside that region and is preserved.
///
/// **Marker contract.** The region sits between:
///
/// ```
/// <!-- scarf-project:begin -->
/// Scarf-managed content
/// <!-- scarf-project:end -->
/// ```
///
/// Same pattern as the v2.2 memory-block appendix bounded, self-
/// declaring, safe to re-generate. Everything outside the markers is
/// left byte-identical across refreshes.
///
/// **Secret-safe.** The block surfaces field NAMES from `config.json`
/// (via the cached manifest's schema) but never VALUES. A rendered
/// block contains no secrets even for a project whose config.json
/// has Keychain-ref URIs.
///
/// **Refresh timing.** `ChatViewModel.startACPSession(resume:projectPath:)`
/// calls `refresh(for:)` immediately before Hermes opens the session.
/// Hermes reads AGENTS.md during session boot, so the marker block
/// must have landed on disk first. Non-blocking on failure a
/// failed refresh logs and the chat proceeds without the block.
struct ProjectAgentContextService: Sendable {
private static let logger = Logger(subsystem: "com.scarf", category: "ProjectAgentContextService")
/// Marker strings. Load-bearing: the format must stay stable
/// across releases so existing project AGENTS.md files continue
/// to be recognized and rewritten cleanly.
static let beginMarker = "<!-- scarf-project:begin -->"
static let endMarker = "<!-- scarf-project:end -->"
let context: ServerContext
nonisolated init(context: ServerContext = .local) {
self.context = context
}
// MARK: - Public
/// Refresh (or create) the Scarf-managed block in the project's
/// AGENTS.md. Reads current project state template manifest,
/// config schema, registered cron jobs and produces a block
/// reflecting today's truth. Idempotent: two consecutive calls
/// with no intervening state change produce byte-identical
/// output.
nonisolated func refresh(for project: ProjectEntry) throws {
let block = renderBlock(for: project)
let path = agentsMdPath(for: project)
let transport = context.makeTransport()
// Ensure the project directory exists this service is the
// first thing that touches the project dir when the user
// scaffolds a bare project via `+` + starts a chat. Normally
// the dir exists (registered project = dir exists); belt-
// and-suspenders for edge cases.
if !transport.fileExists(project.path) {
try transport.createDirectory(project.path)
}
if !transport.fileExists(path) {
// Fresh AGENTS.md with just our block + a trailing
// newline so editors render it cleanly.
let data = (block + "\n").data(using: .utf8) ?? Data()
try transport.writeFile(path, data: data)
Self.logger.info("created AGENTS.md with Scarf block for \(project.name, privacy: .public)")
return
}
// Read existing, splice in the new block.
let existingData = try transport.readFile(path)
let existing = String(data: existingData, encoding: .utf8) ?? ""
let rewritten = Self.applyBlock(block: block, to: existing)
guard let outData = rewritten.data(using: .utf8) else {
throw ProjectAgentContextError.encodingFailed
}
// Skip the write when nothing changed avoids unnecessary
// file-watcher churn. Matches what disk snapshot shows.
guard outData != existingData else { return }
try transport.writeFile(path, data: outData)
Self.logger.info("refreshed Scarf block in AGENTS.md for \(project.name, privacy: .public)")
}
// MARK: - Marker splice (testable in isolation)
/// Core text transform: given an existing file and a freshly-
/// rendered block, return the file with the block spliced in.
///
/// Three cases handled:
/// 1. Existing file has both markers replace the inclusive
/// region, preserve everything outside untouched.
/// 2. Existing file has no markers prepend the block followed
/// by a two-newline separator so it reads as its own section.
/// 3. Existing file has a begin marker but no end we DON'T try
/// to be clever; treat as "no markers present" and prepend.
/// User intervention or a later refresh can restore shape.
/// The stray begin-marker is left in the file; we don't
/// truncate to EOF (as the memory-block installer does)
/// because an orphaned begin on this file is more likely
/// hand-typed than a corrupt Scarf write.
nonisolated static func applyBlock(block: String, to existing: String) -> String {
guard let beginRange = existing.range(of: beginMarker),
let endRange = existing.range(
of: endMarker,
range: beginRange.upperBound..<existing.endIndex
)
else {
// No well-formed Scarf block present prepend.
let trimmedExisting = existing.trimmingCharacters(in: .whitespacesAndNewlines)
if trimmedExisting.isEmpty {
return block + "\n"
}
return block + "\n\n" + existing
}
// Full span: from the begin marker through the end marker
// (inclusive). Consumes any trailing whitespace/newlines
// immediately following the end marker so a re-render of a
// shorter block doesn't leave a dangling blank line.
var upperBound = endRange.upperBound
while upperBound < existing.endIndex,
existing[upperBound].isNewline {
upperBound = existing.index(after: upperBound)
}
let before = String(existing[existing.startIndex..<beginRange.lowerBound])
let after = String(existing[upperBound..<existing.endIndex])
// Preserve the leading whitespace / content structure of
// `before` but ensure exactly one blank line separates it
// from the new block when there IS prior content.
let prefix = before.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
? ""
: before.trimmingRightNewlines() + "\n\n"
// Suffix: a blank line BEFORE the remaining content, ensuring
// the template/user content is visually separated from the
// Scarf block.
let suffix = after.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
? "\n"
: "\n\n" + after.trimmingLeftNewlines()
return prefix + block + suffix
}
// MARK: - Block rendering
/// Build the Markdown block for a given project. Pure function of
/// project state exposed for tests that want to assert on
/// rendered content without touching disk.
nonisolated func renderBlock(for project: ProjectEntry) -> String {
let templateInfo = readTemplateInfo(for: project)
let configFieldsLine = renderConfigFieldsLine(for: project)
let cronLines = renderCronLines(for: project, templateId: templateInfo?.id)
let lockFilePresent = context.makeTransport().fileExists(
project.path + "/.scarf/template.lock.json"
)
var lines: [String] = []
lines.append(Self.beginMarker)
lines.append("## Scarf project context")
lines.append("")
lines.append("_Auto-generated by Scarf — do not edit between the begin/end markers._")
lines.append("")
lines.append("You are operating inside a Scarf project named **\"\(project.name)\"**. Scarf is a macOS GUI for Hermes; the user is working with this project through it. This chat session's working directory is the project's directory — path-relative tool calls resolve inside the project.")
lines.append("")
lines.append("- **Project directory:** `\(project.path)`")
lines.append("- **Dashboard:** `\(project.path)/.scarf/dashboard.json`")
if let tpl = templateInfo {
lines.append("- **Template:** `\(tpl.id)` v\(tpl.version)")
}
lines.append("- **Configuration fields:** \(configFieldsLine)")
if cronLines.isEmpty {
lines.append("- **Registered cron jobs:** (none attributed to this project)")
} else {
lines.append("- **Registered cron jobs:**")
for line in cronLines {
lines.append(" - \(line)")
}
}
if lockFilePresent {
lines.append("- **Uninstall manifest:** `\(project.path)/.scarf/template.lock.json` (tracks files written by template install)")
}
lines.append("")
lines.append("Any content below this block is template- or user-authored; preserve and defer to it for project-specific behavior. Do NOT modify content inside these markers — Scarf rewrites this block on every project-scoped chat start.")
lines.append(Self.endMarker)
return lines.joined(separator: "\n")
}
// MARK: - Helpers
nonisolated private func agentsMdPath(for project: ProjectEntry) -> String {
project.path + "/AGENTS.md"
}
/// Read `<project>/.scarf/manifest.json` for template id + version.
/// Nil when not present (bare project) or when the file is
/// unparseable the block still renders cleanly without the
/// template line.
nonisolated private func readTemplateInfo(for project: ProjectEntry) -> (id: String, version: String)? {
let manifestPath = project.path + "/.scarf/manifest.json"
let transport = context.makeTransport()
guard transport.fileExists(manifestPath) else { return nil }
guard let data = try? transport.readFile(manifestPath) else { return nil }
guard let manifest = try? JSONDecoder().decode(ProjectTemplateManifest.self, from: data) else { return nil }
return (id: manifest.id, version: manifest.version)
}
/// Build the "Configuration fields" bullet's tail. Returns a
/// comma-joined list of backticked field names with inline type
/// hints (`(secret)`), or the literal string "(none)" when the
/// project has no config schema. **Never** includes values.
nonisolated private func renderConfigFieldsLine(for project: ProjectEntry) -> String {
let manifestPath = project.path + "/.scarf/manifest.json"
let transport = context.makeTransport()
guard transport.fileExists(manifestPath),
let data = try? transport.readFile(manifestPath),
let manifest = try? JSONDecoder().decode(ProjectTemplateManifest.self, from: data),
let schema = manifest.config,
!schema.fields.isEmpty
else {
return "(none)"
}
let fieldList = schema.fields.map { field -> String in
let secretTag = field.type == .secret ? " (secret — name only, value stored in Keychain)" : ""
return "`\(field.key)`\(secretTag)"
}
return fieldList.joined(separator: ", ")
}
/// Return a list of human-readable cron-job descriptions for jobs
/// attributed to this project via the `[tmpl:<id>] ` name prefix.
/// Empty array when no jobs match (either the project has no
/// template or no jobs carry the tag).
nonisolated private func renderCronLines(for project: ProjectEntry, templateId: String?) -> [String] {
guard let templateId else { return [] }
let prefix = "[tmpl:\(templateId)]"
let jobs = HermesFileService(context: context).loadCronJobs()
return jobs
.filter { $0.name.hasPrefix(prefix) }
.map { job in
let scheduleDesc = job.schedule.display
?? job.schedule.expression
?? job.schedule.kind
let pausedDesc = job.enabled ? "enabled" : "paused"
return "`\(job.name)` — schedule `\(scheduleDesc)`, currently \(pausedDesc)"
}
}
}
enum ProjectAgentContextError: Error {
case encodingFailed
}
// MARK: - String helpers (file-scoped)
private extension String {
/// Drop trailing newlines + CRs but preserve other trailing
/// whitespace (tabs, non-breaking spaces) that might be
/// meaningful in some edge case.
func trimmingRightNewlines() -> String {
var result = self
while let last = result.last, last.isNewline {
result.removeLast()
}
return result
}
/// Symmetric counterpart: strip leading newlines / CRs.
func trimmingLeftNewlines() -> String {
var result = self
while let first = result.first, first.isNewline {
result.removeFirst()
}
return result
}
}
@@ -0,0 +1,154 @@
import Foundation
import Security
import os
/// Thin wrapper around the macOS Keychain for template-config secrets.
/// Scarf doesn't have other Keychain users yet so this file is the one
/// place that touches the `Security` framework; keep it small and
/// auditable so a reader can tell at a glance what we store, under what
/// identifiers, and when items are removed.
///
/// **What we store.** Generic passwords (kSecClassGenericPassword) in
/// the login Keychain. Each item is identified by a (service, account)
/// pair derived from the template slug + field key + project-path hash
/// see `TemplateKeychainRef.make`. The stored Data is the user's
/// raw secret bytes; we never transform or encode them.
///
/// **When items are written.** By `ProjectTemplateInstaller` after the
/// install preview is confirmed and the user has filled in the
/// configure sheet. By `TemplateConfigSheet` when the user edits a
/// secret field post-install.
///
/// **When items are removed.** By `ProjectTemplateUninstaller`,
/// iterating the lock file's `configKeychainItems` list. The login
/// Keychain is never swept for stray entries if the lock is out of
/// sync we log + skip rather than guess which items are ours.
///
/// **What shows to the user.** macOS prompts "Scarf wants to access
/// the Keychain" the first time we read a secret in a given session.
/// User approves; subsequent reads in that session are silent. We
/// never bypass this the prompt is the user's trust boundary.
struct ProjectConfigKeychain: Sendable {
private static let logger = Logger(subsystem: "com.scarf", category: "ProjectConfigKeychain")
/// Which Keychain to target. The default is the login Keychain
/// (`nil` uses the user's default chain). Tests pass an explicit
/// namespace suffix via `testServiceSuffix` see `TemplateConfigTests`
/// so integration tests can roundtrip without polluting real
/// user state.
let testServiceSuffix: String?
nonisolated init(testServiceSuffix: String? = nil) {
self.testServiceSuffix = testServiceSuffix
}
/// Write or overwrite the secret for (service, account). Tests
/// route their items through a distinct service prefix via
/// `testServiceSuffix` so they can't leak into the user's real
/// Keychain.
nonisolated func set(service: String, account: String, secret: Data) throws {
let svc = resolved(service: service)
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: svc,
kSecAttrAccount as String: account,
]
// Try update first cheaper than delete-then-add and doesn't
// trip macOS's "item already exists" if another thread raced us.
let update: [String: Any] = [
kSecValueData as String: secret,
]
let updateStatus = SecItemUpdate(query as CFDictionary, update as CFDictionary)
if updateStatus == errSecSuccess { return }
if updateStatus != errSecItemNotFound {
throw Self.error(status: updateStatus, op: "update")
}
var insert = query
insert[kSecValueData as String] = secret
// kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly stays in
// this device's Keychain, not synced via iCloud, usable after
// first unlock (so background cron triggers can read).
insert[kSecAttrAccessible as String] = kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly
let addStatus = SecItemAdd(insert as CFDictionary, nil)
if addStatus != errSecSuccess {
throw Self.error(status: addStatus, op: "add")
}
}
/// Retrieve the secret for (service, account). Returns `nil` when
/// the item simply doesn't exist (user never set it, or an
/// uninstall already removed it). Throws on every other Keychain
/// error so callers don't silently treat "access denied" or
/// "corrupt keychain" as "no value."
nonisolated func get(service: String, account: String) throws -> Data? {
let svc = resolved(service: service)
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: svc,
kSecAttrAccount as String: account,
kSecReturnData as String: true,
kSecMatchLimit as String: kSecMatchLimitOne,
]
var result: CFTypeRef?
let status = SecItemCopyMatching(query as CFDictionary, &result)
if status == errSecItemNotFound { return nil }
if status != errSecSuccess {
throw Self.error(status: status, op: "get")
}
return result as? Data
}
/// Delete the secret for (service, account). Absent item is a
/// no-op; any other failure throws. Called by
/// `ProjectTemplateUninstaller` for every item in
/// `TemplateLock.configKeychainItems`.
nonisolated func delete(service: String, account: String) throws {
let svc = resolved(service: service)
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: svc,
kSecAttrAccount as String: account,
]
let status = SecItemDelete(query as CFDictionary)
if status == errSecItemNotFound || status == errSecSuccess { return }
throw Self.error(status: status, op: "delete")
}
/// Convenience: apply the test suffix when in test mode.
nonisolated private func resolved(service: String) -> String {
guard let suffix = testServiceSuffix, !suffix.isEmpty else { return service }
return "\(service).\(suffix)"
}
/// Build a useful NSError from a Keychain OSStatus. Logs at warning
/// callers decide whether the failure is fatal.
nonisolated private static func error(status: OSStatus, op: String) -> NSError {
let description = (SecCopyErrorMessageString(status, nil) as String?) ?? "Keychain error"
logger.warning("Keychain \(op, privacy: .public) failed: \(status) \(description, privacy: .public)")
return NSError(
domain: "com.scarf.keychain",
code: Int(status),
userInfo: [
NSLocalizedDescriptionKey: "Keychain \(op) failed (\(status)): \(description)"
]
)
}
}
// MARK: - Ref-shaped convenience layer
extension ProjectConfigKeychain {
/// Set a secret using a pre-built `TemplateKeychainRef`. Mirrors the
/// service/account plumbing every caller would otherwise repeat.
nonisolated func set(ref: TemplateKeychainRef, secret: Data) throws {
try set(service: ref.service, account: ref.account, secret: secret)
}
nonisolated func get(ref: TemplateKeychainRef) throws -> Data? {
try get(service: ref.service, account: ref.account)
}
nonisolated func delete(ref: TemplateKeychainRef) throws {
try delete(service: ref.service, account: ref.account)
}
}
@@ -0,0 +1,318 @@
import Foundation
import os
/// Per-project configuration I/O: reads `<project>/.scarf/config.json`
/// into typed values, writes them back, resolves Keychain-backed secrets
/// on demand, and validates user-entered values against the schema.
///
/// Separation of concerns:
///
/// - **Schema authority.** `TemplateConfigSchema` lives in the bundle's
/// `template.json` and a copy is stashed at `<project>/.scarf/manifest.json`
/// at install time so the post-install editor works offline. This
/// service treats the schema as read-only input; `validateSchema`
/// checks structural invariants and is called by
/// `ProjectTemplateService` during install-plan building.
/// - **Value storage.** Non-secret values live inline in `config.json`;
/// secret values are Keychain references of the form
/// `"keychain://<service>/<account>"`. The service owns both halves
/// of that storage callers never open `config.json` or touch the
/// Keychain directly.
/// - **Remote readiness.** All file I/O goes through
/// `ServerContext.makeTransport()` so when `ProjectTemplateInstaller`
/// eventually supports remote contexts, the config store comes along
/// for the ride. Keychain access stays local (it's a macOS-side thing
/// by definition agents on remote Hermes installs would fetch
/// values via Scarf's channel, same as today).
struct ProjectConfigService: Sendable {
private static let logger = Logger(subsystem: "com.scarf", category: "ProjectConfigService")
let context: ServerContext
let keychain: ProjectConfigKeychain
nonisolated init(
context: ServerContext = .local,
keychain: ProjectConfigKeychain = ProjectConfigKeychain()
) {
self.context = context
self.keychain = keychain
}
// MARK: - Paths
nonisolated static func configPath(for project: ProjectEntry) -> String {
project.path + "/.scarf/config.json"
}
nonisolated static func manifestCachePath(for project: ProjectEntry) -> String {
project.path + "/.scarf/manifest.json"
}
// MARK: - Load / save on-disk config
/// Read + decode `<project>/.scarf/config.json`. Returns `nil`
/// cleanly when the file is absent (e.g. a project installed from
/// a schema-less template, or a hand-added project). Throws on
/// malformed JSON so the caller can surface a concrete error
/// rather than silently treating a corrupt file as missing.
nonisolated func load(project: ProjectEntry) throws -> ProjectConfigFile? {
let transport = context.makeTransport()
let path = Self.configPath(for: project)
guard transport.fileExists(path) else { return nil }
let data = try transport.readFile(path)
do {
return try JSONDecoder().decode(ProjectConfigFile.self, from: data)
} catch {
Self.logger.error("couldn't decode config.json at \(path, privacy: .public): \(error.localizedDescription, privacy: .public)")
throw error
}
}
/// Write `<project>/.scarf/config.json`. Secrets should already be
/// represented as `TemplateConfigValue.keychainRef` references here
/// this service never inspects their plaintext.
nonisolated func save(
project: ProjectEntry,
templateId: String,
values: [String: TemplateConfigValue]
) throws {
let transport = context.makeTransport()
let file = ProjectConfigFile(
schemaVersion: 2,
templateId: templateId,
values: values,
updatedAt: ISO8601DateFormatter().string(from: Date())
)
let encoder = JSONEncoder()
encoder.outputFormatting = [.prettyPrinted, .sortedKeys]
let data = try encoder.encode(file)
let parent = (Self.configPath(for: project) as NSString).deletingLastPathComponent
try transport.createDirectory(parent)
try transport.writeFile(Self.configPath(for: project), data: data)
}
// MARK: - Manifest cache (schema used by post-install editor)
/// Copy a template's `template.json` into `<project>/.scarf/manifest.json`
/// so the post-install "Configuration" button can render the form
/// offline. Called once by the installer after unpack + validate.
nonisolated func cacheManifest(project: ProjectEntry, manifestData: Data) throws {
let transport = context.makeTransport()
let path = Self.manifestCachePath(for: project)
let parent = (path as NSString).deletingLastPathComponent
try transport.createDirectory(parent)
try transport.writeFile(path, data: manifestData)
}
/// Load the cached manifest into a `ProjectTemplateManifest` so the
/// editor can look up field types + labels. Returns `nil` when the
/// project wasn't installed from a schemaful template.
nonisolated func loadCachedManifest(project: ProjectEntry) throws -> ProjectTemplateManifest? {
let transport = context.makeTransport()
let path = Self.manifestCachePath(for: project)
guard transport.fileExists(path) else { return nil }
let data = try transport.readFile(path)
return try JSONDecoder().decode(ProjectTemplateManifest.self, from: data)
}
// MARK: - Secrets
/// Resolve a `keychainRef` value into the actual secret bytes.
/// Returns `nil` if the Keychain entry has been removed (e.g.
/// external user cleanup, a previous uninstall that didn't finish).
nonisolated func resolveSecret(ref value: TemplateConfigValue) throws -> Data? {
guard case .keychainRef(let uri) = value,
let ref = TemplateKeychainRef.parse(uri) else {
return nil
}
return try keychain.get(ref: ref)
}
/// Store a freshly-entered secret. Returns the `keychainRef` value
/// suitable for writing into `config.json`.
nonisolated func storeSecret(
templateSlug: String,
fieldKey: String,
project: ProjectEntry,
secret: Data
) throws -> TemplateConfigValue {
let ref = TemplateKeychainRef.make(
templateSlug: templateSlug,
fieldKey: fieldKey,
projectPath: project.path
)
try keychain.set(ref: ref, secret: secret)
return .keychainRef(ref.uri)
}
/// Delete every Keychain item tracked in `refs`. Absent items are
/// fine (uninstall may run after the user manually cleaned an
/// entry). Any other failure is logged and re-thrown so the
/// uninstaller can surface it.
nonisolated func deleteSecrets(refs: [TemplateKeychainRef]) throws {
for ref in refs {
try keychain.delete(ref: ref)
}
}
// MARK: - Schema validation (author-facing; called at bundle inspect time)
/// Verify structural invariants on a schema: unique keys, known
/// types, enum options, secret-without-default rule, model
/// recommendation non-empty when present. Called by
/// `ProjectTemplateService.inspect` before buildPlan runs.
nonisolated static func validateSchema(_ schema: TemplateConfigSchema) throws {
var seen = Set<String>()
for field in schema.fields {
if !seen.insert(field.key).inserted {
throw TemplateConfigSchemaError.duplicateKey(field.key)
}
switch field.type {
case .enum:
let opts = field.options ?? []
guard !opts.isEmpty else {
throw TemplateConfigSchemaError.emptyEnumOptions(field.key)
}
var seenValues = Set<String>()
for opt in opts {
if !seenValues.insert(opt.value).inserted {
throw TemplateConfigSchemaError.duplicateEnumValue(key: field.key, value: opt.value)
}
}
case .list:
let item = field.itemType ?? "string"
if item != "string" {
throw TemplateConfigSchemaError.unsupportedListItemType(key: field.key, itemType: item)
}
case .secret:
if field.defaultValue != nil {
throw TemplateConfigSchemaError.secretFieldHasDefault(field.key)
}
case .string, .text, .number, .bool:
break
}
}
if let rec = schema.modelRecommendation {
if rec.preferred.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
throw TemplateConfigSchemaError.emptyModelPreferred
}
}
}
// MARK: - Value validation (runs on user input in the configure sheet)
/// Validate user-entered values against the schema. Returns one
/// `TemplateConfigValidationError` per problem. Empty array means
/// the form is submittable.
nonisolated static func validateValues(
_ values: [String: TemplateConfigValue],
against schema: TemplateConfigSchema
) -> [TemplateConfigValidationError] {
var errors: [TemplateConfigValidationError] = []
for field in schema.fields {
let value = values[field.key]
if field.required && !Self.hasMeaningfulValue(value, type: field.type) {
errors.append(.init(fieldKey: field.key, message: "\(field.label) is required."))
continue
}
guard let value else { continue }
switch field.type {
case .string, .text:
if case .string(let s) = value {
if let min = field.minLength, s.count < min {
errors.append(.init(fieldKey: field.key,
message: "\(field.label) must be at least \(min) characters."))
}
if let max = field.maxLength, s.count > max {
errors.append(.init(fieldKey: field.key,
message: "\(field.label) must be at most \(max) characters."))
}
if let pattern = field.pattern,
s.range(of: pattern, options: .regularExpression) == nil {
errors.append(.init(fieldKey: field.key,
message: "\(field.label) doesn't match the expected format."))
}
} else {
errors.append(.init(fieldKey: field.key,
message: "\(field.label) must be a string."))
}
case .number:
if case .number(let n) = value {
if let min = field.minNumber, n < min {
errors.append(.init(fieldKey: field.key,
message: "\(field.label) must be ≥ \(min)."))
}
if let max = field.maxNumber, n > max {
errors.append(.init(fieldKey: field.key,
message: "\(field.label) must be ≤ \(max)."))
}
} else {
errors.append(.init(fieldKey: field.key,
message: "\(field.label) must be a number."))
}
case .bool:
if case .bool = value { /* ok */ } else {
errors.append(.init(fieldKey: field.key,
message: "\(field.label) must be true or false."))
}
case .enum:
if case .string(let s) = value {
let options = (field.options ?? []).map(\.value)
if !options.contains(s) {
errors.append(.init(fieldKey: field.key,
message: "\(field.label) must be one of \(options.joined(separator: ", "))."))
}
} else {
errors.append(.init(fieldKey: field.key,
message: "\(field.label) must be one of the predefined options."))
}
case .list:
if case .list(let items) = value {
if let min = field.minItems, items.count < min {
errors.append(.init(fieldKey: field.key,
message: "\(field.label) needs at least \(min) item(s)."))
}
if let max = field.maxItems, items.count > max {
errors.append(.init(fieldKey: field.key,
message: "\(field.label) accepts at most \(max) item(s)."))
}
} else {
errors.append(.init(fieldKey: field.key,
message: "\(field.label) must be a list."))
}
case .secret:
if case .keychainRef = value { /* opaque trust it */ } else {
errors.append(.init(fieldKey: field.key,
message: "\(field.label) must be supplied (Keychain entry missing)."))
}
}
}
return errors
}
nonisolated private static func hasMeaningfulValue(
_ value: TemplateConfigValue?,
type: TemplateConfigField.FieldType
) -> Bool {
guard let value else { return false }
switch (type, value) {
case (.string, .string(let s)), (.text, .string(let s)), (.enum, .string(let s)):
return !s.isEmpty
case (.number, .number):
return true
case (.bool, .bool):
return true
case (.list, .list(let arr)):
return !arr.isEmpty
case (.secret, .keychainRef):
return true
default:
return false
}
}
}
@@ -0,0 +1,77 @@
import Foundation
import os
struct ProjectDashboardService: Sendable {
private static let logger = Logger(subsystem: "com.scarf", category: "ProjectDashboardService")
let context: ServerContext
let transport: any ServerTransport
nonisolated init(context: ServerContext = .local) {
self.context = context
self.transport = context.makeTransport()
}
// MARK: - Registry
func loadRegistry() -> ProjectRegistry {
guard let data = try? transport.readFile(context.paths.projectsRegistry) else {
return ProjectRegistry(projects: [])
}
do {
return try JSONDecoder().decode(ProjectRegistry.self, from: data)
} catch {
Self.logger.error("Failed to decode project registry: \(error.localizedDescription, privacy: .public)")
return ProjectRegistry(projects: [])
}
}
/// Persist the project registry to `~/.hermes/scarf/projects.json`.
///
/// **Throws** on every non-success path the previous version of
/// this method silently swallowed `createDirectory` and `writeFile`
/// failures with `try?`, which meant the installer could return a
/// valid-looking `ProjectEntry` while the registry on disk never
/// received the new row (project would complete install, show a
/// success screen, then be invisible in the sidebar). Callers that
/// want fire-and-forget behaviour can still use `try?`, but the
/// choice is now theirs.
func saveRegistry(_ registry: ProjectRegistry) throws {
let dir = context.paths.scarfDir
if !transport.fileExists(dir) {
try transport.createDirectory(dir)
}
let data = try JSONEncoder().encode(registry)
// Pretty-print for readability (agents may read this file).
let writeData: Data
if let pretty = try? JSONSerialization.jsonObject(with: data),
let formatted = try? JSONSerialization.data(withJSONObject: pretty, options: [.prettyPrinted, .sortedKeys]) {
writeData = formatted
} else {
writeData = data
}
try transport.writeFile(context.paths.projectsRegistry, data: writeData)
}
// MARK: - Dashboard
func loadDashboard(for project: ProjectEntry) -> ProjectDashboard? {
guard let data = try? transport.readFile(project.dashboardPath) else {
return nil
}
do {
return try JSONDecoder().decode(ProjectDashboard.self, from: data)
} catch {
print("[Scarf] Failed to decode dashboard for \(project.name): \(error.localizedDescription)")
return nil
}
}
func dashboardExists(for project: ProjectEntry) -> Bool {
transport.fileExists(project.dashboardPath)
}
func dashboardModificationDate(for project: ProjectEntry) -> Date? {
transport.stat(project.dashboardPath)?.mtime
}
}
@@ -0,0 +1,336 @@
import Foundation
import os
/// Builds a `.scarftemplate` bundle from an existing Scarf project plus the
/// caller's selection of skills and cron jobs. Symmetric with the
/// `ProjectTemplateService` + `ProjectTemplateInstaller` pair the output
/// of this exporter can be fed straight back to `inspect()` + `install()`.
struct ProjectTemplateExporter: Sendable {
private static let logger = Logger(subsystem: "com.scarf", category: "ProjectTemplateExporter")
let context: ServerContext
nonisolated init(context: ServerContext = .local) {
self.context = context
}
/// Known filenames in the project root that map to specific agents. When
/// the author opts to include them, each is copied verbatim into
/// `instructions/` in the bundle.
nonisolated static let knownInstructionFiles: [String] = [
"CLAUDE.md",
"GEMINI.md",
".cursorrules",
".github/copilot-instructions.md"
]
/// Author-facing description of what `export` will do with the given
/// selections. Shown in the export sheet so the user knows exactly
/// what's about to go into the bundle before saving.
struct ExportPlan: Sendable {
let templateId: String
let templateName: String
let templateVersion: String
let projectDir: String
let dashboardPresent: Bool
let agentsMdPresent: Bool
let readmePresent: Bool
let instructionFiles: [String]
let skillIds: [String]
let cronJobs: [HermesCronJob]
let memoryAppendix: String?
}
/// Inputs collected by the export sheet.
struct ExportInputs: Sendable {
let project: ProjectEntry
let templateId: String
let templateName: String
let templateVersion: String
let description: String
let authorName: String?
let authorUrl: String?
let category: String?
let tags: [String]
let includeSkillIds: [String]
let includeCronJobIds: [String]
/// Raw markdown the author wants appended to installers' MEMORY.md.
/// `nil` to skip.
let memoryAppendix: String?
}
/// Scan the project dir and report what a fresh export would include
/// given the caller's inputs. Does not write anything.
///
/// Existence checks go through the context's transport the project
/// path comes from the registry on the active server and may be on a
/// remote filesystem (future remote-install support), where
/// `FileManager.default.fileExists` would silently return `false`.
nonisolated func previewPlan(for inputs: ExportInputs) -> ExportPlan {
let dir = inputs.project.path
let transport = context.makeTransport()
let dashboard = transport.fileExists(dir + "/.scarf/dashboard.json")
let readme = transport.fileExists(dir + "/README.md")
let agents = transport.fileExists(dir + "/AGENTS.md")
let instructions = Self.knownInstructionFiles.filter {
transport.fileExists(dir + "/" + $0)
}
let allJobs = HermesFileService(context: context).loadCronJobs()
let picked = allJobs.filter { inputs.includeCronJobIds.contains($0.id) }
return ExportPlan(
templateId: inputs.templateId,
templateName: inputs.templateName,
templateVersion: inputs.templateVersion,
projectDir: dir,
dashboardPresent: dashboard,
agentsMdPresent: agents,
readmePresent: readme,
instructionFiles: instructions,
skillIds: inputs.includeSkillIds,
cronJobs: picked,
memoryAppendix: inputs.memoryAppendix
)
}
/// Build the bundle and write it to `outputZipPath`. Throws if any
/// required file is missing or the zip step fails.
nonisolated func export(
inputs: ExportInputs,
outputZipPath: String
) throws {
let stagingDir = NSTemporaryDirectory() + "scarf-template-export-" + UUID().uuidString
try FileManager.default.createDirectory(atPath: stagingDir, withIntermediateDirectories: true)
defer { try? FileManager.default.removeItem(atPath: stagingDir) }
let plan = previewPlan(for: inputs)
guard plan.dashboardPresent else {
throw ProjectTemplateError.requiredFileMissing("dashboard.json (expected at \(plan.projectDir)/.scarf/dashboard.json)")
}
guard plan.readmePresent else {
throw ProjectTemplateError.requiredFileMissing("README.md (expected at \(plan.projectDir)/README.md)")
}
guard plan.agentsMdPresent else {
throw ProjectTemplateError.requiredFileMissing("AGENTS.md (expected at \(plan.projectDir)/AGENTS.md)")
}
// Required files. All source reads go through the context's
// transport project paths come from the registry on the active
// server and may be on a remote filesystem. Destinations are in
// the local staging dir so Foundation writes are correct.
let transport = context.makeTransport()
try copyFromHermes(plan.projectDir + "/.scarf/dashboard.json", to: stagingDir + "/dashboard.json", transport: transport)
try copyFromHermes(plan.projectDir + "/README.md", to: stagingDir + "/README.md", transport: transport)
try copyFromHermes(plan.projectDir + "/AGENTS.md", to: stagingDir + "/AGENTS.md", transport: transport)
// Optional per-agent instruction shims
for relative in plan.instructionFiles {
let source = plan.projectDir + "/" + relative
let destination = stagingDir + "/instructions/" + relative
try createParent(of: destination)
try copyFromHermes(source, to: destination, transport: transport)
}
// Skills (copied from the global skills dir)
if !plan.skillIds.isEmpty {
let skillsRoot = stagingDir + "/skills"
try FileManager.default.createDirectory(atPath: skillsRoot, withIntermediateDirectories: true)
let allSkills = HermesFileService(context: context).loadSkills()
.flatMap(\.skills)
for skillId in plan.skillIds {
guard let skill = allSkills.first(where: { $0.id == skillId }) else {
throw ProjectTemplateError.requiredFileMissing("skills/" + skillId)
}
// The bundle uses a flat `skills/<name>/` layout (no
// category), matching what the installer expects. If two
// categories ship skills with the same `name`, the second
// collides warn by refusing rather than silently
// overwriting.
let targetDir = skillsRoot + "/" + skill.name
if FileManager.default.fileExists(atPath: targetDir) {
throw ProjectTemplateError.conflictingFile(targetDir)
}
try FileManager.default.createDirectory(atPath: targetDir, withIntermediateDirectories: true)
for file in skill.files {
try copyFromHermes(skill.path + "/" + file, to: targetDir + "/" + file, transport: transport)
}
}
}
// Cron jobs (stripped to the create-CLI-shaped spec)
if !plan.cronJobs.isEmpty {
let specs = plan.cronJobs.map { Self.strip($0) }
let encoder = JSONEncoder()
encoder.outputFormatting = [.prettyPrinted, .sortedKeys]
let data = try encoder.encode(specs)
let cronDir = stagingDir + "/cron"
try FileManager.default.createDirectory(atPath: cronDir, withIntermediateDirectories: true)
try data.write(to: URL(fileURLWithPath: cronDir + "/jobs.json"))
}
// Memory appendix. A write failure here would silently produce a
// bundle whose manifest claims `memory.append = true` but ships an
// empty/missing file installers would then fail on
// contentClaimMismatch with no breadcrumb pointing back at the
// export step. Let the error propagate.
if let appendix = plan.memoryAppendix, !appendix.isEmpty {
let memDir = stagingDir + "/memory"
try FileManager.default.createDirectory(atPath: memDir, withIntermediateDirectories: true)
guard let data = appendix.data(using: .utf8) else {
throw ProjectTemplateError.requiredFileMissing("memory/append.md (non-UTF8)")
}
try data.write(to: URL(fileURLWithPath: memDir + "/append.md"))
}
// If the source project was itself installed from a schemaful
// template, its `.scarf/manifest.json` carries the schema we
// want to forward to the exported bundle. We carry only the
// SCHEMA never user values. Exporting must be safe on a
// project with live config: the schema is author-supplied
// metadata; the values in `config.json` are the current user's
// secrets or personal settings.
let forwardedSchema: TemplateConfigSchema? = try Self.readCachedSchema(
from: plan.projectDir
)
// Bump schemaVersion to 2 when a schema is carried through;
// remain on 1 otherwise so schema-less exports stay
// byte-compatible with existing v2.2 catalog validators.
let schemaVersion = forwardedSchema == nil ? 1 : 2
// Manifest claims exactly what we just wrote
let manifest = ProjectTemplateManifest(
schemaVersion: schemaVersion,
id: inputs.templateId,
name: inputs.templateName,
version: inputs.templateVersion,
minScarfVersion: nil,
minHermesVersion: nil,
author: inputs.authorName.map {
TemplateAuthor(name: $0, url: inputs.authorUrl)
},
description: inputs.description,
category: inputs.category,
tags: inputs.tags.isEmpty ? nil : inputs.tags,
icon: nil,
screenshots: nil,
contents: TemplateContents(
dashboard: true,
agentsMd: true,
instructions: plan.instructionFiles.isEmpty ? nil : plan.instructionFiles,
skills: plan.skillIds.isEmpty ? nil : plan.skillIds.compactMap { $0.split(separator: "/").last.map(String.init) },
cron: plan.cronJobs.isEmpty ? nil : plan.cronJobs.count,
memory: (inputs.memoryAppendix?.isEmpty == false) ? TemplateMemoryClaim(append: true) : nil,
config: forwardedSchema?.fields.count
),
config: forwardedSchema
)
let manifestEncoder = JSONEncoder()
manifestEncoder.outputFormatting = [.prettyPrinted, .sortedKeys]
let manifestData = try manifestEncoder.encode(manifest)
try manifestData.write(to: URL(fileURLWithPath: stagingDir + "/template.json"))
try zip(stagingDir: stagingDir, outputPath: outputZipPath)
}
// MARK: - Private
/// Copy a file whose source lives on the Hermes side (possibly remote)
/// into a local destination path under the staging dir. Using the
/// transport for the read keeps the exporter remote-ready; the write
/// goes through Foundation because the staging dir is always local to
/// the Mac running Scarf.
nonisolated private func copyFromHermes(
_ source: String,
to destination: String,
transport: any ServerTransport
) throws {
let data = try transport.readFile(source)
try createParent(of: destination)
try data.write(to: URL(fileURLWithPath: destination))
}
nonisolated private func createParent(of path: String) throws {
let parent = (path as NSString).deletingLastPathComponent
if !FileManager.default.fileExists(atPath: parent) {
try FileManager.default.createDirectory(atPath: parent, withIntermediateDirectories: true)
}
}
/// Read the cached manifest from `<project>/.scarf/manifest.json` (if
/// present) and pull out just the config schema. Values in
/// `.scarf/config.json` are intentionally ignored an exported
/// bundle carries the schema's shape, never the current user's
/// configured values.
nonisolated private static func readCachedSchema(from projectDir: String) throws -> TemplateConfigSchema? {
let manifestPath = projectDir + "/.scarf/manifest.json"
guard FileManager.default.fileExists(atPath: manifestPath) else { return nil }
let data = try Data(contentsOf: URL(fileURLWithPath: manifestPath))
// Use a bespoke decode rather than ProjectTemplateManifest so
// this helper stays resilient if the manifest shape evolves
// incompatibly in a future release.
struct OnlyConfig: Decodable { let config: TemplateConfigSchema? }
let onlyConfig = try JSONDecoder().decode(OnlyConfig.self, from: data)
return onlyConfig.config
}
/// Convert a live cron job (with runtime state) into the spec the
/// installer will feed back to `hermes cron create`. Only preserves
/// fields the CLI accepts.
nonisolated private static func strip(_ job: HermesCronJob) -> TemplateCronJobSpec {
let schedule: String = {
if let expr = job.schedule.expression, !expr.isEmpty { return expr }
if let runAt = job.schedule.runAt, !runAt.isEmpty { return runAt }
return job.schedule.display ?? ""
}()
return TemplateCronJobSpec(
name: job.name,
schedule: schedule,
prompt: job.prompt.isEmpty ? nil : job.prompt,
deliver: job.deliver?.isEmpty == false ? job.deliver : nil,
skills: (job.skills?.isEmpty == false) ? job.skills : nil,
repeatCount: nil
)
}
/// Shell out to `/usr/bin/zip -r` so the file ordering is deterministic
/// and the archive is standard Apple-provided tools (and the system
/// `unzip` the installer uses) will read it without trouble.
nonisolated private func zip(stagingDir: String, outputPath: String) throws {
// `zip` writes relative paths based on the cwd it's invoked in. Chdir
// via Process.currentDirectoryURL so entries are `template.json`,
// `AGENTS.md`, etc., not absolute paths.
let process = Process()
process.executableURL = URL(fileURLWithPath: "/usr/bin/zip")
process.currentDirectoryURL = URL(fileURLWithPath: stagingDir)
process.arguments = ["-qq", "-r", outputPath, "."]
let outPipe = Pipe()
let errPipe = Pipe()
process.standardOutput = outPipe
process.standardError = errPipe
// Close both ends of each Pipe so we don't leak 4 fds per zip call.
func closePipes() {
try? outPipe.fileHandleForReading.close()
try? outPipe.fileHandleForWriting.close()
try? errPipe.fileHandleForReading.close()
try? errPipe.fileHandleForWriting.close()
}
do {
try process.run()
} catch {
closePipes()
throw ProjectTemplateError.unzipFailed("zip failed to launch: \(error.localizedDescription)")
}
process.waitUntilExit()
let errData = try? errPipe.fileHandleForReading.readToEnd()
closePipes()
guard process.terminationStatus == 0 else {
let err = errData.flatMap { String(data: $0, encoding: .utf8) } ?? ""
throw ProjectTemplateError.unzipFailed(err.isEmpty ? "exit \(process.terminationStatus)" : err)
}
}
}
@@ -0,0 +1,303 @@
import Foundation
import os
/// Executes a `TemplateInstallPlan`. All writes happen in one pass with
/// early-fail semantics: if any step throws, later steps don't run (but
/// earlier ones aren't reversed v1 doesn't ship an atomic rollback). The
/// plan has already verified `projectDir` doesn't exist and no conflicting
/// file exists at target paths, so by the time we start writing, the
/// expected-error surface is small (mostly I/O failures).
struct ProjectTemplateInstaller: Sendable {
private static let logger = Logger(subsystem: "com.scarf", category: "ProjectTemplateInstaller")
let context: ServerContext
nonisolated init(context: ServerContext = .local) {
self.context = context
}
/// Apply the plan. On success, returns the `ProjectEntry` that was added
/// to the registry so the caller can set `AppCoordinator.selectedProjectName`.
@discardableResult
nonisolated func install(plan: TemplateInstallPlan) throws -> ProjectEntry {
try preflight(plan: plan)
try createProjectFiles(plan: plan)
try createSkillsFiles(plan: plan)
try appendMemoryIfNeeded(plan: plan)
let cronJobNames = try createCronJobs(plan: plan)
let entry = try registerProject(plan: plan)
try writeLockFile(plan: plan, cronJobNames: cronJobNames)
Self.logger.info("installed template \(plan.manifest.id, privacy: .public) v\(plan.manifest.version, privacy: .public) into \(plan.projectDir, privacy: .public)")
return entry
}
// MARK: - Preflight
nonisolated private func preflight(plan: TemplateInstallPlan) throws {
// Plan was built on a recent snapshot of the filesystem; re-check the
// invariants at install time so concurrent activity between
// preview-and-confirm can't slip past us.
//
// All existence and read checks for paths that come from
// `context.paths` go through the transport not `FileManager`
// so this code works identically against a future remote
// `ServerContext`. See the warning on `ServerContext.readText`:
// "Foundation file APIs are LOCAL ONLY using them with a remote
// path silently returns nil because the remote path doesn't exist
// on this Mac."
let transport = context.makeTransport()
if transport.fileExists(plan.projectDir) {
throw ProjectTemplateError.projectDirExists(plan.projectDir)
}
for copy in plan.projectFiles where transport.fileExists(copy.destinationPath) {
throw ProjectTemplateError.conflictingFile(copy.destinationPath)
}
for copy in plan.skillsFiles where transport.fileExists(copy.destinationPath) {
throw ProjectTemplateError.conflictingFile(copy.destinationPath)
}
// Memory appendix collision: re-scan MEMORY.md for an existing block
// with the same template id so two installs of v1.0.0 can't
// double-append. A missing MEMORY.md is fine (treated as empty),
// but any *other* read failure (permissions, bad file type) gets
// logged + surfaced so we don't silently pretend MEMORY.md is empty
// and append over a broken file.
if plan.memoryAppendix != nil {
let existing: String
if transport.fileExists(plan.memoryPath) {
do {
let data = try transport.readFile(plan.memoryPath)
existing = String(data: data, encoding: .utf8) ?? ""
} catch {
Self.logger.error("failed to read MEMORY.md at \(plan.memoryPath, privacy: .public): \(error.localizedDescription, privacy: .public)")
throw error
}
} else {
existing = ""
}
let marker = ProjectTemplateService.memoryBlockBeginMarker(templateId: plan.manifest.id)
if existing.contains(marker) {
throw ProjectTemplateError.memoryBlockAlreadyExists(plan.manifest.id)
}
}
}
// MARK: - Project files
nonisolated private func createProjectFiles(plan: TemplateInstallPlan) throws {
let transport = context.makeTransport()
try transport.createDirectory(plan.projectDir)
for copy in plan.projectFiles {
let parent = (copy.destinationPath as NSString).deletingLastPathComponent
try transport.createDirectory(parent)
// Empty `sourceRelativePath` is the "synthesized content"
// sentinel used by `buildPlan` for `.scarf/config.json`.
// The installer materialises config.json from
// `plan.configValues` here rather than copying a bundle
// file that doesn't exist.
if copy.sourceRelativePath.isEmpty {
if copy.destinationPath.hasSuffix("/.scarf/config.json") {
let data = try encodeConfigFile(plan: plan)
try transport.writeFile(copy.destinationPath, data: data)
continue
}
throw ProjectTemplateError.requiredFileMissing(
"synthesized file with unknown destination: \(copy.destinationPath)"
)
}
let source = plan.unpackedDir + "/" + copy.sourceRelativePath
let data = try Data(contentsOf: URL(fileURLWithPath: source))
try transport.writeFile(copy.destinationPath, data: data)
}
}
/// Serialise `plan.configValues` into the `<project>/.scarf/config.json`
/// shape. Secrets appear as `keychainRef` URIs the raw bytes were
/// routed into the Keychain by the VM before `install()` was called.
nonisolated private func encodeConfigFile(plan: TemplateInstallPlan) throws -> Data {
let file = ProjectConfigFile(
schemaVersion: 2,
templateId: plan.manifest.id,
values: plan.configValues,
updatedAt: ISO8601DateFormatter().string(from: Date())
)
let encoder = JSONEncoder()
encoder.outputFormatting = [.prettyPrinted, .sortedKeys]
return try encoder.encode(file)
}
// MARK: - Skills
nonisolated private func createSkillsFiles(plan: TemplateInstallPlan) throws {
guard let namespaceDir = plan.skillsNamespaceDir else { return }
let transport = context.makeTransport()
try transport.createDirectory(namespaceDir)
for copy in plan.skillsFiles {
let source = plan.unpackedDir + "/" + copy.sourceRelativePath
let data = try Data(contentsOf: URL(fileURLWithPath: source))
let parent = (copy.destinationPath as NSString).deletingLastPathComponent
try transport.createDirectory(parent)
try transport.writeFile(copy.destinationPath, data: data)
}
}
// MARK: - Memory
nonisolated private func appendMemoryIfNeeded(plan: TemplateInstallPlan) throws {
guard let appendix = plan.memoryAppendix else { return }
let transport = context.makeTransport()
let existing = (try? transport.readFile(plan.memoryPath)).flatMap { String(data: $0, encoding: .utf8) } ?? ""
let combined = existing + appendix
guard let data = combined.data(using: .utf8) else {
throw ProjectTemplateError.requiredFileMissing("memory/append.md (non-UTF8)")
}
let parent = (plan.memoryPath as NSString).deletingLastPathComponent
try transport.createDirectory(parent)
try transport.writeFile(plan.memoryPath, data: data)
}
// MARK: - Cron
/// Create each cron job via `hermes cron create`, then immediately pause
/// it (Hermes creates jobs enabled). Returns the list of resolved job
/// names, which is what the lock file records we don't know the job
/// ids without parsing the create output, but the name is enough to
/// find + remove them later.
nonisolated private func createCronJobs(plan: TemplateInstallPlan) throws -> [String] {
guard !plan.cronJobs.isEmpty else { return [] }
let existingBefore = Set(HermesFileService(context: context).loadCronJobs().map(\.id))
var createdNames: [String] = []
for job in plan.cronJobs {
var args = ["cron", "create", "--name", job.name]
if let deliver = job.deliver, !deliver.isEmpty { args += ["--deliver", deliver] }
if let repeatCount = job.repeatCount { args += ["--repeat", String(repeatCount)] }
for skill in job.skills ?? [] where !skill.isEmpty {
args += ["--skill", skill]
}
args.append(job.schedule)
if let prompt = job.prompt, !prompt.isEmpty {
// Substitute template-author tokens with install-time
// values. Hermes doesn't set a CWD for cron runs when
// the agent fires the prompt, any relative path
// (`.scarf/config.json`, `status-log.md`, etc.) resolves
// against the agent's own dir, not the project. Templates
// use `{{PROJECT_DIR}}` as a placeholder for the absolute
// path; we swap in the real project dir here so the
// registered cron job carries a fully-qualified prompt
// that works regardless of CWD.
let resolvedPrompt = Self.substituteCronTokens(prompt, plan: plan)
args.append(resolvedPrompt)
}
let (output, exit) = context.runHermes(args)
guard exit == 0 else {
throw ProjectTemplateError.cronCreateFailed(job: job.name, output: output)
}
createdNames.append(job.name)
}
// Diff the current job set against the snapshot we took before
// creating anything new belongs to this install and gets paused.
// We pause by id (not name) because `cron pause` takes an id.
let currentJobs = HermesFileService(context: context).loadCronJobs()
let newJobs = currentJobs.filter { !existingBefore.contains($0.id) && createdNames.contains($0.name) }
for job in newJobs {
let (_, exit) = context.runHermes(["cron", "pause", job.id])
if exit != 0 {
Self.logger.warning("couldn't pause newly-created cron job \(job.id, privacy: .public) — leaving enabled")
}
}
return createdNames
}
// MARK: - Registry
nonisolated private func registerProject(plan: TemplateInstallPlan) throws -> ProjectEntry {
let service = ProjectDashboardService(context: context)
var registry = service.loadRegistry()
let entry = ProjectEntry(name: plan.projectRegistryName, path: plan.projectDir)
registry.projects.append(entry)
// Must throw on failure silent failure here used to make the
// installer return a valid entry while the registry on disk
// never got updated, producing the "install completed but the
// project doesn't show up in the sidebar" bug. If the registry
// write fails, the whole install is surfaced as failed so the
// user can see + address the underlying problem.
try service.saveRegistry(registry)
return entry
}
// MARK: - Token substitution (install-time placeholder resolution)
/// Supported placeholders for template-author prompts. Keep the set
/// intentionally small every token here becomes a load-bearing
/// part of the template format that we can't rename without
/// breaking existing bundles.
///
/// - `{{PROJECT_DIR}}`: absolute path of the newly-created project
/// directory. Required for cron prompts because Hermes doesn't
/// establish a CWD when firing cron jobs; relative paths would
/// resolve against whatever dir Hermes happens to be in.
///
/// - `{{TEMPLATE_ID}}`: the `owner/name` id from the manifest.
/// Less load-bearing; occasionally useful for tagging or
/// delivery targets that reference the template.
///
/// - `{{TEMPLATE_SLUG}}`: the sanitised slug the installer used
/// for the skills namespace and project dir name.
nonisolated static func substituteCronTokens(
_ prompt: String,
plan: TemplateInstallPlan
) -> String {
var out = prompt
out = out.replacingOccurrences(of: "{{PROJECT_DIR}}", with: plan.projectDir)
out = out.replacingOccurrences(of: "{{TEMPLATE_ID}}", with: plan.manifest.id)
out = out.replacingOccurrences(of: "{{TEMPLATE_SLUG}}", with: plan.manifest.slug)
return out
}
// MARK: - Lock file
nonisolated private func writeLockFile(
plan: TemplateInstallPlan,
cronJobNames: [String]
) throws {
// Every value that ended up as a keychainRef in config.json gets
// tracked in the lock so the uninstaller can SecItemDelete each
// entry. Field keys are recorded separately for informational
// display in the uninstall preview sheet.
let keychainItems: [String]? = {
let refs = plan.configValues.compactMap { (_, value) -> String? in
if case .keychainRef(let uri) = value { return uri } else { return nil }
}
return refs.isEmpty ? nil : refs.sorted()
}()
let configFields: [String]? = {
guard let schema = plan.configSchema, !schema.isEmpty else { return nil }
return schema.fields.map(\.key)
}()
let lock = TemplateLock(
templateId: plan.manifest.id,
templateVersion: plan.manifest.version,
templateName: plan.manifest.name,
installedAt: ISO8601DateFormatter().string(from: Date()),
projectFiles: plan.projectFiles.map(\.destinationPath),
skillsNamespaceDir: plan.skillsNamespaceDir,
skillsFiles: plan.skillsFiles.map(\.destinationPath),
cronJobNames: cronJobNames,
memoryBlockId: plan.memoryAppendix == nil ? nil : plan.manifest.id,
configKeychainItems: keychainItems,
configFields: configFields
)
let encoder = JSONEncoder()
encoder.outputFormatting = [.prettyPrinted, .sortedKeys]
let data = try encoder.encode(lock)
let path = plan.projectDir + "/.scarf/template.lock.json"
try context.makeTransport().writeFile(path, data: data)
}
}
@@ -0,0 +1,500 @@
import Foundation
import os
/// Reads, validates, and plans the install of a `.scarftemplate` bundle. Pure
/// owns no state across calls. The installer (see
/// `ProjectTemplateInstaller`) consumes the `TemplateInstallPlan` this
/// produces.
///
/// Responsibilities:
/// 1. Unpack a `.scarftemplate` zip into a caller-owned temp directory.
/// 2. Parse `template.json` and validate it against the schema we know about.
/// 3. Walk the unpacked contents and verify they match the manifest's
/// `contents` claim (so a malicious bundle can't hide files from the
/// preview sheet).
/// 4. Produce a `TemplateInstallPlan` describing every concrete filesystem
/// op the installer will perform, given a parent directory the user
/// picked.
struct ProjectTemplateService: Sendable {
private static let logger = Logger(subsystem: "com.scarf", category: "ProjectTemplateService")
let context: ServerContext
nonisolated init(context: ServerContext = .local) {
self.context = context
}
// MARK: - Inspection
/// Unpack the zip at `zipPath` into a fresh temp directory, parse and
/// validate the manifest, and walk the contents. Throws on any
/// inconsistency. On success, the caller owns `inspection.unpackedDir`
/// and must remove it once they're done.
nonisolated func inspect(zipPath: String) throws -> TemplateInspection {
let unpackedDir = try makeTempDir()
try unzip(zipPath: zipPath, intoDir: unpackedDir)
let manifestPath = unpackedDir + "/template.json"
guard FileManager.default.fileExists(atPath: manifestPath) else {
throw ProjectTemplateError.manifestMissing
}
let manifestData: Data
do {
manifestData = try Data(contentsOf: URL(fileURLWithPath: manifestPath))
} catch {
throw ProjectTemplateError.manifestParseFailed(error.localizedDescription)
}
let manifest: ProjectTemplateManifest
do {
manifest = try JSONDecoder().decode(ProjectTemplateManifest.self, from: manifestData)
} catch {
throw ProjectTemplateError.manifestParseFailed(error.localizedDescription)
}
// schemaVersion 1 is the original v2.2 bundle; 2 adds the
// optional `config` block. Both are valid. Newer versions get
// refused so the installer never silently misinterprets a
// future-shape bundle.
guard manifest.schemaVersion == 1 || manifest.schemaVersion == 2 else {
throw ProjectTemplateError.unsupportedSchemaVersion(manifest.schemaVersion)
}
// Validate the optional config schema at inspect time a
// malformed schema (duplicate keys, secret-with-default, etc.)
// gets rejected before the user ever sees the preview sheet.
if let schema = manifest.config {
do {
try ProjectConfigService.validateSchema(schema)
} catch {
throw ProjectTemplateError.manifestParseFailed(
"invalid config schema: \(error.localizedDescription)"
)
}
}
let files = try Self.walk(unpackedDir)
let cronJobs = try Self.readCronJobs(unpackedDir: unpackedDir)
try Self.verifyClaims(manifest: manifest, files: files, cronJobCount: cronJobs.count)
return TemplateInspection(
manifest: manifest,
unpackedDir: unpackedDir,
files: files,
cronJobs: cronJobs
)
}
// MARK: - Planning
/// Turn an inspection into a concrete install plan given the parent
/// directory the user picked. The plan is deterministic two calls with
/// the same inputs produce the same ops.
nonisolated func buildPlan(
inspection: TemplateInspection,
parentDir: String
) throws -> TemplateInstallPlan {
let manifest = inspection.manifest
let slug = manifest.slug
let projectDir = parentDir + "/" + slug
if FileManager.default.fileExists(atPath: projectDir) {
throw ProjectTemplateError.projectDirExists(projectDir)
}
var projectFiles: [TemplateFileCopy] = [
TemplateFileCopy(
sourceRelativePath: "README.md",
destinationPath: projectDir + "/README.md"
),
TemplateFileCopy(
sourceRelativePath: "AGENTS.md",
destinationPath: projectDir + "/AGENTS.md"
),
TemplateFileCopy(
sourceRelativePath: "dashboard.json",
destinationPath: projectDir + "/.scarf/dashboard.json"
)
]
// Optional per-agent instruction shims. Each is copied verbatim to
// its conventional project-root path; we don't try to be clever.
let instructionRoot = "instructions"
for relative in (manifest.contents.instructions ?? []) {
let source = instructionRoot + "/" + relative
guard inspection.files.contains(source) else {
throw ProjectTemplateError.requiredFileMissing(source)
}
projectFiles.append(
TemplateFileCopy(
sourceRelativePath: source,
destinationPath: projectDir + "/" + relative
)
)
}
// Namespaced skills: copied wholesale from skills/<name>/** into
// ~/.hermes/skills/templates/<slug>/<name>/**.
var skillsFiles: [TemplateFileCopy] = []
var skillsNamespaceDir: String? = nil
if let skillNames = manifest.contents.skills, !skillNames.isEmpty {
let namespaceDir = context.paths.skillsDir + "/templates/" + slug
skillsNamespaceDir = namespaceDir
for skillName in skillNames {
let prefix = "skills/" + skillName + "/"
let skillFiles = inspection.files.filter { $0.hasPrefix(prefix) }
guard !skillFiles.isEmpty else {
throw ProjectTemplateError.requiredFileMissing(prefix)
}
for relative in skillFiles {
let suffix = String(relative.dropFirst("skills/".count))
skillsFiles.append(
TemplateFileCopy(
sourceRelativePath: relative,
destinationPath: namespaceDir + "/" + suffix
)
)
}
}
}
// Cron jobs: always prefix name with the template tag so users can
// find and remove them later. Jobs ship disabled the installer
// pauses each one immediately after `cron create`.
let cronJobs: [TemplateCronJobSpec] = inspection.cronJobs.map { job in
TemplateCronJobSpec(
name: "[tmpl:\(manifest.id)] \(job.name)",
schedule: job.schedule,
prompt: job.prompt,
deliver: job.deliver,
skills: job.skills,
repeatCount: job.repeatCount
)
}
// Memory appendix: wrap whatever the template ships in
// begin/end markers so an uninstall can find and remove exactly the
// bytes this template added. `verifyClaims` already guaranteed the
// file is present so a read error here means something unusual
// (permissions, encoding, etc.); surface it with the real
// `error.localizedDescription` rather than hiding behind a
// generic "file missing."
var memoryAppendix: String? = nil
if manifest.contents.memory?.append == true {
let appendSource = inspection.unpackedDir + "/memory/append.md"
let raw: String
do {
raw = try String(contentsOf: URL(fileURLWithPath: appendSource), encoding: .utf8)
} catch {
Self.logger.error("failed to read memory/append.md in unpacked bundle: \(error.localizedDescription, privacy: .public)")
throw ProjectTemplateError.manifestParseFailed("memory/append.md: \(error.localizedDescription)")
}
memoryAppendix = Self.wrapMemoryBlock(
templateId: manifest.id,
templateVersion: manifest.version,
body: raw.trimmingCharacters(in: .whitespacesAndNewlines)
)
}
// Configuration schema + manifest cache. The installer writes
// `.scarf/config.json` (non-secret values) + `.scarf/manifest.json`
// (schema cache used by the post-install editor) when the
// template declares a non-empty schema. Both paths go into
// projectFiles so the uninstaller picks them up via the lock.
var configSchema: TemplateConfigSchema? = nil
var manifestCachePath: String? = nil
if let schema = manifest.config, !schema.isEmpty {
configSchema = schema
let configPath = projectDir + "/.scarf/config.json"
projectFiles.append(
// Source is synthesized by the installer from configValues;
// no file in the unpacked bundle maps to this entry. We use
// an empty `sourceRelativePath` as the "no physical source"
// sentinel the installer special-cases it below (see
// ProjectTemplateInstaller.createProjectFiles).
TemplateFileCopy(
sourceRelativePath: "",
destinationPath: configPath
)
)
let cachePath = projectDir + "/.scarf/manifest.json"
manifestCachePath = cachePath
projectFiles.append(
TemplateFileCopy(
sourceRelativePath: "template.json",
destinationPath: cachePath
)
)
}
return TemplateInstallPlan(
manifest: manifest,
unpackedDir: inspection.unpackedDir,
projectDir: projectDir,
projectFiles: projectFiles,
skillsNamespaceDir: skillsNamespaceDir,
skillsFiles: skillsFiles,
cronJobs: cronJobs,
memoryAppendix: memoryAppendix,
memoryPath: context.paths.memoryMD,
projectRegistryName: Self.uniqueProjectName(preferred: manifest.name, context: context),
configSchema: configSchema,
configValues: [:], // filled in by TemplateInstallerViewModel before install()
manifestCachePath: manifestCachePath
)
}
// MARK: - Cleanup
/// Remove a temp dir created by `inspect`. Safe to call if it already
/// doesn't exist (install or cancel flows both end here).
nonisolated func cleanupTempDir(_ path: String) {
try? FileManager.default.removeItem(atPath: path)
}
// MARK: - Memory block helpers (installer + future uninstaller share these)
nonisolated static func memoryBlockBeginMarker(templateId: String) -> String {
"<!-- scarf-template:\(templateId):begin -->"
}
nonisolated static func memoryBlockEndMarker(templateId: String) -> String {
"<!-- scarf-template:\(templateId):end -->"
}
nonisolated static func wrapMemoryBlock(
templateId: String,
templateVersion: String,
body: String
) -> String {
let begin = memoryBlockBeginMarker(templateId: templateId)
let end = memoryBlockEndMarker(templateId: templateId)
return "\n\n\(begin) v\(templateVersion)\n\(body)\n\(end)\n"
}
// MARK: - Private
private nonisolated func makeTempDir() throws -> String {
let base = NSTemporaryDirectory() + "scarf-template-" + UUID().uuidString
try FileManager.default.createDirectory(
atPath: base,
withIntermediateDirectories: true
)
return base
}
/// Shell out to `/usr/bin/unzip` matches the existing profile-export
/// pattern (`hermes profile import` shells to `unzip`) and avoids
/// pulling in a third-party zip library.
private nonisolated func unzip(zipPath: String, intoDir: String) throws {
let process = Process()
process.executableURL = URL(fileURLWithPath: "/usr/bin/unzip")
process.arguments = ["-qq", "-o", zipPath, "-d", intoDir]
let outPipe = Pipe()
let errPipe = Pipe()
process.standardOutput = outPipe
process.standardError = errPipe
// Foundation dup()s these handles into the child on `run()`, but the
// parent copies stay open until explicitly released. Both ends must
// be closed or each Process spawn leaks 4 fds.
func closePipes() {
try? outPipe.fileHandleForReading.close()
try? outPipe.fileHandleForWriting.close()
try? errPipe.fileHandleForReading.close()
try? errPipe.fileHandleForWriting.close()
}
do {
try process.run()
} catch {
closePipes()
throw ProjectTemplateError.unzipFailed(error.localizedDescription)
}
process.waitUntilExit()
let errData = try? errPipe.fileHandleForReading.readToEnd()
closePipes()
guard process.terminationStatus == 0 else {
let err = errData.flatMap { String(data: $0, encoding: .utf8) } ?? ""
throw ProjectTemplateError.unzipFailed(err.isEmpty ? "exit \(process.terminationStatus)" : err)
}
}
/// Recursively walk `dir` and return every file (not directory) as a
/// path relative to `dir`. Skips symlinks entirely templates should
/// never contain them, and following them could escape the unpack dir.
///
/// Both the base dir and the enumerated URLs are resolved via
/// `resolvingSymlinksInPath` before comparison. On macOS, temp dirs
/// under `/var/folders/` resolve to `/private/var/folders/`, so a
/// naive string-prefix check would produce malformed relative paths
/// when the base is unresolved but enumerated URLs are resolved.
nonisolated private static func walk(_ dir: String) throws -> [String] {
var results: [String] = []
let baseURL = URL(fileURLWithPath: dir).resolvingSymlinksInPath()
let basePath = baseURL.path.hasSuffix("/") ? baseURL.path : baseURL.path + "/"
let enumerator = FileManager.default.enumerator(
at: baseURL,
includingPropertiesForKeys: [.isRegularFileKey, .isSymbolicLinkKey],
options: [.skipsHiddenFiles]
)
while let url = enumerator?.nextObject() as? URL {
let values = try url.resourceValues(forKeys: [.isRegularFileKey, .isSymbolicLinkKey])
if values.isSymbolicLink == true {
throw ProjectTemplateError.unsafeZipEntry(url.path)
}
guard values.isRegularFile == true else { continue }
var full = url.resolvingSymlinksInPath().path
if full.hasPrefix(basePath) {
full.removeFirst(basePath.count)
}
if full.contains("..") {
throw ProjectTemplateError.unsafeZipEntry(full)
}
results.append(full)
}
return results
}
nonisolated private static func readCronJobs(unpackedDir: String) throws -> [TemplateCronJobSpec] {
let path = unpackedDir + "/cron/jobs.json"
guard FileManager.default.fileExists(atPath: path) else { return [] }
let data: Data
do {
data = try Data(contentsOf: URL(fileURLWithPath: path))
} catch {
throw ProjectTemplateError.requiredFileMissing("cron/jobs.json")
}
do {
return try JSONDecoder().decode([TemplateCronJobSpec].self, from: data)
} catch {
throw ProjectTemplateError.manifestParseFailed("cron/jobs.json: \(error.localizedDescription)")
}
}
/// Verify the manifest's `contents` claim exactly matches the unpacked
/// files. Any mismatch claimed-but-missing or present-but-unclaimed
/// throws, so the preview sheet the user sees is always accurate.
nonisolated private static func verifyClaims(
manifest: ProjectTemplateManifest,
files: [String],
cronJobCount: Int
) throws {
let fileSet = Set(files)
if manifest.contents.dashboard {
if !fileSet.contains("dashboard.json") {
throw ProjectTemplateError.requiredFileMissing("dashboard.json")
}
}
if manifest.contents.agentsMd {
if !fileSet.contains("AGENTS.md") {
throw ProjectTemplateError.requiredFileMissing("AGENTS.md")
}
}
// README and AGENTS are always required; dashboard is always required
// per spec. `contents.dashboard`/`contents.agentsMd` exist so a future
// schema can relax those rules; for v1 we hard-require them regardless.
if !fileSet.contains("README.md") {
throw ProjectTemplateError.requiredFileMissing("README.md")
}
if !fileSet.contains("AGENTS.md") {
throw ProjectTemplateError.requiredFileMissing("AGENTS.md")
}
if !fileSet.contains("dashboard.json") {
throw ProjectTemplateError.requiredFileMissing("dashboard.json")
}
if let claimed = manifest.contents.instructions {
for rel in claimed {
let full = "instructions/" + rel
if !fileSet.contains(full) {
throw ProjectTemplateError.contentClaimMismatch(
"manifest lists \(full) but the file is missing from the bundle"
)
}
}
let present = fileSet.filter { $0.hasPrefix("instructions/") }
let claimedFull = Set(claimed.map { "instructions/" + $0 })
if let extra = present.first(where: { !claimedFull.contains($0) }) {
throw ProjectTemplateError.contentClaimMismatch(
"bundle contains \(extra) but it's not listed in manifest.contents.instructions"
)
}
} else if fileSet.contains(where: { $0.hasPrefix("instructions/") }) {
throw ProjectTemplateError.contentClaimMismatch(
"bundle has instructions/ files but manifest.contents.instructions is missing"
)
}
if let claimed = manifest.contents.skills {
for name in claimed {
let prefix = "skills/" + name + "/"
if !fileSet.contains(where: { $0.hasPrefix(prefix) }) {
throw ProjectTemplateError.contentClaimMismatch(
"manifest lists skill \(name) but skills/\(name)/ has no files"
)
}
}
let presentSkills = Set(fileSet.compactMap { path -> String? in
guard path.hasPrefix("skills/") else { return nil }
let rest = path.dropFirst("skills/".count)
return rest.split(separator: "/", maxSplits: 1).first.map(String.init)
})
let claimedSet = Set(claimed)
if let extra = presentSkills.subtracting(claimedSet).first {
throw ProjectTemplateError.contentClaimMismatch(
"bundle contains skills/\(extra)/ but it's not listed in manifest.contents.skills"
)
}
} else if fileSet.contains(where: { $0.hasPrefix("skills/") }) {
throw ProjectTemplateError.contentClaimMismatch(
"bundle contains skills/ but manifest.contents.skills is missing"
)
}
let claimedCron = manifest.contents.cron ?? 0
if claimedCron != cronJobCount {
throw ProjectTemplateError.contentClaimMismatch(
"manifest.contents.cron=\(claimedCron) but bundle contains \(cronJobCount) cron jobs"
)
}
let hasMemoryFile = fileSet.contains("memory/append.md")
let claimsMemory = manifest.contents.memory?.append == true
if claimsMemory != hasMemoryFile {
throw ProjectTemplateError.contentClaimMismatch(
"manifest.contents.memory.append=\(claimsMemory) disagrees with memory/append.md presence=\(hasMemoryFile)"
)
}
// Config claim must match the schema's actual field count so
// the preview sheet is honest about the size of the configure
// step. `nil` in contents means "no schema" just like `0`;
// we normalise both to 0 before comparing.
let claimedConfig = manifest.contents.config ?? 0
let actualConfig = manifest.config?.fields.count ?? 0
if claimedConfig != actualConfig {
throw ProjectTemplateError.contentClaimMismatch(
"manifest.contents.config=\(claimedConfig) but config.schema has \(actualConfig) field(s)"
)
}
}
/// Resolve a project-registry name that doesn't collide. Deterministic
/// given the same existing registry, always returns the same answer.
nonisolated private static func uniqueProjectName(
preferred: String,
context: ServerContext
) -> String {
let existing = Set(ProjectDashboardService(context: context).loadRegistry().projects.map(\.name))
if !existing.contains(preferred) { return preferred }
var i = 2
while existing.contains("\(preferred) \(i)") {
i += 1
}
return "\(preferred) \(i)"
}
}
@@ -0,0 +1,329 @@
import Foundation
import os
/// Reverses the work of `ProjectTemplateInstaller`, driven by the
/// `<project>/.scarf/template.lock.json` the installer dropped. Symmetric
/// with the installer: `loadUninstallPlan(for:)` builds a plan the preview
/// sheet can display honestly; `uninstall(plan:)` executes it. No hidden
/// side effects every path the uninstaller touches is in the plan.
///
/// **User-added files are preserved.** The lock records exactly what the
/// installer wrote; any file the user created in the project dir after
/// install (e.g. a `sites.txt` or `status-log.md` authored by the agent
/// on first run) is listed as an "extra entry" in the plan and left on
/// disk. If the project dir ends up empty after removing lock-tracked
/// files, the dir itself is removed; otherwise the dir (with user content)
/// stays.
struct ProjectTemplateUninstaller: Sendable {
private static let logger = Logger(subsystem: "com.scarf", category: "ProjectTemplateUninstaller")
let context: ServerContext
nonisolated init(context: ServerContext = .local) {
self.context = context
}
// MARK: - Detection
/// Is the given project installed from a template that we can
/// uninstall cleanly? Cheap just a file-existence check on the lock
/// path.
nonisolated func isTemplateInstalled(project: ProjectEntry) -> Bool {
context.makeTransport().fileExists(lockPath(for: project))
}
// MARK: - Planning
/// Read the lock file, walk the filesystem + cron list, and produce a
/// plan listing every op the uninstaller will perform. Does not
/// modify anything.
nonisolated func loadUninstallPlan(for project: ProjectEntry) throws -> TemplateUninstallPlan {
let transport = context.makeTransport()
let path = lockPath(for: project)
guard transport.fileExists(path) else {
throw ProjectTemplateError.lockFileMissing(path)
}
let lockData: Data
do {
lockData = try transport.readFile(path)
} catch {
throw ProjectTemplateError.lockFileParseFailed(error.localizedDescription)
}
let lock: TemplateLock
do {
lock = try JSONDecoder().decode(TemplateLock.self, from: lockData)
} catch {
throw ProjectTemplateError.lockFileParseFailed(error.localizedDescription)
}
// Partition tracked project files into present vs. already-gone.
// The lock file itself is always in `projectFiles` the installer
// doesn't explicitly record it, but the preview sheet and the
// execute step must remove it.
var lockTrackedFiles = lock.projectFiles
lockTrackedFiles.append(path)
var toRemove: [String] = []
var alreadyGone: [String] = []
for file in lockTrackedFiles {
if transport.fileExists(file) {
toRemove.append(file)
} else {
alreadyGone.append(file)
}
}
// Scan the project dir for entries that AREN'T in the lock these
// are user-added and we preserve them. An empty project dir (after
// removing lock-tracked files) gets removed too.
let trackedSet = Set(lockTrackedFiles)
let extras = try enumerateProjectDirExtras(
projectDir: project.path,
trackedPaths: trackedSet,
transport: transport
)
let projectDirBecomesEmpty = extras.isEmpty
// Resolve cron job ids by matching lock names against the live
// list. Names that no longer exist go into the already-gone bucket
// the user likely removed them by hand.
let currentJobs = HermesFileService(context: context).loadCronJobs()
var cronToRemove: [(id: String, name: String)] = []
var cronGone: [String] = []
for name in lock.cronJobNames {
if let match = currentJobs.first(where: { $0.name == name }) {
cronToRemove.append((id: match.id, name: match.name))
} else {
cronGone.append(name)
}
}
// Memory block detection. The installer wraps its appendix between
// `<!-- scarf-template:<id>:begin -->` / `:end -->` markers; look
// for the begin marker in the current MEMORY.md. If it's missing
// (never installed, or removed by hand) we simply skip the memory
// strip step.
let memoryPath = context.paths.memoryMD
var memoryBlockPresent = false
if lock.memoryBlockId != nil {
if transport.fileExists(memoryPath),
let data = try? transport.readFile(memoryPath),
let text = String(data: data, encoding: .utf8) {
let beginMarker = ProjectTemplateService.memoryBlockBeginMarker(
templateId: lock.memoryBlockId!
)
memoryBlockPresent = text.contains(beginMarker)
}
}
return TemplateUninstallPlan(
lock: lock,
project: project,
projectFilesToRemove: toRemove,
projectFilesAlreadyGone: alreadyGone,
extraProjectEntries: extras,
projectDirBecomesEmpty: projectDirBecomesEmpty,
skillsNamespaceDir: lock.skillsNamespaceDir,
cronJobsToRemove: cronToRemove,
cronJobsAlreadyGone: cronGone,
memoryBlockPresent: memoryBlockPresent,
memoryPath: memoryPath
)
}
// MARK: - Execution
/// Execute the plan. Non-atomic: steps run in order, and if any step
/// throws, later steps don't run. v1 doesn't ship rollback the lock
/// file itself is only removed at the very end, so a mid-flight
/// failure leaves enough breadcrumbs for the user to retry or finish
/// by hand.
nonisolated func uninstall(plan: TemplateUninstallPlan) throws {
let transport = context.makeTransport()
// 1. Project files (tracked only user additions untouched).
for file in plan.projectFilesToRemove {
do {
try transport.removeFile(file)
} catch {
Self.logger.warning("couldn't remove project file \(file, privacy: .public): \(error.localizedDescription, privacy: .public)")
// keep going partial cleanup is better than bailing and
// leaving orphan skills/cron state
}
}
if plan.projectDirBecomesEmpty, transport.fileExists(plan.project.path) {
do {
try transport.removeFile(plan.project.path)
} catch {
Self.logger.warning("couldn't remove empty project dir \(plan.project.path, privacy: .public): \(error.localizedDescription, privacy: .public)")
}
}
// 2. Skills namespace dir (always removed wholesale it's
// isolated, never mixed with user skills).
if let skillsDir = plan.skillsNamespaceDir, transport.fileExists(skillsDir) {
try removeRecursively(skillsDir, transport: transport)
}
// 3. Cron jobs via CLI `hermes cron remove <id>`. A non-zero
// exit gets logged but doesn't abort the uninstall; leaving a
// stray cron job is better than leaving it AND the skills/memory
// state that was supposed to pair with it.
for job in plan.cronJobsToRemove {
let (output, exit) = context.runHermes(["cron", "remove", job.id])
if exit != 0 {
Self.logger.warning("failed to remove cron job \(job.id, privacy: .public) \(job.name, privacy: .public): \(output, privacy: .public)")
}
}
// 4. Memory block strip the bracketed block in place. Safe
// when the block is absent; we already decided presence in the
// plan and only come here when `memoryBlockPresent` was true
// AND the plan recorded a memoryBlockId.
if plan.memoryBlockPresent, let blockId = plan.lock.memoryBlockId {
try stripMemoryBlock(blockId: blockId, memoryPath: plan.memoryPath, transport: transport)
}
// 4a. Config Keychain items remove every secret the template's
// install step stashed in the login Keychain. Items that were
// already deleted (e.g. user cleaned them with Keychain Access)
// hit the `errSecItemNotFound` no-op path inside the wrapper, so
// a stale lock doesn't abort the rest of the uninstall.
let keychain = ProjectConfigKeychain()
for uri in plan.lock.configKeychainItems ?? [] {
guard let ref = TemplateKeychainRef.parse(uri) else {
Self.logger.warning("lock recorded unparseable keychain uri \(uri, privacy: .public); skipping")
continue
}
do {
try keychain.delete(ref: ref)
} catch {
Self.logger.warning("couldn't delete keychain item \(uri, privacy: .public): \(error.localizedDescription, privacy: .public)")
}
}
// 5. Projects registry remove the entry by path (more stable
// than name: user may have renamed the project in the UI).
let dashboardService = ProjectDashboardService(context: context)
var registry = dashboardService.loadRegistry()
registry.projects.removeAll { $0.path == plan.project.path }
// saveRegistry throws now log a write failure but don't abort
// the uninstall. Every earlier step already completed (files
// removed, skills removed, cron jobs removed, memory stripped,
// Keychain cleared); failing here leaves a stale registry row
// pointing at a deleted project cosmetic and easy to fix
// from the sidebar.
do {
try dashboardService.saveRegistry(registry)
} catch {
Self.logger.warning("uninstall couldn't rewrite projects registry: \(error.localizedDescription, privacy: .public)")
}
Self.logger.info("uninstalled template \(plan.lock.templateId, privacy: .public) from \(plan.project.path, privacy: .public)")
}
// MARK: - Helpers
nonisolated private func lockPath(for project: ProjectEntry) -> String {
project.path + "/.scarf/template.lock.json"
}
/// Walk the project dir and return the absolute paths of every entry
/// not in `trackedPaths`. `.scarf/` (and its remaining contents after
/// the lock is recorded) is filtered out because the installer owns
/// that directory entirely if the user dropped a file into it,
/// that's on them, but the common case is that `.scarf/` only holds
/// our dashboard.json + template.lock.json.
nonisolated private func enumerateProjectDirExtras(
projectDir: String,
trackedPaths: Set<String>,
transport: any ServerTransport
) throws -> [String] {
guard transport.fileExists(projectDir) else { return [] }
var extras: [String] = []
let entries: [String]
do {
entries = try transport.listDirectory(projectDir)
} catch {
return []
}
for entry in entries {
let full = projectDir + "/" + entry
// Skip the .scarf/ dir entirely when deciding "does the
// project dir have user content?" the only files we put
// there (dashboard.json + lock) are tracked already, and
// if they're still there the overall project is not yet
// "empty."
if entry == ".scarf" { continue }
if trackedPaths.contains(full) { continue }
extras.append(full)
}
return extras
}
/// Recursively delete a directory via the transport. The transport's
/// `removeFile` works on files and on empty directories; we walk
/// children first, then remove the now-empty parent.
nonisolated private func removeRecursively(
_ path: String,
transport: any ServerTransport
) throws {
guard transport.fileExists(path) else { return }
if transport.stat(path)?.isDirectory != true {
try transport.removeFile(path)
return
}
let entries = (try? transport.listDirectory(path)) ?? []
for entry in entries {
try removeRecursively(path + "/" + entry, transport: transport)
}
try transport.removeFile(path)
}
/// Remove the `<!-- scarf-template:<id>:begin --> :end -->` block
/// from MEMORY.md, preserving everything else. A missing end marker
/// is logged but doesn't fail we strip from the begin marker to
/// EOF in that case, on the theory that a broken template block is
/// worse than a slightly aggressive strip.
nonisolated private func stripMemoryBlock(
blockId: String,
memoryPath: String,
transport: any ServerTransport
) throws {
let beginMarker = ProjectTemplateService.memoryBlockBeginMarker(templateId: blockId)
let endMarker = ProjectTemplateService.memoryBlockEndMarker(templateId: blockId)
let data = try transport.readFile(memoryPath)
guard let text = String(data: data, encoding: .utf8) else { return }
guard let beginRange = text.range(of: beginMarker) else { return }
let stripRange: Range<String.Index>
if let endRange = text.range(of: endMarker, range: beginRange.upperBound..<text.endIndex) {
// Include the end marker and one trailing newline if present.
var upper = endRange.upperBound
if upper < text.endIndex, text[upper] == "\n" {
upper = text.index(after: upper)
}
stripRange = beginRange.lowerBound..<upper
} else {
Self.logger.warning("memory block for \(blockId, privacy: .public) has begin marker but no end marker; stripping to EOF")
stripRange = beginRange.lowerBound..<text.endIndex
}
// Also consume one leading blank line that the installer inserts
// before the begin marker, so repeated install/uninstall cycles
// don't accumulate blank lines at the insertion site.
var lower = stripRange.lowerBound
if lower > text.startIndex {
let prev = text.index(before: lower)
if text[prev] == "\n", prev > text.startIndex {
let prevPrev = text.index(before: prev)
if text[prevPrev] == "\n" {
lower = prev
}
}
}
let updated = text.replacingCharacters(in: lower..<stripRange.upperBound, with: "")
guard let outData = updated.data(using: .utf8) else { return }
try transport.writeFile(memoryPath, data: outData)
}
}
@@ -0,0 +1,115 @@
import Foundation
import os
/// Owns the sidecar that attributes Hermes session IDs to Scarf
/// project paths. The `cwd` passed to `hermes acp` at session
/// creation is ephemeral from Hermes's perspective (not written to
/// `state.db`), so Scarf keeps this Scarf-owned record parallel to
/// Hermes's session store.
///
/// File: `~/.hermes/scarf/session_project_map.json` (resolved via
/// `HermesPathSet.sessionProjectMap`).
///
/// Thread safety: all public methods are `nonisolated` and each
/// performs a single read-modify-write cycle that's atomic on
/// disk. Concurrent writers (two Scarf windows on the same
/// `~/.hermes`) are safe at the file level last write wins
/// but the in-memory read in one window may lag until that window
/// reloads. Acceptable for v2.3's scale; revisit if multi-window
/// cross-talk becomes a problem.
struct SessionAttributionService: Sendable {
private static let logger = Logger(subsystem: "com.scarf", category: "SessionAttributionService")
let context: ServerContext
nonisolated init(context: ServerContext = .local) {
self.context = context
}
// MARK: - Read
/// Load the current sidecar contents. Missing file or unparseable
/// JSON returns an empty map the sidecar is a convenience
/// index, not a source of truth for anything load-bearing.
nonisolated func load() -> SessionProjectMap {
let path = context.paths.sessionProjectMap
let transport = context.makeTransport()
guard transport.fileExists(path) else {
return SessionProjectMap()
}
do {
let data = try transport.readFile(path)
return try JSONDecoder().decode(SessionProjectMap.self, from: data)
} catch {
Self.logger.warning("session-project-map parse failed at \(path, privacy: .public): \(error.localizedDescription, privacy: .public); returning empty map")
return SessionProjectMap()
}
}
/// Look up the project path a given session was attributed to.
/// Returns nil for unattributed sessions (CLI-started, or
/// started before v2.3) those surface in the global Sessions
/// sidebar unchanged and don't appear in any project's Sessions
/// tab.
nonisolated func projectPath(for sessionID: String) -> String? {
load().mappings[sessionID]
}
/// Reverse lookup: every session ID attributed to the given
/// project path. Used by the per-project Sessions tab to filter
/// the global session list. Comparison is exact-string; the
/// registry stores absolute paths and we write absolute paths,
/// so no normalisation is needed in practice.
nonisolated func sessionIDs(forProject projectPath: String) -> Set<String> {
let map = load()
return Set(map.mappings.filter { $0.value == projectPath }.keys)
}
// MARK: - Write
/// Record that `sessionID` was created under the given project
/// path. Idempotent repeated calls for the same pair are no-
/// ops. Replacing an existing mapping (session moved to a
/// different project) is legal but expected to be rare; the
/// caller decides when that's correct.
nonisolated func attribute(sessionID: String, toProjectPath projectPath: String) {
var map = load()
if map.mappings[sessionID] == projectPath {
return
}
map.mappings[sessionID] = projectPath
map.updatedAt = SessionProjectMap.nowISO8601()
persist(map)
}
/// Remove a mapping. Called in v2.3's Sessions-tab code path is
/// minimal we don't currently prune on session delete because
/// Hermes owns session lifecycle and we don't observe deletes.
/// Exposed for future roadmap items (e.g. explicit "detach
/// from project" action) and tests.
nonisolated func forget(sessionID: String) {
var map = load()
guard map.mappings.removeValue(forKey: sessionID) != nil else { return }
map.updatedAt = SessionProjectMap.nowISO8601()
persist(map)
}
// MARK: - Private
private func persist(_ map: SessionProjectMap) {
let path = context.paths.sessionProjectMap
let transport = context.makeTransport()
let dir = context.paths.scarfDir
do {
if !transport.fileExists(dir) {
try transport.createDirectory(dir)
}
let encoder = JSONEncoder()
encoder.outputFormatting = [.prettyPrinted, .sortedKeys]
let data = try encoder.encode(map)
try transport.writeFile(path, data: data)
} catch {
Self.logger.error("failed to persist session-project-map at \(path, privacy: .public): \(error.localizedDescription, privacy: .public)")
}
}
}
@@ -0,0 +1,87 @@
import Foundation
import Observation
import os
/// Process-wide router for `scarf://install?url=` URLs. The app delegate's
/// `onOpenURL` hands the URL in here; the Projects feature observes
/// `pendingInstallURL` and presents the install sheet when it flips non-nil.
///
/// Lives outside SwiftUI so a URL can arrive before any window exists (cold
/// launch from a browser link) and still be picked up by the first
/// `ProjectsView` that appears.
@Observable
@MainActor
final class TemplateURLRouter {
private static let logger = Logger(subsystem: "com.scarf", category: "TemplateURLRouter")
static let shared = TemplateURLRouter()
/// Non-nil when an install request is waiting to be handled. Can be
/// either a remote `https://` URL (from a `scarf://install?url=` deep
/// link) or a local `file://` URL (from a Finder double-click on a
/// `.scarftemplate` file, or a drag onto the app icon). Observers read
/// this, dispatch by scheme, present the install sheet, then call
/// `consume` to clear it. Only one pending install at a time if a
/// second arrives before the first is consumed, it replaces the first
/// (matches browser-link intuition where the latest click wins).
var pendingInstallURL: URL?
private init() {}
/// Parse and validate an inbound URL. Returns `true` if the URL was
/// recognized and staged for handling. Unknown schemes or malformed
/// payloads return `false` so the caller can log/ignore. Supports:
///
/// - `scarf://install?url=https://` remote template URL from a web link.
/// - `file:////foo.scarftemplate` local file from a Finder
/// double-click or a drag onto the app icon.
@discardableResult
func handle(_ url: URL) -> Bool {
if url.isFileURL {
return handleFileURL(url)
}
if url.scheme?.lowercased() == "scarf" {
return handleScarfURL(url)
}
Self.logger.warning("Ignored URL with unknown scheme: \(url.absoluteString, privacy: .public)")
return false
}
private func handleFileURL(_ url: URL) -> Bool {
guard url.pathExtension.lowercased() == "scarftemplate" else {
Self.logger.warning("file:// URL handed to Scarf but not a .scarftemplate: \(url.absoluteString, privacy: .public)")
return false
}
pendingInstallURL = url
Self.logger.info("file:// install staged \(url.path, privacy: .public)")
return true
}
private func handleScarfURL(_ url: URL) -> Bool {
guard url.host?.lowercased() == "install" else {
Self.logger.warning("Ignored unknown scarf:// host: \(url.absoluteString, privacy: .public)")
return false
}
guard let components = URLComponents(url: url, resolvingAgainstBaseURL: false),
let raw = components.queryItems?.first(where: { $0.name == "url" })?.value,
let remote = URL(string: raw) else {
Self.logger.warning("scarf://install missing or invalid ?url=: \(url.absoluteString, privacy: .public)")
return false
}
// Refuse anything but https defense-in-depth against a browser or
// mail client that would happily hand us a javascript: or http://
// URL pointing at something unexpected.
guard remote.scheme?.lowercased() == "https" else {
Self.logger.warning("scarf://install refused non-https url=\(remote.absoluteString, privacy: .public)")
return false
}
pendingInstallURL = remote
Self.logger.info("scarf://install staged \(remote.absoluteString, privacy: .public)")
return true
}
/// Called by the install sheet once it has picked up the URL.
func consume() {
pendingInstallURL = nil
}
}
@@ -0,0 +1,40 @@
import Foundation
import Sparkle
/// Thin wrapper around Sparkle's `SPUStandardUpdaterController`.
///
/// Sparkle reads `SUFeedURL`, `SUPublicEDKey`, and check-interval defaults from Info.plist.
/// This service exposes the bits the UI needs: a "check now" trigger, a toggle for automatic
/// checks, and observable state for the Settings screen.
@MainActor
@Observable
final class UpdaterService: NSObject {
private let controller: SPUStandardUpdaterController
/// User-facing toggle. Mirrors `updater.automaticallyChecksForUpdates`.
var automaticallyChecksForUpdates: Bool {
get { controller.updater.automaticallyChecksForUpdates }
set { controller.updater.automaticallyChecksForUpdates = newValue }
}
/// Last time Sparkle checked the appcast (nil before the first check).
var lastUpdateCheckDate: Date? {
controller.updater.lastUpdateCheckDate
}
override init() {
// startingUpdater: true Sparkle scans for updates on launch per Info.plist schedule.
// Default delegates are sufficient for a non-sandboxed app.
self.controller = SPUStandardUpdaterController(
startingUpdater: true,
updaterDelegate: nil,
userDriverDelegate: nil
)
super.init()
}
/// Triggers a user-initiated update check. Sparkle handles the UI (alert, progress, install).
func checkForUpdates() {
controller.checkForUpdates(nil)
}
}
@@ -0,0 +1,191 @@
import Foundation
import os
/// `ServerTransport` over the local filesystem. Thin wrapper around
/// `FileManager`, `Process`, and `DispatchSourceFileSystemObject` the APIs
/// services were already using before Phase 2.
struct LocalTransport: ServerTransport {
nonisolated private static let logger = Logger(subsystem: "com.scarf", category: "LocalTransport")
let contextID: ServerID
let isRemote: Bool = false
nonisolated init(contextID: ServerID = ServerContext.local.id) {
self.contextID = contextID
}
// MARK: - Files
func readFile(_ path: String) throws -> Data {
do {
return try Data(contentsOf: URL(fileURLWithPath: path))
} catch {
throw TransportError.fileIO(path: path, underlying: error.localizedDescription)
}
}
func writeFile(_ path: String, data: Data) throws {
let tmp = path + ".scarf.tmp"
do {
try data.write(to: URL(fileURLWithPath: tmp))
// Preserve `0600` for dotfiles holding secrets (.env, .auth, ...).
// The existing files already use 0600 via HermesEnvService; we
// mirror that here so a brand-new file created via this write
// also starts with safe permissions.
if Self.shouldEnforcePrivateMode(for: path) {
try FileManager.default.setAttributes([.posixPermissions: 0o600], ofItemAtPath: tmp)
}
// Atomic swap onto the final path.
let destURL = URL(fileURLWithPath: path)
let tmpURL = URL(fileURLWithPath: tmp)
if FileManager.default.fileExists(atPath: path) {
_ = try FileManager.default.replaceItemAt(destURL, withItemAt: tmpURL)
} else {
// Ensure parent exists.
let parent = (path as NSString).deletingLastPathComponent
if !parent.isEmpty, !FileManager.default.fileExists(atPath: parent) {
try FileManager.default.createDirectory(atPath: parent, withIntermediateDirectories: true)
}
try FileManager.default.moveItem(at: tmpURL, to: destURL)
}
} catch {
try? FileManager.default.removeItem(atPath: tmp)
throw TransportError.fileIO(path: path, underlying: error.localizedDescription)
}
}
func fileExists(_ path: String) -> Bool {
FileManager.default.fileExists(atPath: path)
}
func stat(_ path: String) -> FileStat? {
guard let attrs = try? FileManager.default.attributesOfItem(atPath: path) else {
return nil
}
let size = (attrs[.size] as? Int64) ?? Int64((attrs[.size] as? Int) ?? 0)
let mtime = (attrs[.modificationDate] as? Date) ?? Date(timeIntervalSince1970: 0)
let isDir = (attrs[.type] as? FileAttributeType) == .typeDirectory
return FileStat(size: size, mtime: mtime, isDirectory: isDir)
}
func listDirectory(_ path: String) throws -> [String] {
do {
return try FileManager.default.contentsOfDirectory(atPath: path)
} catch {
throw TransportError.fileIO(path: path, underlying: error.localizedDescription)
}
}
func createDirectory(_ path: String) throws {
do {
try FileManager.default.createDirectory(atPath: path, withIntermediateDirectories: true)
} catch {
throw TransportError.fileIO(path: path, underlying: error.localizedDescription)
}
}
func removeFile(_ path: String) throws {
guard FileManager.default.fileExists(atPath: path) else { return }
do {
try FileManager.default.removeItem(atPath: path)
} catch {
throw TransportError.fileIO(path: path, underlying: error.localizedDescription)
}
}
// MARK: - Processes
func runProcess(executable: String, args: [String], stdin: Data?, timeout: TimeInterval?) throws -> ProcessResult {
let proc = Process()
proc.executableURL = URL(fileURLWithPath: executable)
proc.arguments = args
let stdoutPipe = Pipe()
let stderrPipe = Pipe()
let stdinPipe = Pipe()
proc.standardOutput = stdoutPipe
proc.standardError = stderrPipe
if stdin != nil { proc.standardInput = stdinPipe }
do {
try proc.run()
} catch {
throw TransportError.other(message: "Failed to launch \(executable): \(error.localizedDescription)")
}
if let stdin {
try? stdinPipe.fileHandleForWriting.write(contentsOf: stdin)
try? stdinPipe.fileHandleForWriting.close()
}
// Timeout handling: poll every 100ms up to timeout, kill on overrun.
if let timeout {
let deadline = Date().addingTimeInterval(timeout)
while proc.isRunning && Date() < deadline {
Thread.sleep(forTimeInterval: 0.1)
}
if proc.isRunning {
proc.terminate()
let partial = (try? stdoutPipe.fileHandleForReading.readToEnd()) ?? Data()
try? stdoutPipe.fileHandleForReading.close()
try? stderrPipe.fileHandleForReading.close()
throw TransportError.timeout(seconds: timeout, partialStdout: partial)
}
} else {
proc.waitUntilExit()
}
let out = (try? stdoutPipe.fileHandleForReading.readToEnd()) ?? Data()
let err = (try? stderrPipe.fileHandleForReading.readToEnd()) ?? Data()
try? stdoutPipe.fileHandleForReading.close()
try? stderrPipe.fileHandleForReading.close()
try? stdinPipe.fileHandleForWriting.close()
return ProcessResult(exitCode: proc.terminationStatus, stdout: out, stderr: err)
}
func makeProcess(executable: String, args: [String]) -> Process {
let proc = Process()
proc.executableURL = URL(fileURLWithPath: executable)
proc.arguments = args
return proc
}
// MARK: - SQLite
func snapshotSQLite(remotePath: String) throws -> URL {
// Local case: no copy needed. Services open the path directly.
URL(fileURLWithPath: remotePath)
}
// MARK: - Watching
func watchPaths(_ paths: [String]) -> AsyncStream<WatchEvent> {
AsyncStream { continuation in
// Build the source list immutably, then hand a value-typed copy
// to onTermination. Swift 6's concurrent-capture rule rejects a
// `var sources` shared between the outer builder and the inner
// termination closure.
let sources: [DispatchSourceFileSystemObject] = paths.compactMap { path in
let fd = Darwin.open(path, O_EVTONLY)
guard fd >= 0 else { return nil }
let src = DispatchSource.makeFileSystemObjectSource(
fileDescriptor: fd,
eventMask: [.write, .extend, .rename],
queue: .global()
)
src.setEventHandler { continuation.yield(.anyChanged) }
src.setCancelHandler { Darwin.close(fd) }
src.resume()
return src
}
continuation.onTermination = { _ in
for s in sources { s.cancel() }
}
}
}
// MARK: - Helpers
/// Heuristic: files that conventionally hold secrets should be created
/// with restrictive permissions so a future `scp` or editor doesn't end
/// up exposing them.
private static func shouldEnforcePrivateMode(for path: String) -> Bool {
let name = (path as NSString).lastPathComponent
return name == ".env" || name == "auth.json" || name.hasSuffix("-tokens.json")
}
}
@@ -0,0 +1,591 @@
import Foundation
import os
/// `ServerTransport` that reaches a remote Hermes installation through the
/// system `ssh`, `scp`, and `sftp` binaries.
///
/// Why system ssh (not a native library): the user's `~/.ssh/config`,
/// ssh-agent, 1Password/Secretive agents, ProxyJump, and ControlMaster
/// multiplexing all work for free. OpenSSH also owns crypto a smaller
/// audit surface than dragging libssh2 along.
///
/// **ControlMaster matters.** Without it, every remote primitive (stat, cat,
/// cp) authenticates from scratch 500ms-2s per call. With ControlMaster
/// `auto` + `ControlPersist 600`, the first call authenticates, subsequent
/// calls reuse the same TCP/crypto session at ~5ms each. We point the
/// control socket at `~/Library/Caches/scarf/ssh/%C` so multiple Scarf
/// windows pointed at the same host share one session cleanly.
struct SSHTransport: ServerTransport {
nonisolated private static let logger = Logger(subsystem: "com.scarf", category: "SSHTransport")
let contextID: ServerID
let isRemote: Bool = true
let config: SSHConfig
let displayName: String
nonisolated init(contextID: ServerID, config: SSHConfig, displayName: String) {
self.contextID = contextID
self.config = config
self.displayName = displayName
}
// MARK: - ssh/scp binary discovery
nonisolated private var sshBinary: String { "/usr/bin/ssh" }
nonisolated private var scpBinary: String { "/usr/bin/scp" }
/// The fully-qualified `user@host` spec (or just `host` if no user set).
nonisolated private var hostSpec: String {
if let user = config.user, !user.isEmpty { return "\(user)@\(config.host)" }
return config.host
}
/// Absolute path to this server's ControlMaster socket directory. One
/// socket per server, lives under the app's Caches so macOS can sweep it.
nonisolated private var controlDir: String { Self.controlDirPath() }
/// Per-server snapshot cache directory (for SQLite `.backup` drops).
nonisolated private var snapshotDir: String { Self.snapshotDirPath(for: contextID) }
/// Shared control-master socket directory (one dir, sockets within it are
/// per-host via OpenSSH's `%C` token). Exposed as a static so
/// cleanup paths (`ServerRegistry.removeServer`, app-launch sweep) can
/// compute it without instantiating a transport.
///
/// Uses a short path under /tmp to stay within the 104-byte macOS
/// Unix domain socket limit. The Caches path
/// (~/Library/Caches/scarf/ssh/%C) can exceed this limit when the
/// username is long, causing ssh to exit 255.
nonisolated static func controlDirPath() -> String {
return "/tmp/scarf-ssh-\(getuid())"
}
/// Snapshot cache directory for a given server. Stable per-ID so repeated
/// connections to the same server share the cache, and so cleanup can
/// find it from the ID alone.
nonisolated static func snapshotDirPath(for contextID: ServerID) -> String {
let base = FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask).first?.path
?? NSHomeDirectory() + "/Library/Caches"
return base + "/scarf/snapshots/\(contextID.uuidString)"
}
/// Root of the snapshot cache (all servers). Used by the app-launch sweep
/// that prunes dirs whose UUID no longer appears in the registry.
nonisolated static func snapshotRootPath() -> String {
let base = FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask).first?.path
?? NSHomeDirectory() + "/Library/Caches"
return base + "/scarf/snapshots"
}
/// Remove the snapshot directory for a server (no-op if absent). Called
/// on `removeServer` and on app-launch for orphaned dirs.
static func pruneSnapshotCache(for contextID: ServerID) {
let dir = snapshotDirPath(for: contextID)
try? FileManager.default.removeItem(atPath: dir)
}
/// Walk the snapshot root and delete any directory whose UUID isn't in
/// `keep`. Called once at app launch so snapshots from servers the user
/// removed while the app was closed don't linger.
static func sweepOrphanSnapshots(keeping keep: Set<ServerID>) {
let root = snapshotRootPath()
guard let entries = try? FileManager.default.contentsOfDirectory(atPath: root) else { return }
for name in entries {
if let id = ServerID(uuidString: name), keep.contains(id) { continue }
try? FileManager.default.removeItem(atPath: root + "/" + name)
}
}
/// Remove ControlMaster socket files older than `staleAfter` seconds.
///
/// Socket basenames are %C hashes (not ServerIDs), so we can't keep "still
/// registered" sockets the way `sweepOrphanSnapshots` does. But
/// `ControlPersist` is 600s 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.
/// Wiping these on launch keeps `/tmp/scarf-ssh-<uid>/` from accumulating
/// indefinitely until reboot, while leaving any concurrent Scarf
/// instance's live sockets (always <600s old) untouched.
static func sweepStaleControlSockets(staleAfter: TimeInterval = 1800) {
let root = controlDirPath()
guard let entries = try? FileManager.default.contentsOfDirectory(atPath: root) else { return }
let cutoff = Date().addingTimeInterval(-staleAfter)
for name in entries {
let path = root + "/" + name
guard let attrs = try? FileManager.default.attributesOfItem(atPath: path),
let mtime = attrs[.modificationDate] as? Date
else { continue }
if mtime < cutoff {
try? FileManager.default.removeItem(atPath: path)
}
}
}
/// Ask OpenSSH to shut down this host's ControlMaster socket, so the TCP
/// session isn't held open after the user removes this server. If no
/// master is currently running, `ssh -O exit` exits non-zero we ignore
/// the exit code because the desired end state (no master) is reached
/// either way.
func closeControlMaster() {
ensureControlDir()
let args = sshArgs(extra: ["-O", "exit", hostSpec])
_ = try? runLocal(executable: sshBinary, args: args, stdin: nil, timeout: 10)
}
/// Common ssh options used by every invocation. Keep every `-o` flag
/// here so we never drift between calls.
///
/// - `ControlMaster=auto` + `ControlPersist=600` gives us free connection
/// pooling for the bursty stat/cat/cp traffic the services produce.
/// - `StrictHostKeyChecking=accept-new` writes new hosts to
/// `known_hosts` silently the first time but blocks on key mismatch
/// the UX surfaced by `TransportError.hostKeyMismatch`.
/// - `ServerAliveInterval=30` makes dropped connections surface as a
/// process exit rather than a hang.
/// - `LogLevel=QUIET` suppresses the login banner so ACP's line-delimited
/// JSON stays binary-clean.
nonisolated private func sshArgs(extra: [String] = []) -> [String] {
var args: [String] = [
"-o", "ControlMaster=auto",
"-o", "ControlPath=\(controlDir)/%C",
"-o", "ControlPersist=600",
"-o", "ServerAliveInterval=30",
"-o", "ServerAliveCountMax=3",
"-o", "ConnectTimeout=10",
"-o", "StrictHostKeyChecking=accept-new",
"-o", "LogLevel=QUIET",
"-o", "BatchMode=yes" // Never prompt for passphrases; ssh-agent only.
]
if let port = config.port { args += ["-p", String(port)] }
if let id = config.identityFile, !id.isEmpty {
args += ["-i", id]
}
args += extra
return args
}
/// Ensure the ControlMaster socket directory exists, is a real directory
/// (not a symlink), is owned by us, and has mode 0700. Called before every
/// ssh invocation.
///
/// Defensive against `/tmp` pre-creation: any local user can create
/// `/tmp/scarf-ssh-<uid>` before Scarf launches. Plain `mkdir -p` plus
/// `setAttributes` would silently accept a hostile dir (since the chmod
/// fails when we don't own it, and the Foundation API swallows that). So
/// we use POSIX `mkdir` (atomic, sets perms at create time, doesn't
/// follow symlinks) and `lstat` to verify ownership when the entry
/// already exists.
nonisolated private func ensureControlDir() {
let path = controlDir
let mkResult = path.withCString { mkdir($0, 0o700) }
if mkResult == 0 { return }
let mkErr = errno
if mkErr != EEXIST {
Self.logger.error("Failed to create ControlDir \(path, privacy: .public): errno=\(mkErr)")
return
}
var st = Darwin.stat()
let lstatResult = path.withCString { lstat($0, &st) }
guard lstatResult == 0 else {
Self.logger.error("Could not lstat existing ControlDir \(path, privacy: .public): errno=\(errno)")
return
}
guard (st.st_mode & S_IFMT) == S_IFDIR else {
Self.logger.error("ControlDir \(path, privacy: .public) exists but is not a directory (possibly a symlink) — refusing to use")
return
}
guard st.st_uid == getuid() else {
Self.logger.error("ControlDir \(path, privacy: .public) owned by uid \(st.st_uid), expected \(getuid()) — refusing to use")
return
}
if (st.st_mode & 0o777) != 0o700 {
Self.logger.warning("ControlDir \(path, privacy: .public) had mode \(String(st.st_mode & 0o777, radix: 8), privacy: .public), repairing to 700")
_ = path.withCString { chmod($0, 0o700) }
}
}
/// Shell-quote a single argument for remote execution. The remote shell
/// receives our argv joined with spaces, so anything containing
/// whitespace/metacharacters must be quoted to survive that flattening.
nonisolated private static func shellQuote(_ s: String) -> String {
if s.isEmpty { return "''" }
// Safe subset: alphanumerics + a few shell-inert characters.
let safe = CharacterSet(charactersIn: "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789@%+=:,./-_")
if s.unicodeScalars.allSatisfy({ safe.contains($0) }) { return s }
// Wrap in single quotes; close/reopen around any embedded single quote.
return "'" + s.replacingOccurrences(of: "'", with: "'\\''") + "'"
}
/// Format a path for inclusion in a remote `sh -c` command. **Critical**
/// for any path containing `~/`: bash/zsh do NOT expand `~` inside
/// quotes (single OR double), so a single-quoted `'~/.hermes/foo'` is
/// passed to commands as the literal seven-character string
/// `~/.hermes/foo` and lookups fail. We rewrite the leading `~/` to
/// `$HOME/` (which DOES expand inside double quotes) and emit the path
/// double-quoted so embedded spaces / metacharacters are still safe.
///
/// Why not single-quote: that would make `$HOME` literal too. We
/// specifically need partial-expansion semantics, which is what double
/// quotes give us.
nonisolated private static func remotePathArg(_ path: String) -> String {
var p = path
if p.hasPrefix("~/") {
p = "$HOME/" + p.dropFirst(2)
} else if p == "~" {
p = "$HOME"
}
let escaped = p
.replacingOccurrences(of: "\\", with: "\\\\")
.replacingOccurrences(of: "\"", with: "\\\"")
return "\"\(escaped)\""
}
/// Run a remote shell command. Wraps in `sh -c '<command>'` and uses
/// the standard ssh-after-host placement (no `--` separator that
/// would be sent to the remote shell as a literal first token, which
/// most shells reject as "command not found"). The `command` is
/// single-quoted via `shellQuote` so ssh's argv-join-by-space doesn't
/// split it across multiple shell tokens on the remote side.
@discardableResult
nonisolated private func runRemoteShell(_ command: String, timeout: TimeInterval? = 60) throws -> ProcessResult {
var args = sshArgs()
args.append(hostSpec)
args.append("sh")
args.append("-c")
args.append(Self.shellQuote(command))
return try runLocal(executable: sshBinary, args: args, stdin: nil, timeout: timeout)
}
// MARK: - Files
func readFile(_ path: String) throws -> Data {
// `cat` is the simplest portable "give me file bytes" command; we
// don't need scp's progress machinery for typical config/memory
// files (<1 MB each).
let result = try runRemoteShell("cat \(Self.remotePathArg(path))")
if result.exitCode != 0 {
let errText = result.stderrString
// Missing file looks like exit 1 + "No such file" surface as a
// typed fileIO error so callers that treat missing == "empty"
// behave the same as they do locally.
if errText.contains("No such file") {
throw TransportError.fileIO(path: path, underlying: "No such file or directory")
}
throw TransportError.classifySSHFailure(host: config.host, exitCode: result.exitCode, stderr: errText)
}
return result.stdout
}
func writeFile(_ path: String, data: Data) throws {
// Atomic pattern:
// 1. scp to `<path>.scarf.tmp` on the remote
// 2. ssh `mv <tmp> <path>` atomic on POSIX within the same FS
// Hermes never sees a partial write.
let tmp = path + ".scarf.tmp"
// scp from a local temp file (scp reads from disk, not stdin).
let localTmpURL = FileManager.default.temporaryDirectory.appendingPathComponent(
"scarf-scp-\(UUID().uuidString).tmp"
)
do {
try data.write(to: localTmpURL)
} catch {
throw TransportError.fileIO(path: path, underlying: "local temp write: \(error.localizedDescription)")
}
defer { try? FileManager.default.removeItem(at: localTmpURL) }
ensureControlDir()
var scpArgs: [String] = [
"-o", "ControlMaster=auto",
"-o", "ControlPath=\(controlDir)/%C",
"-o", "ControlPersist=600",
"-o", "StrictHostKeyChecking=accept-new",
"-o", "LogLevel=QUIET",
"-o", "BatchMode=yes"
]
if let port = config.port { scpArgs += ["-P", String(port)] }
if let id = config.identityFile, !id.isEmpty { scpArgs += ["-i", id] }
scpArgs.append(localTmpURL.path)
scpArgs.append("\(hostSpec):\(tmp)")
let scpResult = try runLocal(executable: scpBinary, args: scpArgs, stdin: nil, timeout: 60)
if scpResult.exitCode != 0 {
throw TransportError.classifySSHFailure(host: config.host, exitCode: scpResult.exitCode, stderr: scpResult.stderrString)
}
// Now atomic mv on the remote. Note: scp/sftp DOES expand `~` (it
// goes through the SSH file transfer protocol, not a remote shell),
// so the upload landed at the resolved $HOME path. The mv is a
// shell command and needs the $HOME-rewritten path to find it.
let mvResult = try runRemoteShell("mv \(Self.remotePathArg(tmp)) \(Self.remotePathArg(path))")
if mvResult.exitCode != 0 {
// Best-effort cleanup of the orphan tmp.
_ = try? runRemoteShell("rm -f \(Self.remotePathArg(tmp))")
throw TransportError.classifySSHFailure(host: config.host, exitCode: mvResult.exitCode, stderr: mvResult.stderrString)
}
}
func fileExists(_ path: String) -> Bool {
guard let result = try? runRemoteShell("test -e \(Self.remotePathArg(path))") else {
return false
}
return result.exitCode == 0
}
func stat(_ path: String) -> FileStat? {
// macOS and Linux `stat` differ in flags. `stat -f` is macOS's BSD
// form; `stat -c` is GNU/Linux. We try the GNU form first (typical
// remote target) and fall back to BSD. The format strings use
// double quotes safe inside our outer single-quoted sh -c.
let linux = try? runRemoteShell(#"stat -c "%s %Y %F" \#(Self.remotePathArg(path))"#)
if let result = linux, result.exitCode == 0 {
return Self.parseStatOutput(result.stdoutString)
}
let bsd = try? runRemoteShell(#"stat -f "%z %m %HT" \#(Self.remotePathArg(path))"#)
if let result = bsd, result.exitCode == 0 {
return Self.parseStatOutput(result.stdoutString)
}
return nil
}
private static func parseStatOutput(_ s: String) -> FileStat? {
// Expected: "<bytes> <unix-epoch-secs> <type>" where <type> is either
// a GNU word ("regular file", "directory") or a BSD word ("Regular
// File", "Directory"). Only the first word of <type> matters for
// isDirectory.
let parts = s.trimmingCharacters(in: .whitespacesAndNewlines).split(separator: " ", maxSplits: 2)
guard parts.count >= 2 else { return nil }
let size = Int64(parts[0]) ?? 0
let mtimeSecs = TimeInterval(parts[1]) ?? 0
let typeStr = parts.count == 3 ? parts[2].lowercased() : ""
let isDir = typeStr.contains("directory")
return FileStat(size: size, mtime: Date(timeIntervalSince1970: mtimeSecs), isDirectory: isDir)
}
func listDirectory(_ path: String) throws -> [String] {
// `ls -A` lists all entries (incl. dotfiles) except `.`/`..`, one per
// line. Sort order matches local FileManager.contentsOfDirectory.
let result = try runRemoteShell("ls -A \(Self.remotePathArg(path))")
if result.exitCode != 0 {
if result.stderrString.contains("No such file") {
throw TransportError.fileIO(path: path, underlying: "No such file or directory")
}
throw TransportError.classifySSHFailure(host: config.host, exitCode: result.exitCode, stderr: result.stderrString)
}
return result.stdoutString
.split(separator: "\n", omittingEmptySubsequences: true)
.map(String.init)
}
func createDirectory(_ path: String) throws {
let result = try runRemoteShell("mkdir -p \(Self.remotePathArg(path))")
if result.exitCode != 0 {
throw TransportError.classifySSHFailure(host: config.host, exitCode: result.exitCode, stderr: result.stderrString)
}
}
func removeFile(_ path: String) throws {
let result = try runRemoteShell("rm -f \(Self.remotePathArg(path))")
if result.exitCode != 0 {
throw TransportError.classifySSHFailure(host: config.host, exitCode: result.exitCode, stderr: result.stderrString)
}
}
// MARK: - Processes
func runProcess(executable: String, args: [String], stdin: Data?, timeout: TimeInterval?) throws -> ProcessResult {
// Wrap in `sh -c '<exe> <arg> <arg>'` with `~/`-rewritten paths so
// home-relative args expand on the remote. The executable might be
// `~/.local/bin/hermes` or just `hermes`; either survives.
let cmd = ([executable] + args).map { Self.remotePathArg($0) }.joined(separator: " ")
var sshArgv = sshArgs()
sshArgv.append(hostSpec)
sshArgv.append("sh")
sshArgv.append("-c")
sshArgv.append(Self.shellQuote(cmd))
return try runLocal(executable: sshBinary, args: sshArgv, stdin: stdin, timeout: timeout)
}
func makeProcess(executable: String, args: [String]) -> Process {
ensureControlDir()
// `-T` disables pty allocation critical for binary-clean stdin/stdout
// (ACP JSON-RPC, log tail bytes). Same sh -c wrapping as runProcess
// so home-relative paths in `executable`/`args` actually expand.
let cmd = ([executable] + args).map { Self.remotePathArg($0) }.joined(separator: " ")
var sshArgv = sshArgs()
sshArgv.insert("-T", at: 0)
sshArgv.append(hostSpec)
sshArgv.append("sh")
sshArgv.append("-c")
sshArgv.append(Self.shellQuote(cmd))
let proc = Process()
proc.executableURL = URL(fileURLWithPath: sshBinary)
proc.arguments = sshArgv
proc.environment = Self.sshSubprocessEnvironment()
return proc
}
/// Environment for an ssh/scp subprocess: process env merged with
/// SSH_AUTH_SOCK / SSH_AGENT_PID harvested from the user's login shell.
/// Without this, GUI-launched Scarf can't reach 1Password / Secretive /
/// `ssh-add`'d keys that the user's terminal sees fine.
nonisolated private static func sshSubprocessEnvironment() -> [String: String] {
var env = ProcessInfo.processInfo.environment
let shellEnv = HermesFileService.enrichedEnvironment()
for key in ["SSH_AUTH_SOCK", "SSH_AGENT_PID"] {
if env[key] == nil, let value = shellEnv[key], !value.isEmpty {
env[key] = value
}
}
return env
}
// MARK: - SQLite snapshot
func snapshotSQLite(remotePath: String) throws -> URL {
try? FileManager.default.createDirectory(atPath: snapshotDir, withIntermediateDirectories: true)
let localPath = snapshotDir + "/state.db"
// `.backup` is WAL-safe: sqlite takes a consistent snapshot without
// blocking writers. A plain `cp` of a WAL-mode DB could corrupt.
let remoteTmp = "/tmp/scarf-snapshot-\(UUID().uuidString).db"
// sqlite3's `.backup` is a dot-command, not a CLI arg. The whole
// dot-command must be one shell argument (double-quoted) so sqlite3
// receives it as a single command; the backup path inside it is
// single-quoted so sqlite3 parses it correctly. The DB path is a
// separate shell argument and goes through `remotePathArg`
// (double-quoted, $HOME-aware) so `~/.hermes/state.db` actually
// resolves on the remote.
//
// The second sqlite3 invocation flips the snapshot out of WAL mode
// so the scp'd file is self-contained: `.backup` preserves the
// source's journal_mode in the destination header, so without this
// step the client would need the `-wal`/`-shm` sidecars too, and
// every read would fail with "unable to open database file".
//
// Final shell command on the remote:
// sqlite3 "$HOME/.hermes/state.db" ".backup '/tmp/scarf-snapshot-XYZ.db'" \
// && sqlite3 '/tmp/scarf-snapshot-XYZ.db' "PRAGMA journal_mode=DELETE;"
let backupScript = #"sqlite3 \#(Self.remotePathArg(remotePath)) ".backup '\#(remoteTmp)'" && sqlite3 '\#(remoteTmp)' "PRAGMA journal_mode=DELETE;" > /dev/null"#
let backup = try runRemoteShell(backupScript)
if backup.exitCode != 0 {
throw TransportError.classifySSHFailure(host: config.host, exitCode: backup.exitCode, stderr: backup.stderrString)
}
// scp the backup down. scp/sftp expands `~` natively (it goes
// through the SSH file-transfer protocol, not a remote shell), so
// remoteTmp's `/tmp/...` absolute path round-trips as-is.
ensureControlDir()
var scpArgs: [String] = [
"-o", "ControlMaster=auto",
"-o", "ControlPath=\(controlDir)/%C",
"-o", "ControlPersist=600",
"-o", "StrictHostKeyChecking=accept-new",
"-o", "LogLevel=QUIET",
"-o", "BatchMode=yes"
]
if let port = config.port { scpArgs += ["-P", String(port)] }
if let id = config.identityFile, !id.isEmpty { scpArgs += ["-i", id] }
scpArgs.append("\(hostSpec):\(remoteTmp)")
scpArgs.append(localPath)
let pull = try runLocal(executable: scpBinary, args: scpArgs, stdin: nil, timeout: 120)
// Regardless of pull outcome, try to clean up the remote tmp.
_ = try? runRemoteShell("rm -f \(Self.remotePathArg(remoteTmp))")
if pull.exitCode != 0 {
throw TransportError.classifySSHFailure(host: config.host, exitCode: pull.exitCode, stderr: pull.stderrString)
}
return URL(fileURLWithPath: localPath)
}
// MARK: - Watching
func watchPaths(_ paths: [String]) -> AsyncStream<WatchEvent> {
// Polling: call `stat -c %Y` on all paths every 3s and yield a single
// `.anyChanged` when any mtime changed vs. the prior tick. ControlMaster
// makes each stat ~5ms so the cost is bounded.
AsyncStream { continuation in
let task = Task.detached { [self] in
var lastSignature: String = ""
while !Task.isCancelled {
// Build one shell command that stats all paths in one
// ssh round-trip. Missing paths print "0" which still
// participates correctly in change detection. Paths
// get the `~``$HOME` rewrite via remotePathArg.
let argList = paths.map { Self.remotePathArg($0) }.joined(separator: " ")
let cmd = "for p in \(argList); do stat -c %Y \"$p\" 2>/dev/null || stat -f %m \"$p\" 2>/dev/null || echo 0; done"
do {
let result = try runRemoteShell(cmd, timeout: 30)
let signature = result.stdoutString.trimmingCharacters(in: .whitespacesAndNewlines)
if !signature.isEmpty && signature != lastSignature {
if !lastSignature.isEmpty {
continuation.yield(.anyChanged)
}
lastSignature = signature
}
} catch {
// Transient failure (connection drop) skip this tick.
Self.logger.debug("watchPaths poll failed: \(String(describing: error))")
}
try? await Task.sleep(nanoseconds: 3_000_000_000)
}
}
continuation.onTermination = { _ in task.cancel() }
}
}
// MARK: - Private helpers
/// Spawn a local process (ssh/scp/etc.) and collect its result. Mirrors
/// `LocalTransport.runProcess` duplicated rather than shared because
/// SSH-specific code paths live on this type and we want all Process
/// lifecycle in one place per transport.
nonisolated private func runLocal(executable: String, args: [String], stdin: Data?, timeout: TimeInterval?) throws -> ProcessResult {
ensureControlDir()
let proc = Process()
proc.executableURL = URL(fileURLWithPath: executable)
proc.arguments = args
// Inherit the user's shell environment so ssh can reach the
// ssh-agent socket. GUI-launched apps don't see SSH_AUTH_SOCK by
// default without this, terminal ssh works (because the user's
// shell exports it) but Scarf-launched ssh fails auth with exit 255.
proc.environment = Self.sshSubprocessEnvironment()
let stdoutPipe = Pipe()
let stderrPipe = Pipe()
let stdinPipe = Pipe()
proc.standardOutput = stdoutPipe
proc.standardError = stderrPipe
if stdin != nil { proc.standardInput = stdinPipe }
do {
try proc.run()
} catch {
throw TransportError.other(message: "Failed to launch \(executable): \(error.localizedDescription)")
}
if let stdin {
try? stdinPipe.fileHandleForWriting.write(contentsOf: stdin)
try? stdinPipe.fileHandleForWriting.close()
}
if let timeout {
let deadline = Date().addingTimeInterval(timeout)
while proc.isRunning && Date() < deadline {
Thread.sleep(forTimeInterval: 0.1)
}
if proc.isRunning {
proc.terminate()
let partial = (try? stdoutPipe.fileHandleForReading.readToEnd()) ?? Data()
try? stdoutPipe.fileHandleForReading.close()
try? stderrPipe.fileHandleForReading.close()
throw TransportError.timeout(seconds: timeout, partialStdout: partial)
}
} else {
proc.waitUntilExit()
}
let out = (try? stdoutPipe.fileHandleForReading.readToEnd()) ?? Data()
let err = (try? stderrPipe.fileHandleForReading.readToEnd()) ?? Data()
try? stdoutPipe.fileHandleForReading.close()
try? stderrPipe.fileHandleForReading.close()
try? stdinPipe.fileHandleForWriting.close()
return ProcessResult(exitCode: proc.terminationStatus, stdout: out, stderr: err)
}
}
@@ -0,0 +1,102 @@
import Foundation
/// Unified I/O surface shared by local and remote Hermes installations.
///
/// **Design rationale.** The services that read Hermes state (`~/.hermes/`)
/// and spawn the `hermes` CLI all boil down to a handful of primitives:
/// read/write/list files, stat file attributes, run a process to completion,
/// spawn a long-running stdio process for streaming, take a consistent DB
/// snapshot, observe file changes. `ServerTransport` exposes exactly those
/// primitives so the same service code works against either a local
/// filesystem or a remote host reached over SSH.
///
/// The primitives are deliberately **synchronous where possible** (file I/O,
/// process `run` + wait) so services don't need to become `async` end-to-end.
/// The two naturally-streaming cases log tail and ACP stdio use
/// `makeProcess` which returns a configured `Process`; services own the
/// stdio pipes and lifecycle exactly as they do today.
protocol ServerTransport: Sendable {
/// Identifies the context this transport serves. Used for cache
/// namespacing (e.g. per-server SQLite snapshot directories).
nonisolated var contextID: ServerID { get }
/// `true` if this transport talks to a remote host over SSH.
nonisolated var isRemote: Bool { get }
// MARK: - Files
nonisolated func readFile(_ path: String) throws -> Data
/// Atomic write: the file at `path` is either the previous contents or
/// the new contents, never a partial write. Preserves `0600` mode for
/// paths that match `.env` conventions so secrets stay owner-only.
nonisolated func writeFile(_ path: String, data: Data) throws
nonisolated func fileExists(_ path: String) -> Bool
nonisolated func stat(_ path: String) -> FileStat?
nonisolated func listDirectory(_ path: String) throws -> [String]
/// Create directories including intermediates. No-op if already present.
nonisolated func createDirectory(_ path: String) throws
/// Delete a file. No-op if absent.
nonisolated func removeFile(_ path: String) throws
// MARK: - Processes
/// Run a process to completion and capture its stdout/stderr. For remote
/// transports this actually invokes `ssh host -- executable args` under
/// the hood; for local it spawns `executable` directly.
nonisolated func runProcess(
executable: String,
args: [String],
stdin: Data?,
timeout: TimeInterval?
) throws -> ProcessResult
/// Return a `Process` configured for the target already pointed at the
/// right executable with the right arguments, but **not yet started**.
/// Callers attach their own `Pipe`s and call `run()`. Used by ACPClient
/// (JSON-RPC over stdio) and by `HermesLogService`'s streaming tail.
///
/// Local: `executable` + `args` verbatim.
/// Remote: `/usr/bin/ssh` + connection flags + `[host, "--", executable, args]`.
nonisolated func makeProcess(executable: String, args: [String]) -> Process
// MARK: - SQLite
/// Return a local filesystem URL pointing at a fresh, consistent copy of
/// the SQLite database at `remotePath`. For local transports this is
/// just the remote path unchanged. For SSH transports this performs
/// `sqlite3 .backup` on the remote side and scp's the backup into
/// `~/Library/Caches/scarf/<serverID>/state.db`, returning that URL.
nonisolated func snapshotSQLite(remotePath: String) throws -> URL
// MARK: - Watching
/// Observe changes to a set of paths and yield events when any of them
/// change. Local: FSEvents. Remote: polls `stat` mtime every 3s.
nonisolated func watchPaths(_ paths: [String]) -> AsyncStream<WatchEvent>
}
/// Stat-style file metadata. `nil` (return value) means the file does not
/// exist or couldn't be queried.
struct FileStat: Sendable, Hashable {
let size: Int64
let mtime: Date
let isDirectory: Bool
}
/// Result of a one-shot process invocation.
struct ProcessResult: Sendable {
let exitCode: Int32
let stdout: Data
let stderr: Data
nonisolated var stdoutString: String { String(data: stdout, encoding: .utf8) ?? "" }
nonisolated var stderrString: String { String(data: stderr, encoding: .utf8) ?? "" }
}
enum WatchEvent: Sendable {
/// Any path in the watched set changed; implementations may coalesce
/// rapid changes into one event. Consumers should treat this as "refresh
/// whatever you were displaying" rather than expecting fine-grained
/// per-path signals.
case anyChanged
}
@@ -0,0 +1,86 @@
import Foundation
/// Typed errors surfaced by `ServerTransport` implementations. The UI
/// distinguishes these so user-visible messages can be specific
/// ("authentication failed" vs. "command failed") without having to grep
/// stderr strings.
enum TransportError: LocalizedError {
/// `ssh`/`scp` could not reach the host or hit a protocol-level issue
/// (name resolution, connection refused, route error).
case hostUnreachable(host: String, stderr: String)
/// Remote rejected our credentials. Typically means no ssh-agent key is
/// loaded, or the loaded keys don't match any `authorized_keys` entry.
case authenticationFailed(host: String, stderr: String)
/// Remote `~/.ssh/known_hosts` fingerprint no longer matches. Blocking
/// we never auto-accept on mismatch.
case hostKeyMismatch(host: String, stderr: String)
/// The command ran on the remote but exited non-zero.
case commandFailed(exitCode: Int32, stderr: String)
/// Local filesystem operation failed (read/write/stat) with the OS error
/// message attached.
case fileIO(path: String, underlying: String)
/// Timed out waiting for a process to finish. `partialStdout` carries
/// whatever output was captured before the timer fired.
case timeout(seconds: TimeInterval, partialStdout: Data)
/// Something we didn't plan for. Fall-through bucket with enough context
/// for a bug report.
case other(message: String)
var errorDescription: String? {
switch self {
case .hostUnreachable(let host, _):
return "Can't reach \(host). Check the hostname, network, and SSH config."
case .authenticationFailed(let host, _):
return "SSH authentication to \(host) failed. Ensure your key is loaded in ssh-agent."
case .hostKeyMismatch(let host, _):
return "Host key for \(host) has changed. Inspect ~/.ssh/known_hosts before continuing."
case .commandFailed(let code, let stderr):
// Trim stderr to a single line for the summary; full text is in
// the associated value for disclosure views.
let firstLine = stderr.split(separator: "\n").first.map(String.init) ?? ""
return "Remote command exited \(code). \(firstLine)"
case .fileIO(let path, let msg):
return "File I/O failed at \(path): \(msg)"
case .timeout(let secs, _):
return "Command timed out after \(Int(secs))s."
case .other(let msg):
return msg
}
}
/// Full stderr (if any) for display in a disclosure view. Empty string
/// when there's no additional detail worth showing.
var diagnosticStderr: String {
switch self {
case .hostUnreachable(_, let s),
.authenticationFailed(_, let s),
.hostKeyMismatch(_, let s),
.commandFailed(_, let s):
return s
default:
return ""
}
}
/// Heuristic classifier: convert the ssh/scp stderr of a failed command
/// into a specific `TransportError`. Used by `SSHTransport` after a
/// non-zero exit. Defaults to `.commandFailed` when no known marker
/// matches.
static func classifySSHFailure(host: String, exitCode: Int32, stderr: String) -> TransportError {
let s = stderr.lowercased()
if s.contains("permission denied") || s.contains("authentication failed")
|| s.contains("publickey") && s.contains("denied") {
return .authenticationFailed(host: host, stderr: stderr)
}
if s.contains("host key verification failed")
|| s.contains("remote host identification has changed") {
return .hostKeyMismatch(host: host, stderr: stderr)
}
if s.contains("no route to host") || s.contains("connection refused")
|| s.contains("connection timed out") || s.contains("could not resolve hostname")
|| s.contains("connection closed by") && s.contains("port 22") {
return .hostUnreachable(host: host, stderr: stderr)
}
return .commandFailed(exitCode: exitCode, stderr: stderr)
}
}
@@ -0,0 +1,261 @@
import SwiftUI
struct MarkdownContentView: View {
let content: String
var body: some View {
VStack(alignment: .leading, spacing: 6) {
ForEach(Array(parseBlocks().enumerated()), id: \.offset) { _, block in
blockView(block)
}
}
}
@ViewBuilder
private func blockView(_ block: MarkdownBlock) -> some View {
switch block {
case .heading(let level, let text):
headingView(level: level, text: text)
case .paragraph(let text):
Text(MarkdownRenderer.inlineAttributedString(text))
.textSelection(.enabled)
case .codeBlock(let code, let language):
codeBlockView(code: code, language: language)
case .bulletItem(let text, let indent):
bulletView(text: text, indent: indent)
case .numberedItem(let number, let text):
numberedView(number: number, text: text)
case .blockquote(let text):
blockquoteView(text: text)
case .horizontalRule:
Divider().padding(.vertical, 4)
case .blank:
Spacer().frame(height: 4)
}
}
// MARK: - Block Views
private func headingView(level: Int, text: String) -> some View {
let font: Font = switch level {
case 1: .title.bold()
case 2: .title2.bold()
case 3: .title3.bold()
case 4: .headline
default: .subheadline.bold()
}
return Text(MarkdownRenderer.inlineAttributedString(text))
.font(font)
.textSelection(.enabled)
.padding(.top, level <= 2 ? 8 : 4)
}
private func codeBlockView(code: String, language: String?) -> some View {
VStack(alignment: .leading, spacing: 4) {
if let lang = language, !lang.isEmpty {
Text(lang)
.font(.caption2.bold())
.foregroundStyle(.secondary)
}
Text(code)
.font(.system(.callout, design: .monospaced))
.textSelection(.enabled)
.frame(maxWidth: .infinity, alignment: .leading)
}
.padding(10)
.background(Color(.textBackgroundColor).opacity(0.5))
.clipShape(RoundedRectangle(cornerRadius: 6))
.overlay(
RoundedRectangle(cornerRadius: 6)
.strokeBorder(.quaternary, lineWidth: 1)
)
}
private func bulletView(text: String, indent: Int) -> some View {
HStack(alignment: .firstTextBaseline, spacing: 6) {
Text("\u{2022}")
.foregroundStyle(.secondary)
Text(MarkdownRenderer.inlineAttributedString(text))
.textSelection(.enabled)
}
.padding(.leading, CGFloat(indent) * 16)
}
private func numberedView(number: Int, text: String) -> some View {
HStack(alignment: .firstTextBaseline, spacing: 6) {
Text("\(number).")
.foregroundStyle(.secondary)
.frame(width: 20, alignment: .trailing)
Text(MarkdownRenderer.inlineAttributedString(text))
.textSelection(.enabled)
}
}
private func blockquoteView(text: String) -> some View {
HStack(spacing: 0) {
RoundedRectangle(cornerRadius: 1)
.fill(.blue.opacity(0.5))
.frame(width: 3)
Text(MarkdownRenderer.inlineAttributedString(text))
.foregroundStyle(.secondary)
.textSelection(.enabled)
.padding(.leading, 10)
}
.padding(.vertical, 2)
}
// MARK: - Parser
private func parseBlocks() -> [MarkdownBlock] {
var blocks: [MarkdownBlock] = []
let lines = content.components(separatedBy: "\n")
var i = 0
// Skip YAML frontmatter (--- delimited block at start of file)
if i < lines.count && lines[i].trimmingCharacters(in: .whitespaces) == "---" {
i += 1
while i < lines.count {
if lines[i].trimmingCharacters(in: .whitespaces) == "---" {
i += 1
break
}
i += 1
}
}
while i < lines.count {
let line = lines[i]
let trimmed = line.trimmingCharacters(in: .whitespaces)
// Blank line
if trimmed.isEmpty {
if blocks.last != .blank {
blocks.append(.blank)
}
i += 1
continue
}
// Code block (fenced)
if trimmed.hasPrefix("```") {
let language = String(trimmed.dropFirst(3)).trimmingCharacters(in: .whitespaces)
var codeLines: [String] = []
i += 1
while i < lines.count {
if lines[i].trimmingCharacters(in: .whitespaces).hasPrefix("```") {
i += 1
break
}
codeLines.append(lines[i])
i += 1
}
blocks.append(.codeBlock(codeLines.joined(separator: "\n"), language: language.isEmpty ? nil : language))
continue
}
// Heading
if let heading = parseHeading(trimmed) {
blocks.append(heading)
i += 1
continue
}
// Horizontal rule
if isHorizontalRule(trimmed) {
blocks.append(.horizontalRule)
i += 1
continue
}
// Blockquote
if trimmed.hasPrefix("> ") {
var quoteLines: [String] = []
while i < lines.count {
let l = lines[i].trimmingCharacters(in: .whitespaces)
if l.hasPrefix("> ") {
quoteLines.append(String(l.dropFirst(2)))
} else if l.hasPrefix(">") {
quoteLines.append(String(l.dropFirst(1)))
} else {
break
}
i += 1
}
blocks.append(.blockquote(quoteLines.joined(separator: " ")))
continue
}
// Bullet list
if let bullet = parseBullet(line) {
blocks.append(bullet)
i += 1
continue
}
// Numbered list
if let numbered = parseNumbered(trimmed) {
blocks.append(numbered)
i += 1
continue
}
// Paragraph each line is its own paragraph to preserve line breaks
blocks.append(.paragraph(trimmed))
i += 1
}
return blocks
}
private func parseHeading(_ line: String) -> MarkdownBlock? {
let levels: [(prefix: String, level: Int)] = [
("##### ", 5), ("#### ", 4), ("### ", 3), ("## ", 2), ("# ", 1)
]
for (prefix, level) in levels {
if line.hasPrefix(prefix) {
return .heading(level, String(line.dropFirst(prefix.count)))
}
}
return nil
}
private func parseBullet(_ line: String) -> MarkdownBlock? {
let indent = line.prefix(while: { $0 == " " }).count / 2
let trimmed = line.trimmingCharacters(in: .whitespaces)
if trimmed.hasPrefix("- ") {
return .bulletItem(String(trimmed.dropFirst(2)), indent: indent)
}
if trimmed.hasPrefix("* ") {
return .bulletItem(String(trimmed.dropFirst(2)), indent: indent)
}
return nil
}
private func parseNumbered(_ line: String) -> MarkdownBlock? {
guard let dotIdx = line.firstIndex(of: ".") else { return nil }
let numStr = String(line[line.startIndex..<dotIdx])
guard let num = Int(numStr), line[line.index(after: dotIdx)...].hasPrefix(" ") else { return nil }
let text = String(line[line.index(dotIdx, offsetBy: 2)...])
return .numberedItem(num, text)
}
private func isHorizontalRule(_ line: String) -> Bool {
let stripped = line.replacingOccurrences(of: " ", with: "")
return (stripped.allSatisfy({ $0 == "-" }) && stripped.count >= 3) ||
(stripped.allSatisfy({ $0 == "*" }) && stripped.count >= 3) ||
(stripped.allSatisfy({ $0 == "_" }) && stripped.count >= 3)
}
}
// MARK: - Block Model
private enum MarkdownBlock: Equatable {
case heading(Int, String)
case paragraph(String)
case codeBlock(String, language: String?)
case bulletItem(String, indent: Int)
case numberedItem(Int, String)
case blockquote(String)
case horizontalRule
case blank
}
@@ -0,0 +1,10 @@
import Foundation
enum MarkdownRenderer {
/// Inline-only rendering bold, italic, code spans, links. Preserves whitespace/newlines.
static func inlineAttributedString(_ text: String) -> AttributedString {
(try? AttributedString(markdown: text, options: .init(
interpretedSyntax: .inlineOnlyPreservingWhitespace
))) ?? AttributedString(text)
}
}
@@ -2,12 +2,20 @@ import Foundation
@Observable
final class ActivityViewModel {
private let dataService = HermesDataService()
let context: ServerContext
private let dataService: HermesDataService
init(context: ServerContext = .local) {
self.context = context
self.dataService = HermesDataService(context: context)
}
var toolMessages: [HermesMessage] = []
var filterKind: ToolKind?
var filterSessionId: String?
var selectedEntry: ActivityEntry?
var toolResult: String?
var sessionPreviews: [String: String] = [:]
var isLoading = true
@@ -44,7 +52,12 @@ final class ActivityViewModel {
func load() async {
isLoading = true
let opened = await dataService.open()
// refresh() = close + reopen, which forces a fresh snapshot pull on
// remote contexts. Using open() here would short-circuit after the
// first load and show stale data for the view's lifetime. The DB
// stays open after load() returns so selectEntry() can read tool
// results without re-opening cleanup() closes on disappear.
let opened = await dataService.refresh()
guard opened else {
isLoading = false
return
@@ -54,6 +67,15 @@ final class ActivityViewModel {
isLoading = false
}
func selectEntry(_ entry: ActivityEntry?) async {
selectedEntry = entry
if let entry {
toolResult = await dataService.fetchToolResult(callId: entry.id)
} else {
toolResult = nil
}
}
func cleanup() async {
await dataService.close()
}
@@ -1,8 +1,14 @@
import SwiftUI
struct ActivityView: View {
@State private var viewModel = ActivityViewModel()
@State private var viewModel: ActivityViewModel
@Environment(AppCoordinator.self) private var coordinator
@Environment(HermesFileWatcher.self) private var fileWatcher
init(context: ServerContext) {
_viewModel = State(initialValue: ActivityViewModel(context: context))
}
var body: some View {
VStack(spacing: 0) {
@@ -17,6 +23,9 @@ struct ActivityView: View {
}
.navigationTitle("Activity")
.task { await viewModel.load() }
.onChange(of: fileWatcher.lastChangeDate) {
Task { await viewModel.load() }
}
.onDisappear { Task { await viewModel.cleanup() } }
}
@@ -57,11 +66,8 @@ struct ActivityView: View {
List(selection: Binding(
get: { viewModel.selectedEntry?.id },
set: { id in
if let id {
viewModel.selectedEntry = viewModel.filteredActivity.first(where: { $0.id == id })
} else {
viewModel.selectedEntry = nil
}
let entry = id.flatMap { id in viewModel.filteredActivity.first(where: { $0.id == id }) }
Task { await viewModel.selectEntry(entry) }
}
)) {
ForEach(viewModel.filteredActivity) { entry in
@@ -108,7 +114,7 @@ struct ActivityView: View {
VStack(alignment: .leading, spacing: 2) {
Text(entry.toolName)
.font(.title3.bold().monospaced())
Text(entry.kind.rawValue.capitalized)
Text(entry.kind.displayName)
.font(.caption)
.foregroundStyle(.secondary)
}
@@ -146,14 +152,32 @@ struct ActivityView: View {
.clipShape(RoundedRectangle(cornerRadius: 6))
}
if let result = viewModel.toolResult, !result.isEmpty {
VStack(alignment: .leading, spacing: 4) {
Text("Output")
.font(.caption.bold())
.foregroundStyle(.secondary)
Text(result)
.font(.system(.caption, design: .monospaced))
.textSelection(.enabled)
.lineLimit(50)
.padding(8)
.frame(maxWidth: .infinity, alignment: .leading)
.background(Color(.textBackgroundColor).opacity(0.5))
.clipShape(RoundedRectangle(cornerRadius: 6))
.overlay(
RoundedRectangle(cornerRadius: 6)
.strokeBorder(.quaternary, lineWidth: 1)
)
}
}
if !entry.messageContent.isEmpty {
VStack(alignment: .leading, spacing: 4) {
Text("Assistant Message")
.font(.caption.bold())
.foregroundStyle(.secondary)
Text(entry.messageContent)
.font(.caption)
.textSelection(.enabled)
MarkdownContentView(content: entry.messageContent)
.padding(8)
.frame(maxWidth: .infinity, alignment: .leading)
.background(.quaternary.opacity(0.5))
@@ -1,11 +1,34 @@
import Foundation
import AppKit
import SwiftTerm
import os
@Observable
final class ChatViewModel {
private let dataService = HermesDataService()
private let fileService = HermesFileService()
private let logger = Logger(subsystem: "com.scarf", category: "ChatViewModel")
let context: ServerContext
private let dataService: HermesDataService
private let fileService: HermesFileService
init(context: ServerContext = .local) {
self.context = context
self.dataService = HermesDataService(context: context)
self.fileService = HermesFileService(context: context)
self.richChatViewModel = RichChatViewModel(context: context)
// Probe hermes binary existence once off-main, then cache. Doing
// this synchronously inside `hermesBinaryExists`'s getter would
// block main on every chat-body re-evaluation for a remote
// context that's a SSH `test -e` round-trip on every streaming
// chunk, which manifests as the chat screen flashing or going
// blank during prompts.
Task.detached(priority: .userInitiated) { [context] in
let exists = context.fileExists(context.paths.hermesBinary)
await MainActor.run { [weak self] in
self?.hermesBinaryExists = exists
}
}
}
var recentSessions: [HermesSession] = []
var sessionPreviews: [String: String] = [:]
@@ -14,33 +37,577 @@ final class ChatViewModel {
var voiceEnabled = false
var ttsEnabled = false
var isRecording = false
var displayMode: ChatDisplayMode = .richChat
let richChatViewModel: RichChatViewModel
private var coordinator: Coordinator?
var hermesBinaryExists: Bool {
FileManager.default.fileExists(atPath: HermesPaths.hermesBinary)
/// Absolute project path for the current session, when the chat is
/// project-scoped (either started via a project's "New Chat" button
/// or resumed from a session that was previously attributed via the
/// v2.3 sidecar). Nil for plain global chats. Drives the project
/// indicator in SessionInfoBar + the `Chat · <Name>` nav title.
private(set) var currentProjectPath: String?
/// Human-readable name of the active project, resolved from the
/// projects registry at session-start time. Stored alongside the
/// path so the view renders without hitting disk on every update.
/// Nil when `currentProjectPath` is nil OR the path isn't in the
/// registry (project was removed after the session was attributed).
private(set) var currentProjectName: String?
// ACP state
private var acpClient: ACPClient?
private var acpEventTask: Task<Void, Never>?
private var acpPromptTask: Task<Void, Never>?
private var healthMonitorTask: Task<Void, Never>?
private var reconnectTask: Task<Void, Never>?
private var isHandlingDisconnect = false
var isACPConnected: Bool { acpClient != nil && hasActiveProcess }
var acpStatus: String = ""
/// True while a session is being established or restored from the user
/// kicking off "start chat" or "resume session" until the ACP session is
/// ready for messages. The chat pane uses this to show a loader in place
/// of the empty-state placeholder.
var isPreparingSession: Bool {
guard hasActiveProcess else { return false }
switch acpStatus {
case "Starting...",
"Creating session...",
"Creating new session...",
"Loading session...":
return true
default:
return acpStatus.hasPrefix("Reconnecting")
}
}
var acpError: String?
/// Human-readable hint derived from error + stderr (e.g. "set ANTHROPIC_API_KEY").
/// Shown above the raw error in the UI when present.
var acpErrorHint: String?
/// Tail of stderr captured from `hermes acp` at the time of the last
/// failure shown in a collapsible details section so users can copy/paste.
var acpErrorDetails: String?
/// True when `hasAnyAICredential()` returned false at last preflight.
var missingCredentials: Bool = false
private static let maxReconnectAttempts = 5
private static let reconnectBaseDelay: UInt64 = 1_000_000_000 // 1 second
private static let maxReconnectDelay: UInt64 = 16_000_000_000 // 16 seconds
/// Cached result of probing for `hermes` on the target server. Updated
/// once at init by a detached task; defaults to `true` so the chat
/// view doesn't briefly flash "Hermes not found" while the async
/// probe runs. Set to `false` only after the probe confirms the
/// binary really isn't there.
var hermesBinaryExists: Bool = true
/// Re-checks env + `~/.hermes/.env` for AI-provider credentials and
/// updates `missingCredentials`. Cheap safe to call from view `.task`.
func refreshCredentialPreflight() {
missingCredentials = !fileService.hasAnyAICredential()
}
func startNewSession() {
/// Clears the error/hint/details triplet so future failures overwrite
/// cleanly instead of stacking on top of stale state.
private func clearACPErrorState() {
acpError = nil
acpErrorHint = nil
acpErrorDetails = nil
}
/// Populates acpError, acpErrorHint, acpErrorDetails from an error + the
/// stderr tail the ACP client captured, and logs the failure with a
/// site-specific context label. Call on any failure path.
@MainActor
private func recordACPFailure(_ error: Error, client: ACPClient?, context: String) async {
let msg = error.localizedDescription
logger.error("\(context): \(msg)")
let stderrTail = await client?.recentStderr ?? ""
let hint = ACPErrorHint.classify(errorMessage: msg, stderrTail: stderrTail)
acpError = msg
acpErrorHint = hint
acpErrorDetails = stderrTail.isEmpty ? nil : stderrTail
}
// MARK: - Session Lifecycle
func startNewSession(projectPath: String? = nil) {
voiceEnabled = false
ttsEnabled = false
isRecording = false
launchTerminal(arguments: ["chat"])
richChatViewModel.reset()
if displayMode == .richChat {
startACPSession(resume: nil, projectPath: projectPath)
} else {
// Terminal mode doesn't surface project attribution today
// `hermes chat` uses the shell's cwd, so starting a terminal
// chat from a project button would require changing the
// shell's cwd too. Out of scope for v2.3 Rich Chat is
// the primary surface for project-scoped sessions.
launchTerminal(arguments: ["chat"])
}
}
func resumeSession(_ sessionId: String) {
voiceEnabled = false
ttsEnabled = false
isRecording = false
launchTerminal(arguments: ["chat", "--resume", sessionId])
richChatViewModel.reset()
if displayMode == .richChat {
startACPSession(resume: sessionId)
} else {
richChatViewModel.setSessionId(sessionId)
launchTerminal(arguments: ["chat", "--resume", sessionId])
}
}
func continueLastSession() {
voiceEnabled = false
ttsEnabled = false
isRecording = false
launchTerminal(arguments: ["chat", "--continue"])
richChatViewModel.reset()
if displayMode == .richChat {
// Find most recent session and resume via ACP
Task { @MainActor in
let opened = await dataService.open()
if !opened {
acpError = context.isRemote
? "Couldn't reach \(context.displayName). Check the SSH connection and try again."
: "Couldn't open the Hermes state database."
acpErrorHint = nil
acpErrorDetails = nil
return
}
let sessionId = await dataService.fetchMostRecentlyActiveSessionId()
await dataService.close()
if let sessionId {
startACPSession(resume: sessionId)
} else {
startACPSession(resume: nil)
}
}
} else {
launchTerminal(arguments: ["chat", "--continue"])
}
}
// MARK: - Send Message
func sendText(_ text: String) {
if displayMode == .richChat {
if let client = acpClient {
sendViaACP(client: client, text: text)
} else {
// Auto-start ACP and send the queued message
autoStartACPAndSend(text: text)
}
} else if let tv = terminalView {
sendToTerminal(tv, text: text + "\r")
}
}
/// Start ACP for the current session (or create a new one), then send the
/// queued prompt. Typing into a blank Chat screen ALWAYS creates a new
/// session the "Continue from Last Session" button is the explicit path
/// for resuming. The previous behavior (falling back to the most recently
/// active session in the DB) would pick up cron/background sessions the
/// user never interacted with; those can be garbage-collected by Hermes
/// between the DB read and ACP `session/load`, producing a silent prompt
/// failure with no UI feedback.
private func autoStartACPAndSend(text: String) {
// Show the user message immediately
richChatViewModel.addUserMessage(text: text)
Task { @MainActor in
let sessionToResume = richChatViewModel.sessionId
let client = ACPClient(context: context)
self.acpClient = client
do {
try await client.start()
acpStatus = await client.statusMessage
startACPEventLoop(client: client)
startHealthMonitor(client: client)
let cwd = await context.resolvedUserHome()
hasActiveProcess = true
let resolvedSessionId: String
if let existing = sessionToResume {
acpStatus = "Loading session..."
do {
resolvedSessionId = try await client.loadSession(cwd: cwd, sessionId: existing)
} catch {
logger.info("Session \(existing) not found in ACP, creating new session")
acpStatus = "Creating new session..."
resolvedSessionId = try await client.newSession(cwd: cwd)
}
} else {
acpStatus = "Creating session..."
resolvedSessionId = try await client.newSession(cwd: cwd)
}
richChatViewModel.setSessionId(resolvedSessionId)
acpStatus = "Connected (\(resolvedSessionId.prefix(12)))"
// Now send the queued prompt
sendViaACP(client: client, text: text)
} catch {
acpStatus = "Failed"
await recordACPFailure(error, client: client, context: "Auto-start ACP failed")
hasActiveProcess = false
acpClient = nil
}
}
}
private func sendViaACP(client: ACPClient, text: String) {
guard let sessionId = richChatViewModel.sessionId else {
clearACPErrorState()
acpError = "No session ID — cannot send"
return
}
// Don't duplicate user message if autoStartACPAndSend already added it
if richChatViewModel.messages.last?.isUser != true
|| richChatViewModel.messages.last?.content != text {
richChatViewModel.addUserMessage(text: text)
}
acpStatus = "Agent working..."
acpPromptTask = Task { @MainActor in
do {
let result = try await client.sendPrompt(sessionId: sessionId, text: text)
acpStatus = "Ready"
richChatViewModel.handleACPEvent(
.promptComplete(sessionId: sessionId, response: result)
)
// Re-fetch session from DB to pick up cost/token data Hermes may have written
await richChatViewModel.refreshSessionFromDB()
} catch is CancellationError {
acpStatus = "Cancelled"
} catch {
acpStatus = "Error"
await recordACPFailure(error, client: client, context: "ACP prompt failed")
richChatViewModel.handleACPEvent(
.promptComplete(sessionId: sessionId, response: ACPPromptResult(
stopReason: "error",
inputTokens: 0, outputTokens: 0,
thoughtTokens: 0, cachedReadTokens: 0
))
)
}
}
}
// MARK: - ACP Session Management
private func startACPSession(resume sessionId: String?, projectPath: String? = nil) {
stopACP()
clearACPErrorState()
acpStatus = "Starting..."
let client = ACPClient(context: context)
self.acpClient = client
let attribution = SessionAttributionService(context: context)
// If the caller passed a project path, refresh the Scarf-
// managed block in the project's AGENTS.md BEFORE starting
// ACP Hermes auto-reads AGENTS.md at session boot, so the
// block has to land on disk first. Non-blocking on failure:
// we log and proceed without the block. Safe on bare
// projects (creates AGENTS.md with just the block); safe on
// template-installed projects (splices the block into
// existing AGENTS.md without touching template content).
if let projectPath {
let registry = ProjectDashboardService(context: context).loadRegistry()
if let project = registry.projects.first(where: { $0.path == projectPath }) {
do {
try ProjectAgentContextService(context: context).refresh(for: project)
} catch {
logger.warning("couldn't refresh project context block for \(project.name): \(error.localizedDescription)")
}
}
}
Task { @MainActor in
do {
// Start ACP process and event loop FIRST
try await client.start()
acpStatus = await client.statusMessage
startACPEventLoop(client: client)
startHealthMonitor(client: client)
// Project-scoped chats pass the project's absolute path
// as cwd so Hermes tool calls and subsequent ACP ops
// resolve relative paths against the project's files.
// Falls back to the user's home (existing v2.2 behavior)
// when the caller didn't request a project scope.
// `??` can't wrap an async autoclosure, so we
// materialize the fallback with an if-let.
let cwd: String
if let projectPath {
cwd = projectPath
} else {
cwd = await context.resolvedUserHome()
}
// Mark active BEFORE setting session ID so .task(id:) sees isACPMode=true
// and doesn't wipe messages with a DB refresh
hasActiveProcess = true
let resolvedSessionId: String
if let sessionId {
acpStatus = "Loading session..."
do {
resolvedSessionId = try await client.loadSession(cwd: cwd, sessionId: sessionId)
} catch {
logger.info("Session \(sessionId) not found in ACP, creating new session with history")
acpStatus = "Creating new session..."
resolvedSessionId = try await client.newSession(cwd: cwd)
}
// Load messages from both origin CLI session and ACP session
await richChatViewModel.loadSessionHistory(
sessionId: sessionId,
acpSessionId: resolvedSessionId
)
} else {
acpStatus = "Creating session..."
resolvedSessionId = try await client.newSession(cwd: cwd)
}
richChatViewModel.setSessionId(resolvedSessionId)
acpStatus = "Connected (\(resolvedSessionId.prefix(12)))"
// Attribute this session to the project it was started
// under, so the per-project Sessions tab can surface it
// without a user action. No-op when projectPath is nil.
// Idempotent: re-attribution of the same pair is free.
if let projectPath {
attribution.attribute(
sessionID: resolvedSessionId,
toProjectPath: projectPath
)
}
// Resolve which project (if any) this session belongs
// to, so SessionInfoBar + nav title can surface it.
// Two inputs use whichever is non-nil:
// * `projectPath` the caller asked for a project
// scope (fresh project chat). Just-attributed;
// definitely in the sidecar.
// * `attribution.projectPath(for: resolvedSessionId)`
// the resumed session was previously attributed.
// Covers "click an old project-attributed session
// from the global Sessions sidebar / Resume menu"
// where projectPath isn't known at the call site.
let attributedPath = projectPath
?? attribution.projectPath(for: resolvedSessionId)
if let path = attributedPath {
// Look up a human-readable name from the projects
// registry. Missing project (path in the sidecar,
// project since removed) show the path as a
// fallback label so the chip still renders and the
// user sees *something* rather than silently losing
// the indicator.
let registry = ProjectDashboardService(context: context).loadRegistry()
let name = registry.projects.first(where: { $0.path == path })?.name
self.currentProjectPath = path
self.currentProjectName = name ?? path
} else {
// Explicit clear on non-project sessions so the
// indicator doesn't leak from a previous chat.
self.currentProjectPath = nil
self.currentProjectName = nil
}
// Refresh session list so the new ACP session appears in the Resume menu
await loadRecentSessions()
logger.info("ACP session ready: \(resolvedSessionId)")
} catch {
acpStatus = "Failed"
await recordACPFailure(error, client: client, context: "Failed to start ACP session")
hasActiveProcess = false
acpClient = nil
}
}
}
private func startACPEventLoop(client: ACPClient) {
acpEventTask = Task { @MainActor [weak self] in
let eventStream = await client.events
for await event in eventStream {
guard !Task.isCancelled else { break }
self?.richChatViewModel.handleACPEvent(event)
self?.acpStatus = await client.statusMessage
}
// Stream ended if we weren't cancelled, the connection died
if !Task.isCancelled {
self?.handleConnectionDied()
}
}
}
private func startHealthMonitor(client: ACPClient) {
healthMonitorTask = Task { @MainActor [weak self] in
while !Task.isCancelled {
try? await Task.sleep(nanoseconds: 5_000_000_000)
guard !Task.isCancelled else { break }
let healthy = await client.isHealthy
if !healthy {
self?.handleConnectionDied()
break
}
}
}
}
private func handleConnectionDied() {
guard acpClient != nil, !isHandlingDisconnect else { return }
isHandlingDisconnect = true
logger.warning("ACP connection died")
// Finalize any in-progress streaming message before reconnection
richChatViewModel.finalizeOnDisconnect()
// Save session ID for reconnection before cleaning up
let savedSessionId = richChatViewModel.sessionId
// Clean up the dead client
acpPromptTask?.cancel()
acpPromptTask = nil
acpEventTask?.cancel()
acpEventTask = nil
healthMonitorTask?.cancel()
healthMonitorTask = nil
if let client = acpClient {
Task { await client.stop() }
}
acpClient = nil
hasActiveProcess = false
// Attempt auto-reconnect if we have a session to restore
guard let savedSessionId else {
showConnectionFailure()
isHandlingDisconnect = false
return
}
attemptReconnect(sessionId: savedSessionId)
}
private func attemptReconnect(sessionId: String) {
reconnectTask?.cancel()
clearACPErrorState()
reconnectTask = Task { @MainActor [weak self] in
guard let self else { return }
for attempt in 1...Self.maxReconnectAttempts {
guard !Task.isCancelled else { return }
acpStatus = "Reconnecting (\(attempt)/\(Self.maxReconnectAttempts))..."
logger.info("Reconnect attempt \(attempt)/\(Self.maxReconnectAttempts) for session \(sessionId)")
// Backoff delay (skip on first attempt for fast recovery)
if attempt > 1 {
let delay = min(
Self.reconnectBaseDelay * UInt64(1 << (attempt - 1)),
Self.maxReconnectDelay
)
try? await Task.sleep(nanoseconds: delay)
guard !Task.isCancelled else { return }
}
let client = ACPClient(context: context)
do {
try await client.start()
let cwd = await context.resolvedUserHome()
let resolvedSessionId: String
// Try resumeSession first (designed for reconnection), then loadSession.
// NEVER fall back to newSession that loses all conversation context.
do {
resolvedSessionId = try await client.resumeSession(cwd: cwd, sessionId: sessionId)
} catch {
logger.info("session/resume failed, trying session/load: \(error.localizedDescription)")
resolvedSessionId = try await client.loadSession(cwd: cwd, sessionId: sessionId)
}
// Success wire up the new client
self.acpClient = client
self.hasActiveProcess = true
richChatViewModel.setSessionId(resolvedSessionId)
// Reconcile in-memory messages with what Hermes persisted to DB
await richChatViewModel.reconcileWithDB(sessionId: resolvedSessionId)
acpStatus = "Reconnected (\(resolvedSessionId.prefix(12)))"
clearACPErrorState()
startACPEventLoop(client: client)
startHealthMonitor(client: client)
isHandlingDisconnect = false
logger.info("Reconnected successfully on attempt \(attempt)")
return
} catch {
logger.warning("Reconnect attempt \(attempt) failed: \(error.localizedDescription)")
await client.stop()
continue
}
}
// All attempts exhausted
guard !Task.isCancelled else { return }
showConnectionFailure()
isHandlingDisconnect = false
}
}
private func showConnectionFailure() {
richChatViewModel.handleACPEvent(.connectionLost(reason: "The ACP process terminated unexpectedly"))
acpStatus = "Connection lost"
clearACPErrorState()
acpError = "Connection lost. Use the Session menu to reconnect."
}
func stopACP() {
reconnectTask?.cancel()
reconnectTask = nil
acpPromptTask?.cancel()
acpPromptTask = nil
acpEventTask?.cancel()
acpEventTask = nil
healthMonitorTask?.cancel()
healthMonitorTask = nil
if let client = acpClient {
Task { await client.stop() }
}
acpClient = nil
hasActiveProcess = false
isHandlingDisconnect = false
}
/// Respond to a permission request from the ACP agent.
func respondToPermission(optionId: String) {
guard let client = acpClient,
let permission = richChatViewModel.pendingPermission else { return }
Task {
await client.respondToPermission(requestId: permission.requestId, optionId: optionId)
}
richChatViewModel.pendingPermission = nil
}
// MARK: - Recent Sessions
func loadRecentSessions() async {
let opened = await dataService.open()
guard opened else { return }
@@ -55,6 +622,8 @@ final class ChatViewModel {
return session.id
}
// MARK: - Voice (terminal mode only)
func toggleVoice() {
guard let tv = terminalView else { return }
if voiceEnabled {
@@ -76,18 +645,21 @@ final class ChatViewModel {
func pushToTalk() {
guard let tv = terminalView, voiceEnabled else { return }
// Ctrl+B = ASCII 0x02
let ctrlB: [UInt8] = [0x02]
tv.send(source: tv, data: ctrlB[0..<1])
isRecording.toggle()
}
// MARK: - Terminal Mode
private func sendToTerminal(_ tv: LocalProcessTerminalView, text: String) {
let bytes = Array(text.utf8)
tv.send(source: tv, data: bytes[0..<bytes.count])
}
private func launchTerminal(arguments: [String]) {
stopACP()
if let existing = terminalView {
existing.terminate()
existing.removeFromSuperview()
@@ -102,6 +674,7 @@ final class ChatViewModel {
self?.hasActiveProcess = false
self?.voiceEnabled = false
self?.isRecording = false
Task { await self?.richChatViewModel.refreshMessages() }
})
terminal.processDelegate = coord
self.coordinator = coord
@@ -109,11 +682,44 @@ final class ChatViewModel {
var env = ProcessInfo.processInfo.environment
env["TERM"] = "xterm-256color"
env["COLORTERM"] = "truecolor"
// Inherit ssh-agent socket for remote so password-less auth works.
if context.isRemote {
let shellEnv = HermesFileService.enrichedEnvironment()
for key in ["SSH_AUTH_SOCK", "SSH_AGENT_PID"] {
if env[key] == nil, let v = shellEnv[key], !v.isEmpty {
env[key] = v
}
}
}
let envArray = env.map { "\($0.key)=\($0.value)" }
// For remote: wrap the invocation in `ssh -t host -- hermes <args>`
// so the embedded terminal opens a pty against the remote and the
// hermes TUI gets the bytes it expects. `-t` requests a pty (the
// SwiftTerm view is one).
let exe: String
let argv: [String]
if context.isRemote, case .ssh(let cfg) = context.kind {
let host = cfg.user.map { "\($0)@\(cfg.host)" } ?? cfg.host
exe = "/usr/bin/ssh"
var sshArgs: [String] = ["-t"]
if let port = cfg.port { sshArgs += ["-p", String(port)] }
if let id = cfg.identityFile, !id.isEmpty { sshArgs += ["-i", id] }
sshArgs += ["-o", "StrictHostKeyChecking=accept-new"]
sshArgs += ["-o", "BatchMode=yes"]
sshArgs.append(host)
sshArgs.append("--")
sshArgs.append(context.paths.hermesBinary)
sshArgs.append(contentsOf: arguments)
argv = sshArgs
} else {
exe = context.paths.hermesBinary
argv = arguments
}
terminal.startProcess(
executable: HermesPaths.hermesBinary,
args: arguments,
executable: exe,
args: argv,
environment: envArray,
execName: nil
)
@@ -0,0 +1,669 @@
import Foundation
enum ChatDisplayMode: String, CaseIterable {
case terminal
case richChat
}
struct MessageGroup: Identifiable {
let id: Int
let userMessage: HermesMessage?
let assistantMessages: [HermesMessage]
let toolResults: [String: HermesMessage]
var allMessages: [HermesMessage] {
var result: [HermesMessage] = []
if let user = userMessage { result.append(user) }
result.append(contentsOf: assistantMessages)
return result
}
var toolCallCount: Int {
assistantMessages.reduce(0) { $0 + $1.toolCalls.count }
}
}
@Observable
final class RichChatViewModel {
let context: ServerContext
private let dataService: HermesDataService
init(context: ServerContext = .local) {
self.context = context
self.dataService = HermesDataService(context: context)
loadQuickCommands()
}
var messages: [HermesMessage] = []
var currentSession: HermesSession?
var messageGroups: [MessageGroup] = []
var isAgentWorking = false
var pendingPermission: PendingPermission?
/// Mutated to trigger a scroll-to-bottom in the message list.
var scrollTrigger = UUID()
// Cumulative ACP token tracking (ACP returns tokens per prompt but DB has none)
private(set) var acpInputTokens = 0
private(set) var acpOutputTokens = 0
private(set) var acpThoughtTokens = 0
private(set) var acpCachedReadTokens = 0
/// Slash commands advertised by the ACP server via `available_commands_update`.
private(set) var acpCommands: [HermesSlashCommand] = []
/// User-defined commands parsed from `config.yaml` `quick_commands`.
private(set) var quickCommands: [HermesSlashCommand] = []
/// Merged list, ACP-first, de-duplicated by name.
var availableCommands: [HermesSlashCommand] {
let acpNames = Set(acpCommands.map(\.name))
return acpCommands + quickCommands.filter { !acpNames.contains($0.name) }
}
var supportsCompress: Bool { availableCommands.contains { $0.name == "compress" } }
/// True when the menu carries more than just `/compress` used to hide
/// the dedicated compress button in favor of the full slash menu.
var hasBroaderCommandMenu: Bool { availableCommands.count > 1 }
var hasMessages: Bool { !messages.isEmpty }
func requestScrollToBottom() {
scrollTrigger = UUID()
}
private(set) var sessionId: String?
/// The original CLI session ID when resuming a CLI session via ACP.
/// Used to combine old CLI messages with new ACP messages.
private(set) var originSessionId: String?
private var nextLocalId = -1
private var streamingAssistantText = ""
private var streamingThinkingText = ""
private var streamingToolCalls: [HermesToolCall] = []
// DB polling state (used in terminal mode fallback)
private var lastKnownFingerprint: HermesDataService.MessageFingerprint?
private var debounceTask: Task<Void, Never>?
private var resetTimestamp: Date?
private var userSendPending = false
private var activePollingTimer: Timer?
struct PendingPermission {
let requestId: Int
let title: String
let kind: String
let options: [(optionId: String, name: String)]
}
// MARK: - Reset
func reset() {
debounceTask?.cancel()
stopActivePolling()
Task { await dataService.close() }
messages = []
messageGroups = []
currentSession = nil
lastKnownFingerprint = nil
sessionId = nil
originSessionId = nil
isAgentWorking = false
userSendPending = false
resetTimestamp = Date()
nextLocalId = -1
streamingAssistantText = ""
streamingThinkingText = ""
streamingToolCalls = []
acpInputTokens = 0
acpOutputTokens = 0
acpThoughtTokens = 0
acpCachedReadTokens = 0
acpCommands = []
pendingPermission = nil
loadQuickCommands()
}
func setSessionId(_ id: String?) {
sessionId = id
lastKnownFingerprint = nil
}
func cleanup() async {
stopActivePolling()
debounceTask?.cancel()
await dataService.close()
}
/// Re-fetch session metadata from DB to pick up cost/token updates.
func refreshSessionFromDB() async {
guard let sessionId else { return }
let opened = await dataService.open()
guard opened else { return }
if let session = await dataService.fetchSession(id: sessionId) {
currentSession = session
}
await dataService.close()
}
// MARK: - ACP Event Handling
/// Add a user message immediately (before DB write) for instant UI feedback.
func addUserMessage(text: String) {
let id = nextLocalId
nextLocalId -= 1
let message = HermesMessage(
id: id,
sessionId: sessionId ?? "",
role: "user",
content: text,
toolCallId: nil,
toolCalls: [],
toolName: nil,
timestamp: Date(),
tokenCount: nil,
finishReason: nil,
reasoning: nil
)
messages.append(message)
isAgentWorking = true
streamingAssistantText = ""
streamingThinkingText = ""
streamingToolCalls = []
buildMessageGroups()
// User just submitted jump to the bottom so they see their message
// and the incoming response. `.defaultScrollAnchor(.bottom)` handles
// slow streaming fine, but rapid responses (slash commands especially)
// arrive faster than the anchor can track.
requestScrollToBottom()
}
/// Process a streaming ACP event and update the message list.
func handleACPEvent(_ event: ACPEvent) {
switch event {
case .messageChunk(_, let text):
appendMessageChunk(text: text)
case .thoughtChunk(_, let text):
appendThoughtChunk(text: text)
case .toolCallStart(_, let call):
handleToolCallStart(call)
case .toolCallUpdate(_, let update):
handleToolCallComplete(update)
case .permissionRequest(_, let requestId, let request):
pendingPermission = PendingPermission(
requestId: requestId,
title: request.toolCallTitle,
kind: request.toolCallKind,
options: request.options
)
case .promptComplete(_, let response):
handlePromptComplete(response: response)
case .connectionLost(let reason):
handleConnectionLost(reason: reason)
case .availableCommands(_, let commands):
acpCommands = parseACPCommands(commands)
case .unknown:
break
}
}
private func parseACPCommands(_ commands: [[String: Any]]) -> [HermesSlashCommand] {
var result: [HermesSlashCommand] = []
for entry in commands {
guard let rawName = entry["name"] as? String else { continue }
// Hermes sends names either as "compress" or "/compress"
let name = rawName.trimmingCharacters(in: CharacterSet(charactersIn: "/"))
guard !name.isEmpty else { continue }
let description = (entry["description"] as? String) ?? ""
var hint: String? = nil
if let input = entry["input"] as? [String: Any],
let h = input["hint"] as? String,
!h.isEmpty {
hint = h
}
result.append(HermesSlashCommand(
name: name,
description: description,
argumentHint: hint,
source: .acp
))
}
return result
}
/// Load `quick_commands` from `config.yaml` off the main actor and publish
/// them as slash commands. Safe to call repeatedly replaces the existing list.
func loadQuickCommands() {
let ctx = context
Task.detached { [weak self] in
let loaded = QuickCommandsViewModel.loadQuickCommands(context: ctx)
let mapped = loaded.map { qc -> HermesSlashCommand in
let truncated = qc.command.count > 60
? String(qc.command.prefix(60)) + ""
: qc.command
return HermesSlashCommand(
name: qc.name,
description: "Run: \(truncated)",
argumentHint: nil,
source: .quickCommand
)
}
await MainActor.run { [weak self] in
self?.quickCommands = mapped
}
}
}
private func appendMessageChunk(text: String) {
streamingAssistantText += text
upsertStreamingMessage()
}
private func appendThoughtChunk(text: String) {
streamingThinkingText += text
upsertStreamingMessage()
}
private func handleToolCallStart(_ call: ACPToolCallEvent) {
let toolCall = HermesToolCall(
callId: call.toolCallId,
functionName: call.functionName,
arguments: call.argumentsJSON
)
streamingToolCalls.append(toolCall)
upsertStreamingMessage()
}
private func handleToolCallComplete(_ update: ACPToolCallUpdateEvent) {
// Finalize the streaming assistant message (with its tool calls) as a permanent message
finalizeStreamingMessage()
// Add tool result message
let id = nextLocalId
nextLocalId -= 1
messages.append(HermesMessage(
id: id,
sessionId: sessionId ?? "",
role: "tool",
content: update.rawOutput ?? update.content,
toolCallId: update.toolCallId,
toolCalls: [],
toolName: nil,
timestamp: Date(),
tokenCount: nil,
finishReason: nil,
reasoning: nil
))
buildMessageGroups()
}
private func handlePromptComplete(response: ACPPromptResult) {
// Detect a failed prompt that produced no assistant output e.g.
// Hermes returning `stopReason: "refusal"` when the session was
// silently garbage-collected, or `"error"` when the ACP call itself
// threw. Without surfacing this, the user sees their prompt sitting
// alone under "Agent working" that never completes with any text.
let hadAssistantOutput = streamingAssistantText.isEmpty == false
|| messages.last?.isAssistant == true
finalizeStreamingMessage()
if !hadAssistantOutput, response.stopReason != "end_turn" {
let reason: String
switch response.stopReason {
case "refusal":
reason = "The agent refused to respond (the session may have been cleared on the server). Try starting a new session from the Session menu."
case "error":
reason = "The prompt failed — check the ACP error banner above for details."
case "max_tokens":
reason = "The response was cut off before the agent could produce any output (max_tokens reached before any tokens were emitted)."
default:
reason = "The prompt ended without a response (stopReason: \(response.stopReason))."
}
let id = nextLocalId
nextLocalId -= 1
messages.append(HermesMessage(
id: id,
sessionId: sessionId ?? "",
role: "system",
content: reason,
toolCallId: nil,
toolCalls: [],
toolName: nil,
timestamp: Date(),
tokenCount: nil,
finishReason: response.stopReason,
reasoning: nil
))
}
// Accumulate token usage from this prompt
acpInputTokens += response.inputTokens
acpOutputTokens += response.outputTokens
acpThoughtTokens += response.thoughtTokens
acpCachedReadTokens += response.cachedReadTokens
isAgentWorking = false
buildMessageGroups()
// Final position after the prompt settles. Catches fast responses
// (slash commands, short replies) where `.defaultScrollAnchor(.bottom)`
// didn't quite track the abrupt content growth.
requestScrollToBottom()
}
private func handleConnectionLost(reason: String) {
finalizeStreamingMessage()
let id = nextLocalId
nextLocalId -= 1
messages.append(HermesMessage(
id: id,
sessionId: sessionId ?? "",
role: "system",
content: "Connection lost: \(reason). Use the Session menu to start or resume a session.",
toolCallId: nil,
toolCalls: [],
toolName: nil,
timestamp: Date(),
tokenCount: nil,
finishReason: nil,
reasoning: nil
))
isAgentWorking = false
pendingPermission = nil
buildMessageGroups()
}
// MARK: - Streaming Message Management
private static let streamingId = 0
/// Insert or update the in-progress streaming assistant message (id=0).
private func upsertStreamingMessage() {
let msg = HermesMessage(
id: Self.streamingId,
sessionId: sessionId ?? "",
role: "assistant",
content: streamingAssistantText,
toolCallId: nil,
toolCalls: streamingToolCalls,
toolName: nil,
timestamp: Date(),
tokenCount: nil,
finishReason: nil,
reasoning: streamingThinkingText.isEmpty ? nil : streamingThinkingText
)
if let idx = messages.firstIndex(where: { $0.id == Self.streamingId }) {
messages[idx] = msg
} else {
messages.append(msg)
}
buildMessageGroups()
}
/// Convert the streaming message (id=0) into a permanent message and reset streaming state.
private func finalizeStreamingMessage() {
guard let idx = messages.firstIndex(where: { $0.id == Self.streamingId }) else { return }
// Only finalize if there's actual content
let hasContent = !streamingAssistantText.isEmpty
|| !streamingThinkingText.isEmpty
|| !streamingToolCalls.isEmpty
if hasContent {
let id = nextLocalId
nextLocalId -= 1
messages[idx] = HermesMessage(
id: id,
sessionId: sessionId ?? "",
role: "assistant",
content: streamingAssistantText,
toolCallId: nil,
toolCalls: streamingToolCalls,
toolName: nil,
timestamp: Date(),
tokenCount: nil,
finishReason: streamingToolCalls.isEmpty ? "stop" : nil,
reasoning: streamingThinkingText.isEmpty ? nil : streamingThinkingText
)
} else {
// Remove empty streaming placeholder
messages.remove(at: idx)
}
// Reset streaming state for next chunk
streamingAssistantText = ""
streamingThinkingText = ""
streamingToolCalls = []
}
// MARK: - Disconnect Recovery
/// Finalize streaming state on disconnect, before reconnection attempts begin.
/// Saves partial content as a permanent message without adding a system message.
func finalizeOnDisconnect() {
finalizeStreamingMessage()
isAgentWorking = false
pendingPermission = nil
buildMessageGroups()
}
/// Reconcile in-memory messages with DB state after a successful reconnection.
/// Merges DB-persisted messages with any local-only messages (e.g., user messages
/// that the ACP process may not have persisted before crashing).
func reconcileWithDB(sessionId: String) async {
let opened = await dataService.open()
guard opened else { return }
var dbMessages = await dataService.fetchMessages(sessionId: sessionId)
// If we have an origin session (CLI session continued via ACP),
// include those messages too
if let origin = originSessionId, origin != sessionId {
let originMessages = await dataService.fetchMessages(sessionId: origin)
if !originMessages.isEmpty {
dbMessages = originMessages + dbMessages
dbMessages.sort { ($0.timestamp ?? .distantPast) < ($1.timestamp ?? .distantPast) }
}
}
let session = await dataService.fetchSession(id: sessionId)
await dataService.close()
// Find local-only user messages not yet in DB.
// Local messages have negative IDs; DB messages have positive IDs.
let dbUserContents = Set(dbMessages.filter(\.isUser).map(\.content))
let localOnlyMessages = messages.filter { msg in
msg.id < 0 && msg.isUser && !dbUserContents.contains(msg.content)
}
// Build reconciled list: DB messages + unmatched local user messages
var reconciled = dbMessages
for localMsg in localOnlyMessages {
if let ts = localMsg.timestamp,
let insertIdx = reconciled.firstIndex(where: { ($0.timestamp ?? .distantPast) > ts }) {
reconciled.insert(localMsg, at: insertIdx)
} else {
reconciled.append(localMsg)
}
}
messages = reconciled
currentSession = session
let minId = reconciled.map(\.id).min() ?? 0
nextLocalId = min(minId - 1, -1)
buildMessageGroups()
}
// MARK: - Load History from DB (for resumed sessions)
/// Load message history from the DB, optionally combining an origin session
/// (e.g., CLI session) with the current ACP session.
func loadSessionHistory(sessionId: String, acpSessionId: String? = nil) async {
self.sessionId = sessionId
// Force a fresh snapshot pull on remote contexts. An earlier open()
// would have cached a stale copy on resume we need whatever
// Hermes has actually persisted since then, or the resumed session
// will show only history up to the moment the snapshot was taken.
let opened = await dataService.refresh()
guard opened else { return }
var allMessages = await dataService.fetchMessages(sessionId: sessionId)
let session = await dataService.fetchSession(id: sessionId)
// If the ACP session is different from the origin, load its messages too
// and combine them chronologically
if let acpId = acpSessionId, acpId != sessionId {
originSessionId = sessionId
self.sessionId = acpId
let acpMessages = await dataService.fetchMessages(sessionId: acpId)
if !acpMessages.isEmpty {
allMessages.append(contentsOf: acpMessages)
allMessages.sort { ($0.timestamp ?? .distantPast) < ($1.timestamp ?? .distantPast) }
}
}
messages = allMessages
currentSession = session
let minId = allMessages.map(\.id).min() ?? 0
nextLocalId = min(minId - 1, -1)
buildMessageGroups()
}
// MARK: - DB Polling (terminal mode fallback)
func markAgentWorking() {
isAgentWorking = true
userSendPending = true
startActivePolling()
}
func scheduleRefresh() {
debounceTask?.cancel()
debounceTask = Task { @MainActor [weak self] in
try? await Task.sleep(for: .milliseconds(100))
guard !Task.isCancelled else { return }
await self?.refreshMessages()
}
}
func refreshMessages() async {
// Polling tick (terminal mode): pull a fresh snapshot so remote
// reflects Hermes writes since the last tick. On local this is a
// cheap reopen of the live DB.
let opened = await dataService.refresh()
guard opened else { return }
if sessionId == nil {
if let resetTime = resetTimestamp {
if let candidate = await dataService.fetchMostRecentlyStartedSessionId(after: resetTime) {
sessionId = candidate
}
}
if sessionId == nil {
sessionId = await dataService.fetchMostRecentlyActiveSessionId()
}
}
guard let sessionId else { return }
let fingerprint = await dataService.fetchMessageFingerprint(sessionId: sessionId)
if fingerprint != lastKnownFingerprint {
let fetched = await dataService.fetchMessages(sessionId: sessionId)
let session = await dataService.fetchSession(id: sessionId)
lastKnownFingerprint = fingerprint
messages = fetched
currentSession = session
buildMessageGroups()
let derivedWorking = deriveAgentWorking(from: fetched)
if userSendPending {
if fetched.last?.isUser == true {
userSendPending = false
}
isAgentWorking = true
} else {
let wasWorking = isAgentWorking
isAgentWorking = derivedWorking
if wasWorking && !derivedWorking {
stopActivePolling()
}
}
}
}
private func startActivePolling() {
stopActivePolling()
activePollingTimer = Timer.scheduledTimer(withTimeInterval: 0.5, repeats: true) { [weak self] _ in
Task { @MainActor [weak self] in
await self?.refreshMessages()
}
}
}
private func stopActivePolling() {
activePollingTimer?.invalidate()
activePollingTimer = nil
}
private func deriveAgentWorking(from fetched: [HermesMessage]) -> Bool {
guard let last = fetched.last else { return false }
if last.isUser { return true }
if last.isToolResult { return true }
if last.isAssistant {
if !last.toolCalls.isEmpty {
let allCallIds = Set(last.toolCalls.map(\.callId))
let resultCallIds = Set(fetched.compactMap { $0.isToolResult ? $0.toolCallId : nil })
return !allCallIds.subtracting(resultCallIds).isEmpty
}
return last.finishReason == nil
}
return false
}
// MARK: - Message Grouping
private func buildMessageGroups() {
var groups: [MessageGroup] = []
var currentUser: HermesMessage?
var currentAssistant: [HermesMessage] = []
var currentToolResults: [String: HermesMessage] = [:]
var groupIndex = 0
func flushGroup() {
if currentUser != nil || !currentAssistant.isEmpty {
// Use stable sequential IDs so SwiftUI doesn't re-create views
// when streaming messages finalize (id changes from 0 to -N)
groups.append(MessageGroup(
id: groupIndex,
userMessage: currentUser,
assistantMessages: currentAssistant,
toolResults: currentToolResults
))
groupIndex += 1
}
currentUser = nil
currentAssistant = []
currentToolResults = [:]
}
for message in messages {
if message.isUser {
flushGroup()
currentUser = message
} else if message.isToolResult {
if let callId = message.toolCallId {
currentToolResults[callId] = message
}
currentAssistant.append(message)
} else {
if currentUser == nil && !currentAssistant.isEmpty && message.isAssistant {
flushGroup()
}
currentAssistant.append(message)
}
}
flushGroup()
messageGroups = groups
}
}

Some files were not shown because too many files have changed in this diff Show More