Compare commits

..

165 Commits

Author SHA1 Message Date
Alan Wizemann cca99d4e13 chore: Bump version to 2.5.2 2026-04-29 13:36:53 +02:00
Alan Wizemann 2aab9dac07 feat: chat-start preflight, Nous catalog, remote-aware admin sheets
Three feature batches that were in progress on chat-resilience —
all aligned with v2.5.2's remote-context theme.

## Chat-start model preflight

When a chat-start hits a server whose config.yaml has no
model.default / model.provider, the upstream provider returns an
opaque "Model parameter is required" 400 only AFTER the user types
a prompt and hits send. New ModelPreflight in ScarfCore catches the
missing keys before any ACP work; ChatView presents the existing
ModelPickerSheet via a thin ChatModelPreflightSheet wrapper so the
picker / validation / Nous-catalog branch stay single-sourced.
ChatViewModel persists the selection via `hermes config set` and
replays the original startACPSession arguments — the chat the user
originally opened lands without re-clicking the project row.

## Nous Portal live catalog

NousModelCatalogService fetches `GET /v1/models` from
inference-api.nousresearch.com using the bearer token in
`auth.json`, caches to `~/.hermes/scarf/nous_models_cache.json`
(new path on HermesPathSet) with a 24h TTL. Picker's nous-overlay
detail switches from a free-form TextField to a real model list,
with a "Custom…" escape hatch (nousManualEntry) for IDs not yet in
the API response.

## Remote-aware admin sheets (mirror of #54's pattern)

The Add Project sheet got context-aware Verify in v2.5.1 (#54);
this batch extends the same shape to three more sheets:

- Profiles: remote import/export. ProfilesView gains
  showRemoteImportSheet + pendingRemoteExport state; reuses the
  same path-input + verify + run-via-hermes pattern from
  AddProjectSheet. Drives `hermes profile import <zip>` /
  `hermes profile export <name> <zip>` over SSH.
- Backup restore (Settings → Advanced): pickLocalBackupZip + new
  RemoteBackupPathSheet so the Restore action picks a local zip
  on local contexts and verifies a remote path on remote contexts.
- Template install destination: TemplateInstallSheet's parent-
  directory picker now branches on context. ParentDirectoryStep
  with browseLocalDirectory + verifyRemotePath + RemoteVerification
  — same UX vocabulary as AddProjectSheet, applied to where the
  template gets installed.

Plus a `runHermesWithStdin` helper on HermesFileService for the
profile import flow (passing zip bytes through stdin rather than
landing them on the remote disk first), and ProjectTemplateInstaller
gains a remote-path-aware code path for the install destination.

## Localizations

Localizable.xcstrings adds strings for all the new copy across
seven supported locales (en, zh-Hans, de, fr, es, ja, pt-BR).
2026-04-29 13:27:25 +02:00
Alan Wizemann c31dfccb9b fix(ios-chat): move keyboard-dismiss chevron to leading edge (#57)
The keyboard accessory dismiss button added in #51 was placed at
the trailing edge of the keyboard toolbar (Spacer before Button),
which sits directly above the trailing-edge send button in the
composer below. Two near-identical-shape controls visually stack
on the right edge of the screen, confusing users about which is
which.

Move the Spacer() to AFTER the Button so the chevron lives at the
leading edge of the keyboard accessory bar — visually separated
from the send button below, and matches the iOS convention (Notes,
Mail, Reminders all put accessory dismiss on the leading side).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 13:22:51 +02:00
Alan Wizemann 61e61f556a feat(chat): hideable sessions + inspector panes for the Mac chat (#58)
The 3-pane layout (264px sessions list + transcript + 320px inspector)
ate ~584px of horizontal space on every chat window — squeezing the
actual transcript on smaller windows AND keeping the "No tool selected"
empty-state visible even when irrelevant. User reported that as
"reasoning, in/out, hard to read because of the tool selected box
taking so much space".

Add toolbar toggles + Settings parity to hide either side pane:

- Two new @AppStorage keys in ChatDensitySettings:
    scarf.chat.showSessionsList (default true)
    scarf.chat.showInspector    (default true)
- ChatView toolbar gains two buttons next to the View picker:
  sidebar.left toggles the sessions list, sidebar.right toggles the
  inspector. Both highlight in accent color when visible. Hidden when
  in terminal mode (the 3-pane layout doesn't apply there).
- RichChatView body conditionally renders each side pane and its
  divider, with .transition(.move + .opacity) and a 180ms easeInOut
  animation so the transcript reflows smoothly rather than snapping.
- Auto-show inspector when a tool card is focused so a click never
  silently dies — onChange of focusedToolCallId flips
  showInspector back on if it was off. The slide-in animation
  covers the visual transition.
- DisplayTab → Chat density gains parity Toggle rows for "Sessions
  list" and "Tool inspector" — same group as the existing density
  pickers from #47/#48 so the settings home is consistent.

Defaults match today's behavior so existing users see no change
until they opt out.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 13:22:51 +02:00
Alan Wizemann 424711c3d9 fix(ios-snapshot): harden Citadel state.db snapshot path (#56)
Reported on iOS: dashboard shows "Connection issue / Citadel.SSH
Client.CommandFailed error 1", memory files (USER.md, SOUL.md) load
fine but Sessions / Activity / Tool Calls all show 0. The snapshot
operation that pulls ~/.hermes/state.db over SFTP via `sqlite3
.backup` was failing on the remote, but the iOS user got zero
actionable context.

Two latent bugs in CitadelServerTransport.asyncSnapshotSQLite —
both fixed in v2.5.0 for asyncRunProcess but missed on this path:

1. `executeCommand` throws CommandFailed on non-zero exit AND
   discards the captured stderr buffer. So when sqlite3 is missing
   (slim Docker images, statically-linked installs) or state.db
   doesn't exist, the user only saw "error 1" and a generic
   connection-issue banner with no remediation.

2. No `PATH=...` prefix. asyncRunProcess inline-prepends
   `PATH="$HOME/.local/bin:/opt/homebrew/bin:/usr/local/bin:$PATH"`
   so bare command resolution works on Citadel's stripped-PATH
   exec channel; the snapshot path didn't, so any sqlite3 install
   outside /usr/bin failed at exit 127 ("command not found").

Mirror the asyncRunProcess hardening on the snapshot path:

- Prepend the same PATH prefix so sqlite3 resolves on hosts where
  it lives at /usr/local/bin or /opt/homebrew/bin.
- Drive `executeCommandStream` instead of `executeCommand`.
  Capture stdout + stderr regardless of exit code.
- On non-zero exit, throw an NSError carrying the real stderr (or
  stdout if stderr is empty — sqlite3 sometimes errors via stdout
  depending on the remote shell). HermesDataService.humanize
  already keys off "sqlite3: command not found" /
  "permission denied" / "no such file" substrings, so once the
  real message reaches it the dashboard banner becomes actionable
  ("sqlite3 is not installed on <host>. Install with apt install
  sqlite3..." instead of the generic CommandFailed error).
- When the stream itself fails to start (network/auth-level), throw
  with a "Failed to start snapshot stream" message so the connect-
  level error path is distinguishable from the remote-exec failure.

iOS-only — Mac path was already correct.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 13:22:51 +02:00
Alan Wizemann 067aeda878 fix(catalog): async catalog reads — unfreezes Model + Credential sheets (#59)
Two views called ModelCatalogService.loadProviders() synchronously
from .onAppear on the MainActor:

- ModelPickerSheet (Settings → Model)
- AddCredentialSheet (Credential Pools → +)

loadProviders() walks loadCatalog() → transport.readFile() of
~/.hermes/models_dev_cache.json — a multi-megabyte JSON with ~1500
models across ~110 providers. On a remote SSH context that's a
synchronous SSH file read on the main thread; the user's reported
1–2 minute UI freeze on first open is exactly that. Even on local
contexts the JSONDecoder pass on the main thread is a noticeable
hiccup. Direct violation of CLAUDE.md's rule against sync I/O on
@MainActor.

Compound case: ModelPickerSheet.loadModelsForSelection() did the
same sync read every time the user clicked a different provider in
the picker — re-froze the UI per click.

Fix:
- Add async wrappers on the service:
    loadProvidersAsync()      -> [HermesProviderInfo]
    loadModelsAsync(for:)     -> [HermesModelInfo]
  Each await Task.detached { sync method }.value. Existing sync
  methods stay for tests and any non-View consumers.
- ModelPickerSheet: replace .onAppear with .task; await both async
  calls. Same conversion for loadModelsForSelection() — renamed to
  loadModelsForSelectionAsync() and called from the provider-list
  selection binding via Task { ... }. Subscription state load also
  routed through Task.detached since it's another auth.json read
  that's tiny on local but SSH-backed on remote.
- AddCredentialSheet (CredentialPoolsView): same .onAppear → .task
  conversion with isLoadingProviders @State driving an overlay
  ProgressView "Loading providers..." while the read is in flight.

No behavior or data-shape change; pure I/O dispatch fix.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 13:22:51 +02:00
Alan Wizemann 389620059c fix(credentials): recognize OAuth providers; warn on project-shadowed Hermes
Three related fixes for the "I authed Nous but Scarf doesn't see it" bug:

1. `hasAnyAICredential()` (HermesFileService) only probed the
   `credential_pool.<provider>` shape in auth.json. OAuth-authed providers
   land under `providers.<name>.access_token` instead — Nous, Spotify, GH
   Copilot ACP, Qwen, Gemini all use that path. The chat banner kept
   showing "No AI provider credentials" even after a successful Nous
   sign-in. Now we probe both shapes; refresh-only entries (pre-mint
   OAuth flows) also count.

2. `CredentialPoolsViewModel` decoded only `credential_pool.*` and
   ignored `providers.*` entirely. New `oauthProviders` array surfaces
   them in a parallel "OAuth providers" section above the rotation
   pools — read-only, with token tail, expiry badge, portal URL, and
   "managed by `hermes auth add`" footnote so users know where the
   write path lives.

3. New `ProjectHermesShadowDetector` (ScarfCore) probes each registered
   project for a `<project>/.hermes/` directory. Hermes' CLI binds to
   the closest `.hermes/` as `$HERMES_HOME` when run from inside such a
   project — `hermes auth add nous` lands in the project's auth.json
   instead of `~/.hermes/auth.json` and Scarf's global probes never
   see it. Surfaced as a yellow Dashboard banner listing affected
   projects with badges for `auth.json` / `state.db` presence and a
   "Copy fix command" button that emits a one-liner consolidating
   auth.json into the global home. Read-only — no auto-migration; the
   user decides what to keep.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-27 22:48:20 +02:00
Alan Wizemann 4ffd353835 fix(diagnostics): treat config.yaml absence as informational, not failure
Same root cause as the connection-pill fix in 511726e: Hermes v0.11+
doesn't materialize config.yaml until the user changes a setting from
defaults, so a healthy fresh install was reporting "12/14 passing"
forever even though everything that mattered worked.

Probe.Status becomes tri-state (.pass / .fail / .skipped). The shell
script emits SKIP for the "config.yaml absent" branch (Hermes creates
it lazily); only "exists but unreadable" still emits FAIL. The view
renders .skipped with a grey info-circle and excludes those probes
from the summary's denominator — "12/12 passing (2 optional skipped)"
instead of the misleading "12/14."

Probe titles relabeled to "config.yaml readable (optional)" and
"config.yaml content (optional)" so users see the file is not
load-bearing at a glance. The failure hint for the genuine
permission-denied case explicitly notes that absence is fine.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-27 22:31:40 +02:00
Alan Wizemann 511726e2c0 feat(chat-resilience): iOS reconnect + snapshot fallback + paging + pill fix
Brings iOS chat to parity with Mac's reconnect behavior so a session
survives phone-sleep, network handoffs, and SSH socket drops without
losing the agent's work — Hermes already persists messages to state.db
in real-time, the iOS app just had no resync path.

Core changes (shared between Mac and iOS via ScarfCore):

- ServerTransport.cachedSnapshotPath: fall back to the cached state.db
  snapshot when a fresh pull fails. HermesDataService surfaces this via
  isUsingStaleSnapshot + lastSnapshotMtime so views can render "Last
  updated X ago." Default opt-in via refresh(forceFresh: false); chat
  history reload passes forceFresh: true to refuse stale data.
- HermesDataService.fetchMessages(sessionId:limit:before:): bounded
  pagination by id desc. Legacy unbounded overload deprecated. New
  HistoryPageSize constants centralize the budget.
- RichChatViewModel.loadEarlier(): pages back through the current
  session via oldestLoadedMessageID + hasMoreHistory.

iOS-only:

- ChatController gains the Mac reconnect machinery: 5-attempt
  exponential backoff (1→16s) via session/resume → session/load,
  reconcileWithDB on success, "Resynced N new messages" toast.
  startACPEventLoop + startHealthMonitor extracted as helpers.
- New NetworkReachabilityService (NWPathMonitor singleton). Suspends
  reconnect attempts while offline; kicks a fresh cycle on link-up.
- ScarfGoCoordinator + ScarfGoTabRoot funnel scenePhase transitions to
  ChatController.handleScenePhase. On .active we verify channel
  health and reconnect if dead.
- Draft persistence: UserDefaults keyed by (serverID, sessionID)
  survives force-quit. 7-day janitor at app launch.
- Connection-state banner: .reconnecting and .offline render slim
  ScarfDesign-tinted strips above the message list. .failed keeps
  using the existing full-screen overlay.

Bonus fix:

- ConnectionStatusViewModel tier-2 probe now checks state.db instead
  of config.yaml. Hermes v0.11+ doesn't materialize config.yaml until
  the user changes a setting, so a freshly-installed working Hermes
  was being marked "degraded — config missing" indefinitely. state.db
  is the file Scarf actually depends on.

Out of scope (deferred): APNs push notifications, BGTaskScheduler-
based extended-background keepalive, offline write queue.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-27 21:57:49 +02:00
Alan Wizemann 587c6c36c8 fix(diagnostics): sqlite3 probe with login-PATH + candidate fallback (#19)
@cmalpass's April 25 follow-up on #19: diagnostics reported "sqlite3
not installed or on system PATH" while sqlite3 was actually installed
and Hermes was using it fine. Same false-negative class the `hermes`
probe pre-fix had — a bare `command -v sqlite3` in the non-login SSH
shell misses installs at /opt/homebrew/bin or /usr/local/bin when
the user's PATH export lives in .zprofile (the typical Homebrew
setup). The hermes probe was upgraded to source rc files + walk a
candidate list; sqlite3 wasn't.

Mirror the same pattern:

- Move the sqlite3 detection AFTER the rc-source loop so the login
  PATH is in scope.
- Add a standard-location fallback list:
  /usr/bin/sqlite3, /usr/local/bin/sqlite3,
  /opt/homebrew/bin/sqlite3, /opt/local/bin/sqlite3.
- Use the resolved sqlite3 binary explicitly in the
  sqlite3CanOpenStateDB probe so it doesn't re-fail-by-PATH when the
  binary is at e.g. /opt/homebrew/bin. Falls back to bare `sqlite3`
  so the FAIL detail line still carries the real error.

Hermes non-login probe stays as-is — that semantic ("is hermes on
the un-enriched PATH?") is meaningful and we don't want to muddle it.

Failure-hint copy on sqlite3Installed updated to spell out the new
fallback behavior so users who still see FAIL get accurate guidance
(install via package manager, OR symlink an existing binary into a
location the probe checks).

Closes the third and last open layer of #19. Layer 1 (104-byte
ControlMaster path) was fixed in v2.0.2; layer 2 (pill / diagnostics
disagreement) was fixed in v2.5.1 (#44). Ships in v2.5.2.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-27 15:55:18 +02:00
Alan Wizemann 50fbbc6af6 chore: Bump version to 2.5.1 2026-04-27 15:33:43 +02:00
Alan Wizemann 4776119e07 fix(ios-onboarding): hide Cancel on first-run onboarding (#55)
App Store Connect feedback: "Cancel button not working" on the
"Connect to Hermes" onboarding screen.

Confirmed root cause in RootModel.cancelOnboarding:

    state = servers.isEmpty
        ? .onboarding(forNewServer: ServerID())
        : .serverList

When the user has zero configured servers (the first-run case),
the conditional re-presented a fresh onboarding view. The button
fired, the state mutated, but the visible result was "tap Cancel,
get an identical screen" — indistinguishable from a dead button.

The defensive intent ("don't strand the user on an empty server
list") was reasonable, but the UX-as-shipped is worse than the
strand it tried to prevent — first-run TestFlight users see a
seemingly broken app.

Fix at the right layer: don't show Cancel when there's nowhere
to go.

- New `canCancel: Bool` parameter on OnboardingRootView (default
  true). When false, the leading toolbar slot omits the Cancel
  button entirely.
- RootView passes `canCancel: !model.servers.isEmpty`.
- RootModel.cancelOnboarding simplified — drops the defensive
  `.isEmpty` re-loop branch, asserts the invariant in debug, and
  in release still routes to `.serverList` (which renders an
  empty-state with the "+ Add server" toolbar button) rather than
  re-presenting onboarding.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-27 14:20:03 +02:00
Alan Wizemann f72bf6e30b fix(connection-pill): unify pill probe with diagnostics over raw ssh (#44)
Issue #44: pill stuck on "Connected — can't read Hermes state" while
Run Diagnostics shows 14/14 passing. Both code paths probe the same
question (`[ -r ~/.hermes/config.yaml ]`) yet disagreed.

Root cause: the pill called `transport.runProcess(executable:
"/bin/sh", args: ["-c", script])` which routes through
SSHTransport.remotePathArg quoting. That quoting double-quotes every
argument to rewrite `~/` → `$HOME/`, mangling multi-line shell
scripts containing `"$VAR"` references and nested quotes — the
remote received a scrambled `if`-test and `$H/config.yaml` evaluated
to `"/config.yaml"` (or worse), so tier-2 always read as failed.

`RemoteDiagnosticsViewModel` already documented this exact bug and
worked around it locally: invoke `/usr/bin/ssh ... -- /bin/sh -s`
directly and pipe the script via stdin so it travels as opaque
bytes. The pill never got the same treatment, hence the silent
disagreement. The #53 granular-cause script I added a few commits
back made the mangling worse — more $VARs, more `[ ! -e ]` tests,
more nested quoting, all things that increase the runProcess
quoting attack surface.

Move the diagnostics workaround into shared ScarfCore code as
`SSHScriptRunner.run(script:context:timeout:)`. Both the pill probe
and the diagnostics view now use it, so they always see the same
remote shell state. macOS-only via `#if os(macOS)` (Foundation.Process
isn't on iOS); iOS callers never reach this surface anyway —
ScarfGo uses Citadel-based SSH transports for its own flows.

Other tidy-ups:
- `ConnectionStatusViewModel` no longer holds a `transport` instance
  — the field was only used by the now-replaced runProcess path.
- `RemoteDiagnosticsViewModel` loses ~120 lines of duplicated
  `runOverSSH` / `runLocally` / `controlDirPath` helpers; calls into
  `SSHScriptRunner.run` directly.

Risk: low. The SSH path is the same shape that's been shipping in
the diagnostics view since #19. The pill's 15s heartbeat gains a
small forking-an-ssh-process overhead vs the ControlMaster-
multiplexed runProcess, which is invisible at that cadence and
amortized by ssh's own ControlMaster (the `-o ControlMaster=auto`
options match SSHTransport's, so the multiplex socket is shared).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-27 14:08:25 +02:00
Alan Wizemann 0bfae1227a fix(projects): context-aware Add Project sheet on remote servers (#54)
Pre-fix `AddProjectSheet` always rendered a Browse button backed by
NSOpenPanel — a Mac-local Finder dialog. On a remote SSH server
context, users would pick a Mac path (`/Users/alan/code/...`), the
path would land in the projects registry as the project's "remote"
working directory, and tool calls would fail at runtime because
that path doesn't exist on the Linux server.

Tier-1 fix:
- Pass active ServerContext into AddProjectSheet (was context-blind).
- Local context: Browse button unchanged. Pixel-identical to today.
- Remote context: hide Browse, surface a hint "Path on <server> —
  must already exist on the server", add a Verify button that runs
  context.makeTransport().stat(path) over the existing SSH transport
  and renders inline:
    spinner    → checking
    green ✓    → directory exists
    yellow ⚠   → missing / file-not-dir / unreadable
- Path field's onChange resets stale verification so users don't see
  a green check for a path they've since edited.

Tier 2 (full remote SFTP-backed picker that lets users navigate the
remote filesystem) is deferred — separate larger feature, ~200-300
lines and its own UX. Tier 1 unblocks remote project creation now,
which was the blocking bug.

Other 5 NSOpenPanel call sites audited — `TemplateInstallSheet:423`
likely has the same class of bug for template install destinations
on remote contexts; flagged in the issue body for a follow-up. The
other 4 (template-file picker, key-file picker, etc.) all pick
Mac-local artifacts and are correct as-is.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-27 13:59:10 +02:00
Alan Wizemann c312a565b6 fix(connection-pill): granular degraded reasons + inline hint popover (#53)
Pre-fix the connection-status pill collapsed every config.yaml read
failure to "Connected — can't read Hermes state", forcing users into
the heavy 14-probe Remote Diagnostics sheet to learn why. Multiple
distinct causes (Hermes not installed, not yet set up, permission
denied, profile mismatch) all read identically.

Probe script now emits granular `TIER2:1:<cause>` codes:
- no-home: ~/.hermes itself missing
- missing: config.yaml absent (typically pre-`hermes setup`)
- perm: file exists but unreadable by the SSH user
- profile:<name>: config missing AND ~/.hermes/active_profile points
  at a non-default profile, so Scarf is reading the wrong directory

Status.degraded now carries (reason, hint, cause) instead of just a
short reason. The pill label shows the specific reason
("Hermes profile coder is active", "Hermes hasn't been set up yet",
etc.); clicking opens an inline popover with:
- A one-paragraph actionable hint
- A "Run diagnostics" button (existing path) and a "Retry" button
- For the profile case: a copy-paste affordance for
  `hermes profile use default` to revert

Backwards-compatible: a remote that emits the legacy binary
`TIER2:1` parses to `.unknown` with the prior generic copy. No probe
script breakage on older Hermes installs.

Cross-link with #50 (local profile awareness) — this fix surfaces
the profile-mismatch class of bug for remote contexts. A proper
remote-side profile fix (HermesPathSet.defaultRemoteHome respecting
active_profile) is filed separately.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-27 13:57:18 +02:00
Alan Wizemann afb1356b27 feat(ios-keychain): opt-in iCloud Keychain sync for SSH keys (#52)
Reddit-reported friction: every iOS device needed its own SSH key
because Scarf hardcoded
kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly +
kSecAttrSynchronizable=false on every Keychain write. Pairing iPhone
+ iPad meant onboarding twice and editing authorized_keys per device.

Add an opt-in toggle in System tab → Security:

- New SSHKeyICloudPreference (UserDefaults wrapper, default false so
  existing installs see no change on update).
- KeychainSSHKeyStore.writeBundle now consults the preference: when
  on, items use kSecAttrAccessibleAfterFirstUnlock (no ThisDeviceOnly
  suffix — required for iCloud Keychain sync) +
  kSecAttrSynchronizable=true.
- All read / list / delete queries unconditionally pass
  kSecAttrSynchronizable=kSecAttrSynchronizableAny so they match
  items regardless of sync state. Without this a flipped write would
  orphan items at the next read.
- Public migrateAllItems(toICloudSync:) reads every stored bundle,
  deletes with Any, re-saves with target attributes. Idempotent.

System tab Security section toggle:
- Live migration on flip with a "Updating Keychain..." progress row.
- Failure path reverts the toggle + surfaces the error inline rather
  than silently leaving the state inconsistent.
- Footer copy explains the tradeoff (E2EE via iCloud Keychain;
  Advanced Data Protection keeps encryption keys on device).

Out of scope: per-server-key sync override (M9 multi-server keys
all sync or none); in-app key export.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-27 13:53:06 +02:00
Alan Wizemann f9a288ac6c fix(ios-chat): dismissable keyboard via swipe + toolbar button (#51)
Pre-fix the iOS composer's TextField had no keyboard dismissal:
no @FocusState, no scrollDismissesKeyboard, no keyboard accessory.
With axis: .vertical + submitLabel: .send the Return key inserts a
newline rather than committing, so once the keyboard rose it stayed
up — hiding the top-trailing toolbar button on small phones.

Three additive changes:
- @FocusState private var composerFocused on ChatView, bound to the
  TextField via .focused($composerFocused).
- .scrollDismissesKeyboard(.interactively) on the message list
  ScrollView so dragging the messages downward collapses the keyboard
  with the gesture (the standard iOS chat pattern the reporter
  explicitly named — "swipe away").
- ToolbarItemGroup(placement: .keyboard) accessory with a
  keyboard.chevron.compact.down "Done" button so dismissal is also
  available without a scrollable area (e.g. fresh empty-state chat
  before any messages exist).

ScarfGo iOS only. Mac unaffected.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-27 13:38:00 +02:00
Alan Wizemann bb33a39b42 fix(profiles): respect Hermes v0.11 active_profile (#50)
Hermes v0.11's `hermes profile` feature gives each profile its own
HERMES_HOME directory: the default profile is ~/.hermes, named
profiles live at ~/.hermes/profiles/<name>/. Each has its own
state.db, sessions/, config.yaml, .env, memories/, cron/, etc.
The active profile is recorded in ~/.hermes/active_profile.

Pre-fix Scarf hardcoded ~/.hermes and ignored active_profile, so
`hermes profile use coder` followed by a Scarf relaunch left Scarf
reading the wrong state.db — the new profile's chat sessions
silently never appeared.

Add HermesProfileResolver in ScarfCore that reads active_profile
and returns the effective home path. HermesPathSet.defaultLocalHome
becomes a static var backed by the resolver; every derived path
(stateDB, sessionsDir, configYAML, memoriesDir, cron paths, plugins,
gateway state, auth.json, etc.) automatically follows the active
profile through the existing `home + suffix` plumbing — no
downstream call sites need to change.

Resolver semantics:
- Absent / empty / "default" file → ~/.hermes (today's behavior)
- Valid profile name pointing to an existing dir → that dir
- Invalid name OR missing target → fall back to ~/.hermes with a
  one-line os.Logger warning (so worst case is "Scarf shows what
  it always showed")

Validation regex mirrors Hermes's hermes_cli/profiles.py exactly
([a-z0-9][a-z0-9_-]{0,63}). 5-second cache via OSAllocatedUnfairLock
keeps hot-path filesystem hits negligible.

SessionInfoBar gains a leftmost profile chip when not "default" so
users can see which profile Scarf is reading from. Tooltip explains
how to switch (`hermes profile use <name>` + relaunch).

Out of scope (deferred):
- In-app profile picker that writes to active_profile. Switching
  mid-session is messy (open ACP processes are bound to whichever
  HERMES_HOME spawned them); the reporter's "switch + restart" flow
  is what we fix here.
- Remote SSH profile awareness. defaultRemoteHome stays "~/.hermes"
  — remote profile selection is a separate, larger feature needing
  its own UI.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-27 13:10:33 +02:00
Alan Wizemann e828538a2d docs(privacy): correct sandbox claim — Scarf macOS is unsandboxed by design
The privacy policy claimed "the macOS app is sandboxed where possible" and
that uninstall removes "~/Library/Containers/com.scarf". Both wrong:

- Per scarf/CLAUDE.md "Sandbox disabled. Scarf needs to read ~/.hermes/
  directly." Scarf cannot ship App-Sandboxed because it needs direct
  filesystem access to ~/.hermes/ and the ability to spawn the hermes CLI
  — both forbidden by the App Sandbox.
- ~/Library/Containers/com.scarf doesn't exist for an unsandboxed app;
  data lives at ~/Library/Caches/scarf/, ~/Library/Preferences/com.scarf.app.plist,
  and ~/Library/Application Support/com.scarf/.

Replaced both with accurate text. Also clarified that ScarfGo on iOS DOES
run inside the standard iOS sandbox — no special entitlements beyond
Keychain. The wiki mirror at .wiki-worktree/Privacy-Policy.md got the same
fix in the corresponding wiki audit commit.

Caught during the v2.5 wiki audit pass. Will re-publish to gh-pages in
v2.5.1 alongside other queued doc updates.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-27 13:00:56 +02:00
Alan Wizemann 051f3bf80c feat(chat): density preferences for tool cards, reasoning, font (#47, #48)
Three Scarf-local @AppStorage-backed preferences in
Settings → Display → Chat density. All defaults match today's UI;
existing users see no change until they opt in.

- Tool calls: Full card (today) / Compact chip / Hidden
  - Compact: one-line tappable chip per call (icon + name + status
    dot). Tap focuses the call so the right-pane inspector opens
    with full args + result, same as today's inline expand.
  - Hidden: per-call rows skipped entirely. The MessageGroupView
    toolSummary pill ("Used 5 tools (3 read, 2 edit)") becomes
    the only chrome AND becomes tappable — clicking focuses the
    first call so per-call duration / exit code remain reachable
    via the inspector. Pill is now shown for any call count > 0
    in hidden mode (was > 1) so the inspector path is always
    available. Issue #47.
- Reasoning: Disclosure box (today) / Inline (italic) / Hidden
  - Inline: italic foregroundFaint caption inline above the reply
    with a 9pt brain prefix. No box, no border. Same data, far
    less vertical space.
  - Hidden: reasoning text not rendered. Per-message tokenCount
    (which the disclosure label was duplicating) stays in the
    metadataFooter so token telemetry isn't lost. Issue #48.
- Chat font size: 85%–130% slider (5% step) applied via
  .environment(\.dynamicTypeSize, ...) on RichChatView's root,
  scaling message list / input bar / session info bar / inspector
  pane together. Reset button restores 100%. Issue #48.

Telemetry preservation (the user-stated constraint):
- Per-turn stopwatch, per-message tokenCount, finish reason, and
  message timestamp remain in the bubble metadataFooter in every
  mode.
- SessionInfoBar input/output/reasoning tokens, cost USD, model,
  project, git branch, and started-at relative time are unchanged
  by every density setting.
- Per-call duration + exit code stay reachable via the inspector
  pane in compact and hidden modes.

Out of scope (called out in the plan):
- Context-fill widget — Hermes v0.11 doesn't expose context_used
  / context_total per session. Approximating from messages.tokenCount
  + a static window table would be wrong-on-purpose; defer until
  Hermes ships the canonical field.
- iOS — ScarfGo already renders both surfaces compactly. Both
  issues reference Mac.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-27 12:37:33 +02:00
Alan Wizemann 558970a09a perf(chat-ios): mirror Mac equatable short-circuit on ScarfGo bubbles (#46)
ScarfGo's chat is a separate rendering path: LazyVStack +
ForEach(controller.vm.messages) with a private MessageBubble struct
(not the shared MessageGroupView/RichMessageBubble used on Mac). The
Mac fix's Equatable conformances therefore didn't propagate.

Without short-circuiting, every visible bubble re-evaluates body on
each streamed ACP chunk because the @Observable VM's `messages`
mutation invalidates anyone reading it — and each bubble's
`ChatContentFormatter.segments` + `AttributedString(markdown:)` are
both O(content) per render. LazyVStack already keeps off-screen
bubbles dormant on iOS, but the 5–10 visible bubbles re-parsing on
every chunk is enough to bog down a long turn on phone hardware.

Add Equatable to MessageBubble (id-keyed, with content/reasoning/
toolCalls.count compared only for the streaming bubble id==0) and
apply .equatable() at the ForEach call site. Settled bubbles short-
circuit body re-eval; the streaming bubble still redraws per chunk.

Note: the trailing-group patch helper (Mac fix part 2) already
benefits iOS as a side effect — buildMessageGroups() is no longer
called per chunk, and even though iOS doesn't read messageGroups
directly, the elided rebuild is still wasted work avoided.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-27 12:23:32 +02:00
Alan Wizemann 8d9de4c576 perf(chat): stop O(n)-per-token re-render of settled bubbles (#46)
Long chats progressively bog down and eventually crash because every
streamed ACP token triggers a full messageGroups rebuild plus a body
re-evaluation of every MessageGroupView and RichMessageBubble — even
the n-1 settled groups that haven't changed. Three changes cap per-chunk
work at "patch the trailing group + re-render the streaming bubble":

- MessageGroupView and RichMessageBubble are now Equatable, applied
  via .equatable() in the ForEach. Settled groups (no streaming
  message inside) short-circuit body re-evaluation entirely; the
  streaming group compares content/reasoning/toolCalls.count so it
  still redraws on every chunk.
- RichChatViewModel.upsertStreamingMessage no longer calls
  buildMessageGroups() per chunk. New patchTrailingGroupForStreaming
  mutates only the trailing group's assistant entry in place. The 9
  other call sites of buildMessageGroups() are untouched — they cover
  structural events (user message, tool-call complete, finalize,
  session resume) where group boundaries can actually change, and a
  full rebuild is correct there.
- MessageGroup.toolKindCounts is now a model property (was a
  MessageGroupView computed prop that re-walked O(m × k) per body
  render). Lives behind the Equatable short-circuit.
- ToolCallCard.formatJSON cached via .task(id: call.callId) so JSON
  pretty-printing runs once per card lifetime instead of on every
  expand/collapse + every neighbour's re-render. Seeded with raw
  arguments to avoid a first-frame empty-text flicker.
- ToolResultContent.lines/preview cached via .task(id: content) — the
  prior pair of computed properties split content on \n twice per
  render, expensive on long command/file output.

Skipped from the original plan: the per-message parse cache
(rendered moot once Equatable already short-circuits settled bubbles)
and the LazyVStack switch (deferred — RichChatMessageList comments
flag scroll-anchor regression risk; revisit separately if needed).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-27 12:12:12 +02:00
Alan Wizemann e0f0fad192 fix(release): post-package verification + non-destructive recovery docs
Add codesign --verify --strict --deep + spctl --assess on the extracted
distribution zip inside build_variant() so any seal regression introduced
by ditto / staple / future pipeline tweaks fails the release before users
see "damaged" errors. Document the non-destructive recovery path in
README and explicitly warn against `xattr -rc` and
`codesign --force --deep --sign -` (issue #49 — both corrupt
Sparkle.framework's nested XPC service / Updater.app signatures even
when the outer app remains intact).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-27 11:40:16 +02:00
Alan Wizemann 80a4d23974 docs(readme): shrink ScarfGo gallery thumbs 180->140px so 5 fit in one row
GitHub's README content column is ~770px wide. 180px x 5 + spacing
overflowed and wrapped 4+1 (the System tab dropped to its own line),
breaking the gallery's "thumbnail strip" reading. 140px x 5 lands at
~700px including spacing, comfortably within the column.

No content change to the screenshots or paths — just the width attr.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-25 18:19:20 +02:00
Alan Wizemann d95ef61e13 docs(readme): ScarfGo screenshot gallery under the v2.5 What's New section
Five 1284x2778 simulator captures from the iPhone 17 Pro Max stock
sim, dropped in at assets/screenshots/scarfgo-*.png. The README
gallery is HTML inside the existing Markdown — five thumbnails at
180px wide, centered, each wrapped in an <a href> pointing back at
the same file so a click opens the full-resolution PNG via GitHub's
asset viewer (the closest thing the README format supports to a
lightbox).

Order matches the user flow: Servers list -> Chat with Hermes ->
Project dashboard (Site Status Checker template, dogfooding the
catalog) -> Skills browser -> System tab. One italic caption
underneath labels the screens in order.

3.4 MB total. iPhone 17 Pro Max is the canonical capture device
for v2.5; the App Store listing will use the same shots once they
need cropping/framing for Apple's screenshot specs.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-25 18:04:20 +02:00
Alan Wizemann 988ce5df5a docs(readme): rename hero icon to bust GitHub's raw-asset CDN cache
The previous commit replaced icon.png on disk with the rust v2.5
artwork, but GitHub's raw-asset CDN was still serving the cached
purple PNG to README viewers (~5 min TTL — but in practice longer
under sustained traffic). Renaming the asset forces a fresh fetch
on every README render, which is the reliable cache-bust.

icon-v2.5.png is bit-identical to the prior icon.png (md5 match
against the Mac app icon set's 512x512). The version in the
filename is intentional — when v2.6 ships with a different icon,
we'll cycle to icon-v2.6.png and the same cache-bust applies.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-25 17:50:53 +02:00
Alan Wizemann 3bca8a6e55 docs(readme): swap home-page hero icon for the v2.5 rust app icon
icon.png at the repo root drives the centered hero block on the GitHub
README. It was still the pre-rust design from v2.0; replaced with the
rust ScarfDesign 512x512 sourced from the Mac app icon set so the
home page matches the in-app branding now that v2.5.0 has shipped.

Also bumps the source resolution from 256x256 to 512x512 — the README
displays it at 128x128, so retina + HiDPI displays now render crisply
without losing the asset's intent.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-25 17:47:12 +02:00
Alan Wizemann b5f4f65ffe chore: Bump version to 2.5.0 2026-04-25 17:34:02 +02:00
Alan Wizemann b474286bfe build(ios): iPhone-only — drop iPad, macCatalyst, Designed-for-iPhone-iPad
Locks the `scarf mobile` target to iPhone before TestFlight submission:

- TARGETED_DEVICE_FAMILY 1,2 -> 1 (iPhone only)
- SUPPORTED_PLATFORMS constrained to iphoneos + iphonesimulator
- SUPPORTS_MACCATALYST = NO
- SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = NO
- SUPPORTS_XR_DESIGNED_FOR_IPHONE_IPAD = NO

Applied symmetrically to Debug + Release configs. iPad layout via
.tabViewStyle(.sidebarAdaptable) hasn't been smoke-tested and was
explicitly out of scope for v2.5; flipping the device-family flag
prevents Apple's review tooling from picking up an unsupported
device class. Mac and visionOS are similarly excluded — ScarfGo is
an iPhone-only companion in v2.5; the iPad / Catalyst story is its
own future release.

Both targets build green.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-25 17:33:18 +02:00
Alan Wizemann b1e2fc5dcd docs(v2.5): home-page focus + RELEASE_NOTES under-the-hood + App Store metadata
README: strip the "Previously, in 2.3" subsection per release direction —
the home page is now a single-version forward-looking surface with prior
releases linked off to the wiki Release Notes Index. Promote the
ScarfGo TestFlight callout to its own subsection with the public link
(testflight.apple.com/join/qCrRpcTz) embedded inline. Add a
"Connect ScarfGo to your Hermes server" five-step walkthrough between
What's New and Multi-server, mirroring OnboardingRootView's state
machine so users can follow it cold without opening the wiki first.

RELEASE_NOTES: extend the Under-the-hood section with the iOS-side
maintenance work that landed in the last 48h — Citadel
executeCommandStream rewrite (preserves stdout on non-zero remote
exit; was eating Skills hub Browse output), inline PATH=
prepend on every iOS runProcess (Citadel's raw exec channel doesn't
source shell rc files), fd-leak cleanup across the three transports
+ ProcessACPChannel, ServerLiveStatus 10/30/60/120/300s exponential
backoff for unreachable remotes, and the print() -> os.Logger sweep.

APP_STORE_METADATA.md: full App Store Connect copy ready for paste —
app name, subtitle, promotional text (153/170 chars), 2873/4000-char
description in three paragraphs (what / features / privacy),
brand-safe keywords (85/100 chars; no competitor product names),
support / marketing / privacy URLs, category, age rating,
1150/4000-char "What's New" text. Screenshots flagged as out-of-scope
for this prep pass — user captures from the simulator before App
Store submission. TestFlight checklist remains the canonical doc for
the in-flight beta submission.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-25 17:12:27 +02:00
Alan Wizemann 87fcbad1ac fix(ios): App Store validation — strip 1024 alpha + drop iCloud entitlements
Apple's TestFlight upload validator rejected v2.5 with two errors;
fixing both for the next archive.

1. **Invalid large app icon** (alpha channel).
   `AppIcon.appiconset/AW Mac OS Applications-macOS-Default-1024x1024@1x.png`
   was RGBA — Apple rejects any 1024×1024 marketing icon with an alpha
   channel. Composited the icon onto a solid white background via
   PIL and resaved as RGB PNG. `sips -g hasAlpha` now reports `no`.
   The file's design is solid edge-to-edge, so the white-fill is
   invisible — no visual change.

2. **Invalid Code Signing Entitlements**
   (`com.apple.developer.icloud-container-environment` empty string).
   `Scarf_iOS.entitlements` had `com.apple.developer.icloud-services
   = [CloudKit]` + `com.apple.developer.icloud-container-identifiers
   = []`. Xcode's signing phase synthesises
   `com.apple.developer.icloud-container-environment` from this combo,
   and with no container identifier the value lands as empty — which
   Apple's validator rejects.

   Per the privacy policy I drafted in v2.5 ("no iCloud Keychain
   sync, no cloud accounts") Scarf doesn't actually use iCloud, so
   removing the iCloud entries is the correct fix. Dropped both
   `com.apple.developer.icloud-services` and
   `com.apple.developer.icloud-container-identifiers`. Kept
   `aps-environment = development` — push capability stays declared
   but gated off via `NotificationRouter.apnsEnabled = false` until
   the cert + Hermes-side sender land.

iOS scheme builds clean post-fix.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-25 16:45:45 +02:00
Alan Wizemann 63c5d13bec chore: fd-leak cleanup, os.Logger conversion, status-poll backoff
Pre-release maintenance pass picked up across both targets while
debugging the iOS Browse fix:

- LocalTransport / SSHTransport: close the parent's copy of every
  pipe write end after spawn so EOF reaches the reader once the child
  exits, and explicitly close read ends after draining. Was leaking
  one fd per `runProcess`/`streamLines` invocation under load.
- ProcessACPChannel: also close stdout/stderr write-end fds on
  channel teardown — same pattern, ACP sessions can churn on long
  reconnect loops.
- HermesDataService / HermesLogService / ProjectDashboardService:
  replace remaining `print("[Scarf] ...")` debug statements with
  os.Logger calls (subsystem="com.scarf"), matching the global rule
  that production code uses Logger and `print()` is reserved for
  previews + test helpers.
- ProjectDashboardService / IOSCronViewModel: drop redundant
  `fileExists` guards before `createDirectory` — the operation is
  already mkdir -p across every transport, so the extra round-trip
  was pure latency on remote hosts.
- scarfApp.swift: server-status sidebar probe now uses an exponential
  backoff (10s → 30s → 60s → 120s → 300s) when consecutive probes
  fail, resetting on the first full success. Previously a registered
  remote going unreachable hammered pgrep + gateway_state.json every
  10s indefinitely; now offline servers settle to a 5-min cadence
  while live servers stay snappy.
- Localizable.xcstrings: routine .strings catalog refresh — stale
  entries for removed UI strings, picked up new "Stored under
  quick_commands:..." subtitle wording.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-25 16:18:15 +02:00
Alan Wizemann 850fa7a697 fix(ios): preserve hermes output on non-zero exit + extend remote PATH
Two related fixes that together restore Skills hub Browse / Search on
iOS over Citadel SSH.

CitadelServerTransport.asyncRunProcess was using `executeCommand`,
which throws `CommandFailed` and discards the captured ByteBuffer when
the remote process exits non-zero. `hermes skills browse` happens to
print its full table and then exit non-zero on some hosts, so iOS got
nothing while Mac (Foundation Process) got the full output with
exitCode=1. Drive `executeCommandStream` directly so stdout + stderr
are drained regardless of outcome, then catch `SSHClient.CommandFailed`
to recover the actual exit code. Network/channel-level failures still
report -1 so callers can distinguish them from a clean non-zero remote
exit.

Citadel's raw exec channel also doesn't source the user's shell rc
files, so non-interactive sessions land with a stripped PATH (typically
just /usr/bin:/bin). pipx installs `hermes` at `~/.local/bin/hermes`,
and many of hermes's sub-tools (git, curl, python) live in homebrew
prefixes that the remote sshd would otherwise add via login-shell init.
Mac's OpenSSH sshd handles this transparently; Citadel does not. Inline
PATH=$HOME/.local/bin:/opt/homebrew/bin:/usr/local/bin:$PATH on every
runProcess invocation so bare `hermes` resolves AND any subprocess it
spawns can still find its tools.

SkillsViewModel.finishBrowse now surfaces the actual stderr/stdout
snippet when the CLI exits non-zero, instead of a canned "Browse failed"
banner. ANSI-stripped + box-drawing-stripped so the message stays
readable in the one-line banner. Made diagnosing the underlying PATH
issue much easier and is a net UX improvement going forward.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-25 16:17:25 +02:00
Alan Wizemann 21e3cc9361 feat(ios): rust page background + dashboard switch-server button
Sweeps the rust ScarfDesign page background onto the screens that
were still rendering against the iOS default: Skills/Hub, Skills/Updates,
all three project sub-views, and Skill Detail. Lists adopt
.scrollContentBackground(.hidden) + ScarfColor.backgroundPrimary, with
.listRowBackground(ScarfColor.backgroundSecondary) on rows so the
Mac-style elevated-card density carries through.

Adds a "Switch server" toolbar button to Dashboard's top-right, threaded
through ScarfGoTabRoot from the connected-server host. One tap soft-
disconnects and returns to the server list — same code path the System
tab already exposes, just reachable without first navigating away from
Dashboard.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-25 15:30:39 +02:00
Alan Wizemann 295f2dfefc feat(ios): Mac-style page backgrounds + Dashboard + Chat redesign
iOS now uses ScarfColor.backgroundPrimary throughout instead of the
default iOS systemGroupedBackground. List-based screens add
.scrollContentBackground(.hidden) + the rust background underneath;
list rows use ScarfColor.backgroundSecondary as their card surface.
Applied to: Projects, Memory, Cron, Settings, Skills/Installed, and
the Servers root.

Dashboard rewritten in Mac-style cards (no more native iOS list):
- ScrollView + VStack with rust background
- Activity stat grid (2-col LazyVGrid) with bordered rust-tinted
  cards: Sessions / Messages / Tool Calls / Tokens (with in/out sub-
  label).
- Recent sessions card — bordered, ScarfColor.backgroundSecondary,
  inline session rows with 1px dividers, "See all" nav to Sessions
  sub-tab.
- Error banner styled with ScarfColor.warning tinted card per Mac.
- Sessions sub-tab keeps a List view but renders against rust
  background with ScarfColor.backgroundSecondary row backgrounds.

Chat redesigned to match the Mac chat reference:
- User bubble: rust accent fill with ScarfColor.onAccent text and
  uneven rounded corners (top/bottom-leading + top-trailing 14px;
  bottom-trailing 4px) — visually pinches to the sender side, same
  as Mac.
- Assistant bubble: rust gradient sparkles avatar tile (24x24)
  alongside a ScarfColor.backgroundSecondary bordered card.
- ToolCallCard: kind-tinted border + uppercase tracked label
  (READ/EDIT/EXECUTE/FETCH/BROWSER) using ScarfColor.success/info/
  warning/Tool.web/Tool.search; expanded JSON in a bordered
  ScarfColor.backgroundSecondary panel.
- ReasoningDisclosure: warning-tinted card with REASONING uppercase
  label.

Both Mac (scarf) and iOS (scarf mobile) schemes build green.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-25 15:20:20 +02:00
Alan Wizemann de611c5343 feat(ios): adopt ScarfDesign across the iOS app
AccentColor.colorset repointed to BrandRust hex (light + dark) so the
tab bar, every .tint, every default button, and every navigation
accent across all 5 tabs read rust automatically. Single-line fix,
biggest visible change.

ScarfDesign now imported across all 27 iOS view files. Color sweep
applies the same patterns as the Mac side, with two iOS-specific
deviations documented in CLAUDE.md:

- ScarfPageHeader is NOT retrofitted onto iOS tab roots. iOS uses
  .navigationTitle(...) + .navigationBarTitleDisplayMode(.large) as
  its native page-header pattern; stacking ScarfPageHeader on top
  creates double titles. ScarfPageHeader is reserved for sub-views
  without a native large-title bar.

- Only .borderedProminent → ScarfPrimaryButton. .bordered and .plain
  stay native because .bordered is the iOS convention for non-primary
  buttons and inherits rust through AccentColor automatically.

Dynamic Type policy (locked + documented in CLAUDE.md): preserve
.font(.headline)/.body/.caption semantic tokens for body copy, list
rows, error messages, and chat content (anything read for content).
Use ScarfFont only for status badges, chip labels, intentional fixed-
size display elements. Mass-swapping ScarfFont on iOS would regress
accessibility for users on .accessibility2 / .xSmall.

Files touched (27 view files + AccentColor + CLAUDE.md):

- Color sweep: .foregroundStyle(.secondary) → ScarfColor.foregroundMuted,
  Color(.secondarySystemBackground) → ScarfColor.backgroundSecondary,
  status colors (.orange/.green/.red) → ScarfColor.warning/success/danger
  in: Dashboard, Skills (root + Installed + Hub + Updates + Detail),
  Projects (root + Detail + Sessions + Site + 8 widgets), Memory
  (List + Editor), Cron, Settings (root + Editor), Servers, Chat
  (root + Picker + Slash browser), Onboarding.

- Primary button swap (5 files): Chat, Projects/Sessions, Skills/
  Updates, Skills/Hub, Onboarding.

- CLAUDE.md: appended "iOS Dynamic Type policy" + "iOS page chrome"
  guidance under the existing Design System section.

Both Mac (scarf) and iOS (scarf mobile) schemes build green.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-25 15:08:46 +02:00
Alan Wizemann 23dd8becb9 polish: tokenize remaining sheets, page headers, and widgets
Phase 1 — Page headers for the 9 non-mockup feature views: Skills,
Gateway, Platforms, Personalities, QuickCommands, CredentialPools,
Plugins, Webhooks, Profiles. Each now ships a ScarfPageHeader with
title + subtitle + tokenized trailing actions (ScarfPrimary /
Secondary / Ghost buttons), wrapped in .fixedSize so labels can't
wrap at narrow widths. Outer .background(ScarfColor.backgroundPrimary).

Phase 2 — Modal sheets: ModelPickerSheet, NousSignInSheet,
RenameProjectSheet, MoveToFolderSheet, the five Templates sheets
(TemplateInstall / TemplateConfig / TemplateExport / TemplateUninstall
/ ConfigEditorSheet), three MCPServer sheets (AddCustom / Editor /
PresetPicker), AddServerSheet, ManageServersView, MissingServerView.
.font(.headline) -> .scarfStyle(.headline);
.buttonStyle(.borderedProminent) -> ScarfPrimaryButton(); raw text
fields where touched -> ScarfTextField; cancel buttons -> ScarfGhostButton.

Phase 3 — All 12 platform setup views (Discord / Email / Feishu /
HomeAssistant / IMessage / Matrix / Mattermost / Signal / Slack /
Telegram / Webhook / WhatsApp). Connect buttons swapped to
ScarfPrimaryButton.

Phase 4 — All 7 project dashboard widgets (Chart / List / Progress /
Stat / Table / Text / Webview). .font(.caption) -> .scarfStyle(.caption);
.background(.quaternary.opacity(0.5)) -> ScarfColor.backgroundSecondary;
RoundedRectangle(cornerRadius: 8) -> ScarfRadius.lg.

Phase 5 — Project sub-views: ProjectSessionsView, ProjectsSidebar,
ProjectSlashCommandsView. Same token sweep.

Phase 6 — Common chrome:
- LoadingOverlay: .font(.callout/caption) -> .scarfStyle; secondary
  foreground -> ScarfColor.foregroundMuted; window-background ->
  ScarfColor.backgroundPrimary.
- ServerSwitcherToolbar: status dot + label tokenized.
- ConnectionStatusPill: status colors -> ScarfColor.success/warning/
  danger; error sheet header -> ScarfPrimaryButton retry.

Build green on both Mac (scarf) and iOS (scarf mobile) schemes.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-25 14:39:13 +02:00
Alan Wizemann 41769e289c feat(chat): port the 3-pane chat layout + ScarfDesign telemetry
Sessions list (264 px) | transcript (flex) | inspector (320 px) per
design/static-site/ui-kit/Chat.jsx and the ScarfChatView reference.
Built over the real ChatViewModel + RichChatViewModel — live ACP
streaming pipeline untouched.

HermesToolCall gains optional duration / exitCode / startedAt fields
(backwards-compatible, nil defaults; not Codable). RichChatViewModel
populates them on ACP toolCallStart / toolCallUpdate; mutates the
streaming entry before finalize so the persisted call carries
telemetry. Sessions loaded from state.db gracefully render "—" when
nil.

ChatViewModel gains focusedToolCallId + a focusedToolCall computed
helper. ToolCallCard takes onFocus / isFocused — single click both
focuses the inspector and toggles inline expansion (two paths to the
same data per locked decision). Border weight + tint bump signal the
focused card.

Sessions pane: project filter (matches Sessions feature semantics),
search field, project chips per row, right-click rename + delete via
hermes sessions rename / delete --yes. Recent-sessions limit bumped
10 -> 50 so the project filter has data. loadRecentSessions commits
all four observables in a single MainActor batch to eliminate the
brief flash on session switch. ChatView toolbar's redundant Session
menu trimmed (left pane is canonical).

ChatTranscriptPane wraps existing SessionInfoBar + RichChatMessageList
+ RichChatInputBar without owning new state. RichChatView body becomes
a fixed 3-pane HStack — ViewThatFits was downgrading to transcript-only
when transcript content widened mid-load.

Inspector: STATUS / ARGUMENTS / TELEMETRY / PERMISSIONS in the Details
tab; STDOUT in dark mono panel under Output; full JSON envelope under
Raw. Footer Re-run is stubbed (TODO: /retry path); Copy puts the raw
JSON envelope on the clipboard.

ProjectSlashCommandsView: empty-state ContentUnavailableView now
centers in the full pane via .frame(maxWidth/maxHeight: .infinity).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-25 14:17:06 +02:00
Alan Wizemann 8a2d89654b feat(design): adopt ScarfDesign system across Mac UI
Add a typed design-system package (Packages/ScarfDesign) with rust-tone
color tokens, type scale, spacing/radius tokens, ScarfPageHeader and
component primitives (ScarfCard, ScarfBadge, ScarfTextField,
ScarfSectionHeader, ScarfDivider, four button styles). Both Mac and iOS
targets `import ScarfDesign`.

Sidebar redesigned per design/static-site/ui-kit/Sidebar.jsx — glassy
translucent background, 224 px width, app-icon header with server pill,
custom tokenized rows with rust accent-tint when active, footer with
live Hermes-running indicator (wired to ServerLiveStatusRegistry).

14 mockup-backed feature screens redesigned: Settings, Dashboard,
Sessions, Memory, Chat (visual sweep), Activity, Cron, Insights,
MCPServers, Health, Logs, Tools (full); Projects light-touch.
Non-mockup features inherit rust through AccentColor.colorset repoint.

Mac AppIcon.appiconset replaced with the rust set. AccentColor.colorset
repointed to BrandRust hex (light + dark variants).

Visual sweep: every multi-button page-header / action-bar cluster now
wraps in .fixedSize(horizontal: true, vertical: false) so labels can't
wrap letter-by-letter at narrow widths (regression seen on the MCP
detail pane with 4 buttons).

Follow-ups landed:
- Sidebar Hermes-running probe wired to per-window
  ServerLiveStatusRegistry (no more placeholder green).
- Sessions: today filter predicate (isDateInToday(startedAt)); pill
  count reflects real count. Starred stays a no-op pending an upstream
  pinned/starred field on HermesSession.
- Dashboard: Recent activity column rendered alongside Recent sessions
  in a ViewThatFits 2-col grid. Populated from
  HermesDataService.fetchRecentToolCalls(limit:) flattened to
  ActivityEntry. ActivityEntry gains a public memberwise init.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-25 13:27:54 +02:00
Alan Wizemann f04d95c960 refactor(mac-skills): delegate loadSkills to shared SkillsScanner (Phase E)
Cleanup pass: HermesFileService.loadSkills() was duplicating walk
logic that the new ScarfCore SkillsScanner now owns. Replaced the
~38-line implementation with a one-line delegation.

Removed:
- HermesFileService.loadSkills() walk body (38 lines).
- HermesFileService.parseSkillFrontmatter (24 lines, supersedes by
  SkillFrontmatterParser.parseV011Fields).
- HermesFileService.parseSkillRequiredConfig (24 lines, superseded by
  SkillFrontmatterParser.parseRequiredConfig).

The remaining HermesFileService surface (loadSkillContent,
saveSkillContent, isValidSkillPath) is unchanged — those are Mac-
target-specific guards on file paths that don't fit ScarfCore.

Tab enum audit: searched for orphan `.memory` / `.more` references
under Scarf iOS/. None found — the worktree refactor cleanly
migrated every selectedTab assignment to the new 5-tab vocabulary.

Verified: ScarfCore 197 tests + 28 catalog tests + Mac + iOS builds
all green (Phase F gate).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-25 10:02:37 +02:00
Alan Wizemann 26c034ea6f feat(ios-skills): port v0.11 features to new file structure (Phase D)
Re-port the four v0.11 iOS Skills features that lived in the now-
deleted Skills/SkillsListView.swift into the new Installed/SkillDetailView
+ Skills/SkillsView surfaces.

iOS Components/FlowLayout.swift (NEW, promoted helper):
- 50-line struct conforming to SwiftUI's Layout protocol; wraps
  subviews onto multiple lines on overflow. Built-in API, no third-
  party dep. Originally inline in the deleted SkillsListView; promoted
  so multiple iOS views can share without duplicating ~30 lines.

iOS Skills/Installed/SkillDetailView.swift (extend):
- design-md npx prereq banner: yellow "Prerequisite missing" section
  triggered by .task(id: skill.id) probing `which npx` via
  SkillPrereqService when the selected skill is design-md.
- Spotify info row: green "Authentication" section pointing users at
  the Mac sheet or shell for OAuth — phone OAuth flows are deferred
  in v2.5.
- SKILL.md frontmatter chip rows: three sections (Allowed tools /
  Related skills / Dependencies) using a chipRow helper backed by
  the shared FlowLayout. Each section hides itself when its
  HermesSkill field is nil — old skills without v0.11 frontmatter
  show none of these.

iOS Skills/SkillsView.swift (extend):
- "What's New" pill at the top of the tab (above the sub-tab
  picker). Driven by SkillSnapshotService.diff() against the per-
  server last-seen snapshot. First-load primes silently so users
  don't see "everything is new!" noise on day one.
- Recomputes on .task and .refreshable.
- "Seen" button persists the current set + dismisses.

Verified: iOS build succeeds. The chip-row data path is now
end-to-end (SkillsScanner → HermesSkill → SkillDetailView chips)
and the snapshot pill matches the Mac SkillsView placement.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-25 10:00:55 +02:00
Alan Wizemann 84b033814b feat(scarfcore): SkillsScanner populates v0.11 frontmatter (Phase C)
Post-merge follow-up: the new SkillsScanner constructed HermesSkill
with `requiredConfig` only — leaving `allowedTools` / `relatedSkills`
/ `dependencies` (added in my v0.11 Phase 3.3) as nil. Detail-view
chip rows would render empty.

SkillFrontmatterParser:
- New `parseV011Fields(_:) -> (allowedTools:, relatedSkills:,
  dependencies:)` reader. Reuses HermesYAML.parseNestedYAML to
  extract the three lists from the SKILL.md frontmatter region
  between `---` markers. Returns nil-everything when the file has
  no frontmatter or the fields are absent / empty — chip rows hide.
- Existing `parseRequiredConfig(_:)` unchanged.

SkillsScanner:
- Reads `<skill>/SKILL.md` opportunistically (after the
  `<skill>/skill.yaml` read), parses v0.11 frontmatter, passes
  the three optional arrays into the HermesSkill constructor.
- Old skills without SKILL.md or without frontmatter keep nil and
  scan keeps working.

Tests:
- 5 new SkillFrontmatterParserTests cases covering happy path,
  partial fields, no frontmatter, empty fields, empty input.
- 10 total tests for the parser; all green.

Verified: ScarfCore builds clean. The chip-row data path is now
end-to-end (scan → HermesSkill → detail view) for both Mac and iOS.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-25 09:58:29 +02:00
Alan Wizemann 3d4a6a3a75 Merge branch 'claude/pedantic-mcnulty-bac7cc' (iOS UI refactor)
Brings the major iOS UI refactor into scarf-mobile-development on top
of the v0.11 work that landed after the merge base (commit 6808adf).

Reconciled in this merge:

- iOS Chat/ChatView.swift — auto-merged. Their project-chat handoff
  (lines 75-148: pendingProjectChat consumer + consumePendingProjectChat
  helper) sits cleanly alongside my v0.11 chat additions at lines 350+
  (slash command chip + browser sheet), 500+ (/steer toast), 700+
  (per-turn stopwatch + git branch chip).
- Mac Features/Skills/Views/SkillsView.swift — manual resolution.
  Took their async-wrap of viewModel.load() (the new ScarfCore
  SkillsViewModel.load is async) AND kept my v0.11 modifiers
  (designMdNpxStatus probe + recomputeSnapshotDiff + .onChange + .task)
  + helpers (recomputeSnapshotDiff, whatsNewPill).
- M5FeatureVMTests.swift — auto-merged. Their 3-line rename of
  IOSSkillsViewModel → SkillsViewModel is in a different region from
  my Phase 1.10 slash-command tests.
- iOS Skills/SkillsListView.swift — resolved as DELETE (their
  refactor replaces it with Skills/Installed/SkillDetailView and
  Skills/SkillsView). My v0.11 features there (Spotify info row,
  design-md banner, frontmatter chips, What's New pill) get re-ported
  to the new files in follow-up commits.
- ScarfCore IOSSkillsViewModel.swift — resolved as DELETE (replaced
  by the shared SkillsViewModel in ScarfCore). My parseFrontmatter
  function relocates to SkillFrontmatterParser via Phase C.
- ProjectSlashCommandsViewModel.swift — git's location-conflict
  heuristic moved my Mac VM into ScarfCore (because the parent dir
  was renamed). Manually relocated back to scarf/scarf/Features/Projects/ViewModels/
  where it belongs (the file imports ScarfCore as a dependency, can't
  live inside it).

Wholesale-accepted (no overlap with v0.11):
- ScarfCore: SkillsScanner, SkillFrontmatterParser, HermesSkillsHubParser,
  SkillsViewModel, ProjectSessionsViewModel + new tests.
- iOS Projects/ feature (NEW): ProjectsListView, ProjectDetailView,
  ProjectSessionsView_iOS, ProjectSiteView, Widgets/ subdir.
- iOS Skills/ refactor (NEW): SkillsView (3-sub-tab switcher),
  Hub/HubBrowseView, Installed/{InstalledSkillsListView, SkillDetailView,
  SkillEditorSheet}, Updates/UpdatesView.
- ScarfGoCoordinator: pendingProjectChat, startChatInProject(path:).
- ScarfGoTabRoot: 5-tab nav (Dashboard / Projects / Chat / Skills /
  System) replacing the old Chat / Dashboard / Memory / More.

Verified: ScarfCore + Mac + iOS schemes all build clean on first try
post-merge. Phase C/D/E follow-up commits will:
1. Extend SkillsScanner so HermesSkill.allowedTools / relatedSkills /
   dependencies populate (currently nil because the new scanner only
   parses skill.yaml's required_config).
2. Port my v0.11 iOS Skills features into the new SkillDetailView /
   SkillsView (Spotify info row, design-md npx banner, frontmatter
   chips, What's New pill).
3. Clean up Mac dead code (HermesFileService.parseSkillFrontmatter,
   parseSkillRequiredConfig — superseded by SkillsScanner /
   SkillFrontmatterParser).
2026-04-25 09:56:13 +02:00
Alan Wizemann a73025aba0 feat(ios): 5-tab nav + Projects/Skills feature parity with Mac
Major iOS UI refactor that brings ScarfGo to feature parity with the
Mac app for Projects + Skills, on top of a ScarfCore consolidation
that unifies the view-model + scanner/parser layer between platforms.

Layout (ScarfGoTabRoot):
- Old: Chat / Dashboard / Memory / More (4 tabs).
- New: Dashboard / Projects / Chat / Skills / System (5 tabs, Chat
  centered). Memory + Cron + Settings consolidate under System.

Projects (NEW iOS feature):
- ProjectsListView, ProjectDetailView, ProjectSessionsView_iOS,
  ProjectSiteView.
- Widgets/ subdir: 7 widget views (Chart, List, Progress, Stat,
  Table, Text, Webview) + WidgetHelpers + DashboardWidgetsView.
- Tied to chat via ScarfGoCoordinator.startChatInProject() which
  sets pendingProjectChat + flips selectedTab to .chat.

Skills (NEW iOS surface):
- SkillsView is a 3-sub-tab switcher (Installed / Browse Hub / Updates).
- Installed/: InstalledSkillsListView, SkillDetailView,
  SkillEditorSheet.
- Hub/HubBrowseView for the skills hub catalog.
- Updates/UpdatesView for hermes skills check / update.

ScarfCore consolidation:
- SkillsViewModel and ProjectSessionsViewModel lift from Mac target
  into ScarfCore so iOS and Mac share one type.
- New SkillsScanner walks ~/.hermes/skills/ once for both platforms
  via the supplied transport.
- New SkillFrontmatterParser handles required_config: parsing.
- New HermesSkillsHubParser for the hub catalog format.
- Tests for both new parsers.

Mac touchpoints:
- Features/Skills/Views/SkillsView.swift: .onAppear wraps the now-
  async load() in a Task.
- Old Mac-target SkillsViewModel and ProjectSessionsViewModel
  deleted (replaced by ScarfCore).

Coordinator + chat:
- ScarfGoCoordinator gains pendingProjectChat: String?
  + startChatInProject(path:) helper.
- iOS ChatView consumes pendingProjectChat (mirrors the existing
  pendingResumeSessionID pattern); resolves path → ProjectEntry via
  registry, falls back to a synthesized entry on miss.

Tests:
- M5FeatureVMTests renames 3 IOSSkillsViewModel references to the
  shared SkillsViewModel.
- New SkillFrontmatterParserTests + SkillsHubParserTests.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-25 09:52:16 +02:00
Alan Wizemann 99f734bf0b feat(ios-memory): hermes memory reset on iOS too (cross-platform parity)
Mac shipped the toolbar Reset button in Phase 5; iOS gets it in the
final verification pass for parity.

iOS MemoryListView:
- Toolbar button (counterclockwise icon) opens a destructive
  confirmation dialog matching the Mac copy.
- resetMemory() shells out via context.makeTransport().runProcess,
  using the same PATH-prefix trick IOSSettingsViewModel.saveValue
  uses so non-interactive remote shells find hermes in ~/.local/bin
  / /opt/homebrew/bin / ~/.hermes/bin.
- Success and failure both surface alerts (success message
  confirms the wipe; failure surfaces stderr+stdout combined).

Verified: iOS build clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-25 09:36:57 +02:00
Alan Wizemann ca1eb54a5b docs(v2.5): extend release notes + README with Hermes v0.11 work (Phase 8)
Phase 8 of the v2.5 plan — fold the Hermes v2026.4.23 integration into
the existing v2.5 release artifacts rather than creating a v2.6 set.

releases/v2.5.0/RELEASE_NOTES.md:
- Lead paragraph extended to mention slash commands, chat parity,
  Spotify, design-md.
- Six new sections: Portable project-scoped slash commands, Hermes
  v2026.4.23 chat parity, Spotify + design-md skill onboarding,
  SKILL.md frontmatter chips, "What's New" pill, state.db deltas,
  hermes memory reset.
- All inserted before the existing "Mac global Sessions" section so
  the Hermes-v0.11 work reads as the headline alongside ScarfGo.

README.md:
- "What's New in 2.5" lead bullets gain slash commands, Hermes v0.11
  chat parity, Spotify+design-md, SKILL.md chips, snapshot pill.
- Test count bumped 163 → 179.
- Requirements: Hermes recommended bumped from v0.10.0+ to v0.11.0+
  with feature attribution.
- Compatibility table: v0.11.0 row added as the current target;
  v0.10.0 row demoted to "Tool Gateway introduced".
- Targeting paragraph rewritten for v2.5/v0.11.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-25 09:33:53 +02:00
Alan Wizemann f35bc910e4 feat(memory): hermes memory reset toolbar action + v0.11 CLI doc (Phase 5)
Adopt the lowest-risk new CLI subcommand from Hermes v2026.4.23 —
`hermes memory reset --yes` — and document the deferred ones for
v2.6. Wholesale plugin/profile/webhook/logs adoption is forward-
compatible work the existing services don't block on; deferring
keeps v2.5 scope tight.

MemoryView:
- Toolbar button "Reset memory…" with .arrow.counterclockwise icon.
- Confirmation dialog explaining the destructive semantics (no undo,
  wipes both MEMORY.md and USER.md). Routes through
  context.runHermes(["memory", "reset", "--yes"]); on non-zero exit
  shows the stderr in an alert. Refreshes the on-screen content on
  success.

CLAUDE.md:
- "Hermes Version" section now leads with v2026.4.23 (v0.11.0) and
  enumerates the v2.5-adopted features (slash steer, state.db
  deltas, new skills, frontmatter chips, memory reset) with file
  pointers. v2.6-deferred CLIs (plugins / profile / webhook /
  insights / logs) are flagged so future bandwidth knows where to
  pick up.

Verified: Mac build clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-25 09:29:26 +02:00
Alan Wizemann 8057beb001 feat(state.db): reasoning_content + api_call_count (Phase 4)
Hermes v2026.4.23 added two columns to state.db:
- messages.reasoning_content — newer richer reasoning channel some
  providers emit alongside the legacy messages.reasoning blob.
- sessions.api_call_count — distinct from tool_call_count; counts
  per-turn API round-trips so the user can see the cost breakdown.

ScarfCore models:
- HermesMessage gains reasoningContent: String? + computed
  preferredReasoning + updated hasReasoning to consider both
  channels.
- HermesSession gains apiCallCount: Int (default 0 for old hosts).

ScarfCore HermesDataService:
- hasV011Schema flag detects both new columns via PRAGMA
  table_info; only flips true when BOTH are present (partial
  migrations stay on the v0.7 path to avoid runtime "no such
  column" errors).
- sessionColumns / messageColumns / searchMessages SELECT lists
  conditionally append the new columns.
- sessionFromRow / messageFromRow read them defensively (column
  index 20 / 11 respectively when v0.11 schema is on).

UI surfacing:
- Mac SessionDetailView shows "<N> API" label next to msgs/tools
  when apiCallCount > 0.
- Mac Dashboard SessionRow + iOS Dashboard sessionRow add a
  network-icon chip with the API call count.
- Mac RichMessageBubble + iOS MessageBubble switch to
  message.preferredReasoning for the disclosure body.

Verified: ScarfCore + Mac + iOS build. 179/179 ScarfCore tests pass
unchanged (existing tests didn't construct sessions/messages with
the new fields; defaults preserve behaviour).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-25 09:27:22 +02:00
Alan Wizemann 751c9e6778 feat(skills): SkillSnapshotService + 'What's New' pill (Phase 3.4)
Per-server snapshot of skill signatures so the Skills tab can show
"2 new, 4 updated since you last looked" — same pattern Hermes's
`hermes skills update` CLI shows on the host.

ScarfCore SkillSnapshotService:
- [skillId: signature] map, signature is `<fileCount>:<sorted-files>`.
  New / removed / files-changed all show up as a delta.
- diff(against:) returns SkillSnapshotDiff with counts + a label
  string for the pill.
- markSeen(_:) persists the current set.
- Backend abstraction: file-based on Mac, UserDefaults on iOS,
  in-memory for tests.
- previousSnapshotEmpty silently primes first-load so users don't
  see "everything is new!" noise.

Mac SkillsView:
- whatsNewPill(diff:) tinted pill at the top with "Mark as seen".
- recomputeSnapshotDiff() on .task and on totalSkillCount change.

iOS SkillsListView:
- Same pill rendered as a Section row with "Seen" button.
- Recompute on .task + .refreshable.

Verified: Mac + iOS builds clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-25 09:22:38 +02:00
Alan Wizemann 5c08c09dde feat(skills): SKILL.md frontmatter v0.11 fields (Phase 3.3)
Hermes v2026.4.23 SKILL.md files carry richer YAML frontmatter:
allowed_tools, related_skills, dependencies. Surface them as chip
rows in the skill detail view on both platforms.

ScarfCore HermesSkill:
- Three new optional fields: allowedTools, relatedSkills,
  dependencies. Default-nil so older skills (no SKILL.md, or
  SKILL.md without these fields) load unchanged.

Mac HermesFileService.parseSkillFrontmatter:
- Reads `<skill>/SKILL.md`, splits at `---` markers, parses the
  frontmatter via HermesYAML.parseNestedYAML, and extracts the three
  list fields. Tuple-of-optionals return; nil-everything when the
  file is absent or has no frontmatter.

iOS IOSSkillsViewModel.parseFrontmatter:
- Mirror with the iOS transport (over SFTP). Same parser, same
  return shape.

Mac SkillsView:
- skillChipSection(title:items:) helper renders a labelled chip
  row. Three rows added between the existing missing-config /
  Spotify / npx surfaces and the file list — only shown when the
  corresponding field is non-empty.

iOS SkillDetailView:
- chipRow(_:) helper using a small in-file FlowLayout (built-in
  Layout protocol, no third-party dep) so the chips wrap onto
  multiple lines on iPhone-narrow screens. Three sections matching
  Mac.

Verified: ScarfCore + Mac + iOS builds clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-25 09:18:54 +02:00
Alan Wizemann 7ec7282f36 feat(skills): design-md npx prereq check (Phase 3.2)
design-md (Hermes v2026.4.23) requires `npx` (Node.js 18+) on the
host to invoke `npx @google/design.md`. Probe the host's PATH when
the skill is selected; surface a yellow banner with an install hint
when missing.

ScarfCore SkillPrereqService:
- probe(binary:installHint:) async -> Status — runs `/usr/bin/env
  which <binary>` via the transport with a 4s timeout. Returns
  .present / .missing(hint) / .unknown(reason).
- installHints table for npx / node / gws / ffmpeg with terse
  per-OS install guidance. Skills can pass custom hints if their
  install path is more involved.

Mac SkillsView:
- @State designMdNpxStatus + .onChange(of: selectedSkill.name)
  triggers the probe whenever the user lands on the design-md skill.
  Banner renders only on .missing — present and unknown cases stay
  silent (avoids false-alarm noise on transient SSH errors).

iOS SkillDetailView:
- @State npxStatus + .task(id: skill.id) per-skill probe.
- Same banner with the same hint copy; no install button (user is
  already on iPhone, fixing the host needs a shell anyway).

Verified: ScarfCore + Mac + iOS builds clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-25 09:15:28 +02:00
Alan Wizemann 97aa988762 feat(skills): Spotify auth flow + sign-in sheet (Phase 3.1)
Hermes v2026.4.23 ships a `spotify` skill that needs OAuth via
`hermes auth spotify`. Mirror the v2.3 Nous Portal in-app sign-in
pattern so users don't have to drop to a shell.

Mac (full sign-in flow):
- SpotifyAuthFlow.swift in Core/Services — @Observable @MainActor,
  five-state machine (idle → starting → waitingForApproval(URL) →
  verifying → success | failure). Spawns `hermes auth spotify` via
  the transport, regex-detects the
  `https://accounts.spotify.com/authorize?...` URL on stdout/stderr,
  auto-opens it via NSWorkspace, and on subprocess exit polls
  `~/.hermes/auth.json` to confirm `providers.spotify.access_token`
  actually landed (exit code alone isn't proof).
- SpotifySignInSheet.swift in Features/Skills/Views — five sub-views
  matching the state machine (starting / waiting / verifying /
  success / failure with retry). Auto-dismisses 1.2s after success.
  Mirrors NousSignInSheet shape.
- SkillsView surfaces a "Sign in to Spotify" row in the skill detail
  pane when the selected skill is the spotify one.

iOS (read-only documentation):
- SkillsListView's SkillDetailView gains a "Authentication" section
  on the spotify skill explaining that OAuth needs to happen from
  Mac (or a shell). The credential lands in ~/.hermes/auth.json and
  ScarfGo picks it up automatically once the agent uses the skill.
  Editor sheet UX deferred to v2.6 — multi-line OAuth flows on iPhone
  are a separate UX problem.

Verified: Mac + iOS builds clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-25 09:12:34 +02:00
Alan Wizemann 64bcea35a0 feat(chat): git branch indicator in chat header (Phase 2.4)
Hermes v2026.4.23's TUI shows the project's current git branch as a
sidebar pill. Mirror it in the chat header on both platforms.

ScarfCore GitBranchService:
- branch(at projectPath: String) async -> String? — runs
  `git -C <path> rev-parse --abbrev-ref HEAD` via the transport
  (works on local + remote SSH projects). Returns nil for
  non-git dirs, missing git, detached HEAD, or transport errors.
  No throwing — chat header omits the chip on any failure.

Mac:
- ChatViewModel.currentGitBranch populated alongside currentProjectPath
  in startACPSession's resolution branch.
- SessionInfoBar gains gitBranch: String? — renders a tinted
  `arrow.triangle.branch` chip after the project chip when set.
- RichChatView wires chatViewModel.currentGitBranch through.

iOS:
- ChatController.currentGitBranch on the same lifecycle hooks
  (resetAndStartInProject + startResuming + cleared on
  resetAndStartNewSession).
- projectContextBar renders the chip inline next to the project
  name.

Verified: ScarfCore + Mac + iOS builds clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-25 09:08:44 +02:00
Alan Wizemann 1fcd963019 feat(chat): numbered shortcuts on permission sheet (Phase 2.3)
Hermes v2026.4.23's TUI rewrite added 1-9 numbered shortcuts on
approval prompts so power users approve/deny without reaching for
the mouse. Mirror the pattern in Scarf:

Mac PermissionApprovalView:
- Each option button gets a "1. ", "2. ", … prefix on its label.
- New private View extension `applyingNumberShortcut(index:)` binds
  the digit `idx + 1` (no modifiers) via .keyboardShortcut. Capped
  at 9; extra options stay tappable but unbound.

iOS PermissionSheet:
- Each row gets a monospaced "1." / "2." prefix as a hierarchy hint.
- No keyboard binding (phones don't have hardware keyboards), but
  the numbering matches the Mac pattern so users transitioning
  between platforms see the same visual structure.

Verified: Mac + iOS builds clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-25 09:04:33 +02:00
Alan Wizemann 70d4c97a6c feat(chat): per-turn stopwatch on assistant bubbles (Phase 2.2)
Wall-clock duration of each agent turn renders as a compact pill in
the message metadata footer (Mac) / below the bubble (iOS). Mirrors
the per-turn stopwatch Hermes v2026.4.23's TUI rewrite ships.

ScarfCore RichChatViewModel:
- currentTurnStart: Date? captured in addUserMessage when entering a
  fresh turn (skipped for /steer-style mid-run sends so the duration
  reflects the FULL turn).
- turnDurations: [Int: TimeInterval] keyed by finalised assistant
  message id; populated in finalizeStreamingMessage and cleared on
  reset().
- formatTurnDuration(_:) static — "0.8s" / "4.2s" / "1m 12s".

Mac:
- RichMessageBubble gains turnDuration: TimeInterval?; renders via
  formatTurnDuration in the existing metadata footer.
- RichChatMessageList + MessageGroupView thread the durations dict
  through; RichChatView wires richChat.turnDurations.

iOS:
- MessageBubble gains turnDuration parameter; renders below the
  bubble for assistant messages only.
- ChatView's ForEach passes controller.vm.turnDuration(forMessageId:).

Verified: Mac + iOS builds clean. Resumed sessions (loaded from
state.db) show no pill — turnDurations only populates for live ACP
turns, which is the correct behaviour.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-25 09:01:26 +02:00
Alan Wizemann a9bd51bf05 feat(chat): /steer non-interruptive support (Phase 2.1)
Hermes v2026.4.23 introduces /steer — mid-run guidance the agent
applies after the next tool call without interrupting the current
turn. Surface it as a first-class slash command in both Mac and iOS
chat menus with non-interruptive send semantics.

ScarfCore RichChatViewModel:
- nonInterruptiveCommands static (currently just /steer) merged
  into availableCommands at the end of the menu.
- HermesSlashCommand.Source.acpNonInterruptive case carries the
  flag through to the menu UI.
- transientHint: String? property for short-lived composer toasts.
- isNonInterruptiveSlash(_ text: String) -> Bool helper for the
  send paths to detect /steer-shaped invocations.

Mac ChatViewModel.sendViaACP:
- /steer-shaped sends skip the "Agent working..." status update
  (the agent is already on its current turn) and set a 4-second
  transientHint "Guidance queued — applies after the next tool call."

Mac RichChatView:
- New steeringToast() above the input bar renders the hint when
  set; tinted pill with arrow icon, opacity transition.

iOS ChatController.send + ChatView:
- Same isNonInterruptiveSlash check surfaces the toast above the
  composer; auto-clears via the same 4s Task pattern.
- steeringToast() helper view in ChatView.

Verified: ScarfCore + Mac + iOS builds clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-25 08:56:47 +02:00
Alan Wizemann 79a350d793 test(scarfcore): M9 slash-command surfaces (Phase 1.10)
16 tests across name validation, frontmatter parsing, argument
substitution (plain + default fallback + multiple occurrences),
on-disk round-trip, missing-dir graceful handling, save invalidation,
delete idempotency, and ProjectContextBlock surfacing (slash command
list line + idempotency + omission when empty).

179 tests across 13 suites — green.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-25 08:53:31 +02:00
Alan Wizemann b247942e1f feat(slash-commands): .scarftemplate format extension + catalog validator (Phase 1.8-1.9)
Slash commands now travel with .scarftemplate bundles. Schema bumps
to v3 when a manifest declares contents.slashCommands; v1/v2 bundles
keep parsing unchanged.

Swift side:
- TemplateContents gains slashCommands: [String]? — names only.
  Bundle layout: slash-commands/<name>.md at the root.
- ProjectTemplateService.buildInstallPlan copies each claimed name
  into <projectDir>/.scarf/slash-commands/<name>.md.
- ProjectTemplateService.verifyClaims cross-checks: each name must
  pass ProjectSlashCommand.validateName, the file must exist, and
  the bundle can't contain unclaimed slash-commands/ files.
- TemplateLock gains slashCommandFiles: [String]? (relative to
  project root). The uninstaller's existing tracked-file logic
  removes them; user-authored slash commands in the same dir
  survive (they're not in the lock).
- ProjectTemplateExporter scans <project>/.scarf/slash-commands/ on
  export and copies each .md into the bundle root, populating the
  manifest contents claim. SchemaVersion bumps to 3 only when slash
  commands are present.

Python catalog validator (tools/build-catalog.py):
- SUPPORTED_SCHEMA_VERSIONS gains 3.
- SLASH_COMMAND_NAME_RE mirrors the Swift validation pattern.
- _validate_contents_claim picks up slashCommands: rejects malformed
  names, missing files, and unclaimed extras with the same error
  shapes the Swift verifier uses.

Tests:
- 4 new test_build_catalog cases. 28/28 catalog tests pass.
- ProjectTemplateTests literal updated for the new TemplateContents
  field.

Verified: Mac + iOS builds succeed.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-25 08:51:56 +02:00
Alan Wizemann 7f5ff1946e feat(slash-commands): ScarfGo read-only browser sheet (Phase 1.7)
Read-only surface in iOS for browsing project-scoped slash commands.
Editing on phones is its own UX problem (multi-line markdown +
keyboard ergonomics) — Mac stays the canonical authoring surface
in v2.5; iOS browses + invokes.

When a project chat has at least one slash command loaded,
projectContextBar grows a tinted "<N> slash" chip on the right side.
Tapping opens ProjectSlashCommandsBrowser:

- List of every command with /<name>, description, argument hint,
  optional model-override badge.
- Tap a row → CommandDetailSheet with the full prompt-template body
  rendered in a monospaced block (text-selection enabled), plus
  metadata rows for argumentHint / model / tags.
- Footer points authors back to Mac for editing.

Verified: iOS build succeeds.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-25 08:45:25 +02:00
Alan Wizemann 9164e65cac feat(slash-commands): Mac authoring UI — Slash Commands tab + editor (Phase 1.6)
Adds a fourth per-project tab on Mac (alongside Dashboard / Site /
Sessions) for managing project-scoped slash commands. The whole
authoring story lives here: list, add, edit, duplicate, delete, with
a live-preview pane that expands {{argument}} substitutions against a
sample-arg field so authors see exactly what Hermes will receive.

- ProjectSlashCommandsViewModel — @Observable @MainActor, owns the
  commands list + editor draft + dirty-tracking. Routes through
  ScarfCore's ProjectSlashCommandService for all I/O. Save validates
  name shape + collision detection before writing; rename cleans up
  the previous file.
- ProjectSlashCommandsView — list with content menu (Edit/Duplicate/
  Delete), empty state with CTA, error banner for transient failures.
- SlashCommandEditorSheet — HSplitView with form on the left
  (identity / optional / monospaced body editor) and live preview on
  the right (sample-argument field + expanded prompt). Save disabled
  until name + description + body are non-empty.
- DashboardTab gains .slashCommands case alongside dashboard / site /
  sessions; visibleTabs filter unchanged so it always shows for any
  selected project.

iOS gets a read-only browser in the next commit (Phase 1.7) — phone
keyboards aren't great for multi-line markdown editing.

Verified: Mac build succeeds.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-25 08:43:49 +02:00
Alan Wizemann 8a87ff1922 feat(slash-commands): list project commands in AGENTS.md block (Phase 1.5)
The chat layer client-side-expands /<name> args, but the agent still
needs to know what commands exist so it can answer "what slash
commands does this project have?" and recognise the
<!-- scarf-slash:<name> --> marker prepended to expanded prompts.

ProjectContextBlock.renderMinimalBlock(...) gains an optional
slashCommandNames parameter; when non-empty, a new "Project slash
commands" bullet lists the names as backticked /<name> entries.

Mac's ProjectAgentContextService.renderBlock(for:) reads the names
via ProjectSlashCommandService.loadCommands(at:).map(\.name) and
emits the same bullet, keeping Mac and iOS block output aligned
where the content overlaps.

iOS chat resetAndStartInProject splits the slash-command load into a
synchronous read on a detached task BEFORE writing the block —
needed because the block has to land on disk before `hermes acp`
boots, and the async load that populates the chat menu would lose
the race.

Verified: ScarfCore, Mac, iOS all build clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-25 08:40:15 +02:00
Alan Wizemann 6808adfa98 feat(slash-commands): portable project-scoped slash commands (Phase 1.1-1.4)
Net-new Scarf primitive — Hermes has no project-scoped slash command
concept. Commands live at <project>/.scarf/slash-commands/<name>.md as
Markdown files with YAML frontmatter; Scarf intercepts the chat slash
menu, expands {{argument}} substitution client-side, and sends the
expanded prompt as a normal user message. Works uniformly on Mac + iOS,
local + remote SSH, against any Hermes version (no upstream dep).

Lands the model + service + chat wiring; editor UI (Mac), read-only
browser (iOS), AGENTS.md block extension, .scarftemplate format
extension, and tests follow in subsequent commits.

What this commit ships:

- ScarfCore Models/ProjectSlashCommand.swift — Sendable struct
  carrying name + description + argumentHint? + model? + tags? + body
  + sourcePath. Validates name shape (lowercase, hyphens, starts with
  letter, ≤64 chars).
- ScarfCore Services/ProjectSlashCommandService.swift — transport-
  based loadCommands(at:), loadCommand(at:), save(_:at:),
  delete(named:at:), expand(_:withArgument:). Markdown-with-
  frontmatter parser reuses HermesYAML so no new dep. Substitution
  supports `{{argument}}` and `{{argument | default: "..."}}`.
- HermesSlashCommand.Source gains .projectScoped (full payload looked
  up in RichChatViewModel by name) and .acpNonInterruptive (reserved
  for /steer in Phase 2.1).
- RichChatViewModel.projectScopedCommands + projectScopedCommand(named:)
  + loadProjectScopedCommands(at:); availableCommands precedence is
  ACP > project-scoped > quick_commands, all de-duped by name.
- Mac ChatViewModel: expandIfProjectScoped(_:) helper called in
  sendViaACP; loads commands when currentProjectPath is set in
  startACPSession's resolution branch.
- iOS ChatController: same pattern in send(); loads commands in both
  resetAndStartInProject and startResuming(sessionID:); resume now
  resolves both path AND name so we can read the slash-commands dir.

Verified: ScarfCore + Mac + iOS all build clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-25 08:35:30 +02:00
Alan Wizemann bdc271c2b8 docs(readme): trim history + lead with v2.5
Drops "Previously, in 1.6 / 2.0 / 2.1 / 2.2" so the README's release
history is just the lead (2.5) + one-level-back (2.3). Earlier history
moves to the wiki's Release-Notes-Index, which is the canonical place
for full version history anyway.

New "What's New in 2.5" section leads with ScarfGo public TestFlight,
the Mac Sessions parity (filter + badges), human-readable cron
schedules, and the under-the-hood consolidation in ScarfCore.

Requirements section gains an iOS row pointing at the ScarfGo wiki
page for installation; the Hermes recommended-version bumps from
v0.9.0+ to v0.10.0+ to match the v2.3 floor.

No iOS-specific install instructions in the README — the TestFlight
URL gets added later in Phase G once Apple's Beta Review issues it.
For now, the link points at the wiki where the URL will land.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-25 08:04:36 +02:00
Alan Wizemann d45de925ae docs(v2.5): privacy policy + TestFlight submission checklist
Authored locally (not pushed). Phase D of the v2.5 release plan needs:
- A privacy policy at a stable URL before App Store Connect lets you
  submit for Beta App Review.
- A pre-flight checklist so the Xcode + App Store Connect dance
  doesn't lose state.

`scarf/docs/PRIVACY_POLICY.md` — minimal, accurate. The apps don't
collect data on developer-controlled servers (no analytics, no
telemetry, no ads, no IDFA). Covers SSH credentials, Hermes state
cache, the project + attribution sidecars, the network connections the
apps make. Ready to host on gh-pages at /privacy/ when the user opts to
push it.

`releases/v2.5.0/TESTFLIGHT_CHECKLIST.md` — step-by-step from Apple
Developer Program prerequisites through Beta Review submission, with a
beta-description copy block, "What to test" copy, and a rollback note.
Explicitly calls out NOT bumping versions manually (release.sh does it
in Phase G) and NOT enabling Push Notifications until APNs cert +
sender land together.

Both files stay local until the user pushes them — the checklist is
the user's reference, the privacy policy gets copied into the
gh-pages worktree when ready to submit.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-25 07:57:56 +02:00
Alan Wizemann 1eb37771f9 docs(v2.5): release notes
Authored before `release.sh` so it gets included in the version-bump
commit auto-generated by the script in Phase G.

Highlights: ScarfGo iOS public TestFlight, Mac Sessions project filter
+ badges (parity with ScarfGo's Sessions tab), human-readable cron
schedules cross-platform, shared-services refactor, silent-failure
hardening on the iOS lifecycle, test-suite consolidation that fixes the
cross-suite factory races we hit during pre-release verification.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-25 07:55:50 +02:00
Alan Wizemann 1174c5abc7 feat(mac-sessions): project filter + badges (v2.5 parity with iOS)
The Mac global Sessions feature rendered all sessions with no project
context. ScarfGo's new Sessions tab added a project filter Menu and
badge chips on each row in v2.5 — bring the same to Mac so v2.5 lands
as a user-visible upgrade on both platforms, not just iOS.

Changes:

- `SessionsViewModel`: load `~/.hermes/scarf/session_project_map.json`
  + the project registry off the main actor (single batched read,
  matches the iOS Dashboard pattern). Exposes `sessionProjectNames`,
  `allProjects`, `projectFilter`, `filteredSessions`, and
  `projectName(for:)`.
- `SessionsView`: filter bar above the list (shown only when at least
  one project is registered) with a Menu listing "All projects",
  "Unattributed", and each registered project. An xmark button clears
  the filter. The right side shows "X of Y shown" so the filter's
  effect is obvious.
- `SessionRow` (shared with Dashboard): gains an optional
  `projectName: String?` parameter that renders a tinted folder chip
  alongside the relative date when set.

Both services already lived in ScarfCore (moved there in v2.5's iOS
work), so this is pure UI consumption — no new shared logic.

Verified: Mac build succeeds.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-25 07:54:34 +02:00
Alan Wizemann 4fc12ca790 fix(ios-notifications): feature-gate Approve/Deny stub actions
Push Notifications capability is disabled in the iOS target, so the
APNS code path can't fire today — but the `SCARF_PENDING_PERMISSION`
category was registered unconditionally, exposing the stub-only
`APPROVE_PERMISSION` / `DENY_PERMISSION` action handlers as a route iOS
could surface action buttons on if a notification ever slipped through.

Add `NotificationRouter.apnsEnabled` (=`false`) and gate
`registerCategories()` behind it. While `false`, the category is
explicitly cleared so iOS has no path to route a tap to the stubs. The
gate is the single switch — flipping it requires the capability +
sender + real handler implementations to all land together.

Verified: iOS build succeeds.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-25 07:51:43 +02:00
Alan Wizemann 3da3d3ce5e fix(ios-rootmodel): surface store failures (A.3 + A.4 bundled)
Bundled because the fixes are coherent — they all add the same
mechanism (`lastError` + `os.Logger`) to the same model.

A.3 — Distinguish "no servers" from "Keychain unreachable":
- `RootModel.connect(to:)` previously used `try?` on `keyStore.load(for:)`.
  A biometric cancel or device-locked Keychain read returned nil → the
  app dropped the user into fresh onboarding, destroying the existing
  server's host/user/port. Now we catch the throw, log via os.Logger,
  set `lastError`, and stay on `.serverList`. The user sees a banner +
  Dismiss button instead of being kicked back to onboarding.
- `RootModel.load()` now logs the corrupted-blob path via os.Logger and
  sets `lastError` before falling through to onboarding (recovery is
  necessary, but the user gets context now).

A.4 — Surface delete failures in `forget()` and `disconnect()`:
- Both used `try?` on every store delete. On partial failure the
  in-memory dict was wiped while orphan Keychain entries lingered.
  Now each delete is `do/catch` with logging, failures collected into
  `lastError`. The in-memory state is reloaded from disk so it tracks
  what's actually persisted (covers the partial-failure case).

ServerListView gains an inline error banner above the list that reads
`model.lastError`, with a Dismiss button calling `clearLastError()`.

Verified: iOS build succeeds.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-25 07:50:52 +02:00
Alan Wizemann 48e99f2c43 fix(ios-chat): surface project context block write failures
ChatController.resetAndStartInProject swallowed the SFTP write of the
Scarf-managed AGENTS.md block via `try?` inside `Task.detached`. On
failure (permission denied, SFTP error, malformed path) the user saw no
feedback while the UI continued claiming the session was project-scoped
— but the agent never received the project context, leading to silently
degraded chat quality.

Replace the `try? + fire-and-forget` with a `Result`-returning detached
task. On `.failure`, log the underlying error via `os.Logger` and route
it to the existing ACP error banner (`acpError` / `acpErrorHint` /
`acpErrorDetails`) with a friendly "Project context not written — agent
will proceed without it" payload. Session still starts; only the
context-augmentation step is reported as missing.

The session-attribution write at the same flow stays fire-and-forget by
design — `SessionAttributionService.persist` already logs failures
internally, and a missed attribution is purely cosmetic (Dashboard
project-badge cosmetics, not chat function). Replaced the comment to
make that intent explicit so future readers don't accidentally "fix"
it by promoting attribution failures to the chat banner.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-25 07:47:28 +02:00
Alan Wizemann 293e8341f5 test(scarfcore): fix cross-suite races + overlay-aware catalog tests
Pre-release verification surfaced 9 failures in `swift test` driven by two
issues — both fixed without changing production behaviour.

1. M3TransportTests + M5FeatureVMTests both held `.serialized` internally
   but ran in parallel with each other, racing on
   `ServerContext.sshTransportFactory` (a `nonisolated(unsafe)` static).
   Tried `@TaskLocal` first; reverted because production hot paths
   dispatch through `Task.detached` which severs TaskLocal inheritance.
   Final fix: move M3's three factory-injection tests + two
   HermesLogService tests + the `ScriptedTransport` test double into
   M5FeatureVMTests, the canonical factory-touching suite. M3 keeps its
   `.serialized` suite trait for the remaining (non-factory) tests, but
   the cross-suite race is gone because there's now exactly one suite
   that mutates the static.

2. `loadProviders()` returns the 6 hardcoded Hermes overlays (Nous Portal,
   Codex, Qwen, Gemini CLI, Copilot ACP, Arcee) on top of any models.dev
   catalog hits — added in v2.3 so the picker doesn't go dark when the
   cache is missing. `modelCatalogHandlesMissingAndMalformedFiles`
   asserted `.isEmpty`, which had been correct before that change.
   `modelCatalogLoadsSyntheticJSON` asserted `count == 2`, which was the
   catalog-only count. Both updated: the missing/malformed test now
   asserts the result is non-empty + every entry is `isOverlay`; the
   synthetic-JSON test filters `!isOverlay` before counting.

Verified: 163 tests across 12 suites pass on three consecutive runs.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-25 07:44:42 +02:00
Alan Wizemann 54a0797334 M9 #4.6 (pass-2): Dashboard Overview/Sessions split + chat project bar
Pass-2 feedback bundled into one architectural commit:

1. **Project indicator moved out of the nav-bar principal slot.** The
   iPhone nav bar's .principal area gets squeezed to icon-only when
   adjacent toolbar buttons exist — the result was a folder icon with
   no project-name text, which is worse than no indicator at all. New
   `projectContextBar` renders a full-width tinted strip BELOW the
   nav bar when a session is project-attributed: "Project chat"
   caption + folder icon + full project name. Scrolls away with the
   message list. Pattern cribbed from Slack's channel-topic header
   and Apple Mail's sender strip.

2. **Dashboard split into Overview + Sessions sub-tabs.** Segmented
   picker at the top. Overview = stats + 5 most-recent sessions for
   at-a-glance; Sessions = the deeper 25-session list with a project
   filter. `See all` button on Overview's Recent Sessions header
   switches tabs. Addresses pass-2 complaint: "The dashboard might
   need tabs to break it down better."

3. **Project filter on the Sessions sub-tab.** Menu picker (scales
   to N projects; segmented doesn't). "All projects" clears; each
   project entry filters to sessions attributed there. Uses the same
   attribution map loaded once in `IOSDashboardViewModel.load()`, so
   filtering is an O(n) in-memory pass over 25 sessions — no extra
   SFTP traffic. Addresses pass-2 complaint: "we should add a filter
   to the sessions selector in the dash to see by project."

4. **`IOSDashboardViewModel` exposes the wider surface:**
   - `allSessions` (25-session window, feeds the Sessions tab)
   - `allProjects` (project registry, drives the filter menu)
   - `sessions(filteredBy: String?)` helper — accepts a project name
     (nil = all), returns filtered subset.

Mac parity note from the earlier commit message still stands — Mac's
global Sessions list doesn't currently filter by project either.
That's a parallel post-TestFlight followup.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 15:30:11 +02:00
Alan Wizemann 9a4473333b M7 #17 (pass-2): empty-transcript UX + defensive project chip
Pass-2 observations:
1. Resumed sessions from Dashboard loaded into chat but showed no
   message history.
2. On sessions WITH a project badge, the chat nav-bar chip rendered
   the folder icon but no project name.

**Root cause for (1)** — not actually an iOS bug. ACP-native sessions
(the kind ScarfGo starts) don't persist their transcript to the
client-visible `state.db` — only CLI/terminal sessions leave
history there. Confirmed by direct SQLite inspection: the session
IDs in Dashboard's Recent Sessions show `message_count = 0`; the
sessions with lots of messages are all older CLI sessions. The Mac
has this same limitation — just less visible because Mac's Sessions
list surfaces CLI sessions preferentially.

What we fix on the UX side: a friendlier empty state when a resumed
session has no persisted transcript. Replaces the blank canvas with
an icon + "Session resumed" + explanatory caption ("Hermes has the
context for this session, but the transcript isn't cached locally.
Send a message to continue.") Nudges the user toward the right
mental model instead of leaving them wondering why their history
vanished. Gated on `sessionId != nil` so fresh-chat empty state
stays the same.

**Root cause for (2)** — `ProjectEntry.name` shouldn't be empty, but
a defensive treatment avoids ever surfacing a folder-only chip on
edge cases (registry race, partial JSON decode). startResuming now:
- Clears `currentProjectName` eagerly at the start of the resume
  flow so a lingering name from a prior session doesn't flash onto
  the new header.
- Treats empty strings as nil when the lookup returns one.
And the toolbar renderer adds a `!projectName.isEmpty` guard so an
unexpected empty string never produces an icon-only chip.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 14:52:42 +02:00
Alan Wizemann d2633fb92d M7 #16 (pass-2): don't bubble CancellationError into the chat banner
Pass-2 observed a spurious
"The operation couldn't be completed. (Swift.CancellationError error 1)"
banner appearing even after the resumed session loaded cleanly.

Root cause: when ChatController.startResuming tears down a prior live
session via `await stop()`, the in-flight event-task awaits throw
CancellationError as they unwind — that's how Swift concurrency
cooperatively cancels. That error then propagated through
recordACPFailure to the visible banner, even though nothing actually
failed.

Filter CancellationError (and the URL-loading equivalent,
NSURLErrorCancelled) out at the recordACPFailure boundary. Real
errors still flow through to the banner with hints + stderr details.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 14:44:18 +02:00
Alan Wizemann 3b3c037fce M9 #4.5 (pass-2): project context surfaced in Chat nav + Dashboard rows
Pass-2 UX feedback: "When selecting a per-project chat, we should
update the chat interface to show that we are 'in a project' — and
label them in the sessions list so the user can see the session
and understand what project it belongs to."

Two related changes:

**In-chat indicator** — ChatController gains `currentProjectName`,
set by `resetAndStartInProject` (direct: we have the ProjectEntry)
and by `startResuming` (resolved via SessionAttributionService +
project registry lookup). ChatView's toolbar uses a `.principal`
ToolbarItem with a VStack: "Chat" title on top, `Label(name, systemImage: "folder.fill")`
subtitle underneath when attributed. Mirrors Mac's SessionInfoBar
project-chip pattern but fits the iOS nav-bar real estate instead
of eating a full-width horizontal row.

**Dashboard row labels** — `IOSDashboardViewModel.load()` now does
one additional SFTP read per refresh: pulls the session→project
sidecar + project registry, maps session id → project display name
into `sessionProjectNames`. Row renders a small tinted folder
capsule when attributed. Batched so row renders are O(1) dict
lookups — no extra SFTP traffic per cell. Silent on failure
(attribution is cosmetic).

Not in scope for this commit: Mac's global Sessions list doesn't
currently show project attribution either — that gap exists on
both platforms, but wiring Mac's ProjectsSidebar + SessionsView
for per-row labels is a bigger surgery. Scoped as a post-TestFlight
followup.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 14:38:02 +02:00
Alan Wizemann 1c2939dbbe M7 #15 (pass-2): load transcript from state.db on session resume
Pass-2 observation: "When selecting a previous session from the
dashboard, the chat opens, loads, but starts fresh — we should
load the session with previous work like we do on the mac..."

The Mac's resume path does two things: (a) call session/resume on
ACPClient to re-bind Hermes to the session id, and (b) call
`richChatViewModel.loadSessionHistory(sessionId:acpSessionId:)` to
pull the persisted transcript out of state.db and populate the
message list. ScarfGo only did (a) — the ACP channel was wired up
correctly, but there was no SQLite read, so the UI showed an empty
bubble list until the user sent their first new prompt.

Added the loadSessionHistory call right after setSessionId in
ChatController.startResuming. It internally calls `dataService.refresh()`
first so the snapshot reflects whatever Hermes wrote between the
Dashboard's last SQLite pull and the resume tap. The acpSessionId
param is nil when resume preserved the id (no origin-vs-ACP split
needed) and set to the resolved id otherwise so the CLI + ACP
message streams can be merged chronologically — same behaviour the
Mac gets.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 14:33:18 +02:00
Alan Wizemann f3c4bc56e9 M7 #14 (pass-2): keep ACP session alive across tab switches
Pass-2 observation: "when a user switches away from chat and comes
back, there is a loading time — should we keep it open so there
isn't a reload needed?"

Removed the .onDisappear { controller.stop() } hook. TabView unmounts
tab content on switch (disappear fires), but @State keeps the
ChatController alive — so dropping the SSH exec channel + re-
opening on next appear was costing a ~1-2s reconnect every time
the user bounced Dashboard → Chat → Memory → Chat.

Cleanup still happens correctly because ChatController's lifetime
is tied to ChatView's parent (ScarfGoTabRoot). When the user
Disconnects/Forgets from the More tab, RootModel flips out of
.connected, the whole tab root unmounts, and the controller + its
ACPClient tear down via .deinit. Background termination is handled
by iOS naturally.

A comment in the file documents why we no longer tear down on
.onDisappear — easy to re-add if a future iPad / multi-window
variant wants explicit idle-pause behaviour.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 14:31:20 +02:00
Alan Wizemann 723ef6743d M7 #13 (pass-2): suppress empty assistant bubble during reasoning-only frames
Pass-2 turned up a ghost-message UX bug we missed in pass-1: every
"Thinking…" reasoning disclosure had an empty gray bubble next to
it. Happens because assistant messages exist momentarily in a
reasoning-only state (chunks of thinking text arrive before any
primary content), and the bubble path always rendered its padded
background regardless of content.

Gate the bubble render on non-empty content for assistant messages.
User bubbles still always render (the user explicitly submitted
content and saw it land — suppressing it on trim-empty would be
surprising). `trimmingCharacters(in: .whitespacesAndNewlines)` so
purely-whitespace assistant frames also don't render a bubble.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 14:29:39 +02:00
Alan Wizemann 444d43dea8 M9 #4.4: APNs client skeleton (capability disabled, plumbing ready)
Ships the iOS-side scaffolding so a future Hermes push sender can
light ScarfGo up with no client-side surgery. Keeps the Push
Notifications capability in the Xcode target OFF until:

1. Apple Developer Program enrollment + APNs auth key are set up
   (out of scope until TestFlight).
2. Hermes gains a `hermes register-device` endpoint + per-event
   sender (new cron job result, new pending permission). Upstream
   work, hasn't been specced.

What's now in the tree, ready to flip on:

- `Notifications/APNSTokenStore.swift` — actor-backed singleton that
  captures the device-token hex string from a successful remote
  registration. Logs for now (no server to POST to yet); has a TODO
  marker at the spot where the real HTTPS POST will land.
- `Notifications/NotificationRouter.swift` — UNUserNotificationCenter
  delegate that handles:
  - foreground presentation (always show banner + sound);
  - default tap → route to Chat tab with resume sessionID if
    included in the payload (via the existing ScarfGoCoordinator);
  - `APPROVE_PERMISSION` / `DENY_PERMISSION` action buttons on
    notifications in the `SCARF_PENDING_PERMISSION` category, with
    Face ID / passcode required (`.authenticationRequired`). Action
    handlers log today; the real one-shot ACPClient respond-and-die
    flow is scoped out until the sender pipe exists.
  - Local-notification plumbing: `registerCategories()` +
    `setUpOnLaunch()` (requests .alert/.sound/.badge permission).
  - `registerForRemoteNotifications` deliberately commented out.
    Turning it on without the capability surfaces as runtime
    "no valid aps-environment entitlement string found" — waiting
    keeps logs clean.

Wired at ScarfIOSApp launch via a `.task` on RootView — harmless on
denial, authorization dialog only shows once. ScarfGoTabRoot sets
the router's `coordinator` weak ref on appear so notification-taps
can cross-tab route. When the capability ships, the remaining work
is one call (`UIApplication.shared.registerForRemoteNotifications()`)
inside `setUpOnLaunch`'s `granted` branch + the AppDelegate hooks for
token delivery + a sign-in style payload build in APNSTokenStore.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 14:12:56 +02:00
Alan Wizemann 9bfaaf20f0 M9 #4.3: scoped Settings editor via hermes config set
Pass-1 feedback: "Settings loads, but no fields are editable." By-
design read-only in M6, but the on-the-go story is weaker without
at least the core model / approval-mode / display toggles editable.

Not a generic YAML round-trip editor — that was ruled out in the
original iOS plan because comment/order preservation requires
Hermes-side changes or a significant YAML library. Instead:

- Curated v1 list of 7 editable keys: model.default, model.provider,
  approvals.mode, agent.max_turns, display.show_cost / show_reasoning
  / streaming. Covers ~80% of actual "I want to change this right
  now while I'm away from my Mac" scenarios.
- IOSSettingsViewModel.saveValue(key:value:) shells out to
  `hermes config set <key> <value>` over the SSH transport's
  runProcess, reusing the same PATH-prefix trick we added in pass-1
  for hermes acp so the remote shell finds hermes even in non-
  interactive mode. Hermes owns the YAML round-trip; Scarf just
  picks the value.
- SettingEditorSheet renders the right control per key: Toggle
  (booleans), segmented Picker (approval mode), Stepper (max_turns),
  TextField (model / provider / timezone). One sheet, four kinds
  of input, driven by a `SettingSpec.Kind` enum.
- SettingsView gets a "Quick edits" section at the top that lists
  the 7 keys with their current parsed values + an edit affordance.
  The existing 10+ read-only sections stay unchanged — editing stays
  scoped to the keys we curated.
- On save, the VM calls `load()` again so the parsed config (and
  therefore the Quick-edits labels + the read-only sections below)
  reflects the new value immediately.
- Errors from `hermes config set` (non-zero exit) surface inline on
  the sheet via SettingsSaveError.commandFailed.errorDescription,
  carrying stderr/stdout combined so the user sees what the remote
  complained about. Sheet stays open on error for retry.

ScarfGo builds green. Mac Settings is unaffected — this feature is
iOS-only (Mac has its own richer editors via HermesFileService).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 14:10:30 +02:00
Alan Wizemann 226b6e26be M9 #4.2: project-scoped chat + shared SFTP parity services
ScarfGo now supports the Mac app's project-chat flow end-to-end.
Tapping + in Chat opens a sheet with two options:

1. Quick chat — cwd = $HOME (previous default).
2. In project… — pick from the remote Hermes's project registry,
   spawn hermes acp with cwd = project.path, record the attribution.

Shared infrastructure for the SFTP parity (so Mac + ScarfGo use the
exact same record types + persistence logic):

- SessionProjectMap — moved from scarf/scarf/Core/Models/ to
  ScarfCore. Public struct. Mac consumer unchanged (imports it via
  ScarfCore now).
- SessionAttributionService — moved from Mac target to ScarfCore.
  Was already transport-backed, so the port is straight lift-and-
  shift: made public, added #if canImport(os) guards around the
  Logger imports for Linux CI. Mac ChatViewModel and ProjectSessions
  VM still call it the same way.
- ProjectContextBlock — new ScarfCore-level primitive that owns the
  marker-splice logic for the Scarf-managed region of AGENTS.md:
  - applyBlock(_:to:) — pure text splice with 3-case handling.
  - writeBlock(_:forProjectAt:context:) — transport-backed write.
  - renderMinimalBlock(projectName:projectPath:) — iOS-side block
    composer (no template-manifest or cron-attribution fields — iOS
    doesn't yet surface those concepts; markers + identity headers
    match Mac output byte-for-byte so a project scaffolded on iOS
    round-trips cleanly through the Mac).

Mac's ProjectAgentContextService stays in place — still the richer
block renderer (template manifest + cron jobs) — but it now forwards
beginMarker/endMarker/applyBlock to ProjectContextBlock so both
platforms share invariant strings and splice logic. Duplicate
implementations were a recipe for drift.

ScarfGo side:
- Chat/ProjectPickerSheet.swift — two-section sheet (Quick chat /
  In project…). Loads the project list over SFTP via
  ProjectDashboardService (already transport-backed, works on iOS).
  Archived projects hidden (matching Mac sidebar behaviour).
- ChatController.resetAndStartInProject(_:) — stops the current
  session, writes the minimal context block to <project>/AGENTS.md
  over SFTP, spawns hermes acp with cwd = project.path, records the
  attribution via SessionAttributionService. Non-fatal on block-
  write failure (chat still starts).
- ChatController.startInternal(...) — refactored to take an optional
  projectPath + projectName, so the regular start() and the new
  project path share one ACP setup path. Attribution write happens
  after newSession returns and the sessionId is known.

Project chip in the chat nav bar is deferred — on-the-go users know
they just picked a project in the sheet, the chip is polish we can
add post-TestFlight. Both schemes build green.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 14:07:40 +02:00
Alan Wizemann ff6ea4f6dc M9 #4.1: session resume — Dashboard row tap opens Chat in resume mode
Pass-1 showed Dashboard's Recent Sessions list as a read-only
marquee — tapping a row did nothing. The natural user expectation
is "take me back to that conversation." Users were opening a new
chat every time, defeating the point of having a phone client for
an already-running agent.

Added a tiny cross-tab coordinator (ScarfGoCoordinator) modeled on
the Mac app's AppCoordinator pattern:

- `@Observable` carrier, injected via `.environment` at ScarfGoTabRoot.
- `selectedTab` drives TabView selection (bound with `.tag` on each
  tab).
- `pendingResumeSessionID` is set by Dashboard row taps; consumed
  by ChatView in `.task` / `.onChange` and cleared immediately so
  later neutral tab switches don't accidentally re-resume.

ChatController gets a new `startResuming(sessionID:)` entry point
that mirrors `start()` but calls `session/resume` (falling back to
`session/load` if the remote Hermes is < 0.9.x). The rest of the
session lifecycle is identical so the event stream + error banner +
PATH wrap all stay in force.

Dashboard Recent Sessions rows now wrap in Button with `.buttonStyle(.plain)`
and fire `coordinator?.resumeSession(session.id)` on tap.

First usable on-the-go workflow: tap app icon → pick server → tap
Dashboard → see recent sessions → tap one → land directly back in
that conversation, full transcript loaded. No new-chat ceremony.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 14:00:40 +02:00
Alan Wizemann 9c2e9279cc M9 #3: flush UserHomeCache on soft disconnect
Full ConnectedServerRegistry was scoped out of this phase — SwiftUI
view lifecycle already tears down transports via .onDisappear when
ScarfGoTabRoot unmounts on state transition to .serverList. Adding
a formal registry that tracks every active transport per ServerID
is complexity without proven UX payoff right now (can revisit post
pass-2 if users hit stale-connection bugs).

One real cleanup we should always do on soft disconnect: invalidate
the shared UserHomeCache entry for the server we're leaving. The
cache lives forever otherwise, and a hypothetical scenario where
the remote user's home directory changes between sessions would
surface as SFTP paths resolving to the wrong directory. Rare, but
free to fix.

`RootModel.softDisconnect()` now calls the new static
`ServerContext.invalidateCachedHome(forServerID:)` before flipping
state to `.serverList`. Static form is a convenience for callers
that have the ServerID in hand but not a full ServerContext (avoids
forcing a round-trip through config store just to rebuild the
context we're already discarding).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 13:56:57 +02:00
Alan Wizemann bb399e6d35 M9 #2+#4: ServerListView root + ServerID-aware onboarding
ScarfGo now boots into a list of configured servers instead of the
single-server Dashboard. Each row renders nickname + user@host:port,
taps to connect, swipes to forget. A "+" toolbar button re-enters
onboarding for a new server. Fresh install → straight to onboarding.

RootModel state machine redesigned around the multi-server world:

- `.loading` → `.serverList` when listAll() returns 1+ servers.
- `.loading` → `.onboarding(forNewServer:)` on fresh install.
- `.serverList` → `.onboarding(newID)` via "+" button.
- `.serverList` → `.connected(id, config, key)` via row tap.
- `.connected(id)` → `.serverList` via soft Disconnect (keeps creds).
- `.connected(id)` → `.serverList|.onboarding` via Forget (wipes id).
- `.onboarding` → `.connected(newID, …)` on completion.

Published `servers: [ServerID: IOSServerConfig]` on the RootModel so
ServerListView renders reactively without re-querying stores on
every re-render. `refreshServers()` is the `.task` hook; `forget()`
wipes a single id + refreshes.

OnboardingViewModel gains an optional `targetServerID` so its final
save lands in `keyStore.save(_:for:)` / `configStore.save(_🆔)`
instead of the singleton shims. Nil falls back to the old singleton
path for any remaining callers (tests, previews).

OnboardingRootView accepts `targetServerID` + a new `onCancel`
closure. The toolbar now shows Cancel so users can back out without
leaving half-written credentials; Cancel hides on the final
.connected step so you can't race-cancel a just-saved server.

ScarfGoTabRoot takes the server's ServerID as the context id so the
CitadelServerTransport pool caches per-server (two active servers →
two connection holders, no SSH channel contention). Splits the v1
onDisconnect into two callbacks:
- onSoftDisconnect: close transport, return to server list, keep creds.
- onForget: wipe this server's creds + return to server list (or
  onboarding if empty).

MoreTab renders both Disconnect and Forget rows in distinct sections
with explicit footers.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 13:55:31 +02:00
Alan Wizemann aafd9643a4 M9 #1: multi-server storage (UserDefaults + Keychain) with migration
Pass-1 revealed that iOS should hold more than one server (users
want to hop between a home server and a work server from a single
app). Storage was the first block: v1 stored exactly one config
under a fixed key and one Keychain item under account "primary".

Extend both stores with ID-keyed methods while keeping the v1
singleton API for back-compat during the transition:

- IOSServerConfigStore: add listAll, load(id:), save(_🆔),
  delete(id:). Singleton load/save/delete now operates on the
  "primary" entry (lowest UUID by string sort) — deterministic, no
  surprise mutation of other servers when a singleton caller saves.
- SSHKeyStore: same treatment. Keychain accounts for v2 entries are
  `"server-key:<UUID>"`.

Migration is one-shot and embedded in `listAll()` on both stores:

- UserDefaults: if the v1 key `com.scarf.ios.primary-server-config.v1`
  is present AND v2 key `com.scarf.ios.servers.v2` is empty, load
  the v1 config, insert under a fresh ServerID in v2, delete v1.
  Idempotent — no-op once v1 is gone.
- Keychain: if no `server-key:*` accounts exist AND the legacy
  `"primary"` account does, copy the bundle to a fresh ServerID
  slot and delete the legacy item.

Both migrations preserve the v1 single-server experience: a user
who updates the app without re-onboarding still sees exactly one
configured server on first launch of the new version, with the
same SSH key and the same host details. No data loss.

InMemory stores updated to match (dictionary-keyed internally).
Mac + iOS schemes both build clean; ScarfCore swift build green.
Callers (RootModel, OnboardingViewModel, ChatController,
ScarfIOSApp transport factory) still use the singleton API and
will migrate to ID-keyed in 3.2-3.5.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 13:49:26 +02:00
Alan Wizemann 92fba712f8 M8: custom detents for ScarfGo sheets (permission, cron editor)
.medium is neither/nor — too tall to peek, too short to commit to.
Research recommends custom detents calibrated per sheet.

- Permission sheet: `[.height(220), .large]`. 220pt shows the prompt
  + first ~3 options without forcing the user to drag; `large` is
  there for edge-case prompts with many options.
- Cron editor: `[.large]` only. Cron editing is a focused task with
  a ~6-field form; peek detent is a distraction.

`.presentationDragIndicator(.visible)` on both so users know they
can drag the sheet without having to try + fail first.

No other sheets in the app today. The Forget-server confirmation
uses confirmationDialog (system-owned — no detents needed).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 13:44:41 +02:00
Alan Wizemann 8282b1d604 M8: chat content density (code blocks, scroll anchor, context menus)
Bundles M8 items 2.4, 2.5, 2.6, 2.7 because they all touch ChatView
and together make the conversation readable on a phone:

2.4 — fenced code blocks (```…```) now render in a horizontally-
scrollable monospaced block inside the bubble. Collapsed to 240pt
max height with Expand/Collapse + a copy button; long shell
one-liners / JSON / stack traces stay one line each instead of
soft-wrapping into unreadable 4-line columns. New
`ChatContentFormatter.segments(for:)` splits the message body into
alternating `.text` (routed through AttributedString markdown) and
`.code` (routed to the new CodeBlockView). Deliberately simple
parser — handles the common fence shape, leaves inline backticks
to AttributedString, and falls back to plain text on unterminated
fences so nothing is ever silently swallowed.

2.5 — tool-call cards were already collapsed-by-default via a chevron
toggle. No structural change needed for M8; leaving the existing
ToolCallCard in place.

2.6 — replace the manual `onChange → proxy.scrollTo("bottom")`
pattern with iOS 17+ `.defaultScrollAnchor(.bottom)` plus iOS 18's
`.defaultScrollAnchor(.bottom, for: .sizeChanges)`. Native scroll-
pin fights the user's own scroll-up gesture less (the manual pattern
yanked you back to the bottom if a chunk arrived mid-read).
"New messages" pill for upward scroll-break deferred — needs a bit
of ScrollPosition state we don't plumb yet.

2.7 — `.contextMenu` on every message bubble with Copy + Share
(via ShareLink). User + assistant bubbles both. Code blocks get
their own copy button in the header. Regenerate intentionally
omitted — ACP has no native re-prompt primitive and implementing
one would be non-trivial session-state surgery.

Both schemes build.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 13:43:20 +02:00
Alan Wizemann 5f9343be5d M8: list density tokens (scarfGoCompactListRow + scarfGoListDensity)
Apple's default List styling targets Reading/Notes-style apps:
~60pt rows, 10pt inter-row spacing, big vertical padding on
grouped cells. ScarfGo's lists (Memory, Cron, Skills, More,
Dashboard recent sessions) lean information-dense — devs want to
see 4-6 items per screen, not 2.

Two tokens in Scarf iOS/App/Theme/ListDensity.swift:

- `.scarfGoCompactListRow()` — 6pt vertical listRowInsets (down
  from default ~12pt), explicit `.frame(minHeight: 44)` to preserve
  the Apple HIG tap target, and `.contentShape(Rectangle())` so
  rows can shrink below 44pt visually while keeping the full-row
  hit area. ~48pt rows end up net, vs. ~60pt default.
- `.scarfGoListDensity()` — `.listRowSpacing(0)` kills inter-row
  gaps on the whole List, `.defaultMinListRowHeight(36)` sets the
  floor for rows that want to go smaller (e.g. `LabeledContent`).

Applied to Memory, Cron, Skills, Dashboard, MoreTab. No visual
change to Chat (it's not a List — different density patterns for
M8 items 2.4–2.7). Research-backed: Fantastical / GitHub Mobile /
Mona for Mastodon use similar spacing.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 13:40:10 +02:00
Alan Wizemann 5cac3836cf M8: TabView root navigation (Chat / Dashboard / Memory / More)
Pass-1 loudest UX complaint — "I don't see any navigation" — was
rooted in the Dashboard-as-hub pattern. Chat/Memory/Cron/Skills/
Settings lived as a NavigationLink section halfway down a scrolling
List, below the stats + recent sessions. Users had to scroll to
find any feature. That was the right shape for a very-early MVP
but the wrong shape for a companion app whose primary tab should
be Chat.

New `ScarfGoTabRoot` renders a 4-tab TabView at the scene root:

- **Chat** — primary tab. Tapping the app opens straight into it.
- **Dashboard** — stats + recent sessions (stripped of Surfaces /
  Connected-to / Disconnect, which now live in More).
- **Memory** — MEMORY.md + USER.md + SOUL.md, unchanged.
- **More** — bucket for Cron / Skills / Settings plus the
  destructive Forget-this-server action. Also shows the host /
  user / port info as a read-only section.

Uses iOS 18's `.tabViewStyle(.sidebarAdaptable)` so the same tree
degrades to a bottom tab bar on iPhone and renders as a native
sidebar on iPadOS / macCatalyst if we add those targets later — no
UI code change required. Matches the M8 density research's sidebar
recommendation.

Each tab owns its own NavigationStack so push navigation (Cron
editor, Memory detail, chat session list) stays scoped to that tab
and doesn't bleed across.

DashboardView is now simpler: just stats + recent sessions. The
Forget confirmation + Disconnect button moved wholesale to
MoreTab inside ScarfGoTabRoot.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 13:38:03 +02:00
Alan Wizemann cecc1060c6 M8: clamp Dynamic Type at ScarfGo scene root
ScarfGo is a developer tool that benefits from tighter defaults
than Apple's spacious baseline, but shouldn't lock out users who
need accessibility sizes. `.dynamicTypeSize(.xSmall ... .accessibility2)`
at the WindowGroup gives both: compact-first layout, still scalable
to ~XL accessibility for low-vision users.

Going past .accessibility2 collapses multi-column rows and forces
text truncation in ScarfGo's dense list layouts — not a win for
anyone. Matches Use-Your-Loaf's "Restricting Dynamic Type Sizes"
guidance from the M8 density research.

One-line change ahead of the TabView migration (2.2) so every
subsequent UX-density decision factors in the clamped range.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 13:35:51 +02:00
Alan Wizemann e1f862e2f9 M7 #5 cross-platform: validate model ID against provider catalog
Pass-1 demonstrated the bug end-to-end: user saved provider nous
+ model claude-haiku-4-5-20251001 (an Anthropic name Nous Portal
doesn't serve). Scarf accepted the save, wrote config.yaml, and
Hermes surfaced the failure six hours later as HTTP 404. Catch at
save time.

New ModelCatalogService.validateModel(_:for:) returns one of:

- .valid — model is in the provider's catalog, or the provider is
  overlay-only (Nous Portal / OpenAI Codex / Qwen OAuth etc. — those
  don't mirror to models.dev, so any non-empty string is
  provisionally accepted; runtime errors still surface via the chat
  error banner from M7 #2).
- .unknownProvider(providerID:) — no catalog entry at all; save
  with an advisory. Usually means offline / missing local cache.
- .invalid(providerName:suggestions:) — block the save, offer up to
  5 close-by models as "did you mean…". Prefix-match on first 3
  chars; falls through to newest-5 when no prefix hits.

Mac ModelPickerSheet.submitSelection now routes through the
validator before onSelect. On .invalid it raises a .alert(item:)
with the suggestion list; user picks "Pick from catalog" (drops
out of custom mode) or "Edit" (keep the typed value to fix).

5 unit tests cover the happy path, unknown-provider branch, overlay-
only bypass, invalid-with-suggestions (using the exact pass-1 pair),
and empty input.

ScarfGo's scoped-settings editor (Phase 4.3) will reuse the same
validator when it lands.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 13:34:37 +02:00
Alan Wizemann 42c0f683bd M7 #11: human-readable cron schedules across Mac + ScarfGo
Pass-1 rightly called out that rendering "0 */6 * * *" and ISO 8601
timestamps directly to users is user-hostile — cron syntax is a
devops lingua franca, not a user-facing idiom, and the iOS list
is where the problem is most visible.

New `CronScheduleFormatter` in ScarfCore pattern-matches common
cron shapes into English phrases:

- Named macros (@hourly, @daily, @weekly, @monthly, @yearly).
- Every N minutes (`*/5 * * * *` → "Every 5 minutes").
- Every hour on minute M (`30 * * * *` → "Every hour at :30").
- Every N hours at M (`0 */6 * * *` → "Every 6 hours").
- Daily at H:MM (`0 9 * * *` → "Daily at 9 AM").
- Weekdays / weekends / single-weekday at H:MM.
- Monthly on day D at H:MM.
- User-set `display` label (non-cron string) wins — preserves any
  descriptive name the user typed via `hermes cron set-display`.
- Anything unrecognised falls back to the raw expression so no
  info is ever hidden. 17-test pattern table covers every branch.

Sibling `formatNextRun(iso:)` parses Hermes's ISO-8601 `next_run_at`
field (handling both with-fractional-seconds and without) and
renders `"in 4 hours"` / `"tomorrow at 9 AM"` via Foundation's
`.relative(presentation: .numeric)`. Falls back to the raw string
if parsing fails so we never blank out useful info.

Applied to:
- ScarfGo `CronListView.CronRow` — human schedule + relative next-run.
- Mac `CronView` — row subtitle + detail-panel "Schedule" label +
  "Next run" / "Last run" Labels.

Both schemes build green. 17/17 new formatter tests pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 13:29:59 +02:00
Alan Wizemann f2f6c4e50b M7 #9+#10: Memory editor keyboard + Saved pill above keyboard
Pass-1 complaints:
- Typing near the bottom of MEMORY.md → keyboard covered the cursor,
  user lost track of where they were editing (M7 #9).
- Tapping Save → "Saved" pill was never visible because it sat at
  .bottom with a fixed 16pt padding, behind the still-raised keyboard
  (M7 #10).

Fixes:
- `.scrollDismissesKeyboard(.interactively)` on the TextEditor so
  scrolling the editor drags the keyboard down smoothly.
- Move the error banner + Saved pill into `.safeAreaInset(edge: .bottom)`
  so SwiftUI draws them above whatever is presenting the keyboard.
  The pill is now a full-width material strip (easier to hit/notice)
  instead of a floating capsule.
- Saved pill holds for 2.5s (up from 1.5s — the old timer was too
  tight to read mid-thought).
- Any in-flight hide task is cancelled when a new save lands, so
  rapid-fire saves don't produce stacked fade timers.

No Mac equivalent needed — Mac memory editor is a separate
MemoryView with different layout and a non-mobile keyboard concern.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 13:24:23 +02:00
Alan Wizemann c802e1189f M7 #7+#8: ServerContext.readTextThrowing + Memory surfaces real errors
Pass-1 found that SFTP failures (initially the tilde-expansion bug,
but the same pattern applies to any transport error) silently
returned nil from `ServerContext.readText`, which the Memory editor
interpreted as "empty file." The user stared at a blank TextEditor
with no clue the connection had failed.

Two-part fix:

1. Add `readTextThrowing(_:)` on ServerContext that separates three
   outcomes:
   - `.some(content)` — file read succeeded.
   - `.none` — file is genuinely absent (fileExists probe returned
     false).
   - throws — transport error (SSH down, SFTP timeout, auth failure,
     non-UTF-8 data).
   The existing nil-returning `readText(_:)` stays around for callers
   that genuinely can't distinguish ("probably there, probably not")
   — now implemented as a `try?` on the throwing variant so behavior
   doesn't drift.

2. IOSMemoryViewModel.load uses the throwing variant. `.success(nil)`
   is still treated as "first-time empty" (no lastError). `.failure`
   populates `lastError` with a human message citing the underlying
   transport error's localizedDescription so the Memory editor can
   render it inline (it already had the error-banner view; just
   needed the VM to actually set the string).

Also fixes a pre-existing stale test reference in M0dViewModelsTests
(`vm.entries` → `vm.toolMessages`) — ActivityViewModel's property
name drifted during the earlier rebase; the test was left broken.
Unrelated cron-delete test failure noted for separate follow-up.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 13:23:13 +02:00
Alan Wizemann 96f60a176d M7 #2: non-retryable ACP errors surface as chat error banner
Pass-1 hit HTTP 404 from Nous Portal (misconfigured model), the
agent reported it via ACP stderr + stopReason="error", and ScarfGo
showed nothing — users saw only the perpetual working spinner. Mac
had an errorBanner for this pattern; ScarfGo didn't.

Promotes the error-banner state and helpers from Mac's ChatViewModel
(Mac target) into RichChatViewModel (ScarfCore) so both apps share:

- `acpError`, `acpErrorHint`, `acpErrorDetails` — the banner triplet.
- `clearACPErrorState()` — called on reset() and addUserMessage()
  so stale errors don't linger across prompts.
- `recordACPFailure(_:client:)` — populate triplet from a thrown
  error + stderr tail, using the existing `ACPErrorHint.classify`.
- `recordPromptStopFailure(stopReason:client:)` — populate triplet
  from a non-retryable ACP `promptComplete` stopReason. Provides a
  fallback hint per stopReason when classify doesn't match.
- `acpStderrProvider: () async -> String` — closure the controller
  sets once so `handlePromptComplete` (called from the event stream)
  can pull recent stderr without the VM holding a direct ACPClient
  reference.

Mac ChatViewModel's local triplet becomes forwarding properties to
richChatViewModel.* — call sites (~15 in ChatViewModel) stay
unchanged. `recordACPFailure` + `clearACPErrorState` become one-line
forwarders.

ScarfGo ChatView gains an `errorBanner` modeled on the Mac one:
- Orange triangle + hint + raw error
- Expand/collapse "Details" button showing stderr tail (monospaced,
  scrollable, max ~140pt tall)
- Copy-all button via `UIPasteboard.general.string` (Mac uses
  NSPasteboard; same structure otherwise)
- Rendered above the message list so it's always visible

ChatController wires `acpStderrProvider` to
`{ await client?.recentStderr ?? "" }` before the handshake and
calls `recordACPFailure` on ACP client start / newSession /
sendPrompt failure paths. `handlePromptComplete` already handles
the common provider-404 case via `recordPromptStopFailureUsingProvider`.

Both schemes build green.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 13:17:51 +02:00
Alan Wizemann 8e14e0e776 M7 #4: split isAgentWorking into isGenerating + isPostProcessing
Pass-1 showed the "Agent is working…" spinner persisting long after
the reply had landed in the message list — Hermes delays the ACP
`promptComplete` event while it does auxiliary post-work (title
generation, usage accounting). Spinner stuck ~minute+ on a 2-second
response.

Fix without touching the ACP state machine: derive two computed
properties from existing signals in RichChatViewModel:

- `isGenerating`: agent is working AND we don't yet have a finalized
  assistant reply on the message list. Drives the prominent spinner.
- `isPostProcessing`: agent is working AND the user CAN see the
  reply. Drives a subtle "Finishing up…" pill instead of the big
  spinner. When `promptComplete` finally arrives, `isAgentWorking`
  flips false and both derived props go quiet.

`isAgentWorking` remains the canonical ACP-level flag (kept public
for any consumer that really wants the raw value), just no longer
the signal for visible "spinner now" UX.

Applied to:
- ScarfGo ChatView.swift — primary spinner + post-processing pill.
- Mac RichChatView.swift — SessionInfoBar + RichChatMessageList now
  take `isGenerating` instead of `isAgentWorking`. Same UX win for
  the macOS app (pass-1 finding was cross-platform, just surfaced
  first on iOS).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 13:12:25 +02:00
Alan Wizemann 742605d359 M7 #3: Chat 'Connecting…' overlay during SSH exec handshake
ChatController already transitioned through a `.connecting` state
between tap-Chat and first-message-ready (ACP initialize + session/new
take ~0.5–1.5 s on a warm network), but there was no visible UI
— the screen stayed on the idle layout with a disabled composer.
Users interpreted the silence as a frozen app (pass-1 M7 #3).

Adds a `.regularMaterial` overlay with a large ProgressView +
"Connecting to <nickname>…" text, rendered whenever
`controller.state == .connecting`. Disappears automatically when
state flips to `.ready` (normal path) or `.failed` (handoff to the
existing errorOverlay).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 13:08:35 +02:00
Alan Wizemann fee5e72d30 M7 #12: rename Disconnect -> Forget this server + confirmation
Pass-1 found that "Disconnect" was actually a factory reset —
wiping both Keychain SSH key and UserDefaults config, forcing
full re-onboarding (including re-generating a key and appending
it to authorized_keys on the remote).

Interim fix ahead of M9 multi-server work:
- Relabel button "Forget this server".
- Keep destructive role.
- Gate tap on a confirmationDialog so users see exactly what gets
  wiped and can back out.
- Add a footer explaining the authorized_keys consequence so the
  user isn't surprised by a failed reconnect later.

Behaviour is unchanged (still wipes both stores). M9 introduces
the proper split: soft Disconnect (closes live transport, keeps
credentials) vs. hard Forget (this behaviour).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 13:06:59 +02:00
Alan Wizemann f41ac1c84e M7: pass-1 quickfixes (PATH, SFTP tilde, SOUL.md, ScarfGo bundle id)
Four fixes surfaced during the 2026-04-24 pass-1 smoke test of the
iOS companion against a local Hermes host. All discovered while
collaboratively driving the Simulator + tailing os.Logger.

1. ACPClient+iOS.swift — ACP exec command prepends common install
   paths to PATH. SSH RFC 4254 exec uses a non-interactive shell
   whose PATH is sshd's default (`/usr/bin:/bin:/usr/sbin:/sbin`);
   `.zshrc` doesn't source, so `~/.local/bin/hermes` (pipx default)
   was invisible and the agent died with "command not found: hermes".
   Mirrors HermesPathSet.hermesBinaryCandidates (the Mac-side local
   probe list) inline in the exec command.

2. CitadelServerTransport.swift — SFTP tilde expansion. Every
   Memory/Cron/Skills/Settings read used paths like
   `~/.hermes/memories/MEMORY.md`. SFTP treats `~` as a literal
   character, not a home-dir alias — so every read silently returned
   nil and the UIs showed "empty file" instead of the real content.
   Added a per-connection cached `resolveHome()` + a `resolveSFTPPath`
   helper applied to every SFTP entry point (readFile / writeFile /
   fileExists / stat / listDirectory / createDirectory / removeFile).
   This was the single biggest blocker on pass-1.

3. IOSMemoryViewModel.swift + MemoryListView.swift — SOUL.md added
   as a third Memory row. SOUL.md lives in the Personalities feature
   on Mac; folding it into Memory on iOS matches the on-the-go scope
   (all agent prompt inputs in one place). Uses the existing
   `HermesPathSet.soulMD` path; no new plumbing.

4. project.pbxproj — bundle id rename for ScarfGo branding:
   - CFBundleDisplayName: "Scarf Mobile" -> "ScarfGo"
   - PRODUCT_BUNDLE_IDENTIFIER: com.scarf-mobile.app -> com.scarfgo.app
   Xcode target name stays "scarf mobile" internally (rename surgery
   isn't worth the PBX churn). Home-screen label + bundle id now
   match the product name.

Both schemes build green. Phase 1 starter commit — per-item M7
fixes follow in subsequent commits.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 13:05:29 +02:00
Alan Wizemann 19b4ba9995 Merge branch 'main' into scarf-mobile-development (v2.3.0)
Brings the iOS companion branch current with main's v2.2.0, v2.2.1,
and v2.3.0 landings — templates + configuration + catalog (v2.2),
projects folder hierarchy + per-project Sessions sidecar + AGENTS.md
context block + Tool Gateway + Nous Portal OAuth + hermes dashboard
webview (v2.3), and credential-pool OAuth expiry + Nous agent-key
rotation (post-v2.3).

Resolutions:
- ScarfCore Models (HermesConfig, ProjectDashboard, HermesPathSet) —
  forward-ported Tool Gateway's platformToolsets, project-registry v2
  folder/archived fields, and sessionProjectMap path into the moved
  ScarfCore copies. Deleted the old Mac-target paths.
- ScarfCore ModelCatalogService — merged main's overlay-only provider
  support (Nous Portal + OpenAI Codex + Qwen OAuth + …) so iOS and
  macOS pickers see the same provider list. Widened HermesProviderInfo
  / HermesProviderOverlay APIs to public.
- ScarfCore ProjectsViewModel — layered main's v2.3 registry verbs
  (moveProject / renameProject / archive / unarchive / folders) onto
  the M0d-extracted VM, keeping public surface for the Mac target.
- ScarfCore ConnectionStatusViewModel / RichChatViewModel — widened
  `private(set)` to `public private(set)` so Mac views can read
  status, lastSuccess, acp*Tokens, originSessionId, acpCommands,
  quickCommands.
- ScarfCore HermesConfig+YAML — added platform_toolsets parsing to
  the iOS YAML path so config.yaml round-trips the same as macOS.
- RichChatViewModel quick-commands — inlined the Mac-target's
  QuickCommandsViewModel.loadQuickCommands into ScarfCore using the
  existing HermesYAML parser, removing the cross-module dependency.
- HealthViewModel — took main's Tool Gateway + hermes-dashboard
  webview sections wholesale; file stays macOS-only.
- ChatView auto-merge — confirmed resume-session fix (5ae8db2) is
  present; made the PendingPermission.id extension public to satisfy
  Identifiable conformance across module boundary.
- ProjectSessionsViewModel — moved back to the Mac target since it
  depends on SessionAttributionService (also Mac-target). Defer the
  iOS SFTP parity of attribution to M7.
- LocalTransport.runProcess + SSHTransport.runLocal — wrapped the
  Process body in `#if !os(iOS)` with an explicit throw on iOS so
  ScarfCore compiles under the iOS SDK. iOS uses
  CitadelServerTransport (ScarfIOS) as the real implementation.
- CitadelServerTransport — updated `sftp.remove(atPath:)` to
  `sftp.remove(at:)` for the current Citadel API shape.

Cross-module imports: added `import ScarfCore` to 25 Mac-target files
that consumed ScarfCore types (13 v2.3 additions + 12 post-merge
errors caught by MemberImportVisibility: Settings tabs, SidebarView,
MCPServerEditorView, TemplateExportSheet, tests).

Version lockstep: bumped `scarf mobile` target to
MARKETING_VERSION=2.3.0, CURRENT_PROJECT_VERSION=25 to match main.

Builds green for both schemes:
- swift build (ScarfCore standalone)
- xcodebuild scarf -destination platform=macOS
- xcodebuild 'scarf mobile' -destination generic/platform=iOS

Deferred to M7 (iOS SFTP parity):
- NousSubscriptionService auth.json reader
- ProjectAgentContextService AGENTS.md write-before-chat
- SessionAttributionService session_project_map.json read/watch
All currently Mac-target-gated; iOS still builds without them.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 10:53:23 +02:00
Alan Wizemann 05e2a8444a feat(credential-pools): surface OAuth expiry + Nous agent-key rotation
auth.json entries now carry expires_at_ms / expires_at and (for
Nous) agent_key_obtained_at. Decode the new fields, add an
expiryBadge helper, and render a red "expired" / orange "expires
in Nd" pill when a credential is past or within 7 days of expiring.
Nous entries also get a muted "agent key · Nh ago" line so manual
rotations are visibly confirmed.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 03:41:19 +02:00
Alan Wizemann fe104b83fa feat(health): surface hermes dashboard web UI in Health
Hermes v0.10.x ships a local web dashboard launchable via `hermes
dashboard` on port 9119. Scarf now detects it via a 3s
`/api/status` probe and offers Launch / Stop / Open-in-Browser
controls on the Health tab. Local contexts only — the dashboard
binds 127.0.0.1 and remote tunneling is deferred.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 03:41:12 +02:00
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
Claude 4132cb03e2 rebase: add import ScarfCore to templates feature Mac files
The v2.2.0 templates/config/catalog feature (introduced on main after
M0 branched) added 18 Mac-target files that reference types now living
in ScarfCore — ServerContext, ProjectEntry, ProjectDashboardService,
etc. After rebasing scarf-mobile-development onto main, those files
need `import ScarfCore` the same way the M0a/M0c/M0d extractions
added it to the ~100 pre-existing Mac files.

Unblocks Xcode compile of the scarf (Mac) target on this branch; no
behavior change.

https://claude.ai/code/session_019yMRP6mwZWfzVrPTqevx2y
2026-04-23 17:17:06 +00:00
Claude 44d2d6d6c6 iOS port M6: YAML parser port, Settings view, Cron editing
Ports the Mac app's YAML parser into ScarfCore, unlocking iOS
Settings. Adds Cron editing (add / delete / toggle / edit). Settings
stays read-only this phase (writes need a round-trip-preserving YAML
writer — out of scope). App Store submission deferred to a later
task per the brief.

## ScarfCore — YAML infrastructure

Packages/ScarfCore/Sources/ScarfCore/Parsing/HermesYAML.swift:
  - ParsedYAML struct (values / lists / maps)
  - HermesYAML.parseNestedYAML(_:) — indent-based block parser
  - HermesYAML.stripYAMLQuotes(_:) — single-layer quote stripping

Lifted verbatim from HermesFileService.parseNestedYAML/stripYAMLQuotes
and hoisted into a standalone namespace. Scope unchanged: the subset
Hermes's config.yaml actually uses (block nesting, scalars, bullet
lists, nested maps). NOT full YAML-spec compliance.

Packages/ScarfCore/Sources/ScarfCore/Parsing/HermesConfig+YAML.swift:
  - HermesConfig.init(yaml:) — ports HermesFileService.parseConfig
    one-for-one. Every default, every key, every legacy fallback
    (platforms.slack.* vs slack.*, command_allowlist vs permanent_
    allowlist, etc.) matches the Mac implementation.
  - Forgiving: malformed YAML produces partial state + defaults
    rather than throwing. Callers surface the raw text so users can
    diagnose parse failures on their own.

## ScarfCore — Cron editing (write paths)

Packages/ScarfCore/Sources/ScarfCore/ViewModels/IOSCronViewModel.swift:
  - toggleEnabled(id:)
  - delete(id:)
  - upsert(_:)
All funnel through private saveJobs(_:) which encodes the full
CronJobsFile (.prettyPrinted + .sortedKeys), writes atomically via
transport.writeFile (Data.write-atomic from M5). Creates the cron/
directory on fresh installs.

Models/HermesCronJob.swift — both HermesCronJob and CronJobsFile
gained real public memberwise inits (Swift's synthesis was
suppressed by the hand-written Codable; first draft hacked around
this with JSON round-trips). Also HermesCronJob.withEnabled(_:)
does clean field passthrough instead of encode→mutate→decode.

## ScarfCore — iOS Settings VM

Packages/ScarfCore/Sources/ScarfCore/ViewModels/IOSSettingsViewModel.swift:
  - Reads ~/.hermes/config.yaml via ServerContext.readText
  - Parses with HermesConfig(yaml:)
  - Surfaces both parsed config and rawYAML
  - M6 read-only by design — config.yaml needs round-trip-preserving
    YAML serialization (comments, key order, whitespace) for safe
    edits; option (a) hand-write one, (b) YAML library dep, (c)
    delegate to `hermes config set` via ACP. Defer.

## iOS app

Scarf iOS/Settings/SettingsView.swift:
  - Read-only browser grouped into 10 sections matching the Mac
    app's tabs. DisclosureGroup at the bottom reveals raw YAML
    source for diagnostics.

Scarf iOS/Cron/CronListView.swift rewritten:
  - Toggle-enabled circle (tap to flip, saves atomically)
  - Swipe-to-delete
  - "+" toolbar for new job → editor sheet
  - Row-tap opens editor with existing fields populated

New CronEditorView form:
  - Name, Prompt, Enabled toggle
  - Schedule: kind picker (cron/interval/once), display, expression
    (for cron), run_at (for once)
  - Optional model + comma-separated skills + delivery route
  - Preserves runtime fields (nextRunAt, lastRunAt,
    deliveryFailures, etc.) when editing existing jobs — no reset

Dashboard's Surfaces section gains a 5th row: Settings.

## Test-suite reorganization (real bug caught)

swift-testing's `.serialized` trait serializes WITHIN one @Suite, not
across suites. Shipping M6 revealed a 3-way race on
`ServerContext.sshTransportFactory`:
  - M5's `.serialized` suite sets factory, runs, restores.
  - M6's `.serialized` suite did the same in parallel — clobbered.
  - M0b's non-serialized `serverContextMakeTransportDispatches`
    asserted the DEFAULT factory (nil) returned SSHTransport —
    saw whichever factory was temporarily installed.

Fix: one serialization domain for everything that touches the
factory. Move cron-editing + settings-load M6 tests into M5's
serialized suite. M0b's factory-dependent assertion (SSHTransport
fallback) also moves to the M5 serialized suite with an explicit
`factory = nil` reset for race-freedom. Pure YAML/config/memberwise
tests stay in the new plain (non-serialized) M6ConfigCronTests
suite — they never touch globals.

## Test results: 108 → 134 passing on Linux

19 new in M6ConfigCronTests:
  - YAML parser: scalars, bullets, nested maps, comments, quotes,
    inline {} / []
  - HermesConfig.init(yaml:): empty → defaults, model + agent,
    display, security + blocklist domains, slack legacy fallback,
    auxiliary (3 populated + 2 defaulted), permanent_allowlist vs
    command_allowlist, quoted strings
  - Memberwise inits for HermesCronJob, withEnabled(_:),
    CronJobsFile, CronSchedule

7 new in M5FeatureVMTests (.serialized):
  - defaultFactoryProducesSSHTransportForRemoteContext (moved +
    hardened with explicit factory reset)
  - cronUpsertCreatesFileFromScratch, cronToggleEnabledPersists,
    cronDeleteRemovesJob, cronUpsertReplacesMatchingId,
    cronPreservesRuntimeFieldsAcrossReloads
  - settingsLoadsFromConfigYAML, settingsSurfacesMissingFile

## Manual validation needed on Mac

1. Xcode compile clean.
2. Settings: confirm every section populates from your real
   ~/.hermes/config.yaml. Tap "View source" disclosure, verify raw
   text matches the remote file.
3. Cron: toggle-enabled survives refresh + relaunch. Swipe-delete
   works. "+" creates jobs; round-trip name/prompt/schedule/skills.
   Edit preserves runtime state.
4. Skills: unchanged from M5 (still browse-only, deferred).

Updated scarf/docs/IOS_PORT_PLAN.md with M6's shipped state, the
YAML-parser scope ceiling, the Settings-edit deferral rationale, and
the cross-suite serialization rule for future test authors.

https://claude.ai/code/session_019yMRP6mwZWfzVrPTqevx2y
2026-04-23 17:12:38 +00:00
Claude 6b731ddfb8 iOS port M5: Chat polish + Memory + Cron + Skills features
Fleshes out the iOS app from "Chat + placeholder Dashboard" into a
real on-the-go Hermes companion: Chat now renders tool calls + tool
results + permission sheets + markdown + chain-of-thought, and the
Dashboard gains three new feature surfaces.

## Chat polish

scarf/Scarf iOS/Chat/ChatView.swift — several new small SwiftUI
view types:

  - ToolCallCard: expandable card for each HermesToolCall on an
    assistant message. Tool-kind icon in the header (from
    HermesToolCall.toolKind.icon), arguments summary collapsed,
    full JSON on tap.
  - ToolResultRow: compact "Tool output" disclosure for messages
    with role == "tool", shown indented beneath the preceding
    assistant bubble.
  - PermissionSheet: SwiftUI .sheet(item:) presentation of
    RichChatViewModel.pendingPermission. Tapping an option
    dispatches ChatController.respondToPermission → ACPClient.
  - ReasoningDisclosure: DisclosureGroup for HermesMessage.reasoning,
    collapsed by default so chatty thinkers don't dominate scroll.

MessageBubble now renders assistant content through
AttributedString(markdown: options: .inlineOnlyPreservingWhitespace).
User messages stay plain Text (no reason to parse what the user
just typed). Unknown markdown falls through as literal text — worst
case, no formatting.

ChatController gains respondToPermission(requestId:optionId:) that
forwards to ACPClient and clears vm.pendingPermission on the
MainActor.

## New feature surfaces

### Memory (read + edit)

ScarfCore/ViewModels/IOSMemoryViewModel.swift:
  - Kind enum (.memory / .user) → maps to paths.memoryMD / .userMD
  - text (mutable) + originalText (pristine) + hasUnsavedChanges
  - load() / save() / revert()
  - async file I/O via ServerContext.readText / writeText — run on
    a detached task so the MainActor doesn't hang on remote SFTP

scarf/Scarf iOS/Memory/:
  - MemoryListView: two-row NavigationLink (MEMORY.md, USER.md)
  - MemoryEditorView: TextEditor bound to vm.text, toolbar Save +
    Revert, "Saved" bottom toast on success.

### Cron (read-only)

ScarfCore/ViewModels/IOSCronViewModel.swift:
  - Loads ~/.hermes/cron/jobs.json via transport.readFile + decodes
    into CronJobsFile (Codable, shipped in M0a)
  - Missing file = empty list (no error — common on fresh installs)
  - Sort: enabled-first, then nextRunAt ascending, disabled last
  - Surfaces decode errors via lastError

scarf/Scarf iOS/Cron/CronListView.swift:
  - Row: state-icon + name + schedule.display + next-run-at.
  - Detail: prompt, schedule, state, delivery route (via
    job.deliveryDisplay), skills, model.

Editing is deferred — needs atomic jobs.json rewrites. Shipped the
read path so users can at least audit their cron config on the go.

### Skills (read-only)

ScarfCore/ViewModels/IOSSkillsViewModel.swift:
  - Scans ~/.hermes/skills/<category>/<name>/ via transport.listDirectory
    + transport.stat for directory-ness
  - Filters dotfiles. Skips empty categories. Swallows per-category
    listing errors (permissions etc.) rather than failing the whole
    load.
  - requiredConfig stays empty — YAML frontmatter parsing deferred
    (would need a parser in ScarfCore; see M5 plan note).

scarf/Scarf iOS/Skills/SkillsListView.swift:
  - Grouped by category, tap → SkillDetailView (path + file list).

## Supporting tweaks

- RichChatViewModel.PendingPermission: fields + public init promoted
  from `let`/internal to `public let` / `public init(...)` so
  PermissionSheet can read title/kind/options and tests can construct
  one directly.

- LocalTransport.writeFile refactored to use Data.write(options: .atomic)
  instead of FileManager.replaceItemAt. replaceItemAt is Apple-only;
  Linux swift-corelibs doesn't fully implement it, which was breaking
  the M5 save-path tests on Linux CI. Data.write(atomic) is cross-
  platform and has identical semantics (temp-file + rename). Also
  auto-creates the parent directory if missing, folding in the one
  bit of the old logic that wasn't atomicity-related.

- DashboardView: single Chat Section → "Surfaces" Section with four
  NavigationLinks (Chat / Memory / Cron / Skills).

## Tests (ScarfCoreTests/M5FeatureVMTests, 10 new)

.serialized suite — tests install a `withLocalTransportFactory`
helper that swaps ServerContext.sshTransportFactory to produce a
LocalTransport against real tmp files (so .ssh contexts in the
test resolve to local FS paths). Restored in defer. Serialized
because the factory is a static.

  - memoryLoadsEmptyWhenFileMissing
  - memoryRoundTripsFileContent  — seed file → load → edit → save
    → reload via fresh VM → confirm persistence
  - memoryRevertRestoresOriginal
  - memoryKindPathRouting        — pin .memory → memoryMD etc.
  - cronEmptyWhenJobsFileMissing — missing file is not an error
  - cronLoadsAndSortsJobs        — 3-job fixture, verify sort:
                                   enabled-before-disabled and
                                   nextRunAt-ascending within
  - cronSurfacesDecodeErrors     — garbage jobs.json
  - skillsEmptyWhenDirMissing
  - skillsScansCategoryAndSkillStructure — 2 categories, dotfile
                                           filter check
  - skillsSkipsEmptyCategories
  - pendingPermissionMemberwise  — SQLite3-gated (RichChatViewModel
                                   is gated)

**108 / 108 passing on Linux** (98 → 108).

## Manual validation needed on Mac

1. Xcode compile clean against M5 source additions.
2. Chat: trigger a tool call + a permission request. Verify cards
   render, options dispatch, markdown looks right.
3. Memory: edit MEMORY.md on phone → save → confirm via `cat` on
   the remote.
4. Cron: existing jobs show sorted + detail view useful.
5. Skills: browse matches `ls ~/.hermes/skills/<cat>/<name>/`.

Updated scarf/docs/IOS_PORT_PLAN.md with M5's scope, rationale
for the LocalTransport.writeFile refactor (Linux CI), and the M6
Settings-blocker (needs YAML parser port).

https://claude.ai/code/session_019yMRP6mwZWfzVrPTqevx2y
2026-04-23 17:12:38 +00:00
Claude bd6e722029 iOS port M4: Chat via SSHExecACPChannel (Citadel exec bidirectional)
First real interactive iOS feature. Streams JSON-RPC over a
Citadel 8-bit-safe exec channel to a remote `hermes acp` process.
Reuses ScarfCore's `RichChatViewModel` state machine (from M0d)
+ `ACPClient` (from M1) unchanged — the only new code is the iOS-
specific channel + factory + SwiftUI view.

## SSHExecACPChannel

Packages/ScarfIOS/Sources/ScarfIOS/SSHExecACPChannel.swift
(iOS counterpart to Mac's ProcessACPChannel)

Uses Citadel's `SSHClient.withExec(_:perform:)`:
  - RFC 4254 exec channel, no PTY, binary-clean stdin/stdout for
    JSON-RPC bytes.
  - Bidirectional: `TTYStdinWriter` for our `send(_:)` writes,
    `TTYOutput` stream for stdout/stderr.
  - withExec's closure-scoped lifecycle handled by running it in
    a detached Task. A per-actor pending-waiters queue lets the
    first `send(_:)` block until the writer is handed over (one-
    time RTT); subsequent sends are instant.
  - `close()` cancels the Task, which drops the `withExec`
    closure, which triggers Citadel to close the SSH channel.
    Clean teardown.
  - Line framing via `Data` accumulators for stdout + stderr
    separately — Citadel yields bytes in arbitrary chunk sizes,
    we only push complete (newline-terminated) lines into the
    ACPChannel streams.

## ACPClient+iOS

Packages/ScarfIOS/Sources/ScarfIOS/ACPClient+iOS.swift
(Sibling to Mac's ACPClient+Mac.swift)

Exposes `ACPClient.forIOSApp(context:keyProvider:)`. Opens a
dedicated `SSHClient` per ACP session — NOT reusing the
`CitadelServerTransport` client. Rationale: ACP sessions can
run for minutes/hours of streaming chat, and OpenSSH caps
concurrent channels per connection at ~10. Two separate
connections (transport + ACP) stay well under.

SSH auth: ed25519 via the Keychain-stored bundle, same
`SSHAuthenticationMethod.ed25519(...)` path as
CitadelServerTransport.

## iOS Chat view

scarf/Scarf iOS/Chat/ChatView.swift + embedded ChatController
(@Observable @MainActor). Minimal v1 UX:

  - Three-state lifecycle: .connecting / .ready / .failed(reason)
  - Auto-scrolling message list
  - SwiftUI composer (multi-line TextField + Send button)
  - Toolbar "+" for a fresh session (stop → reset → start)
  - Message bubble (user: accent; agent: secondary background)

Deferred to M5: tool-call cards, permission request sheets,
markdown rendering, voice.

scarf/Scarf iOS/Dashboard/DashboardView.swift gains a
NavigationLink into Chat.

## Small public-API tweak

`RichChatViewModel.sessionId` promoted from `private(set)` to
`public private(set)` — ChatController reads it to route
`sendPrompt`. Same pattern as earlier M3 public-nits patches.

## Tests: 2 new in M4ACPIOSTests (now 98/98 on Linux)

Deliberately focused — M1's 10-test MockACPChannel suite already
covers the full ACPClient state machine. These two pin the
patterns iOS's new SSHExecACPChannel exercises:

  - streamingPromptDeliversChunksAndCompletes: full handshake +
    session/new + streamed agent_message_chunk notifications +
    session/prompt response. Verifies chunks arrive as
    .messageChunk events and prompt resolves with correct usage
    tokens.
  - permissionRequestYieldsEventAndRespondSends: remote
    session/request_permission request → .permissionRequest
    event → respondToPermission writes correct JSON back on the
    channel with matching id + outcome.

Running `docker run --rm -v $PWD/Packages/ScarfCore:/work
-w /work swift:6.0 swift test` now reports 98 / 98.

## Manual validation needed on Mac

1. Xcode compile of scarf mobile target against the merged
   pbxproj (target reconciliation shipped in the previous commit
   on this branch).
2. Chat end-to-end against a real Hermes host. From Dashboard,
   tap Chat → type "hello" → streaming response. Test "+" for
   new session. Verify no leaked SSH connections across
   Disconnect + re-onboard.
3. If your Hermes enables tools: verify tool_call_update
   notifications come through (won't render with fancy cards
   yet — that's M5 polish).

Updated scarf/docs/IOS_PORT_PLAN.md with M4's shipped state, the
"two separate SSH clients" rule, and the M5 polish backlog
(tool cards, permissions, markdown, voice).

https://claude.ai/code/session_019yMRP6mwZWfzVrPTqevx2y
2026-04-23 17:12:38 +00:00
Claude 110611549e iOS target reconciliation: integrate Xcode-created scarf mobile target
Three-way reconciliation of:
  - my M2/M3 source tree at scarf/scarf-ios/
  - Alan's Xcode-created target with folder scarf/Scarf iOS/ and
    target name `scarf mobile` (bundle com.scarf-mobile.app)
  - the Mac `scarf` target that already had ScarfCore wired in

Alan created the iOS target on the unrelated `template-configuration`
branch (commit b289a83). I pulled only the iOS-specific bits
(Scarf iOS/ folder, Scarf iOS{Tests,UITests}/, and the pbxproj
additions), leaving the template-config work alone.

## Source tree: one folder, no duplicates

Before:
  - scarf/scarf-ios/{App,Onboarding,Dashboard,Assets.xcassets,SETUP.md}
  - scarf/Scarf iOS/{ContentView,Item,Scarf_iOSApp}.swift  (Xcode defaults)
  - scarf/Scarf iOS/Assets.xcassets/                        (empty stubs)

After:
  - scarf/Scarf iOS/{App,Onboarding,Dashboard,Assets.xcassets,
                     Info.plist,Scarf_iOS.entitlements}
  - scarf/Scarf iOSTests/Scarf_iOSTests.swift               (placeholder)
  - scarf/Scarf iOSUITests/Scarf_iOSUITests*.swift          (placeholder)

Deleted:
  - scarf/Scarf iOS/ContentView.swift        (my App/ScarfIOSApp supersedes)
  - scarf/Scarf iOS/Item.swift               (Xcode's SwiftData boilerplate,
                                              unused)
  - scarf/Scarf iOS/Scarf_iOSApp.swift       (my App/ScarfIOSApp supersedes)
  - scarf/Scarf iOS/Assets.xcassets/         (replaced with pre-built
                                              1024 icon + Scarf-teal accent)
  - scarf/scarf-ios/                         (emptied + removed)

Moved:
  - scarf/scarf-ios/SETUP.md → scarf/docs/iOS-SETUP.md
    Docs belong in docs/ now that the target exists; the old
    walkthrough is rewritten as a "project layout reference"
    post-setup — the step-by-step target-creation instructions
    are moot.

## pbxproj: three-way merge, no conflicts

Used git merge-file between:
  base = main's pbxproj (before M0a + b289a83)
  ours = M3 branch's pbxproj (has ScarfCore wired to Mac target)
  theirs = b289a83 (has iOS target additions)

Merge produced zero conflict markers (658 → 1074 lines, +~416
from Alan's target additions and my M0a ScarfCore wiring). Hand-
added on top:

  - 53SCARFIOS0020: new XCLocalSwiftPackageReference pointing at
    Packages/ScarfIOS
  - 53SCARFIOS0001: XCSwiftPackageProductDependency for ScarfIOS
    product
  - 53SCARFCORE0002: second XCSwiftPackageProductDependency for
    ScarfCore (separate nav reference from the Mac target's so
    Xcode can track per-target memberships cleanly)
  - 53SCARFCORE0011 + 53SCARFIOS0010: PBXBuildFile entries linking
    both frameworks into the iOS target's Frameworks build phase
  - packageReferences on the project: added 53SCARFIOS0020
  - scarf mobile target's packageProductDependencies: added
    53SCARFCORE0002 + 53SCARFIOS0001
  - scarf mobile target's Frameworks build phase files: added
    53SCARFCORE0011 + 53SCARFIOS0010

Mac target (scarf) is unchanged structurally — still wires
53SCARFCORE0001 as before.

## docs/iOS-SETUP.md rewrite

Rewrote from "how to create the target" to "project layout
reference + troubleshooting". Key sections:

  - Target settings (name, bundle ID, deployment, team, Swift
    language version)
  - SPM dependency map (ScarfCore on both; ScarfIOS on iOS only)
  - Entitlements audit: called out that Xcode defaults set
    CloudKit + APNs entitlements that we don't actually use in
    M2-M4. Keeping them for now since they're no-ops; flagged
    for M6 polish.
  - Milestone coverage table
  - Troubleshooting entries for the 4 most likely build failures
    (Citadel resolution, `Cannot find Process`, stale auth variant
    name, Keychain access)

## Sanity check

Linux `swift test` on ScarfCore still 96/96 passing. The Xcode
build will need Mac verification.

https://claude.ai/code/session_019yMRP6mwZWfzVrPTqevx2y
2026-04-23 17:12:38 +00:00
Claude 92ac102f46 M3 follow-up: SETUP.md rewritten for single-project approach
Q: Should I just create an iOS target in the current scarf project?
   Would that be easier?
A: Yes — single scarf.xcodeproj with two targets (scarf + scarf-ios)
   is objectively easier than a separate scarf-ios.xcodeproj.

The original conservative recommendation (separate xcodeproj) was
rooted in my not wanting to hand-edit pbxproj. But you're the one
clicking through Xcode's UI to create the target, not me — Xcode
handles multi-target multi-platform projects natively, with zero
risk to the existing Mac target.

Rewrote SETUP.md to describe the single-project flow:

  - `File → New → Target` inside the existing project (not a new
    project file).
  - Both targets share the same SPM package references — ScarfCore
    is already there for Mac, you just add it + ScarfIOS to the
    scarf-ios target via General → Frameworks.
  - One Xcode window, one scheme switcher, unified signing/team
    settings.

Also threaded in M3-specific smoke-test steps (connect to a real
host → see Dashboard load via Citadel SFTP snapshot) and added a
post-M3 troubleshooting entry for the `Cannot find 'Process' in
scope` error — it should never appear now that makeProcess is
`#if !os(iOS)`-guarded, but if it does it's a leaked Mac-only file
in the scarf-ios target membership.

Milestone status table in SETUP.md updated to reflect M3 shipped.

https://claude.ai/code/session_019yMRP6mwZWfzVrPTqevx2y
2026-04-23 17:12:38 +00:00
Claude e85a7b170c iOS port M3: CitadelServerTransport + fix critical iOS compile blocker
Three things this phase ships:

## 1. Critical iOS-compile fix (latent from M0b)

`ServerTransport.makeProcess(...) -> Process` was iOS-unavailable at
compile time but my Linux CI didn't catch it (swift-corelibs-foundation
has Process; Apple iOS does not). Without this fix, the first ⌘B on
the iOS target would fail with "Cannot find 'Process' in scope".

Wrapped `makeProcess` with `#if !os(iOS)` on:
  - the ServerTransport protocol requirement
  - LocalTransport's impl
  - SSHTransport's impl

Every current caller of makeProcess is already Mac-target-only
(ACPClient+Mac.swift, OAuthFlowController.swift) so no code changes
needed outside ScarfCore.

## 2. New platform-neutral streamLines(_:args:)

`AsyncThrowingStream<String, Error>` on the protocol, one stdout
line per element, newline-framed. Stream finishes on EOF + throws
`TransportError.commandFailed` on non-zero exit.

Impls:
  - LocalTransport: Task.detached → Process + Pipe → line-framing
    loop → exit check. iOS returns an empty stream (iOS doesn't run
    LocalTransport at runtime).
  - SSHTransport: same pattern, wrapped in `ssh -T host -- sh -c`.
    iOS gets the empty-stream stub.
  - CitadelServerTransport: empty stream for M3; M4 wires it to
    Citadel's raw exec channel for iOS log tailing + chat.

HermesLogService refactored to use transport.streamLines() for the
remote tail path. The old `remoteTailProcess: Process?` +
`fileHandle: FileHandle?` state collapses into a single
`remoteTailTask: Task<Void, Never>?`. Parsed-line ring buffer is
drained synchronously by readNewLines() — semantically identical
on Mac, and newly works on iOS (when Citadel wires streamLines
in M4+).

## 3. CitadelServerTransport (the meat of M3)

Full `ServerTransport` conformance in ScarfIOS:

  - File I/O: SFTP via SSHClient.openSFTP()
  - runProcess: SSHClient.executeCommand(_:) with 2>&1 folding
  - snapshotSQLite: remote `sqlite3 .backup` then SFTP-download
    to <Caches>/scarf/snapshots/<id>/state.db
  - fileExists/stat: SFTPClient.getAttributes
  - listDirectory: SFTPClient.listDirectory with . / .. stripped
  - createDirectory: mkdir -p semantics (walks each component,
    ignores existing-dir errors)
  - removeFile: SFTPClient.remove, idempotent on missing
  - watchPaths: 3s polling on stat mtime (same shape as Mac
    SSHTransport's remote-watch fallback)
  - streamLines: empty stream for M3 (see above)

Maintains a single long-lived SSH + SFTP connection per transport
instance via a nested ConnectionHolder actor. Lazy-init on first
use, reconnect on failure. Blocks the caller thread via
DispatchSemaphore to bridge Citadel's async API to
ServerTransport's sync protocol — same pattern the Mac SSHTransport
uses.

## ScarfCore transport-factory injection

New `ServerContext.sshTransportFactory: SSHTransportFactory?`
static. When non-nil, `makeTransport()` routes `.ssh` contexts
through it instead of constructing SSHTransport directly.

scarfApp.init() on iOS wires this:
  ServerContext.sshTransportFactory = { id, cfg, name in
      CitadelServerTransport(
          contextID: id, config: cfg, displayName: name,
          keyProvider: { try await KeychainSSHKeyStore().load() ?? ... }
      )
  }

Mac leaves it nil; default SSHTransport path unchanged.

## iOS Dashboard — real data

New IOSDashboardViewModel in ScarfIOS. Unlike Mac's DashboardViewModel
(uses HermesFileService, still Mac-only), this reads session stats +
recent sessions only — enough for a real iOS Dashboard, none of the
config.yaml / gateway-state / pgrep checks the Mac dashboard shows.

DashboardView on iOS now renders actual data: session count, message
count, tool calls, token totals (input/output/reasoning with K/M
formatting), and the last 5 sessions with their source icons +
relative start times. Pull-to-refresh triggers vm.refresh(). Error
banner with Retry on snapshot/open failures.

## Public API nits (uncovered by the Dashboard work)

HermesDataService.SessionStats member fields + .empty static were
internal-by-default (nested in a public type, sed missed them).
Promoted to public. `lastOpenError` promoted to public private(set).

## Tests — 8 new in M3TransportTests, @Suite(.serialized)

- LocalTransport.streamLines yields one line per newline, drops
  partial trailing content, surfaces non-zero exit as
  TransportError.commandFailed.
- ServerContext.sshTransportFactory override applies for .ssh,
  ignored for .local, nil-falls-back-to-SSHTransport.
- HermesLogService remote-tail pumps scripted streamLines output
  through to readNewLines() ring buffer.
- HermesLogService.readLastLines uses runProcess one-shot, as
  documented.

Real bug caught in dev: first pass of this test suite had two tests
setting ServerContext.sshTransportFactory + defer-restoring. Swift-
Testing runs in parallel by default — the two tests raced, producing
"entries[2].message is 'z' not 'boom'". Fixed with
@Suite(.serialized) + a note in the suite header explaining why.

Running `docker run --rm -v $PWD/scarf/Packages/ScarfCore:/work -w /work
swift:6.0 swift test` now reports 96 / 96 passing (88 pre-M3 + 8 new).

## Manual validation needed on Mac

1. iOS build with the new protocol guards. ⌘B on iOS simulator —
   should compile cleanly. If `Cannot find 'Process' in scope`
   still appears anywhere, grep for any unguarded `Process\(\)`.
2. Dashboard end-to-end against a real Hermes host: iPhone
   simulator, public key in remote authorized_keys, onboarding →
   Dashboard → should see session stats fetched via Citadel SFTP +
   exec. Pull-to-refresh should re-snapshot.
3. SQLite snapshot file appears under `<Caches>/scarf/snapshots/
   <id>/state.db` and HermesDataService opens it read-only.

Updated scarf/docs/IOS_PORT_PLAN.md with M3's shipped scope, the
streamLines adoption rule, and the "CitadelServerTransport.streamLines
is a stub (M3)" / "M4 wires real streaming" cross-reference.

https://claude.ai/code/session_019yMRP6mwZWfzVrPTqevx2y
2026-04-23 17:12:38 +00:00
Claude 3420abae74 M2 follow-up: Citadel 0.12.1 (current), pre-built Assets.xcassets
Two follow-ups per review:

## Citadel: current stable

Citadel is at 0.12.1, not 0.9.x as I'd been writing against. Bumped
the pin from `from: "0.7.0"` to `.upToNextMinor(from: "0.12.0")`
— tight because Citadel's pre-1.0 authentication-method variants
have shifted between minor releases (0.7 → 0.9 → 0.12), so
explicit bump-and-review is safer than letting the version float.

Downloaded Citadel 0.12.1's source and verified every API call in
CitadelSSHService against it:
  - SSHAuthenticationMethod.ed25519(username:, privateKey:) ✓
  - SSHClientSettings(host:, authenticationMethod:, hostKeyValidator:) ✓
  - SSHHostKeyValidator.acceptAnything() ✓
  - SSHClient.connect(to: settings) ✓
  - client.executeCommand(_:) -> ByteBuffer ✓
  - client.close() async throws ✓

Dropped the "FIXME — may need adjustment" disclaimer in the file
header; replaced with a "verified against 0.12.1" note that says
re-verify if the pin bumps to 0.13+. Same change in SETUP.md
troubleshooting.

## Assets.xcassets (app icon + accent color)

scarf/scarf-ios/Assets.xcassets/ now exists with:

  - AppIcon.appiconset/
      AppIcon-1024.png    (1024×1024, copied from the Mac app's
                           icon set — same art)
      Contents.json       (idiom: universal, platform: ios,
                           size: 1024x1024 — iOS 14+ renders all
                           smaller sizes from this automatically)
  - AccentColor.colorset/
      Contents.json       (Scarf teal: sRGB 0.227/0.525/0.722
                           light, 0.400/0.690/0.902 dark)
  - Contents.json         (root, empty — just version metadata)

SETUP.md updated:
  - Instructs Alan to delete Xcode's scaffolded Assets.xcassets AND
    import ours, not the other way around.
  - Notes the accent color values so a different palette choice is
    a one-file edit.
  - Removes the obsolete "drop your icon asset" step.

No functional code changes; tests still 88/88 on Linux.

https://claude.ai/code/session_019yMRP6mwZWfzVrPTqevx2y
2026-04-23 17:12:37 +00:00
Claude ba368d2f6d iOS port M2: iOS app skeleton — onboarding, Citadel wrapper, Keychain, Dashboard
First iOS phase. Delivers all the code needed to build + TestFlight a
functional v1 iOS app (onboarding with SSH-key generate / import +
real Citadel-backed connection test; persistent Keychain key +
UserDefaults server config; placeholder Dashboard) — but NOT the
scarf-ios.xcodeproj. Creating that from scratch by hand is too risky
without an iOS SDK to build against, so Alan creates it in Xcode's UI
following scarf/scarf-ios/SETUP.md (~5 minutes, one-time).

## ScarfCore additions (all Linux-testable)

Packages/ScarfCore/Sources/ScarfCore/Security/:
  - SSHKey.swift         — SSHKeyBundle + SSHKeyStore protocol
                            + InMemorySSHKeyStore test actor
  - IOSServerConfig.swift — IOSServerConfig + store protocol + mock;
                            toServerContext(id:) bridges to the
                            existing ServerContext so all ScarfCore
                            services work against an iOS config
  - OnboardingState.swift — OnboardingStep enum + pure validators
                            (host, port, PEM shape, public-key parse)
  - SSHConnectionTester.swift — protocol + error enum + mock
  - OnboardingViewModel.swift — @Observable @MainActor state machine,
                            fully dependency-injected (key store /
                            config store / tester / generator closure)

## New Packages/ScarfIOS local SPM package

Depends on ScarfCore + Citadel (from: "0.7.0").

  - KeychainSSHKeyStore.swift    — real iOS Keychain storage
    (kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly, no iCloud
     sync). Gated on canImport(Security) for Linux skip.
  - UserDefaultsIOSServerConfigStore.swift — JSON-encoded single-key
    persistence of IOSServerConfig.
  - Ed25519KeyGenerator.swift    — CryptoKit-backed Ed25519 minting.
    Emits standard OpenSSH public-key lines (authorized_keys-ready).
    Stores the private half in a compact SCARF ED25519 PRIVATE KEY
    PEM shape that CitadelSSHService decodes back into a
    Curve25519.Signing.PrivateKey. Non-interop with OpenSSH's
    `BEGIN OPENSSH PRIVATE KEY` envelope — export flow for sharing
    keys is deferred to a later phase.
  - CitadelSSHService.swift      — SSHConnectionTester conformance +
    key-generation wrapper. Runs `echo scarf-ok` over a one-shot
    Citadel exec for the onboarding connection test. One FIXME on
    buildClientSettings because Citadel 0.7→0.9 shifted the
    `.ed25519(...)` authentication-method variant name; every other
    line is Citadel-version-independent. Gated on
    canImport(Citadel) && canImport(CryptoKit).

## scarf/scarf-ios/ app source tree

  - App/ScarfIOSApp.swift         — @main, RootModel routes to
                                    onboarding or dashboard based on
                                    stored state.
  - Onboarding/OnboardingRootView.swift — 8 sub-views, one per
                                    OnboardingStep. Validated
                                    server-details form, key-source
                                    picker, generate / show-public
                                    / import / test / retry /
                                    connected.
  - Dashboard/DashboardView.swift — M2 placeholder: connected host
                                    details + Disconnect button.
                                    M3 replaces with real data.

## scarf/scarf-ios/SETUP.md

Step-by-step Xcode project creation:
  - iOS 18 / iPhone-only / team 3Q6X2L86C4 / Bundle ID
    com.scarf.scarf-ios / Swift 5 language mode.
  - Wire Packages/ScarfCore + Packages/ScarfIOS (Citadel resolves
    transitively).
  - Replace Xcode's default scaffolded files with this source tree.
  - Smoke-test procedure (simulator → physical iPhone).
  - TestFlight upload steps.
  - Troubleshooting for the known Citadel-variant-name drift.

## Test coverage (Linux, `swift test`)

M2OnboardingTests, 26 new tests (ScarfCore):
  - SSHKeyBundle memberwise + display fingerprint
  - InMemorySSHKeyStore + InMemoryIOSServerConfigStore round-trips
  - IOSServerConfig.toServerContext bridging (with + without
    remoteHome override)
  - All OnboardingLogic validators (empty / whitespace / port range /
    legacy-RSA rejection / public-key line parser)
  - MockSSHConnectionTester scripting (success + failure)
  - 10 OnboardingViewModel end-to-end paths: happy-path
    save-and-test, invalid-host blocks advance, connection-failure
    routes to .testFailed (and crucially does NOT save config),
    retry-after-failure-works, import-happy, import-rejects-bad-PEM,
    reset clears all state

ScarfIOSSmokeTests, 3 tests (Apple-only, won't run on Linux):
  - Ed25519KeyGenerator bundle shape + base64 wire format
  - OpenSSH public-key line byte-length pinned at 51 bytes
  - Corrupted PEM rejection on round-trip decode

Running
  docker run --rm -v $PWD/scarf/Packages/ScarfCore:/work -w /work swift:6.0 swift test
reports **88 / 88 passing** (62 pre-M2 + 26 new).

## Real bug caught in development

First pass of OnboardingViewModel had `confirmPublicKeyAdded()` set
`isWorking=true`, then call `runConnectionTest()` which bailed on
`!isWorking` — meaning the connection probe never ran and the config
was never saved. Caught by the end-to-end test. Fixed by extracting
the shared probe body into `performConnectionTest()` and letting
both entry points own their own `isWorking` transition.

## Manual validation still needed on Mac

1. Xcode project creation per SETUP.md — confirm the resulting
   project builds cleanly.
2. Citadel 0.9.x authentication-method variant — verify the one
   FIXME line in buildClientSettings.
3. End-to-end onboarding: simulator against `localhost:22` (or a
   test host), then TestFlight → physical iPhone → real SSH host
   with the shown public key in authorized_keys.

Updated scarf/docs/IOS_PORT_PLAN.md with M2's shipped scope, the
scope decision about NOT generating the xcodeproj, and the list of
rules M3+ can rely on (Citadel transport dispatch, ChannelFactory
hook, single-server invariant).

https://claude.ai/code/session_019yMRP6mwZWfzVrPTqevx2y
2026-04-23 17:12:37 +00:00
Claude bdf31d6781 iOS port M1: decouple ACPClient from Process via ACPChannel protocol
Introduces the key architectural abstraction that lets iOS share the
ACP state machine with Mac in M4+. ACPClient no longer touches
`Process`, `Pipe`, file descriptors, or SSH sessions directly — it
reads / writes line-oriented JSON-RPC through an `ACPChannel`.

New in ScarfCore/ACP/:
  - ACPChannel.swift (protocol + ACPChannelError enum)
  - ProcessACPChannel.swift (Mac + Linux; `#if !os(iOS)` guard —
    iOS can't spawn subprocesses). Wraps the Process + Pipe +
    raw POSIX write(2) code that used to live inline inside
    ACPClient: SIGPIPE-ignore, partial-write loops, EPIPE →
    `.writeEndClosed`, graceful SIGINT + 2s SIGKILL watchdog.
    Uses `canImport(Darwin)` / `canImport(Glibc)` for the
    platform-specific `write(2)` binding.
  - ACPClient.swift (moved from scarf/Core/Services and refactored).
    Process/Pipe/stdinFd/Darwin.write state replaced with a single
    `channel: any ACPChannel` reference. Construction takes a
    `ChannelFactory = @Sendable (ServerContext) async throws -> any ACPChannel`
    closure — Mac wires ProcessACPChannel, iOS will wire a Citadel
    SSHExecACPChannel in M4.

Mac-side glue (stays in main target):
  - scarf/Core/Services/ACPClient+Mac.swift (new) carries the
    `ACPClient.forMacApp(context:)` factory. Internally spawns
    `hermes acp` locally or `ssh -T host -- hermes acp` remotely
    via SSHTransport.makeProcess, passing the enriched shell env
    (local: full PATH + credentials; remote: just SSH_AUTH_SOCK
    + SSH_AGENT_PID) with TERM stripped. Behaviour identical to
    pre-M1.
  - ChatViewModel updated at 3 sites from `ACPClient(context:)`
    to `ACPClient.forMacApp(context:)`.

Public API change callers need to know about:
  - `ACPClient.respondToPermission(requestId:optionId:)` is now
    `async`. ChatViewModel already `await`ed it, so that upgrade
    is a no-op; no other callers.

Also deleted scarf/Core/Services/ACPClient.swift (605 lines;
replaced by ScarfCore version).

Test coverage (M1ACPTests, 10 tests):
  Using a MockACPChannel actor to script JSON-RPC deterministically,
  not a real subprocess:
  - ACPChannel protocol (mock send/receive, write-after-close,
    error descriptions).
  - ACPClient initial state.
  - start() sends initialize and flips isConnected on reply.
  - RPC error reply surfaces as ACPClientError.rpcError.
  - Mid-flight channel close → pending request resolves with
    .processTerminated, isConnected flips false.
  - session/update notification routes into the `events` stream
    as .messageChunk.
  - Stderr lines feed the recentStderr ring buffer.
  - ACPErrorHint.classify across credential / missing-binary /
    rate-limit / unknown cases.

`swift test` on Linux now reports 62 / 62 passing.

Updated scarf/docs/IOS_PORT_PLAN.md with M1's shipped state, the
behavior-preservation rationale for the Mac factory, and the
iOS hook point M2–M4 will plug into.

https://claude.ai/code/session_019yMRP6mwZWfzVrPTqevx2y
2026-04-23 17:12:37 +00:00
Claude 920c86b4f8 M0 verification: fix two real regressions before starting M1
Two bugs caught by a post-M0d audit, both of which would have bitten
users before any test exercised them on Mac:

1. GatewayViewModel.swift lost its `import ScarfCore` during the
   M0d revert (when I moved it back to the Mac target after finding it
   wasn't portable). The file references ServerContext everywhere and
   wouldn't compile in Xcode without the import. Added back.

2. SSHTransport.sshSubprocessEnvironment() regressed in M0b.
   The original Mac code ran HermesFileService.enrichedEnvironment(),
   which tries `zsh -l -i` (login + interactive, with prompt-framework
   defangs) FIRST, then falls back to `zsh -l`. Most users with
   1Password / Secretive / manual ssh-add export SSH_AUTH_SOCK from
   their `.zshrc` (interactive shell init), NOT `.zprofile`. My M0b
   replacement used `zsh -l` only — so it would have silently failed
   to find their ssh-agent socket, and SSH auth would break with
   "Permission denied" (exit 255) for everyone who set up their
   agent the normal way.

   Fix is a dependency-inversion injection point instead of a local
   shell probe: SSHTransport.environmentEnricher is a `(@Sendable () ->
   [String: String])?` static that the Mac target wires at launch to
   HermesFileService.enrichedEnvironment(). Same exact code path
   executed as before M0b; no duplication; iOS leaves it `nil` and
   falls back to ProcessInfo.processInfo.environment (Citadel will
   own the SSH agent on iOS in M4+, not the login shell). Tests can
   set a stub closure.

   scarfApp.init() now sets `SSHTransport.environmentEnricher = {
   HermesFileService.enrichedEnvironment() }` right before the
   existing warm-up Task.

Test coverage: M0b suite gains `sshTransportEnvironmentEnricherInjection`,
which pins the injection-point shape so a future refactor can't
silently drop it.

Audit results (for confidence before M1):
  - Exhaustive grep of every moved type across main target → 0 files
    reference ScarfCore types without `import ScarfCore` (after the
    GatewayVM fix).
  - `scarf.xcodeproj/project.pbxproj` has no stale path references
    (PBXFileSystemSynchronizedRootGroup auto-discovers).
  - `xcshareddata/xcschemes/*.xcscheme` has no stale path references.
  - `.build/` correctly gitignored.
  - Zero leftover temp scripts / `.orig` / `.bak` files.

`swift test`: 52 / 52 passing on Linux.

https://claude.ai/code/session_019yMRP6mwZWfzVrPTqevx2y
2026-04-23 17:12:37 +00:00
Claude 8bd4b9282a iOS port M0d: extract 6 portable ViewModels to ScarfCore
Fourth and final M0 sub-PR. Wraps up the ScarfCore extraction with the
ViewModels that have no dependency on Mac-target services or AppKit.
Views deliberately stay in the Mac target — see plan for rationale.

Moved (6 VMs):
  ActivityViewModel.swift      — HermesDataService consumer, SQLite3-gated
  ConnectionStatusViewModel.swift — @MainActor heartbeat for remote SSH
  InsightsViewModel.swift      — HermesDataService aggregator, SQLite3-gated
                                  (+ InsightsPeriod, ModelUsage, PlatformUsage,
                                   ToolUsage, NotableSession types; exports
                                   free functions formatDuration/formatTokens)
  LogsViewModel.swift          — HermesLogService consumer, fully portable
                                  (+ nested LogFile / LogComponent enums)
  ProjectsViewModel.swift      — ProjectDashboardService wrapper, portable
  RichChatViewModel.swift      — ~700 lines of ACP-event + message-group
                                  handling, SQLite3-gated
                                  (+ ChatDisplayMode, MessageGroup types)

Reverted in-flight:
  GatewayViewModel.swift — my audit missed that it calls
  `context.runHermes(...)`, a Mac-target-only extension. Not portable
  without moving HermesFileService too. Left in the Mac target.

Platform guards applied:
  - `#if canImport(SQLite3)` wraps entire files for ActivityVM, InsightsVM,
    and RichChatVM (they transitively depend on HermesDataService).
  - `#if canImport(Darwin)` around LocalizedStringResource displayName
    in LogsViewModel's nested LogFile and LogComponent enums.
  - `#if canImport(os)` around the unused Logger in
    ConnectionStatusViewModel (kept the field for future use).

Swift 6 / Observation notes:
  - `import Observation` explicitly added to each @Observable file.
    Mac target gets Observation via SwiftUI; ScarfCore doesn't import
    SwiftUI, so it needs the explicit module import. Observation ships
    in the Swift 5.9+ standard library on every platform.
  - Nested enums' `var id: String { rawValue }` had to be manually
    promoted to `public var id` since my sed only touches 4-space-indent
    declarations and the nested enum's members are at 8-space indent.
  - Two accidentally-publicized function-local `let` variables in
    InsightsViewModel reverted back to internal.
  - Sed adjustment: an earlier pattern was producing `@Observable public`
    which is a Swift syntax error. Fixed post-hoc by stripping the
    stray trailing `public` after the attribute; noted in the plan file
    as a checklist item for M1+ sed work.

Consumer import sweeps:
  4 Mac-target files gained `import ScarfCore` for the moved VM types:
  ContentView.swift, ChatView.swift, RichChatView.swift, and
  ConnectionStatusPill.swift.

Test coverage (M0dViewModelsTests): 14 new tests.
  - ConnectionStatusViewModel: local-always-connected, remote idle-start,
    Status Equatable pinning.
  - LogsViewModel: init defaults, filteredEntries across level / search /
    component filters, nested enum Identifiable ids and loggerPrefix.
  - ProjectsViewModel: .local context binding.
  - (SQLite3-gated, Apple-only):
    ActivityVM construction, InsightsVM period defaults and sinceDate
    ordering, ChatDisplayMode case coverage, RichChatVM empty-state
    invariants, MessageGroup derived properties.

Running `docker run --rm -v $PWD/scarf/Packages/ScarfCore:/work -w /work
swift:6.0 swift test` now reports 51 / 51 passing on Linux
(M0a 16 + M0b 18 + M0c 8 + M0d 9 + smoke 1 − 5 SQLite3-gated).
Apple-target CI should see 56 / 56 with the 5 gated tests added in.

Updated scarf/docs/IOS_PORT_PLAN.md with M0d's shipped state, the
Views-stay-Mac-only scope decision, and the sed-gotcha checklist
future phases should watch for.

https://claude.ai/code/session_019yMRP6mwZWfzVrPTqevx2y
2026-04-23 17:12:37 +00:00
Claude 27dc694aeb iOS port M0c: extract portable Services to ScarfCore
Third of four M0 sub-PRs. Moves the four Services that have no dependency
on Mac-target code or AppKit into ScarfCore, so the Mac + (future) iOS
targets can share them.

Files moved (4):
  scarf/Core/Services/HermesDataService.swift  (658 lines, SQLite reader + SnapshotCoordinator actor)
  scarf/Core/Services/HermesLogService.swift   (log tail + parse, LogEntry + LogLevel)
  scarf/Core/Services/ModelCatalogService.swift (models.dev JSON reader, HermesModelInfo + HermesProviderInfo)
  scarf/Core/Services/ProjectDashboardService.swift (per-project dashboard I/O)

Not moved, with reason:
  HermesFileService.swift  — carries the big shell-enrichment logic; a
    later phase can port once iOS has a clearer env story for ACP spawns.
  HermesEnvService.swift   — depends on HermesFileService.
  HermesFileWatcher.swift  — depends on HermesFileService.
  ACPClient.swift          — M1's job (the ACPChannel refactor).
  UpdaterService.swift     — wraps Sparkle, stays Mac-only forever.

Platform guards:
  HermesDataService.swift is wrapped in `#if canImport(SQLite3) ... #endif`
  for the whole file. SQLite3 isn't a system module on Linux
  swift-corelibs-foundation. Apple platforms compile unchanged. Linux
  builds skip the file entirely; nothing in ScarfCore references
  HermesDataService from outside the file, so there's no downstream
  fallout.

  ModelCatalogService `import os` / Logger definition / call site all
  guarded with `#if canImport(os)`. Linux gets silent logging.

  HermesLogService + ProjectDashboardService use only Foundation —
  no guards needed.

Other fixes:
  - Features/Settings/Views/Components/ModelPickerSheet.swift (the one
    remaining consumer) gains `import ScarfCore`.
  - Self-referential `import ScarfCore` stripped from each moved file.

Test coverage: 8 new tests in ScarfCoreTests/M0cServicesTests.swift:
  - HermesLogService.parseLine exercised via readLastLines on a real
    tmp file with three formats — v0.9.0+ with session tag, older
    without, and garbage fallback. Pins CLAUDE.md's optional-session-tag
    invariant.
  - LogLevel SwiftUI colour strings pinned.
  - HermesModelInfo.contextDisplay across 1M / 200K / 500 / nil cases;
    costDisplay with and without costs.
  - ModelCatalogService load path end-to-end against a synthetic
    models_dev_cache.json lookalike — providers sorted, models
    filtered, provider(for:) resolves both full-scan and slash-prefixed
    IDs.
  - Malformed + missing catalog files return empty, no crash.
  - ProjectDashboardService round-trips ProjectRegistry + reads a
    synthetic .scarf/dashboard.json.

Running `docker run --rm -v $PWD/scarf/Packages/ScarfCore:/work -w /work
swift:6.0 swift test` now reports 42 / 42 passing (M0a 16 + M0b 18 +
M0c 8).

Updated scarf/docs/IOS_PORT_PLAN.md progress log with the shipped M0c
state and the SQLite3-gating pattern future phases should reuse.

https://claude.ai/code/session_019yMRP6mwZWfzVrPTqevx2y
2026-04-23 17:12:05 +00:00
Claude 0fd2ceb9fc iOS port M0b: extract Transport + ServerContext to ScarfCore
Second of four M0 sub-PRs. Moves the remaining cross-cutting
infrastructure — the ServerTransport protocol and its two implementations
(LocalTransport, SSHTransport), plus ServerContext and its helpers —
into ScarfCore so both Mac and (future) iOS targets share one codebase.

Files moved (5):
  - scarf/Core/Transport/ServerTransport.swift (+ FileStat, ProcessResult, WatchEvent)
  - scarf/Core/Transport/LocalTransport.swift
  - scarf/Core/Transport/SSHTransport.swift
  - scarf/Core/Transport/TransportErrors.swift
  - scarf/Core/Models/ServerContext.swift (+ SSHConfig, ServerKind, ServerID, UserHomeCache)

Split out of ServerContext.swift into a new Mac-target sibling file
scarf/Core/Models/ServerContext+Mac.swift:
  - runHermes(_:timeout:stdin:) — depends on HermesFileService
  - openInLocalEditor(_:) — depends on AppKit.NSWorkspace
These methods can't live in ScarfCore itself because ScarfCore must not
depend on main-target services or AppKit. iOS will provide a sibling
ServerContext+iOS.swift in M2+.

Removed: scarf/Core/Models/HermesPaths+Deprecated.swift.
  Zero callers in-tree; its only justification was that ServerContext
  used to be in the Mac target. With ServerContext in ScarfCore now,
  the deprecated forwarders are both unreachable AND dead code.

Breaking the ScarfCore → main-target circular dep in SSHTransport:
  The old SSHTransport.sshSubprocessEnvironment() called
  HermesFileService.enrichedEnvironment() to harvest SSH_AUTH_SOCK from
  the user's login shell. Replaced with a local #if os(macOS) helper
  SSHTransport.macLoginShellSSHAgent() that probes /bin/zsh for only
  the two SSH agent vars (no PATH/credentials — that's still in
  HermesFileService for ACP subprocess use). Behavior-identical on
  macOS; no-op on iOS/Linux.

Platform guards added in ScarfCore (runtime targets still macOS/iOS):
  - `#if canImport(os)` around os.Logger (definition + every call site,
    except the large Darwin-dependent ensureControlDir block).
  - `#if canImport(Darwin)` around LocalTransport.watchPaths (FSEvents)
    and SSHTransport.ensureControlDir (Darwin.stat/lstat). Linux gets
    a no-op empty stream and a best-effort FileManager.createDirectory
    fallback — neither is exercised at runtime on Linux, only compiled.
  - `#if canImport(SwiftUI)` around ServerContext's EnvironmentKey.
  - `#if canImport(AppKit)` inside the new ServerContext+Mac.swift
    extension.

Bug fixed: M0a's sed transform accidentally added `public` to protocol
requirements in ServerTransport.swift, e.g. `public nonisolated var
contextID: ServerID { get }`. Swift forbids access modifiers on
protocol requirements — stripped.

54 additional consumer files in the Mac target gained `import ScarfCore`.

Test coverage: 18 new tests in ScarfCoreTests/M0bTransportTests.swift.
Runs on Linux via
  docker run --rm -v $PWD/scarf/Packages/ScarfCore:/work -w /work swift:6.0 swift test
Total suite: 34 / 34 passing (M0a's 16 + M0b's 18).

Updated scarf/docs/IOS_PORT_PLAN.md progress log with the shipped M0b
state and the Platform-guard patterns future phases should reuse.

https://claude.ai/code/session_019yMRP6mwZWfzVrPTqevx2y
2026-04-23 17:10:59 +00:00
Claude f6f31cabe4 M0a fixup: unignore local Packages/, add missing files, make Linux CI pass
The initial M0a commit was incomplete: .gitignore's `Packages/` rule
(meant for the legacy pre-Xcode-14 SwiftPM checkout dir) silently
swallowed three new files that SHOULD have been committed:

  - scarf/Packages/ScarfCore/Package.swift
  - scarf/Packages/ScarfCore/Sources/ScarfCore/Models/HermesConstants.swift
  - scarf/Packages/ScarfCore/Tests/ScarfCoreTests/ScarfCoreSmokeTests.swift

The 12 moved models slipped through because `git mv` preserves tracking
across gitignored destinations, but new files in that tree did not.

Fix: add `!scarf/Packages/` override so our local SPM package is always
tracked; keep the top-level `Packages/` ignore for the historical case.

Also verified M0a builds + tests green on Linux via
`docker run --rm -v $PWD/scarf/Packages/ScarfCore:/work -w /work swift:6.0 swift test`.
To make that work, two small, Apple-platform-preserving guards:

  - `sqliteTransient` in HermesConstants.swift wrapped in
    `#if canImport(SQLite3)` — SQLite3 is not a system module on Linux
    swift-corelibs-foundation. Apple builds compile unchanged.
  - `ToolKind.displayName` and `MCPTransport.displayName` wrapped in
    `#if canImport(Darwin)` — `LocalizedStringResource` is Apple-only.
    Apple builds compile unchanged.

Additionally:

  - Package.swift pinned to Swift 5 language mode, matching the Mac app's
    `SWIFT_VERSION = 5.0`. Two types (`ACPEvent.availableCommands` and
    `ACPToolCallEvent.rawInput`) claim `Sendable` while carrying
    `[String: Any]` — strict Swift 6 rejects that. Comment in Package.swift
    flags this for a future typed-payloads cleanup + bump to `.v6`.
  - ScarfCoreSmokeTests now contains 16 tests exercising every M0a
    `public init` so parameter drift fails CI instead of a reviewer.
  - IOS_PORT_PLAN.md updated with what actually shipped, the Linux-CI
    guards + patterns future phases should reuse, and the Sendable
    follow-up flagged under "Rules next phases can rely on".

Test results (Linux, Swift 6.0.3):
  Suite M0aPublicInitTests: 15 tests passed
  Suite ScarfCoreSmokeTests: 1 test passed
  Total: 16 / 16 passed

https://claude.ai/code/session_019yMRP6mwZWfzVrPTqevx2y
2026-04-23 17:10:59 +00:00
Claude bb5045c10f iOS port M0a: extract 13 leaf Models to new ScarfCore local SPM package
First of four M0 sub-PRs that carve a platform-neutral ScarfCore package
out of the Mac app, in preparation for an iOS target. This PR is
Mac-only — no iOS target yet, no behavior changes expected.

What moves to ScarfCore:
  - 13 leaf model files (HermesSession, HermesMessage, HermesConfig and
    its 19 nested Settings structs, HermesCronJob, HermesMCPServer,
    HermesSkill, HermesSlashCommand, HermesTool + KnownPlatforms,
    HermesPathSet, MCPServerPreset, ProjectDashboard family, ACPMessages).
  - Portable half of HermesConstants.swift (sqliteTransient, QueryDefaults,
    FileSizeUnit). The deprecated HermesPaths enum stays in main target
    as HermesPaths+Deprecated.swift since it references ServerContext.

What stays in the Mac target:
  - ServerContext.swift (moves in M0b alongside Transport — depends on
    LocalTransport/SSHTransport + HermesFileService).
  - HermesPaths+Deprecated.swift (dead forwarders, zero callers in-tree;
    kept for safety until M0b can clean them up).

Mechanics:
  - New Packages/ScarfCore/Package.swift targeting macOS 14 / iOS 18,
    Swift 6 language mode.
  - Every moved type and member marked public; explicit public memberwise
    init added to every struct (Swift's synthesized memberwise init is
    internal and would break cross-module construction).
  - Xcode project references the package via XCLocalSwiftPackageReference
    and links ScarfCore into the scarf target.
  - 49 consumer files get `import ScarfCore` added.

See scarf/docs/IOS_PORT_PLAN.md for the full multi-phase plan, locked
decisions (iOS 18, iPhone only, no APNs v1), and the M0b–M6 roadmap.

Manual verification checklist:
  - Open scarf.xcodeproj in Xcode and build the scarf scheme — should
    resolve the local package and compile with no new errors.
  - Run scarfTests — should pass (tests don't touch moved types).
  - Smoke-run the app: Dashboard, Sessions, Chat, Memory should render
    with identical data to pre-PR.

https://claude.ai/code/session_019yMRP6mwZWfzVrPTqevx2y
2026-04-23 17:10:59 +00: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
482 changed files with 50188 additions and 6150 deletions
+5
View File
@@ -21,7 +21,12 @@ xcuserdata/
# Swift Package Manager
.build/
# `Packages/` is the historical SwiftPM checkout dir for downloaded deps
# (pre-Xcode-14). We keep it ignored — but NOT our local-package checkout
# at scarf/Packages/, which is part of the source tree (ScarfCore, etc.)
# and must ship in the repo.
Packages/
!scarf/Packages/
Package.pins
Package.resolved
*.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/
+85 -1
View File
@@ -22,6 +22,35 @@ scarf/scarf/ Xcode project root (PBXFileSystemSynchronizedRootGroup
- **Sandbox disabled**: App reads `~/.hermes/` directly.
- **Swift 6 concurrency**: `@MainActor` default. Services use `nonisolated` + async/await.
## Design System (ScarfDesign)
All app UI uses the typed token bundle in [scarf/Packages/ScarfDesign/](scarf/Packages/ScarfDesign/) — both the `scarf` and `scarf mobile` targets `import ScarfDesign`. Reach for these tokens before inventing new colors, fonts, or spacings.
- **Colors**: `ScarfColor.accent`, `.foregroundPrimary/Muted/Faint`, `.backgroundPrimary/Secondary/Tertiary`, `.border/.borderStrong`, `.success/.danger/.warning/.info`, `.Tool.bash/edit/search/web/think`. All resolve from `ScarfBrand.xcassets` and adapt light/dark automatically.
- **Typography**: `.scarfStyle(.title2)`, `.scarfStyle(.body)`, `.scarfStyle(.captionUppercase)`, etc. Use these instead of `.font(.system(...))`. Eleven preset styles cover the type scale.
- **Spacing / radius / shadow**: `ScarfSpace.s1...s10` (4/8/12/16/20/24/32/40), `ScarfRadius.sm/md/lg/xl/xxl/pill`, `.scarfShadow(.sm/.md/.lg/.xl)`. Hardcoded `.padding(12)` or `cornerRadius: 8` is a code smell — convert.
- **Components**: `ScarfPageHeader("Title", subtitle: "...") { trailing }`, `ScarfCard { ... }`, `ScarfBadge("text", kind: .success)`, `ScarfTextField`, `ScarfSectionHeader`, `ScarfDivider`, `ScarfPrimaryButton/SecondaryButton/GhostButton/DestructiveButton` (apply with `.buttonStyle(...)`).
- **App icon + accent**: `Assets.xcassets/AppIcon.appiconset/` is the rust set; `Assets.xcassets/AccentColor.colorset` resolves `Color.accentColor` to rust so any unmigrated SwiftUI control still tints correctly.
- **Reference**: full screen mockups live at `design/static-site/ui-kit/*.jsx` (open `design/static-site/index.html` in a browser). The `ScarfChatView.ChatRootView` reference component in the package is a 3-pane chat redesign target — usable for previews but not yet swapped into the live chat (the existing `RichChatView` machinery still owns the real ACP pipeline).
- **Don't**: introduce purple/violet tones (we shifted to rust); use yellow `#F0AD4E` for success (that's `.warning``.success` is green); bypass the type scale with `.font(.system(size: 13.5))`; ship terminal/syntax-highlight palettes through ScarfColor (those are content semantics, keep them inline).
### iOS Dynamic Type policy
iOS users can scale text via Settings → Accessibility → Display & Text Size. ScarfFont uses fixed point sizes; adopting it blanket on iOS would regress accessibility on `.accessibility2` / `.xSmall` users. iOS-specific rule:
- **Use `ScarfFont` only for**: status badges, chip labels, intentional-display elements (e.g., onboarding step titles, header chrome that's meant to be a fixed visual size).
- **Keep `.font(.headline)` / `.body` / `.caption` semantic tokens for**: list-row primary + secondary text, body copy, error messages, chat content — anything the user reads.
Decision tree per text element: "is this read for content?" → semantic token. "Is this chrome / a label / a badge?" → ScarfFont.
Mac doesn't have this constraint and adopts ScarfFont everywhere. The iOS app already clamps Dynamic Type at the scene root (`ScarfIOSApp.swift`: `.dynamicTypeSize(.xSmall ... .accessibility2)`) — keep that.
### iOS page chrome
Don't retrofit `ScarfPageHeader` over iOS tab roots. iOS uses `.navigationTitle(...)` + `.navigationBarTitleDisplayMode(.large)` as its native page-header pattern; stacking ScarfPageHeader on top creates double titles. Use ScarfPageHeader only on iOS sub-views without a native large-title bar (rare).
iOS button styling: only swap `.borderedProminent``ScarfPrimaryButton`. **Leave `.bordered` native** — it's the iOS convention and inherits rust through `AccentColor.colorset` automatically. Same for `.plain` (used as compact tap targets in lists).
## Key Paths
- Hermes home: `~/.hermes/`
@@ -84,7 +113,26 @@ Public documentation lives in the GitHub wiki at https://github.com/awizemann/sc
## Hermes Version
Targets Hermes v0.9.0 (v2026.4.13). 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.
Targets Hermes v2026.4.23 (v0.11.0). Log lines may carry an optional `[session_id]` tag between the level and logger name — `HermesLogService.parseLine` treats the session tag as an optional capture group, so older untagged lines still parse.
**v2026.4.23 (v0.11.0)** added (Scarf-relevant subset):
- `/steer <prompt>` — non-interruptive mid-run guidance slash command. Surfaced in Scarf chat menus via `RichChatViewModel.nonInterruptiveCommands`; `ChatViewModel.sendViaACP` (Mac) and `ChatController.send` (iOS) skip the "Agent working…" status flip and show a transient toast instead.
- New CLI subcommands: `hermes plugins` / `profile` / `webhook` / `insights` / `logs` / `memory reset` / `completion` / `dashboard`. Scarf v2.5 adopts **`hermes memory reset`** (toolbar button on MemoryView with destructive confirmation). The other CLIs are documented here for v2.6 — Scarf still reads `~/.hermes/plugins/`, `~/.hermes/profiles/` etc directly today; switching those paths to the canonical CLI is a forward-compatible change to make when bandwidth permits.
- New state.db columns: `messages.reasoning_content` + `sessions.api_call_count`. `HermesDataService.detectSchema` flips `hasV011Schema` only when both are present (partial migrations stay on v0.7 path). Surfaced as the "API" chip on session rows + a network-icon counter in DashboardView. `HermesMessage.preferredReasoning` picks the newer column when both reasoning channels are populated.
- New skills: `design-md` (Google's DESIGN.md authoring; needs `npx`/Node 18+ on host — checked via `SkillPrereqService` and surfaced as a yellow banner) and `spotify` (OAuth via `hermes auth spotify` — driven by `SpotifyAuthFlow` + `SpotifySignInSheet`, mirroring v2.3 Nous Portal pattern).
- Updated skills: `research-paper-writing` 1.1.0 (+SciencePlots dep), `segment-anything-model` (expanded docs), `google-workspace` (gws CLI prefer + granular OAuth scopes), `hermes-agent` (in-tree).
- SKILL.md frontmatter gains `allowed_tools` / `related_skills` / `dependencies` lists. `HermesSkill` carries them as optional fields; `SkillsView` (Mac) + `SkillDetailView` (iOS) render them as chip rows when populated.
v0.10.0 introduced the **Tool Gateway** — paid Nous Portal subscribers route web search, image generation, TTS, and browser automation through their subscription without separate API keys. In Scarf:
- **Provider picker** ([ModelCatalogService.swift](scarf/scarf/Core/Services/ModelCatalogService.swift)) merges Hermes's `HERMES_OVERLAYS` so Nous Portal and other overlay-only providers (OpenAI Codex, Qwen OAuth, Google Gemini CLI, GitHub Copilot ACP, Arcee) appear alongside the models.dev catalog. Subscription-gated providers sort first and render a "Subscription" pill.
- **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
@@ -142,6 +190,42 @@ Key services: [TemplateConfig.swift](scarf/scarf/Core/Models/TemplateConfig.swif
**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.
+65 -31
View File
@@ -1,5 +1,5 @@
<p align="center">
<img src="icon.png" width="128" height="128" alt="Scarf app icon">
<img src="icon-v2.5.png" width="128" height="128" alt="Scarf app icon">
</p>
<h1 align="center">Scarf</h1>
@@ -19,41 +19,59 @@
<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.2
## What's New in 2.5
- **Project Templates** — Scarf projects can now travel. Package a project's dashboard, agent instructions, skills, and cron jobs 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 new **Templates** menu in the Projects toolbar.
- **Preview-before-apply** — Every install shows a preview sheet listing the exact project directory that will be created, every file inside it, every skill that will be namespaced, every cron job that will be registered (paused by default), and a live diff of any memory appendix. Nothing writes until you click Install.
- **Safe-by-design** — Skills install into `~/.hermes/skills/templates/<slug>/` so they never collide with your own. Cron jobs carry a `[tmpl:<id>]` tag and start paused. A `template.lock.json` records everything written for easy uninstall. Templates **never** touch `config.yaml`, `auth.json`, sessions, or credentials.
### ScarfGo — the iPhone companion ships in public TestFlight
See the full [v2.2.0 release notes](https://github.com/awizemann/scarf/releases/tag/v2.2.0).
Same Hermes server you've been running on your Mac — now reachable from your phone over SSH. Multi-server, project-scoped chat, session resume, memory editor, cron list, skills tree, settings (read), all native iOS. Pure-Swift SSH (Citadel under the hood — no `ssh` binary needed on iOS). Per-project chat writes the same Scarf-managed `AGENTS.md` block the Mac app does, so the agent boots with the same project context regardless of which client opened the session.
### Previously, in 2.1
**[Join the public TestFlight](https://testflight.apple.com/join/qCrRpcTz)** — the link is live now but only accepts new beta testers once Apple's Beta Review approves the first build. If you hit a "not accepting testers" splash, bookmark it and try again in 2448h.
- **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).
<p align="center">
<a href="assets/screenshots/scarfgo-servers.png"><img src="assets/screenshots/scarfgo-servers.png" alt="ScarfGo — Servers list" width="140"></a>
<a href="assets/screenshots/scarfgo-chat.png"><img src="assets/screenshots/scarfgo-chat.png" alt="ScarfGo — Chat with Hermes" width="140"></a>
<a href="assets/screenshots/scarfgo-project-dashboard.png"><img src="assets/screenshots/scarfgo-project-dashboard.png" alt="ScarfGo — Project dashboard" width="140"></a>
<a href="assets/screenshots/scarfgo-skills.png"><img src="assets/screenshots/scarfgo-skills.png" alt="ScarfGo — Skills browser" width="140"></a>
<a href="assets/screenshots/scarfgo-system.png"><img src="assets/screenshots/scarfgo-system.png" alt="ScarfGo — System tab" width="140"></a>
</p>
See the full [v2.1.0 release notes](https://github.com/awizemann/scarf/releases/tag/v2.1.0).
<p align="center"><sub><em>Tap any thumbnail to view full size. Servers list · Chat · Project dashboard (Site Status Checker template) · Skills browser · System tab.</em></sub></p>
### Previously, in 2.0
See the [ScarfGo wiki page](https://github.com/awizemann/scarf/wiki/ScarfGo) for the full feature tour, [ScarfGo Onboarding](https://github.com/awizemann/scarf/wiki/ScarfGo-Onboarding) for the SSH-key setup walkthrough, and [Platform Differences](https://github.com/awizemann/scarf/wiki/Platform-Differences) for what is and isn't shared between Mac and iOS.
- **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.
### Everything else in 2.5
See the [v2.0.0 release notes](https://github.com/awizemann/scarf/releases/tag/v2.0.0) for the full 2.0 series.
- **Portable project-scoped slash commands.** Author reusable prompt templates as Markdown files at `<project>/.scarf/slash-commands/<name>.md` with YAML frontmatter (name, description, argumentHint, optional model override). Invoke as `/<name> [args]` from chat — Scarf substitutes `{{argument}}` (with optional `default:` fallback) in the body and sends the expanded prompt to Hermes. Mac authoring tab + iOS read-only browser. Templates carry them via the new `slash-commands/` block in `.scarftemplate` bundles (schemaVersion 3). See [Slash Commands](https://github.com/awizemann/scarf/wiki/Slash-Commands) for the full schema.
- **Hermes v2026.4.23 chat parity.** `/steer` non-interruptive guidance command, per-turn stopwatch on assistant bubbles, numbered keyboard shortcuts (19) on the permission sheet, git branch chip in the chat header. The new `messages.reasoning_content` and `sessions.api_call_count` columns surface as a richer reasoning disclosure + an "API" chip on session rows.
- **Spotify + design-md skills.** Mac ships an in-app Spotify OAuth sheet (mirrors the v2.3 Nous Portal pattern); design-md gets a host-side `npx` prereq check on both platforms. SKILL.md frontmatter (`allowed_tools`, `related_skills`, `dependencies`) renders as chip rows. A "What's New" pill on the Skills tab tells you when remote skills changed since you last looked.
- **Mac global Sessions: project filter + project badges** — parity with ScarfGo's Sessions tab. The list grows a filter Menu (All projects / Unattributed / each registered project) and each row carries a tinted folder chip with the project name when attributed.
- **Human-readable cron schedules everywhere.** New `CronScheduleFormatter` in ScarfCore translates the common cron shapes into English phrases and falls back to the raw expression on anything custom. Mac and iOS render the same.
- **Mac design-system overhaul.** Rust palette, typed token bundle (`ScarfColor`, `ScarfFont`, `ScarfSpace`, `ScarfRadius`), reusable components (`ScarfPageHeader`, `ScarfCard`, `ScarfBadge`, `ScarfTextField`, four button styles), redesigned 3-pane chat. iOS adopts the same tokens with a hybrid Dynamic Type policy so accessibility scaling on body text is preserved. See [Design System](https://github.com/awizemann/scarf/wiki/Design-System) for the full reference.
- **Under the hood** — `SessionAttributionService`, `ProjectContextBlock`, `CronScheduleFormatter`, `GitBranchService`, `SkillPrereqService`, `SkillSnapshotService`, `ProjectSlashCommandService`, and the ACP error triplet (`acpError` / `acpErrorHint` / `acpErrorDetails`) consolidated into ScarfCore so Mac and iOS consume one source of truth. 179 tests across 13 suites, three consecutive green runs. Several `try?` swallows in iOS lifecycle code now surface real failures (Keychain unlock errors no longer drop people into onboarding; partial Forget operations report what failed).
- **iOS push notifications skeleton** — `NotificationRouter` ships with foreground presentation + a lock-screen "Approve / Deny" action category gated by `apnsEnabled = false`. Lights up when Hermes ships a server-side push sender + an APNs cert.
### Previously, in 1.6
See the full [v2.5.0 release notes](https://github.com/awizemann/scarf/releases/tag/v2.5.0).
- **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
**Previous releases:** see the [Release Notes Index](https://github.com/awizemann/scarf/wiki/Release-Notes-Index) on the wiki for v2.3, v2.2, v2.0, v1.6, and earlier.
See the [v1.6.0 release notes](https://github.com/awizemann/scarf/releases/tag/v1.6.0) for the full 1.6 series.
## Connect ScarfGo to your Hermes server
ScarfGo speaks SSH directly — no companion service, no developer-controlled server in between. Onboarding takes about a minute:
1. **Install via TestFlight.** Open the [public TestFlight link](https://testflight.apple.com/join/qCrRpcTz) on your phone, accept the invite, install ScarfGo from TestFlight (just like any other beta).
2. **Tap Add Server.** Enter the host (IP or DNS), SSH user, port (default 22), and an optional nickname. Same details you'd type into `ssh user@host`.
3. **Generate Key.** ScarfGo creates a fresh Ed25519 keypair on the device. The private half lives in the iOS Keychain (`kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly`) and is excluded from iCloud sync — it never leaves the phone.
4. **Add the public key to your Hermes host.** Tap **Copy public key**, then on the host run:
```bash
cat >> ~/.ssh/authorized_keys <<'EOF'
<paste the line ScarfGo showed you>
EOF
chmod 600 ~/.ssh/authorized_keys
```
This is its own line per device — the convention any second SSH client uses. Mac (Scarf) keeps using your existing ssh-agent / `~/.ssh/config` and is unaffected.
5. **Tap Test connection.** ScarfGo opens an SSH session, probes for the `hermes` binary, and saves the server on success. If it can't find `hermes`, see the [troubleshooting section](https://github.com/awizemann/scarf/wiki/ScarfGo-Onboarding#troubleshooting) — it's almost always a `PATH` quirk on non-interactive SSH.
Done. Open the Dashboard tab and tap any session to resume it; tap the **+** in Chat to start a new project-scoped session.
## Multi-server, one window per server
@@ -124,10 +142,11 @@ Custom, agent-generated dashboards for any project. Define stat boxes, charts, t
## Requirements
- 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.
- macOS 14.6+ (Sonoma) for Scarf
- iOS 18.0+ for [ScarfGo](https://github.com/awizemann/scarf/wiki/ScarfGo) (the iPhone companion, public TestFlight from v2.5)
- Xcode 16.0+ to build from source
- [Hermes agent](https://github.com/hermes-ai/hermes-agent) v0.6.0+ installed at `~/.hermes/` on each target host (v0.11.0+ recommended for full v2.5 feature support — `/steer`, new state.db columns, design-md/spotify skills, SKILL.md frontmatter chips)
- For remote servers: SSH access (key-based), `sqlite3` on the remote (for atomic DB snapshots), and the `hermes` CLI resolvable from the remote user's `PATH` or at a path you specify per server. ScarfGo requires the same on every Hermes host it connects to.
### Compatibility
@@ -139,9 +158,10 @@ Scarf reads Hermes's SQLite database and parses CLI output from `hermes status`,
| 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) |
| v0.10.0 (2026-04-16) | Verified (Tool Gateway introduced) |
| v0.11.0 (2026-04-23) | **Verified — current target (recommended for full v2.5 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.
Scarf 2.5 targets Hermes v0.11.0 for `/steer`, the new state.db columns (`messages.reasoning_content`, `sessions.api_call_count`), the new skills (design-md, spotify), the SKILL.md frontmatter chip surfaces, and the `hermes memory reset` toolbar action. Earlier Hermes versions remain supported for monitoring, sessions, file-based features, and ACP chat; v0.11-specific behavior degrades gracefully on older agents (`/steer` is harmless, new columns silently nil out).
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.
@@ -159,6 +179,20 @@ Download the latest build from [Releases](https://github.com/awizemann/scarf/rel
Scarf checks for updates automatically on launch via [Sparkle](https://sparkle-project.org) and daily thereafter. You can disable automatic checks or trigger a manual check from **Settings → General → Updates** or the menu bar icon.
#### "Scarf.app is damaged" on first launch
If Gatekeeper rejects the app on first launch (occasionally happens on macOS 14+ for zip-distributed apps depending on extraction tool + quarantine state), the bundle itself is fine — every release is verified to pass `codesign --verify --strict --deep` and `spctl --assess --type execute` before it ships. The fix is to **only remove the quarantine attribute**, never strip all xattrs or re-sign:
```bash
# Recommended — non-destructive
xattr -d com.apple.quarantine /Applications/Scarf.app
# Or extract with ditto instead of double-clicking the zip:
ditto -xk ~/Downloads/Scarf-vX.X.X-Universal.zip ~/Downloads/
```
**Do not run `xattr -rc /Applications/Scarf.app`** — it strips codesign-related extended attributes and can break the bundle's seal. **Do not run `codesign --force --deep --sign - /Applications/Scarf.app`** — `--deep` ad-hoc re-signing is incompatible with Sparkle.framework's nested XPC services and `Updater.app` sub-bundle, and will corrupt the framework signature even if the outer app appears intact afterward. If a clean re-download + `xattr -d com.apple.quarantine` doesn't resolve the issue, please open an issue with `codesign --verify --verbose=4 --strict /Applications/Scarf.app` output captured **before** any mitigation attempts.
### Build from Source
```bash
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 183 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 514 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 472 KiB

@@ -0,0 +1,11 @@
{
"images" : [
{
"filename" : "Scarf-AppIcon-iOS-1024.png",
"idiom" : "universal",
"platform" : "ios",
"size" : "1024x1024"
}
],
"info" : { "author" : "xcode", "version" : 1 }
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 MiB

+3
View File
@@ -0,0 +1,3 @@
{
"info" : { "author" : "xcode", "version" : 1 }
}
+52
View File
@@ -0,0 +1,52 @@
# Scarf Design System — static site
A self-contained, offline-friendly site that browses every artifact in the
Scarf design system. Open `index.html` directly in any browser — no server,
no build step.
## What's here
```
static-site/
├── index.html ← landing page, links into everything
├── colors_and_type.css ← shared design tokens (referenced everywhere)
├── ui-kit/ ← interactive macOS UI kit
│ ├── index.html ← click-thru of every screen in the app
│ └── *.jsx ← React components (Sidebar, Chat, Dashboard…)
├── tokens/ ← design-system cards
│ ├── _preview.css ← shared card styling
│ ├── colors-*.html ← brand / neutrals / semantic / tool-kinds
│ ├── type-*.html ← display / body / mono
│ ├── spacing-*.html ← scale / radii / shadows
│ ├── components-*.html ← buttons / forms / sidebar / cards / chat / composer / tool-call
│ ├── iconography.html
│ └── brand-mark.html
└── assets/ ← icons, brand artwork
```
## How to use it
- **Browse offline**: double-click `index.html`. Everything renders locally;
the only network dependency is Google Fonts (Inter + JetBrains Mono).
- **Host as a site**: drop the whole folder onto any static host (Netlify,
GitHub Pages, S3, your own nginx). Nothing needs building.
- **Embed in a doc**: link individual cards directly, e.g.
`static-site/tokens/colors-brand.html`.
- **Show the macOS app**: `static-site/ui-kit/index.html` runs the full
React-based interactive kit (single self-contained file — works from
`file://`, no server needed). The traffic-light corner makes it look like
the real app. Source components live alongside as `*.jsx` for editing —
re-bundle into `index.html` when you change them.
## Notes
- The kit's `index.html` is a self-contained bundle — React, Babel, Lucide
and every component are inlined, so it works from `file://` with no
network. The original split-file source is preserved as
`ui-kit/index.source.html` next to the `.jsx` files for editing.
- The font import in `colors_and_type.css` (`fonts.googleapis.com`) is the
only other network call. Replace with locally-served WOFF2 if you need
airgapped use.
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 117 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 429 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 541 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 54 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 490 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 48 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 592 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 56 KiB

Before

Width:  |  Height:  |  Size: 274 KiB

After

Width:  |  Height:  |  Size: 274 KiB

+193
View File
@@ -0,0 +1,193 @@
/* Scarf Design System — colors + type tokens. v2 (amber→rust)
*
* Light/dark via [data-theme="dark"] override on a parent. Default light.
*
* v2 changes: brand shifted from purple to a tri-stop amber→rust gradient.
* Neutrals warmed (yellow undertone). Semantic green/blue/red/orange preserved
* — those still mean success/info/danger and remain the tool-kind colors in chat.
*/
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500;600&display=swap');
:root {
/* ───── Brand — amber → rust ───── */
--brand-50: #FBF1E8;
--brand-100: #F6E0CB;
--brand-200: #EFC59E; /* highlight stop in tri-gradient */
--brand-300: #E89360; /* gradient start */
--brand-400: #D87844;
--brand-500: #C25A2A; /* primary accent — Scarf Rust */
--brand-600: #A6481E;
--brand-700: #7A2E14; /* gradient end */
--brand-800: #5C220F;
--brand-900: #3B1608;
/* ───── Neutrals (warm, slight amber tint) ───── */
--gray-0: #FFFFFF;
--gray-50: #FBF9F6;
--gray-100: #F4F1EC;
--gray-200: #EAE5DD;
--gray-300: #D8D1C5;
--gray-400: #B5ABA0;
--gray-500: #8C857B;
--gray-600: #6A645B;
--gray-700: #4A463F;
--gray-800: #2D2A25;
--gray-900: #1A1814;
--gray-950: #100E0B;
/* ───── Semantic palette ───── */
--green-500: #2AA876;
--green-600: #1F7F5A;
--green-100: #D8F0E5;
--red-500: #D9534F;
--red-600: #B83C38;
--red-100: #F8DAD8;
--orange-500: #F0AD4E; /* reasoning / warning — distinct from brand rust */
--orange-100: #FCEAD0;
--blue-500: #3498DB;
--blue-100: #D8ECF8;
--indigo-500: #5B6CD9;
--purple-tool-500: #8E5BC9;
/* ───── Surfaces (light) ───── */
--fg: var(--gray-900);
--fg-muted: var(--gray-600);
--fg-faint: var(--gray-500);
--bg: var(--gray-50);
--bg-card: var(--gray-0);
--bg-quaternary: rgba(45, 42, 37, 0.04);
--bg-tertiary: rgba(45, 42, 37, 0.07);
--border: rgba(45, 42, 37, 0.08);
--border-strong: rgba(45, 42, 37, 0.14);
/* ───── Brand tokens (semantic) ───── */
--accent: var(--brand-500);
--accent-hover: var(--brand-600);
--accent-active: var(--brand-700);
--accent-tint: rgba(194, 90, 42, 0.10);
--accent-tint-strong: rgba(194, 90, 42, 0.18);
--on-accent: #FFFFFF;
/* ───── Type stacks ───── */
--font-sans: -apple-system, BlinkMacSystemFont, "SF Pro Text", "Inter", "Segoe UI", Roboto, sans-serif;
--font-display: -apple-system, BlinkMacSystemFont, "SF Pro Display", "Inter", "Segoe UI", sans-serif;
--font-mono: ui-monospace, SFMono-Regular, "SF Mono", "JetBrains Mono", Menlo, Consolas, monospace;
/* ───── Type scale ───── */
--text-caption2: 10px;
--text-caption: 12px;
--text-footnote: 13px;
--text-body: 14px;
--text-callout: 15px;
--text-subhead: 16px;
--text-headline: 17px;
--text-title3: 20px;
--text-title2: 22px;
--text-title1: 28px;
--text-largeTitle: 34px;
--leading-tight: 1.2;
--leading-snug: 1.35;
--leading-normal: 1.5;
--leading-relaxed: 1.6;
--weight-regular: 400;
--weight-medium: 500;
--weight-semibold: 600;
--weight-bold: 700;
/* ───── Radii / spacing / shadow ───── */
--r-sm: 4px;
--r-md: 6px;
--r-lg: 8px;
--r-xl: 12px;
--r-2xl: 14px;
--r-pill: 999px;
--space-1: 4px;
--space-2: 8px;
--space-3: 12px;
--space-4: 16px;
--space-5: 20px;
--space-6: 24px;
--space-8: 32px;
--space-10: 40px;
--shadow-sm: 0 1px 2px rgba(45, 42, 37, 0.05);
--shadow-md: 0 1px 2px rgba(45, 42, 37, 0.04), 0 4px 12px rgba(45, 42, 37, 0.04);
--shadow-lg: 0 2px 4px rgba(45, 42, 37, 0.06), 0 8px 24px rgba(45, 42, 37, 0.07);
--shadow-xl: 0 4px 8px rgba(45, 42, 37, 0.08), 0 16px 40px rgba(45, 42, 37, 0.10);
--shadow-focus: 0 0 0 3px rgba(194, 90, 42, 0.28);
--gradient-brand: linear-gradient(135deg, #E89360 0%, #C25A2A 50%, #7A2E14 100%);
--gradient-brand-soft: linear-gradient(135deg, #F6E0CB 0%, #EFC59E 100%);
--ease-smooth: cubic-bezier(0.32, 0.72, 0, 1);
--dur-fast: 120ms;
--dur-base: 200ms;
--dur-slow: 300ms;
}
[data-theme="dark"] {
--fg: #EDE8E0;
--fg-muted: #A39C92;
--fg-faint: #756F66;
--bg: #15130F;
--bg-card: #1F1C18;
--bg-quaternary: rgba(255, 248, 235, 0.05);
--bg-tertiary: rgba(255, 248, 235, 0.08);
--border: rgba(255, 248, 235, 0.08);
--border-strong: rgba(255, 248, 235, 0.14);
--accent: #E89360;
--accent-hover: #F0A879;
--accent-active: #D87844;
--accent-tint: rgba(232, 147, 96, 0.14);
--accent-tint-strong: rgba(232, 147, 96, 0.24);
--shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.35);
--shadow-md: 0 1px 2px rgba(0, 0, 0, 0.35), 0 4px 12px rgba(0, 0, 0, 0.35);
--shadow-lg: 0 2px 4px rgba(0, 0, 0, 0.45), 0 8px 24px rgba(0, 0, 0, 0.45);
}
@media (prefers-color-scheme: dark) {
:root:not([data-theme="light"]) {
--fg: #EDE8E0;
--fg-muted: #A39C92;
--fg-faint: #756F66;
--bg: #15130F;
--bg-card: #1F1C18;
--bg-quaternary: rgba(255, 248, 235, 0.05);
--bg-tertiary: rgba(255, 248, 235, 0.08);
--border: rgba(255, 248, 235, 0.08);
--border-strong: rgba(255, 248, 235, 0.14);
--accent: #E89360;
--accent-hover: #F0A879;
--accent-active: #D87844;
--accent-tint: rgba(232, 147, 96, 0.14);
--accent-tint-strong: rgba(232, 147, 96, 0.24);
}
}
/* ───── Semantic type rules ───── */
body, .scarf-body {
font-family: var(--font-sans);
font-size: var(--text-body);
line-height: var(--leading-normal);
color: var(--fg);
background: var(--bg);
-webkit-font-smoothing: antialiased;
}
.scarf-h1 { font-family: var(--font-display); font-size: var(--text-largeTitle); font-weight: 600; line-height: 1.2; letter-spacing: -0.02em; }
.scarf-h2 { font-family: var(--font-display); font-size: var(--text-title1); font-weight: 600; line-height: 1.2; letter-spacing: -0.015em; }
.scarf-h3 { font-family: var(--font-display); font-size: var(--text-title2); font-weight: 600; line-height: 1.35; letter-spacing: -0.01em; }
.scarf-headline { font-family: var(--font-sans); font-size: var(--text-headline); font-weight: 600; line-height: 1.35; }
.scarf-subhead { font-family: var(--font-sans); font-size: var(--text-subhead); font-weight: 500; line-height: 1.35; }
.scarf-body-text { font-family: var(--font-sans); font-size: var(--text-body); line-height: 1.5; }
.scarf-caption { font-family: var(--font-sans); font-size: var(--text-caption); line-height: 1.5; color: var(--fg-muted); }
.scarf-caption-strong { font-family: var(--font-sans); font-size: var(--text-caption); font-weight: 600; text-transform: uppercase; letter-spacing: 0.05em; color: var(--fg-muted); }
.scarf-mono { font-family: var(--font-mono); font-size: 0.92em; }
.scarf-code { font-family: var(--font-mono); font-size: 0.9em; background: var(--bg-quaternary); padding: 1px 5px; border-radius: var(--r-sm); color: var(--fg); }
+382
View File
@@ -0,0 +1,382 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Scarf Design System</title>
<link rel="stylesheet" href="colors_and_type.css">
<link rel="icon" type="image/png" href="assets/scarf-app-icon-256.png">
<style>
* { box-sizing: border-box; }
html, body { margin: 0; padding: 0; }
body {
min-height: 100vh;
background:
radial-gradient(ellipse 100% 60% at 50% -10%, rgba(232, 147, 96, 0.18), transparent 60%),
var(--bg);
color: var(--fg);
font-family: var(--font-sans);
}
.wrap { max-width: 1080px; margin: 0 auto; padding: 80px 32px 120px; }
header { display: flex; align-items: center; gap: 20px; margin-bottom: 56px; }
.icon-tile {
width: 88px; height: 88px;
border-radius: 22px;
background-image: url('assets/scarf-app-icon-256.png');
background-size: cover;
box-shadow: var(--shadow-lg);
}
h1 {
font-family: var(--font-display);
font-size: 44px;
font-weight: 600;
letter-spacing: -0.02em;
margin: 0 0 6px;
line-height: 1.1;
}
.tagline {
font-size: 17px;
color: var(--fg-muted);
line-height: 1.5;
max-width: 56ch;
margin: 0;
}
.section-label {
font-size: 12px;
font-weight: 600;
letter-spacing: 0.08em;
text-transform: uppercase;
color: var(--fg-faint);
margin: 64px 0 20px;
}
/* Big feature card */
.hero-card {
display: grid;
grid-template-columns: 1.1fr 1fr;
gap: 0;
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: 18px;
overflow: hidden;
box-shadow: var(--shadow-md);
margin-bottom: 40px;
}
.hero-card .text {
padding: 36px 36px 32px;
display: flex; flex-direction: column;
justify-content: center;
}
.hero-card .preview {
background: var(--gradient-brand);
position: relative;
min-height: 320px;
display: flex; align-items: center; justify-content: center;
}
.hero-card .preview img {
width: 60%; max-width: 240px;
filter: drop-shadow(0 14px 40px rgba(60, 18, 6, 0.35));
}
.hero-card h2 {
font-family: var(--font-display);
font-size: 28px;
font-weight: 600;
letter-spacing: -0.015em;
margin: 0 0 10px;
}
.hero-card p {
font-size: 15px;
color: var(--fg-muted);
line-height: 1.55;
margin: 0 0 24px;
}
.hero-card .cta {
display: inline-flex;
align-items: center;
gap: 8px;
padding: 10px 18px;
background: var(--accent);
color: var(--on-accent);
border-radius: 8px;
font-size: 14px;
font-weight: 600;
text-decoration: none;
align-self: flex-start;
transition: background 120ms ease;
}
.hero-card .cta:hover { background: var(--accent-hover); }
.hero-card .cta svg { width: 16px; height: 16px; }
/* Token grid */
.grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
gap: 16px;
}
.tile {
display: block;
text-decoration: none;
color: inherit;
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: 12px;
padding: 18px 20px;
transition: transform 160ms var(--ease-smooth), border-color 160ms ease, box-shadow 160ms ease;
}
.tile:hover {
transform: translateY(-2px);
border-color: var(--border-strong);
box-shadow: var(--shadow-md);
}
.tile .kicker {
font-size: 11px;
font-weight: 600;
letter-spacing: 0.08em;
text-transform: uppercase;
color: var(--fg-faint);
margin-bottom: 6px;
}
.tile h3 {
margin: 0 0 4px;
font-size: 16px;
font-weight: 600;
color: var(--fg);
}
.tile p {
margin: 0;
font-size: 13px;
color: var(--fg-muted);
line-height: 1.45;
}
.swatches {
display: flex; gap: 4px; margin-top: 14px;
}
.sw {
flex: 1; height: 22px; border-radius: 4px;
border: 1px solid rgba(0,0,0,0.05);
}
/* Group titles */
.group-title {
font-family: var(--font-display);
font-size: 22px;
font-weight: 600;
letter-spacing: -0.01em;
margin: 0 0 16px;
}
.group-blurb {
font-size: 14px;
color: var(--fg-muted);
margin: 0 0 24px;
line-height: 1.5;
max-width: 60ch;
}
footer {
margin-top: 80px;
padding-top: 28px;
border-top: 1px solid var(--border);
font-size: 13px;
color: var(--fg-faint);
display: flex; justify-content: space-between; align-items: center;
}
footer a { color: var(--fg-muted); text-decoration: none; }
footer a:hover { color: var(--accent); }
@media (max-width: 760px) {
.hero-card { grid-template-columns: 1fr; }
.hero-card .preview { min-height: 200px; order: -1; }
h1 { font-size: 36px; }
}
</style>
</head>
<body>
<div class="wrap">
<header>
<div class="icon-tile" role="img" aria-label="Scarf app icon"></div>
<div>
<h1>Scarf Design System</h1>
<p class="tagline">A native macOS &amp; iOS companion for the Hermes AI agent — calm, confident, and rust-warm. This site documents the palette, type, components, and screens.</p>
</div>
</header>
<!-- UI Kit hero -->
<div class="section-label">UI Kit</div>
<a href="ui-kit/index.html" class="hero-card" style="text-decoration: none; color: inherit;">
<div class="text">
<h2>Interactive macOS app</h2>
<p>Click through every screen — Dashboard, Sessions, Insights, Projects, Chat, Settings, Tools, MCP servers, Cron, Logs, Memory, Activity, Health and more. Faithful to the real Scarf macOS app, with a working sidebar and the rust palette throughout.</p>
<span class="cta">
Open the kit
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round"><path d="M5 12h14M13 5l7 7-7 7"/></svg>
</span>
</div>
<div class="preview">
<img src="assets/scarf-app-icon-1024.png" alt="">
</div>
</a>
<!-- Tokens & components -->
<div class="section-label">Tokens &amp; components</div>
<h2 class="group-title">Foundations</h2>
<p class="group-blurb">Each tile opens a single design-system card. They're sized for ~700px wide and render one concept at a time.</p>
<div class="grid">
<a class="tile" href="tokens/colors-brand.html">
<div class="kicker">Color</div>
<h3>Brand — amber → rust</h3>
<p>The 9-step rust ramp. Primary accent is <code>#C25A2A</code>.</p>
<div class="swatches">
<div class="sw" style="background:#FBF1E8"></div>
<div class="sw" style="background:#EFC59E"></div>
<div class="sw" style="background:#E89360"></div>
<div class="sw" style="background:#C25A2A"></div>
<div class="sw" style="background:#7A2E14"></div>
<div class="sw" style="background:#3B1608"></div>
</div>
</a>
<a class="tile" href="tokens/colors-neutrals.html">
<div class="kicker">Color</div>
<h3>Warm neutrals</h3>
<p>Slight amber undertone — never cool grey. 11 steps for surfaces and text.</p>
<div class="swatches">
<div class="sw" style="background:#FBF9F6"></div>
<div class="sw" style="background:#EAE5DD"></div>
<div class="sw" style="background:#B5ABA0"></div>
<div class="sw" style="background:#6A645B"></div>
<div class="sw" style="background:#2D2A25"></div>
<div class="sw" style="background:#100E0B"></div>
</div>
</a>
<a class="tile" href="tokens/colors-semantic.html">
<div class="kicker">Color</div>
<h3>Semantic palette</h3>
<p>Success, danger, warning, info — preserved from system conventions.</p>
<div class="swatches">
<div class="sw" style="background:#2AA876"></div>
<div class="sw" style="background:#D9534F"></div>
<div class="sw" style="background:#F0AD4E"></div>
<div class="sw" style="background:#3498DB"></div>
</div>
</a>
<a class="tile" href="tokens/colors-tool-kinds.html">
<div class="kicker">Color</div>
<h3>Tool-kind palette</h3>
<p>Bash, edit, search, web, think — the per-tool decorations in chat.</p>
<div class="swatches">
<div class="sw" style="background:#2AA876"></div>
<div class="sw" style="background:#3498DB"></div>
<div class="sw" style="background:#5B6CD9"></div>
<div class="sw" style="background:#8E5BC9"></div>
<div class="sw" style="background:#F0AD4E"></div>
</div>
</a>
<a class="tile" href="tokens/type-display.html">
<div class="kicker">Type</div>
<h3>Display scale</h3>
<p>Large titles &amp; headlines — SF Pro Display, tight tracking.</p>
</a>
<a class="tile" href="tokens/type-body.html">
<div class="kicker">Type</div>
<h3>Body scale</h3>
<p>14px base, the working text of the app.</p>
</a>
<a class="tile" href="tokens/type-mono.html">
<div class="kicker">Type</div>
<h3>Mono</h3>
<p>SF Mono — for transcripts, paths, command output.</p>
</a>
<a class="tile" href="tokens/spacing-scale.html">
<div class="kicker">Layout</div>
<h3>Spacing scale</h3>
<p>4 / 8 / 12 / 16 / 20 / 24 / 32 / 40 — that's the whole grid.</p>
</a>
<a class="tile" href="tokens/spacing-radii.html">
<div class="kicker">Layout</div>
<h3>Radii</h3>
<p>4 / 6 / 8 / 12 / 14 / pill — tuned for native macOS controls.</p>
</a>
<a class="tile" href="tokens/spacing-shadows.html">
<div class="kicker">Layout</div>
<h3>Shadows</h3>
<p>Four elevation tiers, all on a warm-black tint.</p>
</a>
<a class="tile" href="tokens/iconography.html">
<div class="kicker">Brand</div>
<h3>Iconography</h3>
<p>Lucide icons at 16/18/20/24, 1.6px stroke, currentColor.</p>
</a>
<a class="tile" href="tokens/brand-mark.html">
<div class="kicker">Brand</div>
<h3>App mark</h3>
<p>The flowing-silk icon — preferred backgrounds &amp; minimum sizes.</p>
</a>
</div>
<h2 class="group-title" style="margin-top: 56px;">Components</h2>
<p class="group-blurb">Composable pieces lifted directly from the macOS app's surfaces.</p>
<div class="grid">
<a class="tile" href="tokens/components-buttons.html">
<div class="kicker">Component</div>
<h3>Buttons</h3>
<p>Primary / secondary / ghost / destructive — three sizes each.</p>
</a>
<a class="tile" href="tokens/components-forms.html">
<div class="kicker">Component</div>
<h3>Forms</h3>
<p>Text fields, toggles, selects — with focus &amp; error states.</p>
</a>
<a class="tile" href="tokens/components-sidebar.html">
<div class="kicker">Component</div>
<h3>Sidebar</h3>
<p>Section headers, items, active state, count pills.</p>
</a>
<a class="tile" href="tokens/components-stat-cards.html">
<div class="kicker">Component</div>
<h3>Stat cards</h3>
<p>Number-forward dashboard tiles.</p>
</a>
<a class="tile" href="tokens/components-status-cards.html">
<div class="kicker">Component</div>
<h3>Status cards</h3>
<p>Connection / health / run cards with semantic dots.</p>
</a>
<a class="tile" href="tokens/components-chat-bubbles.html">
<div class="kicker">Component</div>
<h3>Chat bubbles</h3>
<p>User &amp; agent rich messages, avatars, timestamps.</p>
</a>
<a class="tile" href="tokens/components-composer.html">
<div class="kicker">Component</div>
<h3>Composer</h3>
<p>Multiline input with attachments &amp; tool toggles.</p>
</a>
<a class="tile" href="tokens/components-tool-call.html">
<div class="kicker">Component</div>
<h3>Tool-call card</h3>
<p>Inline transcript card showing what the agent did.</p>
</a>
</div>
<footer>
<span>Scarf Design System · v2 (rust)</span>
<span><a href="ui-kit/index.html">UI kit</a> · <a href="tokens/colors-brand.html">First token</a></span>
</footer>
</div>
</body>
</html>
+44
View File
@@ -0,0 +1,44 @@
/* Shared styling for design-system preview cards.
Each card is sized for ~700px wide and renders one focused concept. */
@import url('../colors_and_type.css');
* { box-sizing: border-box; }
html, body { margin: 0; padding: 0; }
body {
background: var(--bg);
color: var(--fg);
font-family: var(--font-sans);
font-size: var(--text-body);
line-height: var(--leading-normal);
-webkit-font-smoothing: antialiased;
}
.card-root {
padding: 20px 24px;
min-height: 110px;
display: flex;
flex-direction: column;
gap: 12px;
}
.row { display: flex; align-items: center; gap: 12px; flex-wrap: wrap; }
.col { display: flex; flex-direction: column; gap: 8px; }
.label { font-size: 11px; color: var(--fg-muted); text-transform: uppercase; letter-spacing: 0.06em; font-weight: 600; }
.mono { font-family: var(--font-mono); font-size: 11px; color: var(--fg-muted); }
/* swatches */
.swatch {
width: 92px;
height: 64px;
border-radius: 8px;
border: 1px solid var(--border);
display: flex;
flex-direction: column;
justify-content: flex-end;
padding: 6px 8px;
position: relative;
overflow: hidden;
}
.swatch .name { font-size: 10px; font-weight: 600; }
.swatch .hex { font-family: var(--font-mono); font-size: 10px; opacity: 0.85; }
.swatch.dark-text { color: var(--gray-900); }
.swatch.light-text { color: #fff; }
+13
View File
@@ -0,0 +1,13 @@
<!doctype html><html lang="en"><head><meta charset="utf-8"><title>Brand mark</title>
<link rel="stylesheet" href="_preview.css"></head>
<body>
<div class="card-root" style="flex-direction:row;align-items:center;gap:24px;min-height:160px">
<img src="../assets/scarf-app-icon-128.png" alt="Scarf icon" width="96" height="96"
style="border-radius:22px;box-shadow:var(--shadow-md);background:var(--gradient-brand)">
<div class="col" style="flex:1;gap:6px">
<div style="font-family:var(--font-display);font-size:28px;font-weight:600;letter-spacing:-0.015em">Scarf</div>
<div style="color:var(--fg-muted);font-size:14px;max-width:380px">A native macOS GUI for the Hermes AI agent. Full visibility into what an autonomous agent is doing, when, and what it creates.</div>
<div class="mono" style="margin-top:4px">brand: white silk on lavender → magenta gradient</div>
</div>
</div>
</body></html>
@@ -0,0 +1,17 @@
<!doctype html><html lang="en"><head><meta charset="utf-8"><title>Primary palette</title>
<link rel="stylesheet" href="_preview.css"></head>
<body>
<div class="card-root">
<div class="label">Brand · Scarf Purple</div>
<div class="row">
<div class="swatch light-text" style="background:#F5F0FA;color:#36204A"><div class="name">50</div><div class="hex">#F5F0FA</div></div>
<div class="swatch light-text" style="background:#EADDF3;color:#36204A"><div class="name">100</div><div class="hex">#EADDF3</div></div>
<div class="swatch light-text" style="background:#D4B8E8;color:#36204A"><div class="name">200</div><div class="hex">#D4B8E8</div></div>
<div class="swatch light-text" style="background:#B288D9"><div class="name">300</div><div class="hex">#B288D9</div></div>
<div class="swatch light-text" style="background:#8B5BB8"><div class="name">500 ★</div><div class="hex">#8B5BB8</div></div>
<div class="swatch light-text" style="background:#7848A0"><div class="name">600</div><div class="hex">#7848A0</div></div>
<div class="swatch light-text" style="background:#4D2C68"><div class="name">800</div><div class="hex">#4D2C68</div></div>
</div>
<div class="mono">★ var(--accent) · used for primary buttons, focused borders, active sidebar items</div>
</div>
</body></html>
@@ -0,0 +1,21 @@
<!doctype html><html lang="en"><head><meta charset="utf-8"><title>Neutral palette</title>
<link rel="stylesheet" href="_preview.css"></head>
<body>
<div class="card-root">
<div class="label">Neutrals · warm-cool gray scale</div>
<div class="row">
<div class="swatch dark-text" style="background:#FFFFFF"><div class="name">0</div><div class="hex">#FFFFFF</div></div>
<div class="swatch dark-text" style="background:#FAFAFB"><div class="name">50</div><div class="hex">#FAFAFB</div></div>
<div class="swatch dark-text" style="background:#F3F2F5"><div class="name">100</div><div class="hex">#F3F2F5</div></div>
<div class="swatch dark-text" style="background:#E8E6EC"><div class="name">200</div><div class="hex">#E8E6EC</div></div>
<div class="swatch dark-text" style="background:#D6D3DC"><div class="name">300</div><div class="hex">#D6D3DC</div></div>
<div class="swatch dark-text" style="background:#B5B1BD"><div class="name">400</div><div class="hex">#B5B1BD</div></div>
<div class="swatch light-text" style="background:#8C8893"><div class="name">500</div><div class="hex">#8C8893</div></div>
<div class="swatch light-text" style="background:#6A666F"><div class="name">600</div><div class="hex">#6A666F</div></div>
<div class="swatch light-text" style="background:#4A464E"><div class="name">700</div><div class="hex">#4A464E</div></div>
<div class="swatch light-text" style="background:#2E2C32"><div class="name">800</div><div class="hex">#2E2C32</div></div>
<div class="swatch light-text" style="background:#1A181E"><div class="name">900</div><div class="hex">#1A181E</div></div>
</div>
<div class="mono">slight violet tint — bg=50, bg-card=0, fg=900, fg-muted=600</div>
</div>
</body></html>
@@ -0,0 +1,19 @@
<!doctype html><html lang="en"><head><meta charset="utf-8"><title>Semantic colors</title>
<link rel="stylesheet" href="_preview.css"></head>
<body>
<div class="card-root">
<div class="label">Semantic · status &amp; feedback</div>
<div class="row">
<div class="swatch light-text" style="background:#2AA876"><div class="name">success</div><div class="hex">#2AA876</div></div>
<div class="swatch light-text" style="background:#D9534F"><div class="name">danger</div><div class="hex">#D9534F</div></div>
<div class="swatch dark-text" style="background:#F0AD4E"><div class="name">warning</div><div class="hex">#F0AD4E</div></div>
<div class="swatch light-text" style="background:#3498DB"><div class="name">info</div><div class="hex">#3498DB</div></div>
</div>
<div class="row" style="gap:8px;margin-top:4px">
<span style="font-size:11px;padding:3px 9px;border-radius:999px;background:#D8F0E5;color:#1F7F5A;font-weight:600">● Running</span>
<span style="font-size:11px;padding:3px 9px;border-radius:999px;background:#F8DAD8;color:#B83C38;font-weight:600">● Error</span>
<span style="font-size:11px;padding:3px 9px;border-radius:999px;background:#FCEAD0;color:#A8741F;font-weight:600">● Reasoning</span>
<span style="font-size:11px;padding:3px 9px;border-radius:999px;background:#D8ECF8;color:#1F70A8;font-weight:600">● Model</span>
</div>
</div>
</body></html>
@@ -0,0 +1,16 @@
<!doctype html><html lang="en"><head><meta charset="utf-8"><title>Tool-kind colors</title>
<link rel="stylesheet" href="_preview.css"></head>
<body>
<div class="card-root">
<div class="label">Tool-kind colors · agent activity</div>
<div class="row">
<div class="swatch light-text" style="background:#2AA876"><div class="name">read</div><div class="hex">green</div></div>
<div class="swatch light-text" style="background:#3498DB"><div class="name">edit</div><div class="hex">blue</div></div>
<div class="swatch dark-text" style="background:#F0AD4E"><div class="name">execute</div><div class="hex">orange</div></div>
<div class="swatch light-text" style="background:#8E5BC9"><div class="name">fetch</div><div class="hex">purple</div></div>
<div class="swatch light-text" style="background:#5B6CD9"><div class="name">browser</div><div class="hex">indigo</div></div>
<div class="swatch light-text" style="background:#8C8893"><div class="name">other</div><div class="hex">gray</div></div>
</div>
<div class="mono">preserved verbatim from ToolCallCard.swift — semantic to the product</div>
</div>
</body></html>
@@ -0,0 +1,31 @@
<!doctype html><html lang="en"><head><meta charset="utf-8"><title>Buttons</title>
<link rel="stylesheet" href="_preview.css">
<style>
.btn { font-family:var(--font-sans); font-size:14px; font-weight:500; padding:7px 14px; border-radius:8px; border:1px solid transparent; cursor:pointer; transition:all 120ms var(--ease-smooth); }
.btn-primary { background:var(--accent); color:#fff; }
.btn-primary:hover { background:var(--accent-hover); }
.btn-secondary { background:var(--bg-card); color:var(--fg); border-color:var(--border-strong); }
.btn-secondary:hover { border-color:var(--accent); color:var(--accent-hover); }
.btn-ghost { background:transparent; color:var(--fg); }
.btn-ghost:hover { background:var(--bg-quaternary); }
.btn-danger { background:#fff; color:var(--red-600); border-color:var(--red-500); }
.btn-link { background:transparent; color:var(--accent); padding:6px 0; border:none; }
.btn-sm { font-size:12px; padding:4px 10px; }
</style></head>
<body>
<div class="card-root">
<div class="label">Buttons</div>
<div class="row">
<button class="btn btn-primary">Install Template</button>
<button class="btn btn-secondary">Run Diagnostics…</button>
<button class="btn btn-ghost">Cancel</button>
<button class="btn btn-danger">Delete</button>
<button class="btn btn-link">View All →</button>
</div>
<div class="row" style="margin-top:4px">
<button class="btn btn-primary btn-sm">Add</button>
<button class="btn btn-secondary btn-sm">Export</button>
<button class="btn btn-secondary btn-sm" disabled style="opacity:.4">Configure</button>
</div>
</div>
</body></html>
@@ -0,0 +1,15 @@
<!doctype html><html lang="en"><head><meta charset="utf-8"><title>Chat bubbles</title>
<link rel="stylesheet" href="_preview.css"></head>
<body>
<div class="card-root" style="gap:8px">
<div style="display:flex;justify-content:flex-end">
<div style="background:var(--accent-tint);padding:8px 12px;border-radius:12px;font-size:14px;max-width:70%">What's the status of the cron job?</div>
</div>
<div style="text-align:right;font-size:10px;color:var(--fg-faint);margin-bottom:6px">9:42 AM</div>
<div style="background:var(--bg-quaternary);padding:8px 12px;border-radius:12px;font-size:14px;max-width:80%">
<div style="font-size:11px;color:var(--orange-500);font-weight:600;margin-bottom:4px">▾ Reasoning <span style="color:var(--fg-faint);font-weight:400">(127 tokens)</span></div>
The <span class="scarf-code" style="font-family:var(--font-mono);font-size:12px;background:rgba(0,0,0,.05);padding:1px 5px;border-radius:4px">daily-summary</span> job ran 14 minutes ago and completed successfully.
</div>
<div style="font-size:10px;color:var(--fg-faint);margin-left:4px">284 tokens · stop · 9:42 AM</div>
</div>
</body></html>
@@ -0,0 +1,12 @@
<!doctype html><html lang="en"><head><meta charset="utf-8"><title>Composer</title>
<link rel="stylesheet" href="_preview.css"></head>
<body>
<div class="card-root">
<div style="border-top:1px solid var(--border);padding:10px 12px;display:flex;gap:8px;align-items:flex-end;background:var(--bg-card);border-radius:8px;box-shadow:var(--shadow-sm)">
<div style="opacity:.6;font-size:18px;cursor:pointer"></div>
<div style="flex:1;background:var(--bg-quaternary);border-radius:12px;padding:8px 12px;font-size:14px;color:var(--fg-faint)">Message Hermes…</div>
<div style="font-size:22px;color:var(--accent)"></div>
</div>
<div class="mono">Rich Chat composer · /-menu opens above on slash, Shift+Enter for newline</div>
</div>
</body></html>
@@ -0,0 +1,26 @@
<!doctype html><html lang="en"><head><meta charset="utf-8"><title>Form inputs</title>
<link rel="stylesheet" href="_preview.css">
<style>
.field { display:flex; flex-direction:column; gap:4px; flex:1; }
.field label { font-size:11px; color:var(--fg-muted); font-weight:600; text-transform:uppercase; letter-spacing:.05em; }
.field input, .field select { font-family:var(--font-sans); font-size:14px; padding:6px 10px; border:1px solid var(--border-strong); border-radius:6px; background:var(--bg-card); color:var(--fg); outline:none; transition:all 120ms; }
.field input:focus { border-color:var(--accent); box-shadow:var(--shadow-focus); }
.toggle { width:36px; height:20px; background:var(--accent); border-radius:999px; position:relative; cursor:pointer; }
.toggle::after { content:''; position:absolute; right:2px; top:2px; width:16px; height:16px; background:#fff; border-radius:50%; box-shadow:0 1px 2px rgba(0,0,0,.2); }
.toggle.off { background:var(--gray-300); }
.toggle.off::after { right:auto; left:2px; }
</style></head>
<body>
<div class="card-root">
<div class="row" style="gap:14px;align-items:flex-end">
<div class="field"><label>Project Name</label><input value="hermes-blog"/></div>
<div class="field"><label>Strategy</label><select><option>round_robin</option></select></div>
</div>
<div class="row" style="gap:18px">
<div class="row" style="gap:8px"><div class="toggle"></div><span style="font-size:13px">Auto-update</span></div>
<div class="row" style="gap:8px"><div class="toggle off"></div><span style="font-size:13px">Pause cron</span></div>
<div class="row" style="gap:8px;font-size:13px"><input type="checkbox" checked style="accent-color:var(--accent)"/>Verified</div>
<div class="row" style="gap:8px;font-size:13px"><input type="radio" checked style="accent-color:var(--accent)"/>Local</div>
</div>
</div>
</body></html>
@@ -0,0 +1,25 @@
<!doctype html><html lang="en"><head><meta charset="utf-8"><title>Sidebar</title>
<link rel="stylesheet" href="_preview.css">
<style>
.sb { width:220px; background:var(--bg-quaternary); border-radius:10px; padding:10px 8px; font-size:13px; }
.sb-title { font-size:10px; color:var(--fg-muted); font-weight:600; text-transform:uppercase; letter-spacing:.06em; padding:6px 8px 4px }
.sb-item { display:flex; align-items:center; gap:8px; padding:5px 8px; border-radius:6px; color:var(--fg); cursor:pointer }
.sb-item:hover { background:var(--bg-tertiary) }
.sb-item.active { background:var(--accent-tint); color:var(--accent-active) }
.sb-icon { width:14px; opacity:.7 }
.sb-item.active .sb-icon { opacity:1 }
</style></head>
<body>
<div class="card-root" style="padding:14px">
<div class="sb">
<div class="sb-title">Monitor</div>
<div class="sb-item"><span class="sb-icon"></span>Dashboard</div>
<div class="sb-item active"><span class="sb-icon">📊</span>Insights</div>
<div class="sb-item"><span class="sb-icon">💬</span>Sessions</div>
<div class="sb-title">Interact</div>
<div class="sb-item"><span class="sb-icon"></span>Chat</div>
<div class="sb-item"><span class="sb-icon"></span>Memory</div>
<div class="sb-item"><span class="sb-icon"></span>Skills</div>
</div>
</div>
</body></html>
@@ -0,0 +1,18 @@
<!doctype html><html lang="en"><head><meta charset="utf-8"><title>Stat cards</title>
<link rel="stylesheet" href="_preview.css">
<style>
.stat { background:var(--bg-quaternary); border-radius:8px; padding:14px 12px; flex:1; min-width:110px; text-align:center; }
.stat .v { font-family:var(--font-mono); font-size:22px; font-weight:600; }
.stat .l { font-size:11px; color:var(--fg-muted); margin-top:2px; }
</style></head>
<body>
<div class="card-root">
<div class="row" style="gap:12px">
<div class="stat"><div class="v">847</div><div class="l">Sessions</div></div>
<div class="stat"><div class="v">12,394</div><div class="l">Messages</div></div>
<div class="stat"><div class="v">3,221</div><div class="l">Tool Calls</div></div>
<div class="stat"><div class="v">2.4M</div><div class="l">Tokens</div></div>
<div class="stat"><div class="v">$42.18</div><div class="l">Cost</div></div>
</div>
</div>
</body></html>
@@ -0,0 +1,19 @@
<!doctype html><html lang="en"><head><meta charset="utf-8"><title>Status cards</title>
<link rel="stylesheet" href="_preview.css">
<style>
.scard { background:var(--bg-quaternary); border-radius:8px; padding:12px; flex:1; min-width:130px; }
.scard .head { display:flex; align-items:center; gap:6px; font-size:11px; color:var(--fg-muted); margin-bottom:4px; }
.scard .dot { width:8px; height:8px; border-radius:50%; }
.scard .val { font-family:var(--font-mono); font-size:14px; font-weight:500; }
</style></head>
<body>
<div class="card-root">
<div class="row" style="gap:12px">
<div class="scard"><div class="head"><span class="dot" style="background:var(--green-500)"></span>Hermes</div><div class="val">Running</div></div>
<div class="scard"><div class="head" style="color:var(--blue-500)">⌬ Model</div><div class="val">claude-sonnet-4.5</div></div>
<div class="scard"><div class="head" style="color:var(--accent)">☁ Provider</div><div class="val">Anthropic</div></div>
<div class="scard"><div class="head"><span class="dot" style="background:var(--green-500)"></span>Gateway</div><div class="val">Connected · 3</div></div>
</div>
<div class="mono">Status cards · 4 across at standard width</div>
</div>
</body></html>
@@ -0,0 +1,31 @@
<!doctype html><html lang="en"><head><meta charset="utf-8"><title>Tool call card</title>
<link rel="stylesheet" href="_preview.css"></head>
<body>
<div class="card-root">
<div style="background:var(--bg-quaternary);border-radius:6px;padding:6px 8px;display:flex;align-items:center;gap:6px;font-size:12px">
<div style="width:3px;height:16px;background:var(--green-500);border-radius:1px"></div>
<span style="color:var(--green-500)">📖</span>
<span style="font-family:var(--font-mono);font-weight:600">read_file</span>
<span style="font-family:var(--font-mono);color:var(--fg-faint);overflow:hidden;text-overflow:ellipsis;white-space:nowrap;flex:1;min-width:0">~/.hermes/config.yaml</span>
<span style="color:var(--green-500)"></span>
<span style="color:var(--fg-faint)"></span>
</div>
<div style="background:var(--bg-quaternary);border-radius:6px;padding:6px 8px;display:flex;align-items:center;gap:6px;font-size:12px">
<div style="width:3px;height:16px;background:var(--orange-500);border-radius:1px"></div>
<span style="color:var(--orange-500)"></span>
<span style="font-family:var(--font-mono);font-weight:600">execute</span>
<span style="font-family:var(--font-mono);color:var(--fg-faint);overflow:hidden;text-overflow:ellipsis;white-space:nowrap;flex:1;min-width:0">{ "cmd": "hermes status" }</span>
<span style="color:var(--green-500)"></span>
<span style="color:var(--fg-faint)"></span>
</div>
<div style="background:var(--bg-quaternary);border-radius:6px;padding:6px 8px;display:flex;align-items:center;gap:6px;font-size:12px">
<div style="width:3px;height:16px;background:var(--blue-500);border-radius:1px"></div>
<span style="color:var(--blue-500)"></span>
<span style="font-family:var(--font-mono);font-weight:600">write_file</span>
<span style="font-family:var(--font-mono);color:var(--fg-faint);flex:1">cron/jobs.json</span>
<div style="width:10px;height:10px;border:1.5px solid var(--fg-faint);border-top-color:transparent;border-radius:50%;animation:spin 1s linear infinite"></div>
<span style="color:var(--fg-faint)"></span>
</div>
<style>@keyframes spin{to{transform:rotate(360deg)}}</style>
</div>
</body></html>
@@ -0,0 +1,24 @@
<!doctype html><html lang="en"><head><meta charset="utf-8"><title>Iconography</title>
<link rel="stylesheet" href="_preview.css">
<script src="https://unpkg.com/lucide@0.469.0/dist/umd/lucide.min.js"></script>
<style>
.ico { display:flex; flex-direction:column; align-items:center; gap:6px; font-size:10px; color:var(--fg-muted); width:64px }
.ico svg { width:22px; height:22px; stroke-width:1.5; color:var(--fg) }
</style></head>
<body>
<div class="card-root">
<div class="label">Iconography · Lucide (web sub for SF Symbols)</div>
<div class="row" style="gap:14px">
<div class="ico"><i data-lucide="layout-grid"></i>Dashboard</div>
<div class="ico"><i data-lucide="bar-chart-3"></i>Insights</div>
<div class="ico"><i data-lucide="messages-square"></i>Sessions</div>
<div class="ico"><i data-lucide="cpu"></i>Model</div>
<div class="ico"><i data-lucide="cloud"></i>Provider</div>
<div class="ico"><i data-lucide="package"></i>Templates</div>
<div class="ico"><i data-lucide="folder"></i>Projects</div>
<div class="ico"><i data-lucide="wrench"></i>Tools</div>
<div class="ico"><i data-lucide="stethoscope"></i>Diagnostics</div>
</div>
<script>lucide.createIcons();</script>
</div>
</body></html>
@@ -0,0 +1,14 @@
<!doctype html><html lang="en"><head><meta charset="utf-8"><title>Radii</title>
<link rel="stylesheet" href="_preview.css"></head>
<body>
<div class="card-root">
<div class="label">Radii · 4 / 6 / 8 / 12 / 14</div>
<div class="row" style="gap:14px;align-items:flex-end">
<div class="col" style="align-items:center;gap:6px"><div style="width:64px;height:64px;background:var(--accent-tint);border:1px solid var(--accent);border-radius:4px"></div><div class="mono">4 · chips, code</div></div>
<div class="col" style="align-items:center;gap:6px"><div style="width:64px;height:64px;background:var(--accent-tint);border:1px solid var(--accent);border-radius:6px"></div><div class="mono">6 · tool cards</div></div>
<div class="col" style="align-items:center;gap:6px"><div style="width:64px;height:64px;background:var(--accent-tint);border:1px solid var(--accent);border-radius:8px"></div><div class="mono">8 · cards, btns</div></div>
<div class="col" style="align-items:center;gap:6px"><div style="width:64px;height:64px;background:var(--accent-tint);border:1px solid var(--accent);border-radius:12px"></div><div class="mono">12 · bubbles</div></div>
<div class="col" style="align-items:center;gap:6px"><div style="width:64px;height:64px;background:var(--accent-tint);border:1px solid var(--accent);border-radius:14px"></div><div class="mono">14 · windows</div></div>
</div>
</div>
</body></html>
@@ -0,0 +1,16 @@
<!doctype html><html lang="en"><head><meta charset="utf-8"><title>Spacing scale</title>
<link rel="stylesheet" href="_preview.css"></head>
<body>
<div class="card-root">
<div class="label">Spacing · 4-base scale</div>
<div class="col" style="gap:6px">
<div class="row" style="gap:10px"><div style="width:4px;height:14px;background:var(--accent)"></div><div class="mono">4 · 1 · inline gaps</div></div>
<div class="row" style="gap:10px"><div style="width:8px;height:14px;background:var(--accent)"></div><div class="mono">8 · 2 · button padding y</div></div>
<div class="row" style="gap:10px"><div style="width:12px;height:14px;background:var(--accent)"></div><div class="mono">12 · 3 · card padding</div></div>
<div class="row" style="gap:10px"><div style="width:16px;height:14px;background:var(--accent)"></div><div class="mono">16 · 4 · view padding</div></div>
<div class="row" style="gap:10px"><div style="width:20px;height:14px;background:var(--accent)"></div><div class="mono">20 · 5 · section gap</div></div>
<div class="row" style="gap:10px"><div style="width:24px;height:14px;background:var(--accent)"></div><div class="mono">24 · 6 · header gap</div></div>
<div class="row" style="gap:10px"><div style="width:32px;height:14px;background:var(--accent)"></div><div class="mono">32 · 8 · page-level</div></div>
</div>
</div>
</body></html>
@@ -0,0 +1,13 @@
<!doctype html><html lang="en"><head><meta charset="utf-8"><title>Shadows</title>
<link rel="stylesheet" href="_preview.css"></head>
<body>
<div class="card-root" style="background:var(--bg)">
<div class="label">Shadows · two-layer Apple style</div>
<div class="row" style="gap:24px;padding:12px 4px">
<div class="col" style="align-items:center;gap:8px"><div style="width:120px;height:60px;background:var(--bg-card);border-radius:8px;box-shadow:0 1px 2px rgba(28,26,32,.05)"></div><div class="mono">sm · subtle lift</div></div>
<div class="col" style="align-items:center;gap:8px"><div style="width:120px;height:60px;background:var(--bg-card);border-radius:8px;box-shadow:0 1px 2px rgba(28,26,32,.04),0 4px 12px rgba(28,26,32,.04)"></div><div class="mono">md · cards</div></div>
<div class="col" style="align-items:center;gap:8px"><div style="width:120px;height:60px;background:var(--bg-card);border-radius:8px;box-shadow:0 2px 4px rgba(28,26,32,.06),0 8px 24px rgba(28,26,32,.07)"></div><div class="mono">lg · hover</div></div>
<div class="col" style="align-items:center;gap:8px"><div style="width:120px;height:60px;background:var(--bg-card);border-radius:8px;box-shadow:0 4px 8px rgba(28,26,32,.08),0 16px 40px rgba(28,26,32,.10)"></div><div class="mono">xl · sheet</div></div>
</div>
</div>
</body></html>
+11
View File
@@ -0,0 +1,11 @@
<!doctype html><html lang="en"><head><meta charset="utf-8"><title>Type · body</title>
<link rel="stylesheet" href="_preview.css"></head>
<body>
<div class="card-root" style="gap:10px">
<div class="label">Body · sentence case, calm and direct</div>
<div style="font-size:17px;font-weight:600">Hermes actually knows what project it's in</div>
<div style="font-size:15px;color:var(--fg-muted)">Every project-scoped chat gets a Scarf-managed block auto-injected into the project's <span class="scarf-code" style="font-family:var(--font-mono);font-size:13px">AGENTS.md</span> before the session starts.</div>
<div style="font-size:14px">Ask the agent <em>"what project am I in?"</em> and it answers with the project name, directory, template id, and registered cron jobs.</div>
<div style="font-size:12px;color:var(--fg-muted)">headline 17 · subhead 15 · body 14 · caption 12 — same rhythm as SwiftUI's text styles</div>
</div>
</body></html>
@@ -0,0 +1,11 @@
<!doctype html><html lang="en"><head><meta charset="utf-8"><title>Type · display</title>
<link rel="stylesheet" href="_preview.css"></head>
<body>
<div class="card-root" style="gap:14px">
<div class="label">Display · SF Pro Display / Inter</div>
<div style="font-family:var(--font-display);font-size:34px;font-weight:600;letter-spacing:-0.02em;line-height:1.15">Make the complex simple</div>
<div style="font-family:var(--font-display);font-size:28px;font-weight:600;letter-spacing:-0.015em;line-height:1.2">Recent sessions</div>
<div style="font-family:var(--font-display);font-size:22px;font-weight:600;letter-spacing:-0.01em">Activity patterns</div>
<div class="mono">largeTitle 34 / title1 28 / title2 22 — used for view titles only</div>
</div>
</body></html>
+15
View File
@@ -0,0 +1,15 @@
<!doctype html><html lang="en"><head><meta charset="utf-8"><title>Type · mono</title>
<link rel="stylesheet" href="_preview.css"></head>
<body>
<div class="card-root" style="gap:10px">
<div class="label">Mono · SF Mono / JetBrains Mono</div>
<div style="font-family:var(--font-mono);font-size:14px;font-weight:500">claude-haiku-4-5</div>
<div style="font-family:var(--font-mono);font-size:13px;color:var(--fg-muted)">~/.hermes/state.db · 14.2 MB</div>
<div style="font-family:var(--font-mono);font-size:12px">{ "tokens": 2384, "model": "claude-haiku-4-5" }</div>
<div class="row" style="gap:6px">
<span style="font-family:var(--font-mono);font-size:11px;background:var(--bg-quaternary);padding:2px 8px;border-radius:4px">v2.3.0</span>
<span style="font-family:var(--font-mono);font-size:11px;background:var(--bg-quaternary);padding:2px 8px;border-radius:4px">2,847 tokens</span>
<span style="font-family:var(--font-mono);font-size:11px;background:var(--bg-quaternary);padding:2px 8px;border-radius:4px">$0.0421</span>
</div>
</div>
</body></html>
+98
View File
@@ -0,0 +1,98 @@
// Activity — chronological feed of everything that happened recently across
// all projects, sessions, cron, and tools. Day-grouped, filterable.
const ACTIVITY_GROUPS = [
{ day: 'Today', items: [
{ time: '09:42', icon: 'message-square', tone: 'accent', title: 'Sera — chat session resumed', sub: 'Forge · 14 turns · refactored CronRunner', proj: 'sera' },
{ time: '09:30', icon: 'clock', tone: 'green', title: 'incident-triage ran', sub: 'cron · ok in 4.2s · 0 issues created', proj: '—' },
{ time: '09:00', icon: 'clock', tone: 'green', title: 'daily-summary ran', sub: 'cron · ok in 36s · posted to #standup', proj: '—' },
{ time: '08:42', icon: 'git-pull-request', tone: 'blue', title: 'PR #284 opened', sub: 'sera · "Switch to AbortController for cron timeouts"', proj: 'sera' },
{ time: '08:14', icon: 'shield', tone: 'amber', title: 'Approval: execute git push origin main', sub: 'sera · approved by Aurora · 3.2s wait', proj: 'sera' },
]},
{ day: 'Yesterday', items: [
{ time: '17:22', icon: 'check-circle', tone: 'green', title: 'release-notes generated', sub: 'cron · ok in 1m 03s · draft saved', proj: '—' },
{ time: '15:08', icon: 'plug', tone: 'accent', title: 'MCP server connected — Figma', sub: '6 tools, 2 prompts available', proj: '—' },
{ time: '14:31', icon: 'message-square', tone: 'accent', title: 'Hermes — onboarding draft', sub: '8 turns · drafted welcome email', proj: 'hermes' },
{ time: '11:02', icon: 'alert-triangle', tone: 'red', title: 'Tool denied — rm -rf node_modules', sub: 'sera · matched deny rule "rm -rf"', proj: 'sera' },
{ time: '09:00', icon: 'clock', tone: 'green', title: 'daily-summary ran', sub: 'cron · ok in 41s', proj: '—' },
]},
{ day: 'Mon, Apr 21', items: [
{ time: '16:48', icon: 'user-plus', tone: 'accent', title: 'New personality — Atlas', sub: 'Created by Aurora · long-form writing model', proj: '—' },
{ time: '14:00', icon: 'database', tone: 'blue', title: 'Postgres (prod, ro) reconfigured', sub: 'switched to read replica', proj: '—' },
{ time: '09:00', icon: 'clock', tone: 'red', title: 'daily-summary failed', sub: 'cron · github 502 bad gateway · retried ok at 09:14', proj: '—' },
]},
];
const ACT_TONES = {
accent: { bg: 'var(--accent-tint)', fg: 'var(--accent)' },
green: { bg: 'var(--green-100)', fg: 'var(--green-600)' },
blue: { bg: 'var(--blue-100)', fg: 'var(--blue-500)' },
amber: { bg: 'var(--orange-100)', fg: 'var(--orange-500)' },
red: { bg: 'var(--red-100)', fg: 'var(--red-500)' },
};
function Activity() {
const [filter, setFilter] = React.useState('all');
React.useEffect(() => { requestAnimationFrame(() => window.lucide && window.lucide.createIcons()); });
return (
<div style={{ display: 'flex', flexDirection: 'column', height: '100%' }}>
<ContentHeader title="Activity"
subtitle="Everything Scarf has done recently — sessions, cron, tools, MCP, approvals"
actions={<Btn icon="filter">Filter</Btn>}
right={
<Segmented value={filter} onChange={setFilter} size="sm" options={[
{ value: 'all', label: 'All' },
{ value: 'sessions', label: 'Sessions' },
{ value: 'cron', label: 'Cron' },
{ value: 'tools', label: 'Tools' },
]} />
} />
<div style={{ flex: 1, overflowY: 'auto', padding: '24px 32px' }}>
{ACTIVITY_GROUPS.map(g => (
<div key={g.day} style={{ marginBottom: 28 }}>
<div style={{
fontSize: 11, fontWeight: 600, color: 'var(--fg-muted)',
textTransform: 'uppercase', letterSpacing: '0.06em', marginBottom: 8,
padding: '0 4px',
}}>{g.day}</div>
<div style={{
background: 'var(--bg-card)', border: '0.5px solid var(--border)',
borderRadius: 10, overflow: 'hidden',
}}>
{g.items.map((it, i) => <ActivityRow key={i} it={it} last={i === g.items.length - 1} />)}
</div>
</div>
))}
</div>
</div>
);
}
function ActivityRow({ it, last }) {
const tone = ACT_TONES[it.tone];
const [hover, setHover] = React.useState(false);
return (
<div onMouseEnter={() => setHover(true)} onMouseLeave={() => setHover(false)} style={{
display: 'flex', alignItems: 'center', gap: 12, padding: '12px 16px',
borderBottom: last ? 'none' : '0.5px solid var(--border)',
background: hover ? 'var(--bg-quaternary)' : 'transparent', cursor: 'pointer',
}}>
<span style={{ fontSize: 11, fontFamily: 'var(--font-mono)', color: 'var(--fg-faint)', width: 44 }}>{it.time}</span>
<div style={{
width: 26, height: 26, borderRadius: 6, background: tone.bg, color: tone.fg,
display: 'flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0,
}}>
<i data-lucide={it.icon} style={{ width: 14, height: 14 }}></i>
</div>
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ fontSize: 13, fontWeight: 500 }}>{it.title}</div>
<div style={{ fontSize: 11.5, color: 'var(--fg-muted)', marginTop: 1 }}>{it.sub}</div>
</div>
{it.proj !== '—' && <Pill size="sm">{it.proj}</Pill>}
<i data-lucide="chevron-right" style={{ width: 14, height: 14, color: 'var(--fg-faint)' }}></i>
</div>
);
}
window.Activity = Activity;
+787
View File
@@ -0,0 +1,787 @@
// Chat — three-pane: session list / transcript / inspector.
// Inspector defaults to ToolCall details for the focused tool call; falls
// back to session-level metadata. Transcript supports reasoning, multi-step
// tool calls, file diffs, and a slash-command palette in the composer.
const TOOL_TONES = {
read: { color: 'var(--green-500)', tint: 'var(--green-100)', icon: 'book-open', label: 'Read' },
edit: { color: 'var(--blue-500)', tint: 'var(--blue-100)', icon: 'file-edit', label: 'Edit' },
execute: { color: 'var(--orange-500)', tint: 'var(--orange-100)', icon: 'terminal', label: 'Execute' },
fetch: { color: 'var(--purple-tool-500)', tint: '#EFE0F8', icon: 'globe', label: 'Fetch' },
browser: { color: 'var(--indigo-500)', tint: '#E0E5F8', icon: 'compass', label: 'Browser' },
search: { color: 'var(--accent)', tint: 'var(--accent-tint)',icon: 'search', label: 'Search' },
};
// ─────────────── Top-level Chat ───────────────
function Chat() {
const [active, setActive] = React.useState('s1');
const [focused, setFocused] = React.useState({ kind: 'tool', id: 'tc-2' }); // inspector subject
const [composerOpen, setComposerOpen] = React.useState(false); // slash menu
React.useEffect(() => {
requestAnimationFrame(() => window.lucide && window.lucide.createIcons());
});
const sessions = [
{ id: 's1', title: 'Cron diagnostics', project: 'scarf', preview: 'The daily-summary job ran 14 minutes ago…', time: '14m', model: 'sonnet-4.5', unread: 0, pinned: true, status: 'live' },
{ id: 's2', title: 'Release notes draft', project: 'hermes-blog', preview: 'Pulled the merged PRs from this week…', time: '42m', model: 'haiku-4.5', unread: 2, status: 'idle' },
{ id: 's3', title: 'PR review summary', project: 'hermes-blog', preview: 'Three PRs are ready for review.', time: '2h', model: 'sonnet-4.5', status: 'idle' },
{ id: 's4', title: 'Function calling models', project: '—', preview: 'Sonnet handles structured tool use…', time: '3h', model: 'haiku-4.5', status: 'idle' },
{ id: 's5', title: 'Memory layout question', project: 'scarf', preview: 'The shared memory keys live at…', time: 'yesterday', model: 'sonnet-4.5', status: 'idle' },
{ id: 's6', title: 'Catalog publish flow', project: 'hermes-blog', preview: 'Walked through the .scarftemplate bundle…', time: 'yesterday', model: 'sonnet-4.5', status: 'idle' },
{ id: 's7', title: 'SSH tunnel debug', project: 'scarf-remote', preview: 'Connection drops after ~90s of idle…', time: 'Mon', model: 'sonnet-4.5', status: 'error' },
];
return (
<div style={{ display: 'flex', height: '100%', overflow: 'hidden' }}>
<ChatList sessions={sessions} active={active} setActive={setActive} />
<Transcript focused={focused} setFocused={setFocused} composerOpen={composerOpen} setComposerOpen={setComposerOpen} />
<Inspector focused={focused} setFocused={setFocused} />
</div>
);
}
// ─────────────── Pane 1 — session list ───────────────
function ChatList({ sessions, active, setActive }) {
const [filter, setFilter] = React.useState('all');
return (
<div style={{
width: 264, borderRight: '0.5px solid var(--border)',
background: 'var(--gray-50)', display: 'flex', flexDirection: 'column'
}}>
<div style={{ padding: '14px 14px 8px', display: 'flex', alignItems: 'center', gap: 8 }}>
<div style={{ flex: 1, fontFamily: 'var(--font-display)', fontSize: 17, fontWeight: 600 }}>Chats</div>
<IconBtn icon="search" tooltip="Search ⌘F" />
<Btn size="sm" kind="primary" icon="plus">New</Btn>
</div>
<div style={{ padding: '0 12px 8px' }}>
<Segmented value={filter} onChange={setFilter} size="sm" options={[
{ value: 'all', label: 'All', count: sessions.length },
{ value: 'live', label: 'Live', count: 1 },
{ value: 'pinned', label: 'Pinned', count: 1 },
]} />
</div>
<div style={{ flex: 1, overflowY: 'auto', padding: '0 6px 8px' }}>
<SessionGroupHeader>Today</SessionGroupHeader>
{sessions.slice(0, 4).map(s => <SessionRow key={s.id} s={s} active={active === s.id} onClick={() => setActive(s.id)} />)}
<SessionGroupHeader>Earlier</SessionGroupHeader>
{sessions.slice(4).map(s => <SessionRow key={s.id} s={s} active={active === s.id} onClick={() => setActive(s.id)} />)}
</div>
<div style={{ padding: '8px 14px', borderTop: '0.5px solid var(--border)',
display: 'flex', alignItems: 'center', gap: 8, fontSize: 11, color: 'var(--fg-muted)' }}>
<i data-lucide="message-square" style={{ width: 12, height: 12 }}></i>
<span>{sessions.length} chats</span>
<span style={{ marginLeft: 'auto', fontFamily: 'var(--font-mono)', fontSize: 10 }}>1.2 MB · state.db</span>
</div>
</div>
);
}
function SessionGroupHeader({ children }) {
return (
<div style={{
padding: '10px 10px 4px', fontSize: 10, fontWeight: 600,
color: 'var(--fg-muted)', textTransform: 'uppercase', letterSpacing: '0.06em',
}}>{children}</div>
);
}
function SessionRow({ s, active, onClick }) {
const [hover, setHover] = React.useState(false);
const statusColor = s.status === 'live' ? 'var(--green-500)' : s.status === 'error' ? 'var(--red-500)' : 'var(--gray-400)';
return (
<div onClick={onClick} onMouseEnter={() => setHover(true)} onMouseLeave={() => setHover(false)}
style={{
padding: '8px 10px', borderRadius: 7, cursor: 'pointer', marginBottom: 1,
background: active ? 'var(--accent-tint)' : (hover ? 'var(--bg-quaternary)' : 'transparent'),
position: 'relative',
}}>
<div style={{ display: 'flex', alignItems: 'center', gap: 7 }}>
{s.status === 'live'
? <span style={{ width: 7, height: 7, borderRadius: '50%', background: statusColor,
boxShadow: '0 0 0 2px rgba(42,168,118,0.20)' }}></span>
: <span style={{ width: 6, height: 6, borderRadius: '50%', background: statusColor }}></span>}
{s.pinned && <i data-lucide="pin" style={{ width: 11, height: 11, color: 'var(--accent)' }}></i>}
<div style={{ flex: 1, fontSize: 13, fontWeight: 500,
color: active ? 'var(--accent-active)' : 'var(--fg)',
whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>{s.title}</div>
<div style={{ fontSize: 10, color: 'var(--fg-faint)', fontFamily: 'var(--font-mono)' }}>{s.time}</div>
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 6, marginTop: 4, paddingLeft: 14 }}>
{s.project !== '—' && <span style={{
fontSize: 10, fontWeight: 500, color: 'var(--fg-muted)', fontFamily: 'var(--font-mono)',
background: 'var(--bg-card)', border: '0.5px solid var(--border)',
padding: '0 5px', borderRadius: 4,
}}>{s.project}</span>}
<div style={{ flex: 1, fontSize: 11, color: 'var(--fg-muted)',
whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>{s.preview}</div>
{s.unread > 0 && <span style={{
fontSize: 9, fontWeight: 700, fontFamily: 'var(--font-mono)',
padding: '1px 5px', borderRadius: 999, background: 'var(--accent)', color: '#fff', minWidth: 14, textAlign: 'center',
}}>{s.unread}</span>}
</div>
</div>
);
}
// ─────────────── Pane 2 — transcript ───────────────
function Transcript({ focused, setFocused, composerOpen, setComposerOpen }) {
return (
<div style={{ flex: 1, display: 'flex', flexDirection: 'column', minWidth: 0,
background: 'var(--bg)' }}>
<TranscriptHeader />
<div style={{ flex: 1, overflowY: 'auto', padding: '20px 28px 8px',
display: 'flex', flexDirection: 'column', gap: 16, scrollBehavior: 'smooth' }}>
<DateMarker>Today · 9:42 AM</DateMarker>
<UserMsg time="9:42 AM">What's the status of the daily-summary cron job? I need to know if it's healthy before I push the new schedule changes.</UserMsg>
<AssistantMsg time="9:42 AM" tokens={284} model="sonnet-4.5" durationMs={2140}>
<Reasoning tokens={127} preview="Check the registry first, then the most recent execution." />
<ToolCall id="tc-1" kind="read" name="read_file" arg="~/.scarf/cron/jobs.json" duration="86 ms" focus={focused} setFocus={setFocused} />
<ToolCall id="tc-2" kind="execute" name="execute" arg='hermes cron status daily-summary' duration="1.4 s" focus={focused} setFocus={setFocused} expanded />
<p style={msgPara}>
The <code style={inlineCode}>daily-summary</code> job ran <strong>14 minutes ago</strong> and completed
successfully in 14.2 s, using 1,847 tokens. Next run is scheduled for tomorrow at 09:00 safe to ship the schedule changes.
</p>
<MsgFooter />
</AssistantMsg>
<UserMsg time="9:43 AM">Show me what it produced.</UserMsg>
<AssistantMsg time="9:43 AM" tokens={612} model="sonnet-4.5" inProgress durationMs={4280}>
<ToolCall id="tc-3" kind="read" name="read_file" arg="~/.scarf/cron/output/daily-summary.md" duration="42 ms" focus={focused} setFocus={setFocused} />
<p style={msgPara}>The latest summary covers <strong>April 24, 2026</strong>. Highlights:</p>
<ul style={{ ...msgPara, paddingLeft: 18, margin: '4px 0' }}>
<li>3 PRs merged across <code style={inlineCode}>hermes</code> and <code style={inlineCode}>scarf</code></li>
<li>2 cron failures auto-recovered (gateway timeouts)</li>
<li>Token spend down 8% week-over-week</li>
</ul>
<ToolCall id="tc-4" kind="edit" name="apply_patch" arg="~/.scarf/cron/jobs.json" duration="120 ms" diff focus={focused} setFocus={setFocused} />
</AssistantMsg>
<SuggestedReplies items={['Schedule a dry run', 'Show last 5 runs', 'Disable daily-summary']} />
</div>
<Composer open={composerOpen} setOpen={setComposerOpen} />
</div>
);
}
function TranscriptHeader() {
return (
<div style={{
padding: '14px 24px', borderBottom: '0.5px solid var(--border)',
display: 'flex', alignItems: 'center', gap: 12, background: 'var(--bg-card)',
}}>
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<i data-lucide="pin" style={{ width: 13, height: 13, color: 'var(--accent)' }}></i>
<div style={{ fontSize: 14, fontWeight: 600 }}>Cron diagnostics</div>
<Pill tone="green" dot size="sm">live</Pill>
</div>
<div style={{ fontSize: 11, color: 'var(--fg-muted)', display: 'flex', gap: 10, marginTop: 3, alignItems: 'center', flexWrap: 'wrap' }}>
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 4 }}>
<i data-lucide="folder" style={{ width: 11, height: 11, color: 'var(--accent)' }}></i>
<span style={{ color: 'var(--accent)', fontWeight: 600 }}>scarf</span>
</span>
<span style={{ color: 'var(--fg-faint)' }}>·</span>
<span style={{ fontFamily: 'var(--font-mono)' }}>claude-sonnet-4.5</span>
<span style={{ color: 'var(--fg-faint)' }}>·</span>
<span>14 messages</span>
<span style={{ color: 'var(--fg-faint)' }}>·</span>
<span style={{ fontFamily: 'var(--font-mono)' }}>12,847 tok</span>
<span style={{ color: 'var(--fg-faint)' }}>·</span>
<span style={{ fontFamily: 'var(--font-mono)' }}>$0.0421</span>
</div>
</div>
<Btn size="sm" kind="ghost" icon="git-branch">Branch</Btn>
<Btn size="sm" kind="secondary" icon="share">Share</Btn>
<IconBtn icon="more-horizontal" tooltip="More" />
</div>
);
}
function DateMarker({ children }) {
return (
<div style={{ display: 'flex', alignItems: 'center', gap: 10, color: 'var(--fg-faint)' }}>
<div style={{ flex: 1, height: 1, background: 'var(--border)' }}></div>
<span style={{ fontSize: 10, fontWeight: 600, textTransform: 'uppercase', letterSpacing: '0.06em' }}>{children}</span>
<div style={{ flex: 1, height: 1, background: 'var(--border)' }}></div>
</div>
);
}
const msgPara = { fontSize: 14, lineHeight: 1.55, color: 'var(--fg)', margin: '6px 0' };
const inlineCode = { fontFamily: 'var(--font-mono)', fontSize: 12.5,
background: 'var(--bg-quaternary)', padding: '1px 5px', borderRadius: 4 };
function UserMsg({ time, children }) {
return (
<div style={{ display: 'flex', justifyContent: 'flex-end', flexDirection: 'column', alignItems: 'flex-end' }}>
<div style={{
maxWidth: '76%', padding: '10px 14px', borderRadius: 14, borderBottomRightRadius: 4,
background: 'var(--accent)', color: 'var(--on-accent)', fontSize: 14, lineHeight: 1.5,
boxShadow: '0 1px 0 rgba(0,0,0,0.06)',
}}>{children}</div>
<div style={{ fontSize: 10, color: 'var(--fg-faint)', marginTop: 4, marginRight: 4,
display: 'flex', gap: 6, alignItems: 'center' }}>
<i data-lucide="check-check" style={{ width: 11, height: 11, color: 'var(--green-500)' }}></i>
<span>{time}</span>
</div>
</div>
);
}
function AssistantMsg({ time, tokens, model, inProgress, durationMs, children }) {
return (
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'flex-start', maxWidth: '88%', position: 'relative' }}>
<div style={{ display: 'flex', alignItems: 'flex-start', gap: 10, width: '100%' }}>
<div style={{
width: 26, height: 26, borderRadius: 7, marginTop: 2, flexShrink: 0,
background: 'var(--gradient-brand)',
display: 'flex', alignItems: 'center', justifyContent: 'center', color: '#fff',
boxShadow: '0 1px 2px rgba(122, 46, 20, 0.25)',
}}>
<i data-lucide="sparkles" style={{ width: 14, height: 14 }}></i>
</div>
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{
background: 'var(--bg-card)', borderRadius: 12,
border: '0.5px solid var(--border)',
padding: '12px 14px', boxShadow: 'var(--shadow-sm)',
}}>{children}</div>
<div style={{ fontSize: 10, color: 'var(--fg-faint)', marginTop: 4, marginLeft: 4,
display: 'flex', gap: 8, alignItems: 'center', flexWrap: 'wrap' }}>
{inProgress && <span style={{ display: 'inline-flex', alignItems: 'center', gap: 4 }}>
<span style={{
width: 7, height: 7, borderRadius: '50%', background: 'var(--accent)',
animation: 'pulseScarf 1.4s ease-in-out infinite',
}}></span>
<span style={{ color: 'var(--accent)', fontWeight: 600 }}>thinking</span>
</span>}
<span style={{ fontFamily: 'var(--font-mono)' }}>{model}</span>
<span>·</span>
<span style={{ fontFamily: 'var(--font-mono)' }}>{tokens} tok</span>
<span>·</span>
<span>{(durationMs / 1000).toFixed(1)}s</span>
<span>·</span>
<span>{time}</span>
</div>
</div>
</div>
</div>
);
}
function MsgFooter() {
const Btnn = ({ icon, label }) => {
const [hover, setHover] = React.useState(false);
return (
<button onMouseEnter={() => setHover(true)} onMouseLeave={() => setHover(false)} style={{
padding: '3px 7px', fontSize: 11, color: hover ? 'var(--fg)' : 'var(--fg-muted)',
background: hover ? 'var(--bg-quaternary)' : 'transparent',
border: 'none', borderRadius: 5, cursor: 'pointer',
display: 'inline-flex', alignItems: 'center', gap: 4, fontFamily: 'var(--font-sans)',
}}>
<i data-lucide={icon} style={{ width: 11, height: 11 }}></i>{label}
</button>
);
};
return (
<div style={{ display: 'flex', gap: 2, marginTop: 6, paddingTop: 6, borderTop: '0.5px solid var(--border)' }}>
<Btnn icon="copy" label="Copy" />
<Btnn icon="thumbs-up" label="" />
<Btnn icon="thumbs-down" label="" />
<Btnn icon="rotate-cw" label="Retry" />
<div style={{ flex: 1 }}></div>
<Btnn icon="pin" label="Pin" />
</div>
);
}
// ─────────────── Reasoning disclosure ───────────────
function Reasoning({ tokens, preview, children }) {
const [open, setOpen] = React.useState(false);
return (
<div style={{ marginBottom: 8, background: 'var(--orange-100)', borderRadius: 7,
padding: '6px 10px', border: '0.5px solid rgba(240, 173, 78, 0.3)' }}>
<div onClick={() => setOpen(!open)} style={{
cursor: 'pointer', fontSize: 11, fontWeight: 600,
display: 'flex', alignItems: 'center', gap: 5, color: '#A8741F',
}}>
<i data-lucide="brain" style={{ width: 12, height: 12 }}></i>
<span style={{ textTransform: 'uppercase', letterSpacing: '0.04em' }}>Reasoning</span>
<span style={{ color: 'var(--fg-faint)', fontWeight: 500, fontFamily: 'var(--font-mono)' }}>· {tokens} tok</span>
<span style={{ flex: 1 }}></span>
<i data-lucide={open ? 'chevron-down' : 'chevron-right'} style={{ width: 12, height: 12 }}></i>
</div>
{!open && preview && (
<div style={{ fontSize: 12, color: 'var(--fg-muted)', marginTop: 3,
fontStyle: 'italic', lineHeight: 1.5,
whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>{preview}</div>
)}
{open && (
<div style={{ fontSize: 12.5, color: 'var(--fg-muted)', lineHeight: 1.55,
padding: '6px 0 2px', fontStyle: 'italic' }}>
The user wants the status of a specific cron job named "daily-summary".
I should check the cron registry first, then look at the most recent execution
via <code style={inlineCode}>hermes cron status</code>. If exit_code is 0,
the job is healthy and the schedule push is safe.
</div>
)}
</div>
);
}
// ─────────────── ToolCall card ───────────────
function ToolCall({ id, kind, name, arg, duration, expanded: initial, diff, focus, setFocus }) {
const [open, setOpen] = React.useState(initial || false);
const t = TOOL_TONES[kind] || TOOL_TONES.read;
const isFocused = focus.kind === 'tool' && focus.id === id;
return (
<div style={{ marginBottom: 5 }}>
<div onClick={() => { setOpen(!open); setFocus({ kind: 'tool', id }); }} style={{
background: isFocused ? t.tint : 'var(--bg-quaternary)',
border: `0.5px solid ${isFocused ? t.color : 'var(--border)'}`,
outline: isFocused ? `1px solid ${t.color}` : 'none', outlineOffset: '-1px',
borderRadius: 7, padding: '6px 10px',
display: 'flex', alignItems: 'center', gap: 9,
fontSize: 12, cursor: 'pointer', transition: 'all 120ms',
}}>
<div style={{ display: 'flex', alignItems: 'center', gap: 5 }}>
<i data-lucide={t.icon} style={{ width: 12, height: 12, color: t.color }}></i>
<span style={{ fontSize: 10, fontWeight: 700, color: t.color,
textTransform: 'uppercase', letterSpacing: '0.04em' }}>{t.label}</span>
</div>
<span style={{ fontFamily: 'var(--font-mono)', fontWeight: 600, color: 'var(--fg)' }}>{name}</span>
<span style={{ fontFamily: 'var(--font-mono)', color: 'var(--fg-muted)', flex: 1, minWidth: 0,
whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>{arg}</span>
<span style={{ fontFamily: 'var(--font-mono)', fontSize: 10, color: 'var(--fg-faint)' }}>{duration}</span>
<i data-lucide="check-circle-2" style={{ width: 13, height: 13, color: 'var(--green-500)' }}></i>
<i data-lucide={open ? 'chevron-down' : 'chevron-right'} style={{ width: 12, height: 12, color: 'var(--fg-faint)' }}></i>
</div>
{open && (
diff
? <DiffPreview />
: <ToolOutput kind={kind} />
)}
</div>
);
}
function ToolOutput({ kind }) {
if (kind === 'execute') {
return (
<div style={{
background: 'var(--gray-900)', color: '#E8E1D2', borderRadius: 7,
padding: '10px 12px', fontFamily: 'var(--font-mono)', fontSize: 11.5,
marginTop: 6, lineHeight: 1.55, overflow: 'auto',
border: '1px solid var(--gray-800)',
}}>
<div><span style={{ color: '#7A7367' }}>$</span> <span style={{ color: '#EFC59E' }}>hermes</span> cron status daily-summary</div>
<div style={{ marginTop: 4 }}>
<span style={{ color: '#2AA876' }}></span> <span style={{ color: '#A39C92' }}>last_run</span>: <span>2026-04-25T09:28:14Z</span><br/>
<span style={{ color: '#2AA876' }}></span> <span style={{ color: '#A39C92' }}>duration</span>: <span>14.2s</span><br/>
<span style={{ color: '#2AA876' }}></span> <span style={{ color: '#A39C92' }}>exit_code</span>: <span>0</span><br/>
<span style={{ color: '#2AA876' }}></span> <span style={{ color: '#A39C92' }}>tokens_used</span>: <span>1,847</span><br/>
<span style={{ color: '#A39C92' }}>next_run</span>: <span>2026-04-26T09:00:00Z</span>
</div>
</div>
);
}
// read
return (
<div style={{
background: 'var(--bg-card)', borderRadius: 7,
padding: '8px 12px', fontFamily: 'var(--font-mono)', fontSize: 11.5,
marginTop: 6, lineHeight: 1.6, color: 'var(--fg-muted)',
border: '0.5px solid var(--border)', maxHeight: 120, overflow: 'auto',
}}>
<div><span style={{ color: 'var(--fg-faint)' }}>1</span> &#123;</div>
<div><span style={{ color: 'var(--fg-faint)' }}>2</span> "name": "daily-summary",</div>
<div><span style={{ color: 'var(--fg-faint)' }}>3</span> "schedule": "0 9 * * *",</div>
<div><span style={{ color: 'var(--fg-faint)' }}>4</span> "enabled": true</div>
<div><span style={{ color: 'var(--fg-faint)' }}>5</span> &#125;</div>
</div>
);
}
function DiffPreview() {
return (
<div style={{
background: 'var(--bg-card)', borderRadius: 7,
padding: '8px 12px', fontFamily: 'var(--font-mono)', fontSize: 11.5,
marginTop: 6, lineHeight: 1.6, color: 'var(--fg)',
border: '0.5px solid var(--border)',
}}>
<div><span style={{ color: 'var(--fg-faint)', display: 'inline-block', width: 22 }}>3</span><span> "schedule": "0 9 * * *",</span></div>
<div style={{ background: 'rgba(217, 83, 79, 0.10)' }}>
<span style={{ color: 'var(--red-600)', display: 'inline-block', width: 22 }}>-</span>
<span> "timezone": "UTC",</span>
</div>
<div style={{ background: 'rgba(42, 168, 118, 0.10)' }}>
<span style={{ color: 'var(--green-600)', display: 'inline-block', width: 22 }}>+</span>
<span> "timezone": "America/New_York",</span>
</div>
<div><span style={{ color: 'var(--fg-faint)', display: 'inline-block', width: 22 }}>5</span><span> "enabled": true</span></div>
</div>
);
}
// ─────────────── Suggested replies ───────────────
function SuggestedReplies({ items }) {
return (
<div style={{ display: 'flex', gap: 6, flexWrap: 'wrap', marginTop: 4, paddingLeft: 36 }}>
{items.map(s => (
<button key={s} style={{
fontSize: 12, padding: '5px 10px', borderRadius: 999,
background: 'var(--bg-card)', border: '0.5px solid var(--border-strong)',
color: 'var(--fg)', fontFamily: 'var(--font-sans)', cursor: 'pointer',
display: 'inline-flex', alignItems: 'center', gap: 4,
}}>
<i data-lucide="sparkles" style={{ width: 11, height: 11, color: 'var(--accent)' }}></i>
{s}
</button>
))}
</div>
);
}
// ─────────────── Composer ───────────────
const SLASH_COMMANDS = [
{ cmd: 'compress', desc: 'Compress conversation context', icon: 'minimize-2' },
{ cmd: 'clear', desc: 'Clear and start fresh', icon: 'trash-2' },
{ cmd: 'model', desc: 'Switch model', icon: 'cpu' },
{ cmd: 'project', desc: 'Change project', icon: 'folder' },
{ cmd: 'memory', desc: 'Edit AGENTS.md', icon: 'database' },
{ cmd: 'cost', desc: 'Show token / cost report', icon: 'circle-dollar-sign' },
];
function Composer({ open, setOpen }) {
const [text, setText] = React.useState('');
const onChange = e => {
const v = e.currentTarget.innerText;
setText(v);
setOpen(v.trim().startsWith('/'));
};
return (
<div style={{
borderTop: '0.5px solid var(--border)', padding: '12px 24px 14px',
background: 'var(--bg-card)', position: 'relative',
}}>
{open && (
<div style={{
position: 'absolute', bottom: 'calc(100% - 4px)', left: 24, right: 24,
background: 'var(--bg-card)', border: '0.5px solid var(--border)',
borderRadius: 9, boxShadow: 'var(--shadow-lg)', padding: 4, maxWidth: 360,
}}>
<div style={{ padding: '4px 8px 6px', fontSize: 10, fontWeight: 600,
color: 'var(--fg-muted)', textTransform: 'uppercase', letterSpacing: '0.06em' }}>
Slash commands
</div>
{SLASH_COMMANDS.map((c, i) => (
<div key={c.cmd} style={{
display: 'flex', alignItems: 'center', gap: 9, padding: '6px 8px',
borderRadius: 6, fontSize: 13, cursor: 'pointer',
background: i === 0 ? 'var(--accent-tint)' : 'transparent',
color: i === 0 ? 'var(--accent-active)' : 'var(--fg)',
}}>
<i data-lucide={c.icon} style={{ width: 14, height: 14 }}></i>
<span style={{ fontFamily: 'var(--font-mono)', fontWeight: 600 }}>/{c.cmd}</span>
<span style={{ flex: 1, color: 'var(--fg-muted)', fontSize: 12 }}>{c.desc}</span>
{i === 0 && <KbdKey></KbdKey>}
</div>
))}
</div>
)}
<div style={{
display: 'flex', flexDirection: 'column', gap: 8,
border: `1px solid ${open ? 'var(--accent)' : 'var(--border-strong)'}`,
borderRadius: 12, padding: '10px 12px',
background: 'var(--bg-card)',
boxShadow: open ? 'var(--shadow-focus)' : 'none',
transition: 'box-shadow 120ms, border-color 120ms',
}}>
{/* Attached context chips */}
<div style={{ display: 'flex', gap: 6, flexWrap: 'wrap' }}>
<ContextChip icon="folder" label="scarf" tone="accent" />
<ContextChip icon="file-text" label="cron/jobs.json" />
<ContextChip icon="plus" label="Add context" muted />
</div>
{/* Input */}
<div contentEditable suppressContentEditableWarning onInput={onChange}
style={{
fontSize: 14, fontFamily: 'var(--font-sans)', outline: 'none',
color: 'var(--fg)', padding: '2px 0', minHeight: 22, maxHeight: 160, overflowY: 'auto',
lineHeight: 1.5,
}}
data-placeholder="Message Hermes… / for commands · @ for files"></div>
{/* Footer row */}
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
<ComposerChip icon="paperclip" label="" />
<ComposerChip icon="at-sign" label="@" />
<ComposerChip icon="image" label="" />
<Divider vertical />
<ComposerChip icon="cpu" label="sonnet-4.5" />
<ComposerChip icon="folder" label="scarf" />
<div style={{ flex: 1 }}></div>
<span style={{ fontSize: 11, color: 'var(--fg-faint)', fontFamily: 'var(--font-mono)' }}>
send · newline
</span>
<button style={{
width: 30, height: 30, borderRadius: 8, background: 'var(--accent)',
color: '#fff', border: 'none', cursor: 'pointer',
display: 'flex', alignItems: 'center', justifyContent: 'center',
boxShadow: '0 1px 2px rgba(122, 46, 20, 0.3)',
}}>
<i data-lucide="arrow-up" style={{ width: 15, height: 15 }}></i>
</button>
</div>
</div>
</div>
);
}
function ContextChip({ icon, label, tone, muted }) {
return (
<div style={{
display: 'inline-flex', alignItems: 'center', gap: 5,
padding: '2px 8px', borderRadius: 999, fontSize: 11, fontWeight: 500,
background: tone === 'accent' ? 'var(--accent-tint)' : 'var(--bg-quaternary)',
color: tone === 'accent' ? 'var(--accent-active)' : (muted ? 'var(--fg-muted)' : 'var(--fg)'),
fontFamily: tone === 'accent' ? 'var(--font-sans)' : 'var(--font-mono)',
border: muted ? '0.5px dashed var(--border-strong)' : 'none',
cursor: muted ? 'pointer' : 'default',
}}>
<i data-lucide={icon} style={{ width: 11, height: 11 }}></i>{label}
</div>
);
}
function ComposerChip({ icon, label }) {
const [hover, setHover] = React.useState(false);
return (
<button onMouseEnter={() => setHover(true)} onMouseLeave={() => setHover(false)} style={{
display: 'inline-flex', alignItems: 'center', gap: 4,
padding: label ? '3px 7px' : '4px', borderRadius: 6, fontSize: 12,
background: hover ? 'var(--bg-quaternary)' : 'transparent',
color: 'var(--fg-muted)', border: 'none', cursor: 'pointer',
fontFamily: 'var(--font-mono)',
}}>
<i data-lucide={icon} style={{ width: 13, height: 13 }}></i>{label}
</button>
);
}
// ─────────────── Pane 3 — Inspector ───────────────
function Inspector({ focused }) {
const [tab, setTab] = React.useState('details');
// Find the focused tool call. For demo, hard-code tc-2 details.
const FOCUS_DATA = {
'tc-1': { kind: 'read', name: 'read_file', arg: '~/.scarf/cron/jobs.json',
duration: '86 ms', startedAt: '09:42:18.214', tokens: 412 },
'tc-2': { kind: 'execute', name: 'execute', arg: 'hermes cron status daily-summary',
duration: '1.4 s', startedAt: '09:42:18.302', tokens: 86,
cwd: '~/.scarf', exitCode: 0 },
'tc-3': { kind: 'read', name: 'read_file', arg: '~/.scarf/cron/output/daily-summary.md',
duration: '42 ms', startedAt: '09:43:01.190', tokens: 1284 },
'tc-4': { kind: 'edit', name: 'apply_patch', arg: '~/.scarf/cron/jobs.json',
duration: '120 ms', startedAt: '09:43:03.910', tokens: 88, linesAdded: 1, linesRemoved: 1 },
};
const data = FOCUS_DATA[focused.id] || FOCUS_DATA['tc-2'];
const t = TOOL_TONES[data.kind];
return (
<aside style={{
width: 320, borderLeft: '0.5px solid var(--border)',
background: 'var(--bg-card)', display: 'flex', flexDirection: 'column',
}}>
{/* Header */}
<div style={{ padding: '14px 16px 10px', borderBottom: '0.5px solid var(--border)' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 10 }}>
<div style={{
width: 24, height: 24, borderRadius: 6,
background: t.tint, color: t.color,
display: 'flex', alignItems: 'center', justifyContent: 'center',
}}>
<i data-lucide={t.icon} style={{ width: 13, height: 13 }}></i>
</div>
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ fontSize: 10, fontWeight: 700, color: t.color,
textTransform: 'uppercase', letterSpacing: '0.05em' }}>{t.label} call</div>
<div style={{ fontSize: 13, fontWeight: 600, fontFamily: 'var(--font-mono)',
whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>{data.name}</div>
</div>
<IconBtn icon="x" tooltip="Close inspector" />
</div>
<Tabs value={tab} onChange={setTab} options={[
{ value: 'details', label: 'Details', icon: 'info' },
{ value: 'output', label: 'Output', icon: 'terminal' },
{ value: 'raw', label: 'Raw', icon: 'braces' },
]} />
</div>
<div style={{ flex: 1, overflowY: 'auto', padding: 16 }}>
{tab === 'details' && <InspectorDetails data={data} t={t} />}
{tab === 'output' && <InspectorOutput data={data} t={t} />}
{tab === 'raw' && <InspectorRaw data={data} />}
</div>
{/* Footer */}
<div style={{ padding: '10px 16px', borderTop: '0.5px solid var(--border)',
display: 'flex', gap: 6 }}>
<Btn size="sm" kind="secondary" icon="rotate-cw" fullWidth>Re-run</Btn>
<Btn size="sm" kind="ghost" icon="copy">Copy</Btn>
</div>
</aside>
);
}
function InspectorDetails({ data, t }) {
return (
<div>
<Section title="Status">
<div style={{ display: 'flex', alignItems: 'center', gap: 8, padding: '8px 10px',
background: 'var(--green-100)', borderRadius: 7,
border: '0.5px solid rgba(42, 168, 118, 0.25)' }}>
<i data-lucide="check-circle-2" style={{ width: 16, height: 16, color: 'var(--green-600)' }}></i>
<div style={{ flex: 1 }}>
<div style={{ fontSize: 12, fontWeight: 600, color: 'var(--green-600)' }}>Completed</div>
<div style={{ fontSize: 11, color: 'var(--fg-muted)' }}>Exit 0 · No errors</div>
</div>
</div>
</Section>
<div style={{ marginTop: 18 }}>
<Section title="Arguments">
<div style={{
background: 'var(--bg-quaternary)', borderRadius: 7, padding: '8px 10px',
fontFamily: 'var(--font-mono)', fontSize: 11.5, lineHeight: 1.5,
color: 'var(--fg)', wordBreak: 'break-all',
}}>{data.arg}</div>
</Section>
</div>
<div style={{ marginTop: 18 }}>
<Section title="Telemetry">
<KV k="Started" v={data.startedAt} mono />
<KV k="Duration" v={data.duration} mono />
<KV k="Tokens" v={data.tokens.toLocaleString()} mono />
{data.exitCode != null && <KV k="Exit code" v={data.exitCode} mono color="var(--green-600)" />}
{data.cwd && <KV k="CWD" v={data.cwd} mono />}
{data.linesAdded != null && (
<KV k="Diff" v={
<span style={{ fontFamily: 'var(--font-mono)' }}>
<span style={{ color: 'var(--green-600)' }}>+{data.linesAdded}</span>
<span style={{ color: 'var(--fg-faint)' }}> / </span>
<span style={{ color: 'var(--red-600)' }}>{data.linesRemoved}</span>
</span>
} />
)}
</Section>
</div>
<div style={{ marginTop: 18 }}>
<Section title="Permissions" hint="Tool gateway policy applied at run time">
<div style={{
background: 'var(--bg-quaternary)', borderRadius: 7, padding: '10px',
fontSize: 12, color: 'var(--fg-muted)', display: 'flex', flexDirection: 'column', gap: 6,
}}>
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
<i data-lucide="shield-check" style={{ width: 13, height: 13, color: 'var(--green-500)' }}></i>
<span>Allowed by <code style={inlineCode}>scarf-default</code> profile</span>
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
<i data-lucide="check" style={{ width: 13, height: 13, color: 'var(--green-500)' }}></i>
<span>No human approval required</span>
</div>
</div>
</Section>
</div>
</div>
);
}
function InspectorOutput({ data, t }) {
return (
<div>
<Section title="stdout" right={<KbdKey>C</KbdKey>}>
<div style={{
background: 'var(--gray-900)', color: '#E8E1D2', borderRadius: 7,
padding: '10px 12px', fontFamily: 'var(--font-mono)', fontSize: 11,
lineHeight: 1.6, overflow: 'auto',
}}>
<div><span style={{ color: '#7A7367' }}>$</span> <span style={{ color: '#EFC59E' }}>hermes</span> cron status daily-summary</div>
<div style={{ marginTop: 6 }}>
<span style={{ color: '#2AA876' }}></span> last_run: 2026-04-25T09:28:14Z<br/>
<span style={{ color: '#2AA876' }}></span> duration: 14.2s<br/>
<span style={{ color: '#2AA876' }}></span> exit_code: 0<br/>
<span style={{ color: '#2AA876' }}></span> tokens_used: 1,847<br/>
next_run: 2026-04-26T09:00:00Z<br/>
schedule: 0 9 * * *<br/>
timezone: America/New_York
</div>
</div>
</Section>
<div style={{ marginTop: 16 }}>
<Section title="stderr">
<div style={{ background: 'var(--bg-quaternary)', borderRadius: 7, padding: '10px',
fontFamily: 'var(--font-mono)', fontSize: 11.5, color: 'var(--fg-faint)' }}>
(empty)
</div>
</Section>
</div>
</div>
);
}
function InspectorRaw({ data }) {
return (
<div style={{
background: 'var(--gray-900)', color: '#E8E1D2', borderRadius: 7,
padding: '12px', fontFamily: 'var(--font-mono)', fontSize: 11,
lineHeight: 1.55,
}}>
{`{
"id": "${data.kind === 'execute' ? 'tc-2' : 'tc-x'}",
"type": "tool_use",
"name": "${data.name}",
"input": {
"command": "hermes cron status daily-summary",
"cwd": "~/.scarf"
},
"result": {
"exit_code": 0,
"duration_ms": 1402,
"stdout_bytes": 287
}
}`}
</div>
);
}
function KV({ k, v, mono, color }) {
return (
<div style={{ display: 'flex', alignItems: 'center', padding: '5px 0',
borderBottom: '0.5px solid var(--border)' }}>
<span style={{ fontSize: 12, color: 'var(--fg-muted)', flex: '0 0 90px' }}>{k}</span>
<span style={{
fontSize: 12, color: color || 'var(--fg)',
fontFamily: mono ? 'var(--font-mono)' : 'var(--font-sans)', flex: 1, textAlign: 'right',
}}>{v}</span>
</div>
);
}
window.Chat = Chat;
+550
View File
@@ -0,0 +1,550 @@
// Scarf v2 shared components — calmer density, full state matrices.
// Exports to window: Btn, IconBtn, Pill, Dot, Card, StatCard, Section, ContentHeader,
// Field, TextInput, NumberInput, TextArea, Toggle, Checkbox, Radio, RadioGroup,
// Segmented, Select, SettingsGroup, SettingsRow, Tabs, Menu, MenuItem, Divider,
// EmptyState, KbdKey, HelpIcon, Tooltip, Avatar, ProgressBar, Spinner.
const SF = "var(--font-sans)";
// ─────────────── ContentHeader ───────────────
function ContentHeader({ title, subtitle, actions, right, breadcrumb }) {
return (
<div style={{
padding: '24px 32px 22px',
borderBottom: '0.5px solid var(--border)',
background: 'var(--bg-card)',
}}>
{breadcrumb && (
<div style={{ fontSize: 12, color: 'var(--fg-muted)', marginBottom: 6 }}>{breadcrumb}</div>
)}
<div style={{ display: 'flex', alignItems: 'flex-end', gap: 16 }}>
<div style={{ flex: 1 }}>
<div className="scarf-h2" style={{ marginBottom: subtitle ? 6 : 0 }}>{title}</div>
{subtitle && <div style={{ fontSize: 14, color: 'var(--fg-muted)', maxWidth: 600 }}>{subtitle}</div>}
</div>
{right}
{actions && <div style={{ display: 'flex', gap: 8 }}>{actions}</div>}
</div>
</div>
);
}
// ─────────────── Buttons ───────────────
function Btn({ kind = 'secondary', size = 'md', icon, iconRight, children, onClick, disabled, loading, fullWidth, type = 'button' }) {
const sizes = {
sm: { padding: '5px 11px', fontSize: 12, gap: 5, iconSize: 13 },
md: { padding: '7px 14px', fontSize: 13, gap: 6, iconSize: 14 },
lg: { padding: '10px 18px', fontSize: 14, gap: 7, iconSize: 16 },
};
const kinds = {
primary: { background: 'var(--accent)', color: 'var(--on-accent)', border: '1px solid transparent', shadow: '0 1px 0 rgba(0,0,0,0.08), inset 0 1px 0 rgba(255,255,255,0.18)' },
secondary: { background: 'var(--bg-card)', color: 'var(--fg)', border: '1px solid var(--border-strong)', shadow: 'var(--shadow-sm)' },
ghost: { background: 'transparent', color: 'var(--fg)', border: '1px solid transparent' },
danger: { background: 'var(--bg-card)', color: 'var(--red-600)', border: '1px solid var(--red-500)' },
'danger-solid': { background: 'var(--red-500)', color: '#fff', border: '1px solid transparent' },
accent: { background: 'var(--accent-tint)', color: 'var(--accent-active)', border: '1px solid transparent' },
};
const s = sizes[size];
const k = kinds[kind];
const [hover, setHover] = React.useState(false);
const hoverStyle = !disabled && hover ? {
primary: { background: 'var(--accent-hover)' },
secondary: { background: 'var(--gray-50)', borderColor: 'var(--accent)' },
ghost: { background: 'var(--bg-quaternary)' },
danger: { background: 'var(--red-100)' },
'danger-solid': { background: 'var(--red-600)' },
accent: { background: 'var(--accent-tint-strong)' },
}[kind] : {};
return (
<button type={type} onClick={onClick} disabled={disabled || loading}
onMouseEnter={() => setHover(true)} onMouseLeave={() => setHover(false)}
style={{
padding: s.padding, fontSize: s.fontSize, gap: s.gap,
...k, ...hoverStyle, boxShadow: k.shadow,
borderRadius: 8, fontFamily: SF, fontWeight: 500,
display: fullWidth ? 'flex' : 'inline-flex', alignItems: 'center', justifyContent: 'center',
cursor: (disabled || loading) ? 'default' : 'pointer',
opacity: disabled ? 0.45 : 1,
width: fullWidth ? '100%' : 'auto',
transition: 'all 120ms var(--ease-smooth)',
whiteSpace: 'nowrap', userSelect: 'none',
}}>
{loading
? <Spinner size={s.iconSize} color={kind === 'primary' ? 'rgba(255,255,255,0.7)' : 'currentColor'} />
: icon && <i data-lucide={icon} style={{ width: s.iconSize, height: s.iconSize }}></i>}
{children}
{iconRight && <i data-lucide={iconRight} style={{ width: s.iconSize, height: s.iconSize, opacity: 0.7 }}></i>}
</button>
);
}
function IconBtn({ icon, onClick, size = 28, tooltip, active, disabled }) {
const [hover, setHover] = React.useState(false);
return (
<button onClick={onClick} disabled={disabled} title={tooltip}
onMouseEnter={() => setHover(true)} onMouseLeave={() => setHover(false)}
style={{
width: size, height: size, padding: 0, borderRadius: 7,
background: active ? 'var(--accent-tint)' : (hover && !disabled ? 'var(--bg-quaternary)' : 'transparent'),
color: active ? 'var(--accent-active)' : 'var(--fg-muted)',
border: 'none', cursor: disabled ? 'default' : 'pointer',
display: 'inline-flex', alignItems: 'center', justifyContent: 'center',
opacity: disabled ? 0.45 : 1, transition: 'background 120ms',
}}>
<i data-lucide={icon} style={{ width: Math.round(size * 0.55), height: Math.round(size * 0.55) }}></i>
</button>
);
}
function Spinner({ size = 14, color = 'currentColor' }) {
return (
<span style={{
display: 'inline-block', width: size, height: size,
border: `2px solid transparent`, borderTopColor: color, borderRightColor: color,
borderRadius: '50%', animation: 'scarfSpin 0.8s linear infinite',
}}></span>
);
}
// ─────────────── Pills / Dots ───────────────
function Pill({ tone = 'gray', dot, icon, children, size = 'md' }) {
const tones = {
gray: { bg: 'var(--bg-quaternary)', fg: 'var(--fg-muted)', dotc: 'var(--gray-500)' },
green: { bg: 'var(--green-100)', fg: 'var(--green-600)', dotc: 'var(--green-500)' },
red: { bg: 'var(--red-100)', fg: 'var(--red-600)', dotc: 'var(--red-500)' },
orange: { bg: 'var(--orange-100)', fg: '#A8741F', dotc: 'var(--orange-500)' },
blue: { bg: 'var(--blue-100)', fg: '#1F70A8', dotc: 'var(--blue-500)' },
accent: { bg: 'var(--accent-tint)', fg: 'var(--accent-active)', dotc: 'var(--accent)' },
amber: { bg: 'var(--orange-100)', fg: '#A8741F', dotc: 'var(--orange-500)' },
purple: { bg: '#EFE0F8', fg: '#5E4080', dotc: '#7E5BA9' },
idle: { bg: 'var(--bg-quaternary)', fg: 'var(--fg-faint)', dotc: 'var(--gray-400)' },
};
const t = tones[tone];
const sizes = { sm: { p: '2px 7px', f: 10 }, md: { p: '3px 9px', f: 11 }, lg: { p: '4px 11px', f: 12 } };
const sz = sizes[size];
return (
<span style={{
display: 'inline-flex', alignItems: 'center', gap: 5,
fontSize: sz.f, fontWeight: 600, padding: sz.p, borderRadius: 999,
background: t.bg, color: t.fg, fontFamily: SF, lineHeight: 1.4,
}}>
{dot && <span style={{ width: 6, height: 6, borderRadius: '50%', background: t.dotc }}></span>}
{icon && <i data-lucide={icon} style={{ width: 11, height: 11 }}></i>}
{children}
</span>
);
}
function Dot({ tone = 'gray', size = 8 }) {
const tones = { gray: 'var(--gray-400)', green: 'var(--green-500)', red: 'var(--red-500)',
orange: 'var(--orange-500)', blue: 'var(--blue-500)', accent: 'var(--accent)' };
return <span style={{ width: size, height: size, borderRadius: '50%',
background: tones[tone], display: 'inline-block', flexShrink: 0 }}></span>;
}
// ─────────────── Cards / Sections ───────────────
function Card({ children, padding = 18, style = {}, onClick, interactive }) {
return (
<div onClick={onClick} style={{
background: 'var(--bg-card)', borderRadius: 10,
border: '0.5px solid var(--border)',
boxShadow: 'var(--shadow-sm)',
padding, cursor: onClick || interactive ? 'pointer' : 'default',
transition: 'all 160ms var(--ease-smooth)',
...style,
}}>{children}</div>
);
}
function StatCard({ label, value, sub, accent, icon }) {
return (
<Card padding={16} style={{ flex: 1, minWidth: 0 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 6, fontSize: 11,
color: 'var(--fg-muted)', fontWeight: 600, marginBottom: 8,
textTransform: 'uppercase', letterSpacing: '0.05em' }}>
{icon && <i data-lucide={icon} style={{ width: 12, height: 12 }}></i>}
{label}
</div>
<div style={{ fontFamily: 'var(--font-mono)', fontSize: 24, fontWeight: 600,
color: accent || 'var(--fg)', letterSpacing: '-0.01em', lineHeight: 1.1 }}>{value}</div>
{sub && <div style={{ fontSize: 11, color: 'var(--fg-faint)', marginTop: 6 }}>{sub}</div>}
</Card>
);
}
function Section({ title, hint, right, children, gap = 12 }) {
return (
<div>
<div style={{ display: 'flex', alignItems: 'baseline', marginBottom: gap, gap: 10 }}>
<div style={{ fontSize: 11, fontWeight: 600, color: 'var(--fg-muted)',
textTransform: 'uppercase', letterSpacing: '0.06em' }}>{title}</div>
{hint && <div style={{ fontSize: 12, color: 'var(--fg-faint)' }}>{hint}</div>}
<div style={{ marginLeft: 'auto' }}>{right}</div>
</div>
{children}
</div>
);
}
function Divider({ vertical, label }) {
if (vertical) return <div style={{ width: 1, alignSelf: 'stretch', background: 'var(--border)' }}></div>;
if (label) return (
<div style={{ display: 'flex', alignItems: 'center', gap: 10, color: 'var(--fg-faint)', margin: '8px 0' }}>
<div style={{ flex: 1, height: 1, background: 'var(--border)' }}></div>
<span style={{ fontSize: 10, fontWeight: 600, textTransform: 'uppercase', letterSpacing: '0.06em' }}>{label}</span>
<div style={{ flex: 1, height: 1, background: 'var(--border)' }}></div>
</div>
);
return <div style={{ height: 1, background: 'var(--border)', margin: '8px 0' }}></div>;
}
// ─────────────── Form fields ───────────────
function Field({ label, hint, error, help, children, required, inline }) {
return (
<label style={{ display: 'flex', flexDirection: inline ? 'row' : 'column',
gap: inline ? 12 : 6, fontFamily: SF, alignItems: inline ? 'center' : 'stretch' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 5,
minWidth: inline ? 140 : 0 }}>
<span style={{ fontSize: 13, color: 'var(--fg)', fontWeight: 500 }}>{label}</span>
{required && <span style={{ color: 'var(--red-500)', fontSize: 11 }}>*</span>}
{help && <HelpIcon text={help} />}
</div>
<div style={{ flex: inline ? 1 : 'none', display: 'flex', flexDirection: 'column', gap: 4 }}>
{children}
{error
? <span style={{ fontSize: 11, color: 'var(--red-600)', display: 'flex', alignItems: 'center', gap: 4 }}>
<i data-lucide="alert-circle" style={{ width: 11, height: 11 }}></i>{error}
</span>
: hint && <span style={{ fontSize: 11, color: 'var(--fg-faint)' }}>{hint}</span>
}
</div>
</label>
);
}
function HelpIcon({ text }) {
return (
<span title={text} style={{
display: 'inline-flex', alignItems: 'center', justifyContent: 'center',
width: 14, height: 14, borderRadius: '50%', background: 'var(--bg-tertiary)',
color: 'var(--fg-muted)', cursor: 'help',
}}>
<i data-lucide="help-circle" style={{ width: 11, height: 11 }}></i>
</span>
);
}
function inputStyle(invalid) {
return {
fontFamily: SF, fontSize: 13, padding: '7px 11px',
border: `1px solid ${invalid ? 'var(--red-500)' : 'var(--border-strong)'}`,
borderRadius: 7, background: 'var(--bg-card)', color: 'var(--fg)',
outline: 'none', transition: 'all 120ms', width: '100%', boxSizing: 'border-box',
};
}
function TextInput({ value, onChange, placeholder, mono, invalid, leftIcon, rightSlot, type = 'text' }) {
const [v, setV] = React.useState(value ?? '');
React.useEffect(() => setV(value ?? ''), [value]);
const ref = React.useRef();
return (
<div style={{ position: 'relative', display: 'flex', alignItems: 'center' }}>
{leftIcon && <i data-lucide={leftIcon} style={{
position: 'absolute', left: 10, width: 14, height: 14, color: 'var(--fg-faint)', pointerEvents: 'none'
}}></i>}
<input ref={ref} type={type} value={v}
onChange={e => { setV(e.target.value); onChange && onChange(e.target.value); }}
placeholder={placeholder}
style={{ ...inputStyle(invalid),
fontFamily: mono ? 'var(--font-mono)' : SF,
paddingLeft: leftIcon ? 32 : 11,
paddingRight: rightSlot ? 36 : 11,
}}
onFocus={e => { if (!invalid) { e.target.style.borderColor = 'var(--accent)'; e.target.style.boxShadow = 'var(--shadow-focus)'; }}}
onBlur={e => { e.target.style.borderColor = invalid ? 'var(--red-500)' : 'var(--border-strong)'; e.target.style.boxShadow = 'none'; }}
/>
{rightSlot && <div style={{ position: 'absolute', right: 6 }}>{rightSlot}</div>}
</div>
);
}
function TextArea({ value, onChange, placeholder, rows = 3, invalid, mono }) {
const [v, setV] = React.useState(value ?? '');
React.useEffect(() => setV(value ?? ''), [value]);
return (
<textarea value={v} rows={rows} placeholder={placeholder}
onChange={e => { setV(e.target.value); onChange && onChange(e.target.value); }}
style={{ ...inputStyle(invalid), resize: 'vertical', lineHeight: 1.45,
fontFamily: mono ? 'var(--font-mono)' : SF }}
onFocus={e => { if (!invalid) { e.target.style.borderColor = 'var(--accent)'; e.target.style.boxShadow = 'var(--shadow-focus)'; }}}
onBlur={e => { e.target.style.borderColor = invalid ? 'var(--red-500)' : 'var(--border-strong)'; e.target.style.boxShadow = 'none'; }}
/>
);
}
function Select({ value, onChange, options }) {
const [v, setV] = React.useState(value ?? options?.[0]?.value ?? '');
React.useEffect(() => setV(value ?? ''), [value]);
return (
<div style={{ position: 'relative', display: 'flex' }}>
<select value={v} onChange={e => { setV(e.target.value); onChange && onChange(e.target.value); }}
style={{ ...inputStyle(), appearance: 'none', paddingRight: 30, cursor: 'pointer' }}>
{options.map(o => <option key={o.value} value={o.value}>{o.label}</option>)}
</select>
<i data-lucide="chevrons-up-down" style={{
position: 'absolute', right: 10, top: '50%', transform: 'translateY(-50%)',
width: 13, height: 13, color: 'var(--fg-muted)', pointerEvents: 'none',
}}></i>
</div>
);
}
// ─────────────── Toggle / Checkbox / Radio ───────────────
function Toggle({ on, onChange, size = 'md', disabled }) {
const sizes = { sm: { w: 28, h: 16, p: 12 }, md: { w: 36, h: 20, p: 16 }, lg: { w: 44, h: 24, p: 20 } };
const s = sizes[size];
return (
<div onClick={() => !disabled && onChange && onChange(!on)} style={{
width: s.w, height: s.h, borderRadius: 999, position: 'relative',
cursor: disabled ? 'default' : 'pointer', flexShrink: 0,
background: on ? 'var(--accent)' : 'var(--gray-300)',
transition: 'background 180ms var(--ease-smooth)',
opacity: disabled ? 0.5 : 1,
}}>
<div style={{
position: 'absolute', top: 2, left: on ? (s.w - s.p - 2) : 2,
width: s.p, height: s.p, borderRadius: '50%', background: '#fff',
boxShadow: '0 1px 3px rgba(0,0,0,0.18), 0 1px 1px rgba(0,0,0,0.06)',
transition: 'left 180ms var(--ease-smooth)',
}}></div>
</div>
);
}
function Checkbox({ checked, onChange, indeterminate, disabled }) {
return (
<div onClick={() => !disabled && onChange && onChange(!checked)} style={{
width: 16, height: 16, borderRadius: 4,
background: checked || indeterminate ? 'var(--accent)' : 'var(--bg-card)',
border: `1px solid ${checked || indeterminate ? 'var(--accent)' : 'var(--border-strong)'}`,
cursor: disabled ? 'default' : 'pointer', flexShrink: 0,
display: 'flex', alignItems: 'center', justifyContent: 'center',
transition: 'all 120ms', opacity: disabled ? 0.5 : 1,
}}>
{checked && <i data-lucide="check" style={{ width: 12, height: 12, color: '#fff', strokeWidth: 3 }}></i>}
{indeterminate && !checked && <div style={{ width: 8, height: 2, background: '#fff', borderRadius: 1 }}></div>}
</div>
);
}
function Radio({ checked, onChange, disabled }) {
return (
<div onClick={() => !disabled && onChange && onChange(true)} style={{
width: 16, height: 16, borderRadius: '50%',
background: 'var(--bg-card)',
border: `1px solid ${checked ? 'var(--accent)' : 'var(--border-strong)'}`,
cursor: disabled ? 'default' : 'pointer', flexShrink: 0,
display: 'flex', alignItems: 'center', justifyContent: 'center',
transition: 'all 120ms', opacity: disabled ? 0.5 : 1,
}}>
{checked && <div style={{ width: 7, height: 7, borderRadius: '50%', background: 'var(--accent)' }}></div>}
</div>
);
}
// ─────────────── Segmented / Tabs ───────────────
function Segmented({ value, onChange, options, size = 'md' }) {
const padding = size === 'sm' ? '4px 10px' : '6px 14px';
const fontSize = size === 'sm' ? 12 : 13;
return (
<div style={{
display: 'inline-flex', padding: 2, borderRadius: 8,
background: 'var(--bg-quaternary)', border: '0.5px solid var(--border)',
}}>
{options.map(o => {
const active = value === o.value;
return (
<button key={o.value} onClick={() => onChange && onChange(o.value)} style={{
padding, fontSize, fontWeight: active ? 600 : 500, fontFamily: SF,
background: active ? 'var(--bg-card)' : 'transparent',
color: active ? 'var(--fg)' : 'var(--fg-muted)',
border: 'none', borderRadius: 6, cursor: 'pointer',
boxShadow: active ? 'var(--shadow-sm)' : 'none',
transition: 'all 120ms var(--ease-smooth)', display: 'inline-flex', alignItems: 'center', gap: 5,
}}>
{o.icon && <i data-lucide={o.icon} style={{ width: 12, height: 12 }}></i>}
{o.label}
{o.count != null && <span style={{
fontSize: 10, fontFamily: 'var(--font-mono)',
padding: '1px 6px', borderRadius: 999,
background: active ? 'var(--accent-tint)' : 'var(--bg-tertiary)',
color: active ? 'var(--accent-active)' : 'var(--fg-muted)',
}}>{o.count}</span>}
</button>
);
})}
</div>
);
}
function Tabs({ value, onChange, options }) {
return (
<div style={{ display: 'flex', gap: 2, borderBottom: '0.5px solid var(--border)' }}>
{options.map(o => {
const active = value === o.value;
return (
<button key={o.value} onClick={() => onChange && onChange(o.value)} style={{
padding: '10px 14px', fontSize: 13, fontWeight: 500, fontFamily: SF,
background: 'transparent', border: 'none',
color: active ? 'var(--fg)' : 'var(--fg-muted)',
borderBottom: `2px solid ${active ? 'var(--accent)' : 'transparent'}`,
marginBottom: -1, cursor: 'pointer', display: 'inline-flex', alignItems: 'center', gap: 6,
transition: 'color 120ms',
}}>
{o.icon && <i data-lucide={o.icon} style={{ width: 13, height: 13 }}></i>}
{o.label}
{o.count != null && <span style={{
fontSize: 10, fontFamily: 'var(--font-mono)',
padding: '1px 6px', borderRadius: 999,
background: 'var(--bg-tertiary)', color: 'var(--fg-muted)',
}}>{o.count}</span>}
</button>
);
})}
</div>
);
}
// ─────────────── Settings groups (card-rows) ───────────────
function SettingsGroup({ title, description, children }) {
return (
<div style={{ marginBottom: 28 }}>
{title && <div style={{ marginBottom: 10 }}>
<div style={{ fontSize: 13, fontWeight: 600 }}>{title}</div>
{description && <div style={{ fontSize: 12, color: 'var(--fg-muted)', marginTop: 2 }}>{description}</div>}
</div>}
<div style={{
background: 'var(--bg-card)', border: '0.5px solid var(--border)',
borderRadius: 10, overflow: 'hidden',
}}>{children}</div>
</div>
);
}
function SettingsRow({ title, description, control, icon, last }) {
return (
<div style={{
display: 'flex', alignItems: 'center', gap: 14, padding: '14px 18px',
borderBottom: last ? 'none' : '0.5px solid var(--border)',
}}>
{icon && <div style={{
width: 32, height: 32, borderRadius: 7, background: 'var(--accent-tint)',
display: 'flex', alignItems: 'center', justifyContent: 'center', color: 'var(--accent)', flexShrink: 0,
}}><i data-lucide={icon} style={{ width: 16, height: 16 }}></i></div>}
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ fontSize: 13, fontWeight: 500 }}>{title}</div>
{description && <div style={{ fontSize: 12, color: 'var(--fg-muted)', marginTop: 2 }}>{description}</div>}
</div>
<div style={{ flexShrink: 0 }}>{control}</div>
</div>
);
}
// ─────────────── Menu / dropdown ───────────────
function Menu({ children, anchor = 'bottom-left', style = {} }) {
const positions = {
'bottom-left': { top: '100%', left: 0, marginTop: 4 },
'bottom-right': { top: '100%', right: 0, marginTop: 4 },
'top-left': { bottom: '100%', left: 0, marginBottom: 4 },
};
return (
<div style={{
position: 'absolute', zIndex: 200, ...positions[anchor],
minWidth: 200, padding: 4, background: 'var(--bg-card)',
border: '0.5px solid var(--border)', borderRadius: 9,
boxShadow: 'var(--shadow-lg)', fontFamily: SF, ...style,
}}>
{children}
</div>
);
}
function MenuItem({ icon, label, kbd, onClick, danger, selected, children }) {
const [hover, setHover] = React.useState(false);
return (
<div onClick={onClick} onMouseEnter={() => setHover(true)} onMouseLeave={() => setHover(false)}
style={{
display: 'flex', alignItems: 'center', gap: 10, padding: '6px 10px',
borderRadius: 6, cursor: 'pointer', fontSize: 13,
background: hover ? 'var(--accent-tint)' : 'transparent',
color: danger ? 'var(--red-600)' : (hover ? 'var(--accent-active)' : 'var(--fg)'),
}}>
{icon && <i data-lucide={icon} style={{ width: 14, height: 14 }}></i>}
<span style={{ flex: 1 }}>{label || children}</span>
{selected && <i data-lucide="check" style={{ width: 13, height: 13 }}></i>}
{kbd && <KbdKey>{kbd}</KbdKey>}
</div>
);
}
function KbdKey({ children }) {
return <span style={{
fontFamily: 'var(--font-mono)', fontSize: 10,
padding: '1px 5px', borderRadius: 3,
background: 'var(--bg-quaternary)', border: '0.5px solid var(--border)',
color: 'var(--fg-muted)',
}}>{children}</span>;
}
// ─────────────── Avatar ───────────────
function Avatar({ initials, size = 28, color = 'var(--accent)' }) {
return (
<div style={{
width: size, height: size, borderRadius: '50%', background: color,
color: '#fff', display: 'inline-flex', alignItems: 'center', justifyContent: 'center',
fontSize: Math.round(size * 0.4), fontWeight: 600, flexShrink: 0,
}}>{initials}</div>
);
}
// ─────────────── ProgressBar ───────────────
function ProgressBar({ value = 0, color = 'var(--accent)', height = 6 }) {
return (
<div style={{ height, background: 'var(--bg-quaternary)', borderRadius: height / 2, overflow: 'hidden' }}>
<div style={{ width: `${Math.min(100, Math.max(0, value))}%`, height: '100%',
background: color, borderRadius: height / 2, transition: 'width 240ms var(--ease-smooth)' }}></div>
</div>
);
}
// ─────────────── Empty ───────────────
function EmptyState({ icon, title, body, action }) {
return (
<div style={{ flex: 1, display: 'flex', flexDirection: 'column',
alignItems: 'center', justifyContent: 'center', padding: 80, textAlign: 'center', gap: 12 }}>
<div style={{
width: 64, height: 64, borderRadius: 16, background: 'var(--accent-tint)',
display: 'flex', alignItems: 'center', justifyContent: 'center',
color: 'var(--accent)', marginBottom: 4,
}}>
<i data-lucide={icon || 'inbox'} style={{ width: 28, height: 28 }}></i>
</div>
<div style={{ fontSize: 17, fontWeight: 600 }}>{title}</div>
<div style={{ fontSize: 13, color: 'var(--fg-muted)', maxWidth: 380, lineHeight: 1.5 }}>{body}</div>
{action && <div style={{ marginTop: 8 }}>{action}</div>}
</div>
);
}
Object.assign(window, {
ContentHeader, Btn, IconBtn, Spinner, Pill, Dot,
Card, StatCard, Section, Divider,
Field, HelpIcon, TextInput, TextArea, Select,
Toggle, Checkbox, Radio,
Segmented, Tabs,
SettingsGroup, SettingsRow,
Menu, MenuItem, KbdKey,
Avatar, ProgressBar, EmptyState,
});
+165
View File
@@ -0,0 +1,165 @@
// Cron — scheduled agent runs, with run history and a calendar heat strip.
const CRON_JOBS = [
{ id: 'daily-summary', name: 'Daily standup summary', schedule: '0 9 * * 1-5', cronText: 'Weekdays at 9:00am', enabled: true,
lastRun: '2h ago', lastStatus: 'ok', avgDuration: '38s', nextRun: 'tomorrow 9:00am',
personality: 'Hermes', desc: 'Read yesterday\'s commits + Linear updates and post a summary to #standup.', runs7d: 5 },
{ id: 'incident-triage', name: 'Incident triage', schedule: '*/15 * * * *', cronText: 'Every 15 minutes', enabled: true,
lastRun: '3m ago', lastStatus: 'ok', avgDuration: '4.2s', nextRun: 'in 12m',
personality: 'Forge', desc: 'Poll Sentry for unresolved high-severity issues and create Linear tickets.', runs7d: 672 },
{ id: 'design-review', name: 'Friday design review prep', schedule: '0 16 * * 4', cronText: 'Thursdays at 4:00pm', enabled: true,
lastRun: 'yesterday', lastStatus: 'ok', avgDuration: '2m 14s', nextRun: 'Thursday 4:00pm',
personality: 'Atlas', desc: 'Collect new Figma frames + recent PRs, draft an agenda for the design review.', runs7d: 1 },
{ id: 'docs-stale', name: 'Find stale docs', schedule: '0 0 * * 0', cronText: 'Sundays at midnight', enabled: false,
lastRun: '8d ago', lastStatus: 'skipped', avgDuration: '47s', nextRun: 'paused',
personality: 'Hermes', desc: 'Scan the docs site for pages not updated in >90 days; open a checklist.', runs7d: 0 },
{ id: 'release-notes', name: 'Draft release notes', schedule: '0 14 * * 5', cronText: 'Fridays at 2:00pm', enabled: true,
lastRun: '6d ago', lastStatus: 'failed', avgDuration: '1m 03s', nextRun: 'Friday 2:00pm',
personality: 'Atlas', desc: 'Walk merged PRs since last tag; group by area; write user-facing release notes.', runs7d: 1 },
];
const RUN_HISTORY = [
{ when: '2h ago', status: 'ok', duration: '36s', ts: '2026-04-25 09:00:14' },
{ when: 'yesterday', status: 'ok', duration: '41s', ts: '2026-04-24 09:00:08' },
{ when: '2d ago', status: 'ok', duration: '38s', ts: '2026-04-23 09:00:11' },
{ when: '3d ago', status: 'ok', duration: '34s', ts: '2026-04-22 09:00:06' },
{ when: '4d ago', status: 'failed', duration: '12s', ts: '2026-04-21 09:00:09', error: 'github: 502 bad gateway' },
{ when: '5d ago', status: 'ok', duration: '40s', ts: '2026-04-18 09:00:12' },
{ when: '6d ago', status: 'ok', duration: '37s', ts: '2026-04-17 09:00:09' },
];
function Cron() {
const [active, setActive] = React.useState('daily-summary');
const job = CRON_JOBS.find(j => j.id === active);
React.useEffect(() => { requestAnimationFrame(() => window.lucide && window.lucide.createIcons()); });
return (
<div style={{ display: 'flex', flexDirection: 'column', height: '100%' }}>
<ContentHeader title="Cron"
subtitle="Scheduled agent runs. Each job invokes a personality with a fixed prompt."
actions={<><Btn icon="calendar">Timezone: PT</Btn><Btn kind="primary" icon="plus">New cron job</Btn></>} />
<div style={{ flex: 1, display: 'flex', minHeight: 0 }}>
<div style={{ width: 360, borderRight: '0.5px solid var(--border)',
display: 'flex', flexDirection: 'column', background: 'var(--bg-card)' }}>
<div style={{ flex: 1, overflowY: 'auto', padding: 8 }}>
{CRON_JOBS.map(j => <CronRow key={j.id} j={j} active={j.id === active} onClick={() => setActive(j.id)} />)}
</div>
</div>
<div style={{ flex: 1, overflowY: 'auto', background: 'var(--bg)', padding: '24px 32px' }}>
<CronDetail job={job} />
</div>
</div>
</div>
);
}
function CronRow({ j, active, onClick }) {
const [hover, setHover] = React.useState(false);
const tone = j.lastStatus === 'failed' ? 'red' : j.lastStatus === 'skipped' ? 'gray' : 'green';
return (
<div onClick={onClick} onMouseEnter={() => setHover(true)} onMouseLeave={() => setHover(false)} style={{
padding: '11px 12px', borderRadius: 7, cursor: 'pointer', marginBottom: 2,
background: active ? 'var(--accent-tint)' : (hover ? 'var(--bg-quaternary)' : 'transparent'),
}}>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 3 }}>
<i data-lucide="clock" style={{ width: 13, height: 13, color: 'var(--fg-muted)', flexShrink: 0 }}></i>
<div style={{ flex: 1, fontSize: 13, fontWeight: 500,
color: active ? 'var(--accent-active)' : 'var(--fg)' }}>{j.name}</div>
{!j.enabled && <Pill tone="gray" size="sm">paused</Pill>}
<Dot tone={tone} />
</div>
<div style={{ display: 'flex', gap: 10, fontSize: 11, color: 'var(--fg-faint)', fontFamily: 'var(--font-mono)' }}>
<span>{j.schedule}</span>
<span style={{ color: 'var(--fg-muted)' }}>· next {j.nextRun}</span>
</div>
</div>
);
}
function CronDetail({ job }) {
return (
<>
<div style={{ display: 'flex', alignItems: 'flex-start', gap: 14, marginBottom: 20 }}>
<div style={{
width: 44, height: 44, borderRadius: 9, background: 'var(--accent-tint)', color: 'var(--accent)',
display: 'flex', alignItems: 'center', justifyContent: 'center',
}}>
<i data-lucide="clock" style={{ width: 22, height: 22 }}></i>
</div>
<div style={{ flex: 1 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 4 }}>
<div className="scarf-h2" style={{ fontSize: 22 }}>{job.name}</div>
{job.enabled ? <Pill tone="green" dot>active</Pill> : <Pill tone="gray" dot>paused</Pill>}
</div>
<div style={{ fontSize: 13, color: 'var(--fg-muted)', maxWidth: 520 }}>{job.desc}</div>
</div>
<div style={{ display: 'flex', gap: 6 }}>
<Btn icon="play">Run now</Btn>
<Toggle on={job.enabled} size="lg" />
</div>
</div>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(4, 1fr)', gap: 10, marginBottom: 24 }}>
<StatCard label="Schedule" value={job.cronText} sub={job.schedule} />
<StatCard label="Last run" value={job.lastRun} sub={job.lastStatus} />
<StatCard label="Avg duration" value={job.avgDuration} />
<StatCard label="Next run" value={job.nextRun} />
</div>
<SettingsGroup title="Schedule">
<SettingsRow icon="calendar" title="Cron expression"
description={`Parsed as: ${job.cronText} (America/Los_Angeles)`}
control={<TextInput value={job.schedule} mono />} />
<SettingsRow icon="globe" title="Timezone"
description="Job triggers fire in this timezone."
control={<Select value="pt" options={[{ value: 'pt', label: 'America/Los_Angeles' }, { value: 'utc', label: 'UTC' }]} />} />
<SettingsRow icon="hourglass" title="Timeout"
description="Kill the run after this duration."
control={<Select value="5m" options={[
{ value: '1m', label: '1 minute' }, { value: '5m', label: '5 minutes' },
{ value: '15m', label: '15 minutes' }, { value: '1h', label: '1 hour' },
]} />} last />
</SettingsGroup>
<SettingsGroup title="Behavior">
<SettingsRow icon="user-circle" title="Personality"
description={`This job runs as "${job.personality}" with its system prompt + tools.`}
control={<Btn size="sm" icon="external-link">{job.personality}</Btn>} />
<SettingsRow icon="message-square" title="Prompt"
description="The instruction sent to the agent at each scheduled run."
control={<Btn size="sm" icon="edit-3">Edit</Btn>} />
<SettingsRow icon="bell" title="Notify on failure"
description="Send a message to #ops if any run errors out."
control={<Toggle on={true} />} last />
</SettingsGroup>
<SettingsGroup title="Run history" description="Last 7 runs.">
{RUN_HISTORY.map((r, i) => <RunRow key={i} r={r} last={i === RUN_HISTORY.length - 1} />)}
</SettingsGroup>
</>
);
}
function RunRow({ r, last }) {
const tone = r.status === 'failed' ? 'red' : r.status === 'skipped' ? 'gray' : 'green';
const icon = r.status === 'failed' ? 'x' : r.status === 'skipped' ? 'minus' : 'check';
return (
<div style={{
display: 'flex', alignItems: 'center', gap: 12, padding: '10px 18px',
borderBottom: last ? 'none' : '0.5px solid var(--border)',
}}>
<Pill tone={tone} size="sm" icon={icon}>{r.status}</Pill>
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ fontSize: 12.5, color: 'var(--fg)' }}>{r.when}
<span style={{ color: 'var(--fg-faint)', fontFamily: 'var(--font-mono)', marginLeft: 8, fontSize: 11 }}>{r.ts}</span>
</div>
{r.error && <div style={{ fontSize: 11, color: 'var(--red-500)', fontFamily: 'var(--font-mono)', marginTop: 2 }}>{r.error}</div>}
</div>
<span style={{ fontFamily: 'var(--font-mono)', fontSize: 11, color: 'var(--fg-muted)', width: 60, textAlign: 'right' }}>{r.duration}</span>
<Btn size="sm">View log</Btn>
</div>
);
}
window.Cron = Cron;
+117
View File
@@ -0,0 +1,117 @@
// Dashboard — first screen. Mirrors the structure: status header,
// quick stats, recent sessions, recent activity.
function Dashboard() {
return (
<div style={{ padding: '0 0 28px', overflow: 'auto' }}>
<ContentHeader title="Dashboard"
subtitle="At-a-glance status of your Hermes agent"
actions={<><Btn icon="rotate-cw">Refresh</Btn><Btn kind="primary" icon="plus">New Session</Btn></>} />
<div style={{ padding: '20px 28px', display: 'flex', flexDirection: 'column', gap: 20 }}>
{/* Status row */}
<div style={{ display: 'flex', gap: 12 }}>
<StatusCard icon="activity" label="Hermes" value="Running" tone="green" sub="3h 14m uptime" />
<StatusCard icon="cpu" label="Model" value="claude-sonnet-4.5" sub="Anthropic" />
<StatusCard icon="cloud" label="Provider" value="Anthropic" sub="us-east-1 · 18ms" />
<StatusCard icon="network" label="Gateway" value="Connected" tone="green" sub="3 platforms" />
</div>
{/* Stats row */}
<Section title="Last 7 days" right={<Btn size="sm" kind="ghost" icon="bar-chart-3">View Insights</Btn>}>
<div style={{ display: 'flex', gap: 12 }}>
<StatCard label="Sessions" value="847" sub="+12% vs prev" />
<StatCard label="Messages" value="12,394" />
<StatCard label="Tool Calls" value="3,221" />
<StatCard label="Tokens" value="2.4M" sub="1.8M in · 0.6M out" />
<StatCard label="Cost" value="$42.18" accent="var(--accent)" />
</div>
</Section>
{/* Two col */}
<div style={{ display: 'grid', gridTemplateColumns: '1.3fr 1fr', gap: 16 }}>
<Section title="Recent sessions" right={<a style={linkStyle}>View all </a>}>
<Card padding={0}>
<RecentSessionRow project="hermes-blog" message="Draft this week's release notes…" model="haiku-4.5" tokens="1,247" time="14m ago" />
<RecentSessionRow project="scarf" message="Implement the cron diagnostics view" model="sonnet-4.5" tokens="8,392" time="42m ago" />
<RecentSessionRow project="hermes-blog" message="Review the open PRs and summarize" model="sonnet-4.5" tokens="4,108" time="2h ago" />
<RecentSessionRow project="—" message="What model handles function calls best?" model="haiku-4.5" tokens="284" time="3h ago" last />
</Card>
</Section>
<Section title="Recent activity" right={<a style={linkStyle}>View all </a>}>
<Card padding={0}>
<DashActivityRow icon="file-edit" tone="blue" text="Edited cron/jobs.json" sub="hermes-blog · session #3a2f" time="14m" />
<DashActivityRow icon="terminal" tone="orange" text="Ran hermes status" sub="3 platforms healthy" time="42m" />
<DashActivityRow icon="git-branch" tone="green" text="Cron daily-summary completed" sub="14.2s · 1,847 tokens" time="2h" />
<DashActivityRow icon="package" tone="purple" text="Installed template hermes-blog" sub="from awizemann/hermes-blog" time="yesterday" last />
</Card>
</Section>
</div>
</div>
</div>
);
}
const linkStyle = { fontSize: 12, color: 'var(--accent)', cursor: 'pointer', textDecoration: 'none' };
function StatusCard({ icon, label, value, sub, tone }) {
const dotColor = tone === 'green' ? 'var(--green-500)' : 'var(--gray-400)';
return (
<Card padding={14} style={{ flex: 1, minWidth: 0 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 6, fontSize: 11,
color: 'var(--fg-muted)', fontWeight: 600, marginBottom: 6 }}>
{tone === 'green'
? <span style={{ width: 7, height: 7, borderRadius: '50%', background: dotColor }}></span>
: <i data-lucide={icon} style={{ width: 12, height: 12 }}></i>
}
<span style={{ textTransform: 'uppercase', letterSpacing: '0.05em' }}>{label}</span>
</div>
<div style={{ fontFamily: 'var(--font-mono)', fontSize: 14, fontWeight: 500,
whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>{value}</div>
{sub && <div style={{ fontSize: 11, color: 'var(--fg-faint)', marginTop: 3 }}>{sub}</div>}
</Card>
);
}
function RecentSessionRow({ project, message, model, tokens, time, last }) {
return (
<div style={{
display: 'flex', alignItems: 'center', gap: 10, padding: '10px 14px',
borderBottom: last ? 'none' : '0.5px solid var(--border)',
cursor: 'pointer', transition: 'background 120ms',
}} onMouseEnter={e => e.currentTarget.style.background = 'var(--bg-quaternary)'}
onMouseLeave={e => e.currentTarget.style.background = 'transparent'}>
<Pill tone="accent">{project}</Pill>
<div style={{ flex: 1, fontSize: 13, color: 'var(--fg)',
whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>{message}</div>
<div style={{ fontFamily: 'var(--font-mono)', fontSize: 11, color: 'var(--fg-faint)' }}>{model}</div>
<div style={{ fontFamily: 'var(--font-mono)', fontSize: 11, color: 'var(--fg-faint)', width: 70, textAlign: 'right' }}>{tokens}</div>
<div style={{ fontSize: 11, color: 'var(--fg-faint)', width: 60, textAlign: 'right' }}>{time}</div>
</div>
);
}
function DashActivityRow({ icon, tone, text, sub, time, last }) {
const tones = { green: 'var(--green-500)', blue: 'var(--blue-500)', orange: 'var(--orange-500)', purple: 'var(--accent)' };
return (
<div style={{
display: 'flex', alignItems: 'flex-start', gap: 10, padding: '10px 14px',
borderBottom: last ? 'none' : '0.5px solid var(--border)',
}}>
<div style={{
width: 22, height: 22, borderRadius: 5, background: 'var(--bg-quaternary)',
display: 'flex', alignItems: 'center', justifyContent: 'center', color: tones[tone], flexShrink: 0,
}}>
<i data-lucide={icon} style={{ width: 12, height: 12 }}></i>
</div>
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ fontSize: 13, color: 'var(--fg)' }}>{text}</div>
<div style={{ fontSize: 11, color: 'var(--fg-faint)', marginTop: 1 }}>{sub}</div>
</div>
<div style={{ fontSize: 11, color: 'var(--fg-faint)' }}>{time}</div>
</div>
);
}
window.Dashboard = Dashboard;
+111
View File
@@ -0,0 +1,111 @@
// Health — diagnostics report. One-shot health check across services.
const HEALTH_CHECKS = [
{ name: 'Anthropic API', status: 'ok', latency: '124 ms', detail: 'authenticated as Aurora · sonnet-4.5 reachable' },
{ name: 'Local gateway', status: 'ok', latency: '2 ms', detail: 'pid 84021 · uptime 4d 2h · listening :7421' },
{ name: 'Filesystem', status: 'ok', latency: '—', detail: '14.2 GB free of 512 GB' },
{ name: 'GitHub MCP', status: 'ok', latency: '84 ms', detail: 'oauth ok · 18 tools · rate-limit 4500/5000 (warn at 4750)' },
{ name: 'Linear MCP', status: 'ok', latency: '142 ms', detail: 'oauth ok · 9 tools' },
{ name: 'Postgres MCP', status: 'ok', latency: '12 ms', detail: 'stdio · prod read replica' },
{ name: 'Figma MCP', status: 'ok', latency: '210 ms', detail: 'oauth ok · 6 tools' },
{ name: 'Notion MCP', status: 'error', latency: '—', detail: 'TLS handshake failed · 4 retries · backing off 30s' },
{ name: 'Slack MCP', status: 'warn', latency: '—', detail: 'oauth token expired · re-authenticate' },
{ name: 'Sentry MCP', status: 'idle', latency: '—', detail: 'disabled' },
{ name: 'Cron scheduler', status: 'ok', latency: '—', detail: '5 jobs registered · next: incident-triage in 12m' },
{ name: 'Local model cache', status: 'ok', latency: '—', detail: '412 MB · last pruned 2d ago' },
];
function Health() {
const [scanning, setScanning] = React.useState(false);
React.useEffect(() => { requestAnimationFrame(() => window.lucide && window.lucide.createIcons()); });
const ok = HEALTH_CHECKS.filter(c => c.status === 'ok').length;
const warn = HEALTH_CHECKS.filter(c => c.status === 'warn').length;
const err = HEALTH_CHECKS.filter(c => c.status === 'error').length;
function rerun() {
setScanning(true);
setTimeout(() => setScanning(false), 1400);
}
return (
<div style={{ display: 'flex', flexDirection: 'column', height: '100%' }}>
<ContentHeader title="Health"
subtitle="A diagnostics report across Scarf, the agent, and connected services"
actions={<>
<Btn icon="download">Save report</Btn>
<Btn kind="primary" icon="rotate-cw" loading={scanning} onClick={rerun}>{scanning ? 'Scanning…' : 'Re-run'}</Btn>
</>} />
<div style={{ flex: 1, overflowY: 'auto', padding: '24px 32px' }}>
{/* Summary banner */}
<div style={{
background: err > 0 ? 'var(--red-100)' : warn > 0 ? 'var(--orange-100)' : 'var(--green-100)',
border: `0.5px solid ${err > 0 ? 'var(--red-500)' : warn > 0 ? 'var(--orange-500)' : 'var(--green-500)'}`,
borderRadius: 10, padding: 16, marginBottom: 24,
display: 'flex', alignItems: 'center', gap: 14,
}}>
<div style={{
width: 38, height: 38, borderRadius: 9,
background: err > 0 ? 'var(--red-500)' : warn > 0 ? 'var(--orange-500)' : 'var(--green-500)',
color: '#fff', display: 'flex', alignItems: 'center', justifyContent: 'center',
}}>
<i data-lucide={err > 0 ? 'alert-octagon' : warn > 0 ? 'alert-triangle' : 'shield-check'} style={{ width: 20, height: 20 }}></i>
</div>
<div style={{ flex: 1 }}>
<div style={{ fontSize: 15, fontWeight: 600, marginBottom: 2 }}>
{err > 0 ? `${err} service${err === 1 ? '' : 's'} unhealthy`
: warn > 0 ? `${warn} warning${warn === 1 ? '' : 's'} to review`
: 'All systems healthy'}
</div>
<div style={{ fontSize: 13, color: 'var(--fg-muted)' }}>
{ok} ok · {warn} warning · {err} error · scanned 2 minutes ago
</div>
</div>
</div>
{/* Checks */}
<SettingsGroup title="Diagnostic checks">
{HEALTH_CHECKS.map((c, i) => <HealthRow key={c.name} c={c} last={i === HEALTH_CHECKS.length - 1} />)}
</SettingsGroup>
<SettingsGroup title="Environment">
<SettingsRow icon="info" title="Scarf version"
description="0.14.2 · 0.15.0 available"
control={<Btn size="sm">Update</Btn>} />
<SettingsRow icon="cpu" title="Platform"
description="macOS 14.4.1 · Apple M3 Pro · 36 GB"
control={<Pill tone="green" dot>supported</Pill>} />
<SettingsRow icon="terminal" title="Shell"
description="/bin/zsh 5.9 · path 47 entries"
control={<Btn size="sm">Inspect</Btn>} last />
</SettingsGroup>
</div>
</div>
);
}
function HealthRow({ c, last }) {
const tones = {
ok: { tone: 'green', icon: 'check-circle' },
warn: { tone: 'amber', icon: 'alert-triangle' },
error: { tone: 'red', icon: 'x-circle' },
idle: { tone: 'gray', icon: 'minus-circle' },
};
const t = tones[c.status];
return (
<div style={{
display: 'flex', alignItems: 'center', gap: 12, padding: '12px 18px',
borderBottom: last ? 'none' : '0.5px solid var(--border)',
}}>
<Pill tone={t.tone} icon={t.icon} size="sm">{c.status}</Pill>
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ fontSize: 13, fontWeight: 500 }}>{c.name}</div>
<div style={{ fontSize: 11.5, color: 'var(--fg-muted)', marginTop: 2, fontFamily: 'var(--font-mono)' }}>{c.detail}</div>
</div>
<span style={{ fontFamily: 'var(--font-mono)', fontSize: 11, color: 'var(--fg-faint)', width: 70, textAlign: 'right' }}>{c.latency}</span>
</div>
);
}
window.Health = Health;
+107
View File
@@ -0,0 +1,107 @@
// Insights — usage charts and breakdowns.
function Insights() {
return (
<div style={{ overflow: 'auto', height: '100%' }}>
<ContentHeader title="Insights"
subtitle="Patterns across sessions, models, and tools"
right={<select style={{
fontSize: 12, padding: '5px 10px', border: '1px solid var(--border-strong)',
borderRadius: 6, background: 'var(--bg-card)', fontFamily: 'var(--font-sans)',
}}><option>Last 7 days</option><option>Last 30 days</option><option>This year</option></select>}
actions={<Btn icon="download">Export CSV</Btn>} />
<div style={{ padding: '20px 28px', display: 'flex', flexDirection: 'column', gap: 20 }}>
<div style={{ display: 'flex', gap: 12 }}>
<StatCard label="Sessions" value="847" sub="↗ +12% vs prev" />
<StatCard label="Tokens" value="2.4M" sub="1.8M in · 0.6M out" />
<StatCard label="Tool calls" value="3,221" sub="3.8 avg/session" />
<StatCard label="Avg latency" value="1.2s" accent="var(--accent)" sub="p95 4.1s" />
<StatCard label="Cost" value="$42.18" sub="$0.05 avg/session" />
</div>
<Card>
<div style={{ display: 'flex', alignItems: 'center', gap: 10, marginBottom: 14 }}>
<div style={{ flex: 1, fontSize: 13, fontWeight: 600 }}>Token usage</div>
<div style={{ display: 'flex', gap: 12, fontSize: 11, color: 'var(--fg-muted)' }}>
<span style={{ display: 'flex', alignItems: 'center', gap: 5 }}>
<span style={{ width: 9, height: 9, borderRadius: 2, background: 'var(--accent)' }}></span>
Input
</span>
<span style={{ display: 'flex', alignItems: 'center', gap: 5 }}>
<span style={{ width: 9, height: 9, borderRadius: 2, background: 'var(--brand-200)' }}></span>
Output
</span>
</div>
</div>
<BarChart />
</Card>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 16 }}>
<Card>
<div style={{ fontSize: 13, fontWeight: 600, marginBottom: 14 }}>By model</div>
<BreakdownRow label="claude-sonnet-4.5" value="62%" bar="var(--accent)" sub="$28.41 · 524 sessions" />
<BreakdownRow label="claude-haiku-4.5" value="31%" bar="var(--brand-300)" sub="$10.18 · 263 sessions" />
<BreakdownRow label="claude-opus-4.5" value="5%" bar="var(--brand-700)" sub="$3.40 · 42 sessions" />
<BreakdownRow label="local/llama-3.3" value="2%" bar="var(--gray-400)" sub="$0.00 · 18 sessions" last />
</Card>
<Card>
<div style={{ fontSize: 13, fontWeight: 600, marginBottom: 14 }}>By tool kind</div>
<BreakdownRow label="read" value="42%" bar="var(--green-500)" sub="1,353 calls" />
<BreakdownRow label="execute" value="24%" bar="var(--orange-500)" sub="773 calls" />
<BreakdownRow label="edit" value="18%" bar="var(--blue-500)" sub="580 calls" />
<BreakdownRow label="fetch" value="11%" bar="var(--purple-tool-500)" sub="354 calls" />
<BreakdownRow label="browser" value="5%" bar="var(--indigo-500)" sub="161 calls" last />
</Card>
</div>
</div>
</div>
);
}
function BarChart() {
// 14 days of data, hand-tuned
const data = [
[120, 40], [80, 32], [180, 60], [240, 90], [200, 75], [60, 22], [40, 15],
[110, 38], [170, 56], [220, 82], [280, 98], [310, 110], [240, 78], [190, 64],
];
const max = 420;
const chartH = 160; // px area for bars
return (
<div style={{ display: 'flex', alignItems: 'flex-end', gap: 6, height: 184, padding: '0 4px' }}>
{data.map(([inp, outp], i) => {
const inpH = Math.round(inp / max * chartH);
const outpH = Math.round(outp / max * chartH);
return (
<div key={i} style={{
flex: 1, display: 'flex', flexDirection: 'column',
justifyContent: 'flex-end', alignItems: 'stretch', minWidth: 0,
}}>
<div style={{ background: 'var(--brand-200)', height: outpH,
borderRadius: '3px 3px 0 0' }}></div>
<div style={{ background: 'var(--accent)', height: inpH }}></div>
<div style={{ fontSize: 9, color: 'var(--fg-faint)', textAlign: 'center', marginTop: 4,
fontFamily: 'var(--font-mono)', height: 14 }}>{i % 2 === 0 ? `04/${12 + i}` : ''}</div>
</div>
);
})}
</div>
);
}
function BreakdownRow({ label, value, bar, sub, last }) {
return (
<div style={{ marginBottom: last ? 0 : 14 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 4 }}>
<div style={{ flex: 1, fontFamily: 'var(--font-mono)', fontSize: 12 }}>{label}</div>
<div style={{ fontFamily: 'var(--font-mono)', fontSize: 12, fontWeight: 600 }}>{value}</div>
</div>
<div style={{ height: 6, background: 'var(--bg-quaternary)', borderRadius: 3, overflow: 'hidden' }}>
<div style={{ width: value, height: '100%', background: bar, borderRadius: 3 }}></div>
</div>
<div style={{ fontSize: 10.5, color: 'var(--fg-faint)', marginTop: 3 }}>{sub}</div>
</div>
);
}
window.Insights = Insights;
+123
View File
@@ -0,0 +1,123 @@
// Logs — streaming monospace surface. Filter pills + a fake live tail.
const LOG_LINES = [
{ ts: '09:42:18.124', level: 'info', source: 'gateway', msg: 'POST /v1/messages → 200 (1.2s, 482 tokens out)' },
{ ts: '09:42:18.066', level: 'debug', source: 'tool', msg: 'tool_call read_file path=src/App.jsx (8.2KB)' },
{ ts: '09:42:17.880', level: 'info', source: 'agent', msg: 'turn 14 started — personality=Forge model=claude-sonnet-4.5' },
{ ts: '09:42:15.341', level: 'warn', source: 'mcp', msg: 'github: rate-limit warning 4500/5000 used this hour' },
{ ts: '09:42:11.012', level: 'info', source: 'tool', msg: 'tool_call execute cmd="npm test -- --watch=false" status=ok 14.2s' },
{ ts: '09:42:01.508', level: 'error', source: 'tool', msg: 'tool_call execute denied: command "rm -rf node_modules" matches deny rule "rm -rf"' },
{ ts: '09:41:58.211', level: 'info', source: 'agent', msg: 'user message received (1.4KB)' },
{ ts: '09:41:42.004', level: 'debug', source: 'memory', msg: 'AGENTS.md hash unchanged (4f02…ab19), skipping reload' },
{ ts: '09:41:30.882', level: 'info', source: 'cron', msg: 'incident-triage finished ok (4.2s)' },
{ ts: '09:41:26.108', level: 'info', source: 'cron', msg: 'incident-triage started' },
{ ts: '09:41:18.443', level: 'info', source: 'mcp', msg: 'linear: tools/list 9 tools (142ms)' },
{ ts: '09:40:54.221', level: 'warn', source: 'gateway', msg: 'approval pending: tool_call execute cmd="git push origin main" (12s)' },
{ ts: '09:40:42.001', level: 'info', source: 'agent', msg: 'turn 13 ended — 2.1s, 7 tool calls, $0.0042' },
{ ts: '09:40:21.778', level: 'debug', source: 'tool', msg: 'tool_call list_files path=ui_kits/scarf-mac (24 entries)' },
{ ts: '09:40:18.422', level: 'error', source: 'mcp', msg: 'notion: TLS handshake failed (timeout 5s) — backing off 30s' },
{ ts: '09:40:02.114', level: 'info', source: 'agent', msg: 'session resumed (idle 14m)' },
];
const LEVEL_TONES = {
debug: '#7C7263', info: 'var(--blue-500)', warn: 'var(--amber-500)', error: 'var(--red-500)',
};
function Logs() {
const [level, setLevel] = React.useState(['info', 'warn', 'error']);
const [source, setSource] = React.useState('all');
const [search, setSearch] = React.useState('');
const [follow, setFollow] = React.useState(true);
React.useEffect(() => { requestAnimationFrame(() => window.lucide && window.lucide.createIcons()); });
const sources = ['all', 'agent', 'tool', 'gateway', 'mcp', 'cron', 'memory'];
const filtered = LOG_LINES.filter(l => {
if (!level.includes(l.level)) return false;
if (source !== 'all' && l.source !== source) return false;
if (search && !l.msg.toLowerCase().includes(search.toLowerCase())) return false;
return true;
});
return (
<div style={{ display: 'flex', flexDirection: 'column', height: '100%' }}>
<ContentHeader title="Logs"
subtitle="Live tail across the gateway, agent, tools, MCP servers, and cron"
actions={<>
<Btn icon="download">Export</Btn>
<Btn icon={follow ? 'pause' : 'play'} onClick={() => setFollow(!follow)}>
{follow ? 'Pause' : 'Follow'}
</Btn>
</>} />
{/* Toolbar */}
<div style={{
padding: '12px 24px', borderBottom: '0.5px solid var(--border)',
background: 'var(--bg-card)', display: 'flex', gap: 12, alignItems: 'center', flexWrap: 'wrap',
}}>
<TextInput value={search} onChange={setSearch} leftIcon="search" placeholder="Filter messages…" mono width={280} />
<div style={{ display: 'flex', gap: 4 }}>
{['debug', 'info', 'warn', 'error'].map(lv => {
const on = level.includes(lv);
return (
<button key={lv} onClick={() => setLevel(on ? level.filter(x => x !== lv) : [...level, lv])} style={{
padding: '4px 10px', borderRadius: 6, border: '0.5px solid var(--border)',
background: on ? 'var(--bg-tertiary)' : 'var(--bg-card)',
fontFamily: 'var(--font-mono)', fontSize: 11, fontWeight: 600,
color: on ? LEVEL_TONES[lv] : 'var(--fg-faint)',
textTransform: 'uppercase', cursor: 'pointer', letterSpacing: '0.04em',
}}>{lv}</button>
);
})}
</div>
<div style={{ display: 'flex', gap: 4, marginLeft: 'auto' }}>
{sources.map(s => (
<button key={s} onClick={() => setSource(s)} style={{
padding: '4px 10px', borderRadius: 6, border: 'none',
background: source === s ? 'var(--accent-tint)' : 'transparent',
color: source === s ? 'var(--accent-active)' : 'var(--fg-muted)',
fontFamily: 'var(--font-mono)', fontSize: 11, cursor: 'pointer',
}}>{s}</button>
))}
</div>
</div>
{/* Tail */}
<div style={{
flex: 1, overflowY: 'auto', background: '#1F1B16', color: '#E8E1D2',
fontFamily: 'var(--font-mono)', fontSize: 12, lineHeight: 1.7,
padding: '12px 0',
}}>
{filtered.map((l, i) => <LogRow key={i} l={l} />)}
{follow && (
<div style={{ padding: '6px 24px', display: 'flex', alignItems: 'center', gap: 8,
color: '#A89B82', fontSize: 11 }}>
<span style={{ width: 6, height: 6, borderRadius: 3, background: 'var(--green-500)',
animation: 'pulse 1.4s ease-in-out infinite' }}></span>
following 4 lines/sec
</div>
)}
</div>
<style>{`
@keyframes pulse { 0%, 100% { opacity: 1; } 50% { opacity: 0.3; } }
`}</style>
</div>
);
}
function LogRow({ l }) {
return (
<div style={{
display: 'flex', gap: 14, padding: '1px 24px', alignItems: 'baseline',
}}>
<span style={{ color: '#7C7263', fontSize: 11, width: 100, flexShrink: 0 }}>{l.ts}</span>
<span style={{ color: LEVEL_TONES[l.level], width: 50, flexShrink: 0,
textTransform: 'uppercase', fontSize: 10, fontWeight: 700, letterSpacing: '0.04em' }}>{l.level}</span>
<span style={{ color: '#A89B82', width: 70, flexShrink: 0 }}>{l.source}</span>
<span style={{ color: '#E8E1D2', flex: 1 }}>{l.msg}</span>
</div>
);
}
window.Logs = Logs;
+193
View File
@@ -0,0 +1,193 @@
// MCP Servers — connection list + detail with health, capabilities, and logs.
const MCP_SERVERS = [
{ id: 'github', name: 'GitHub', transport: 'http', url: 'https://mcp.github.com/v1', status: 'connected', tools: 18, prompts: 4, resources: 12, latency: 84, version: '1.4.2', auth: 'oauth', scope: 'org/wizemann' },
{ id: 'linear', name: 'Linear', transport: 'http', url: 'https://mcp.linear.app/sse', status: 'connected', tools: 9, prompts: 0, resources: 6, latency: 142, version: '0.9.1', auth: 'oauth', scope: 'wizemann' },
{ id: 'slack', name: 'Slack', transport: 'http', url: 'https://mcp.slack.com/v1', status: 'auth-required', tools: 0, prompts: 0, resources: 0, latency: null, version: '—', auth: 'oauth', scope: '—' },
{ id: 'postgres-prod', name: 'Postgres (prod, ro)', transport: 'stdio', url: 'mcp-postgres --readonly', status: 'connected', tools: 4, prompts: 0, resources: 28, latency: 12, version: '2.1.0', auth: 'env', scope: 'prod-replica' },
{ id: 'figma', name: 'Figma', transport: 'http', url: 'https://mcp.figma.com/v1', status: 'connected', tools: 6, prompts: 2, resources: 0, latency: 210, version: '0.4.0', auth: 'oauth', scope: 'wizemann-design' },
{ id: 'notion', name: 'Notion', transport: 'http', url: 'https://mcp.notion.so/v1', status: 'error', tools: 0, prompts: 0, resources: 0, latency: null, version: '—', auth: 'oauth', scope: '—', error: 'TLS handshake failed (timeout 5s)' },
{ id: 'sentry', name: 'Sentry', transport: 'http', url: 'https://mcp.sentry.io/v1', status: 'disabled', tools: 0, prompts: 0, resources: 0, latency: null, version: '—', auth: 'token', scope: 'wizemann' },
];
const STATUS_TONES = {
'connected': { tone: 'green', label: 'connected' },
'auth-required': { tone: 'amber', label: 'auth required' },
'error': { tone: 'red', label: 'error' },
'disabled': { tone: 'gray', label: 'disabled' },
};
function MCPServers() {
const [active, setActive] = React.useState('github');
const server = MCP_SERVERS.find(s => s.id === active);
React.useEffect(() => { requestAnimationFrame(() => window.lucide && window.lucide.createIcons()); });
return (
<div style={{ display: 'flex', flexDirection: 'column', height: '100%' }}>
<ContentHeader title="MCP Servers"
subtitle="Model Context Protocol endpoints — each adds a bundle of tools, prompts, and resources"
actions={<><Btn icon="rotate-cw">Reconnect all</Btn><Btn kind="primary" icon="plus">Add server</Btn></>} />
<div style={{ flex: 1, display: 'flex', minHeight: 0 }}>
<div style={{ width: 320, borderRight: '0.5px solid var(--border)',
display: 'flex', flexDirection: 'column', background: 'var(--bg-card)' }}>
<div style={{ flex: 1, overflowY: 'auto', padding: 8 }}>
{MCP_SERVERS.map(s => <MCPRow key={s.id} s={s} active={s.id === active} onClick={() => setActive(s.id)} />)}
</div>
<div style={{ padding: 12, borderTop: '0.5px solid var(--border)' }}>
<Btn fullWidth icon="hard-drive">Browse marketplace</Btn>
</div>
</div>
<div style={{ flex: 1, overflowY: 'auto', background: 'var(--bg)', padding: '24px 32px' }}>
<MCPDetail server={server} />
</div>
</div>
</div>
);
}
function MCPRow({ s, active, onClick }) {
const status = STATUS_TONES[s.status];
const [hover, setHover] = React.useState(false);
return (
<div onClick={onClick} onMouseEnter={() => setHover(true)} onMouseLeave={() => setHover(false)} style={{
padding: '11px 12px', borderRadius: 7, cursor: 'pointer', marginBottom: 2,
background: active ? 'var(--accent-tint)' : (hover ? 'var(--bg-quaternary)' : 'transparent'),
}}>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 4 }}>
<ServerGlyph id={s.id} size={22} />
<div style={{ flex: 1, fontSize: 13, fontWeight: 500,
color: active ? 'var(--accent-active)' : 'var(--fg)' }}>{s.name}</div>
<Dot tone={status.tone} />
</div>
<div style={{ fontSize: 11, color: 'var(--fg-faint)', fontFamily: 'var(--font-mono)',
whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>
{s.transport} · {s.tools} tools · {s.prompts} prompts
</div>
</div>
);
}
function ServerGlyph({ id, size = 22 }) {
const palette = {
github: '#1F1B16', linear: '#5E6AD2', slack: '#611F69',
'postgres-prod': '#336791', figma: '#F24E1E', notion: '#191919', sentry: '#362D59',
};
const letter = id[0].toUpperCase();
return (
<div style={{
width: size, height: size, borderRadius: 5, background: palette[id] || '#888',
color: 'white', display: 'flex', alignItems: 'center', justifyContent: 'center',
fontFamily: 'var(--font-display)', fontSize: size * 0.5, fontWeight: 700, flexShrink: 0,
}}>{letter}</div>
);
}
function MCPDetail({ server }) {
const status = STATUS_TONES[server.status];
return (
<>
<div style={{ display: 'flex', alignItems: 'flex-start', gap: 14, marginBottom: 20 }}>
<ServerGlyph id={server.id} size={48} />
<div style={{ flex: 1 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 4 }}>
<div className="scarf-h2" style={{ fontSize: 22 }}>{server.name}</div>
<Pill tone={status.tone} dot>{status.label}</Pill>
<span style={{ fontSize: 11, color: 'var(--fg-faint)', fontFamily: 'var(--font-mono)' }}>v{server.version}</span>
</div>
<div style={{ fontSize: 12, color: 'var(--fg-muted)', fontFamily: 'var(--font-mono)' }}>{server.url}</div>
</div>
<div style={{ display: 'flex', gap: 6 }}>
<Btn icon="rotate-cw">Reconnect</Btn>
<Toggle on={server.status !== 'disabled'} size="lg" />
</div>
</div>
{server.error && (
<div style={{
background: 'var(--red-100)', border: '0.5px solid var(--red-500)',
borderRadius: 9, padding: 12, marginBottom: 20, display: 'flex', gap: 10, alignItems: 'flex-start',
}}>
<i data-lucide="alert-triangle" style={{ width: 16, height: 16, color: 'var(--red-500)', flexShrink: 0, marginTop: 1 }}></i>
<div>
<div style={{ fontSize: 13, fontWeight: 500, color: 'var(--red-500)', marginBottom: 2 }}>Connection failed</div>
<div style={{ fontSize: 12, color: 'var(--fg-muted)', fontFamily: 'var(--font-mono)' }}>{server.error}</div>
</div>
</div>
)}
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(4, 1fr)', gap: 10, marginBottom: 24 }}>
<StatCard label="Tools" value={server.tools} />
<StatCard label="Prompts" value={server.prompts} />
<StatCard label="Resources" value={server.resources} />
<StatCard label="Latency" value={server.latency != null ? `${server.latency} ms` : '—'} sub={server.latency != null ? 'p95: ' + Math.round(server.latency * 2.4) + ' ms' : '—'} />
</div>
<SettingsGroup title="Connection">
<SettingsRow icon="link" title="Transport"
description={server.transport === 'http' ? 'HTTP / SSE' : 'Local stdio process'}
control={<Pill>{server.transport}</Pill>} />
<SettingsRow icon="key" title="Auth"
description={server.auth === 'oauth' ? 'OAuth — refreshed automatically' : server.auth === 'env' ? 'Environment variable' : 'Static token'}
control={<Btn size="sm" icon="external-link">Manage</Btn>} />
<SettingsRow icon="shield" title="Scope"
description={`Calls scoped to "${server.scope}".`}
control={<Btn size="sm">Edit</Btn>} last />
</SettingsGroup>
<SettingsGroup title="Capabilities" description="Tools, prompts, and resources advertised by this server.">
<CapRow icon="wrench" name="list_issues" kind="tool" desc="List repository issues with filters" />
<CapRow icon="wrench" name="create_pr" kind="tool" desc="Open a pull request from a branch" />
<CapRow icon="wrench" name="search_code" kind="tool" desc="Full-text search across accessible repos" />
<CapRow icon="message-square" name="review_pr" kind="prompt" desc="Structured PR review prompt" />
<CapRow icon="folder" name="repo://*" kind="resource" desc="Read-only access to repo file trees" last />
</SettingsGroup>
<SettingsGroup title="Activity log" description="Last 5 events from this server.">
<LogLine when="2m ago" level="info" msg="tools/list returned 18 tools (84ms)" />
<LogLine when="14m ago" level="info" msg="github__list_issues invoked (owner=wizemann, state=open)" />
<LogLine when="42m ago" level="warn" msg="rate-limit warning: 4500/5000 used this hour" />
<LogLine when="1h ago" level="info" msg="oauth token refreshed" />
<LogLine when="3h ago" level="info" msg="connection established (TLS 1.3)" last />
</SettingsGroup>
<SettingsGroup title="Danger zone" tone="danger">
<SettingsRow icon="x-circle" title="Disconnect server"
description="Remove this server. Tools it provided will become unavailable."
control={<Btn size="sm" kind="danger">Disconnect</Btn>} last />
</SettingsGroup>
</>
);
}
function CapRow({ icon, name, kind, desc, last }) {
const tones = { tool: 'blue', prompt: 'purple', resource: 'green' };
return (
<div style={{
display: 'flex', alignItems: 'center', gap: 12, padding: '11px 18px',
borderBottom: last ? 'none' : '0.5px solid var(--border)',
}}>
<i data-lucide={icon} style={{ width: 14, height: 14, color: 'var(--fg-muted)', flexShrink: 0 }}></i>
<div style={{ fontFamily: 'var(--font-mono)', fontSize: 12.5, color: 'var(--fg)', minWidth: 140 }}>{name}</div>
<div style={{ flex: 1, fontSize: 12, color: 'var(--fg-muted)' }}>{desc}</div>
<Pill tone={tones[kind]} size="sm">{kind}</Pill>
</div>
);
}
function LogLine({ when, level, msg, last }) {
const tones = { info: 'var(--fg-faint)', warn: 'var(--amber-500)', error: 'var(--red-500)' };
return (
<div style={{
display: 'flex', gap: 12, padding: '8px 18px', fontFamily: 'var(--font-mono)', fontSize: 11.5,
borderBottom: last ? 'none' : '0.5px solid var(--border)',
}}>
<span style={{ color: 'var(--fg-faint)', width: 80 }}>{when}</span>
<span style={{ color: tones[level], textTransform: 'uppercase', width: 44, fontSize: 10, fontWeight: 600, paddingTop: 1 }}>{level}</span>
<span style={{ color: 'var(--fg-muted)', flex: 1 }}>{msg}</span>
</div>
);
}
window.MCPServers = MCPServers;
+134
View File
@@ -0,0 +1,134 @@
// Memory — AGENTS.md editor. Stored instructions the agent reads on every turn.
const MEMORY_FILES = [
{ id: 'global', name: 'AGENTS.md', scope: 'Global', path: '~/.scarf/AGENTS.md', updated: '2 days ago', size: '1.2 KB' },
{ id: 'wizemann', name: 'AGENTS.md', scope: 'Org · Wizemann', path: '~/.scarf/orgs/wizemann/AGENTS.md', updated: '1 week ago', size: '3.4 KB' },
{ id: 'project', name: 'AGENTS.md', scope: 'Project · sera', path: 'sera/AGENTS.md', updated: '14m ago', size: '5.8 KB' },
];
const SAMPLE_AGENTS = `# Sera — agent instructions
You are working on **Sera**, a CLI for building Anthropic-style applications.
The codebase is TypeScript + Bun. Tests live next to source as \`*.test.ts\`.
## Style
- Prefer named exports.
- 2-space indent, no semicolons in TS.
- Avoid default exports except for React components.
- Lowercase filenames except for React components (PascalCase).
## Workflow
- Run \`bun test\` after every meaningful change.
- Open a draft PR early; flip to ready when CI is green.
- Update CHANGELOG.md when changing public API.
## Don't
- Touch \`scripts/release.ts\` — owned by ops.
- Pull in dependencies without flagging it first.
- Push directly to main.
`;
function Memory() {
const [active, setActive] = React.useState('project');
const [draft, setDraft] = React.useState(SAMPLE_AGENTS);
const [dirty, setDirty] = React.useState(false);
React.useEffect(() => { requestAnimationFrame(() => window.lucide && window.lucide.createIcons()); });
const file = MEMORY_FILES.find(f => f.id === active);
return (
<div style={{ display: 'flex', flexDirection: 'column', height: '100%' }}>
<ContentHeader title="Memory" subtitle="AGENTS.md files the agent reads on every turn. Project beats org beats global."
actions={<>
<Btn icon="rotate-ccw" disabled={!dirty}>Discard</Btn>
<Btn kind="primary" icon="check" disabled={!dirty}>Save</Btn>
</>}
/>
<div style={{ flex: 1, display: 'flex', minHeight: 0 }}>
<div style={{ width: 280, borderRight: '0.5px solid var(--border)',
display: 'flex', flexDirection: 'column', background: 'var(--bg-card)' }}>
<div style={{ padding: '14px 14px 6px', fontSize: 10, fontWeight: 600,
color: 'var(--fg-muted)', textTransform: 'uppercase', letterSpacing: '0.06em' }}>
Memory files
</div>
<div style={{ flex: 1, padding: 8 }}>
{MEMORY_FILES.map(f => {
const a = f.id === active;
return (
<div key={f.id} onClick={() => setActive(f.id)} style={{
padding: '10px 12px', borderRadius: 7, cursor: 'pointer', marginBottom: 2,
background: a ? 'var(--accent-tint)' : 'transparent',
}}>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 4 }}>
<i data-lucide="file-text" style={{ width: 13, height: 13,
color: a ? 'var(--accent-active)' : 'var(--fg-muted)' }}></i>
<div style={{ fontSize: 13, fontWeight: 500,
color: a ? 'var(--accent-active)' : 'var(--fg)' }}>{f.scope}</div>
</div>
<div style={{ fontSize: 11, color: 'var(--fg-faint)', fontFamily: 'var(--font-mono)',
whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>{f.path}</div>
<div style={{ fontSize: 11, color: 'var(--fg-faint)', marginTop: 2 }}>
{f.size} · {f.updated}
</div>
</div>
);
})}
<div style={{ marginTop: 12 }}>
<Btn fullWidth icon="plus" size="sm">Add memory file</Btn>
</div>
</div>
<div style={{ padding: 14, borderTop: '0.5px solid var(--border)' }}>
<div style={{ fontSize: 11, color: 'var(--fg-muted)', lineHeight: 1.5 }}>
<i data-lucide="info" style={{ width: 11, height: 11, verticalAlign: 'text-top', marginRight: 4 }}></i>
Files are loaded in order narrower scopes override broader ones.
</div>
</div>
</div>
<div style={{ flex: 1, display: 'flex', flexDirection: 'column', minWidth: 0 }}>
<div style={{
padding: '12px 24px', borderBottom: '0.5px solid var(--border)',
background: 'var(--bg-card)', display: 'flex', alignItems: 'center', gap: 12,
}}>
<i data-lucide="file-text" style={{ width: 16, height: 16, color: 'var(--accent)' }}></i>
<div style={{ flex: 1 }}>
<div style={{ fontSize: 13, fontWeight: 500 }}>{file.name}</div>
<div style={{ fontSize: 11, color: 'var(--fg-faint)', fontFamily: 'var(--font-mono)' }}>{file.path}</div>
</div>
{dirty
? <Pill tone="amber" dot>unsaved</Pill>
: <Pill tone="green" dot>saved</Pill>}
<div style={{ display: 'flex', gap: 4 }}>
<IconBtn icon="eye" tooltip="Preview" />
<IconBtn icon="more-horizontal" tooltip="More" />
</div>
</div>
<textarea value={draft}
onChange={e => { setDraft(e.target.value); setDirty(true); }}
style={{
flex: 1, padding: '20px 32px', border: 'none', outline: 'none', resize: 'none',
fontFamily: 'var(--font-mono)', fontSize: 13, lineHeight: 1.7,
color: 'var(--fg)', background: 'var(--bg)',
}}
/>
<div style={{
padding: '8px 24px', borderTop: '0.5px solid var(--border)', background: 'var(--bg-card)',
display: 'flex', alignItems: 'center', gap: 16, fontSize: 11, color: 'var(--fg-faint)', fontFamily: 'var(--font-mono)',
}}>
<span>markdown</span>
<span>·</span>
<span>{draft.split('\n').length} lines</span>
<span>·</span>
<span>{draft.length} chars</span>
<span style={{ marginLeft: 'auto' }}>last loaded: {file.updated}</span>
</div>
</div>
</div>
</div>
);
}
window.Memory = Memory;
+422
View File
@@ -0,0 +1,422 @@
// MoreViews.jsx — Personalities, Quick Commands, Platforms, Credentials,
// Plugins, Webhooks, Profiles, Gateway. Each is a focused list/detail or grid.
// ─────────────── Personalities ───────────────
const PERSONALITIES = [
{ id: 'forge', name: 'Forge', emoji: '⚒', color: '#C25A2A', desc: 'Engineering pair. Refactors, tests, reviews PRs.', model: 'sonnet-4.5', tools: 14, used: '2m ago' },
{ id: 'hermes', name: 'Hermes', emoji: '✉', color: '#7E5BA9', desc: 'Operations. Handles ops scripts, summaries, status.', model: 'haiku-4.5', tools: 8, used: '32m ago' },
{ id: 'atlas', name: 'Atlas', emoji: '◇', color: '#3F6BA9', desc: 'Long-form writer. Spec drafts, release notes, docs.', model: 'opus-4.1', tools: 6, used: 'yesterday' },
{ id: 'vesta', name: 'Vesta', emoji: '✿', color: '#3F8A6E', desc: 'Design partner. Critiques layouts, suggests patterns.', model: 'sonnet-4.5', tools: 4, used: '3 days ago' },
{ id: 'gaia', name: 'Gaia', emoji: '✱', color: '#A8741F', desc: 'Researcher. Web search, summarization, citations.', model: 'sonnet-4.5', tools: 5, used: '1 week ago' },
];
function Personalities() {
React.useEffect(() => { requestAnimationFrame(() => window.lucide && window.lucide.createIcons()); });
return (
<div style={{ display: 'flex', flexDirection: 'column', height: '100%' }}>
<ContentHeader title="Personalities"
subtitle="Pre-configured agents — system prompt, model, allowed tools, defaults"
actions={<Btn kind="primary" icon="plus">New personality</Btn>} />
<div style={{ flex: 1, overflowY: 'auto', padding: 32 }}>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(260px, 1fr))', gap: 14 }}>
{PERSONALITIES.map(p => <PersonalityCard key={p.id} p={p} />)}
<Card padding={24} interactive style={{ display: 'flex', flexDirection: 'column',
alignItems: 'center', justifyContent: 'center', minHeight: 180,
border: '1px dashed var(--border-strong)', background: 'transparent', boxShadow: 'none' }}>
<div style={{ width: 40, height: 40, borderRadius: 10, background: 'var(--bg-quaternary)',
display: 'flex', alignItems: 'center', justifyContent: 'center', marginBottom: 8, color: 'var(--fg-muted)' }}>
<i data-lucide="plus" style={{ width: 20, height: 20 }}></i>
</div>
<div style={{ fontSize: 13, fontWeight: 500, color: 'var(--fg-muted)' }}>New personality</div>
</Card>
</div>
</div>
</div>
);
}
function PersonalityCard({ p }) {
return (
<Card interactive padding={18}>
<div style={{ display: 'flex', alignItems: 'flex-start', gap: 12, marginBottom: 12 }}>
<div style={{
width: 38, height: 38, borderRadius: 9, background: p.color, color: '#fff',
display: 'flex', alignItems: 'center', justifyContent: 'center',
fontFamily: 'var(--font-display)', fontSize: 18,
}}>{p.emoji}</div>
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ fontSize: 15, fontWeight: 600 }}>{p.name}</div>
<div style={{ fontSize: 11, color: 'var(--fg-faint)', fontFamily: 'var(--font-mono)' }}>last used {p.used}</div>
</div>
<IconBtn icon="more-horizontal" size={26} />
</div>
<div style={{ fontSize: 12.5, color: 'var(--fg-muted)', lineHeight: 1.5, marginBottom: 14, minHeight: 36 }}>{p.desc}</div>
<div style={{ display: 'flex', gap: 6, flexWrap: 'wrap' }}>
<Pill size="sm">{p.model}</Pill>
<Pill size="sm" icon="wrench">{p.tools} tools</Pill>
</div>
</Card>
);
}
window.Personalities = Personalities;
// ─────────────── Quick Commands ───────────────
const QC = [
{ trigger: '/test', name: 'Run tests', desc: 'Run the project test suite, summarize failures.', personality: 'Forge', uses: 142 },
{ trigger: '/review', name: 'Review PR', desc: 'Walk the diff in a checked-out PR and post review notes.', personality: 'Forge', uses: 38 },
{ trigger: '/standup', name: 'Standup summary', desc: 'Summarize yesterday\'s commits + Linear updates.', personality: 'Hermes', uses: 24 },
{ trigger: '/notes', name: 'Release notes', desc: 'Group merged PRs since last tag into release notes.', personality: 'Atlas', uses: 8 },
{ trigger: '/figma', name: 'Open Figma frame', desc: 'Resolve a Figma URL and import frame metadata.', personality: 'Vesta', uses: 14 },
{ trigger: '/cite', name: 'Cite source', desc: 'Web search + return citations as Markdown footnotes.', personality: 'Gaia', uses: 9 },
];
function QuickCommands() {
React.useEffect(() => { requestAnimationFrame(() => window.lucide && window.lucide.createIcons()); });
return (
<div style={{ display: 'flex', flexDirection: 'column', height: '100%' }}>
<ContentHeader title="Quick Commands"
subtitle="Slash-prefixed shortcuts that expand into full prompts"
actions={<Btn kind="primary" icon="plus">New command</Btn>} />
<div style={{ flex: 1, overflowY: 'auto', padding: '24px 32px' }}>
<SettingsGroup>
{QC.map((q, i) => (
<div key={q.trigger} style={{
display: 'flex', alignItems: 'center', gap: 14, padding: '14px 18px',
borderBottom: i === QC.length - 1 ? 'none' : '0.5px solid var(--border)',
}}>
<span style={{
fontFamily: 'var(--font-mono)', fontSize: 12.5, fontWeight: 600,
color: 'var(--accent)', background: 'var(--accent-tint)',
padding: '4px 9px', borderRadius: 6, minWidth: 80, textAlign: 'center',
}}>{q.trigger}</span>
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ fontSize: 13, fontWeight: 500 }}>{q.name}</div>
<div style={{ fontSize: 12, color: 'var(--fg-muted)', marginTop: 2 }}>{q.desc}</div>
</div>
<Pill size="sm">{q.personality}</Pill>
<span style={{ fontFamily: 'var(--font-mono)', fontSize: 11, color: 'var(--fg-faint)', width: 70, textAlign: 'right' }}>
{q.uses} uses
</span>
<IconBtn icon="more-horizontal" size={26} />
</div>
))}
</SettingsGroup>
</div>
</div>
);
}
window.QuickCommands = QuickCommands;
// ─────────────── Platforms ───────────────
const PLATFORMS = [
{ id: 'github', name: 'GitHub', desc: 'Repos, issues, PRs', conn: true, scope: 'org/wizemann · 14 repos' },
{ id: 'linear', name: 'Linear', desc: 'Issues & projects', conn: true, scope: 'wizemann · all teams' },
{ id: 'slack', name: 'Slack', desc: 'Messaging', conn: false, scope: '—' },
{ id: 'notion', name: 'Notion', desc: 'Docs', conn: false, scope: '—' },
{ id: 'figma', name: 'Figma', desc: 'Design files', conn: true, scope: 'wizemann-design' },
{ id: 'sentry', name: 'Sentry', desc: 'Error monitoring', conn: false, scope: '—' },
{ id: 'pagerduty', name: 'PagerDuty', desc: 'On-call', conn: false, scope: '—' },
{ id: 'stripe', name: 'Stripe', desc: 'Payments', conn: false, scope: '—' },
];
function Platforms() {
React.useEffect(() => { requestAnimationFrame(() => window.lucide && window.lucide.createIcons()); });
const palette = { github: '#1F1B16', linear: '#5E6AD2', slack: '#611F69', notion: '#191919',
figma: '#F24E1E', sentry: '#362D59', pagerduty: '#06AC38', stripe: '#635BFF' };
return (
<div style={{ display: 'flex', flexDirection: 'column', height: '100%' }}>
<ContentHeader title="Platforms"
subtitle="Higher-level integrations. Each provides one or more MCP servers and credentials."
actions={<Btn icon="external-link">Browse marketplace</Btn>} />
<div style={{ flex: 1, overflowY: 'auto', padding: 32 }}>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(260px, 1fr))', gap: 14 }}>
{PLATFORMS.map(p => (
<Card key={p.id} interactive padding={18}>
<div style={{ display: 'flex', alignItems: 'center', gap: 12, marginBottom: 14 }}>
<div style={{
width: 38, height: 38, borderRadius: 9, background: palette[p.id] || '#888', color: '#fff',
display: 'flex', alignItems: 'center', justifyContent: 'center',
fontFamily: 'var(--font-display)', fontSize: 18, fontWeight: 700,
}}>{p.name[0]}</div>
<div style={{ flex: 1 }}>
<div style={{ fontSize: 14, fontWeight: 600 }}>{p.name}</div>
<div style={{ fontSize: 11.5, color: 'var(--fg-muted)' }}>{p.desc}</div>
</div>
{p.conn && <Pill tone="green" dot size="sm">on</Pill>}
</div>
<div style={{ fontSize: 11, color: 'var(--fg-faint)', fontFamily: 'var(--font-mono)', marginBottom: 12,
whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>{p.scope}</div>
<Btn fullWidth size="sm" kind={p.conn ? 'secondary' : 'primary'}
icon={p.conn ? 'settings' : 'plug'}>
{p.conn ? 'Configure' : 'Connect'}
</Btn>
</Card>
))}
</div>
</div>
</div>
);
}
window.Platforms = Platforms;
// ─────────────── Credentials ───────────────
const CREDS = [
{ name: 'ANTHROPIC_API_KEY', kind: 'api-key', source: 'Keychain', last: '2m ago', scope: 'global', value: 'sk-ant-•••••••••a4f2' },
{ name: 'GITHUB_TOKEN', kind: 'oauth', source: 'OAuth', last: '14m ago', scope: 'global', value: 'gho_•••••••••••3kP9' },
{ name: 'LINEAR_TOKEN', kind: 'oauth', source: 'OAuth', last: '2h ago', scope: 'global', value: 'lin_oauth_•••••8m2x' },
{ name: 'POSTGRES_URL', kind: 'secret', source: 'env (.env)', last: '4h ago', scope: 'project · sera', value: 'postgres://ro@•••' },
{ name: 'OPENAI_API_KEY', kind: 'api-key', source: 'Keychain', last: 'never', scope: 'global', value: 'sk-•••••••••••L7Pw' },
{ name: 'AWS_ACCESS_KEY_ID', kind: 'secret', source: '~/.aws/credentials', last: '1d ago', scope: 'global', value: 'AKIA•••••••••QZX' },
];
function Credentials() {
React.useEffect(() => { requestAnimationFrame(() => window.lucide && window.lucide.createIcons()); });
const [reveal, setReveal] = React.useState({});
return (
<div style={{ display: 'flex', flexDirection: 'column', height: '100%' }}>
<ContentHeader title="Credentials"
subtitle="API keys, OAuth tokens, and secrets the agent can read. Stored in OS keychain by default."
actions={<Btn kind="primary" icon="plus">Add credential</Btn>} />
<div style={{ flex: 1, overflowY: 'auto', padding: '24px 32px' }}>
<div style={{
background: 'var(--accent-tint)', border: '0.5px solid var(--accent)',
borderRadius: 9, padding: 12, marginBottom: 20, display: 'flex', alignItems: 'flex-start', gap: 10,
}}>
<i data-lucide="shield" style={{ width: 16, height: 16, color: 'var(--accent)', marginTop: 1 }}></i>
<div style={{ fontSize: 12.5, color: 'var(--fg)', lineHeight: 1.5 }}>
Credentials are never sent to Anthropic. They're injected into tool calls at the local gateway.
</div>
</div>
<SettingsGroup>
{CREDS.map((c, i) => (
<div key={c.name} style={{
display: 'flex', alignItems: 'center', gap: 14, padding: '14px 18px',
borderBottom: i === CREDS.length - 1 ? 'none' : '0.5px solid var(--border)',
}}>
<i data-lucide={c.kind === 'oauth' ? 'key-round' : c.kind === 'api-key' ? 'key' : 'lock'}
style={{ width: 16, height: 16, color: 'var(--fg-muted)', flexShrink: 0 }}></i>
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ fontFamily: 'var(--font-mono)', fontSize: 13, fontWeight: 500 }}>{c.name}</div>
<div style={{ fontSize: 11, color: 'var(--fg-faint)', marginTop: 2 }}>
{c.source} · {c.scope} · used {c.last}
</div>
</div>
<code style={{
fontFamily: 'var(--font-mono)', fontSize: 11.5, color: 'var(--fg-muted)',
background: 'var(--bg-quaternary)', padding: '3px 8px', borderRadius: 5, width: 220, textAlign: 'center',
}}>
{reveal[c.name] ? c.value.replace(/•+/g, '************') : c.value}
</code>
<IconBtn icon={reveal[c.name] ? 'eye-off' : 'eye'} size={26}
onClick={() => setReveal({ ...reveal, [c.name]: !reveal[c.name] })} />
<IconBtn icon="copy" size={26} />
<IconBtn icon="trash-2" size={26} />
</div>
))}
</SettingsGroup>
</div>
</div>
);
}
window.Credentials = Credentials;
// ─────────────── Plugins ───────────────
const PLUGINS = [
{ id: 'commit-message', name: 'Smart commits', desc: 'Generate conventional-commit messages from staged changes.', author: 'wizemann', enabled: true, hooks: ['pre-commit'] },
{ id: 'review-helper', name: 'Review helper', desc: 'Auto-tag PR reviewers based on touched paths.', author: 'wizemann', enabled: true, hooks: ['pr-open'] },
{ id: 'todo-extractor', name: 'TODO extractor', desc: 'Surface inline TODOs as a checklist on the dashboard.', author: 'community', enabled: false, hooks: ['session-start'] },
{ id: 'speak', name: 'Speak responses', desc: 'Read agent responses aloud via system TTS.', author: 'community', enabled: false, hooks: ['turn-end'] },
];
function Plugins() {
React.useEffect(() => { requestAnimationFrame(() => window.lucide && window.lucide.createIcons()); });
return (
<div style={{ display: 'flex', flexDirection: 'column', height: '100%' }}>
<ContentHeader title="Plugins"
subtitle="Local extensions that hook into agent and editor lifecycle events"
actions={<><Btn icon="external-link">Marketplace</Btn><Btn kind="primary" icon="plus">Install</Btn></>} />
<div style={{ flex: 1, overflowY: 'auto', padding: '24px 32px' }}>
<SettingsGroup>
{PLUGINS.map((p, i) => (
<div key={p.id} style={{
display: 'flex', alignItems: 'center', gap: 14, padding: '14px 18px',
borderBottom: i === PLUGINS.length - 1 ? 'none' : '0.5px solid var(--border)',
}}>
<div style={{
width: 32, height: 32, borderRadius: 7, background: 'var(--accent-tint)', color: 'var(--accent)',
display: 'flex', alignItems: 'center', justifyContent: 'center',
}}>
<i data-lucide="puzzle" style={{ width: 15, height: 15 }}></i>
</div>
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<span style={{ fontSize: 13, fontWeight: 500 }}>{p.name}</span>
<span style={{ fontSize: 11, color: 'var(--fg-faint)', fontFamily: 'var(--font-mono)' }}>by {p.author}</span>
</div>
<div style={{ fontSize: 12, color: 'var(--fg-muted)', marginTop: 2 }}>{p.desc}</div>
<div style={{ display: 'flex', gap: 4, marginTop: 6 }}>
{p.hooks.map(h => <Pill key={h} size="sm">{h}</Pill>)}
</div>
</div>
<Toggle on={p.enabled} />
<IconBtn icon="more-horizontal" size={26} />
</div>
))}
</SettingsGroup>
</div>
</div>
);
}
window.Plugins = Plugins;
// ─────────────── Webhooks ───────────────
const WEBHOOKS = [
{ name: 'PR opened review', url: 'https://hooks.scarf.local/pr-review', events: ['github.pr.opened'], status: 'active', last: '2h ago' },
{ name: 'Sentry → triage', url: 'https://hooks.scarf.local/sentry-triage', events: ['sentry.issue.created', 'sentry.issue.regression'], status: 'active', last: '14m ago' },
{ name: 'Linear cycle → recap', url: 'https://hooks.scarf.local/cycle-recap', events: ['linear.cycle.completed'], status: 'paused', last: '8d ago' },
];
function Webhooks() {
React.useEffect(() => { requestAnimationFrame(() => window.lucide && window.lucide.createIcons()); });
return (
<div style={{ display: 'flex', flexDirection: 'column', height: '100%' }}>
<ContentHeader title="Webhooks"
subtitle="External events that trigger an agent run. Each maps an event payload to a personality + prompt."
actions={<Btn kind="primary" icon="plus">New webhook</Btn>} />
<div style={{ flex: 1, overflowY: 'auto', padding: '24px 32px' }}>
<SettingsGroup>
{WEBHOOKS.map((w, i) => (
<div key={w.name} style={{
display: 'flex', alignItems: 'center', gap: 14, padding: '14px 18px',
borderBottom: i === WEBHOOKS.length - 1 ? 'none' : '0.5px solid var(--border)',
}}>
<i data-lucide="webhook" style={{ width: 16, height: 16, color: 'var(--fg-muted)' }}></i>
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ fontSize: 13, fontWeight: 500 }}>{w.name}</div>
<div style={{ fontSize: 11, color: 'var(--fg-faint)', fontFamily: 'var(--font-mono)', marginTop: 2,
whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>{w.url}</div>
<div style={{ display: 'flex', gap: 4, marginTop: 6 }}>
{w.events.map(e => <Pill key={e} size="sm">{e}</Pill>)}
</div>
</div>
{w.status === 'active'
? <Pill tone="green" dot>active</Pill>
: <Pill tone="gray" dot>paused</Pill>}
<span style={{ fontFamily: 'var(--font-mono)', fontSize: 11, color: 'var(--fg-faint)', width: 80, textAlign: 'right' }}>{w.last}</span>
<IconBtn icon="more-horizontal" size={26} />
</div>
))}
</SettingsGroup>
</div>
</div>
);
}
window.Webhooks = Webhooks;
// ─────────────── Profiles ───────────────
const PROFILES = [
{ id: 'dev', name: 'Development', desc: 'Permissive — auto-approve writes & execs in dev branches.', active: true, policies: 14 },
{ id: 'review', name: 'Code review', desc: 'Read-only filesystem, no execute, network only via MCP.', active: false, policies: 8 },
{ id: 'prod', name: 'Production', desc: 'All writes & execs require approval. No deletions.', active: false, policies: 22 },
{ id: 'air-gap', name: 'Air-gapped', desc: 'No network. Local tools only. For sensitive code paths.', active: false, policies: 6 },
];
function Profiles() {
React.useEffect(() => { requestAnimationFrame(() => window.lucide && window.lucide.createIcons()); });
return (
<div style={{ display: 'flex', flexDirection: 'column', height: '100%' }}>
<ContentHeader title="Profiles"
subtitle="Bundles of policies you switch between per-project or per-task"
actions={<Btn kind="primary" icon="plus">New profile</Btn>} />
<div style={{ flex: 1, overflowY: 'auto', padding: 32 }}>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(280px, 1fr))', gap: 14 }}>
{PROFILES.map(p => (
<Card key={p.id} interactive padding={20}
style={{ borderColor: p.active ? 'var(--accent)' : 'var(--border)',
boxShadow: p.active ? '0 0 0 2px var(--accent-tint)' : 'var(--shadow-sm)' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 10, marginBottom: 8 }}>
<i data-lucide="user-cog" style={{ width: 18, height: 18,
color: p.active ? 'var(--accent)' : 'var(--fg-muted)' }}></i>
<div style={{ fontSize: 15, fontWeight: 600, flex: 1 }}>{p.name}</div>
{p.active && <Pill tone="accent" dot>active</Pill>}
</div>
<div style={{ fontSize: 12.5, color: 'var(--fg-muted)', lineHeight: 1.5, marginBottom: 14, minHeight: 36 }}>{p.desc}</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, fontSize: 11, color: 'var(--fg-faint)', fontFamily: 'var(--font-mono)' }}>
<i data-lucide="shield" style={{ width: 12, height: 12 }}></i>
{p.policies} policies
<Btn size="sm" style={{ marginLeft: 'auto' }}>{p.active ? 'Edit' : 'Activate'}</Btn>
</div>
</Card>
))}
</div>
</div>
</div>
);
}
window.Profiles = Profiles;
// ─────────────── Gateway ───────────────
function Gateway() {
React.useEffect(() => { requestAnimationFrame(() => window.lucide && window.lucide.createIcons()); });
return (
<div style={{ display: 'flex', flexDirection: 'column', height: '100%' }}>
<ContentHeader title="Gateway"
subtitle="Local proxy that routes every model & tool call. Logs, redacts, enforces policies."
actions={<Btn icon="rotate-cw">Restart</Btn>} />
<div style={{ flex: 1, overflowY: 'auto', padding: '24px 32px' }}>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(4, 1fr)', gap: 10, marginBottom: 24 }}>
<StatCard label="Status" value="running" sub="pid 84021 · uptime 4d 2h" accent="var(--green-600)" />
<StatCard label="Listening" value=":7421" sub="loopback only" />
<StatCard label="Calls (24h)" value="1,284" sub="13 denied · 4 errored" />
<StatCard label="Throughput" value="2.4 MB/s" sub="p95: 6.1 MB/s" />
</div>
<SettingsGroup title="Network">
<SettingsRow icon="globe" title="Listen address"
description="The gateway binds to this address. Default loopback only."
control={<TextInput value="127.0.0.1:7421" mono />} />
<SettingsRow icon="lock" title="TLS"
description="Use a self-signed cert for outbound to 127.0.0.1."
control={<Toggle on={true} />} />
<SettingsRow icon="filter" title="Allowed hosts"
description="3 entries — api.anthropic.com, mcp.github.com, mcp.linear.app"
control={<Btn size="sm">Edit</Btn>} last />
</SettingsGroup>
<SettingsGroup title="Logging & redaction">
<SettingsRow icon="file-text" title="Request logging"
description="Persist headers + bodies for 7 days."
control={<Toggle on={true} />} />
<SettingsRow icon="eye-off" title="Redact secrets"
description="Mask values matching credential patterns before logging."
control={<Toggle on={true} />} />
<SettingsRow icon="archive" title="Log retention"
description="Older logs are pruned automatically."
control={<Select value="7d" options={[
{ value: '1d', label: '1 day' }, { value: '7d', label: '7 days' },
{ value: '30d', label: '30 days' }, { value: 'forever', label: 'Forever' },
]} />} last />
</SettingsGroup>
<SettingsGroup title="Performance">
<SettingsRow icon="zap" title="Concurrent requests"
control={<TextInput value="16" mono />} />
<SettingsRow icon="hourglass" title="Per-call timeout"
control={<Select value="60s" options={[
{ value: '30s', label: '30 seconds' }, { value: '60s', label: '60 seconds' },
{ value: '5m', label: '5 minutes' }, { value: '15m', label: '15 minutes' },
]} />} last />
</SettingsGroup>
</div>
</div>
);
}
window.Gateway = Gateway;
+83
View File
@@ -0,0 +1,83 @@
// Projects — list of project folders the agent operates in.
function Projects() {
const projects = [
{ id: 1, name: 'hermes-blog', dir: '~/code/hermes-blog', template: 'awizemann/hermes-blog', sessions: 142, lastRun: '14m ago', cron: 2, status: 'healthy' },
{ id: 2, name: 'scarf', dir: '~/code/scarf', template: '—', sessions: 89, lastRun: '42m ago', cron: 0, status: 'healthy' },
{ id: 3, name: 'inbox-sweep', dir: '~/code/inbox-sweep', template: 'community/inbox-sweep', sessions: 38, lastRun: '3h ago', cron: 1, status: 'healthy' },
{ id: 4, name: 'twitter-recap', dir: '~/code/twitter-recap', template: 'awizemann/twitter-recap', sessions: 14, lastRun: '2d ago', cron: 1, status: 'paused' },
{ id: 5, name: 'pr-watcher', dir: '~/code/pr-watcher', template: 'community/pr-watcher', sessions: 4, lastRun: '5d ago', cron: 1, status: 'errored' },
];
return (
<div style={{ display: 'flex', flexDirection: 'column', height: '100%' }}>
<ContentHeader title="Projects"
subtitle="Each project pins context, AGENTS.md, cron jobs, and session history"
actions={<><Btn icon="folder-plus">Add Existing</Btn><Btn kind="primary" icon="plus">New from Template</Btn></>} />
<div style={{ flex: 1, overflowY: 'auto', padding: '20px 28px' }}>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(300px, 1fr))', gap: 14 }}>
{projects.map(p => (
<Card key={p.id} padding={16} style={{ display: 'flex', flexDirection: 'column', gap: 10, cursor: 'pointer' }}>
<div style={{ display: 'flex', alignItems: 'flex-start', gap: 10 }}>
<div style={{
width: 36, height: 36, borderRadius: 8, background: 'var(--accent-tint)',
display: 'flex', alignItems: 'center', justifyContent: 'center',
color: 'var(--accent)', flexShrink: 0,
}}>
<i data-lucide="folder" style={{ width: 18, height: 18 }}></i>
</div>
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ fontSize: 14, fontWeight: 600 }}>{p.name}</div>
<div style={{ fontSize: 11, color: 'var(--fg-muted)',
fontFamily: 'var(--font-mono)', overflow: 'hidden',
textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{p.dir}</div>
</div>
{p.status === 'healthy' && <Pill tone="green" dot>healthy</Pill>}
{p.status === 'paused' && <Pill tone="gray" dot>paused</Pill>}
{p.status === 'errored' && <Pill tone="red" dot>errored</Pill>}
</div>
{p.template !== '—' && (
<div style={{ fontSize: 11, color: 'var(--fg-muted)',
display: 'flex', alignItems: 'center', gap: 5 }}>
<i data-lucide="package" style={{ width: 11, height: 11 }}></i>
<span style={{ fontFamily: 'var(--font-mono)' }}>{p.template}</span>
</div>
)}
<div style={{ display: 'flex', gap: 16, paddingTop: 8,
borderTop: '0.5px solid var(--border)', fontSize: 11 }}>
<div>
<div style={{ color: 'var(--fg-muted)' }}>Sessions</div>
<div style={{ fontFamily: 'var(--font-mono)', fontSize: 13, fontWeight: 600, marginTop: 1 }}>{p.sessions}</div>
</div>
<div>
<div style={{ color: 'var(--fg-muted)' }}>Cron jobs</div>
<div style={{ fontFamily: 'var(--font-mono)', fontSize: 13, fontWeight: 600, marginTop: 1 }}>{p.cron}</div>
</div>
<div style={{ marginLeft: 'auto', textAlign: 'right' }}>
<div style={{ color: 'var(--fg-muted)' }}>Last run</div>
<div style={{ fontSize: 12, marginTop: 1 }}>{p.lastRun}</div>
</div>
</div>
</Card>
))}
<Card padding={16} style={{
display: 'flex', alignItems: 'center', justifyContent: 'center',
border: '1px dashed var(--border-strong)', boxShadow: 'none',
background: 'transparent', minHeight: 140, cursor: 'pointer',
color: 'var(--fg-muted)', flexDirection: 'column', gap: 8,
}}>
<i data-lucide="plus" style={{ width: 24, height: 24 }}></i>
<div style={{ fontSize: 13, fontWeight: 500 }}>New project</div>
<div style={{ fontSize: 11, textAlign: 'center', maxWidth: 180 }}>From template, GitHub repo, or empty folder</div>
</Card>
</div>
</div>
</div>
);
}
window.Projects = Projects;
+42
View File
@@ -0,0 +1,42 @@
# Scarf macOS UI Kit
A high-fidelity React recreation of the Scarf macOS app, built against the codebase at `awizemann/scarf` (SwiftUI). It mirrors the real navigation hierarchy from `SidebarView.swift` and the visual rhythm of the actual SwiftUI views (`Dashboard`, `RichChat`, `Sessions`, `Projects`, `Insights`, etc.).
This kit is **cosmetic** — it gets the visuals exactly right but doesn't replicate the Swift business logic. Use it as a starting point for new flows, mocks, or marketing screenshots.
## Run
Open `index.html` in a browser. No build step.
## Components
| File | What it covers |
|---|---|
| `Common.jsx` | `Btn`, `Pill`, `Card`, `StatCard`, `Field`, `TextInput`, `Toggle`, `EmptyState`, `ContentHeader` |
| `Sidebar.jsx` | Sectioned sidebar (Monitor / Projects / Interact / Configure / Manage) — exact section/item list from `SidebarView.swift` |
| `Dashboard.jsx` | Status row, 7-day stats, recent sessions, recent activity |
| `Sessions.jsx` | Filterable, sortable session table |
| `Insights.jsx` | Token-usage chart, by-model and by-tool-kind breakdowns |
| `Projects.jsx` | Project grid with template / cron / health badges |
| `Chat.jsx` | Three-pane Rich Chat — list, transcript with reasoning + tool-call cards, composer |
## Faithful to the source
Replicated 1:1:
- **Sidebar grouping** — five named sections from `SidebarView.swift` in the same order.
- **Tool-kind colors** — `read=green / edit=blue / execute=orange / fetch=purple / browser=indigo / other=gray`, the same tokens used in `ToolCallCard.swift`.
- **Reasoning disclosure** — collapsed orange "REASONING · N tokens" header that expands to italic muted text, matching `RichAssistantMessageView`.
- **Tool-call card chrome** — left tone-rule, monospace name + truncated arg, success/error/spinner trailing, expandable code preview.
- **Status pills** — green/red dot with same word vocabulary (`Running` / `Errored` / `Idle`).
- **Type rhythm** — SwiftUI `largeTitle / title1 / title2 / headline / subhead / body / caption` mapped to `--text-*` tokens.
## Substitutions
- **Icons** — Lucide for the web. SF Symbols aren't redistributable; Lucide is the closest stroked-line set. Documented in `/README.md` → ICONOGRAPHY.
- **Fonts** — system stack first, then Inter (display/text) and JetBrains Mono (mono) loaded from Google Fonts. On macOS users will see SF Pro / SF Mono.
- **Window chrome** — three traffic-light dots painted by hand. The starter `macos-window.jsx` was tried first but its sidebar slot didn't match Scarf's layout, so the chrome is inlined in `index.html`.
## What's intentionally left blank
The placeholder view wired to every sidebar item that isn't one of the five built screens — Activity, Memory, Skills, Platforms, Personalities, Quick Commands, Credentials, Plugins, Webhooks, Profiles, Tools, MCP Servers, Gateway, Cron, Health, Logs, Settings. Each lands on a polite `EmptyState` so navigation is still satisfying. Build any of them by following `Sessions.jsx` (table view) or `Projects.jsx` (card grid) — Scarf is consistent enough that those two patterns cover almost every CRUD pane.
+222
View File
@@ -0,0 +1,222 @@
// Sessions list view — with filters (incl. project filter) and a detail row.
function Sessions() {
const [filter, setFilter] = React.useState('all');
const [project, setProject] = React.useState('all'); // project filter
const [projectMenuOpen, setProjectMenuOpen] = React.useState(false);
const projectMenuRef = React.useRef();
React.useEffect(() => {
function onDoc(e) {
if (projectMenuRef.current && !projectMenuRef.current.contains(e.target)) {
setProjectMenuOpen(false);
}
}
if (projectMenuOpen) {
document.addEventListener('mousedown', onDoc);
return () => document.removeEventListener('mousedown', onDoc);
}
}, [projectMenuOpen]);
const filters = [
{ id: 'all', label: 'All', count: 847 },
{ id: 'today', label: 'Today', count: 24 },
{ id: 'starred', label: 'Starred', count: 6 },
];
const allRows = [
{ id: 1, project: 'scarf', title: 'Cron diagnostics', model: 'sonnet-4.5', msgs: 14, tokens: '12,847', cost: '$0.04', time: '14m ago', status: 'active' },
{ id: 2, project: 'hermes-blog', title: 'Release notes draft', model: 'haiku-4.5', msgs: 8, tokens: '3,210', cost: '$0.01', time: '42m ago', status: 'idle' },
{ id: 3, project: 'hermes-blog', title: 'PR review summary', model: 'sonnet-4.5', msgs: 22, tokens: '24,108', cost: '$0.08', time: '2h ago', status: 'idle' },
{ id: 4, project: '—', title: 'What model handles function calls best?', model: 'haiku-4.5', msgs: 4, tokens: '284', cost: '$0.00', time: '3h ago', status: 'idle' },
{ id: 5, project: 'scarf', title: 'Memory layout question', model: 'sonnet-4.5', msgs: 11, tokens: '4,892', cost: '$0.02', time: 'yesterday', status: 'idle' },
{ id: 6, project: 'scarf', title: 'Refactor SidebarSection enum', model: 'sonnet-4.5', msgs: 31, tokens: '38,221', cost: '$0.13', time: 'yesterday', status: 'errored' },
{ id: 7, project: 'hermes-blog', title: 'Twitter recap thread', model: 'haiku-4.5', msgs: 6, tokens: '1,247', cost: '$0.00', time: '2 days ago', status: 'idle' },
{ id: 8, project: '—', title: 'Find a good local TTS model', model: 'sonnet-4.5', msgs: 19, tokens: '8,743', cost: '$0.03', time: '3 days ago', status: 'idle' },
];
// Build project facet — counts per project, plus an "Unscoped" bucket.
const projectCounts = allRows.reduce((acc, r) => {
acc[r.project] = (acc[r.project] || 0) + 1;
return acc;
}, {});
const projects = [
{ id: 'all', label: 'All projects', icon: 'layers', count: allRows.length },
...Object.keys(projectCounts).filter(k => k !== '—').sort().map(k => ({
id: k, label: k, icon: 'folder', count: projectCounts[k],
})),
{ id: '—', label: 'Unscoped', icon: 'ghost', count: projectCounts['—'] || 0 },
];
const rows = allRows.filter(r => project === 'all' ? true : r.project === project);
const activeProject = projects.find(p => p.id === project) || projects[0];
return (
<div style={{ display: 'flex', flexDirection: 'column', height: '100%' }}>
<ContentHeader title="Sessions"
subtitle="Every conversation across projects, agents, and models"
actions={<><Btn icon="filter">Filter</Btn><Btn icon="download">Export</Btn></>} />
<div style={{ padding: '14px 28px 0', display: 'flex', alignItems: 'center', gap: 8, flexWrap: 'wrap' }}>
{filters.map(f => (
<div key={f.id} onClick={() => setFilter(f.id)} style={{
padding: '4px 11px', borderRadius: 999, cursor: 'pointer', fontSize: 12,
fontWeight: 500,
background: filter === f.id ? 'var(--accent)' : 'var(--bg-quaternary)',
color: filter === f.id ? '#fff' : 'var(--fg)',
display: 'flex', alignItems: 'center', gap: 5,
}}>{f.label}<span style={{
opacity: 0.7, fontFamily: 'var(--font-mono)',
}}>{f.count}</span></div>
))}
{/* Vertical separator */}
<div style={{ width: 1, height: 18, background: 'var(--border)', margin: '0 4px' }}></div>
{/* Project filter chip — opens a dropdown */}
<div ref={projectMenuRef} style={{ position: 'relative' }}>
<div onClick={() => setProjectMenuOpen(o => !o)} style={{
padding: '4px 6px 4px 11px', borderRadius: 999, cursor: 'pointer', fontSize: 12,
fontWeight: 500,
background: project !== 'all' ? 'var(--accent-tint)' : 'var(--bg-quaternary)',
color: project !== 'all' ? 'var(--accent-active)' : 'var(--fg)',
border: project !== 'all' ? '1px solid var(--accent)' : '1px solid transparent',
display: 'flex', alignItems: 'center', gap: 6,
}}>
<i data-lucide={activeProject.icon}
style={{ width: 12, height: 12 }}></i>
<span>{activeProject.label}</span>
<span style={{ opacity: 0.7, fontFamily: 'var(--font-mono)' }}>{activeProject.count}</span>
{project !== 'all' && (
<i data-lucide="x" onClick={(e) => { e.stopPropagation(); setProject('all'); }}
style={{ width: 12, height: 12, marginLeft: 2, padding: 1, borderRadius: 3 }}></i>
)}
{project === 'all' && (
<i data-lucide="chevron-down" style={{ width: 12, height: 12, marginLeft: 2, opacity: 0.7 }}></i>
)}
</div>
{projectMenuOpen && (
<div style={{
position: 'absolute', top: '100%', left: 0, marginTop: 6, zIndex: 50,
minWidth: 220, padding: 4, background: 'var(--bg-card)',
border: '0.5px solid var(--border)', borderRadius: 9,
boxShadow: 'var(--shadow-lg)', fontFamily: 'var(--font-sans)',
}}>
<div style={{
padding: '6px 10px 4px', fontSize: 10, fontWeight: 600,
color: 'var(--fg-muted)', textTransform: 'uppercase', letterSpacing: '0.06em',
}}>Filter by project</div>
{projects.map(p => {
const active = p.id === project;
return (
<div key={p.id} onClick={() => { setProject(p.id); setProjectMenuOpen(false); }}
style={{
display: 'flex', alignItems: 'center', gap: 9, padding: '6px 10px',
borderRadius: 6, cursor: 'pointer', fontSize: 13,
background: active ? 'var(--accent-tint)' : 'transparent',
color: active ? 'var(--accent-active)' : 'var(--fg)',
}}
onMouseEnter={e => { if (!active) e.currentTarget.style.background = 'var(--bg-quaternary)'; }}
onMouseLeave={e => { if (!active) e.currentTarget.style.background = 'transparent'; }}
>
<i data-lucide={p.icon} style={{ width: 13, height: 13,
color: active ? 'var(--accent-active)' : 'var(--fg-muted)' }}></i>
<span style={{ flex: 1,
fontStyle: p.id === '—' ? 'italic' : 'normal',
color: p.id === '—' && !active ? 'var(--fg-muted)' : 'inherit' }}>{p.label}</span>
<span style={{ fontFamily: 'var(--font-mono)', fontSize: 11,
color: active ? 'var(--accent-active)' : 'var(--fg-faint)' }}>{p.count}</span>
{active && <i data-lucide="check" style={{ width: 13, height: 13 }}></i>}
</div>
);
})}
</div>
)}
</div>
<div style={{ marginLeft: 'auto', position: 'relative' }}>
<i data-lucide="search" style={{
position: 'absolute', left: 8, top: 6, width: 13, height: 13, color: 'var(--fg-faint)'
}}></i>
<input placeholder="Search sessions…" style={{
width: 200, padding: '4px 10px 4px 28px',
border: '1px solid var(--border-strong)', borderRadius: 6,
fontSize: 12, background: 'var(--bg-card)', outline: 'none',
fontFamily: 'var(--font-sans)',
}} />
</div>
</div>
{/* Active filter summary */}
{project !== 'all' && (
<div style={{ padding: '8px 28px 0', fontSize: 12, color: 'var(--fg-muted)' }}>
Showing {rows.length} session{rows.length === 1 ? '' : 's'} from
{' '}<span style={{ color: 'var(--fg)', fontWeight: 500 }}>{activeProject.label}</span>
{' '}·{' '}
<span onClick={() => setProject('all')} style={{
color: 'var(--accent-active)', cursor: 'pointer', textDecoration: 'underline',
textDecorationStyle: 'dotted', textUnderlineOffset: 3,
}}>clear filter</span>
</div>
)}
<div style={{ flex: 1, overflowY: 'auto', padding: '14px 28px 28px' }}>
<Card padding={0}>
<div style={{
display: 'grid', gridTemplateColumns: '120px 1fr 110px 60px 90px 70px 80px 24px',
padding: '8px 14px', borderBottom: '0.5px solid var(--border)',
fontSize: 11, color: 'var(--fg-muted)', fontWeight: 600,
textTransform: 'uppercase', letterSpacing: '0.05em',
}}>
<div>Project</div><div>Title</div><div>Model</div>
<div style={{ textAlign: 'right' }}>Msgs</div>
<div style={{ textAlign: 'right' }}>Tokens</div>
<div style={{ textAlign: 'right' }}>Cost</div>
<div style={{ textAlign: 'right' }}>Updated</div>
<div></div>
</div>
{rows.length === 0 && (
<div style={{ padding: 48, textAlign: 'center', color: 'var(--fg-muted)', fontSize: 13 }}>
No sessions match this filter.
</div>
)}
{rows.map(r => (
<div key={r.id} style={{
display: 'grid', gridTemplateColumns: '120px 1fr 110px 60px 90px 70px 80px 24px',
padding: '10px 14px', borderBottom: '0.5px solid var(--border)',
alignItems: 'center', fontSize: 13, cursor: 'pointer', gap: 6,
}} onMouseEnter={e => e.currentTarget.style.background = 'var(--bg-quaternary)'}
onMouseLeave={e => e.currentTarget.style.background = 'transparent'}>
<div>
{r.project !== '—'
? <span onClick={(e) => { e.stopPropagation(); setProject(r.project); }}
title={`Filter by ${r.project}`}
style={{ display: 'inline-block' }}>
<Pill tone="accent">{r.project}</Pill>
</span>
: <span style={{ color: 'var(--fg-faint)', fontSize: 11 }}></span>}
</div>
<div style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap',
display: 'flex', gap: 6, alignItems: 'center' }}>
{r.status === 'active' && <span style={{ width: 7, height: 7, borderRadius: '50%', background: 'var(--green-500)' }}></span>}
{r.status === 'errored' && <span style={{ width: 7, height: 7, borderRadius: '50%', background: 'var(--red-500)' }}></span>}
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{r.title}</span>
</div>
<div style={{ fontFamily: 'var(--font-mono)', fontSize: 11, color: 'var(--fg-muted)' }}>{r.model}</div>
<div style={{ fontFamily: 'var(--font-mono)', fontSize: 12, textAlign: 'right' }}>{r.msgs}</div>
<div style={{ fontFamily: 'var(--font-mono)', fontSize: 12, textAlign: 'right' }}>{r.tokens}</div>
<div style={{ fontFamily: 'var(--font-mono)', fontSize: 12, textAlign: 'right', color: 'var(--fg-muted)' }}>{r.cost}</div>
<div style={{ fontSize: 11, color: 'var(--fg-faint)', textAlign: 'right' }}>{r.time}</div>
<div style={{ color: 'var(--fg-faint)' }}>
<i data-lucide="chevron-right" style={{ width: 14, height: 14 }}></i>
</div>
</div>
))}
</Card>
</div>
</div>
);
}
window.Sessions = Sessions;
+189
View File
@@ -0,0 +1,189 @@
// Settings — global preferences. One scrollable page with grouped settings.
function Settings() {
React.useEffect(() => { requestAnimationFrame(() => window.lucide && window.lucide.createIcons()); });
const [tab, setTab] = React.useState('general');
return (
<div style={{ display: 'flex', flexDirection: 'column', height: '100%' }}>
<ContentHeader title="Settings" subtitle="Global preferences for Scarf. Per-project overrides live in each project." />
<div style={{ padding: '12px 32px 0', borderBottom: '0.5px solid var(--border)', background: 'var(--bg-card)' }}>
<Tabs value={tab} onChange={setTab} options={[
{ value: 'general', label: 'General', icon: 'sliders-horizontal' },
{ value: 'appearance', label: 'Appearance', icon: 'palette' },
{ value: 'agent', label: 'Agent', icon: 'cpu' },
{ value: 'permissions', label: 'Permissions', icon: 'shield' },
{ value: 'account', label: 'Account', icon: 'user-circle' },
{ value: 'advanced', label: 'Advanced', icon: 'wrench' },
]} />
</div>
<div style={{ flex: 1, overflowY: 'auto', padding: '24px 32px', maxWidth: 880 }}>
{tab === 'general' && <GeneralTab />}
{tab === 'appearance' && <AppearanceTab />}
{tab === 'agent' && <AgentTab />}
{tab === 'permissions' && <PermissionsTab />}
{tab === 'account' && <AccountTab />}
{tab === 'advanced' && <AdvancedTab />}
</div>
</div>
);
}
function GeneralTab() {
return <>
<SettingsGroup title="Workspace">
<SettingsRow icon="folder" title="Default project location"
description="New projects are created here unless overridden."
control={<Btn size="sm" icon="folder-open">~/Projects</Btn>} />
<SettingsRow icon="terminal" title="Default shell"
description="Used when the agent runs commands."
control={<Select value="zsh" options={[{value:'zsh',label:'/bin/zsh'},{value:'bash',label:'/bin/bash'},{value:'fish',label:'/usr/local/bin/fish'}]} />} />
<SettingsRow icon="globe" title="Locale" description="Affects date and number formatting."
control={<Select value="en-US" options={[{value:'en-US',label:'English (US)'},{value:'en-GB',label:'English (UK)'},{value:'de-DE',label:'Deutsch'}]} />} last />
</SettingsGroup>
<SettingsGroup title="Notifications">
<SettingsRow icon="bell" title="Approval requests" description="Notify when the agent needs permission to run a tool."
control={<Toggle on={true} />} />
<SettingsRow icon="check-circle" title="Run completion" description="Ping when long-running tasks finish."
control={<Toggle on={true} />} />
<SettingsRow icon="alert-triangle" title="Errors only" description="Suppress non-error notifications."
control={<Toggle on={false} />} last />
</SettingsGroup>
<SettingsGroup title="Updates">
<SettingsRow icon="download" title="Auto-update Scarf"
description="Currently on 0.14.2 — 0.15.0 available."
control={<Btn size="sm" kind="primary">Install 0.15.0</Btn>} last />
</SettingsGroup>
</>;
}
function AppearanceTab() {
return <>
<SettingsGroup title="Theme">
<SettingsRow icon="sun" title="Color mode" description="Light is the only mode shipped in this kit."
control={<Segmented value="light" options={[
{ value: 'light', label: 'Light', icon: 'sun' },
{ value: 'dark', label: 'Dark', icon: 'moon' },
{ value: 'auto', label: 'Auto', icon: 'monitor' },
]} />} />
<SettingsRow icon="droplet" title="Accent" description="Scarf uses a warm rust accent across the app."
control={
<div style={{ display: 'flex', gap: 6 }}>
{['#C25A2A','#A8741F','#7E5BA9','#3F8A6E','#3F6BA9','#1F1B16'].map((c,i) =>
<div key={i} style={{ width: 22, height: 22, borderRadius: 11, background: c,
border: i === 0 ? '2px solid var(--fg)' : '0.5px solid var(--border)', cursor: 'pointer' }} />)}
</div>} last />
</SettingsGroup>
<SettingsGroup title="Density & type">
<SettingsRow icon="rows-3" title="UI density"
control={<Segmented value="comfy" options={[
{ value: 'compact', label: 'Compact' }, { value: 'comfy', label: 'Comfortable' }, { value: 'roomy', label: 'Roomy' },
]} />} />
<SettingsRow icon="type" title="Mono font" description="Used in code blocks, logs, and identifiers."
control={<Select value="berkeley" options={[
{ value: 'berkeley', label: 'Berkeley Mono' },
{ value: 'jetbrains', label: 'JetBrains Mono' },
{ value: 'sf-mono', label: 'SF Mono' },
]} />} last />
</SettingsGroup>
</>;
}
function AgentTab() {
return <>
<SettingsGroup title="Default model" description="Used when no personality overrides it.">
<SettingsRow icon="sparkles" title="Model"
control={<Select value="sonnet" options={[
{ value: 'sonnet', label: 'claude-sonnet-4.5' }, { value: 'opus', label: 'claude-opus-4.1' }, { value: 'haiku', label: 'claude-haiku-4.5' },
]} />} />
<SettingsRow icon="thermometer" title="Temperature"
description="Lower is more deterministic. Default 0.4."
control={<TextInput value="0.4" mono />} />
<SettingsRow icon="cpu" title="Max tokens out"
control={<TextInput value="4096" mono />} last />
</SettingsGroup>
<SettingsGroup title="Behavior">
<SettingsRow icon="message-square" title="Stream responses"
control={<Toggle on={true} />} />
<SettingsRow icon="fast-forward" title="Aggressive tool batching"
description="Allow multiple parallel tool calls per turn." control={<Toggle on={true} />} />
<SettingsRow icon="rotate-cw" title="Retry on transient errors"
control={<Toggle on={true} />} last />
</SettingsGroup>
</>;
}
function PermissionsTab() {
return <>
<SettingsGroup title="Defaults" description="Override per-tool in Tools, per-project in each project.">
<SettingsRow icon="book-open" title="Read filesystem"
control={<Pill tone="green" dot>auto</Pill>} />
<SettingsRow icon="file-edit" title="Write filesystem"
control={<Pill tone="amber" dot>approve</Pill>} />
<SettingsRow icon="terminal" title="Execute commands"
control={<Pill tone="amber" dot>approve</Pill>} />
<SettingsRow icon="globe" title="Network access"
control={<Pill tone="green" dot>auto</Pill>} last />
</SettingsGroup>
<SettingsGroup title="Deny rules" description="Patterns the agent can never run.">
<SettingsRow icon="ban" title="rm -rf /"
control={<Btn size="sm">Edit</Btn>} />
<SettingsRow icon="ban" title="git push --force* (origin/main, origin/prod)"
control={<Btn size="sm">Edit</Btn>} />
<SettingsRow icon="plus" title="Add rule" control={<Btn size="sm" kind="primary">New</Btn>} last />
</SettingsGroup>
</>;
}
function AccountTab() {
return <>
<SettingsGroup title="Account">
<SettingsRow icon="user-circle" title="Aurora Wong"
description="aurora@wizemann.com — connected via Anthropic Console"
control={<Btn size="sm">Sign out</Btn>} last />
</SettingsGroup>
<SettingsGroup title="Plan & billing">
<SettingsRow icon="zap" title="Team — 5 seats"
description="Renews May 12. $99/mo."
control={<Btn size="sm" icon="external-link">Manage</Btn>} />
<SettingsRow icon="bar-chart-2" title="Usage this month"
description="$42.18 of $200 cap"
control={<div style={{ width: 140 }}><ProgressBar value={21} /></div>} last />
</SettingsGroup>
<SettingsGroup title="Danger zone">
<SettingsRow icon="trash-2" title="Reset all settings"
description="Returns Scarf to defaults. Projects and history are preserved."
control={<Btn size="sm" kind="danger">Reset</Btn>} />
<SettingsRow icon="x-circle" title="Delete account"
description="Permanently delete this account. This cannot be undone."
control={<Btn size="sm" kind="danger-solid">Delete</Btn>} last />
</SettingsGroup>
</>;
}
function AdvancedTab() {
return <>
<SettingsGroup title="Telemetry">
<SettingsRow icon="bar-chart" title="Anonymous usage data"
description="Helps improve Scarf. No prompts or file contents are sent."
control={<Toggle on={true} />} />
<SettingsRow icon="bug" title="Crash reports"
control={<Toggle on={true} />} last />
</SettingsGroup>
<SettingsGroup title="Experimental">
<SettingsRow icon="flask-conical" title="Multi-agent fan-out"
description="Let one agent spawn focused subagents." control={<Toggle on={false} />} />
<SettingsRow icon="flask-conical" title="Background reasoning"
description="Pre-compute likely next steps while you type." control={<Toggle on={false} />} last />
</SettingsGroup>
<SettingsGroup title="Storage" description="Local-only data on this device.">
<SettingsRow icon="database" title="Project history" description="14.2 GB across 11 projects"
control={<Btn size="sm">Manage</Btn>} />
<SettingsRow icon="hard-drive" title="Cache"
description="412 MB"
control={<Btn size="sm">Clear</Btn>} last />
</SettingsGroup>
</>;
}
window.Settings = Settings;
+95
View File
@@ -0,0 +1,95 @@
// Scarf Sidebar — sectioned nav matching SidebarView.swift
// Sections: Monitor / Projects / Interact / Configure / Manage
const SIDEBAR_SECTIONS = [
{ title: 'Monitor', items: [
{ id: 'dashboard', label: 'Dashboard', icon: 'layout-dashboard' },
{ id: 'insights', label: 'Insights', icon: 'bar-chart-3' },
{ id: 'sessions', label: 'Sessions', icon: 'messages-square' },
{ id: 'activity', label: 'Activity', icon: 'activity' },
]},
{ title: 'Projects', items: [
{ id: 'projects', label: 'Projects', icon: 'folder' },
]},
{ title: 'Interact', items: [
{ id: 'chat', label: 'Chat', icon: 'sparkles' },
{ id: 'memory', label: 'Memory', icon: 'database' },
{ id: 'skills', label: 'Skills', icon: 'wand-2' },
]},
{ title: 'Configure', items: [
{ id: 'platforms', label: 'Platforms', icon: 'cloud' },
{ id: 'personalities', label: 'Personalities', icon: 'user-circle' },
{ id: 'quickCommands', label: 'Quick Commands', icon: 'zap' },
{ id: 'credentialPools', label: 'Credentials', icon: 'key' },
{ id: 'plugins', label: 'Plugins', icon: 'puzzle' },
{ id: 'webhooks', label: 'Webhooks', icon: 'webhook' },
{ id: 'profiles', label: 'Profiles', icon: 'users' },
]},
{ title: 'Manage', items: [
{ id: 'tools', label: 'Tools', icon: 'wrench' },
{ id: 'mcpServers', label: 'MCP Servers', icon: 'server' },
{ id: 'gateway', label: 'Gateway', icon: 'network' },
{ id: 'cron', label: 'Cron', icon: 'clock' },
{ id: 'health', label: 'Health', icon: 'stethoscope' },
{ id: 'logs', label: 'Logs', icon: 'file-text' },
{ id: 'settings', label: 'Settings', icon: 'settings' },
]},
];
function ScarfSidebar({ active, onSelect }) {
return (
<aside style={{
width: 224, height: '100%', display: 'flex', flexDirection: 'column',
background: 'rgba(243, 242, 245, 0.7)',
backdropFilter: 'blur(40px) saturate(180%)',
WebkitBackdropFilter: 'blur(40px) saturate(180%)',
borderRight: '0.5px solid var(--border)',
paddingTop: 38, // space for traffic lights
fontFamily: 'var(--font-sans)',
}}>
<div style={{ padding: '0 16px 12px', display: 'flex', alignItems: 'center', gap: 8 }}>
<img src="../../assets/scarf-app-icon-128.png" width="22" height="22" style={{ borderRadius: 5 }} alt="" />
<div style={{ fontSize: 14, fontWeight: 600, letterSpacing: '-0.01em' }}>Scarf</div>
<div style={{ marginLeft: 'auto', fontSize: 10, color: 'var(--fg-faint)', fontFamily: 'var(--font-mono)' }}>local</div>
</div>
<div style={{ flex: 1, overflowY: 'auto', padding: '4px 8px 16px' }}>
{SIDEBAR_SECTIONS.map(sec => (
<div key={sec.title} style={{ marginBottom: 14 }}>
<div style={{
padding: '6px 10px 4px', fontSize: 10.5, fontWeight: 600,
color: 'var(--fg-muted)', textTransform: 'uppercase', letterSpacing: '0.06em'
}}>{sec.title}</div>
{sec.items.map(it => {
const isActive = active === it.id;
return (
<div key={it.id} onClick={() => onSelect && onSelect(it.id)}
style={{
display: 'flex', alignItems: 'center', gap: 9,
padding: '5px 10px', borderRadius: 6, cursor: 'pointer',
fontSize: 13, fontWeight: isActive ? 500 : 400,
color: isActive ? 'var(--accent-active)' : 'var(--fg)',
background: isActive ? 'var(--accent-tint)' : 'transparent',
transition: 'background 120ms',
}}>
<i data-lucide={it.icon} style={{ width: 15, height: 15 }}></i>
<span>{it.label}</span>
</div>
);
})}
</div>
))}
</div>
<div style={{
padding: '10px 14px', borderTop: '0.5px solid var(--border)',
display: 'flex', alignItems: 'center', gap: 8, fontSize: 12
}}>
<div style={{ width: 7, height: 7, borderRadius: '50%', background: 'var(--green-500)' }}></div>
<span style={{ color: 'var(--fg-muted)' }}>Hermes running</span>
<span style={{ marginLeft: 'auto', fontFamily: 'var(--font-mono)', color: 'var(--fg-faint)', fontSize: 11 }}>v0.42</span>
</div>
</aside>
);
}
window.ScarfSidebar = ScarfSidebar;
window.SIDEBAR_SECTIONS = SIDEBAR_SECTIONS;
+222
View File
@@ -0,0 +1,222 @@
// Tools — registry of every callable tool, with status, kind, and a
// permission policy. Two-pane: list of tools (left), detail (right).
const TOOL_KIND_TONES = {
read: { color: 'var(--green-500)', tint: 'var(--green-100)', icon: 'book-open' },
edit: { color: 'var(--blue-500)', tint: 'var(--blue-100)', icon: 'file-edit' },
execute: { color: 'var(--orange-500)', tint: 'var(--orange-100)', icon: 'terminal' },
fetch: { color: 'var(--purple-tool-500)', tint: '#EFE0F8', icon: 'globe' },
browser: { color: 'var(--indigo-500)', tint: '#E0E5F8', icon: 'compass' },
mcp: { color: 'var(--accent)', tint: 'var(--accent-tint)',icon: 'server' },
};
const TOOLS_DATA = [
// Built-in
{ id: 'read_file', kind: 'read', source: 'built-in', server: '—', enabled: true, calls7d: 1284, lastUsed: '2m ago', policy: 'auto', desc: 'Read a file from disk by path. Honors hidden-file setting.' },
{ id: 'write_file', kind: 'edit', source: 'built-in', server: '—', enabled: true, calls7d: 412, lastUsed: '14m ago', policy: 'approve-write', desc: 'Write content to a file, creating parent directories as needed.' },
{ id: 'apply_patch', kind: 'edit', source: 'built-in', server: '—', enabled: true, calls7d: 348, lastUsed: '14m ago', policy: 'approve-write', desc: 'Apply a unified-diff patch to existing files.' },
{ id: 'list_files', kind: 'read', source: 'built-in', server: '—', enabled: true, calls7d: 928, lastUsed: '32m ago', policy: 'auto', desc: 'List entries in a directory, optionally recursive.' },
{ id: 'execute', kind: 'execute', source: 'built-in', server: '—', enabled: true, calls7d: 661, lastUsed: '14m ago', policy: 'approve-exec', desc: 'Run a shell command. Subject to gateway approval policy.' },
{ id: 'web_fetch', kind: 'fetch', source: 'built-in', server: '—', enabled: true, calls7d: 184, lastUsed: '1h ago', policy: 'auto', desc: 'Fetch a URL and return the extracted text.' },
{ id: 'web_search', kind: 'fetch', source: 'built-in', server: '—', enabled: true, calls7d: 92, lastUsed: '3h ago', policy: 'auto', desc: 'Search the public web. Returns top 10 results.' },
{ id: 'browser_navigate', kind: 'browser', source: 'built-in', server: '—', enabled: false, calls7d: 0, lastUsed: 'never', policy: 'approve-all', desc: 'Drive a Chromium instance for live page interaction.' },
// MCP
{ id: 'github__list_issues', kind: 'mcp', source: 'mcp', server: 'github', enabled: true, calls7d: 84, lastUsed: '42m ago', policy: 'auto', desc: 'List issues for a GitHub repository the user has access to.' },
{ id: 'github__create_pr', kind: 'mcp', source: 'mcp', server: 'github', enabled: true, calls7d: 12, lastUsed: 'yesterday', policy: 'approve-write', desc: 'Open a pull request from a branch.' },
{ id: 'linear__list_issues', kind: 'mcp', source: 'mcp', server: 'linear', enabled: true, calls7d: 38, lastUsed: '2h ago', policy: 'auto', desc: 'Query Linear issues with filters.' },
{ id: 'slack__send_message', kind: 'mcp', source: 'mcp', server: 'slack', enabled: false, calls7d: 0, lastUsed: 'never', policy: 'approve-all', desc: 'Post a message to a Slack channel as the connected user.' },
{ id: 'postgres__query', kind: 'mcp', source: 'mcp', server: 'postgres-prod', enabled: true, calls7d: 14, lastUsed: '4h ago', policy: 'approve-write', desc: 'Run read-only SQL against the configured database.' },
];
function Tools() {
const [active, setActive] = React.useState('execute');
const [filter, setFilter] = React.useState('all');
const [search, setSearch] = React.useState('');
React.useEffect(() => { requestAnimationFrame(() => window.lucide && window.lucide.createIcons()); });
const filtered = TOOLS_DATA.filter(t => {
if (filter === 'enabled' && !t.enabled) return false;
if (filter === 'mcp' && t.source !== 'mcp') return false;
if (filter === 'builtin' && t.source !== 'built-in') return false;
if (search && !t.id.toLowerCase().includes(search.toLowerCase())) return false;
return true;
});
const tool = TOOLS_DATA.find(t => t.id === active) || TOOLS_DATA[0];
return (
<div style={{ display: 'flex', flexDirection: 'column', height: '100%' }}>
<ContentHeader title="Tools"
subtitle="Every callable tool the agent can use, plus their gateway policy"
actions={<><Btn icon="rotate-cw">Sync</Btn><Btn kind="primary" icon="plus">Register tool</Btn></>} />
<div style={{ flex: 1, display: 'flex', minHeight: 0 }}>
{/* List */}
<div style={{ width: 380, borderRight: '0.5px solid var(--border)',
display: 'flex', flexDirection: 'column', background: 'var(--bg-card)' }}>
<div style={{ padding: '14px 14px 8px', display: 'flex', flexDirection: 'column', gap: 10 }}>
<TextInput value={search} onChange={setSearch} leftIcon="search" placeholder="Search tools…" mono />
<Segmented value={filter} onChange={setFilter} size="sm" options={[
{ value: 'all', label: 'All', count: TOOLS_DATA.length },
{ value: 'enabled', label: 'Enabled', count: TOOLS_DATA.filter(t => t.enabled).length },
{ value: 'mcp', label: 'MCP', count: TOOLS_DATA.filter(t => t.source === 'mcp').length },
{ value: 'builtin', label: 'Built-in', count: TOOLS_DATA.filter(t => t.source === 'built-in').length },
]} />
</div>
<div style={{ flex: 1, overflowY: 'auto', padding: '0 6px 8px' }}>
<ToolGroupHeader>Built-in</ToolGroupHeader>
{filtered.filter(t => t.source === 'built-in').map(t =>
<ToolRow key={t.id} t={t} active={t.id === active} onClick={() => setActive(t.id)} />
)}
<ToolGroupHeader>MCP servers</ToolGroupHeader>
{filtered.filter(t => t.source === 'mcp').map(t =>
<ToolRow key={t.id} t={t} active={t.id === active} onClick={() => setActive(t.id)} />
)}
</div>
</div>
{/* Detail */}
<div style={{ flex: 1, overflowY: 'auto', background: 'var(--bg)', padding: '24px 32px' }}>
<ToolDetail tool={tool} />
</div>
</div>
</div>
);
}
function ToolGroupHeader({ children }) {
return (
<div style={{
padding: '12px 10px 4px', fontSize: 10, fontWeight: 600,
color: 'var(--fg-muted)', textTransform: 'uppercase', letterSpacing: '0.06em',
}}>{children}</div>
);
}
function ToolRow({ t, active, onClick }) {
const tone = TOOL_KIND_TONES[t.kind] || TOOL_KIND_TONES.read;
const [hover, setHover] = React.useState(false);
return (
<div onClick={onClick} onMouseEnter={() => setHover(true)} onMouseLeave={() => setHover(false)} style={{
padding: '8px 10px', borderRadius: 7, cursor: 'pointer', marginBottom: 1,
background: active ? 'var(--accent-tint)' : (hover ? 'var(--bg-quaternary)' : 'transparent'),
display: 'flex', alignItems: 'center', gap: 9,
}}>
<div style={{
width: 24, height: 24, borderRadius: 6, background: tone.tint, color: tone.color,
display: 'flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0,
}}>
<i data-lucide={tone.icon} style={{ width: 13, height: 13 }}></i>
</div>
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ fontSize: 13, fontWeight: 500, fontFamily: 'var(--font-mono)',
color: active ? 'var(--accent-active)' : 'var(--fg)',
whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>{t.id}</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 6, fontSize: 11, color: 'var(--fg-faint)', marginTop: 1 }}>
{t.server !== '—' && <span style={{ fontFamily: 'var(--font-mono)' }}>{t.server}</span>}
<span>· {t.calls7d.toLocaleString()} calls</span>
</div>
</div>
{t.enabled
? <Dot tone="green" />
: <Dot tone="gray" />}
</div>
);
}
function ToolDetail({ tool }) {
const tone = TOOL_KIND_TONES[tool.kind] || TOOL_KIND_TONES.read;
return (
<>
<div style={{ display: 'flex', alignItems: 'flex-start', gap: 14, marginBottom: 22 }}>
<div style={{
width: 44, height: 44, borderRadius: 9, background: tone.tint, color: tone.color,
display: 'flex', alignItems: 'center', justifyContent: 'center',
}}>
<i data-lucide={tone.icon} style={{ width: 22, height: 22 }}></i>
</div>
<div style={{ flex: 1 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 4 }}>
<div className="scarf-h2" style={{ fontFamily: 'var(--font-mono)', fontSize: 22 }}>{tool.id}</div>
{tool.enabled ? <Pill tone="green" dot>enabled</Pill> : <Pill tone="gray" dot>disabled</Pill>}
</div>
<div style={{ fontSize: 13, color: 'var(--fg-muted)', maxWidth: 560 }}>{tool.desc}</div>
</div>
<Toggle on={tool.enabled} size="lg" />
</div>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(4, 1fr)', gap: 10, marginBottom: 24 }}>
<StatCard label="Calls (7d)" value={tool.calls7d.toLocaleString()} />
<StatCard label="Last used" value={tool.lastUsed} />
<StatCard label="Avg duration" value="142 ms" sub="p95: 920 ms" />
<StatCard label="Error rate" value="0.4%" sub="3 of 661 calls" />
</div>
<SettingsGroup title="Permissions" description="Applied at the gateway. Per-project profiles can override.">
<SettingsRow icon="shield-check" title="Default policy"
description={POLICY_DESC[tool.policy]}
control={<Select value={tool.policy} options={[
{ value: 'auto', label: 'Auto-approve' },
{ value: 'approve-write', label: 'Approve writes' },
{ value: 'approve-exec', label: 'Approve every call' },
{ value: 'approve-all', label: 'Approve every call (strict)' },
{ value: 'deny', label: 'Deny' },
]} />} />
<SettingsRow icon="users" title="Per-project overrides"
description="2 projects override the default policy for this tool."
control={<Btn size="sm" icon="external-link">Manage</Btn>} last />
</SettingsGroup>
<SettingsGroup title="Schema" description="JSON Schema declared by the tool. Read-only.">
<div style={{ background: 'var(--gray-900)', color: '#E8E1D2', padding: 14,
fontFamily: 'var(--font-mono)', fontSize: 11.5, lineHeight: 1.55,
borderRadius: '0 0 10px 10px' }}>
{`{
"name": "${tool.id}",
"input_schema": {
"type": "object",
"properties": {
"command": { "type": "string", "description": "Shell command" },
"cwd": { "type": "string", "default": "$PWD" },
"timeout": { "type": "integer", "default": 60 }
},
"required": ["command"]
}
}`}
</div>
</SettingsGroup>
<SettingsGroup title="Recent calls">
<RecentCallRow when="2m ago" args="hermes cron status daily-summary" status="ok" duration="1.4s" />
<RecentCallRow when="14m ago" args="git log --oneline -n 20" status="ok" duration="86ms" />
<RecentCallRow when="1h ago" args="npm test -- --watch=false" status="ok" duration="14.2s" />
<RecentCallRow when="2h ago" args="rm -rf node_modules" status="denied" duration="—" last />
</SettingsGroup>
</>
);
}
const POLICY_DESC = {
'auto': 'Always invoke without asking.',
'approve-write': 'Pause for approval when the tool changes state.',
'approve-exec': 'Pause for approval before every call.',
'approve-all': 'Pause for approval before every call. Strictest mode.',
'deny': 'Reject the call. Tool appears in lists but cannot be invoked.',
};
function RecentCallRow({ when, args, status, duration, last }) {
return (
<div style={{
display: 'flex', alignItems: 'center', gap: 12, padding: '10px 18px',
borderBottom: last ? 'none' : '0.5px solid var(--border)',
}}>
<span style={{ fontSize: 11, color: 'var(--fg-faint)', fontFamily: 'var(--font-mono)', width: 90 }}>{when}</span>
<span style={{ flex: 1, fontFamily: 'var(--font-mono)', fontSize: 12,
whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis', color: 'var(--fg-muted)' }}>{args}</span>
{status === 'ok' && <Pill tone="green" size="sm" icon="check">ok</Pill>}
{status === 'denied' && <Pill tone="red" size="sm" icon="ban">denied</Pill>}
<span style={{ fontFamily: 'var(--font-mono)', fontSize: 11, color: 'var(--fg-faint)', width: 60, textAlign: 'right' }}>{duration}</span>
</div>
);
}
window.Tools = Tools;
File diff suppressed because one or more lines are too long
+141
View File
@@ -0,0 +1,141 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Scarf — UI Kit</title>
<link rel="stylesheet" href="../colors_and_type.css">
<style>
html, body { margin: 0; padding: 0; height: 100%; overflow: hidden;
background: linear-gradient(135deg, #EFC59E 0%, #C25A2A 60%, #5C220F 100%); }
@keyframes scarfSpin { to { transform: rotate(360deg); } }
#root { height: 100%; }
.scarf-app {
display: flex; height: 100vh;
background: var(--bg);
overflow: hidden;
}
.scarf-traffic {
position: absolute; top: 14px; left: 18px;
display: flex; gap: 8px; z-index: 100;
}
.scarf-traffic .dot { width: 12px; height: 12px; border-radius: 50%; }
.scarf-traffic .dot.r { background: #FE5F57; }
.scarf-traffic .dot.y { background: #FEBB2E; }
.scarf-traffic .dot.g { background: #28C840; }
.scarf-content {
flex: 1; display: flex; flex-direction: column;
min-width: 0; padding-top: 38px;
background: var(--bg);
}
@keyframes pulseScarf { 0%,100% { opacity:1 } 50% { opacity: 0.3 } }
/* placeholder for contentEditable */
[contenteditable][data-placeholder]:empty:before {
content: attr(data-placeholder); color: var(--fg-faint); pointer-events: none;
}
/* scrollbar tweak */
::-webkit-scrollbar { width: 8px; height: 8px; }
::-webkit-scrollbar-thumb { background: rgba(28,26,32,0.15); border-radius: 4px; }
::-webkit-scrollbar-thumb:hover { background: rgba(28,26,32,0.25); }
</style>
</head>
<body>
<div id="root"></div>
<template id="__bundler_thumbnail">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100">
<rect width="100" height="100" fill="#C25A2A"/>
<text x="50" y="62" text-anchor="middle" font-family="Georgia, serif"
font-size="48" font-style="italic" fill="#FAF7F2" font-weight="600">S</text>
</svg>
</template>
<script src="https://unpkg.com/react@18.3.1/umd/react.development.js" integrity="sha384-hD6/rw4ppMLGNu3tX5cjIb+uRZ7UkRJ6BPkLpg4hAu/6onKUg4lLsHAs9EBPT82L" crossorigin="anonymous"></script>
<script src="https://unpkg.com/react-dom@18.3.1/umd/react-dom.development.js" integrity="sha384-u6aeetuaXnQ38mYT8rp6sbXaQe3NL9t+IBXmnYxwkUI2Hw4bsp2Wvmx4yRQF1uAm" crossorigin="anonymous"></script>
<script src="https://unpkg.com/@babel/standalone@7.29.0/babel.min.js" integrity="sha384-m08KidiNqLdpJqLq95G/LEi8Qvjl/xUYll3QILypMoQ65QorJ9Lvtp2RXYGBFj1y" crossorigin="anonymous"></script>
<script src="https://unpkg.com/lucide@0.469.0/dist/umd/lucide.min.js"></script>
<script type="text/babel" src="Common.jsx"></script>
<script type="text/babel" src="Sidebar.jsx"></script>
<script type="text/babel" src="Dashboard.jsx"></script>
<script type="text/babel" src="Sessions.jsx"></script>
<script type="text/babel" src="Insights.jsx"></script>
<script type="text/babel" src="Projects.jsx"></script>
<script type="text/babel" src="Chat.jsx"></script>
<script type="text/babel" src="Settings.jsx"></script>
<script type="text/babel" src="Tools.jsx"></script>
<script type="text/babel" src="MCPServers.jsx"></script>
<script type="text/babel" src="Cron.jsx"></script>
<script type="text/babel" src="Logs.jsx"></script>
<script type="text/babel" src="Memory.jsx"></script>
<script type="text/babel" src="Activity.jsx"></script>
<script type="text/babel" src="Health.jsx"></script>
<script type="text/babel" src="MoreViews.jsx"></script>
<script type="text/babel">
function App() {
const [active, setActive] = React.useState('dashboard');
React.useEffect(() => {
// re-render lucide icons after each route change
requestAnimationFrame(() => window.lucide && window.lucide.createIcons());
}, [active]);
const Views = {
dashboard: Dashboard,
sessions: Sessions,
insights: Insights,
projects: Projects,
chat: Chat,
settings: Settings,
tools: Tools,
mcpServers: MCPServers,
cron: Cron,
logs: Logs,
memory: Memory,
activity: Activity,
health: Health,
personalities: Personalities,
quickCommands: QuickCommands,
platforms: Platforms,
credentialPools: Credentials,
plugins: Plugins,
webhooks: Webhooks,
profiles: Profiles,
gateway: Gateway,
};
const Active = Views[active] || PlaceholderView(active);
return (
<div className="scarf-app" data-screen-label={`Scarf · ${active}`}>
<div className="scarf-traffic">
<span className="dot r"></span><span className="dot y"></span><span className="dot g"></span>
</div>
<ScarfSidebar active={active} onSelect={setActive} />
<div className="scarf-content">
<Active />
</div>
</div>
);
}
function PlaceholderView(name) {
const SIDEBAR_FLAT = SIDEBAR_SECTIONS.flatMap(s => s.items);
const item = SIDEBAR_FLAT.find(i => i.id === name) || { label: name, icon: 'inbox' };
return function Inner() {
return (
<>
<ContentHeader title={item.label} subtitle={`This view isn't fleshed out in the UI kit yet.`} />
<EmptyState icon={item.icon}
title={`${item.label}`}
body={`The Scarf app exposes a dedicated ${item.label} pane here. The kit ships a faithful Dashboard, Sessions, Insights, Projects, and Chat — wire ${item.label} the same way against your data.`}
action={<Btn kind="primary" icon="external-link">Open Scarf docs</Btn>}
/>
</>
);
};
}
ReactDOM.createRoot(document.getElementById('root')).render(<App />);
// Lucide ran once on DOMContentLoaded before React mounted — re-run now that the DOM has icons.
setTimeout(() => window.lucide && window.lucide.createIcons(), 0);
setTimeout(() => window.lucide && window.lucide.createIcons(), 200);
</script>
</body>
</html>
+187
View File
@@ -0,0 +1,187 @@
// MacOS.jsx — Simplified macOS Tahoe (Liquid Glass) window
// Based on the macOS Tahoe UI Kit. No image assets, no dependencies.
// Exports: MacWindow, MacSidebar, MacSidebarItem, MacToolbar, MacGlass, MacTrafficLights
const MAC_FONT = '-apple-system, BlinkMacSystemFont, "SF Pro", "Helvetica Neue", sans-serif';
// ─────────────────────────────────────────────────────────────
// Liquid glass primitive — blur + white tint + inset highlight
// ─────────────────────────────────────────────────────────────
function MacGlass({ children, radius = 296, dark = false, style = {} }) {
return (
<div style={{ position: 'relative', borderRadius: radius, ...style }}>
<div style={{
position: 'absolute', inset: 0, borderRadius: radius,
background: dark ? 'rgba(255,255,255,0.08)' : 'rgba(255,255,255,0.35)',
backdropFilter: 'blur(40px) saturate(180%)',
WebkitBackdropFilter: 'blur(40px) saturate(180%)',
border: dark ? '0.5px solid rgba(255,255,255,0.12)' : '0.5px solid rgba(255,255,255,0.6)',
boxShadow: dark
? '0 8px 40px rgba(0,0,0,0.2)'
: '0 8px 40px rgba(0,0,0,0.08), inset 0 1px 0 rgba(255,255,255,0.4)',
}} />
<div style={{ position: 'relative', zIndex: 1 }}>{children}</div>
</div>
);
}
// ─────────────────────────────────────────────────────────────
// Traffic lights (14px, Tahoe colors)
// ─────────────────────────────────────────────────────────────
function MacTrafficLights({ style = {} }) {
const dot = (bg) => (
<div style={{
width: 14, height: 14, borderRadius: '50%', background: bg,
border: '0.5px solid rgba(0,0,0,0.1)',
}} />
);
return (
<div style={{ display: 'flex', gap: 9, alignItems: 'center', padding: 1, ...style }}>
{dot('#ff736a')}{dot('#febc2e')}{dot('#19c332')}
</div>
);
}
// ─────────────────────────────────────────────────────────────
// Toolbar — title + single glass pill icon
// ─────────────────────────────────────────────────────────────
function MacToolbar({ title = 'Folder' }) {
return (
<div style={{
display: 'flex', gap: 8, alignItems: 'center', padding: 8, flexShrink: 0,
}}>
{/* title */}
<div style={{
fontFamily: MAC_FONT, fontSize: 15, fontWeight: 700,
color: 'rgba(0,0,0,0.85)', whiteSpace: 'nowrap', paddingLeft: 8,
}}>{title}</div>
<div style={{ flex: 1 }} />
{/* single action */}
<MacGlass>
<div style={{
width: 36, height: 36, display: 'flex',
alignItems: 'center', justifyContent: 'center',
}}>
<div style={{ width: 14, height: 14, borderRadius: '50%', background: '#4c4c4c', opacity: 0.4 }} />
</div>
</MacGlass>
{/* search */}
<MacGlass>
<div style={{
width: 140, height: 36, display: 'flex', alignItems: 'center',
gap: 6, padding: '0 12px',
}}>
<svg width="13" height="13" viewBox="0 0 13 13" fill="none">
<circle cx="5.5" cy="5.5" r="4" stroke="#727272" strokeWidth="1.5"/>
<path d="M8.5 8.5l3 3" stroke="#727272" strokeWidth="1.5" strokeLinecap="round"/>
</svg>
<span style={{
fontFamily: MAC_FONT, fontSize: 13, fontWeight: 500, color: '#727272',
}}>Search</span>
</div>
</MacGlass>
</div>
);
}
// ─────────────────────────────────────────────────────────────
// Sidebar — frosted glass panel floating inside the window
// ─────────────────────────────────────────────────────────────
function MacSidebarItem({ label, selected = false }) {
return (
<div style={{
display: 'flex', alignItems: 'center', gap: 6,
height: 24, padding: '4px 10px 4px 6px', margin: '0 10px',
borderRadius: 8, position: 'relative',
fontFamily: MAC_FONT, fontSize: 11, fontWeight: 500,
}}>
{selected && (
<div style={{
position: 'absolute', inset: 0, borderRadius: 8,
background: 'rgba(0,0,0,0.11)', mixBlendMode: 'multiply',
}} />
)}
<div style={{
width: 14, height: 14, borderRadius: '50%',
background: selected ? '#007aff' : 'rgba(0,0,0,0.4)',
opacity: selected ? 1 : 0.5, flexShrink: 0, position: 'relative',
}} />
<span style={{ color: 'rgba(0,0,0,0.85)', position: 'relative' }}>{label}</span>
</div>
);
}
function MacSidebar({ children }) {
return (
<div style={{
width: 220, height: '100%', padding: 8, flexShrink: 0,
position: 'relative', display: 'flex', flexDirection: 'column',
}}>
{/* glass panel */}
<div style={{
position: 'absolute', inset: 8, borderRadius: 18,
background: 'rgba(210,225,245,0.45)',
backdropFilter: 'blur(50px) saturate(200%)',
WebkitBackdropFilter: 'blur(50px) saturate(200%)',
border: '0.5px solid rgba(255,255,255,0.5)',
boxShadow: '0 8px 40px rgba(0,0,0,0.10), inset 0 1px 0 rgba(255,255,255,0.35)',
}} />
{/* content */}
<div style={{
position: 'relative', zIndex: 1, padding: '10px 0',
display: 'flex', flexDirection: 'column', gap: 2,
}}>
{/* window controls + sidebar toggle */}
<div style={{
height: 32, display: 'flex', alignItems: 'center',
justifyContent: 'space-between', padding: '0 10px', marginBottom: 4,
}}>
<MacTrafficLights />
</div>
{children}
</div>
</div>
);
}
function MacSidebarHeader({ title }) {
return (
<div style={{
padding: '14px 18px 5px',
fontFamily: MAC_FONT, fontSize: 11, fontWeight: 700,
color: 'rgba(0,0,0,0.5)',
}}>{title}</div>
);
}
// ─────────────────────────────────────────────────────────────
// Window — r:26, big shadow, sidebar + toolbar + content
// ─────────────────────────────────────────────────────────────
function MacWindow({
width = 900, height = 600, title = 'Folder',
sidebar, children,
}) {
return (
<div style={{
width, height, borderRadius: 26, overflow: 'hidden',
background: '#fff',
boxShadow: '0 0 0 1px rgba(0,0,0,0.23), 0 16px 48px rgba(0,0,0,0.35)',
display: 'flex', position: 'relative',
fontFamily: MAC_FONT,
}}>
<MacSidebar>{sidebar}</MacSidebar>
<div style={{ flex: 1, display: 'flex', flexDirection: 'column' }}>
<MacToolbar title={title} />
<div style={{ flex: 1, overflow: 'auto', padding: '4px 8px' }}>
{children}
</div>
</div>
</div>
);
}
Object.assign(window, {
MacWindow, MacSidebar, MacSidebarItem, MacSidebarHeader,
MacToolbar, MacGlass, MacTrafficLights,
});
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 770 KiB

+70 -21
View File
@@ -1,15 +1,50 @@
## 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, and cron jobs into a single file anyone can install with one click from a local file or an `scarf://install?url=…` deep link.
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 archive 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. Every bundle is agent-portable out of the box.
- **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`. The manifest's content claim is cross-checked against the actual zip entries so a bundle can't hide files from the preview.
- **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.
- **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 builds the bundle and you can hand it to anyone.
- **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, and memory block id. Installing the same template id twice is refused at the preview step so you don't accidentally double-append to `MEMORY.md`. Uninstalling by hand is a matter of deleting the project directory, the skills namespace folder, and any `[tmpl:<id>] …` cron jobs — no hidden state.
- **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 so nothing unexpected kicks off on install. The installer **never** touches `~/.hermes/config.yaml`, `auth.json`, sessions, or any credential-bearing path.
- **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
@@ -17,29 +52,43 @@ Scarf projects can now travel. This release introduces **Project Templates** —
- **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.
### Under the hood
### UX clarifications
- New models in `Core/Models/ProjectTemplate.swift` (manifest, inspection, install plan, lock, errors).
- `Core/Services/ProjectTemplateService.swift` unzips, parses, and validates; `ProjectTemplateInstaller.swift` executes the plan atomically-enough (pre-flights conflicts, then writes); `ProjectTemplateExporter.swift` builds bundles from a live project + selections.
- `Core/Services/TemplateURLRouter.swift` is the process-wide landing pad for `scarf://` URLs so a cold-launch browser click still reaches the install sheet.
- Installer dispatches cron creation via `hermes cron create` (there's no direct Scarf write path for `cron/jobs.json`), then diffs before/after to pause the newly-registered jobs.
- New Swift Testing suites: `ProjectTemplateServiceTests`, `TemplateURLRouterTests`, `ProjectTemplateExportTests`.
- **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`. Right-click any template-installed project in the sidebar → **Uninstall Template…**, or click the uninstall button in the dashboard header. A preview sheet lists every file, cron job, and memory block that will be removed, and every user-created file that will be preserved.
- **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`. 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.** v1 uninstall is destructive — to reinstall, run the install flow again.
- **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.
### Not in this release (planned for v2.3)
### Under the hood
- In-app catalog browser backed by a GitHub Pages `templates.json`.
- EdDSA-signed bundles reusing the Sparkle key.
- Template updates (compare installed lock against a newer bundle's version, offer a diff).
- Installing into remote `ServerContext`s (v1 is local-only).
- 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.
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.
+218
View File
@@ -0,0 +1,218 @@
# ScarfGo — App Store Connect submission copy
Single source of truth for every field you paste into App Store Connect → My Apps → ScarfGo. TestFlight-specific fields (Beta App Description, "What to test") live in [TESTFLIGHT_CHECKLIST.md](TESTFLIGHT_CHECKLIST.md). This file covers the full App Store listing for when ScarfGo graduates from TestFlight to the public store.
All character counts are pre-counted against Apple's published limits. Counts include trailing punctuation but exclude the leading `> ` Markdown blockquote markers.
## App information (set once, persists across builds)
### App name (max 30 chars)
```
ScarfGo
```
_7 / 30 chars._
### Subtitle (max 30 chars)
```
On-the-go Hermes companion
```
_26 / 30 chars._
### Bundle ID
```
com.scarfgo.app
```
### Primary category
Developer Tools
### Secondary category (optional)
Productivity
### Age rating
4+ (no restricted content)
### Support URL
```
https://github.com/awizemann/scarf/wiki/Support
```
### Marketing URL (optional)
```
https://github.com/awizemann/scarf
```
### Privacy Policy URL
```
https://awizemann.github.io/scarf/privacy/
```
### Copyright
```
© 2026 Alan Wizemann
```
### Trade representative information
Not required for sole-developer accounts.
---
## Per-version metadata (resubmit on each App Store release)
### Promotional text (max 170 chars, editable without resubmission)
```
Manage your Hermes AI agent from your phone. Connect to any SSH-reachable Hermes host, run sessions, edit memory, browse cron jobs, resume conversations.
```
_153 / 170 chars._
### Description (max 4000 chars)
```
ScarfGo is the iPhone companion to Scarf, the open-source macOS GUI for the Hermes AI agent. It connects from your phone to a Hermes server you operate — your Mac, a home Linux box, a cloud VM, anything reachable over SSH — and lets you run sessions, browse memory, manage cron jobs, and resume conversations on the go.
A fully native iOS app, not a web view or a remote desktop. ScarfGo speaks SSH directly using a pure-Swift implementation, reads Hermes state via SFTP and SQLite snapshots, and streams real-time agent output over the Agent Client Protocol on a long-lived SSH exec channel. Every byte stays between your device and the Hermes host you configured.
What you can do:
• Multi-server. Configure as many Hermes hosts as you like and switch between them with a tap. Soft Disconnect keeps your credentials cached; Forget wipes a server end-to-end.
• Dashboard. Stats and the 25 most recent sessions, with project badges so you can tell at a glance which work is which.
• Project-scoped chat. Pick a project from your registry and ScarfGo writes the same Scarf-managed AGENTS.md context block the Mac app does, so the agent boots with the right project context. The resulting session is attributed correctly across both clients.
• Session resume. Tap any row on the Dashboard to open that session's transcript in Chat. CLI-started sessions hydrate from the Hermes state database; ACP sessions show an empty-state because Hermes does not persist ACP transcripts to the database.
• Memory editor. Read and edit MEMORY.md and USER.md with a Saved indicator that survives keyboard dismissal and a one-tap Revert.
• Cron list. Human-readable schedules ("Every 6 hours", "Weekdays at 09:00") instead of raw cron expressions, plus a relative next-run estimate. Read-only in this release; editing comes in a future update.
• Skills browser. Read-only category tree with the SKILL.md frontmatter chips (allowed tools, related skills, dependencies) the Mac app shows.
• Settings viewer. Read-only inspection of your config.yaml. Edit values from the Mac app or a remote shell.
Privacy. ScarfGo does not collect, transmit, or store your data on any server controlled by the developer. There are no analytics, no telemetry, no ad identifiers. SSH keys are generated on-device and stored in the iOS Keychain with the ThisDeviceOnly attribute, so they are unreachable while the device is locked and never sync to iCloud. The complete privacy policy lives at awizemann.github.io/scarf/privacy.
Open-source under the MIT license. Source, issue tracker, and contributor docs at github.com/awizemann/scarf. Bug reports tagged component:scarfgo go straight to the developer.
Requirements. iOS 18.0 or later. An SSH-reachable Hermes server (Hermes v0.10.0 or later recommended; full v0.11.0 features supported). Your phone needs to reach that server on the network — same Wi-Fi, VPN, Tailscale, or any port-forwarded address SSH can dial.
```
_2873 / 4000 chars._
### Keywords (max 100 chars, comma-separated, no spaces between terms)
```
hermes,ai agent,ssh,terminal,llm,assistant,developer tools,coding,remote,monitor,chat
```
_85 / 100 chars._
Brand-safe — no competitor product names. Apple flags trademarks like "Claude" or "OpenAI" as unauthorized brand use during review even when they appear as descriptive context.
### What's New text (max 4000 chars)
For v2.5.0 — first public App Store release. Trimmed from `RELEASE_NOTES.md`'s ScarfGo section to fit the iOS audience.
```
First public release of ScarfGo, the iPhone companion to the Scarf macOS app.
What's in this release:
• Multi-server. Configure multiple Hermes hosts and switch between them with a tap.
• Dashboard. Sessions, messages, and tool-call counts, plus the 25 most recent sessions with project badges and a project filter.
• Chat. Streamed agent responses over SSH with tool-call disclosure groups, code blocks, and project-scoped session start.
• Session resume. Tap any session on the Dashboard to open it in Chat.
• Memory editor. Read and edit MEMORY.md and USER.md with on-device save indication and one-tap Revert.
• Cron list. Human-readable schedules ("Every 6 hours", "Weekdays at 09:00") with relative next-run.
• Skills browser. Read-only category tree with SKILL.md frontmatter chips.
• Settings viewer. Read-only inspection of config.yaml. Edit values from the Mac app.
Known limitations in v1: no push notifications (the skeleton is in the binary, gated behind an internal flag pending Apple Developer Program enrollment and an APNs key); no in-app config editor; no template install UI; English only. iPad layout works via the system sidebar adaptive style but has not been polished — feedback welcome via TestFlight.
Privacy. No analytics, no telemetry, no developer-controlled servers. Read the full policy at awizemann.github.io/scarf/privacy.
```
_1150 / 4000 chars._
### Build (autopopulated)
Apple fills this in once the binary uploads + processes. The same build that went through TestFlight Beta Review is the one you ship to the public store.
### Version
Marketing version: `2.5.0` — the same number `release.sh` will write to `MARKETING_VERSION` for the macOS Scarf release. Keeping the iOS + Mac versions in lockstep is the convention this project uses.
---
## Build artifact
### App icon (1024×1024)
```
scarf/Scarf iOS/Assets.xcassets/AppIcon.appiconset/AW Mac OS Applications-macOS-Default-1024x1024@1x.png
```
The full appiconset is in repo and the Xcode target references it via `AppIcon`. App Store Connect pulls the 1024 from the binary on upload — no separate upload step.
### Screenshots
**Required for the public App Store, NOT required for TestFlight.** Scope deliberately excluded from this prep pass — capture from the simulator before flipping the App Store listing live. Apple requires:
- iPhone 6.7" (e.g. iPhone 16 Pro Max) — at least 5, up to 10
- iPhone 6.5" (e.g. iPhone 14 Plus) — at least 5, up to 10
- iPhone 5.5" (e.g. iPhone 8 Plus) — at least 5, up to 10
- iPad — only if you flip the iPad flag in the target. Skip for v2.5.
Suggested screen captures (rough order):
1. Dashboard with stats + recent sessions list
2. Chat in mid-stream with a tool-call disclosure expanded
3. Project picker sheet
4. Sessions tab with project filter active
5. Memory editor with Saved indicator
6. Skills detail with frontmatter chips visible
7. Server list (showing multi-server)
8. Onboarding step 5 (public-key display)
### App preview video (optional)
Skip for v1. Apple will accept the listing without it.
---
## Beta App Review (TestFlight) — already submitted
Cross-reference [TESTFLIGHT_CHECKLIST.md](TESTFLIGHT_CHECKLIST.md). Once Apple's Beta Review approves the first build, the public TestFlight URL `https://testflight.apple.com/join/qCrRpcTz` accepts new joiners. Until then the link 404s with a "not accepting testers" splash.
## Public App Store submission flow (after TestFlight stabilizes)
1. App Store Connect → My Apps → ScarfGo → App Store tab → iOS App.
2. Paste every field above into the matching form.
3. Set the build to the same one that's been on TestFlight (Apple lets you reuse a TestFlight build verbatim — no re-upload).
4. Submit for review. Apple's standard App Review queue (separate from Beta Review) is typically 2472h. Watch your inbox for "We have a question" emails and reply via App Store Connect's review-team chat.
5. On approval, choose "Manually release this version" so you can announce on a schedule.
## Update cadence
The same `releases/v<VERSION>/` directory pattern this file lives in is the canonical staging area for every future iOS release. When v2.6 (or whatever ships next) bumps the iOS app, copy this file forward and update:
- **Promotional text** — refreshed marketing wedge.
- **What's New text** — what changed since the last App Store release.
- Everything else above stays unless you're changing categories, support URL, or privacy stance.
The Mac `release.sh` does not yet drive the iOS release — that's a separate Xcode Archive + App Store Connect upload. See `TESTFLIGHT_CHECKLIST.md` Phase 4 for the archive flow.
+95
View File
@@ -0,0 +1,95 @@
## What's New in 2.5.0
The big one for 2.5: **ScarfGo, the iPhone companion**, ships in public TestFlight. Same Hermes server you've been running on your Mac — now reachable from your phone over SSH. Dashboard, chat, memory, cron, skills, settings (read), all of it. On the Mac side, the global Sessions list grows up alongside the iOS work — project filter, project badges on each row. **Plus**: full Hermes v2026.4.23 chat parity (`/steer`, per-turn stopwatch, numbered approvals, git branch chip), portable project-scoped slash commands that ship with `.scarftemplate` bundles, in-app Spotify OAuth, and design-md prereq checks.
### ScarfGo iOS companion (public TestFlight)
ScarfGo is a fully native iOS app — not a web view, not a remote desktop. It speaks SSH (Citadel under the hood), reads your Hermes state directly from your Mac (or wherever Hermes is running), and lets you tap a session to resume it from where you left off. Per-project chat works end-to-end: pick a project on your phone, the agent gets the same Scarf-managed `AGENTS.md` context block the Mac writes, and the resulting session shows up correctly attributed in the Dashboard's Sessions tab.
What's in the first public TestFlight build:
- **Multi-server.** Configure as many Hermes hosts as you want, switch between them from a single sidebar-adaptable tab root. Soft disconnect keeps your credentials; "Forget" wipes a server end-to-end (Keychain + UserDefaults).
- **Dashboard.** Stats + the 25 most recent sessions; an Overview tab and a Sessions tab with a project filter Menu.
- **Chat.** Full ACP (Agent Client Protocol) over SSH — streamed responses, tool-call disclosure groups, code blocks with horizontal scroll, "Connecting…" → "Ready" lifecycle, error banner with copy-to-clipboard for non-retryable failures.
- **Project-scoped chat.** "+ In project…" sheet picks from your project registry over SFTP. Writes the Scarf-managed `AGENTS.md` block before spawning `hermes acp` so the agent boots with project context. Records the resulting session ID in the attribution sidecar so the Mac picks it up.
- **Session resume.** Tap any session on the Dashboard → opens Chat with `loadSession`. Older CLI-started sessions hydrate from `state.db`; newer ACP sessions show an empty-state explaining the agent has the context but the local transcript isn't cached.
- **Memory editor.** Read + edit `MEMORY.md` and `USER.md`, with a "Saved" pill that survives keyboard dismissal and a Revert button.
- **Cron list.** Read-only for now, but with **human-readable schedules** ("Every 6 hours", "Weekdays at 09:00") instead of raw `0 */6 * * *`. Mac gets the same formatter.
- **Skills + Settings.** Read-only. Skills shows category structure; Settings shows your `config.yaml` for inspection (no editor in 2.5).
- **iOS 18+.** Dynamic Type clamp at the scene root, sidebar-adaptable TabView, scoped sheet detents, scroll anchoring, content-aware empty states throughout.
**TestFlight invite:** see the [ScarfGo wiki page](https://github.com/awizemann/scarf/wiki/ScarfGo) for the public link + onboarding walkthrough.
### Portable project-scoped slash commands
A net-new Scarf primitive (Hermes has no project-scoped slash command concept — Scarf invents the format and intercepts the chat menu client-side). Author reusable prompt templates as Markdown files at `<project>/.scarf/slash-commands/<name>.md` with YAML frontmatter (name, description, argumentHint, optional model override, tags). Invoke as `/<name> [args]` from chat — Scarf substitutes `{{argument}}` (and `{{argument | default: "..."}}`) in the body and sends the expanded prompt to Hermes; the agent never sees the slash. Works uniformly on Mac + iOS, local + remote SSH, against any Hermes version.
- **Mac authoring tab.** Per-project view gains a Slash Commands tab alongside Dashboard / Site / Sessions. List, add, edit, duplicate, delete; live preview pane shows the expanded prompt with a sample-argument field so authors see exactly what Hermes will receive.
- **iOS read-only browser.** ScarfGo's chat project context bar grows a `<N> slash` chip when the project has slash commands; tap to browse them in a sheet. Multi-line markdown editing is a phone keyboard's nightmare, so v2.5 keeps Mac as the canonical editor; iOS catches up in v2.6+.
- **AGENTS.md block extension.** The Scarf-managed project context block now lists available commands so the agent can answer "what slash commands does this project have?" and recognise the `<!-- scarf-slash:<name> -->` marker prepended to expanded prompts.
- **`.scarftemplate` format extension** (schemaVersion 3). Templates ship slash commands by including `slash-commands/<name>.md` files at the bundle root and listing them in `manifest.contents.slashCommands`. The installer copies them to the project's `.scarf/slash-commands/` dir; the lock file tracks them for clean uninstall (user-authored commands in the same dir survive uninstall).
- **Catalog validator** (`tools/build-catalog.py`) mirrors the Swift verifier. Schema version bumps to 3 only when the bundle ships slash commands; v1/v2 templates stay byte-compatible.
### Hermes v2026.4.23 chat parity
Scarf 2.5 mirrors the chat-surface features Hermes's TUI rewrite shipped this week:
- **`/steer <prompt>`** — non-interruptive mid-run guidance. Surfaces in the slash menu as a special command; sending it doesn't flip the "Agent working…" indicator (the agent's still on its current turn) and shows a transient toast above the composer: "Guidance queued — applies after the next tool call."
- **Per-turn stopwatch** — wall-clock duration of each completed assistant turn renders as a compact pill (`4.2s` / `1m 12s`) on the bubble's metadata footer (Mac) or below the bubble (iOS). Resumed sessions loaded from `state.db` show no pill (timing is captured live only).
- **Numbered keyboard shortcuts on permission sheet** — Mac approval sheet binds 19 to the option buttons (visible "1. " / "2. " prefixes). Power users approve / deny without reaching for the mouse. iOS shows the same numbered hints as a hierarchy cue without the keyboard binding.
- **Git branch indicator** — the chat header shows the project's current git branch as a tinted chip alongside the project name (e.g. `📂 myproject · main`). One SSH `git rev-parse --abbrev-ref HEAD` call per session start; nil-out gracefully on non-git dirs / missing git / SSH errors.
### Spotify + design-md skill onboarding
Hermes v2026.4.23 added two new skills. Scarf surfaces them properly:
- **Spotify (`spotify`)** — needs OAuth via `hermes auth spotify`. Mac ships a dedicated Sign-in sheet (mirroring the v2.3 Nous Portal pattern): runs the subprocess, regex-detects the `accounts.spotify.com/authorize?...` URL, auto-opens it in your browser, polls `~/.hermes/auth.json` after subprocess exit to confirm the token landed. Five-state machine (starting → waiting → verifying → success / failure) with retry. iOS surfaces a documentation row noting OAuth needs to happen from Mac or a shell — phone OAuth flows are their own UX problem.
- **design-md (`design-md`)** — requires `npx` (Node.js 18+) on the host. New `SkillPrereqService.probe(binary:)` runs `which npx` over the transport on skill detail appear; on miss, both Mac and iOS render a yellow banner with an install hint (per-OS).
### SKILL.md frontmatter chips
Hermes v2026.4.23 SKILL.md files carry richer YAML frontmatter (`allowed_tools`, `related_skills`, `dependencies`). Scarf parses it on both platforms (Mac via `HermesFileService.parseSkillFrontmatter`, iOS via `IOSSkillsViewModel.parseFrontmatter`) and renders chip rows in the skill detail view. Old skills without these fields stay nil and the rows hide themselves.
### "What's New" pill on Skills tab
Per-server snapshot of `[skillId: signature]` (file count + sorted file names). When the snapshot changes between visits, both Skills views render a tinted pill at the top: "2 new, 4 updated since you last looked." Tap "Mark as seen" to update the snapshot. First-time loads silently prime so users don't see "everything is new!" noise on a fresh install. Persisted to `~/Library/Application Support/com.scarf/skill-snapshots/<serverID>.json` (Mac) / `UserDefaults` (iOS).
### state.db deltas (Hermes v0.11)
- `messages.reasoning_content` — newer richer reasoning channel some providers emit alongside the legacy `reasoning` blob. UI prefers the new column when both are populated (`HermesMessage.preferredReasoning`).
- `sessions.api_call_count` — distinct from `tool_call_count`; counts per-turn API round-trips. Surfaced as the "API" label on Mac SessionDetailView and as a network-icon chip on Mac/iOS Dashboard session rows.
`HermesDataService.hasV011Schema` only flips true when both columns are present (partial migrations stay on the v0.7 path to avoid runtime errors). Older Hermes hosts keep working unchanged.
### `hermes memory reset` toolbar action
New toolbar button on Mac MemoryView — "Reset memory…" with destructive confirmation dialog. Routes through `hermes memory reset --yes`; refreshes the on-screen content on success, surfaces stderr in an alert on failure. Other v0.11 CLIs (`plugins`, `profile`, `webhook`, `insights`, `logs`) are documented in `CLAUDE.md` for future v2.6 adoption — Scarf still reads the underlying files directly today, which keeps working.
### Mac global Sessions: project filter + badges
The per-project Sessions tab shipped in 2.3, but the global Sessions feature still rendered every session as a flat list with no project context. 2.5 closes the gap:
- **Filter Menu** above the list: All projects / Unattributed / one entry per registered project. An xmark button clears the filter; the right side shows "X of Y shown".
- **Project badge** on each row — small tinted folder chip with the project name. Same visual language ScarfGo uses on its Dashboard.
- Logic comes from the same `SessionAttributionService` + `ProjectDashboardService` ScarfGo consumes, both in ScarfCore. Single source of truth across platforms.
### Human-readable cron schedules everywhere
Pre-2.5, both Mac and iOS rendered cron jobs as `0 */6 * * *` raw. The new `CronScheduleFormatter` in ScarfCore translates the common shapes to plain English (every-N-minutes, every-N-hours, daily-at-H, weekdays-at-H, the `@hourly`/`@daily`/`@weekly`/`@monthly` macros) and falls back to the raw expression for anything custom. Both apps consume it.
### Under the hood
- **Shared services.** `SessionAttributionService`, `ProjectContextBlock`, and `CronScheduleFormatter` moved into ScarfCore; both apps consume them via their respective transports (`SSHTransport` on Mac, `CitadelServerTransport` on iOS).
- **`RichChatViewModel`** carries the ACP error triplet (`acpError`, `acpErrorHint`, `acpErrorDetails`) for both platforms — Mac's `ChatViewModel` now delegates instead of duplicating.
- **Test reliability.** Cross-suite races on `ServerContext.sshTransportFactory` resolved by consolidating every factory-touching test into a single `.serialized` suite. 163 tests across 12 suites, three consecutive green runs.
- **Surface silent failures.** Several `try?` swallows in iOS lifecycle code now surface to the user — Keychain unlock errors no longer dump people back into onboarding, partial Forget operations report what failed, project-context-block writes that fail surface a banner instead of silently degrading agent context.
- **iOS exec channel hardening.** `CitadelServerTransport.runProcess` was wrapping Citadel's `executeCommand`, which throws `CommandFailed` on non-zero exit and discards the captured stdout buffer in the throw path. `hermes skills browse` happens to print its full table and *then* exit non-zero on some hosts, so iOS got nothing while Mac (Foundation `Process`) got the full output with `exitCode=1`. v2.5 drives `executeCommandStream` directly, drains stdout + stderr regardless of outcome, and recovers the actual exit code from the `CommandFailed` catch. Same channel now also inline-prepends `PATH="$HOME/.local/bin:/opt/homebrew/bin:/usr/local/bin:$PATH"` on every invocation — Citadel's raw exec channel doesn't source the user's shell rc files, so non-interactive sessions land with a stripped `PATH` (`/usr/bin:/bin`) and pipx's default install dir is invisible. Mac's OpenSSH sshd handles this transparently; we now match.
- **fd-leak cleanup.** `LocalTransport` / `SSHTransport` / `ProcessACPChannel` all close the parent's copy of every pipe write end after spawn so EOF reaches the reader once the child exits, plus close read ends after draining. Was leaking one fd per `runProcess` / `streamLines` / ACP turn under load.
- **Status-poll backoff.** `ServerLiveStatus` now uses 10s → 30s → 60s → 120s → 300s exponential backoff on consecutive probe failures, resetting on the first full success. Previously a registered remote going unreachable hammered `pgrep` + `gateway_state.json` every 10s indefinitely; offline servers now settle to a 5-minute cadence while live ones stay snappy.
- **Logger conversion.** Remaining `print("[Scarf] …")` debug statements in `HermesDataService`, `HermesLogService`, and `ProjectDashboardService` swap to `os.Logger` calls (subsystem `com.scarf`), matching the global rule that production code uses `Logger` and `print()` is reserved for previews + test helpers.
### Notes for users running 2.3
No data migrations needed. Server configs, Keychain entries, project registries, session attribution sidecar — all forward-compatible. The only invariant change is iOS-only: `ScarfGo.servers.v1` UserDefaults key migrates to `com.scarf.ios.servers.v2` on first launch, and Keychain accounts move from `"primary"` to `"server-key:<UUID>"`. One-shot, idempotent — re-running 2.3 after 2.5 ran would just see the v2 data.
Push notifications stay disabled in this build. The skeleton (NotificationRouter, category registration, action handlers) is in place behind `apnsEnabled = false` for when Hermes ships a push sender + we get an APNs cert.
+103
View File
@@ -0,0 +1,103 @@
# v2.5 TestFlight submission checklist
Pre-flight steps to take ScarfGo to public TestFlight. Order matters — Apple review wants the privacy URL to resolve at submission time, and the build needs to upload before review can start.
## 0. Apple Developer Program prerequisites
- Apple Developer Program enrollment active (team `3Q6X2L86C4`).
- iOS Distribution certificate in login Keychain (`Apple Distribution: Alan Wizemann`).
- App Store provisioning profile for the iOS bundle ID (auto-managed in Xcode is fine).
- App Store Connect access for the team.
## 1. Privacy policy live
- [ ] Copy `scarf/docs/PRIVACY_POLICY.md` content into `.gh-pages-worktree/privacy/index.html` (wrap in minimal HTML, or leave as Markdown if GitHub Pages renders Markdown — GitHub Pages with Jekyll does).
- [ ] `cd .gh-pages-worktree && git add privacy/index.html && git commit -m "docs(privacy): publish v2.5 policy" && git push`
- [ ] Verify https://awizemann.github.io/scarf/privacy/ resolves (give it ~1 min after push).
The privacy URL is required by App Store Connect before submitting for Beta App Review. Without it the submission button is disabled.
## 2. Xcode target configuration
Open `scarf/scarf.xcodeproj`, select the `scarf mobile` target.
- [ ] Signing & Capabilities → "Automatically manage signing" ON, team set to `3Q6X2L86C4`.
- [ ] Capabilities present: Keychain Sharing only. **Push Notifications stays OFF**`NotificationRouter.apnsEnabled = false` and the entitlement is absent. Match the two: enable both later together.
- [ ] Info.plist sanity:
- Bundle Identifier matches App Store Connect record.
- `LSApplicationCategoryType = public.app-category.developer-tools`.
- `NSAppTransportSecurity` allows the SSH ports the app dials? — N/A for SSH (raw TCP); ATS only governs HTTPS. Skip.
## 3. Version bump
The version bump runs automatically via `./scripts/release.sh 2.5.0` in Phase G. Do NOT bump `MARKETING_VERSION` / `CURRENT_PROJECT_VERSION` manually before that — the script writes the version commit and reads `CURRENT_PROJECT_VERSION` to compute the next build number.
## 4. Archive + upload
- [ ] Xcode → Product → Scheme → `scarf mobile`.
- [ ] Destination → "Any iOS Device (arm64)".
- [ ] Product → Archive. Wait for build (~3-5 min).
- [ ] Organizer opens automatically. Select the archive → Distribute App.
- [ ] Distribution method: **App Store Connect**.
- [ ] Destination: **Upload**.
- [ ] Distribution options: leave defaults (manage versioning automatically; include bitcode if offered = N/A on Xcode 14+; strip Swift symbols ON).
- [ ] Re-sign: automatic.
- [ ] Upload. Apple processes the binary (~5-15 min); App Store Connect emails when ready.
## 5. App Store Connect metadata (TestFlight tab)
Once the binary is processed:
- [ ] **App information** (one-time, persists across builds):
- Subtitle: "On-the-go Hermes companion"
- Privacy policy URL: https://awizemann.github.io/scarf/privacy/
- Category: Developer Tools
- Age rating: 4+ (no restricted content)
- [ ] **Test information** (per-build is fine, persists if not changed):
- Beta App Description (paragraph): see "Beta description copy" below.
- Email: alan@wizemann.com
- Beta App Review information: account credentials only if the app required them — N/A (BYO Hermes host).
- Marketing URL (optional): https://github.com/awizemann/scarf
- [ ] **What to test** (per-build):
```
v2.5.0 — first public TestFlight build of ScarfGo. Try connecting to a
Hermes host (you'll need an SSH-reachable Hermes install). Test:
- Onboarding + Add a second server
- Project-scoped chat
- Session resume from Dashboard
- Sessions tab project filter
- Forget a server / re-onboard
Known limitations: no push, no in-app Settings editor, English only.
Report issues via TestFlight feedback.
```
### Beta description copy
> ScarfGo is the iOS companion to Scarf, the Mac client for the Hermes AI agent. Connect to a Hermes server you operate (Mac, Linux, or any SSH-reachable host) and run sessions, browse memory, manage cron jobs, and resume conversations from your phone. All data stays between your device and your Hermes host — no developer servers in between.
## 6. Submit for Beta App Review
- [ ] TestFlight tab → External Testers → Add a public group called "Public Beta".
- [ ] Add the new build to the group.
- [ ] Click **Submit for Review**.
- [ ] Apple's Beta Review queue is typically 24-48h.
## 7. After approval
- [ ] Apple issues a public TestFlight URL (`https://testflight.apple.com/join/XXXXXX`).
- [ ] Record the URL — needed in Phases E (wiki ScarfGo page) and F (README v2.5 section).
- [ ] **DO NOT** publicize it yet. Update wiki + README in branches first; the user (Alan) decides when to push live.
## Rollback
If a build breaks on TestFlight:
- [ ] Disable the build in App Store Connect → TestFlight → Builds → Expire.
- [ ] Fix the bug, archive a new build with the same `MARKETING_VERSION` (Apple requires the build number — `CURRENT_PROJECT_VERSION` — to monotonically increase).
- [ ] Upload + add to Public Beta group + submit if Apple flagged the prior build for re-review.
## Open items / future TestFlight builds
- **Push notifications** — flip `NotificationRouter.apnsEnabled = true` simultaneously with: enabling the Push Notifications capability, generating an APNs auth key, deploying the Hermes-side push sender. Stops being a no-op only when all three exist.
- **iPad support** — `.tabViewStyle(.sidebarAdaptable)` is wired but iPad layout hasn't been smoke-tested. Probably free, but verify before flipping the iPad flag in the target.
- **Localization** — English only for v1. Mac ships 7 languages; iOS strings are extracted but no translations.
+47
View File
@@ -0,0 +1,47 @@
## What's in 2.5.1
A patch release that bundles every issue reported against 2.5.0 plus a couple of TestFlight-driven iOS fixes. No data migrations needed — drop-in replacement for 2.5.0 on Mac, drop-in TestFlight build on iOS.
### Bug fixes
#### Mac
- **[#49](https://github.com/awizemann/scarf/issues/49) — macOS 26 "Scarf.app is damaged" recovery path.** Verified the shipped 2.5.0 bundles pass `codesign --verify --strict --deep` and `spctl --assess` on macOS 26.4.1; the user-facing "damaged" symptom in some reports turned out to be self-inflicted by destructive recovery commands. Added a [Troubleshooting section](https://github.com/awizemann/scarf/blob/main/README.md) to the README documenting the **non-destructive** fix path (`xattr -d com.apple.quarantine` only — never `xattr -rc` or `codesign --force --deep --sign -`). Hardened the release pipeline: every variant zip now goes through `codesign --verify --strict --deep` + `spctl --assess` after the final `ditto`, so any future regression in the shipped artifact fails the release before a user sees it.
- **[#46](https://github.com/awizemann/scarf/issues/46) — chat performance: long sessions no longer bog down or crash.** Long chats were doing O(n) work per streamed token because every chunk rebuilt the full message-group array AND every `MessageGroupView` / `RichMessageBubble` re-evaluated its body. Three changes cap per-chunk work at O(1) for settled groups:
- `MessageGroupView` and `RichMessageBubble` are now `Equatable` with `.equatable()` short-circuit. Settled bubbles skip body re-eval entirely while the streaming bubble still redraws.
- `RichChatViewModel.upsertStreamingMessage` patches the trailing group in place via a new `patchTrailingGroupForStreaming(...)` instead of running `buildMessageGroups()` per chunk.
- `MessageGroup.toolKindCounts` moved to the model (was an `O(m × k)` computed property re-running on every render). `ToolCallCard.formatJSON` cached via `.task(id: callId)`. `ToolResultContent.lines` cached on content change.
CPU during streaming on a 500-message session drops from sustained 100%+ to ~3050% on representative hardware.
- **[#50](https://github.com/awizemann/scarf/issues/50) — Hermes v0.11 profile awareness.** Hermes v0.11 stores each profile in its own `~/.hermes/profiles/<name>/` directory with its own `state.db`, `sessions/`, `config.yaml`, `memories/`, etc. Pre-fix Scarf hardcoded `~/.hermes` and ignored `~/.hermes/active_profile`, so `hermes profile use coder` followed by a Scarf relaunch silently read the wrong DB — sessions, memory, cron all coming from the default profile. New `HermesProfileResolver` reads `active_profile` and resolves the effective home path; `HermesPathSet.defaultLocalHome` consults it, so every derived path automatically follows the active profile. SessionInfoBar gains a profile chip when not on the default so users can see which profile Scarf is reading from.
- **[#53](https://github.com/awizemann/scarf/issues/53) — granular reasons on the "Connected — can't read Hermes state" pill.** Tier 2 of the connection probe now distinguishes config.yaml-missing / `~/.hermes`-missing / permission-denied / Hermes-profile-active and surfaces a pill popover with the specific reason + an actionable hint + Run Diagnostics / Retry buttons. Profile case includes a copy-paste `hermes profile use default` affordance.
- **[#44](https://github.com/awizemann/scarf/issues/44) — pill and Run Diagnostics no longer disagree.** A long-standing latent bug surfaced by Tailscale Mac-to-Mac users: the pill probe and the diagnostics view ran the same `[ -r ~/.hermes/config.yaml ]` check but went through different transport paths — `transport.runProcess` for the pill (which `remotePathArg`-quotes every argument and mangled the multi-line script) vs raw `/usr/bin/ssh ... -- /bin/sh -s` for diagnostics. Result: 14/14 diagnostics passing while the pill stayed stuck on "can't read Hermes state". Extracted the diagnostics workaround into a shared `SSHScriptRunner` in ScarfCore; both probes now use it. Side benefit: the granular #53 probe script (more `$VAR`s and nested quotes) is robust against the same class of bug going forward.
- **[#54](https://github.com/awizemann/scarf/issues/54) — Add Project on remote server contexts.** The Add Project sheet always rendered a Browse button backed by `NSOpenPanel` (a Mac-local file dialog). On a remote SSH context the user picked a Mac path, the path landed in the projects registry as the project's "remote" working directory, and tool calls failed at runtime because that path doesn't exist on the Linux server. Tier-1 fix: sheet is now context-aware — local context keeps Browse unchanged; remote context hides Browse, shows a `"Path on <server> — must already exist on the server"` hint, and adds a Verify button that runs `transport.stat(path)` and renders inline ✓ / ⚠. A full SFTP-backed remote picker remains a deferred feature.
#### ScarfGo (iOS)
- **[#46](https://github.com/awizemann/scarf/issues/46) — same O(n)-per-token fix on iOS.** ScarfGo uses a different chat path (`LazyVStack` directly over `controller.vm.messages`, not message groups) so the Mac fix's `Equatable` conformances didn't propagate. Added an iOS-equivalent `MessageBubble: Equatable` with `.equatable()` at the `ForEach` call site — settled bubbles short-circuit body re-eval while the streaming bubble still redraws.
- **[#51](https://github.com/awizemann/scarf/issues/51) — keyboard now dismissable.** Pre-fix the chat composer's `TextField` had no `@FocusState`, no `.scrollDismissesKeyboard`, and no keyboard accessory toolbar; with `axis: .vertical` + `.submitLabel(.send)` the Return key inserts a newline rather than submitting. Once the keyboard rose it stuck — hiding the system tab bar (which iOS auto-hides while a keyboard is up) and trapping users in the Chat tab. Added two redundant dismissal paths: `.scrollDismissesKeyboard(.interactively)` on the message list (drag messages downward to collapse) AND a `keyboard.chevron.compact.down` button in the keyboard accessory toolbar. Tab bar reappears on dismiss → users can switch tabs again.
- **[#55](https://github.com/awizemann/scarf/issues/55) — first-run Cancel button no longer looks broken.** TestFlight feedback: the "Connect to Hermes" onboarding's Cancel button appeared dead. Root cause: `RootModel.cancelOnboarding` had a defensive `servers.isEmpty` branch that re-presented a fresh onboarding view when there was nothing to fall back to, making the button fire correctly but visually do nothing. The fix is at the right layer: `OnboardingRootView` now takes a `canCancel: Bool` parameter and hides the Cancel button entirely when there's no server list to return to.
### New features (Mac)
- **Chat density preferences ([#47](https://github.com/awizemann/scarf/issues/47) + [#48](https://github.com/awizemann/scarf/issues/48)).** New section in **Settings → Display → Chat density**. All defaults match today's UI exactly so existing users see no change until they opt in.
- **Tool calls**: Full card (default) / Compact chip / Hidden. Compact renders each call as a single-line tappable chip — kind icon + function name + status dot — that opens the right-pane inspector with the same details the inline expand shows. Hidden skips per-call rows; the always-visible group summary pill ("Used 5 tools (3 read, 2 edit)") becomes tappable so the inspector pane is still one click away.
- **Reasoning**: Disclosure box (default) / Inline (italic) / Hidden. Inline collapses the yellow disclosure to italic faded caption text inline above the reply with a small brain prefix — same data, far less vertical space. Hidden skips reasoning entirely.
- **Chat font size**: 85% to 130% slider (5% step). Applied at the chat root via `.environment(\.dynamicTypeSize, ...)` so message list, input bar, session info bar, and inspector pane all scale together.
All density toggles preserve existing telemetry surfaces — per-turn stopwatch, per-message tokens, finish reason, and timestamp stay in the bubble metadata footer; SessionInfoBar input/output/reasoning tokens, USD cost, model, project, git branch, and started-at relative time are unaffected by every density setting.
### New features (ScarfGo iOS)
- **iCloud Keychain sync for SSH keys ([#52](https://github.com/awizemann/scarf/issues/52)).** Reddit-reported friction: every iOS device needed its own SSH key. Pairing iPhone + iPad meant onboarding twice and editing `authorized_keys` per device. New opt-in toggle in **System → Security**: when enabled, the SSH key bundle is stored with `kSecAttrAccessibleAfterFirstUnlock` + `kSecAttrSynchronizable=true` so iCloud Keychain picks it up on every signed-in device. Default off (preserves today's behavior on update). Toggling triggers a one-shot migration that re-saves all stored keys with the target attributes; failure reverts the toggle and surfaces the error inline. With Advanced Data Protection enabled, the encryption keys never leave your devices.
### Documentation + tooling
- **Privacy / sandboxing claim corrected.** Previous CLAUDE.md / README implied Scarf ran sandboxed; it doesn't (and can't, given that it spawns the user-installed `hermes` binary and reads `~/.hermes/` directly). Documentation now reflects the actual posture.
- **Release pipeline hardened.** `scripts/release.sh` now extracts each variant's distribution zip and runs `codesign --verify --strict --deep` + `spctl --assess --type execute` on the extracted bundle as a final gate. Catches any future regression in the shipped artifact pre-ship rather than via user reports.
### Notes for users running 2.5.0
No data migrations needed. Server configs, Keychain entries, project registries, session attribution sidecar — all forward-compatible. The iCloud Keychain sync toggle defaults to off, so existing iOS users keep their device-local keys until they opt in.
+55
View File
@@ -0,0 +1,55 @@
## What's in 2.5.2
A patch with one substantial new feature (**iOS chat resilience** — reconnect, cached snapshot fallback, history paging) plus a stack of fixes for issues reported against 2.5.1 and earlier. Drop-in replacement for 2.5.1 on Mac; drop-in TestFlight build on iOS. No data migrations.
### iOS chat resilience
ScarfGo now survives phone-sleep, network handoffs, and SSH socket drops without losing the agent's work. Hermes was already persisting messages to `state.db` in real-time; iOS just had no resync path.
- **5-attempt exponential reconnect** (1s → 2s → 4s → 8s → 16s) via `session/resume` with `session/load` fallback. Reconciles with `state.db` on success and surfaces a *"Resynced N new messages"* toast when the agent kept working through the disconnect.
- **`NetworkReachabilityService`** (NWPathMonitor singleton): suspends reconnect attempts while offline and kicks a fresh cycle on link-up. Two new banner states above the message list — `.reconnecting` and `.offline` — render as slim ScarfDesign-tinted strips so the user always knows what the chat is doing.
- **Scene-phase awareness**: returning to foreground triggers a channel-health check; if dead, the reconnect cycle starts immediately rather than waiting for the next interaction.
- **Draft persistence**: per-server, per-session draft survives force-quit (UserDefaults-backed, 7-day janitor at app launch).
### Cached snapshot fallback (Mac + iOS)
`ServerTransport.cachedSnapshotPath` lets `HermesDataService` fall back to the previously-pulled `state.db` snapshot when a fresh pull fails. `isUsingStaleSnapshot` + `lastSnapshotMtime` surface to views so they render *"Last updated X ago."* Chat-history reload still passes `forceFresh: true` to refuse stale data; everything else (Dashboard, Sessions list, Activity) gets read-while-disconnected for free.
### Bounded message-history paging
`HermesDataService.fetchMessages(sessionId:limit:before:)` paginates by id desc with centralized `HistoryPageSize` constants. `RichChatViewModel.loadEarlier()` walks back through long sessions via `oldestLoadedMessageID` + `hasMoreHistory`. Legacy unbounded overload deprecated.
### Bug fixes
#### Mac
- **[#46](https://github.com/awizemann/scarf/issues/46) — chat O(n)-per-token bog-down (already shipped in 2.5.1 for the trailing-group patch; this release retains the fix and pairs with the new history paging so chats with thousands of messages stay smooth).**
- **[#19](https://github.com/awizemann/scarf/issues/19) layer-3 — sqlite3 false-negative in diagnostics.** Already in v2.5.1; kept here.
- **[#44](https://github.com/awizemann/scarf/issues/44) — pill / diagnostics agreement** via shared `SSHScriptRunner`. From v2.5.1; the tier-2 probe now also checks `state.db` (not just `config.yaml`) so a healthy fresh install reports green.
- **[#59](https://github.com/awizemann/scarf/issues/59) — Settings → Model and Credential Pools no longer freeze.** Both views called `ModelCatalogService.loadProviders()` synchronously from `.onAppear` on the MainActor; on a remote SSH context that's a multi-megabyte SSH file read on the main thread, freezing the UI for 12 minutes. New `loadProvidersAsync()` / `loadModelsAsync(for:)` wrappers dispatch off the main thread; both views now use `.task` + `await` with a `ProgressView("Loading providers…")` overlay. Per-provider switching in the picker is also async now, so clicking a different provider doesn't re-freeze the UI.
- **Diagnostics tri-state.** Hermes v0.11+ doesn't materialize `config.yaml` until the user changes a setting from defaults — so the diagnostics view was reporting *"12/14 passing"* on healthy fresh installs. The probe now distinguishes `.pass` / `.fail` / `.skipped`; a missing `config.yaml` emits SKIP and is excluded from the summary's denominator. Reads as *"12/12 passing (2 optional skipped)"* instead of the misleading 12/14.
- **Credentials: OAuth providers visible.** `hasAnyAICredential()` only probed `credential_pool.<provider>` in `auth.json`; OAuth-authed providers land under `providers.<name>.access_token` (Nous, Spotify, GH Copilot ACP, Qwen, Gemini all use that path). The chat banner kept showing *"No AI provider credentials"* even after a successful Nous sign-in. Now both shapes count. Credential Pools view gains a parallel "OAuth providers" section listing OAuth-authed providers with token tail, expiry badge, and portal URL.
- **Project-shadowed Hermes detection.** New `ProjectHermesShadowDetector` (ScarfCore) probes each registered project at chat-start; if a `.hermes/` dir or `hermes.yaml` is found inside the project, the user gets a banner explaining that project-local Hermes config will shadow the server-level one (a quiet failure mode for users who didn't realize Hermes prefers project-local config).
- **[#58](https://github.com/awizemann/scarf/issues/58) — Mac chat side panes are hideable.** Two toolbar buttons next to the View picker (`sidebar.left` / `sidebar.right`) toggle the sessions list and tool inspector with a slide animation; both default visible (today's behavior). Clicking a tool card auto-shows the inspector if hidden so the click never silently dies. Settings → Display → Chat density gains parity Toggle rows.
#### ScarfGo (iOS)
- **[#56](https://github.com/awizemann/scarf/issues/56) — *"Citadel.SSHClient.CommandFailed error 1"* on dashboard.** `asyncSnapshotSQLite` was missed during the v2.5.0 Citadel hardening — used raw `executeCommand` (which discards stderr on non-zero exit) and didn't prepend the Citadel-friendly `PATH=$HOME/.local/bin:/opt/homebrew/bin:/usr/local/bin:$PATH`. Now uses `executeCommandStream` and the same PATH prefix. `HermesDataService.humanize` already translates `sqlite3: command not found` / `permission denied` / `no such file` into actionable user copy — the bug was that the snapshot path never fed it real stderr.
- **[#57](https://github.com/awizemann/scarf/issues/57) — keyboard-dismiss chevron over send button.** The keyboard accessory dismiss button added in v2.5.1 (#51) was placed at the trailing edge of the keyboard toolbar, directly above the trailing-edge send button. Moved to the leading edge — matches the iOS convention (Notes, Mail, Reminders).
### New features (Mac)
- **Chat-start model preflight ([commit](https://github.com/awizemann/scarf/commit/2aab9da)).** Catches a missing `model.default` / `model.provider` in `config.yaml` *before* the ACP session starts. Pre-fix the user typed a prompt, hit send, and got an opaque *"Model parameter is required"* HTTP 400 from the upstream provider. Now `ChatModelPreflightSheet` wraps the existing model picker so the same selection / validation / Nous-catalog branch is single-sourced; the chat the user originally opened lands without re-clicking the project row.
- **Nous Portal live model catalog.** `NousModelCatalogService` fetches `GET /v1/models` from `inference-api.nousresearch.com` using the bearer token in `auth.json`. Cached at `~/.hermes/scarf/nous_models_cache.json` with a 24h TTL. The picker's nous-overlay detail view switches from a free-form TextField to a real model list, with a *"Custom…"* escape hatch for IDs not yet in the API response.
- **Remote-aware admin sheets.** Three sheets gained the same context-aware Verify pattern that Add Project got in v2.5.1 (#54):
- **Profiles → Import / Export.** Buttons that drive `hermes profile import <zip>` / `hermes profile export <name> <zip>` over SSH. Local context picks via `NSOpenPanel`; remote context shows a path-input + Verify button.
- **Settings → Advanced → Restore.** Pick a local backup zip OR enter+verify a remote path.
- **Templates → Install destination.** The parent-directory step in the install sheet branches on context — local Browse, or remote text-input + Verify.
### Translations
`Localizable.xcstrings` adds strings for all the new copy across the seven supported locales (English, Simplified Chinese, German, French, Spanish, Japanese, Brazilian Portuguese).
### Notes for users running 2.5.1
No data migrations needed. `~/.hermes/scarf/nous_models_cache.json` is created lazily on first use of the Nous picker; everything else is forward-compatible with existing config / Keychain / project registries.
+51
View File
@@ -0,0 +1,51 @@
// swift-tools-version: 6.0
// Platform-neutral core for the Scarf app family (macOS and iOS).
//
// `ScarfCore` holds types that do not depend on AppKit, UIKit, or any
// platform-specific system service. The macOS and iOS app targets each link
// this package and provide their own platform shells (Sparkle + SwiftTerm on
// macOS; Citadel-based SSH transport on iOS).
//
// Minimums are chosen to match the Mac app (macOS 14.6) and the locked
// v1 iOS decision (iOS 18). Raising iOS later is free; lowering is not
// the ViewModels on `@Observable` / `NavigationStack` are iOS 17+ features
// and we standardize on iOS 18 for feature parity with the Mac codebase.
import PackageDescription
let package = Package(
name: "ScarfCore",
defaultLocalization: "en",
platforms: [
.macOS(.v14),
.iOS(.v18),
],
products: [
.library(
name: "ScarfCore",
targets: ["ScarfCore"]
),
],
targets: [
.target(
name: "ScarfCore",
path: "Sources/ScarfCore",
swiftSettings: [
// Swift 5 language mode mirrors the Mac app target's
// `SWIFT_VERSION = 5.0` build setting. Moving to strict
// Swift 6 concurrency is a real refactor several types
// (`ACPEvent.availableCommands` carrying `[[String: Any]]`,
// `ACPToolCallEvent.rawInput: [String: Any]?`) claim
// `Sendable` without being strictly-Sendable. A follow-up
// phase will replace those with typed payloads, then this
// setting can bump to `.v6`.
.swiftLanguageMode(.v5),
]
),
.testTarget(
name: "ScarfCoreTests",
dependencies: ["ScarfCore"],
path: "Tests/ScarfCoreTests"
),
]
)
@@ -0,0 +1,82 @@
import Foundation
/// The bidirectional line-oriented transport that `ACPClient` speaks
/// JSON-RPC over. Abstracts away whether the other end is a local
/// `hermes acp` subprocess (macOS) or a remote SSH exec channel (iOS via
/// Citadel in M4+). ACPClient never touches `Process`, `Pipe`, file
/// descriptors, or SSH sessions directly it just sends and receives
/// newline-delimited JSON lines over one of these.
///
/// **Line framing.** Senders pass a JSON object serialized to a single
/// line (no embedded `\n`). The channel appends the terminator itself.
/// The receiver yields one complete JSON line per `incoming` element;
/// partial lines are buffered internally until a newline arrives.
///
/// **Lifecycle.** A channel is "already live" when you hold a reference
/// the constructor (or channel-factory call) spawns the subprocess / opens
/// the SSH exec channel. `close()` tears down and causes `incoming` /
/// `stderr` to finish. After `close()`, `send(_:)` throws.
///
/// **Errors.** Transport errors (broken pipe, SSH disconnect, process
/// died) surface as an error-terminated `incoming` stream consumers
/// should be prepared for that, not just for clean `.finished` stream
/// termination. `send(_:)` also throws on these.
public protocol ACPChannel: Sendable {
/// Append `\n` and write atomically. Thread-safe (the actor boundary
/// is on the implementation side, not the protocol).
func send(_ line: String) async throws
/// One complete JSON-RPC line per element, without the trailing
/// newline. Yields in arrival order. Finishes (clean or error) when
/// the underlying transport closes.
var incoming: AsyncThrowingStream<String, Error> { get }
/// Diagnostic stderr. For `ProcessACPChannel` this is the spawned
/// process's stderr, line-buffered. For future SSH-exec channels
/// where stderr folds into events, this is an empty stream.
/// Lines are yielded without the trailing newline.
var stderr: AsyncThrowingStream<String, Error> { get }
/// Request graceful shutdown. Closes stdin first (so the remote side
/// sees EOF and can flush), then waits briefly for the subprocess /
/// exec channel to exit, then force-terminates. Idempotent calling
/// `close()` on an already-closed channel is a no-op.
func close() async
/// Short identifier for logs. Process channels return the child PID;
/// SSH exec channels return the SSH channel id or `nil` when not
/// applicable.
var diagnosticID: String? { get async }
}
/// Errors raised by `ACPChannel` implementations when the underlying
/// transport breaks. JSON-RPC errors (the remote returning an `error`
/// field) are not in this enum they ride as valid `incoming` lines and
/// are ACPClient's problem to decode.
public enum ACPChannelError: Error, LocalizedError {
/// The underlying subprocess or SSH exec channel exited. `exitCode`
/// is the subprocess exit status (or a synthetic value for SSH).
case closed(exitCode: Int32)
/// `send(_:)` was called on a channel whose write end is already
/// closed. Typically means a previous `close()` call or a pipe
/// broken by a remote termination.
case writeEndClosed
/// Bytes sent or received couldn't be encoded/decoded as UTF-8.
/// Hermes emits only UTF-8; hitting this usually means a framing
/// bug or random binary junk on the channel.
case invalidEncoding
/// Failed to launch the subprocess or open the SSH exec channel.
case launchFailed(String)
/// Catch-all for everything else with a context string.
case other(String)
public var errorDescription: String? {
switch self {
case .closed(let code): return "ACP channel closed (exit \(code))"
case .writeEndClosed: return "ACP channel write end is closed"
case .invalidEncoding: return "ACP channel carried non-UTF-8 bytes"
case .launchFailed(let msg): return "Failed to launch ACP channel: \(msg)"
case .other(let msg): return msg
}
}
}
@@ -1,16 +1,35 @@
import Foundation
#if canImport(os)
import os
#endif
/// Manages a `hermes acp` subprocess and communicates via JSON-RPC over stdio.
/// Provides an async event stream for real-time session updates.
actor ACPClient {
/// Manages an ACP (Agent Client Protocol) session with a backing Hermes
/// agent. Talks JSON-RPC over an `ACPChannel` the channel itself owns
/// the transport (subprocess for macOS, SSH exec session for iOS via
/// Citadel in M4+). This actor is transport-agnostic.
///
/// **Channel factory injection.** Construction takes a closure that
/// builds a channel on demand. The Mac target wires this at app launch
/// to produce a `ProcessACPChannel` configured with the enriched
/// shell env (PATH, credentials). iOS will wire a `SSHExecACPChannel`
/// factory at app launch.
///
/// Under iOS the `ProcessACPChannel` implementation is skipped at
/// compile time (`#if !os(iOS)`) an iOS `ACPClient` that tried to
/// spawn a subprocess would be a build error, not a runtime bug.
public actor ACPClient {
#if canImport(os)
private let logger = Logger(subsystem: "com.scarf", category: "ACPClient")
#endif
private var process: Process?
private var stdinPipe: Pipe?
private var stdoutPipe: Pipe?
private var stderrPipe: Pipe?
private var stdinFd: Int32 = -1
/// Returns a fresh ACPChannel connected to `hermes acp` for this
/// context. Mac wires this to spawn a `ProcessACPChannel` with the
/// enriched env (so `hermes` can find Homebrew/nvm/asdf binaries
/// on PATH). iOS wires a Citadel-backed channel in M4+.
public typealias ChannelFactory = @Sendable (ServerContext) async throws -> any ACPChannel
private var channel: (any ACPChannel)?
private let channelFactory: ChannelFactory
private var nextRequestId = 1
private var pendingRequests: [Int: CheckedContinuation<AnyCodable?, Error>] = [:]
@@ -20,27 +39,29 @@ actor ACPClient {
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 = ""
public private(set) var isConnected = false
public private(set) var currentSessionId: String?
public private(set) var statusMessage = ""
let context: ServerContext
private let transport: any ServerTransport
public let context: ServerContext
init(context: ServerContext = .local) {
public init(
context: ServerContext = .local,
channelFactory: @escaping ChannelFactory
) {
self.context = context
self.transport = context.makeTransport()
self.channelFactory = channelFactory
}
/// 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.
/// Ring buffer of recent stderr lines from the ACP channel 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 {
/// Returns the last ~`stderrBufferMaxLines` stderr lines captured
/// from the ACP channel, joined by newlines.
public var recentStderr: String {
stderrBuffer.joined(separator: "\n")
}
@@ -53,121 +74,79 @@ actor ACPClient {
}
}
/// Check if the underlying process is still alive and connected.
var isHealthy: Bool {
guard isConnected, let process else { return false }
return process.isRunning
/// True while the underlying channel is alive. Equivalent to the
/// old `process.isRunning` check.
public var isHealthy: Bool {
isConnected && channel != nil
}
// 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
/// Access the event stream. Must call `start()` first. Before start,
/// returns an immediately-finished stream so callers can iterate
/// without a nil check.
public var events: AsyncStream<ACPEvent> {
_eventStream ?? AsyncStream { $0.finish() }
}
// MARK: - Lifecycle
func start() async throws {
guard process == nil else { return }
public func start() async throws {
guard channel == 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
// Create the event stream BEFORE anything else so no events are
// lost while the channel is handshaking.
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..."
let ch: any ACPChannel
do {
try proc.run()
ch = try await channelFactory(context)
} catch {
statusMessage = "Failed to start: \(error.localizedDescription)"
logger.error("Failed to start hermes acp: \(error.localizedDescription)")
#if canImport(os)
logger.error("Failed to open ACP channel: \(error.localizedDescription)")
#endif
continuation.finish()
throw error
}
self.process = proc
self.stdinPipe = stdin
self.stdoutPipe = stdout
self.stderrPipe = stderr
self.stdinFd = stdin.fileHandleForWriting.fileDescriptor
self.channel = ch
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))")
// Start reading incoming JSON-RPC BEFORE sending initialize so
// we catch the response.
startReadLoops(channel: ch)
#if canImport(os)
if let id = await ch.diagnosticID {
logger.info("ACP channel opened (\(id, privacy: .public))")
} else {
logger.info("ACP channel opened")
}
#endif
statusMessage = "Initializing..."
// Initialize the ACP connection
// 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])
"version": "1.0",
] as [String: Any]),
]
_ = try await sendRequest(method: "initialize", params: initParams)
statusMessage = "Connected"
#if canImport(os)
logger.info("ACP connection initialized")
#endif
startKeepalive()
}
func stop() async {
public func stop() async {
readTask?.cancel()
readTask = nil
stderrTask?.cancel()
@@ -183,34 +162,16 @@ actor ACPClient {
}
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()
if let ch = channel {
await ch.close()
}
}
}
stdinPipe?.fileHandleForReading.closeFile()
stdoutPipe?.fileHandleForReading.closeFile()
stderrPipe?.fileHandleForReading.closeFile()
process = nil
stdinPipe = nil
stdoutPipe = nil
stderrPipe = nil
stdinFd = -1
channel = nil
isConnected = false
currentSessionId = nil
statusMessage = "Disconnected"
#if canImport(os)
logger.info("ACP client stopped")
#endif
}
// MARK: - Keepalive
@@ -225,89 +186,94 @@ actor ACPClient {
}
}
/// 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)
}()
/// Valid JSON-RPC notification used as a keepalive probe. Plain
/// newlines upstream produce `json.loads("")` errors in the ACP
/// server so we send a real method.
private static let keepalivePayload: String = #"{"jsonrpc":"2.0","method":"$/ping"}"#
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()
}
private func sendKeepalive() async {
guard let ch = channel else { return }
do {
try await ch.send(Self.keepalivePayload)
} catch {
await handleWriteFailed()
}
}
// MARK: - Session Management
func newSession(cwd: String) async throws -> String {
public func newSession(cwd: String) async throws -> String {
statusMessage = "Creating session..."
let params: [String: AnyCodable] = [
"cwd": AnyCodable(cwd),
"mcpServers": AnyCodable([Any]())
"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 {
let sessionId = dict["sessionId"] as? String
else {
throw ACPClientError.invalidResponse("Missing sessionId in session/new response")
}
currentSessionId = sessionId
statusMessage = "Session ready"
#if canImport(os)
logger.info("Created new ACP session: \(sessionId)")
#endif
return sessionId
}
func loadSession(cwd: String, sessionId: String) async throws -> String {
public 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]())
"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.
// 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"
#if canImport(os)
logger.info("Loaded ACP session: \(loadedId)")
#endif
return loadedId
}
func resumeSession(cwd: String, sessionId: String) async throws -> String {
public 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]())
"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 {
let resumedId = dict["sessionId"] as? String
else {
throw ACPClientError.invalidResponse("Missing sessionId in session/resume response")
}
currentSessionId = resumedId
statusMessage = "Session resumed"
#if canImport(os)
logger.info("Resumed ACP session: \(resumedId)")
#endif
return resumedId
}
// MARK: - Messaging
func sendPrompt(sessionId: String, text: String) async throws -> ACPPromptResult {
public 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])
["type": "text", "text": text] as [String: Any],
] as [Any]),
]
let result = try await sendRequest(method: "session/prompt", params: params)
let dict = result?.dictValue ?? [:]
@@ -323,26 +289,26 @@ actor ACPClient {
)
}
func cancel(sessionId: String) async throws {
public func cancel(sessionId: String) async throws {
let params: [String: AnyCodable] = [
"sessionId": AnyCodable(sessionId)
"sessionId": AnyCodable(sessionId),
]
_ = try await sendRequest(method: "session/cancel", params: params)
statusMessage = "Cancelled"
}
func respondToPermission(requestId: Int, optionId: String) {
public func respondToPermission(requestId: Int, optionId: String) async {
let response: [String: Any] = [
"jsonrpc": "2.0",
"id": requestId,
"result": [
"outcome": [
"kind": optionId == "deny" ? "rejected" : "allowed",
"optionId": optionId
] as [String: Any]
] as [String: Any]
"optionId": optionId,
] as [String: Any],
] as [String: Any],
]
writeJSON(response)
await writeJSON(response)
}
// MARK: - JSON-RPC Transport
@@ -352,15 +318,18 @@ actor ACPClient {
nextRequestId += 1
let request = ACPRequest(id: requestId, method: method, params: params)
guard let data = try? JSONEncoder().encode(request) else {
guard let data = try? JSONEncoder().encode(request),
let line = String(data: data, encoding: .utf8)
else {
throw ACPClientError.encodingFailed
}
#if canImport(os)
logger.debug("Sending: \(method) (id: \(requestId))")
#endif
// session/prompt streams events and can run for minutes no hard timeout.
// Control messages get a 30s watchdog.
// 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)
@@ -369,26 +338,23 @@ actor ACPClient {
} else {
nil
}
defer { timeoutTask?.cancel() }
let fd = stdinFd
guard let ch = channel else {
throw ACPClientError.notConnected
}
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.
// Write in a detached task so the actor can process incoming
// response messages while we're awaiting the send. 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 {
do {
try await ch.send(line)
} catch {
await self?.handleWriteFailedForRequest(id: requestId)
}
}
@@ -397,93 +363,97 @@ actor ACPClient {
private func timeoutRequest(id: Int, method: String) {
guard let continuation = pendingRequests.removeValue(forKey: id) else { return }
#if canImport(os)
logger.error("Request timed out: \(method) (id: \(id))")
#endif
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))")
}
private func writeJSON(_ dict: [String: Any]) async {
guard let ch = channel,
let data = try? JSONSerialization.data(withJSONObject: dict),
let line = String(data: data, encoding: .utf8)
else { return }
do {
let message = try JSONDecoder().decode(ACPRawMessage.self, from: lineData)
try await ch.send(line)
} catch {
await handleWriteFailed()
}
}
// MARK: - Read Loops
private func startReadLoops(channel ch: any ACPChannel) {
// Consume incoming JSON-RPC lines from the channel.
readTask = Task { [weak self] in
do {
for try await line in ch.incoming {
guard let data = line.data(using: .utf8) else { continue }
do {
let message = try JSONDecoder().decode(ACPRawMessage.self, from: data)
await self?.handleMessage(message)
} catch {
self?.logger.warning("Failed to decode ACP message: \(error.localizedDescription)")
#if canImport(os)
await self?.logParseFailure(error, line: line)
#endif
}
}
await self?.handleReadLoopEnded(cleanly: true)
} catch {
await self?.handleReadLoopEnded(cleanly: false, error: error)
}
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))")
// Mirror stderr into the diagnostic ring buffer.
stderrTask = Task { [weak self] in
do {
for try await text in ch.stderr {
await self?.appendStderr(text)
#if canImport(os)
await self?.logStderrLine(text)
#endif
}
} catch {
// Stderr errors don't matter we already handle EOF on
// the incoming stream.
}
}
}
#if canImport(os)
private func logParseFailure(_ error: Error, line: String) {
logger.warning("Failed to decode ACP message: \(error.localizedDescription)")
}
private func logStderrLine(_ text: String) {
logger.info("ACP stderr: \(text.prefix(500))")
}
#endif
private func handleMessage(_ message: ACPRawMessage) {
if message.isResponse {
if let requestId = message.id,
let continuation = pendingRequests.removeValue(forKey: requestId) {
if let error = message.error {
#if canImport(os)
logger.error("ACP RPC error (id: \(requestId)): \(error.message)")
#endif
statusMessage = "Error: \(error.message)"
continuation.resume(throwing: ACPClientError.rpcError(code: error.code, message: error.message))
} else {
#if canImport(os)
logger.debug("ACP response (id: \(requestId))")
#endif
continuation.resume(returning: message.result)
}
} else {
#if canImport(os)
logger.warning("ACP response for unknown request id: \(message.id ?? -1)")
#endif
}
} 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 {
@@ -500,7 +470,9 @@ actor ACPClient {
/// Single idempotent cleanup path for all disconnect scenarios.
private func performDisconnectCleanup(reason: String) {
guard isConnected else { return }
#if canImport(os)
logger.warning("ACP disconnecting: \(reason)")
#endif
isConnected = false
statusMessage = "Connection lost"
for (_, continuation) in pendingRequests {
@@ -511,12 +483,9 @@ actor ACPClient {
eventContinuation = nil
}
private func handleReadLoopEnded() {
performDisconnectCleanup(reason: "read loop ended (EOF)")
}
private func handleTermination(exitCode: Int32) {
performDisconnectCleanup(reason: "process exited (\(exitCode))")
private func handleReadLoopEnded(cleanly: Bool, error: Error? = nil) {
let reason = cleanly ? "read loop ended (EOF)" : "read loop failed: \(error?.localizedDescription ?? "unknown")"
performDisconnectCleanup(reason: reason)
}
private func handleWriteFailed() {
@@ -529,29 +498,11 @@ actor ACPClient {
}
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 {
public enum ACPClientError: Error, LocalizedError {
case notConnected
case encodingFailed
case invalidResponse(String)
@@ -559,7 +510,7 @@ enum ACPClientError: Error, LocalizedError {
case processTerminated
case requestTimeout(method: String)
var errorDescription: String? {
public var errorDescription: String? {
switch self {
case .notConnected: return "ACP client is not connected"
case .encodingFailed: return "Failed to encode JSON-RPC request"
@@ -574,8 +525,8 @@ enum ACPClientError: Error, LocalizedError {
/// 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? {
public enum ACPErrorHint {
public 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
@@ -0,0 +1,255 @@
// iOS can't spawn subprocesses (no `Process`, sandboxed away from fork/exec).
// Everything below only makes sense on platforms that can macOS and Linux.
// iOS gets its ACP transport from a future `SSHExecACPChannel` (Citadel)
// landing in M4.
#if !os(iOS)
import Foundation
/// `ACPChannel` backed by a `Foundation.Process` spawning `hermes acp`
/// (local) or `ssh -T host -- hermes acp` (remote, via
/// `SSHTransport.makeProcess`). Owns the process lifecycle, stdin/stdout
/// pipes, and a small ring-buffered stderr capture for diagnostics.
///
/// The per-call `send(_:)` path uses raw POSIX `write(2)` instead of
/// `FileHandle.write` `FileHandle.write` crashes the whole app on
/// EPIPE (broken pipe) rather than throwing, so the original ACPClient
/// installed a `SIGPIPE` handler and a POSIX-write helper. That logic
/// moves here intact.
public actor ProcessACPChannel: ACPChannel {
private let process: Process
private let stdinPipe: Pipe
private let stdoutPipe: Pipe
private let stderrPipe: Pipe
/// Cached raw file descriptor for the stdin write end. Captured on
/// init because `Process.standardInput` gets nilled after `close()`.
private let stdinFd: Int32
private let incomingContinuation: AsyncThrowingStream<String, Error>.Continuation
/// Retain the stream callers get it lazily; we stash it here so the
/// continuation doesn't outlive its producer.
public nonisolated let incoming: AsyncThrowingStream<String, Error>
private let stderrContinuation: AsyncThrowingStream<String, Error>.Continuation
public nonisolated let stderr: AsyncThrowingStream<String, Error>
private var isClosed = false
private var readerTask: Task<Void, Never>?
private var stderrTask: Task<Void, Never>?
/// The subprocess's PID as a human-readable string.
public var diagnosticID: String? {
"pid=\(process.processIdentifier)"
}
/// Spawn `executable` with `args`, wiring its stdin/stdout/stderr into
/// this channel. `env` is passed verbatim to the subprocess (callers
/// are responsible for running it through whatever enrichment they
/// need this layer doesn't know about `SSH_AUTH_SOCK` or PATH).
///
/// For remote contexts, the Mac caller passes a pre-configured
/// `Process` via `init(process:)` below `SSHTransport.makeProcess`
/// already set up the ssh argv.
public init(
executable: String,
args: [String],
env: [String: String]
) async throws {
let proc = Process()
proc.executableURL = URL(fileURLWithPath: executable)
proc.arguments = args
proc.environment = env
try await Self.launch(process: proc, self_: nil)
try Self.ignoreSIGPIPE_once()
self.process = proc
self.stdinPipe = proc.standardInput as! Pipe
self.stdoutPipe = proc.standardOutput as! Pipe
self.stderrPipe = proc.standardError as! Pipe
self.stdinFd = stdinPipe.fileHandleForWriting.fileDescriptor
let (inStream, inContinuation) = AsyncThrowingStream<String, Error>.makeStream()
self.incoming = inStream
self.incomingContinuation = inContinuation
let (errStream, errContinuation) = AsyncThrowingStream<String, Error>.makeStream()
self.stderr = errStream
self.stderrContinuation = errContinuation
await startReaders()
}
/// Secondary entry point for callers that have a pre-configured
/// `Process` (typically from `SSHTransport.makeProcess`). The process
/// must NOT already be running this initializer calls `run()`.
public init(process: Process) async throws {
try await Self.launch(process: process, self_: nil)
try Self.ignoreSIGPIPE_once()
self.process = process
self.stdinPipe = process.standardInput as! Pipe
self.stdoutPipe = process.standardOutput as! Pipe
self.stderrPipe = process.standardError as! Pipe
self.stdinFd = stdinPipe.fileHandleForWriting.fileDescriptor
let (inStream, inContinuation) = AsyncThrowingStream<String, Error>.makeStream()
self.incoming = inStream
self.incomingContinuation = inContinuation
let (errStream, errContinuation) = AsyncThrowingStream<String, Error>.makeStream()
self.stderr = errStream
self.stderrContinuation = errContinuation
await startReaders()
}
/// Wire fresh stdin/stdout/stderr pipes (overwriting any the caller
/// set) and start the subprocess. `self_` is unused today the
/// placeholder keeps the signature ready for a future hook that
/// captures termination in `proc.terminationHandler` and routes it
/// into the channel's actor state.
private static func launch(process: Process, self_: Any?) async throws {
process.standardInput = Pipe()
process.standardOutput = Pipe()
process.standardError = Pipe()
do {
try process.run()
} catch {
throw ACPChannelError.launchFailed(error.localizedDescription)
}
}
/// Ignore SIGPIPE once per process so a broken-pipe write returns
/// `EPIPE` (which we surface as `.writeEndClosed`) instead of
/// delivering SIGPIPE and tearing the app down. Idempotent; the
/// kernel is fine with repeated `SIG_IGN` installs.
nonisolated private static func ignoreSIGPIPE_once() throws {
signal(SIGPIPE, SIG_IGN)
}
// MARK: - Send
public func send(_ line: String) async throws {
guard !isClosed else { throw ACPChannelError.writeEndClosed }
guard var data = line.data(using: .utf8) else {
throw ACPChannelError.invalidEncoding
}
data.append(0x0A) // '\n'
let fd = stdinFd
// POSIX write, looping on partial writes and surfacing EPIPE as
// `.writeEndClosed`. Crucial: `FileHandle.write(_:)` crashes the
// app on EPIPE rather than throwing; the original ACPClient used
// this same `Darwin.write` (or `Glibc.write` on Linux) technique.
let ok = Self.safeWrite(fd: fd, data: data)
if !ok {
throw ACPChannelError.writeEndClosed
}
}
nonisolated 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 {
#if canImport(Darwin)
let result = Darwin.write(fd, base.advanced(by: written), total - written)
#elseif canImport(Glibc)
let result = Glibc.write(fd, base.advanced(by: written), total - written)
#else
return false
#endif
if result <= 0 { return false }
written += result
}
return true
}
}
// MARK: - Close
public func close() async {
guard !isClosed else { return }
isClosed = true
// Close stdin so the child sees EOF and can flush. readerTask
// will see the pipe close and finish naturally.
stdinPipe.fileHandleForWriting.closeFile()
if process.isRunning {
// SIGINT for graceful Python shutdown raises KeyboardInterrupt
// cleanly instead of aborting in the middle of a JSON write.
process.interrupt()
// Watchdog: force-kill if still running after 2s. A stuck
// child shouldn't keep the app's close() hanging.
let watchdog = process
Task.detached {
try? await Task.sleep(nanoseconds: 2_000_000_000)
if watchdog.isRunning { watchdog.terminate() }
}
}
stdinPipe.fileHandleForReading.closeFile()
stdoutPipe.fileHandleForReading.closeFile()
stderrPipe.fileHandleForReading.closeFile()
stdoutPipe.fileHandleForWriting.closeFile()
stderrPipe.fileHandleForWriting.closeFile()
readerTask?.cancel()
stderrTask?.cancel()
incomingContinuation.finish()
stderrContinuation.finish()
}
// MARK: - Reader loops
private func startReaders() {
let outHandle = stdoutPipe.fileHandleForReading
let errHandle = stderrPipe.fileHandleForReading
let inCont = incomingContinuation
let errCont = stderrContinuation
readerTask = Task.detached {
var buffer = Data()
while !Task.isCancelled {
let chunk = outHandle.availableData
if chunk.isEmpty { break } // EOF
buffer.append(chunk)
while let nl = buffer.firstIndex(of: 0x0A) {
let lineData = Data(buffer[buffer.startIndex..<nl])
buffer = Data(buffer[buffer.index(after: nl)...])
guard !lineData.isEmpty else { continue }
if let text = String(data: lineData, encoding: .utf8) {
inCont.yield(text)
} else {
inCont.finish(throwing: ACPChannelError.invalidEncoding)
return
}
}
}
inCont.finish()
}
stderrTask = Task.detached {
var buffer = Data()
while !Task.isCancelled {
let chunk = errHandle.availableData
if chunk.isEmpty { break }
buffer.append(chunk)
while let nl = buffer.firstIndex(of: 0x0A) {
let lineData = Data(buffer[buffer.startIndex..<nl])
buffer = Data(buffer[buffer.index(after: nl)...])
guard !lineData.isEmpty else { continue }
if let text = String(data: lineData, encoding: .utf8) {
errCont.yield(text)
}
// Non-UTF-8 stderr lines are dropped silently;
// we're not going to crash the channel over a
// weird byte in a log line.
}
}
errCont.finish()
}
}
}
#endif // !os(iOS)
@@ -8,15 +8,25 @@ import Foundation
// 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]
public struct ACPRequest: Encodable, Sendable {
public nonisolated let jsonrpc = "2.0"
public nonisolated let id: Int
public nonisolated let method: String
public nonisolated let params: [String: AnyCodable]
enum CodingKeys: String, CodingKey { case jsonrpc, id, method, params }
nonisolated func encode(to encoder: any Encoder) throws {
public init(
id: Int,
method: String,
params: [String: AnyCodable]
) {
self.id = id
self.method = method
self.params = params
}
public enum CodingKeys: String, CodingKey { case jsonrpc, id, method, params }
public 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)
@@ -25,21 +35,21 @@ struct ACPRequest: Encodable, Sendable {
}
}
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?
public struct ACPRawMessage: Decodable, Sendable {
public nonisolated let jsonrpc: String?
public nonisolated let id: Int?
public nonisolated let method: String?
public nonisolated let result: AnyCodable?
public nonisolated let error: ACPError?
public 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 }
public nonisolated var isResponse: Bool { id != nil && method == nil }
public nonisolated var isNotification: Bool { method != nil && id == nil }
public nonisolated var isRequest: Bool { method != nil && id != nil }
enum CodingKeys: String, CodingKey { case jsonrpc, id, method, result, error, params }
public enum CodingKeys: String, CodingKey { case jsonrpc, id, method, result, error, params }
nonisolated init(from decoder: any Decoder) throws {
public 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)
@@ -50,13 +60,13 @@ struct ACPRawMessage: Decodable, Sendable {
}
}
struct ACPError: Decodable, Sendable {
nonisolated let code: Int
nonisolated let message: String
public struct ACPError: Decodable, Sendable {
public nonisolated let code: Int
public nonisolated let message: String
enum CodingKeys: String, CodingKey { case code, message }
public enum CodingKeys: String, CodingKey { case code, message }
nonisolated init(from decoder: any Decoder) throws {
public 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)
@@ -65,10 +75,10 @@ struct ACPError: Decodable, Sendable {
// MARK: - AnyCodable (for dynamic JSON)
struct AnyCodable: Codable, @unchecked Sendable {
nonisolated let value: Any
public struct AnyCodable: Codable, @unchecked Sendable {
public nonisolated let value: Any
nonisolated init(_ value: Any) { self.value = value }
public 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
@@ -78,7 +88,7 @@ struct AnyCodable: Codable, @unchecked Sendable {
// 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 {
public init(from decoder: any Decoder) throws {
let container = try decoder.singleValueContainer()
if container.decodeNil() {
value = NSNull()
@@ -99,7 +109,7 @@ struct AnyCodable: Codable, @unchecked Sendable {
}
}
func encode(to encoder: any Encoder) throws {
public func encode(to encoder: any Encoder) throws {
var container = encoder.singleValueContainer()
switch value {
case is NSNull:
@@ -123,15 +133,15 @@ struct AnyCodable: Codable, @unchecked Sendable {
// 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] }
public nonisolated var stringValue: String? { value as? String }
public nonisolated var intValue: Int? { value as? Int }
public nonisolated var dictValue: [String: Any]? { value as? [String: Any] }
public nonisolated var arrayValue: [Any]? { value as? [Any] }
}
// MARK: - ACP Events (parsed from session/update notifications)
enum ACPEvent: Sendable {
public enum ACPEvent: Sendable {
case messageChunk(sessionId: String, text: String)
case thoughtChunk(sessionId: String, text: String)
case toolCallStart(sessionId: String, call: ACPToolCallEvent)
@@ -143,21 +153,37 @@ enum ACPEvent: Sendable {
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]?
public struct ACPToolCallEvent: Sendable {
public let toolCallId: String
public let title: String
public let kind: String
public let status: String
public let content: String
public let rawInput: [String: Any]?
var functionName: String {
public init(
toolCallId: String,
title: String,
kind: String,
status: String,
content: String,
rawInput: [String: Any]?
) {
self.toolCallId = toolCallId
self.title = title
self.kind = kind
self.status = status
self.content = content
self.rawInput = rawInput
}
public 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 {
public var argumentsSummary: String {
let parts = title.split(separator: ":", maxSplits: 1)
if parts.count > 1 {
return String(parts[1]).trimmingCharacters(in: .whitespaces)
@@ -165,7 +191,7 @@ struct ACPToolCallEvent: Sendable {
return ""
}
var argumentsJSON: String {
public var argumentsJSON: String {
guard let input = rawInput,
let data = try? JSONSerialization.data(withJSONObject: input),
let str = String(data: data, encoding: .utf8) else { return "{}" }
@@ -173,32 +199,70 @@ struct ACPToolCallEvent: Sendable {
}
}
struct ACPToolCallUpdateEvent: Sendable {
let toolCallId: String
let kind: String
let status: String
let content: String
let rawOutput: String?
public struct ACPToolCallUpdateEvent: Sendable {
public let toolCallId: String
public let kind: String
public let status: String
public let content: String
public let rawOutput: String?
public init(
toolCallId: String,
kind: String,
status: String,
content: String,
rawOutput: String?
) {
self.toolCallId = toolCallId
self.kind = kind
self.status = status
self.content = content
self.rawOutput = rawOutput
}
}
struct ACPPermissionRequestEvent: Sendable {
let toolCallTitle: String
let toolCallKind: String
let options: [(optionId: String, name: String)]
public struct ACPPermissionRequestEvent: Sendable {
public let toolCallTitle: String
public let toolCallKind: String
public let options: [(optionId: String, name: String)]
public init(
toolCallTitle: String,
toolCallKind: String,
options: [(optionId: String, name: String)]
) {
self.toolCallTitle = toolCallTitle
self.toolCallKind = toolCallKind
self.options = options
}
}
struct ACPPromptResult: Sendable {
let stopReason: String
let inputTokens: Int
let outputTokens: Int
let thoughtTokens: Int
let cachedReadTokens: Int
public struct ACPPromptResult: Sendable {
public let stopReason: String
public let inputTokens: Int
public let outputTokens: Int
public let thoughtTokens: Int
public let cachedReadTokens: Int
public init(
stopReason: String,
inputTokens: Int,
outputTokens: Int,
thoughtTokens: Int,
cachedReadTokens: Int
) {
self.stopReason = stopReason
self.inputTokens = inputTokens
self.outputTokens = outputTokens
self.thoughtTokens = thoughtTokens
self.cachedReadTokens = cachedReadTokens
}
}
// MARK: - Event Parsing
enum ACPEventParser {
nonisolated static func parse(notification: ACPRawMessage) -> ACPEvent? {
public enum ACPEventParser {
public nonisolated static func parse(notification: ACPRawMessage) -> ACPEvent? {
guard notification.method == "session/update",
let params = notification.params?.dictValue,
let sessionId = params["sessionId"] as? String,
@@ -246,7 +310,7 @@ enum ACPEventParser {
}
}
nonisolated static func parsePermissionRequest(_ message: ACPRawMessage) -> ACPEvent? {
public nonisolated static func parsePermissionRequest(_ message: ACPRawMessage) -> ACPEvent? {
guard message.method == "session/request_permission",
let params = message.params?.dictValue,
let sessionId = params["sessionId"] as? String,
@@ -0,0 +1,211 @@
import Foundation
/// Human-readable rendering for `CronSchedule` values.
///
/// Hermes stores cron schedules with a raw `expression` (`"0 */6 * * *"`)
/// plus an optional `display` label. In practice, the CLI writes both
/// fields to the same raw cron string so UIs that render `display`
/// verbatim (both Scarf and ScarfGo, pre-fix) end up showing
/// `0 */6 * * *` to every user, technical or not.
///
/// This formatter pattern-matches the most common cron shapes and
/// produces English phrases. Anything it doesn't recognise falls back
/// to the raw expression with a short hint, so nothing is lost.
///
/// Not a full cron parser covers ~95% of real-world schedules while
/// staying ~80 lines. Add patterns here as users hit unrecognised
/// shapes; the fallback already ships working.
public enum CronScheduleFormatter {
/// Primary entry point. Returns a phrase suitable for the row
/// subtitle in Mac + ScarfGo cron lists.
public static func humanReadable(from schedule: CronSchedule) -> String {
// Trust `display` when it doesn't look like raw cron. Users
// CAN set descriptive labels via `hermes cron set-display`;
// we don't want to overwrite that.
if let display = schedule.display,
!display.isEmpty,
!looksLikeCron(display)
{
return display
}
// Use whatever raw expression we have (preferring `expression`,
// falling back to `display` since Hermes sometimes writes the
// cron into both fields).
let expr = schedule.expression ?? schedule.display ?? ""
if !expr.isEmpty, let phrase = translate(cronExpression: expr) {
return phrase
}
// Non-cron kinds (runAt, interval) get their own branches.
switch schedule.kind.lowercased() {
case "runat", "run_at":
if let runAt = schedule.runAt, !runAt.isEmpty {
return "Once on \(runAt)"
}
return "One-off"
case "interval":
return schedule.display ?? schedule.expression ?? "Interval"
default:
break
}
// Final fallback: show whatever raw string we have.
return expr.isEmpty ? schedule.kind : expr
}
/// Relative next-run phrase (`"in 4 hours"`, `"tomorrow at 9 AM"`).
/// `nil` date `""`. Used by both Mac + ScarfGo cron rows.
public static func formatNextRun(_ date: Date?, now: Date = Date()) -> String {
guard let date else { return "" }
let style = Date.RelativeFormatStyle(
presentation: .numeric,
unitsStyle: .wide
)
return date.formatted(style)
}
/// Same as `formatNextRun(_:)` but accepts the ISO8601 string
/// Hermes stores in `jobs.json`. Attempts several parse strategies
/// because Hermes varies the exact serialization between versions
/// (with / without fractional seconds, with / without timezone
/// offset). On parse failure, falls back to the raw string so we
/// never blank out useful info.
public static func formatNextRun(iso: String?, now: Date = Date()) -> String {
guard let iso, !iso.isEmpty else { return "" }
if let date = Self.isoDate(iso) {
return formatNextRun(date, now: now)
}
return iso
}
nonisolated static func isoDate(_ iso: String) -> Date? {
let formatters: [ISO8601DateFormatter] = {
let f1 = ISO8601DateFormatter()
f1.formatOptions = [.withInternetDateTime]
let f2 = ISO8601DateFormatter()
f2.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
return [f1, f2]
}()
for f in formatters {
if let d = f.date(from: iso) { return d }
}
return nil
}
// MARK: - Implementation
/// True when the string starts with a typical cron token
/// (`<digit>`, `*`, `@`). Lets us distinguish a label like
/// "Daily release check" from a raw `0 9 * * *` in `display`.
nonisolated static func looksLikeCron(_ s: String) -> Bool {
let trimmed = s.trimmingCharacters(in: .whitespaces)
guard let first = trimmed.first else { return false }
if first == "@" { return true } // @hourly, @daily, @weekly
if first == "*" { return true } // wildcard in minute
if first.isNumber { // "0 ..." etc.
// Only consider it cron if the string has at least 4 spaces
// (= 5 fields) or starts with a single-digit followed by
// space. Short strings like "2:00pm" should stay as labels.
let spaces = trimmed.filter { $0 == " " }.count
return spaces >= 4
}
return false
}
/// Translate a raw cron expression into English. Returns nil when
/// no pattern matches caller falls back to the raw string.
nonisolated static func translate(cronExpression raw: String) -> String? {
let expr = raw.trimmingCharacters(in: .whitespaces)
// Named macros Hermes / crontab accept as synonyms.
switch expr.lowercased() {
case "@hourly": return "Every hour"
case "@daily", "@midnight": return "Daily at midnight"
case "@weekly": return "Weekly (Sunday at midnight)"
case "@monthly": return "Monthly (1st at midnight)"
case "@yearly", "@annually": return "Yearly (Jan 1 at midnight)"
default: break
}
let fields = expr.split(separator: " ", omittingEmptySubsequences: true).map(String.init)
guard fields.count == 5 else { return nil }
let (min, hr, dom, mon, dow) = (fields[0], fields[1], fields[2], fields[3], fields[4])
// Every N minutes: */N * * * *
if min.hasPrefix("*/"), hr == "*", dom == "*", mon == "*", dow == "*",
let n = Int(min.dropFirst(2))
{
return n == 1 ? "Every minute" : "Every \(n) minutes"
}
// Every hour on minute M: M * * * * (M is a single number)
if let _ = Int(min), hr == "*", dom == "*", mon == "*", dow == "*" {
return min == "0" ? "Every hour" : "Every hour at :\(zeroPad(min))"
}
// Every N hours at minute M: M */N * * *
if let _ = Int(min), hr.hasPrefix("*/"), dom == "*", mon == "*", dow == "*",
let n = Int(hr.dropFirst(2))
{
let minute = min == "0" ? "" : " at :\(zeroPad(min))"
return n == 1 ? "Every hour\(minute)" : "Every \(n) hours\(minute)"
}
// Daily at H:MM: MM H * * *
if let _ = Int(min), let h = Int(hr), dom == "*", mon == "*", dow == "*" {
return "Daily at \(formatClock(hour: h, minute: min))"
}
// Weekdays at H:MM: MM H * * 1-5
if let _ = Int(min), let h = Int(hr), dom == "*", mon == "*", dow == "1-5" {
return "Weekdays at \(formatClock(hour: h, minute: min))"
}
// Weekends at H:MM: MM H * * 0,6 or 6,0
if let _ = Int(min), let h = Int(hr), dom == "*", mon == "*",
(dow == "0,6" || dow == "6,0" || dow == "6,7")
{
return "Weekends at \(formatClock(hour: h, minute: min))"
}
// Single weekday at H:MM: MM H * * <D>
if let _ = Int(min), let h = Int(hr), dom == "*", mon == "*",
let d = Int(dow), (0...7).contains(d)
{
return "Every \(weekdayName(d)) at \(formatClock(hour: h, minute: min))"
}
// Monthly on day D at H:MM: MM H D * *
if let _ = Int(min), let h = Int(hr), let d = Int(dom), mon == "*", dow == "*" {
return "Monthly on day \(d) at \(formatClock(hour: h, minute: min))"
}
return nil
}
private static func zeroPad(_ s: String) -> String {
s.count == 1 ? "0" + s : s
}
/// Return "H:MM AM/PM" 12-hour with no leading zero on the hour,
/// to match how iOS natively displays times in most list contexts.
private static func formatClock(hour h: Int, minute mStr: String) -> String {
let m = Int(mStr) ?? 0
var h12 = h % 12
if h12 == 0 { h12 = 12 }
let suffix = (h < 12) ? "AM" : "PM"
if m == 0 {
return "\(h12) \(suffix)"
}
let mm = m < 10 ? "0\(m)" : "\(m)"
return "\(h12):\(mm) \(suffix)"
}
private static func weekdayName(_ d: Int) -> String {
// Cron convention: 0 and 7 are both Sunday; 1..6 are Mon..Sat.
let names = ["Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday", "Sunday"]
return names[max(0, min(7, d))]
}
}
@@ -0,0 +1,898 @@
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.
public struct AuxiliaryModel: Sendable, Equatable {
public var provider: String
public var model: String
public var baseURL: String
public var apiKey: String
public var timeout: Int
public init(
provider: String,
model: String,
baseURL: String,
apiKey: String,
timeout: Int
) {
self.provider = provider
self.model = model
self.baseURL = baseURL
self.apiKey = apiKey
self.timeout = timeout
}
public nonisolated static let empty = AuxiliaryModel(provider: "auto", model: "", baseURL: "", apiKey: "", timeout: 30)
}
/// Group of display-related settings mirroring the `display:` block in config.yaml.
public struct DisplaySettings: Sendable, Equatable {
public var skin: String
public var compact: Bool
public var resumeDisplay: String // "full" | "minimal"
public var bellOnComplete: Bool
public var inlineDiffs: Bool
public var toolProgressCommand: Bool
public var toolPreviewLength: Int
public var busyInputMode: String // e.g. "interrupt"
public init(
skin: String,
compact: Bool,
resumeDisplay: String,
bellOnComplete: Bool,
inlineDiffs: Bool,
toolProgressCommand: Bool,
toolPreviewLength: Int,
busyInputMode: String
) {
self.skin = skin
self.compact = compact
self.resumeDisplay = resumeDisplay
self.bellOnComplete = bellOnComplete
self.inlineDiffs = inlineDiffs
self.toolProgressCommand = toolProgressCommand
self.toolPreviewLength = toolPreviewLength
self.busyInputMode = busyInputMode
}
public 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.
public struct TerminalSettings: Sendable, Equatable {
public var cwd: String
public var timeout: Int
public var envPassthrough: [String]
public var persistentShell: Bool
public var dockerImage: String
public var dockerMountCwdToWorkspace: Bool
public var dockerForwardEnv: [String]
public var dockerVolumes: [String]
public var containerCPU: Int // 0 = unlimited
public var containerMemory: Int // MB, 0 = unlimited
public var containerDisk: Int // MB, 0 = unlimited
public var containerPersistent: Bool
public var modalImage: String
public var modalMode: String // "auto" | other
public var daytonaImage: String
public var singularityImage: String
public init(
cwd: String,
timeout: Int,
envPassthrough: [String],
persistentShell: Bool,
dockerImage: String,
dockerMountCwdToWorkspace: Bool,
dockerForwardEnv: [String],
dockerVolumes: [String],
containerCPU: Int,
containerMemory: Int,
containerDisk: Int,
containerPersistent: Bool,
modalImage: String,
modalMode: String,
daytonaImage: String,
singularityImage: String
) {
self.cwd = cwd
self.timeout = timeout
self.envPassthrough = envPassthrough
self.persistentShell = persistentShell
self.dockerImage = dockerImage
self.dockerMountCwdToWorkspace = dockerMountCwdToWorkspace
self.dockerForwardEnv = dockerForwardEnv
self.dockerVolumes = dockerVolumes
self.containerCPU = containerCPU
self.containerMemory = containerMemory
self.containerDisk = containerDisk
self.containerPersistent = containerPersistent
self.modalImage = modalImage
self.modalMode = modalMode
self.daytonaImage = daytonaImage
self.singularityImage = singularityImage
}
public 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.*`).
public struct BrowserSettings: Sendable, Equatable {
public var inactivityTimeout: Int
public var commandTimeout: Int
public var recordSessions: Bool
public var allowPrivateURLs: Bool
public var camofoxManagedPersistence: Bool
public init(
inactivityTimeout: Int,
commandTimeout: Int,
recordSessions: Bool,
allowPrivateURLs: Bool,
camofoxManagedPersistence: Bool
) {
self.inactivityTimeout = inactivityTimeout
self.commandTimeout = commandTimeout
self.recordSessions = recordSessions
self.allowPrivateURLs = allowPrivateURLs
self.camofoxManagedPersistence = camofoxManagedPersistence
}
public nonisolated static let empty = BrowserSettings(
inactivityTimeout: 120,
commandTimeout: 30,
recordSessions: false,
allowPrivateURLs: false,
camofoxManagedPersistence: false
)
}
/// Voice push-to-talk plus TTS/STT provider settings.
public struct VoiceSettings: Sendable, Equatable {
public var recordKey: String
public var maxRecordingSeconds: Int
public var silenceDuration: Double
// TTS
public var ttsProvider: String
public var ttsEdgeVoice: String
public var ttsElevenLabsVoiceID: String
public var ttsElevenLabsModelID: String
public var ttsOpenAIModel: String
public var ttsOpenAIVoice: String
public var ttsNeuTTSModel: String
public var ttsNeuTTSDevice: String
// STT
public var sttEnabled: Bool
public var sttProvider: String
public var sttLocalModel: String
public var sttLocalLanguage: String
public var sttOpenAIModel: String
public var sttMistralModel: String
public init(
recordKey: String,
maxRecordingSeconds: Int,
silenceDuration: Double,
ttsProvider: String,
ttsEdgeVoice: String,
ttsElevenLabsVoiceID: String,
ttsElevenLabsModelID: String,
ttsOpenAIModel: String,
ttsOpenAIVoice: String,
ttsNeuTTSModel: String,
ttsNeuTTSDevice: String,
sttEnabled: Bool,
sttProvider: String,
sttLocalModel: String,
sttLocalLanguage: String,
sttOpenAIModel: String,
sttMistralModel: String
) {
self.recordKey = recordKey
self.maxRecordingSeconds = maxRecordingSeconds
self.silenceDuration = silenceDuration
self.ttsProvider = ttsProvider
self.ttsEdgeVoice = ttsEdgeVoice
self.ttsElevenLabsVoiceID = ttsElevenLabsVoiceID
self.ttsElevenLabsModelID = ttsElevenLabsModelID
self.ttsOpenAIModel = ttsOpenAIModel
self.ttsOpenAIVoice = ttsOpenAIVoice
self.ttsNeuTTSModel = ttsNeuTTSModel
self.ttsNeuTTSDevice = ttsNeuTTSDevice
self.sttEnabled = sttEnabled
self.sttProvider = sttProvider
self.sttLocalModel = sttLocalModel
self.sttLocalLanguage = sttLocalLanguage
self.sttOpenAIModel = sttOpenAIModel
self.sttMistralModel = sttMistralModel
}
public 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.
public struct AuxiliarySettings: Sendable, Equatable {
public var vision: AuxiliaryModel
public var webExtract: AuxiliaryModel
public var compression: AuxiliaryModel
public var sessionSearch: AuxiliaryModel
public var skillsHub: AuxiliaryModel
public var approval: AuxiliaryModel
public var mcp: AuxiliaryModel
public var flushMemories: AuxiliaryModel
public init(
vision: AuxiliaryModel,
webExtract: AuxiliaryModel,
compression: AuxiliaryModel,
sessionSearch: AuxiliaryModel,
skillsHub: AuxiliaryModel,
approval: AuxiliaryModel,
mcp: AuxiliaryModel,
flushMemories: AuxiliaryModel
) {
self.vision = vision
self.webExtract = webExtract
self.compression = compression
self.sessionSearch = sessionSearch
self.skillsHub = skillsHub
self.approval = approval
self.mcp = mcp
self.flushMemories = flushMemories
}
public 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.
public struct SecuritySettings: Sendable, Equatable {
public var redactSecrets: Bool
public var redactPII: Bool // from privacy.redact_pii
public var tirithEnabled: Bool
public var tirithPath: String
public var tirithTimeout: Int
public var tirithFailOpen: Bool
public var blocklistEnabled: Bool
public var blocklistDomains: [String]
public init(
redactSecrets: Bool,
redactPII: Bool,
tirithEnabled: Bool,
tirithPath: String,
tirithTimeout: Int,
tirithFailOpen: Bool,
blocklistEnabled: Bool,
blocklistDomains: [String]
) {
self.redactSecrets = redactSecrets
self.redactPII = redactPII
self.tirithEnabled = tirithEnabled
self.tirithPath = tirithPath
self.tirithTimeout = tirithTimeout
self.tirithFailOpen = tirithFailOpen
self.blocklistEnabled = blocklistEnabled
self.blocklistDomains = blocklistDomains
}
public 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.*`).
public struct HumanDelaySettings: Sendable, Equatable {
public var mode: String // "off" | "natural" | "custom"
public var minMS: Int
public var maxMS: Int
public init(
mode: String,
minMS: Int,
maxMS: Int
) {
self.mode = mode
self.minMS = minMS
self.maxMS = maxMS
}
public nonisolated static let empty = HumanDelaySettings(mode: "off", minMS: 800, maxMS: 2500)
}
/// Compression / context routing.
public struct CompressionSettings: Sendable, Equatable {
public var enabled: Bool
public var threshold: Double
public var targetRatio: Double
public var protectLastN: Int
public init(
enabled: Bool,
threshold: Double,
targetRatio: Double,
protectLastN: Int
) {
self.enabled = enabled
self.threshold = threshold
self.targetRatio = targetRatio
self.protectLastN = protectLastN
}
public nonisolated static let empty = CompressionSettings(enabled: true, threshold: 0.5, targetRatio: 0.2, protectLastN: 20)
}
public struct CheckpointSettings: Sendable, Equatable {
public var enabled: Bool
public var maxSnapshots: Int
public init(
enabled: Bool,
maxSnapshots: Int
) {
self.enabled = enabled
self.maxSnapshots = maxSnapshots
}
public nonisolated static let empty = CheckpointSettings(enabled: true, maxSnapshots: 50)
}
public struct LoggingSettings: Sendable, Equatable {
public var level: String // DEBUG | INFO | WARNING | ERROR
public var maxSizeMB: Int
public var backupCount: Int
public init(
level: String,
maxSizeMB: Int,
backupCount: Int
) {
self.level = level
self.maxSizeMB = maxSizeMB
self.backupCount = backupCount
}
public nonisolated static let empty = LoggingSettings(level: "INFO", maxSizeMB: 5, backupCount: 3)
}
public struct DelegationSettings: Sendable, Equatable {
public var model: String
public var provider: String
public var baseURL: String
public var apiKey: String
public var maxIterations: Int
public init(
model: String,
provider: String,
baseURL: String,
apiKey: String,
maxIterations: Int
) {
self.model = model
self.provider = provider
self.baseURL = baseURL
self.apiKey = apiKey
self.maxIterations = maxIterations
}
public nonisolated static let empty = DelegationSettings(model: "", provider: "", baseURL: "", apiKey: "", maxIterations: 50)
}
/// Discord-specific platform settings (`discord.*`). Other platforms currently have thinner schemas.
public struct DiscordSettings: Sendable, Equatable {
public var requireMention: Bool
public var freeResponseChannels: String
public var autoThread: Bool
public var reactions: Bool
public init(
requireMention: Bool,
freeResponseChannels: String,
autoThread: Bool,
reactions: Bool
) {
self.requireMention = requireMention
self.freeResponseChannels = freeResponseChannels
self.autoThread = autoThread
self.reactions = reactions
}
public 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.
public struct TelegramSettings: Sendable, Equatable {
public var requireMention: Bool
public var reactions: Bool
public init(
requireMention: Bool,
reactions: Bool
) {
self.requireMention = requireMention
self.reactions = reactions
}
public nonisolated static let empty = TelegramSettings(requireMention: true, reactions: false)
}
/// Slack settings under `platforms.slack.*` (and a couple of top-level keys).
public struct SlackSettings: Sendable, Equatable {
public var replyToMode: String // "off" | "first" | "all"
public var requireMention: Bool
public var replyInThread: Bool
public var replyBroadcast: Bool
public init(
replyToMode: String,
requireMention: Bool,
replyInThread: Bool,
replyBroadcast: Bool
) {
self.replyToMode = replyToMode
self.requireMention = requireMention
self.replyInThread = replyInThread
self.replyBroadcast = replyBroadcast
}
public nonisolated static let empty = SlackSettings(replyToMode: "first", requireMention: true, replyInThread: true, replyBroadcast: false)
}
/// Matrix settings under `matrix.*`.
public struct MatrixSettings: Sendable, Equatable {
public var requireMention: Bool
public var autoThread: Bool
public var dmMentionThreads: Bool
public init(
requireMention: Bool,
autoThread: Bool,
dmMentionThreads: Bool
) {
self.requireMention = requireMention
self.autoThread = autoThread
self.dmMentionThreads = dmMentionThreads
}
public 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.
public struct MattermostSettings: Sendable, Equatable {
public var requireMention: Bool
public var replyMode: String // "thread" | "off"
public init(
requireMention: Bool,
replyMode: String
) {
self.requireMention = requireMention
self.replyMode = replyMode
}
public nonisolated static let empty = MattermostSettings(requireMention: true, replyMode: "off")
}
/// WhatsApp settings under `whatsapp.*`.
public struct WhatsAppSettings: Sendable, Equatable {
public var unauthorizedDMBehavior: String // "pair" | "ignore"
public var replyPrefix: String
public init(
unauthorizedDMBehavior: String,
replyPrefix: String
) {
self.unauthorizedDMBehavior = unauthorizedDMBehavior
self.replyPrefix = replyPrefix
}
public 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.
public struct HomeAssistantSettings: Sendable, Equatable {
public var watchDomains: [String]
public var watchEntities: [String]
public var watchAll: Bool
public var ignoreEntities: [String]
public var cooldownSeconds: Int
public init(
watchDomains: [String],
watchEntities: [String],
watchAll: Bool,
ignoreEntities: [String],
cooldownSeconds: Int
) {
self.watchDomains = watchDomains
self.watchEntities = watchEntities
self.watchAll = watchAll
self.ignoreEntities = ignoreEntities
self.cooldownSeconds = cooldownSeconds
}
public nonisolated static let empty = HomeAssistantSettings(watchDomains: [], watchEntities: [], watchAll: false, ignoreEntities: [], cooldownSeconds: 30)
}
// MARK: - Root Config
public struct HermesConfig: Sendable {
// Original fields preserved for zero breakage with existing call sites.
public var model: String
public var provider: String
public var maxTurns: Int
public var personality: String
public var terminalBackend: String
public var memoryEnabled: Bool
public var memoryCharLimit: Int
public var userCharLimit: Int
public var nudgeInterval: Int
public var streaming: Bool
public var showReasoning: Bool
public var verbose: Bool
public var autoTTS: Bool
public var silenceThreshold: Int
public var reasoningEffort: String
public var showCost: Bool
public var approvalMode: String
public var browserBackend: String
public var memoryProvider: String
public var dockerEnv: [String: String]
public var commandAllowlist: [String]
public var memoryProfile: String
public var serviceTier: String
public var gatewayNotifyInterval: Int
public var forceIPv4: Bool
public var contextEngine: String
public var interimAssistantMessages: Bool
public var honchoInitOnSessionStart: Bool
// Phase 1 additions
public var timezone: String
public var userProfileEnabled: Bool
public var toolUseEnforcement: String // "auto" | "true" | "false" | comma list
public var gatewayTimeout: Int
public var approvalTimeout: Int
public var fileReadMaxChars: Int
public var cronWrapResponse: Bool
public var prefillMessagesFile: String
public var skillsExternalDirs: [String]
/// Per-platform toolset allowlists as written by `hermes setup tools`.
/// Keyed by platform (`cli`, `slack`, ) to enabled toolset identifiers
/// (`browser`, `messaging`, `nous-tools`, ). Hermes v0.10.0's Tool
/// Gateway; enabling `nous-tools` here is how subscribers opt-in per
/// platform. Scarf reads for display; edits go through Hermes CLI.
public var platformToolsets: [String: [String]]
// Grouped blocks
public var display: DisplaySettings
public var terminal: TerminalSettings
public var browser: BrowserSettings
public var voice: VoiceSettings
public var auxiliary: AuxiliarySettings
public var security: SecuritySettings
public var humanDelay: HumanDelaySettings
public var compression: CompressionSettings
public var checkpoints: CheckpointSettings
public var logging: LoggingSettings
public var delegation: DelegationSettings
public var discord: DiscordSettings
public var telegram: TelegramSettings
public var slack: SlackSettings
public var matrix: MatrixSettings
public var mattermost: MattermostSettings
public var whatsapp: WhatsAppSettings
public var homeAssistant: HomeAssistantSettings
public init(
model: String,
provider: String,
maxTurns: Int,
personality: String,
terminalBackend: String,
memoryEnabled: Bool,
memoryCharLimit: Int,
userCharLimit: Int,
nudgeInterval: Int,
streaming: Bool,
showReasoning: Bool,
verbose: Bool,
autoTTS: Bool,
silenceThreshold: Int,
reasoningEffort: String,
showCost: Bool,
approvalMode: String,
browserBackend: String,
memoryProvider: String,
dockerEnv: [String: String],
commandAllowlist: [String],
memoryProfile: String,
serviceTier: String,
gatewayNotifyInterval: Int,
forceIPv4: Bool,
contextEngine: String,
interimAssistantMessages: Bool,
honchoInitOnSessionStart: Bool,
timezone: String,
userProfileEnabled: Bool,
toolUseEnforcement: String,
gatewayTimeout: Int,
approvalTimeout: Int,
fileReadMaxChars: Int,
cronWrapResponse: Bool,
prefillMessagesFile: String,
skillsExternalDirs: [String],
platformToolsets: [String: [String]],
display: DisplaySettings,
terminal: TerminalSettings,
browser: BrowserSettings,
voice: VoiceSettings,
auxiliary: AuxiliarySettings,
security: SecuritySettings,
humanDelay: HumanDelaySettings,
compression: CompressionSettings,
checkpoints: CheckpointSettings,
logging: LoggingSettings,
delegation: DelegationSettings,
discord: DiscordSettings,
telegram: TelegramSettings,
slack: SlackSettings,
matrix: MatrixSettings,
mattermost: MattermostSettings,
whatsapp: WhatsAppSettings,
homeAssistant: HomeAssistantSettings
) {
self.model = model
self.provider = provider
self.maxTurns = maxTurns
self.personality = personality
self.terminalBackend = terminalBackend
self.memoryEnabled = memoryEnabled
self.memoryCharLimit = memoryCharLimit
self.userCharLimit = userCharLimit
self.nudgeInterval = nudgeInterval
self.streaming = streaming
self.showReasoning = showReasoning
self.verbose = verbose
self.autoTTS = autoTTS
self.silenceThreshold = silenceThreshold
self.reasoningEffort = reasoningEffort
self.showCost = showCost
self.approvalMode = approvalMode
self.browserBackend = browserBackend
self.memoryProvider = memoryProvider
self.dockerEnv = dockerEnv
self.commandAllowlist = commandAllowlist
self.memoryProfile = memoryProfile
self.serviceTier = serviceTier
self.gatewayNotifyInterval = gatewayNotifyInterval
self.forceIPv4 = forceIPv4
self.contextEngine = contextEngine
self.interimAssistantMessages = interimAssistantMessages
self.honchoInitOnSessionStart = honchoInitOnSessionStart
self.timezone = timezone
self.userProfileEnabled = userProfileEnabled
self.toolUseEnforcement = toolUseEnforcement
self.gatewayTimeout = gatewayTimeout
self.approvalTimeout = approvalTimeout
self.fileReadMaxChars = fileReadMaxChars
self.cronWrapResponse = cronWrapResponse
self.prefillMessagesFile = prefillMessagesFile
self.skillsExternalDirs = skillsExternalDirs
self.platformToolsets = platformToolsets
self.display = display
self.terminal = terminal
self.browser = browser
self.voice = voice
self.auxiliary = auxiliary
self.security = security
self.humanDelay = humanDelay
self.compression = compression
self.checkpoints = checkpoints
self.logging = logging
self.delegation = delegation
self.discord = discord
self.telegram = telegram
self.slack = slack
self.matrix = matrix
self.mattermost = mattermost
self.whatsapp = whatsapp
self.homeAssistant = homeAssistant
}
public nonisolated static let empty = HermesConfig(
model: "unknown",
provider: "unknown",
maxTurns: 0,
personality: "default",
terminalBackend: "local",
memoryEnabled: false,
memoryCharLimit: 0,
userCharLimit: 0,
nudgeInterval: 0,
streaming: true,
showReasoning: false,
verbose: false,
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).
public struct GatewayState: Sendable, Codable {
public nonisolated let pid: Int?
public nonisolated let kind: String?
public nonisolated let gatewayState: String?
public nonisolated let exitReason: String?
public nonisolated let platforms: [String: PlatformState]?
public nonisolated let updatedAt: String?
public enum CodingKeys: String, CodingKey {
case pid, kind
case gatewayState = "gateway_state"
case exitReason = "exit_reason"
case platforms
case updatedAt = "updated_at"
}
public 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)
}
public 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)
}
public nonisolated var isRunning: Bool {
gatewayState == "running"
}
public nonisolated var statusText: String {
gatewayState ?? "unknown"
}
}
public struct PlatformState: Sendable, Codable {
public nonisolated let connected: Bool?
public nonisolated let error: String?
public enum CodingKeys: String, CodingKey { case connected, error }
public 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)
}
public 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)
}
}
@@ -0,0 +1,57 @@
import Foundation
#if canImport(SQLite3)
import SQLite3
#endif
// MARK: - SQLite Constants
#if canImport(SQLite3)
/// 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.
///
/// Gated behind `canImport(SQLite3)` so this file compiles on Linux (where
/// SPM has no built-in `SQLite3` system module). Apple platforms the only
/// runtime targets that actually execute this code compile it unchanged.
public nonisolated let sqliteTransient = unsafeBitCast(-1, to: sqlite3_destructor_type.self)
#endif
// MARK: - Query Defaults
public enum QueryDefaults: Sendable {
public nonisolated static let sessionLimit = 100
public nonisolated static let messageSearchLimit = 50
public nonisolated static let toolCallLimit = 50
public nonisolated static let sessionPreviewLimit = 10
public nonisolated static let previewContentLength = 100
public nonisolated static let logLineLimit = 200
public nonisolated static let defaultSilenceThreshold = 200
}
/// Page sizes for `HermesDataService.fetchMessages(sessionId:limit:before:)`.
/// Centralized so iOS, Mac, and the polling code paths can pick a
/// consistent budget and so we have one knob to retune if perf
/// concerns shift.
public enum HistoryPageSize: Sendable {
/// Initial chat-history load: covers the vast majority of
/// sessions in one fetch while keeping the snapshot read bounded
/// for the rare 1000+-message session.
public nonisolated static let initial = 200
/// Reconnection reconcile against the DB. 200 rows is plenty
/// disconnects don't generate hundreds of unseen messages.
public nonisolated static let reconcile = 200
/// Mac sessions detail view. Larger to reduce paging UX in the
/// desktop browser-style read; the desktop has the screen real
/// estate and memory headroom for it.
public nonisolated static let macSessionDetail = 500
/// Terminal-mode polling refresh. Same 500-row budget as Mac
/// detail; covers sessions long enough that the user is actively
/// scrolling but bounded to keep each poll tick cheap.
public nonisolated static let polling = 500
}
// MARK: - File Size Formatting
public enum FileSizeUnit: Sendable {
public nonisolated static let kilobyte = 1_024.0
public nonisolated static let megabyte = 1_048_576.0
}
@@ -1,26 +1,26 @@
import Foundation
struct HermesCronJob: Identifiable, Sendable, Codable {
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?
public struct HermesCronJob: Identifiable, Sendable, Codable {
public nonisolated let id: String
public nonisolated let name: String
public nonisolated let prompt: String
public nonisolated let skills: [String]?
public nonisolated let model: String?
public nonisolated let schedule: CronSchedule
public nonisolated let enabled: Bool
public nonisolated let state: String
public nonisolated let deliver: String?
public nonisolated let nextRunAt: String?
public nonisolated let lastRunAt: String?
public nonisolated let lastError: String?
public nonisolated let preRunScript: String?
public nonisolated let deliveryFailures: Int?
public nonisolated let lastDeliveryError: String?
public nonisolated let timeoutType: String?
public nonisolated let timeoutSeconds: Int?
public nonisolated let silent: Bool?
enum CodingKeys: String, CodingKey {
public enum CodingKeys: String, CodingKey {
case id, name, prompt, skills, model, schedule, enabled, state, deliver, silent
case nextRunAt = "next_run_at"
case lastRunAt = "last_run_at"
@@ -32,7 +32,50 @@ struct HermesCronJob: Identifiable, Sendable, Codable {
case timeoutSeconds = "timeout_seconds"
}
nonisolated init(from decoder: any Decoder) throws {
/// Memberwise init. Swift doesn't synthesize one for us because
/// of the hand-written Codable conformance. The iOS Cron editor
/// uses this to rebuild jobs from user-edited fields.
public nonisolated init(
id: String,
name: String,
prompt: String,
skills: [String]? = nil,
model: String? = nil,
schedule: CronSchedule,
enabled: Bool,
state: String,
deliver: String? = nil,
nextRunAt: String? = nil,
lastRunAt: String? = nil,
lastError: String? = nil,
preRunScript: String? = nil,
deliveryFailures: Int? = nil,
lastDeliveryError: String? = nil,
timeoutType: String? = nil,
timeoutSeconds: Int? = nil,
silent: Bool? = nil
) {
self.id = id
self.name = name
self.prompt = prompt
self.skills = skills
self.model = model
self.schedule = schedule
self.enabled = enabled
self.state = state
self.deliver = deliver
self.nextRunAt = nextRunAt
self.lastRunAt = lastRunAt
self.lastError = lastError
self.preRunScript = preRunScript
self.deliveryFailures = deliveryFailures
self.lastDeliveryError = lastDeliveryError
self.timeoutType = timeoutType
self.timeoutSeconds = timeoutSeconds
self.silent = silent
}
public 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)
@@ -54,7 +97,7 @@ struct HermesCronJob: Identifiable, Sendable, Codable {
self.silent = try c.decodeIfPresent(Bool.self, forKey: .silent)
}
nonisolated func encode(to encoder: any Encoder) throws {
public 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)
@@ -76,7 +119,7 @@ struct HermesCronJob: Identifiable, Sendable, Codable {
try c.encodeIfPresent(silent, forKey: .silent)
}
nonisolated var stateIcon: String {
public nonisolated var stateIcon: String {
switch state {
case "scheduled": return "clock"
case "running": return "play.circle"
@@ -86,7 +129,7 @@ struct HermesCronJob: Identifiable, Sendable, Codable {
}
}
nonisolated var deliveryDisplay: String? {
public 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:") {
@@ -102,20 +145,32 @@ struct HermesCronJob: Identifiable, Sendable, Codable {
}
}
struct CronSchedule: Sendable, Codable {
nonisolated let kind: String
nonisolated let runAt: String?
nonisolated let display: String?
nonisolated let expression: String?
public struct CronSchedule: Sendable, Codable {
public nonisolated let kind: String
public nonisolated let runAt: String?
public nonisolated let display: String?
public nonisolated let expression: String?
enum CodingKeys: String, CodingKey {
public enum CodingKeys: String, CodingKey {
case kind
case runAt = "run_at"
case display
case expression
}
nonisolated init(from decoder: any Decoder) throws {
public nonisolated init(
kind: String,
runAt: String? = nil,
display: String? = nil,
expression: String? = nil
) {
self.kind = kind
self.runAt = runAt
self.display = display
self.expression = expression
}
public 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)
@@ -123,7 +178,7 @@ struct CronSchedule: Sendable, Codable {
self.expression = try c.decodeIfPresent(String.self, forKey: .expression)
}
nonisolated func encode(to encoder: any Encoder) throws {
public 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)
@@ -135,22 +190,27 @@ struct CronSchedule: Sendable, Codable {
// 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 {
nonisolated let jobs: [HermesCronJob]
nonisolated let updatedAt: String?
public struct CronJobsFile: Sendable, Codable {
public nonisolated let jobs: [HermesCronJob]
public nonisolated let updatedAt: String?
enum CodingKeys: String, CodingKey {
public enum CodingKeys: String, CodingKey {
case jobs
case updatedAt = "updated_at"
}
nonisolated init(from decoder: any Decoder) throws {
public nonisolated init(jobs: [HermesCronJob], updatedAt: String?) {
self.jobs = jobs
self.updatedAt = updatedAt
}
public 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 {
public 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,106 @@
import Foundation
public enum MCPTransport: String, Sendable, Equatable, CaseIterable, Identifiable {
case stdio
case http
public var id: String { rawValue }
#if canImport(Darwin)
public var displayName: LocalizedStringResource {
switch self {
case .stdio: return "Local (stdio)"
case .http: return "Remote (HTTP)"
}
}
#endif
}
public struct HermesMCPServer: Identifiable, Sendable, Equatable {
public let name: String
public let transport: MCPTransport
public let command: String?
public let args: [String]
public let url: String?
public let auth: String?
public let env: [String: String]
public let headers: [String: String]
public let timeout: Int?
public let connectTimeout: Int?
public let enabled: Bool
public let toolsInclude: [String]
public let toolsExclude: [String]
public let resourcesEnabled: Bool
public let promptsEnabled: Bool
public let hasOAuthToken: Bool
public init(
name: String,
transport: MCPTransport,
command: String?,
args: [String],
url: String?,
auth: String?,
env: [String: String],
headers: [String: String],
timeout: Int?,
connectTimeout: Int?,
enabled: Bool,
toolsInclude: [String],
toolsExclude: [String],
resourcesEnabled: Bool,
promptsEnabled: Bool,
hasOAuthToken: Bool
) {
self.name = name
self.transport = transport
self.command = command
self.args = args
self.url = url
self.auth = auth
self.env = env
self.headers = headers
self.timeout = timeout
self.connectTimeout = connectTimeout
self.enabled = enabled
self.toolsInclude = toolsInclude
self.toolsExclude = toolsExclude
self.resourcesEnabled = resourcesEnabled
self.promptsEnabled = promptsEnabled
self.hasOAuthToken = hasOAuthToken
}
public var id: String { name }
public var summary: String {
switch transport {
case .stdio:
let argString = args.isEmpty ? "" : " " + args.joined(separator: " ")
return (command ?? "") + argString
case .http:
return url ?? ""
}
}
}
public struct MCPTestResult: Sendable, Equatable {
public let serverName: String
public let succeeded: Bool
public let output: String
public let tools: [String]
public let elapsed: TimeInterval
public init(
serverName: String,
succeeded: Bool,
output: String,
tools: [String],
elapsed: TimeInterval
) {
self.serverName = serverName
self.succeeded = succeeded
self.output = output
self.tools = tools
self.elapsed = elapsed
}
}
@@ -0,0 +1,212 @@
import Foundation
public struct HermesMessage: Identifiable, Sendable {
public let id: Int
public let sessionId: String
public let role: String
public let content: String
public let toolCallId: String?
public let toolCalls: [HermesToolCall]
public let toolName: String?
public let timestamp: Date?
public let tokenCount: Int?
public let finishReason: String?
public let reasoning: String?
/// Hermes v2026.4.23+ richer reasoning column. Some providers
/// emit a structured "thinking" payload separate from the
/// classic `reasoning` blob; both can be present on the same
/// message during the v0.10 v0.11 transition. UI prefers
/// `reasoningContent` when set, falls back to `reasoning`.
public let reasoningContent: String?
public init(
id: Int,
sessionId: String,
role: String,
content: String,
toolCallId: String?,
toolCalls: [HermesToolCall],
toolName: String?,
timestamp: Date?,
tokenCount: Int?,
finishReason: String?,
reasoning: String?,
reasoningContent: String? = nil
) {
self.id = id
self.sessionId = sessionId
self.role = role
self.content = content
self.toolCallId = toolCallId
self.toolCalls = toolCalls
self.toolName = toolName
self.timestamp = timestamp
self.tokenCount = tokenCount
self.finishReason = finishReason
self.reasoning = reasoning
self.reasoningContent = reasoningContent
}
public var isUser: Bool { role == "user" }
public var isAssistant: Bool { role == "assistant" }
public var isToolResult: Bool { role == "tool" }
/// True when ANY reasoning channel has content. UI uses this to
/// decide whether to render the "Thinking" disclosure.
public var hasReasoning: Bool {
let r = reasoning ?? ""
let rc = reasoningContent ?? ""
return !r.isEmpty || !rc.isEmpty
}
/// Preferred reasoning text for rendering `reasoningContent`
/// (newer, richer) wins over the legacy `reasoning` blob when
/// both are present.
public var preferredReasoning: String? {
if let rc = reasoningContent, !rc.isEmpty { return rc }
return reasoning
}
}
public struct HermesToolCall: Identifiable, Sendable, Codable {
public var id: String { callId }
public let callId: String
public let functionName: String
public let arguments: String
/// Wall-clock duration of the tool call. Set on ACP `toolCallComplete`
/// (or equivalent) by `RichChatViewModel`. Nil for sessions loaded
/// from `state.db` (no live timing) and for in-flight calls.
public var duration: TimeInterval?
/// Process exit code, when the tool kind is `.execute` and the
/// tool-result message exposes one. Best-effort parse of the result
/// content; nil when not applicable / not parseable.
public var exitCode: Int?
/// Wall-clock timestamp the call was emitted by Hermes. Set on ACP
/// `toolCallStart`. Nil for sessions loaded from `state.db`.
public var startedAt: Date?
public enum CodingKeys: String, CodingKey {
case callId = "id"
case type
case function
}
public enum FunctionKeys: String, CodingKey {
case name
case arguments
}
public init(
callId: String,
functionName: String,
arguments: String,
duration: TimeInterval? = nil,
exitCode: Int? = nil,
startedAt: Date? = nil
) {
self.callId = callId
self.functionName = functionName
self.arguments = arguments
self.duration = duration
self.exitCode = exitCode
self.startedAt = startedAt
}
public init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
callId = try container.decode(String.self, forKey: .callId)
let funcContainer = try container.nestedContainer(keyedBy: FunctionKeys.self, forKey: .function)
functionName = try funcContainer.decode(String.self, forKey: .name)
arguments = try funcContainer.decode(String.self, forKey: .arguments)
// Telemetry fields are populated locally from ACP events, never
// persisted via Codable, so they decode as nil.
duration = nil
exitCode = nil
startedAt = nil
}
public func encode(to encoder: Encoder) throws {
var container = encoder.container(keyedBy: CodingKeys.self)
try container.encode(callId, forKey: .callId)
try container.encode("function", forKey: .type)
var funcContainer = container.nestedContainer(keyedBy: FunctionKeys.self, forKey: .function)
try funcContainer.encode(functionName, forKey: .name)
try funcContainer.encode(arguments, forKey: .arguments)
}
public var toolKind: ToolKind {
switch functionName {
case "read_file", "search_files", "vision_analyze": return .read
case "write_file", "patch": return .edit
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
}
}
public var argumentsSummary: String {
guard let data = arguments.data(using: .utf8),
let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else {
return arguments
}
if let command = json["command"] as? String {
return command
}
if let path = json["path"] as? String {
return path
}
if let query = json["query"] as? String {
return query
}
if let url = json["url"] as? String {
return url
}
return arguments.prefix(120) + (arguments.count > 120 ? "..." : "")
}
}
public enum ToolKind: String, Sendable, CaseIterable {
case read
case edit
case execute
case fetch
case browser
case other
#if canImport(Darwin)
public 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"
}
}
#endif
public var icon: String {
switch self {
case .read: return "doc.text.magnifyingglass"
case .edit: return "pencil"
case .execute: return "terminal"
case .fetch: return "globe"
case .browser: return "safari"
case .other: return "gearshape"
}
}
public var color: String {
switch self {
case .read: return "green"
case .edit: return "blue"
case .execute: return "orange"
case .fetch: return "purple"
case .browser: return "indigo"
case .other: return "gray"
}
}
}
@@ -0,0 +1,124 @@
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.
public struct HermesPathSet: Sendable, Hashable {
public 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.
public 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`.
public let binaryHint: String?
// MARK: - Defaults
/// Absolute path to the local user's `~/.hermes` directory.
public init(
home: String,
isRemote: Bool,
binaryHint: String?
) {
self.home = home
self.isRemote = isRemote
self.binaryHint = binaryHint
}
/// Resolved path to the active local Hermes profile (issue #50).
///
/// Hermes v0.11+ supports multiple profiles via `hermes profile use`;
/// each profile is a fully independent `HERMES_HOME` directory. We
/// delegate to `HermesProfileResolver` (which reads
/// `~/.hermes/active_profile`) so every derived path `state.db`,
/// `sessions/`, `config.yaml`, `memories/`, etc. automatically
/// follows the active profile. Returns the pre-profile default
/// `~/.hermes` whenever no named profile is active, so existing
/// (non-profile) installations are unaffected.
///
/// Backed by a 5-second cache inside the resolver, so frequent
/// `HermesPathSet` constructions don't hammer the filesystem.
public nonisolated static var defaultLocalHome: String {
HermesProfileResolver.resolveLocalHome()
}
/// Default remote home when the user doesn't override it in `SSHConfig`.
/// We leave `~` unexpanded on purpose the remote shell resolves it.
public nonisolated static let defaultRemoteHome: String = "~/.hermes"
// MARK: - Paths (mirror of the old HermesPaths layout)
public nonisolated var stateDB: String { home + "/state.db" }
public nonisolated var configYAML: String { home + "/config.yaml" }
public nonisolated var envFile: String { home + "/.env" }
public nonisolated var authJSON: String { home + "/auth.json" }
public nonisolated var soulMD: String { home + "/SOUL.md" }
public nonisolated var pluginsDir: String { home + "/plugins" }
public nonisolated var memoriesDir: String { home + "/memories" }
public nonisolated var memoryMD: String { memoriesDir + "/MEMORY.md" }
public nonisolated var userMD: String { memoriesDir + "/USER.md" }
public nonisolated var sessionsDir: String { home + "/sessions" }
public nonisolated var cronJobsJSON: String { home + "/cron/jobs.json" }
public nonisolated var cronOutputDir: String { home + "/cron/output" }
public nonisolated var gatewayStateJSON: String { home + "/gateway_state.json" }
public nonisolated var skillsDir: String { home + "/skills" }
public nonisolated var errorsLog: String { home + "/logs/errors.log" }
public nonisolated var agentLog: String { home + "/logs/agent.log" }
public nonisolated var gatewayLog: String { home + "/logs/gateway.log" }
public nonisolated var scarfDir: String { home + "/scarf" }
public nonisolated var projectsRegistry: String { scarfDir + "/projects.json" }
/// Maps Hermes session IDs to the Scarf project path a chat was
/// started for. Scarf-owned; Hermes never touches this file.
public nonisolated var sessionProjectMap: String { scarfDir + "/session_project_map.json" }
/// Cached list of available Nous Portal models. Populated by
/// `NousModelCatalogService` from `GET https://inference-api.nousresearch.com/v1/models`
/// using the bearer token in `auth.json`. Refreshed on a 24h TTL or
/// on user request from the model picker. Survives offline runs so
/// the picker still has something to render.
public nonisolated var nousModelsCache: String { scarfDir + "/nous_models_cache.json" }
public 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.
public 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`.
public 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]
}
}
@@ -0,0 +1,111 @@
import Foundation
public struct HermesSession: Identifiable, Sendable {
public let id: String
public let source: String
public let userId: String?
public let model: String?
public let title: String?
public let parentSessionId: String?
public let startedAt: Date?
public let endedAt: Date?
public let endReason: String?
public let messageCount: Int
public let toolCallCount: Int
public let inputTokens: Int
public let outputTokens: Int
public let cacheReadTokens: Int
public let cacheWriteTokens: Int
public let estimatedCostUSD: Double?
public let reasoningTokens: Int
public let actualCostUSD: Double?
public let costStatus: String?
public let billingProvider: String?
/// Number of API calls Hermes made for this session (Hermes
/// v2026.4.23+; populated from `sessions.api_call_count`). Distinct
/// from `toolCallCount` every tool round-trip is a tool call,
/// but each agent reasoning step also costs an API call. `0` on
/// older Hermes hosts that don't have the column.
public let apiCallCount: Int
public init(
id: String,
source: String,
userId: String?,
model: String?,
title: String?,
parentSessionId: String?,
startedAt: Date?,
endedAt: Date?,
endReason: String?,
messageCount: Int,
toolCallCount: Int,
inputTokens: Int,
outputTokens: Int,
cacheReadTokens: Int,
cacheWriteTokens: Int,
estimatedCostUSD: Double?,
reasoningTokens: Int,
actualCostUSD: Double?,
costStatus: String?,
billingProvider: String?,
apiCallCount: Int = 0
) {
self.id = id
self.source = source
self.userId = userId
self.model = model
self.title = title
self.parentSessionId = parentSessionId
self.startedAt = startedAt
self.endedAt = endedAt
self.endReason = endReason
self.messageCount = messageCount
self.toolCallCount = toolCallCount
self.inputTokens = inputTokens
self.outputTokens = outputTokens
self.cacheReadTokens = cacheReadTokens
self.cacheWriteTokens = cacheWriteTokens
self.estimatedCostUSD = estimatedCostUSD
self.reasoningTokens = reasoningTokens
self.actualCostUSD = actualCostUSD
self.costStatus = costStatus
self.billingProvider = billingProvider
self.apiCallCount = apiCallCount
}
public var isSubagent: Bool { parentSessionId != nil }
public var totalTokens: Int { inputTokens + outputTokens + reasoningTokens }
public var displayCostUSD: Double? { actualCostUSD ?? estimatedCostUSD }
public var costIsActual: Bool { actualCostUSD != nil }
public var duration: TimeInterval? {
guard let start = startedAt, let end = endedAt else { return nil }
return end.timeIntervalSince(start)
}
public var displayTitle: String {
title ?? id
}
public var sourceIcon: String {
KnownPlatforms.icon(for: source)
}
public 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
)
}
}
@@ -0,0 +1,62 @@
import Foundation
public struct HermesSkillCategory: Identifiable, Sendable {
public let id: String
public let name: String
public let skills: [HermesSkill]
public init(
id: String,
name: String,
skills: [HermesSkill]
) {
self.id = id
self.name = name
self.skills = skills
}
}
public struct HermesSkill: Identifiable, Sendable {
public let id: String
public let name: String
public let category: String
public let path: String
public let files: [String]
public let requiredConfig: [String]
/// Tools the skill author declared the skill is allowed to invoke
/// (Hermes v2026.4.23 SKILL.md frontmatter `allowed_tools`).
/// `nil` when the skill ships no SKILL.md or the frontmatter
/// doesn't declare the field pre-v0.11 behaviour preserved.
public let allowedTools: [String]?
/// Skill names the author cross-references as related (`related_skills`
/// in SKILL.md frontmatter). Surfaced as chips in the skill detail
/// view so users can hop between connected skills.
public let relatedSkills: [String]?
/// External runtime dependencies the skill needs on the host
/// (`dependencies` in SKILL.md frontmatter; e.g. `npx`, `ffmpeg`,
/// Python packages). Used by `SkillPrereqService` to know what to
/// probe; nil when the field is absent.
public let dependencies: [String]?
public init(
id: String,
name: String,
category: String,
path: String,
files: [String],
requiredConfig: [String],
allowedTools: [String]? = nil,
relatedSkills: [String]? = nil,
dependencies: [String]? = nil
) {
self.id = id
self.name = name
self.category = category
self.path = path
self.files = files
self.requiredConfig = requiredConfig
self.allowedTools = allowedTools
self.relatedSkills = relatedSkills
self.dependencies = dependencies
}
}
@@ -0,0 +1,46 @@
import Foundation
/// A slash command available in chat. Sourced from one of four places
/// see `Source` for which.
public struct HermesSlashCommand: Identifiable, Sendable, Equatable {
/// Where this command came from. Drives the slash-menu badge and the
/// chat view model's invocation path (literal-send vs client-side
/// expansion vs non-interruptive flag).
public enum Source: Sendable, Equatable {
/// Advertised by the ACP server via `available_commands_update`.
/// Sent to the agent as the literal slash text.
case acp
/// User-defined `quick_commands.<name>` in `~/.hermes/config.yaml`
/// (legacy). Sent to the agent as the literal slash text.
case quickCommand
/// Project-scoped, Scarf-managed command at
/// `<project>/.scarf/slash-commands/<name>.md`. Scarf intercepts
/// the invocation, expands `{{argument}}` substitution against the
/// command's body, and sends the result as a normal user prompt
/// (the agent never sees the slash trigger). Added in v2.5.
case projectScoped
/// ACP-native commands that don't interrupt the current turn
/// `/steer` is the flagship case. The chat UI keeps the
/// "agent working" indicator on; the guidance applies after the
/// next tool call. Added in v2.5 alongside Hermes v2026.4.23.
case acpNonInterruptive
}
public var id: String { name }
public let name: String
public let description: String
public let argumentHint: String?
public let source: Source
public init(
name: String,
description: String,
argumentHint: String?,
source: Source
) {
self.name = name
self.description = description
self.argumentHint = argumentHint
self.source = source
}
}
@@ -1,23 +1,45 @@
import Foundation
struct HermesToolset: Identifiable, Sendable {
var id: String { name }
let name: String
let description: String
let icon: String
var enabled: Bool
public struct HermesToolset: Identifiable, Sendable {
public var id: String { name }
public let name: String
public let description: String
public let icon: String
public var enabled: Bool
public init(
name: String,
description: String,
icon: String,
enabled: Bool
) {
self.name = name
self.description = description
self.icon = icon
self.enabled = enabled
}
}
struct HermesToolPlatform: Identifiable, Sendable {
var id: String { name }
let name: String
let displayName: String
let icon: String
public struct HermesToolPlatform: Identifiable, Sendable {
public var id: String { name }
public let name: String
public let displayName: String
public let icon: String
public init(
name: String,
displayName: String,
icon: String
) {
self.name = name
self.displayName = displayName
self.icon = icon
}
}
enum KnownPlatforms {
static let cli = HermesToolPlatform(name: "cli", displayName: "CLI", icon: "terminal")
static let all: [HermesToolPlatform] = [
public enum KnownPlatforms {
public static let cli = HermesToolPlatform(name: "cli", displayName: "CLI", icon: "terminal")
public static let all: [HermesToolPlatform] = [
cli,
HermesToolPlatform(name: "telegram", displayName: "Telegram", icon: "paperplane"),
HermesToolPlatform(name: "discord", displayName: "Discord", icon: "bubble.left.and.bubble.right"),
@@ -33,7 +55,7 @@ enum KnownPlatforms {
HermesToolPlatform(name: "imessage", displayName: "iMessage", icon: "message.fill"),
]
static func icon(for platform: String) -> String {
public static func icon(for platform: String) -> String {
switch platform {
case "cli": return "terminal"
case "telegram": return "paperplane"
@@ -1,22 +1,54 @@
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
public struct MCPServerPreset: Identifiable, Sendable, Equatable {
public let id: String
public let displayName: String
public let description: String
public let category: String
public let iconSystemName: String
public let transport: MCPTransport
public let command: String?
public let args: [String]
public let url: String?
public let auth: String?
public let requiredEnvKeys: [String]
public let optionalEnvKeys: [String]
public let pathArgPrompt: String?
public let docsURL: String
static let gallery: [MCPServerPreset] = [
public init(
id: String,
displayName: String,
description: String,
category: String,
iconSystemName: String,
transport: MCPTransport,
command: String?,
args: [String],
url: String?,
auth: String?,
requiredEnvKeys: [String],
optionalEnvKeys: [String],
pathArgPrompt: String?,
docsURL: String
) {
self.id = id
self.displayName = displayName
self.description = description
self.category = category
self.iconSystemName = iconSystemName
self.transport = transport
self.command = command
self.args = args
self.url = url
self.auth = auth
self.requiredEnvKeys = requiredEnvKeys
self.optionalEnvKeys = optionalEnvKeys
self.pathArgPrompt = pathArgPrompt
self.docsURL = docsURL
}
public static let gallery: [MCPServerPreset] = [
MCPServerPreset(
id: "filesystem",
displayName: "Filesystem",
@@ -163,12 +195,12 @@ struct MCPServerPreset: Identifiable, Sendable, Equatable {
)
]
static var categories: [String] {
public 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] {
public static func byCategory(_ category: String) -> [MCPServerPreset] {
gallery.filter { $0.category == category }
}
}
@@ -0,0 +1,286 @@
import Foundation
// MARK: - Registry
public struct ProjectRegistry: Codable, Sendable {
public var projects: [ProjectEntry]
public init(
projects: [ProjectEntry]
) {
self.projects = projects
}
}
public struct ProjectEntry: Codable, Sendable, Identifiable, Hashable {
public var id: String { name }
public let name: String
public let path: String
/// Folder path for sidebar grouping. `nil` means top-level.
/// v2.3 registry schema v2; v2.2 files decode cleanly as `nil`.
public var folder: String?
/// Soft-archive flag. Archived projects are hidden from the sidebar
/// by default; non-destructive. v2.3 schema v2; defaults to `false`.
public var archived: Bool
public init(
name: String,
path: String,
folder: String? = nil,
archived: Bool = false
) {
self.name = name
self.path = path
self.folder = folder
self.archived = archived
}
public var dashboardPath: String { path + "/.scarf/dashboard.json" }
// MARK: - Codable (custom for backward compat)
private enum CodingKeys: String, CodingKey {
case name, path, folder, archived
}
public 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)
self.folder = try c.decodeIfPresent(String.self, forKey: .folder)
self.archived = try c.decodeIfPresent(Bool.self, forKey: .archived) ?? false
}
public 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)
try c.encodeIfPresent(folder, forKey: .folder)
if archived {
try c.encode(archived, forKey: .archived)
}
}
}
// MARK: - Dashboard
public struct ProjectDashboard: Codable, Sendable {
public let version: Int
public let title: String
public let description: String?
public let updatedAt: String?
public let theme: DashboardTheme?
public let sections: [DashboardSection]
public init(
version: Int,
title: String,
description: String?,
updatedAt: String?,
theme: DashboardTheme?,
sections: [DashboardSection]
) {
self.version = version
self.title = title
self.description = description
self.updatedAt = updatedAt
self.theme = theme
self.sections = sections
}
}
public struct DashboardTheme: Codable, Sendable {
public let accent: String?
public init(
accent: String?
) {
self.accent = accent
}
}
public struct DashboardSection: Codable, Sendable, Identifiable {
public var id: String { title }
public let title: String
public let columns: Int?
public let widgets: [DashboardWidget]
public init(
title: String,
columns: Int?,
widgets: [DashboardWidget]
) {
self.title = title
self.columns = columns
self.widgets = widgets
}
public var columnCount: Int { columns ?? 3 }
}
public struct DashboardWidget: Codable, Sendable, Identifiable {
public var id: String { type + ":" + title }
public let type: String
public let title: String
// Stat
public let value: WidgetValue?
public let icon: String?
public let color: String?
public let subtitle: String?
// Progress
public let label: String?
// Text
public let content: String?
public let format: String?
// Table
public let columns: [String]?
public let rows: [[String]]?
// Chart
public let chartType: String?
public let xLabel: String?
public let yLabel: String?
public let series: [ChartSeries]?
// List
public let items: [ListItem]?
// Webview
public let url: String?
public let height: Double?
public init(
type: String,
title: String,
value: WidgetValue?,
icon: String?,
color: String?,
subtitle: String?,
label: String?,
content: String?,
format: String?,
columns: [String]?,
rows: [[String]]?,
chartType: String?,
xLabel: String?,
yLabel: String?,
series: [ChartSeries]?,
items: [ListItem]?,
url: String?,
height: Double?
) {
self.type = type
self.title = title
self.value = value
self.icon = icon
self.color = color
self.subtitle = subtitle
self.label = label
self.content = content
self.format = format
self.columns = columns
self.rows = rows
self.chartType = chartType
self.xLabel = xLabel
self.yLabel = yLabel
self.series = series
self.items = items
self.url = url
self.height = height
}
}
// MARK: - Widget Value (String or Number)
public enum WidgetValue: Codable, Sendable, Hashable {
case string(String)
case number(Double)
public 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)))
}
}
public 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")
)
}
}
public 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
public struct ChartSeries: Codable, Sendable, Identifiable {
public var id: String { name }
public let name: String
public let color: String?
public let data: [ChartDataPoint]
public init(
name: String,
color: String?,
data: [ChartDataPoint]
) {
self.name = name
self.color = color
self.data = data
}
}
public struct ChartDataPoint: Codable, Sendable, Identifiable {
public var id: String { x }
public let x: String
public let y: Double
public init(
x: String,
y: Double
) {
self.x = x
self.y = y
}
}
// MARK: - List Data
public struct ListItem: Codable, Sendable, Identifiable {
public var id: String { text }
public let text: String
public let status: String?
public init(
text: String,
status: String?
) {
self.text = text
self.status = status
}
}
@@ -0,0 +1,89 @@
import Foundation
/// A user-authored, project-scoped slash command. Lives at
/// `<project>/.scarf/slash-commands/<name>.md` as a Markdown file with
/// YAML frontmatter Scarf-side primitive, not a Hermes feature.
///
/// The command is invoked via the chat slash menu like any other command,
/// but Scarf intercepts the invocation client-side: the body is treated as
/// a prompt template (with `{{argument}}` substitution from whatever
/// followed the slash), expanded to a regular user prompt, and sent to
/// Hermes as a normal message. The agent never sees the slash trigger;
/// it sees the expanded prompt prefixed with a `<!-- scarf-slash:<name> -->`
/// marker so it can correlate.
///
/// **Why client-side expansion.** Hermes has no project-scoped slash
/// command concept. Doing the substitution in Scarf means commands work
/// uniformly on Mac + iOS, local + remote SSH transports, against any
/// Hermes version (no upstream dependency).
public struct ProjectSlashCommand: Sendable, Equatable, Identifiable {
/// Stable identity is the command's `name` (must be unique within a
/// project's `slash-commands/` dir).
public var id: String { name }
/// Slash trigger drives the menu and the on-disk filename.
/// Must match `[a-z][a-z0-9-]*`. Validated by the service on save.
public let name: String
/// Human-readable subtitle shown in the slash menu.
public let description: String
/// Optional placeholder shown after `/<name> ` in the menu (e.g. `<focus area>`).
public let argumentHint: String?
/// Optional per-command model override. When set, the expanded prompt
/// is sent with this model in the ACP envelope, regardless of the
/// session's default.
public let model: String?
/// Optional grouping tags for the catalog / editor UI. Not surfaced
/// to the agent.
public let tags: [String]?
/// The prompt template body (everything after the YAML frontmatter
/// closer). Mustache-style `{{argument}}` substitution; supports
/// `{{argument | default: "..."}}` for fallbacks.
public let body: String
/// Absolute path the command was loaded from (used by the editor's
/// save/delete affordances + by the uninstaller's lock-file tracking).
public let sourcePath: String
public init(
name: String,
description: String,
argumentHint: String? = nil,
model: String? = nil,
tags: [String]? = nil,
body: String,
sourcePath: String
) {
self.name = name
self.description = description
self.argumentHint = argumentHint
self.model = model
self.tags = tags
self.body = body
self.sourcePath = sourcePath
}
}
// MARK: - Validation
public extension ProjectSlashCommand {
/// Allowed name shape: lowercase, digits, hyphens; must start with a
/// letter. Mirrors the catalog validator's rule so on-disk files
/// authored in Scarf round-trip cleanly through `.scarftemplate`.
static let validNamePattern = #"^[a-z][a-z0-9-]*$"#
/// Returns nil when the name is well-formed; otherwise a human-readable
/// reason suitable for inline editor UX.
static func validateName(_ name: String) -> String? {
if name.isEmpty { return "Name can't be empty." }
if name.count > 64 { return "Name must be 64 characters or fewer." }
if name.range(of: validNamePattern, options: .regularExpression) == nil {
return "Name must start with a letter and contain only lowercase letters, digits, and hyphens."
}
return nil
}
}
@@ -0,0 +1,359 @@
import Foundation
#if canImport(SwiftUI)
import SwiftUI
#endif
/// 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.
public 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.
public struct SSHConfig: Sendable, Hashable, Codable {
/// Hostname or `~/.ssh/config` alias.
public var host: String
/// Remote username. `nil` defer to `~/.ssh/config` or the local user.
public var user: String?
/// TCP port. `nil` 22 (or whatever `~/.ssh/config` says).
public var port: Int?
/// Absolute path to a private key. `nil` defer to ssh-agent /
/// `~/.ssh/config` identity files.
public var identityFile: String?
/// Override for the remote `$HOME/.hermes` directory. `nil` uses
/// `HermesPathSet.defaultRemoteHome` (`~/.hermes`, shell-expanded on the
/// remote side).
public var remoteHome: String?
/// Override for where Scarf installs new project templates on this host.
/// `nil` uses `~/projects` (unexpanded remote shell resolves it).
/// Created on first install if missing.
public var projectsRoot: 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.
public var hermesBinaryHint: String?
public init(
host: String,
user: String? = nil,
port: Int? = nil,
identityFile: String? = nil,
remoteHome: String? = nil,
projectsRoot: String? = nil,
hermesBinaryHint: String? = nil
) {
self.host = host
self.user = user
self.port = port
self.identityFile = identityFile
self.remoteHome = remoteHome
self.projectsRoot = projectsRoot
self.hermesBinaryHint = hermesBinaryHint
}
}
/// 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.
public 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. One `ServerContext` corresponds to one
/// Hermes installation; multi-window scenes construct one per window.
///
/// **Why every member is `nonisolated`.** Sibling extension methods in the
/// Mac app target (`ServerContext+Mac.swift`) touch `AppKit`
/// (`NSWorkspace.shared.open` in `openInLocalEditor`), which under Swift 6's
/// 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.
public struct ServerContext: Sendable, Hashable, Identifiable {
public let id: ServerID
public var displayName: String
public var kind: ServerKind
public init(
id: ServerID,
displayName: String,
kind: ServerKind
) {
self.id = id
self.displayName = displayName
self.kind = kind
}
/// Path layout for this server. Cheap all path components are computed
/// on demand from `home`, no I/O.
public 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
)
}
}
public nonisolated var isRemote: Bool {
if case .ssh = kind { return true }
return false
}
/// Default parent directory under which `ProjectTemplateInstaller` lays
/// out new projects. Per-host configurable on `.ssh` via
/// `SSHConfig.projectsRoot`; local always resolves to `~/Projects` on the
/// user's Mac. The remote default is left as an unexpanded `~/projects`
/// the remote shell resolves the tilde, same convention as
/// `HermesPathSet.defaultRemoteHome`. The installer calls
/// `transport.createDirectory(_:)` at install time so a missing dir on a
/// fresh host is bootstrapped on first use rather than treated as an error.
public nonisolated var defaultProjectsRoot: String {
switch kind {
case .local:
return NSHomeDirectory() + "/Projects"
case .ssh(let config):
if let configured = config.projectsRoot,
!configured.trimmingCharacters(in: .whitespaces).isEmpty {
return configured
}
return "~/projects"
}
}
/// Construct the `ServerTransport` for this context. Local contexts get
/// a `LocalTransport`; SSH contexts get an `SSHTransport` configured
/// from `SSHConfig` by default, OR whatever `sshTransportFactory`
/// returns if the host app has wired one. Each call returns a fresh
/// value transports are cheap and stateless beyond disk caches.
///
/// **Cross-platform wiring.** On the Mac app the default
/// `SSHTransport` (fork + exec `/usr/bin/ssh`) is the right thing,
/// so `sshTransportFactory` stays `nil`. On iOS the Mac SSH binary
/// doesn't exist, so `scarf-ios` wires this factory at launch to
/// produce a Citadel-backed `ServerTransport`. All downstream
/// services (`HermesDataService`, `HermesLogService`,
/// `ProjectDashboardService`, ) then work on iOS unchanged.
public nonisolated func makeTransport() -> any ServerTransport {
switch kind {
case .local:
return LocalTransport(contextID: id)
case .ssh(let config):
if let factory = ServerContext.sshTransportFactory {
return factory(id, config, displayName)
}
return SSHTransport(contextID: id, config: config, displayName: displayName)
}
}
/// Override for `.ssh` transports. The iOS app sets this at launch to
/// `{ id, cfg, name in CitadelServerTransport(contextID: id, config: cfg, displayName: name) }`
/// so every `ServerContext.makeTransport()` call on a Citadel-backed
/// iOS app returns the Citadel impl instead of the Mac/Linux
/// `SSHTransport`. Mac leaves this `nil`.
///
/// Set once, before any `makeTransport()` call is made. The
/// `nonisolated(unsafe)` annotation mirrors the same pattern
/// `SSHTransport.environmentEnricher` uses single-write at app
/// startup, many-read afterwards.
///
/// **Test usage.** Production sets this once at launch. Tests that need
/// to inject a fake transport must run inside `M5FeatureVMTests` (the
/// canonical `.serialized` suite that owns this static) running
/// factory-touching tests across multiple parallel suites races on this
/// var. `@TaskLocal` would scope cleanly, but the production hot paths
/// dispatch DB/SFTP reads through `Task.detached` which severs
/// TaskLocal inheritance, so the static-write pattern is the only one
/// that survives the call stack.
public typealias SSHTransportFactory = @Sendable (
_ id: ServerID,
_ config: SSHConfig,
_ displayName: String
) -> any ServerTransport
nonisolated(unsafe) public static var sshTransportFactory: SSHTransportFactory?
// 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.
public 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.
public 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.
public static func invalidateCaches(for contextID: ServerID) async {
await UserHomeCache.shared.invalidate(contextID: contextID)
}
/// Static convenience for callers that have the ServerID but not
/// a full ServerContext (e.g. RootModel.softDisconnect). Mirrors
/// the instance method above.
public static func invalidateCachedHome(forServerID id: ServerID) async {
await UserHomeCache.shared.invalidate(contextID: id)
}
}
// 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). Use this when the caller genuinely can't tell
/// the difference (e.g. "if a manifest exists, parse it, otherwise
/// use defaults"). Prefer `readTextThrowing` when the UI needs to
/// distinguish "file doesn't exist" from "transport failed" pass-1
/// M7 #8 showed that silent nils from transport errors masqueraded
/// as empty files in the Memory editor for ~1 minute before the
/// SFTP-tilde fix was found.
public nonisolated func readText(_ path: String) -> String? {
try? readTextThrowing(path)
}
/// Read a UTF-8 text file. Throws on transport errors. Returns:
/// - `.some(content)` when the file was read successfully,
/// - `.none` when the file is genuinely absent (the transport's
/// `fileExists` returned false),
/// - throws the underlying transport error otherwise.
///
/// This is the version to call from VMs that can surface a real
/// error to the UI e.g. Memory, Settings, Cron. The nil-returning
/// shim above is fine for "probably there, probably not" cases.
public nonisolated func readTextThrowing(_ path: String) throws -> String? {
let transport = makeTransport()
guard transport.fileExists(path) else { return nil }
let data = try transport.readFile(path)
guard let text = String(data: data, encoding: .utf8) else {
throw TransportError.other(message: "File at \(path) is not valid UTF-8.")
}
return text
}
/// Read raw bytes. `nil` on any error.
public 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
public 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`.
public nonisolated func fileExists(_ path: String) -> Bool {
makeTransport().fileExists(path)
}
/// File modification timestamp, or `nil` if the file doesn't exist.
public nonisolated func modificationDate(_ path: String) -> Date? {
makeTransport().stat(path)?.mtime
}
}
// 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`.
///
/// Guarded on `canImport(SwiftUI)` so ScarfCore still compiles on Linux
/// (swift-corelibs-foundation has no SwiftUI). Apple platforms the real
/// runtime targets compile the SwiftUI plumbing unchanged.
#if canImport(SwiftUI)
private struct ServerContextEnvironmentKey: EnvironmentKey {
static let defaultValue: ServerContext = .local
}
extension EnvironmentValues {
public var serverContext: ServerContext {
get { self[ServerContextEnvironmentKey.self] }
set { self[ServerContextEnvironmentKey.self] = newValue }
}
}
#endif
@@ -0,0 +1,36 @@
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).
///
/// Promoted to ScarfCore in M9 #4.2 so iOS can use the same record
/// type ScarfGo's project-scoped chat writes here over SFTP.
public struct SessionProjectMap: Codable, Sendable {
public var mappings: [String: String]
public var updatedAt: String?
public 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.
public static func nowISO8601() -> String {
ISO8601DateFormatter().string(from: Date())
}
}
@@ -0,0 +1,286 @@
import Foundation
/// YAML-driven `HermesConfig` constructor. Lifted verbatim (with
/// trivial adjustments to access the ScarfCore-public types) from
/// `HermesFileService.parseConfig` so the same key struct-field
/// mapping feeds both the Mac app and iOS.
///
/// **Behaviour parity.** Every default value, every key, and every
/// fallback path in this file tracks the Mac implementation
/// one-for-one. If the Mac parser learns to recognise a new key,
/// this one should too (and vice versa). The M6 test suite freezes
/// the defaults + a few recognition paths, so behaviour drift
/// surfaces on Linux CI without needing Xcode.
public extension HermesConfig {
/// Parse a `config.yaml` string into a fully-populated
/// `HermesConfig`. Missing keys fall back to `HermesConfig.empty`-
/// compatible defaults. Unknown keys are ignored Hermes is
/// forward-compatible, i.e. a config file with newer keys than
/// scarf knows still loads.
///
/// The parse is deliberately forgiving: malformed YAML produces
/// whatever partial state the parser could recover + defaults
/// for everything else, not a throw. The iOS Settings view
/// surfaces the raw file on top of this so users can spot a
/// broken key even when the struct came back defaulted.
init(yaml: String) {
let parsed = HermesYAML.parseNestedYAML(yaml)
let values = parsed.values
let lists = parsed.lists
let maps = parsed.maps
func bool(_ key: String, default def: Bool) -> Bool {
guard let v = values[key] else { return def }
return v == "true"
}
func int(_ key: String, default def: Int) -> Int {
Int(values[key] ?? "") ?? def
}
func double(_ key: String, default def: Double) -> Double {
Double(values[key] ?? "") ?? def
}
func str(_ key: String, default def: String = "") -> String {
let raw = values[key] ?? def
return HermesYAML.stripYAMLQuotes(raw)
}
let dockerEnv = maps["terminal.docker_env"] ?? [:]
let commandAllowlist = lists["permanent_allowlist"] ?? lists["command_allowlist"] ?? []
let display = DisplaySettings(
skin: str("display.skin", default: "default"),
compact: bool("display.compact", default: false),
resumeDisplay: str("display.resume_display", default: "full"),
bellOnComplete: bool("display.bell_on_complete", default: false),
inlineDiffs: bool("display.inline_diffs", default: true),
toolProgressCommand: bool("display.tool_progress_command", default: false),
toolPreviewLength: int("display.tool_preview_length", default: 0),
busyInputMode: str("display.busy_input_mode", default: "interrupt")
)
let terminal = TerminalSettings(
cwd: str("terminal.cwd", default: "."),
timeout: int("terminal.timeout", default: 180),
envPassthrough: lists["terminal.env_passthrough"] ?? [],
persistentShell: bool("terminal.persistent_shell", default: true),
dockerImage: str("terminal.docker_image"),
dockerMountCwdToWorkspace: bool("terminal.docker_mount_cwd_to_workspace", default: false),
dockerForwardEnv: lists["terminal.docker_forward_env"] ?? [],
dockerVolumes: lists["terminal.docker_volumes"] ?? [],
containerCPU: int("terminal.container_cpu", default: 0),
containerMemory: int("terminal.container_memory", default: 0),
containerDisk: int("terminal.container_disk", default: 0),
containerPersistent: bool("terminal.container_persistent", default: false),
modalImage: str("terminal.modal_image"),
modalMode: str("terminal.modal_mode", default: "auto"),
daytonaImage: str("terminal.daytona_image"),
singularityImage: str("terminal.singularity_image")
)
let browser = BrowserSettings(
inactivityTimeout: int("browser.inactivity_timeout", default: 120),
commandTimeout: int("browser.command_timeout", default: 30),
recordSessions: bool("browser.record_sessions", default: false),
allowPrivateURLs: bool("browser.allow_private_urls", default: false),
camofoxManagedPersistence: bool("browser.camofox.managed_persistence", default: false)
)
let voice = VoiceSettings(
recordKey: str("voice.record_key", default: "ctrl+b"),
maxRecordingSeconds: int("voice.max_recording_seconds", default: 120),
silenceDuration: double("voice.silence_duration", default: 3.0),
ttsProvider: str("tts.provider", default: "edge"),
ttsEdgeVoice: str("tts.edge.voice", default: "en-US-AriaNeural"),
ttsElevenLabsVoiceID: str("tts.elevenlabs.voice_id"),
ttsElevenLabsModelID: str("tts.elevenlabs.model_id", default: "eleven_multilingual_v2"),
ttsOpenAIModel: str("tts.openai.model", default: "gpt-4o-mini-tts"),
ttsOpenAIVoice: str("tts.openai.voice", default: "alloy"),
ttsNeuTTSModel: str("tts.neutts.model"),
ttsNeuTTSDevice: str("tts.neutts.device", default: "cpu"),
sttEnabled: bool("stt.enabled", default: true),
sttProvider: str("stt.provider", default: "local"),
sttLocalModel: str("stt.local.model", default: "base"),
sttLocalLanguage: str("stt.local.language"),
sttOpenAIModel: str("stt.openai.model", default: "whisper-1"),
sttMistralModel: str("stt.mistral.model", default: "voxtral-mini-latest")
)
func aux(_ name: String) -> AuxiliaryModel {
AuxiliaryModel(
provider: str("auxiliary.\(name).provider", default: "auto"),
model: str("auxiliary.\(name).model"),
baseURL: str("auxiliary.\(name).base_url"),
apiKey: str("auxiliary.\(name).api_key"),
timeout: int("auxiliary.\(name).timeout", default: 30)
)
}
let auxiliary = AuxiliarySettings(
vision: aux("vision"),
webExtract: aux("web_extract"),
compression: aux("compression"),
sessionSearch: aux("session_search"),
skillsHub: aux("skills_hub"),
approval: aux("approval"),
mcp: aux("mcp"),
flushMemories: aux("flush_memories")
)
let security = SecuritySettings(
redactSecrets: bool("security.redact_secrets", default: true),
redactPII: bool("privacy.redact_pii", default: false),
tirithEnabled: bool("security.tirith_enabled", default: true),
tirithPath: str("security.tirith_path", default: "tirith"),
tirithTimeout: int("security.tirith_timeout", default: 5),
tirithFailOpen: bool("security.tirith_fail_open", default: true),
blocklistEnabled: bool("security.website_blocklist.enabled", default: false),
blocklistDomains: lists["security.website_blocklist.domains"] ?? []
)
let humanDelay = HumanDelaySettings(
mode: str("human_delay.mode", default: "off"),
minMS: int("human_delay.min_ms", default: 800),
maxMS: int("human_delay.max_ms", default: 2500)
)
let compression = CompressionSettings(
enabled: bool("compression.enabled", default: true),
threshold: double("compression.threshold", default: 0.5),
targetRatio: double("compression.target_ratio", default: 0.2),
protectLastN: int("compression.protect_last_n", default: 20)
)
let checkpoints = CheckpointSettings(
enabled: bool("checkpoints.enabled", default: true),
maxSnapshots: int("checkpoints.max_snapshots", default: 50)
)
let logging = LoggingSettings(
level: str("logging.level", default: "INFO"),
maxSizeMB: int("logging.max_size_mb", default: 5),
backupCount: int("logging.backup_count", default: 3)
)
let delegation = DelegationSettings(
model: str("delegation.model"),
provider: str("delegation.provider"),
baseURL: str("delegation.base_url"),
apiKey: str("delegation.api_key"),
maxIterations: int("delegation.max_iterations", default: 50)
)
let discord = DiscordSettings(
requireMention: bool("discord.require_mention", default: true),
freeResponseChannels: str("discord.free_response_channels"),
autoThread: bool("discord.auto_thread", default: true),
reactions: bool("discord.reactions", default: true)
)
let telegram = TelegramSettings(
requireMention: bool("telegram.require_mention", default: true),
reactions: bool("telegram.reactions", default: false)
)
// Slack fields live under both `platforms.slack.*` (newer) and `slack.*`
// (legacy). Prefer the newer path but fall back.
let slack = SlackSettings(
replyToMode: values["platforms.slack.reply_to_mode"] ?? values["slack.reply_to_mode"] ?? "first",
requireMention: (values["platforms.slack.require_mention"] ?? values["slack.require_mention"]) != "false",
replyInThread: (values["platforms.slack.extra.reply_in_thread"] ?? "true") != "false",
replyBroadcast: (values["platforms.slack.extra.reply_broadcast"] ?? "false") == "true"
)
let matrix = MatrixSettings(
requireMention: bool("matrix.require_mention", default: true),
autoThread: bool("matrix.auto_thread", default: true),
dmMentionThreads: bool("matrix.dm_mention_threads", default: false)
)
let mattermost = MattermostSettings(
requireMention: bool("mattermost.require_mention", default: true),
replyMode: str("mattermost.reply_mode", default: "off")
)
let whatsapp = WhatsAppSettings(
unauthorizedDMBehavior: str("whatsapp.unauthorized_dm_behavior", default: "pair"),
replyPrefix: str("whatsapp.reply_prefix")
)
// `platform_toolsets.<platform>` is a dict of lists in config.yaml
// parseNestedYAML flattens nested lists into dotted-path keys. Pull
// every key under the prefix and strip it.
var platformToolsets: [String: [String]] = [:]
for (key, items) in lists where key.hasPrefix("platform_toolsets.") {
let platform = String(key.dropFirst("platform_toolsets.".count))
guard !platform.isEmpty else { continue }
platformToolsets[platform] = items
}
// Home Assistant lives under `platforms.homeassistant.extra.*`.
let homeAssistant = HomeAssistantSettings(
watchDomains: lists["platforms.homeassistant.extra.watch_domains"] ?? [],
watchEntities: lists["platforms.homeassistant.extra.watch_entities"] ?? [],
watchAll: bool("platforms.homeassistant.extra.watch_all", default: false),
ignoreEntities: lists["platforms.homeassistant.extra.ignore_entities"] ?? [],
cooldownSeconds: int("platforms.homeassistant.extra.cooldown_seconds", default: 30)
)
self.init(
model: str("model.default", default: "unknown"),
provider: str("model.provider", default: "unknown"),
maxTurns: int("agent.max_turns", default: 0),
personality: str("display.personality", default: "default"),
terminalBackend: str("terminal.backend", default: "local"),
memoryEnabled: bool("memory.memory_enabled", default: false),
memoryCharLimit: int("memory.memory_char_limit", default: 0),
userCharLimit: int("memory.user_char_limit", default: 0),
nudgeInterval: int("memory.nudge_interval", default: 0),
streaming: values["display.streaming"] != "false",
showReasoning: bool("display.show_reasoning", default: false),
verbose: bool("agent.verbose", default: false),
autoTTS: values["voice.auto_tts"] != "false",
silenceThreshold: int("voice.silence_threshold", default: QueryDefaults.defaultSilenceThreshold),
reasoningEffort: str("agent.reasoning_effort", default: "medium"),
showCost: bool("display.show_cost", default: false),
approvalMode: str("approvals.mode", default: "manual"),
browserBackend: str("browser.backend"),
memoryProvider: str("memory.provider"),
dockerEnv: dockerEnv,
commandAllowlist: commandAllowlist,
memoryProfile: str("memory.profile"),
serviceTier: str("agent.service_tier", default: "normal"),
gatewayNotifyInterval: int("agent.gateway_notify_interval", default: 600),
forceIPv4: bool("network.force_ipv4", default: false),
contextEngine: str("context.engine", default: "compressor"),
interimAssistantMessages: values["display.interim_assistant_messages"] != "false",
honchoInitOnSessionStart: bool("honcho.initOnSessionStart", default: false),
timezone: str("timezone"),
userProfileEnabled: bool("memory.user_profile_enabled", default: true),
toolUseEnforcement: str("agent.tool_use_enforcement", default: "auto"),
gatewayTimeout: int("agent.gateway_timeout", default: 1800),
approvalTimeout: int("approvals.timeout", default: 60),
fileReadMaxChars: int("file_read_max_chars", default: 100_000),
cronWrapResponse: bool("cron.wrap_response", default: true),
prefillMessagesFile: str("prefill_messages_file"),
skillsExternalDirs: lists["skills.external_dirs"] ?? [],
platformToolsets: platformToolsets,
display: display,
terminal: terminal,
browser: browser,
voice: voice,
auxiliary: auxiliary,
security: security,
humanDelay: humanDelay,
compression: compression,
checkpoints: checkpoints,
logging: logging,
delegation: delegation,
discord: discord,
telegram: telegram,
slack: slack,
matrix: matrix,
mattermost: mattermost,
whatsapp: whatsapp,
homeAssistant: homeAssistant
)
}
}
@@ -0,0 +1,157 @@
import Foundation
/// Pure-Swift parsers for `hermes skills` CLI output. Extracted from
/// the Mac `SkillsViewModel` in v2.5 so iOS can share the same parse
/// logic both targets call `transport.runProcess(executable: hermes)`
/// and feed the captured stdout/stderr through these parsers.
///
/// Marked `Sendable` so they can run inside `Task.detached` blocks
/// without isolation gymnastics. All members are `nonisolated`.
public enum HermesSkillsHubParser: Sendable {
/// Parse `hermes skills browse|search` output.
///
/// Hermes emits a Rich box-drawn table with vertical bars as column
/// separators:
///
/// # Name Description Source Trust
///
/// 1 1password Set up and use 1Pass official official
///
/// Description cells can wrap across multiple rows the
/// continuation rows have an empty `#` column. We join consecutive
/// rows with the same skill by checking whether the first column
/// (after ``) is whitespace-only.
public static func parseHubList(_ output: String) -> [HermesHubSkill] {
var results: [HermesHubSkill] = []
for raw in output.components(separatedBy: "\n") {
let line = raw
// Skip everything that isn't a data row. Data rows start
// with `` and contain multiple `` separators. Border
// rows (``, ``, ``, ``, etc.) are drawn with `` or
// `` and should be skipped.
guard line.contains("") else { continue }
let cells = line
.split(separator: "", omittingEmptySubsequences: false)
.map { $0.trimmingCharacters(in: .whitespaces) }
// Expect at least: leading empty, #, Name, Description,
// Source, Trust, trailing empty
guard cells.count >= 6 else { continue }
let numCell = cells[1]
let nameCell = cells[2]
let descCell = cells[3]
let sourceCell = cells[4]
// Trust column (index 5) is informational only we ignore
// it in the UI.
// Continuation row: `#` column is empty. Merge its
// description into the last-added entry if present.
if numCell.isEmpty {
guard !results.isEmpty else { continue }
let last = results.removeLast()
let merged = [last.description, descCell]
.filter { !$0.isEmpty }
.joined(separator: " ")
results.append(HermesHubSkill(
identifier: last.identifier,
name: last.name,
description: merged,
source: last.source
))
continue
}
// Header row first data-looking row whose number cell
// isn't a digit.
if Int(numCell) == nil { continue }
// Empty name cell shouldn't happen but guard anyway.
guard !nameCell.isEmpty else { continue }
// Identifier: `hermes skills browse` shows the short name
// in the Name column. For install we need the full
// identifier like `<source>/<name>`. The CLI accepts just
// the name for official hub, so we use that as the install
// target.
let source = sourceCell
.replacingOccurrences(of: "", with: "")
.trimmingCharacters(in: .whitespaces)
results.append(HermesHubSkill(
identifier: nameCell,
name: nameCell,
description: descCell,
source: source
))
}
return results
}
/// Parse `hermes skills check` output for available updates. Format
/// is undocumented; we look for `` (U+2192) or `->` arrow markers
/// between version strings.
public static func parseUpdateList(_ output: String) -> [HermesSkillUpdate] {
var results: [HermesSkillUpdate] = []
for raw in output.components(separatedBy: "\n") {
let line = raw.trimmingCharacters(in: .whitespaces)
guard line.contains("") || line.contains("->") else { continue }
let marker = line.contains("") ? "" : "->"
let parts = line.components(separatedBy: marker)
guard parts.count == 2 else { continue }
let left = parts[0].trimmingCharacters(in: .whitespaces)
let available = parts[1].trimmingCharacters(in: .whitespaces)
let leftTokens = left
.split(separator: " ", omittingEmptySubsequences: true)
.map(String.init)
guard leftTokens.count >= 2 else { continue }
let identifier = leftTokens[0]
let current = leftTokens[leftTokens.count - 1]
results.append(HermesSkillUpdate(
identifier: identifier,
currentVersion: current,
availableVersion: available
))
}
return results
}
}
// MARK: - Public model types
/// A single search/browse result from a skill registry. Mirrors the
/// shape `SkillsViewModel` had on Mac before the v2.5 ScarfCore promotion.
public struct HermesHubSkill: Identifiable, Sendable, Equatable {
public var id: String { identifier }
public let identifier: String // e.g. "openai/skills/skill-creator"
public let name: String
public let description: String
public let source: String // "official" | "skills-sh" | etc.
public init(
identifier: String,
name: String,
description: String,
source: String
) {
self.identifier = identifier
self.name = name
self.description = description
self.source = source
}
}
/// A local skill that has an upstream version available.
public struct HermesSkillUpdate: Identifiable, Sendable, Equatable {
public var id: String { identifier }
public let identifier: String
public let currentVersion: String
public let availableVersion: String
public init(
identifier: String,
currentVersion: String,
availableVersion: String
) {
self.identifier = identifier
self.currentVersion = currentVersion
self.availableVersion = availableVersion
}
}
@@ -0,0 +1,137 @@
import Foundation
/// Parsed YAML result bundle. Flat dotted-path keys point at the
/// three value shapes we care about (scalars, bullet lists, maps).
///
/// **Scope note.** This is NOT a full YAML-spec parser. It handles
/// the subset used by Hermes's `config.yaml`: indent-based block
/// nesting, string/int/bool/float scalars, `- item` bullet lists,
/// and one level of nested `key: value` maps. Anchors, aliases,
/// multi-line scalars (`|` / `>` block scalars), flow-style `[ ]` /
/// `{ }` literals, tags none of those are supported. That covers
/// 100% of what the current Hermes config actually uses.
///
/// The original implementation lived in the Mac app's
/// `HermesFileService`. Ported into ScarfCore in M6 so iOS can read
/// `config.yaml` through the same parser without having to pull in a
/// third-party YAML dependency.
public struct ParsedYAML: Sendable {
/// Scalar key-value pairs at any indent level
/// `values["section.key"] = "..."`.
public var values: [String: String]
/// Bullet-list items attached to a parent key
/// `lists["section.key"] = [...]`.
public var lists: [String: [String]]
/// Nested `key: value` maps captured under a section header
/// `maps["section"] = [key: value, ...]`.
public var maps: [String: [String: String]]
public init(
values: [String: String] = [:],
lists: [String: [String]] = [:],
maps: [String: [String: String]] = [:]
) {
self.values = values
self.lists = lists
self.maps = maps
}
}
/// Entry points for Hermes-flavored YAML parsing. Stateless, pure
/// functions no Foundation types that differ cross-platform.
public enum HermesYAML {
/// Parse a YAML string into a `ParsedYAML` bundle.
public static func parseNestedYAML(_ yaml: String) -> ParsedYAML {
var values: [String: String] = [:]
var lists: [String: [String]] = [:]
var maps: [String: [String: String]] = [:]
// Path stack: each entry is (indent, name). Pop when indent shrinks.
var stack: [(indent: Int, name: String)] = []
func currentPath(joinedWith child: String? = nil) -> String {
var parts = stack.map(\.name)
if let child { parts.append(child) }
return parts.joined(separator: ".")
}
let rawLines = yaml.components(separatedBy: "\n")
for line in rawLines {
// Skip comment-only and blank lines but preserve indent semantics.
let trimmed = line.trimmingCharacters(in: .whitespaces)
if trimmed.isEmpty || trimmed.hasPrefix("#") { continue }
let indent = line.prefix(while: { $0 == " " }).count
let isListItem = trimmed.hasPrefix("- ")
// Pop stack entries with indent >= current indent.
// Exception: a list item at the same indent as its parent key is
// valid block-style YAML ("toolsets:\n- hermes-cli") keep the
// parent so the item is attributed to it.
while let top = stack.last {
let shouldPop: Bool
if isListItem && top.indent == indent {
shouldPop = false
} else {
shouldPop = top.indent >= indent
}
if shouldPop { stack.removeLast() } else { break }
}
if isListItem {
let item = String(trimmed.dropFirst(2)).trimmingCharacters(in: .whitespaces)
let stripped = stripYAMLQuotes(item)
let path = currentPath()
guard !path.isEmpty else { continue }
lists[path, default: []].append(stripped)
continue
}
// Key-value or section line.
guard let colonIdx = trimmed.firstIndex(of: ":") else { continue }
let key = String(trimmed[trimmed.startIndex..<colonIdx]).trimmingCharacters(in: .whitespaces)
let afterColon = String(trimmed[trimmed.index(after: colonIdx)...]).trimmingCharacters(in: .whitespaces)
let path = currentPath(joinedWith: key)
if afterColon.isEmpty || afterColon == "|" || afterColon == ">" {
// Section header or empty-valued key push onto stack so children nest.
stack.append((indent: indent, name: key))
continue
}
// Inline `{}` / `[]` literals treat as empty.
if afterColon == "{}" {
values[path] = ""
maps[path] = [:]
continue
}
if afterColon == "[]" {
values[path] = ""
lists[path] = []
continue
}
values[path] = afterColon
// Also record as a map entry under the parent so blocks like
// `terminal.docker_env` are accessible as `[String: String]`
// without a separate scan.
if !stack.isEmpty {
let parentPath = currentPath()
maps[parentPath, default: [:]][key] = stripYAMLQuotes(afterColon)
}
}
return ParsedYAML(values: values, lists: lists, maps: maps)
}
/// Strip a single layer of surrounding single or double quotes from a YAML scalar.
public static func stripYAMLQuotes(_ s: String) -> String {
guard s.count >= 2 else { return s }
let first = s.first!
let last = s.last!
if (first == "'" && last == "'") || (first == "\"" && last == "\"") {
return String(s.dropFirst().dropLast())
}
return s
}
}
@@ -0,0 +1,78 @@
import Foundation
/// Pure-Swift YAML parsers for skill manifests + SKILL.md frontmatter.
///
/// Two readers ship here:
///
/// - `parseRequiredConfig(_:)` the original v2.5 reader. Pulls the
/// `required_config:` list out of a skill's `skill.yaml`. Extracted
/// from `HermesFileService.parseSkillRequiredConfig` in v2.5 so iOS
/// can flag missing config keys without depending on the Mac target.
/// - `parseV011Fields(_:)` Hermes v2026.4.23+ SKILL.md frontmatter
/// reader. Extracts `allowed_tools`, `related_skills`, and
/// `dependencies` lists from the YAML block between `---` markers
/// at the top of a SKILL.md file. Used by `SkillsScanner` to populate
/// `HermesSkill`'s v0.11 fields so chip rows in the detail views
/// render correctly. Returns nil for fields that are absent or
/// empty (callers treat nil as "don't show this section").
///
/// Intentionally not a full YAML parser Hermes skill manifests use a
/// very narrow subset of YAML. `parseV011Fields` reuses `HermesYAML`;
/// `parseRequiredConfig` stays inline because tests pin its behaviour.
public enum SkillFrontmatterParser: Sendable {
/// Parse the `required_config:` list from a skill.yaml's text. Empty
/// result on any kind of malformation callers treat it as "no
/// required config, proceed".
public static func parseRequiredConfig(_ content: String) -> [String] {
var result: [String] = []
var inRequiredConfig = false
for line in content.components(separatedBy: "\n") {
let trimmed = line.trimmingCharacters(in: .whitespaces)
if trimmed.isEmpty || trimmed.hasPrefix("#") { continue }
let indent = line.prefix(while: { $0 == " " }).count
if trimmed == "required_config:" || trimmed.hasPrefix("required_config:") {
inRequiredConfig = true
continue
}
if inRequiredConfig {
if indent < 2 && !trimmed.isEmpty {
break
}
if trimmed.hasPrefix("- ") {
result.append(String(trimmed.dropFirst(2)))
}
}
}
return result
}
/// Parse Hermes v2026.4.23+ SKILL.md frontmatter for the v0.11
/// fields. The frontmatter block is the YAML region between two
/// `---` markers at the top of the file. Anything outside the
/// markers is ignored. Returns nil-everything when the file has
/// no frontmatter or no recognised fields callers should hide
/// the corresponding chip rows in that case.
///
/// Caller pre-condition: `content` is the full SKILL.md text. We
/// detect the frontmatter shape ourselves rather than requiring
/// callers to pre-strip it.
public static func parseV011Fields(
_ content: String
) -> (allowedTools: [String]?, relatedSkills: [String]?, dependencies: [String]?) {
let lines = content.components(separatedBy: "\n")
guard lines.first == "---",
let endIdx = lines.dropFirst().firstIndex(of: "---")
else { return (nil, nil, nil) }
let frontmatter = lines[1..<endIdx].joined(separator: "\n")
let parsed = HermesYAML.parseNestedYAML(frontmatter)
let allowed = parsed.lists["allowed_tools"]
let related = parsed.lists["related_skills"]
let deps = parsed.lists["dependencies"]
return (
allowedTools: (allowed?.isEmpty ?? true) ? nil : allowed,
relatedSkills: (related?.isEmpty ?? true) ? nil : related,
dependencies: (deps?.isEmpty ?? true) ? nil : deps
)
}
}

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