Compare commits

..

273 Commits

Author SHA1 Message Date
Alan Wizemann 80589b3f23 chore(i18n): pick up autogenerated v0.12 string keys
Xcode-autogenerated strings for the v12 surface — curator chip labels,
image attachment button + counter, archived-skill banner — that the
extractor produced while the v12-updates branch was being authored.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-01 15:17:11 +02:00
Alan Wizemann 13f89e309b docs(claude-md): correct Hermes v0.12 surface drift after review fixes
CLAUDE.md was rewritten in 3d85b91 to describe the new v0.12 surfaces
but several claims drifted from what actually shipped (or have since
walked back during the review-fix pass):

- Curator iOS panel was described as "read-only"; it ships Run Now /
  Pause / Resume actions and inline pin toggles.
- Curator path symbols were named `curatorReportJSON` / `curatorReportMD`;
  the actual additions to `HermesPathSet` are `curatorLogsDir` and
  `curatorStateFile`, with the per-cycle `run.json` / `REPORT.md`
  resolved at runtime via the state file's `last_report_path`.
- The `flush_memories` bullet claimed Scarf had dropped the field; it's
  preserved on pre-v0.12 hosts via `hasFlushMemoriesAux` (restored in
  commit 33022ae).
- The cron `--workdir` bullet didn't mention the capability gating that
  landed in commit 4a2ef74, nor the empty-string clear gesture from
  commit 46cec81.
- The v0.12 surface list omitted the iOS Phase H catch-up
  (Webhooks/Plugins/Profiles read-only tabs + HermesVersionBanner)
  shipped in commit 799332f.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-01 14:15:34 +02:00
Alan Wizemann c055081ba3 perf(chat-ios): ingest picker items in parallel via TaskGroup
`ingestPickerItems` ran loadTransferable + encode sequentially per
selected image. PhotosPickerItem.loadTransferable is async and hops
off MainActor (nonisolated), but for 5+ iCloud-backed PHAssets the
sequential pipeline meant five round-trips back-to-back instead of
five concurrent ones.

Switched to `withTaskGroup` keyed by selection index so:
- Slot cap is computed once up front and items past the cap are
  dropped (previously we mid-loop-broke after the first overage).
- Each item's loadTransferable + ImageEncoder runs concurrently.
- Results land back in selection order via index sort, so the
  attachment chip row matches what the user picked.

Errors carry a Sendable `String` message rather than the raw `Error`,
which isn't Sendable under strict concurrency.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-01 14:12:41 +02:00
Alan Wizemann bd05e01d1c fix(webhooks-ios): surface parse failure in lastError
The post-load assignment was a true no-op:
`self.lastError = parsed.isEmpty && !result.isEmpty ? nil : nil` —
both ternary branches assigned `nil`. The intent (visible from the
condition shape) was to set an error message when the CLI returned
text but the parser produced no webhooks.

Now that branch sets a "Couldn't parse webhook list output" message
which the existing banner at line 33 renders. Normal flow (parse
succeeds, or empty output) still clears the error.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-01 14:11:25 +02:00
Alan Wizemann b66ed7e8d7 fix(kanban): show stderr-only in error banner, parse stdout-only as JSON
`KanbanViewModel.load` previously assigned the combined stdout+stderr
output of `runHermesCLI` into both the JSON-parse `data` and the
`stderr` slot of its result tuple. Two consequences:

- On non-zero exit, the error banner showed combined output (often
  stdout usage text concatenated with the actual error), reducing the
  signal-to-noise ratio when troubleshooting.
- On non-zero exit with mixed output, JSON decoding could fail because
  stderr text was prepended to the JSON body.

Added `HermesFileService.runHermesCLISplit` — a sibling of `runHermesCLI`
that returns `(exitCode, stdout, stderr)` separately, leaning on the
already-separated `stdoutString` / `stderrString` from the transport
layer. KanbanViewModel now uses it: stdout is the JSON parse target,
stderr is the error-banner source. Existing `runHermesCLI` callers are
untouched.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-01 13:29:16 +02:00
Alan Wizemann 46cec816ec fix(cron): allow clearing an existing workdir on edit
`updateJob` only emitted `--workdir <path>` when the value was non-empty,
so once a workdir was set on a job, the user had no way to remove it
through Scarf — clearing the TextField and saving was a silent no-op.

Hermes' `cron edit --workdir` argparse documents passing an empty string
as the explicit clear gesture (mirroring the existing `--script` shape,
which already passes empty through here). Drop the `!isEmpty` predicate
so a non-nil value — including "" — reaches the CLI.

The previous capability gate keeps this safe on pre-v0.12 hosts: CronView
passes `workdir: nil` there, so the flag is omitted and v0.11 argparse
is never asked about an unknown arg.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-01 13:27:49 +02:00
Alan Wizemann 681fa40c3c fix(skills): use ScarfFont token for OFF pill badge
The disabled-skill row's "OFF" pill used `.font(.system(size: 9, weight:
.semibold))`, which the project CLAUDE.md flags as a code smell ("bypass
the type scale… is a code smell"). The design system documents
`scarfStyle(.captionUppercase)` as the canonical badge font; switching
to it picks up the matching tracking + uppercase casing as a bonus.

The pin glyph above (`Image(systemName: "pin.fill").font(.system(size:
9))`) is left as-is — that's intentional glyph sizing on an `Image`,
which the design rule explicitly excludes.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-01 13:27:07 +02:00
Alan Wizemann 15642d37cf fix(skills): parse equal-indent disabled list in skills config
`readDisabledSkillNames` broke out of the loop on `leading <= baseIndent`,
but PyYAML's default `yaml.dump` (what Hermes uses to write the disabled
list) emits list items at the SAME indent as the parent key:

    skills:
      disabled:
      - foo
      - bar

Here `disabled:` is at indent 2 and `- foo` is also at indent 2, so the
old check terminated before any item was appended — every disabled skill
written by Hermes would have appeared enabled in the UI.

Now the loop only breaks when the indent is strictly shallower than the
`disabled:` line, or when a same-indent line isn't a list item (sibling
key — that's still the end of the block). The deeper-indent layout still
parses correctly.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-01 13:23:01 +02:00
Alan Wizemann 33022aeb92 fix(settings): restore flush_memories aux row on pre-v0.12 hosts
Phase B removed the `flushMemories` field from `AuxiliarySettings`,
the `aux("flush_memories")` reader from the YAML parser, and the
"Flush Memories" row from `AuxiliaryTab.tasks` outright. But
`HermesCapabilities.hasFlushMemoriesAux` still claims (with inverse
semantics) that the row should stay visible on pre-v0.12 hosts where
the task is alive. Project CLAUDE.md documents the same contract.

Restored:
- `AuxiliarySettings.flushMemories: AuxiliaryModel` (and `.empty`).
- `aux("flush_memories")` in both YAML readers
  (`HermesConfig+YAML.swift` and the `HermesFileService` mirror).
- `AuxiliaryTab.tasks` appends the Flush Memories row when
  `hasFlushMemoriesAux` is true, mirroring how `curator` is appended
  on the v0.12+ branch.

On v0.12+ hosts the flag is `false` so the field stays `.empty` and
the row is hidden — no behaviour change for current users.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-01 13:22:41 +02:00
Alan Wizemann 4a2ef74b74 fix(cron): gate --workdir flag on hasCronWorkdir capability
`HermesCapabilities.hasCronWorkdir` was added but never consumed: the
editor sheet always rendered the Workdir TextField and the view model
unconditionally appended `--workdir <path>` whenever the field was
non-empty. On a pre-v0.12 host argparse rejects the unknown flag and
the entire `cron create`/`cron edit` call fails.

Two-layer gate:
- CronJobEditor takes a `supportsWorkdir` flag and hides the field on
  pre-v0.12 hosts.
- CronView reads `\.hermesCapabilities` and forces the workdir argument
  to "" / nil when the capability is absent, so an editing-an-existing-
  job path that hydrates `form.workdir` from a pre-existing value can't
  smuggle the flag through.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-01 13:21:35 +02:00
Alan Wizemann 11bb2bd0c3 fix(chat): detach NSOpenPanel image read off MainActor
`presentImagePicker()` ran `Data(contentsOf: url)` synchronously on
MainActor inside the URL loop before the detached `encode()`. A 24 MP
HEIC at 8-15 MB stalled the chat composer per file. The drag/drop and
paste paths already read off-main via `loadObject`/`loadDataRepresentation`
callbacks; this brings the open-panel branch in line by capturing the
URLs into a `Task.detached` and reading bytes there.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-01 13:20:50 +02:00
Alan Wizemann 3d85b91392 docs(hermes-v12): release notes + CLAUDE.md polish (Phase I)
Adds releases/v2.6.0/RELEASE_NOTES.md covering every Phase A-H surface
(Curator, multimodal image input, 5 new providers, Skills v0.12,
Settings deltas, Cron workdir, Teams + Yuanbao, read-only Kanban, iOS
read-only Webhooks/Plugins/Profiles, version banner, internal
capability detector). Drops a paragraph at the top noting Hermes
v0.11 hosts continue to work — every new surface is gated on
HermesCapabilities so v2.6 against v0.11 looks identical to v2.5.2
against v0.11.

Polishes CLAUDE.md inaccuracies introduced in Phase A's first pass:

- ACP image wire shape: corrected to {"type":"image","data":...,"mimeType":...}
  (matches acp.schema.ImageContentBlock); previous Anthropic-style
  source: {type: base64, ...} sketch was wrong.
- Cron --context-from: clarified that Hermes hasn't exposed it as a
  CLI flag yet (read-only via HermesCronJob.contextFrom), only
  --workdir is writable.
- hermes memory setup: noted that the interactive verb stays in
  Terminal (no in-app shellout); Settings → Memory just exposes the
  provider picker.
- Skills surface: more precise about which CLI verbs back the Mac UI
  affordances and why the disable-toggle is deferred to v2.7.

215 ScarfCore tests green; both Mac and iOS schemes build clean. Wiki
update + the actual release.sh ship are deferred to the user's
typical release-prep flow (the wiki repo is a separate worktree
that needs scripts/wiki.sh pull/commit/push, and release.sh expects
a clean working tree pointed at main).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-01 13:01:43 +02:00
Alan Wizemann 799332fbcd feat(hermes-v12): iOS catch-up — Webhooks/Plugins/Profiles read-only + version banner (Phase H)
Closes the iOS read-only inspection gap on three CLI-driven Hermes
surfaces and adds a Hermes-version banner so mobile users on remote
v0.11 hosts see the upgrade nudge inline.

Components:

- Scarf iOS/Components/HermesVersionBanner.swift — yellow banner shown
  on the Dashboard when the active server's HermesCapabilities returns
  detected==true && hasCurator==false. One-tap session dismiss; comes
  back on next app open. Lists the v0.12 capabilities the user is
  missing out on (curator, multimodal, new providers).

- Scarf iOS/Webhooks/WebhooksView.swift — read-only list rendered from
  `hermes webhook list`. Tolerant block parser mirrors the Mac
  WebhooksViewModel shape so future drift fixes in one canonical place
  if/when promoted into ScarfCore. Detects the "platform not enabled"
  state and shows a setup-required pane instead of synthesizing rows
  from instructional text.

- Scarf iOS/Plugins/PluginsView.swift — filesystem-first scan over
  `~/.hermes/plugins/<name>/` with plugin.json / plugin.yaml manifest
  reads (mirrors the Mac VM). Enabled/disabled badge, version, source.
  Uses HermesYAML.parseNestedYAML / stripYAMLQuotes from ScarfCore
  (already public).

- Scarf iOS/Profiles/ProfilesView.swift — `hermes profile list` text
  parser with active-profile highlighting from
  `~/.hermes/active_profile`. Defensively handles both Rich box-drawn
  table output and plain-text fallback.

ScarfGoTabRoot's System tab gains an "Inspect" section with the three
new NavigationLinks. None are capability-gated — the underlying
list verbs exist on both v0.11 and v0.12, so the read views work
against either Hermes version without surprises.

Tests: 215 ScarfCore tests pass; both Mac and iOS schemes build clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-01 12:58:28 +02:00
Alan Wizemann 7a833b6c5a feat(hermes-v12): Cron workdir + Microsoft Teams + Yuanbao + read-only Kanban (Phase G)
Mac-only Phase G surfaces. Three additions:

Cron — `--workdir` flag (v0.12+):

- HermesCronJob carries `workdir: String?` and `contextFrom: [String]?`
  fields (the latter is read-only from CLI today; YAML-only chaining).
- FormState.workdir; CronJobEditor adds an absolute-path field;
  CronViewModel.createJob/updateJob forward `--workdir` when set,
  omit it when blank so v0.11 hosts (which don't know the flag) keep
  working unchanged.

Platforms — Microsoft Teams + Yuanbao (v0.12+):

- KnownPlatforms gains the two new platform identifiers + icons.
- PlatformsView adds inline read-only setup panels for each since the
  full setup flow lives outside Scarf (OAuth dance for Yuanbao, plugin
  install for Teams). Both panels surface the type, the recommended
  setup command, and the current configured/connected status the
  existing connectivity probe already understands.

Kanban — read-only list (v0.12+):

- HermesKanbanTask Sendable Codable model mirroring
  `_task_to_dict` in hermes_cli/kanban.py.
- KanbanViewModel polls `hermes kanban list --json` every 5s while the
  view is foregrounded; status filter dropdown maps to `--status`.
  Empty list and "no matching tasks" text outputs both render the
  empty state cleanly.
- KanbanView: page header + status badges + meta chips
  (id/assignee/workspace/skills) per row. No create/claim/dispatch UI
  — multi-profile collaboration was reverted upstream while the
  design is reworked, so v2.6 ships read-only and defers the editor
  to v2.7+.
- AppCoordinator.SidebarSection.kanban + ContentView routing.
  SidebarView's capability-aware `sections` filters out the row when
  `HermesCapabilities.hasKanban` is false.

Tests: 215 ScarfCore tests pass; both Mac and iOS schemes build clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-01 12:54:38 +02:00
Alan Wizemann 6954f0276a feat(hermes-v12): Settings deltas — cache TTL, redaction, runtime footer, Piper, Vercel (Phase F)
Surfaces the v0.12 config knobs that landed without their own dedicated
UI elsewhere:

- prompt_caching.cache_ttl picker (5m default, 1h opt-in) — reduces
  cache writes on long agent loops with stable system prompts.
- redaction.enabled toggle — Hermes flipped this off by default in
  v0.12 because the substitution corrupted patches; security-sensitive
  users can flip it back on here.
- agent.runtime_metadata_footer toggle — opt-in compact footer on each
  final reply (provider/model/cost/turn count).
- TTS provider list gains "piper" — native local TTS engine new in
  v0.12.
- Terminal backend list gains "vercel" — Vercel Sandbox backend for
  execute_code/terminal added in v0.12.

The new "Caching & Redaction" section in AdvancedTab is gated on
HermesCapabilities.hasPromptCacheTTL — pre-v0.12 hosts don't see
toggles that would write keys Hermes ignores. The Piper + Vercel
options ride along unconditionally because Hermes silently accepts
unknown values and falls back to safe defaults.

Model + parser:

- HermesConfig grows three optional scalar fields (cacheTTL: String,
  redactionEnabled: Bool, runtimeMetadataFooter: Bool). All three
  have init defaults so existing call sites — including
  HermesConfig.empty — keep compiling.
- Both YAML readers (HermesFileService for Mac, HermesConfig+YAML for
  the package) now parse the new keys with v0.12-defaults.

Tests: 215 ScarfCore tests pass; both Mac and iOS schemes build clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-01 12:47:54 +02:00
Alan Wizemann ee3791a1b2 feat(hermes-v12): Skills v0.12 surface — URL install + reload + pin/disable badges (Phase E)
Hermes v0.12 added three skills surfaces Scarf can now reach:

- Direct-URL install: `hermes skills install <https://...>` lets users
  pull a one-off skill without going through a registry. Mac SkillsView
  grew an "Install from URL…" toolbar button (capability-gated on
  HermesCapabilities.hasSkillURLInstall) opening a sheet with the URL
  field plus optional --category / --name overrides.
- Reload: `hermes skills audit` rescans `~/.hermes/skills/` and refreshes
  the agent's view of available skills without restarting. Wired to a
  "Reload" toolbar button next to the install button on Mac.
- Enabled state: skills.disabled in config.yaml is now read at scan time
  (SkillsViewModel.readDisabledSkillNames). Disabled skills render
  strikethrough + an "OFF" pill on Mac and iOS rows so users see what
  Hermes won't load. iOS detail view explains the state in plain text.
- Curator pin badge: pinned-skill names from
  `~/.hermes/skills/.curator_state` (SkillsViewModel.readPinnedSkillNames)
  surface as a pin glyph on each row. Mac sidebar + iOS list both show
  it; iOS detail view explains "pinned by curator — won't auto-archive."

Model + scanner:

- HermesSkill gains `enabled: Bool` (default true) and `pinned: Bool`
  (default false). Both default to backwards-compatible values so
  unmodified call sites keep compiling.
- SkillsScanner.scan now takes optional `disabledNames` and
  `pinnedNames` sets and applies them per skill at scan time.
- SkillsViewModel.load auto-fetches both sets internally so Mac/iOS
  callers don't have to plumb curator state manually; an opt-in
  `pinnedNames` override is available for the Curator screen which
  has a fresher snapshot in hand.

Tests: 215 ScarfCore tests pass; both Mac and iOS schemes build clean.

Note: the disable-toggle path (writing the array back into
config.yaml) is deferred to v2.7 — Hermes ships
`hermes skills config` as an interactive verb only, and we'd rather
read accurately than risk clobbering the user's list with a
half-tested write path.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-01 12:44:15 +02:00
Alan Wizemann 686fb37630 feat(hermes-v12): Curator feature module on Mac + iOS (Phase D)
Hermes v0.12 ships an autonomous Curator that prunes / consolidates
agent-created skills on a 7-day cycle. This phase brings that surface
into Scarf so users can see status, trigger runs, pin protected skills,
and restore archived ones.

Pipeline:

- HermesCuratorStatus + HermesCuratorSkillRow: Sendable value types for
  parsed status + per-skill leaderboard rows.
- HermesCuratorStatusParser: pure text parser for `hermes curator status`
  stdout (no `--json` flag exists upstream). Tolerates Hermes's
  whitespace-padded leaderboard layout (`activity=  0` with N spaces
  between `=` and the value) by slicing between known key positions
  rather than splitting on whitespace. State-file JSON overrides
  text-parsed values for last_run_at / last_run_summary /
  last_report_path because the file carries full ISO timestamps the
  text output may have rounded.
- CuratorViewModel: @Observable @MainActor, drives the CLI verbs
  (status / run / pause / resume / pin / unpin / restore) via
  transport.runProcess so it works equally over local and Citadel SSH.
- HermesPathSet: adds curatorLogsDir + curatorStateFile (the latter
  is `.curator_state` with no extension despite holding JSON).

Mac:

- Features/Curator/Views/CuratorView.swift — page-header + status card
  + skill counts + pinned chips + 3 leaderboard tables (least recent,
  most active, least active) with inline pin toggles and a
  per-skill counter chip row. "Run Now" button + a kebab menu for
  Pause/Resume + Restore Archived.
- Features/Curator/Views/CuratorRestoreSheet.swift — name-entry sheet
  for `hermes curator restore <skill>`. Free-form text field; Hermes
  doesn't ship a `curator list-archived` yet so we don't synthesize a
  picker.
- Sidebar: AppCoordinator + SidebarView gain a `.curator` case under
  Interact (between Memory and Skills); the row is filtered out by
  SidebarView's capability-aware `sections` computed property when
  `HermesCapabilities.hasCurator` is false. ContentView routes
  `.curator` to CuratorView. Pre-v0.12 hosts see the v0.11 sidebar
  unchanged.

iOS:

- Scarf iOS/Curator/CuratorView.swift — read-mostly List with the same
  status / skill counts / pinned / leaderboards + inline pin toggles.
  Run Now / Pause / Resume actions in the section footer.
- ScarfGoTabRoot's System tab gains a Curator NavigationLink under
  Features, gated on `hasCurator`. Uses a stable
  `systemTabContextID` so the SSH transport pool reuses the cached
  Citadel connection keyed by that id.

Tests: 6 new parser tests (215 total, all green). Locks the empty-state
output captured from a real v0.12.0 install + paused-state + state-file
override + multi-word-name-row parsing. Both Mac and iOS schemes build
clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-01 12:37:48 +02:00
Alan Wizemann 1354568992 feat(hermes-v12): ACP multimodal image input on Mac + iOS (Phase C)
Hermes v0.12 advertises `prompt_capabilities.image = true` and accepts
image content blocks in `session/prompt`. This wires a producer flow on
both targets so users can attach images alongside text and have them
routed to the vision-capable model automatically.

Pipeline:

- ChatImageAttachment: Sendable value type holding base64 payload +
  thumbnail, MIME type, source filename, and approximate byte count.
- ImageEncoder: detached-only Sendable service that downsamples to
  Anthropic's 1568px long-edge cap, JPEG-encodes at q=0.85, and
  produces a small inline thumbnail for composer chips. Cross-platform
  (NSImage on Mac, UIImage on iOS, JPEG-passthrough on Linux/CI).
- ACPClient.sendPrompt(sessionId:text:images:) overload emits a content
  array `[{type: "text"...}, {type: "image", data, mimeType}]` matching
  the wire shape in hermes-agent/acp_adapter/server.py. The
  zero-arg-images convenience overload preserves the v0.11 wire shape
  for any unmodified callers.

Mac UI:

- RichChatInputBar grew an `attachments: [ChatImageAttachment]` state
  array, a paperclip button (NSOpenPanel multi-pick), drag-drop and
  paste handlers, and a horizontal preview chip strip. The "send"
  callback's signature is `(String, [ChatImageAttachment]) -> Void`
  threaded through RichChatView -> ChatTranscriptPane -> ChatView ->
  ChatViewModel.sendText(text, images:). Image-only prompts are
  permitted ("describe this") once at least one attachment is queued.

iOS UI:

- ChatView's composer adopts a paperclip + PhotosPicker flow with the
  same chip strip and 5-attachment cap. Attachments live on
  ChatController so they survive across PhotosPicker presentations.
  loadTransferable(type: Data.self) feeds raw bytes into the same
  ImageEncoder; encode work runs detached so MainActor stays
  responsive on cellular.

Capability gating:

- Both composers hide the entire attachment surface when
  HermesCapabilities.hasACPImagePrompts is false (pre-v0.12 hosts).
  No paperclip button, no drop target, no paste accept — the input bar
  is byte-for-byte the v0.11 surface against an older Hermes.

Tests: 209 ScarfCore tests pass; both Mac and iOS schemes build clean.
The encoder's pixel work is hard to unit-test at the package level
(no NSImage/UIImage in plain Swift CI) — manual end-to-end testing
is the verification path here.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-01 12:28:41 +02:00
Alan Wizemann da721fa276 feat(hermes-v12): provider catalog + auxiliary swap (Phase B)
Adds the five v0.12 inference providers to ModelCatalogService.overlayOnlyProviders
so the model picker reaches them. IDs match HERMES_OVERLAYS verbatim:

- gmi → GMI Cloud (api_key)
- azure-foundry → Azure AI Foundry (api_key)
- lmstudio → LM Studio (api_key, promoted from custom-endpoint alias)
- minimax-oauth → MiniMax (OAuth, oauth_external)
- tencent-tokenhub → Tencent TokenHub (api_key)

Auxiliary tasks: drop the `flush_memories` row (Hermes removed it
entirely in v0.12) and add `auxiliary.curator` so users can configure
the model the autonomous curator's review fork uses. The Curator row is
gated on HermesCapabilities.hasCuratorAux, so v0.11 hosts don't see a
control that writes a key Hermes ignores. AuxiliarySettings, the YAML
parser, and HealthViewModel's Tool Gateway breakdown are all updated.

Side fixes:

- CredentialPoolsGatingTests was missing `import ScarfCore` after
  ModelCatalogService moved to the package (broke the test target's
  compile against pure-Mac scarf).
- Promoted `ModelCatalogService.overlayOnlyProviders` to public so the
  new `v012OverlayProvidersCarryCorrectAuthTypes` lock-in test can
  reach it.

Tests: 14 ToolGateway tests pass; 209 ScarfCore tests pass; both Mac
and iOS schemes build clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-01 12:16:37 +02:00
Alan Wizemann a90a29add8 feat(hermes-v12): version-aware capability detection (Phase A)
Introduces `HermesCapabilities` (parsed from `hermes --version`) and a
per-server `HermesCapabilitiesStore` injected into Mac `ContextBoundRoot`
and iOS `ScarfGoTabRoot` via `.environment(_:)` and `.hermesCapabilities`.
Subsequent v0.12-targeted UI (Curator, Kanban, ACP image input,
auxiliary.curator, prompt cache TTL, etc.) can branch on these flags so
older Hermes installs degrade silently instead of throwing on unknown CLI
subcommands.

Adds `curatorReportJSON` / `curatorReportMD` paths to `HermesPathSet`.

Bumps the Hermes version target in CLAUDE.md from v2026.4.23 (v0.11.0) to
v2026.4.30 (v0.12.0) and lists the v0.12 surfaces Scarf will consume.

Side fixes:

- `M5FeatureVMTests.ScriptedTransport` was missing
  `cachedSnapshotPath` after that property was added in 7b864d7;
  added `URL? { nil }` stub.
- `M0dViewModelsTests` referenced `.degraded(reason:)` after the case
  gained `hint` + `cause`; updated.
- `RemoteBackupService.zipDirectory` and `RemoteRestoreService.unzipArchive`
  used `Foundation.Process` unconditionally, breaking the iOS build
  (Process is unavailable on iOS). Wrapped in `#if !os(iOS)` with iOS
  stubs that throw — the backup/restore flow is Mac-only by design.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-01 12:10:06 +02:00
Alan Wizemann 421e6030df fix(dashboard): shadow Hermes-home consolidation actually clears the warning
The "Project-local Hermes home shadowing global setup" banner has a
"Copy fix command" button that produced a one-liner the user could
paste on the remote. The old command only `cp`'d the project's
`auth.json` into the global `~/.hermes/`; it never touched the
project-local `.hermes/` directory. Hermes' CLI binds to the
*closest* `.hermes/` as `$HERMES_HOME`, so the directory still being
there meant it still shadowed — the detector's
`fileExists(<project>/.hermes)` correctly kept returning true and
the warning didn't go away after the user "fixed" it. They got
stuck.

Fix: rename the project-local `.hermes/` to
`.hermes.scarf-bak.<UTC-stamp>/` after the auth copy. Hermes scans
for a directory literally named `.hermes`, so the rename is enough
to stop binding without losing user data — `state.db`, sessions,
skills all survive untouched in the renamed folder. The user can
inspect / delete the `.bak` later when confident. `mv` over
`rm -rf` because a project's shadow can hold uncommitted session
history; deletion would be unrecoverable, the rename is reversible.

Also removes the `if shadow.hasAuthJSON` gate around the "Copy fix
command" button — a state-only shadow (no creds, just `state.db`)
still binds as `$HERMES_HOME` and needs the same rename to clear
the warning. The button now always shows; the help-tooltip text
branches on `hasAuthJSON` to describe what the command will do.

Help-text now spells out the rename so the user knows where their
data went before they paste anything.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-30 17:51:33 +02:00
Alan Wizemann 7b864d77d5 feat(servers): backup + restore for any Scarf server
Adds an end-to-end "back up this server's full Hermes state" flow
with a verifiable archive format and a matching restore that pushes
it onto a fresh droplet. Tested against a 570 MB local Hermes home
+ 5 projects, then iterated against a real DigitalOcean droplet.

Architecture
- `.scarfbackup` is a ZIP containing `manifest.json` (schema v1,
  source server + hermes version + per-tarball SHA-256), one
  `hermes.tar.gz` (gzipped tar of `~/.hermes/`), and one
  `projects/<id>.tar.gz` per registered project. Streams via
  `tar -czf - …` over SSH; never buffers a full archive in memory.
- New `streamRawBytes(executable:args:)` on `ServerTransport`
  (Local + SSH impls) yields binary `Data` chunks. `streamLines`
  splits on `\n` and would corrupt tar output — needed a
  binary-safe sibling.
- `RemoteBackupService` runs preflight (resolves $HOME, probes
  hermes version, enumerates projects via the existing
  `ProjectDashboardService`, sizes each via `du -sb`, checks for
  `sqlite3`), optionally runs `PRAGMA wal_checkpoint(TRUNCATE)`
  to quiesce state.db, streams each tarball with incremental
  SHA-256, then ZIP-bundles via `/usr/bin/zip`. Atomic
  temp-then-rename so a partial archive never appears at the
  user-chosen destination.
- `RemoteRestoreService` unzips into a temp dir, validates the
  manifest's `kind` magic + `schemaVersion`, hash-verifies every
  inner tarball BEFORE pushing any bytes to the target, then
  streams each tarball into `tar -xzf - -C …` over SSH stdin.
  Post-restore: rewrites `~/.hermes/scarf/projects.json` with
  source→target path mappings via a small `python3 -c` script,
  and pauses every cron job (`enabled: false`) so restored jobs
  don't surprise-fire on a fresh droplet.

Defaults + safety
- Excluded from the backup unless explicitly opted in:
  `auth.json` (provider creds), `mcp-tokens/` (per-host OAuth),
  `logs/`. Always excluded: `state.db-{wal,shm}`,
  `gateway_state.json`, and standard project junk
  (`node_modules`, `.venv`, `.git/objects`, `__pycache__`,
  `.next`, `dist`).
- Manifest records `options.includeAuth/includeMcpTokens/
  includeLogs/checkpointedWAL` honestly so restore can warn
  the user about what they'll need to re-establish manually.
- All paths are tilde-expanded against the resolved remote
  `$HOME` before being passed to `tar`/`sqlite3`.
  `tar -C '~/projects'` would otherwise fail with
  "No such file or directory" because `shellQuote` wraps the
  path in single quotes and tar doesn't expand tildes itself.

UI
- Per-row ellipsis menu on `ManageServersView` consolidates
  Back Up… / Restore from Backup… / Diagnostics… / Remove…
  Keeps the row visually clean as actions grow. Local server
  gets Back Up + Restore (no Remove or Diagnostics).
- `BackupServerSheet` walks loading → ready (size + project
  list + auth/logs toggles) → running (byte-counter progress
  per stage) → done (Show in Finder) | failed (Try again).
- `RestoreServerSheet` walks awaitingFile → inspecting →
  ready (source-vs-target preview, projects-root chooser,
  cron-pause toggle, "auth was excluded" notes) → running →
  done | failed.
- Both view models use a `WeakBox` two-step capture pattern so
  the @Sendable progress callback hops back into MainActor
  without the Swift 6 var-self warning on nested closures.

Cleanup folded in
- Drops two no-op `await`s on sync `startReaders()` in
  `ProcessACPChannel` (warning surfaced after the Phase 1 ACP
  changes; cleanest to fix in the same Transport-layer touch).

Verified
- Local round-trip via a Swift CLI harness:
  preflight → backup → unzip listing matches manifest →
  on-disk SHA-256 matches manifest claim for every tarball.
- Real DigitalOcean droplet: backup completes after the
  tilde-expansion fix; restore preserves projects + sessions.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-30 17:51:10 +02:00
Alan Wizemann 11946aad67 feat(remote): legible SSH/ACP failures + servers.json export/import
A vanished or misconfigured remote surfaced as an opaque 30s
"ACP request 'initialize' timed out" because the channel's EOF
fired with no exit code or stderr context, and `sh -c` on the
remote couldn't find pipx-installed `hermes` on PATH. This makes
remote failure modes immediately legible and adds a recovery path
for the server registry itself.

- `ACPClientError.processTerminated` now carries exit code + stderr
  tail; `performDisconnectCleanup` reads them from the channel
  before failing pending requests, and `ACPErrorHint.classify`
  recognises Connection refused, Operation timed out, Permission
  denied (publickey), Host key verification failed, Could not
  resolve hostname, and exit 127 / command not found.
- `ProcessACPChannel.terminationHandler` closes the stdout read
  end the moment the OS reaps the child so disconnect cleanup
  fires within ~1s instead of waiting on `availableData`.
  `lastExitCode` reads `Process.terminationStatus` directly to
  avoid an actor-handshake race.
- `SSHTransport.makeProcess` / `streamLines` switch from `sh -c`
  to `bash -lc` so non-interactive SSH shells source the user's
  profile and pick up pipx (`~/.local/bin`), Linuxbrew, asdf,
  and conda PATH entries.
- New `ServerRegistry.exportFile()` / `importEntries(from:)` with
  a `.scarfservers` JSON envelope (schema v1, dedupe by UUID,
  default-server flag preserved). UI in `ManageServersView`'s
  header menu surfaces Export… / Import… via NSSave/OpenPanel.
  No secrets travel — `identityFile` is a path string and SSH
  keys live in `~/.ssh/`, not in `servers.json`.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-30 16:04:14 +02:00
Alan Wizemann 4140983866 feat(site): marketing landing page for Mac + ScarfGo
Replace the gh-pages root placeholder with a real landing page that
sells both apps. Sources live at site/landing/ and publish through a
new scripts/site.sh that mirrors scripts/catalog.sh and scripts/wiki.sh
(check / build / preview / serve / publish, two-pass secret-scan, only
touches root files + assets/ on gh-pages so appcast.xml and templates/
stay disjoint).

Page is rust-palette tokens mapped from ScarfDesign, semantic HTML,
SEO + AEO infra (OpenGraph, Twitter cards, JSON-LD SoftwareApplication
+ MobileApplication + FAQPage, llms.txt, sitemap, manifest), 12-entry
FAQ, light/dark via prefers-color-scheme + manual toggle that swaps
both site chrome and screenshot variants. tools/og-image.html renders
the 1200x630 OG / 1200x600 Twitter cards via headless Chromium.

Real captures from the live Mac app (9 surfaces x light + dark) +
existing ScarfGo screenshots round out the imagery.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-30 14:41:37 +02:00
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
Alan Wizemann 64b7d3beaf feat(config): manifest schemaVersion 2 + installer/uninstaller/exporter wiring
Extends the template format to schemaVersion 2 (schema-less bundles at
v1 keep working unchanged) and threads TemplateConfigSchema through
inspect → buildPlan → install → uninstall → export end-to-end.

Model additions (ProjectTemplate.swift):
- ProjectTemplateManifest gains optional `config: TemplateConfigSchema?`.
- TemplateContents gains optional `config: Int?` claim (field count)
  cross-checked against the schema by `verifyClaims` so a manifest
  can't hide its configuration from the preview sheet.
- TemplateInstallPlan gains `configSchema`, `configValues` (populated
  by the VM just before install()), and `manifestCachePath`. New
  fields also feed totalWriteCount so the preview footer is honest.
- TemplateLock gains optional `configKeychainItems: [String]?` and
  `configFields: [String]?`. Optional so pre-2.3 lock files still
  uninstall cleanly — Codable's default decoding skips missing fields.

Service changes:
- ProjectTemplateService.inspect now accepts schemaVersion 1 or 2.
  When the manifest declares a config block, the service validates it
  immediately via ProjectConfigService.validateSchema and fails the
  install with a manifestParseFailed before the preview sheet ever
  renders. verifyClaims cross-checks contents.config count against
  the actual schema length.
- ProjectTemplateService.buildPlan populates configSchema and queues
  two new entries in projectFiles: .scarf/config.json (synthesized by
  the installer from configValues at write time, using an empty
  sourceRelativePath sentinel) and .scarf/manifest.json (copy of the
  bundle's template.json so the post-install Configuration editor can
  render offline).
- ProjectTemplateInstaller.createProjectFiles now special-cases the
  empty-source sentinel: for .scarf/config.json, it encodes
  plan.configValues into a ProjectConfigFile on the fly. Secrets in
  that file are keychain:// refs — the raw bytes were routed into the
  Keychain by the VM before install() was called.
- ProjectTemplateInstaller.writeLockFile records every keychainRef
  URI from configValues in lock.configKeychainItems and the schema
  field keys in lock.configFields.
- ProjectTemplateUninstaller.uninstall adds a new step 4a: iterate
  lock.configKeychainItems, parse each URI into a TemplateKeychainRef,
  SecItemDelete each one. Absent items are no-ops (the Keychain
  wrapper already handles errSecItemNotFound silently).
- ProjectTemplateExporter now reads the source project's
  .scarf/manifest.json (if present) and forwards the SCHEMA through
  to the exported bundle while zeroing values. schemaVersion bumps to
  2 only when a schema is carried; schema-less exports stay at 1 for
  byte-compatibility with v2.2 catalog validators.

Tests (ProjectTemplateTests.swift): 5 new tests in 1 new suite.
- inspectAcceptsSchemaV2Bundle: v2 manifest unpacks cleanly.
- buildPlanSurfacesSchemaAndQueuesConfigFiles: plan carries the
  schema; projectFiles contains both config.json + manifest.json.
- verifyClaimsRejectsConfigCountMismatch: a manifest lying about
  contents.config vs. schema.fields.count is refused at inspect.
- installWritesConfigJsonAndManifestCache: install round-trip writes
  config.json (with non-secret values inline + secret as keychainRef),
  manifest.json cache, and lock with configKeychainItems +
  configFields. Real Keychain is exercised; the test cleans up the
  single item it creates.
- uninstallDeletesKeychainItemsViaLock: install + then uninstall,
  verify the Keychain entry is gone via SecItemCopyMatching.

sampleManifest test helper gains `configFieldCount` and `configSchema`
params so tests that want schemaful bundles don't need to rebuild the
whole manifest record. schemaVersion auto-bumps to 2 when a schema is
present so the fixture mirrors real bundle shape.

50/50 tests in 13 suites pass; pre-existing 45 from v2.2 unchanged.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 01:27:26 +02:00
Alan Wizemann 385c3a2e4d feat(config): template-config models + Keychain wrapper + ProjectConfigService
Groundwork for v2.3 template configuration. No user-visible behaviour
yet — this commit adds the data structures, storage layer, and
validation rules that the installer/uninstaller/UI will integrate with
in the next two commits.

Models (Core/Models/TemplateConfig.swift):
- TemplateConfigSchema + TemplateConfigField for the author-declared
  manifest.config block. 7 field types: string, text, number, bool,
  enum, list, secret. Type-specific constraints (pattern, min/max,
  min/maxLength, min/maxItems, enum options) are all optional and
  the validator enforces only those applicable to the field's type.
- TemplateModelRecommendation for the author's model-of-choice hint
  (preferred + rationale + alternatives). Purely advisory — Scarf
  never auto-switches the active model.
- TemplateConfigValue enum: string / number / bool / list / keychainRef.
  Custom Codable preserves keychain:// refs on round-trip — a round
  through save/load never demotes a secret ref to plaintext.
- ProjectConfigFile is the on-disk shape at <project>/.scarf/config.json.
- TemplateKeychainRef: derives (service, account) from templateSlug +
  fieldKey + project-path hash. The 32-bit FNV-1a suffix prevents two
  installs of the same template in different dirs from colliding in
  the login Keychain. uri <-> parse round-trips losslessly.

Keychain layer (Core/Services/ProjectConfigKeychain.swift):
- Thin wrapper over kSecClassGenericPassword. set() tries update-first
  then add-if-missing so we don't trip "already exists" on a race.
- kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly: no iCloud sync,
  but cron triggers can still read after the user's first unlock.
- testServiceSuffix lets unit tests route items under a distinct
  service prefix so nothing leaks into the user's real Keychain.

Service layer (Core/Services/ProjectConfigService.swift):
- load/save for <project>/.scarf/config.json through the ServerContext
  transport (so remote-ready for when installer goes remote).
- cacheManifest/loadCachedManifest: the installer copies template.json
  into <project>/.scarf/manifest.json so the post-install "Configuration"
  button can render the form offline.
- resolveSecret / storeSecret / deleteSecrets: the three Keychain paths
  any caller needs. Non-secret values never pass through these.
- validateSchema: author-facing invariants (unique keys, known types,
  enum opts present/unique, no defaults on secrets, non-empty model
  preferred). Called by ProjectTemplateService during inspect.
- validateValues: user-facing invariants (required, pattern, numeric
  range, list bounds, enum membership). Returns one error per problem
  so the UI can surface them inline with the offending field.

Tests (scarfTests/TemplateConfigTests.swift): 23 tests in 5 suites.
- Schema validation: happy path + every rejection rule.
- Value validation: required, pattern, numeric range, list bounds,
  enum membership, secret-via-keychain-ref acceptance.
- Keychain ref: uri round-trip, parse rejection of malformed input,
  path-hash differs across project dirs but is stable for same path.
- ProjectConfigFile round-trips non-secret values cleanly AND preserves
  keychain:// refs (the bug that would silently demote secrets to
  plaintext if the Codable were wrong).
- Real Keychain integration: store+resolve+delete, set overwrites,
  delete of missing item is a no-op, bulk delete clears all. Tests
  use unique testServiceSuffix per run so no cross-contamination.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 00:56:34 +02:00
Alan Wizemann e76fbf9937 chore: audit follow-ups from plan review
Four small fixes surfaced by a side-by-side plan-vs-shipped pass:

- README.md: adds the Template Catalog section the plan called out —
  links to the live site URL, the install flows (web / file / Finder),
  and templates/CONTRIBUTING.md for authors. Placed right before the
  existing Contributing section, with a catalog-specific cross-link at
  the end of that section too.
- CLAUDE.md: adds the Template Catalog section so future agent sessions
  know the regenerator pipeline exists, how it relates to release.sh +
  wiki.sh, and what the schema-sync rule is when DashboardWidget or
  ProjectTemplateManifest change.
- scarf/scarfTests/ProjectTemplateTests.swift: fixes the stale
  ProjectTemplateExampleTemplateTests docstring still referencing
  `examples/templates/` (the example moved to `templates/awizemann/`
  in 70f7cea).
- .github/workflows/validate-template-pr.yml: untangles the self-
  contradictory Python-version comment. The validator is 3.9+
  compatible; CI uses 3.11 for faster runner caching. Same stdlib
  surface, same code paths — just clearer about why.

All tests still green: 22 Swift tests in 7 suites, 16 Python tests,
catalog check passes on the site-status-checker example.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 00:35:46 +02:00
Alan Wizemann c9b8da9ec5 feat(ci): validate template submissions on PR + tailored checklist
Adds the CI gate that runs on every PR touching templates/, the catalog
validator, or its tests. The Action:
- runs tools/test_build_catalog.py (catches drift between validator +
  its own test suite on the same PR that introduces the drift)
- runs tools/build-catalog.py --check (validates every shipped .scarftemplate
  against the same invariants ProjectTemplateService.verifyClaims enforces
  at install time)
- posts a PR comment with the last 3 KB of the validator log on failure,
  so contributors see the specific mismatch without hunting through the
  Actions UI

.github/PULL_REQUEST_TEMPLATE/template-submission.md is the author-facing
checklist that mirrors templates/CONTRIBUTING.md. Opt-in via the
?template=template-submission.md compare URL (documented in the
contribution guide). CONTRIBUTING.md now links both the PR template and
the workflow file so authors know what to expect.

Phase 4 closes the community loop — from this commit on, a stranger can
fork the repo, follow templates/CONTRIBUTING.md, push a PR, and get
deterministic green/red feedback before a maintainer ever looks at it.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 00:35:46 +02:00
Alan Wizemann 6175bee27d feat(site): dogfood the Scarf dashboard format as the catalog website
Adds site/ with vanilla HTML + CSS + ~300 lines of JavaScript that
renders ProjectDashboard JSON directly in the browser. Each template's
detail page shows a live preview of the exact dashboard the user will
get post-install — the catalog IS the dogfood.

site/widgets.js mirrors the Swift widget dispatcher:
- stat (big number + colored icon + optional subtitle)
- progress (0..1 bar)
- text with inline markdown subset (headings, bold/italic, inline code,
  code fences, bullet + numbered lists, links)
- table (plain HTML)
- list (with up/down/unknown status badges)
- chart (SVG line + bar — no Chart.js dependency)
- webview (sandboxed iframe)
- unknown (placeholder so the page doesn't silently omit widgets)

Plus the renderMarkdown helper used by the template detail page to
display the bundle's README.

site/index.html.tmpl + site/template.html.tmpl are substitution-only —
the Python regenerator swaps {{CARDS}}, {{COUNT}}, {{COUNT_PLURAL}},
{{NAME}}, {{DESC}}, {{VERSION}}, {{AUTHOR_HTML}}, {{TAGS_HTML}},
{{INSTALL_URL_ENCODED}}, {{SCARF_INSTALL_URL}}. The detail page fetches
dashboard.json + README.md at page load and hands them to widgets.js.
No client-side framework, no bundler, no npm.

site/styles.css: minimal CSS with scarf green accent, prefers-color-
scheme dark support, responsive at 680px. One file, ~280 lines.

build-catalog.py extended to copy dashboard.json + README.md out of each
bundle into its detail dir so widgets.js can fetch them without
reaching across directories (and so gh-pages doesn't need to serve zip
contents at request time).

Two new Python tests: end-to-end site rendering (both cards, install
URL wiring, static asset copy, per-template dashboard + README copy)
and the {{COUNT_PLURAL}} singular-vs-plural flip. 16/16 Python tests
green.

Smoke-tested locally with python3 -m http.server: every endpoint
(index, catalog.json, detail HTML, per-template dashboard.json + README,
widgets.js) returns 200. The .gh-pages-worktree/appcast.xml +
.gh-pages-worktree/index.html are untouched — the catalog is purely
additive under /templates/.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 00:35:46 +02:00
Alan Wizemann 11732baa3c feat(catalog): stdlib-only Python validator + regenerator for templates/
Adds the catalog pipeline without introducing any external dependencies.
tools/build-catalog.py walks templates/<author>/<name>/, validates every
shipped .scarftemplate against its manifest (same invariants Swift's
ProjectTemplateService.verifyClaims enforces at install time), and emits
templates/catalog.json for the frontend to read.

Validator invariants:
- Required bundle files: template.json, README.md, AGENTS.md, dashboard.json
- contents claim cross-checked against actual zip entries (instructions,
  skills, cron count, memory appendix)
- dashboard.json widget types restricted to the vocabulary the Swift
  renderer knows
- Manifest id author component must match the template directory
- 5 MB bundle-size cap on submissions (installer's own cap is 50 MB)
- High-confidence secret patterns (private keys, GitHub PATs, Slack tokens,
  AWS access keys, OpenAI/Anthropic keys) block the bundle
- staging/ source tree must match the built bundle byte-for-byte — catches
  the common failure mode of editing staging/ but forgetting to rebuild

scripts/catalog.sh wraps the Python script with check/build/preview/serve/
publish subcommands, mirroring the scripts/wiki.sh shape. publish adds a
second-pass hard-pattern secret scan on the rendered gh-pages output so
template prose can't leak credentials even if the Python scan missed them.

tools/test_build_catalog.py has 14 unit tests covering the main validator
paths (minimal-valid, missing-AGENTS, content-claim mismatch, author
mismatch, oversized bundle, unknown widget type, secret detection,
staging-drift detection, missing bundle, catalog.json shape, and a real-
bundle end-to-end check against templates/awizemann/site-status-checker).
Python 3.9 compatible (Xcode's bundled python3), so no runtime needs
installing.

templates/catalog.json committed as the first generated aggregate index;
maintainers regenerate on merge by running `./scripts/catalog.sh build`.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 00:35:46 +02:00
Alan Wizemann d8a0a89db2 feat(templates): promote examples/ to templates/<author>/<name>/ catalog layout
Set up the catalog directory structure this branch will fill with
community templates. The existing site-status-checker example moves
from examples/templates/ to templates/awizemann/site-status-checker/
(tracked by git as a rename so history is preserved). The examples/
directory is removed.

New top-level docs:
- templates/README.md — landing for folks browsing the catalog on
  github.com. Lists the current templates and points at the live site.
- templates/CONTRIBUTING.md — author-facing submission walkthrough.
  Requires AGENTS.md, pre-flight with tools/build-catalog.py --check
  (added in the next commit), one template per PR, don't edit
  catalog.json (maintainer regenerates it post-merge).

ProjectTemplateExampleTemplateTests.locateExample updated to search
templates/<author>/<name>/ instead of examples/templates/ — the test
still walks up from #filePath to find the repo root so it works in
both xcodebuild and Xcode IDE test runs.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 00:35:46 +02:00
Alan Wizemann 38c075d61d docs: ship site-status-checker example template + v2.2.0 release notes
First installable template demonstrating the format:
- Dashboard with stat widgets (up/down/last-checked) + configured-sites
  list + quick-start markdown.
- Cross-agent AGENTS.md with the full cron-prompt contract so any agent
  that reads agents.md (Claude Code, Cursor, Codex, Aider, Jules,
  Copilot, Zed, …) picks up the behavior on first run.
- Cron job (0 9 * * *) that ships paused with the [tmpl:…] tag, pinging
  a user-editable sites.txt and writing results to status-log.md.
- First-run bootstrap logic in AGENTS.md: if sites.txt doesn't exist
  yet the agent creates it with two placeholder URLs, then proceeds.

Plus examples/templates/README.md explaining the staging/ layout,
authoring conventions, and how to rebuild a bundle after editing. CI
validates the bundle via ProjectTemplateExampleTemplateTests so drift
between staging/ and the built .scarftemplate fails on every build.

v2.2.0 release notes cover the full feature surface including the
install preview sheet, scarf:// + file:// URL handling, skills
namespacing, cron-job tagging, memory-block markers, and the
lock-driven uninstall flow.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 00:35:46 +02:00
Alan Wizemann c800b93804 feat: project templates v1 (install + uninstall + export + URL handler)
Shareable `.scarftemplate` bundle format lets users package a project's
dashboard, cross-agent AGENTS.md, optional per-agent instruction shims,
optional namespaced skills, optional tagged cron jobs, and an optional
memory appendix into a single zip that anyone can install with one click.

Core:
- Bundle format + manifest schema v1 (template.json with contents claim
  cross-checked against zip entries to prevent hidden files).
- ProjectTemplateService inspects + validates + builds an install plan.
- ProjectTemplateInstaller executes plans with transport-routed I/O so
  the v1 local-only flow extends cleanly to remote ServerContexts later.
- ProjectTemplateExporter builds bundles from existing projects with
  user-selected skills + cron jobs.
- ProjectTemplateUninstaller reverses installs using template.lock.json.
  Only lock-tracked files are removed; user-added files are preserved.

UI:
- Templates menu in Projects toolbar: Install from File, Install from
  URL, Export as Template.
- Preview-and-confirm sheets for install, uninstall, and export with
  full diff of what will be written/removed before anything runs.
- Right-click context menu on project list + dashboard header button
  for uninstall (only shown when template.lock.json exists).

Deep link + file associations:
- scarf:// URL scheme registered; onOpenURL in scarfApp.swift routes
  scarf://install?url=https://... and file:// URLs for .scarftemplate
  files to the install sheet.
- Custom UTType com.scarf.template registered so Finder shows the file
  with a Scarf icon and double-click opens the install preview.
- Cold-launch race fix: .task picks up any URL staged on the router
  before the onChange observer was installed.

Safety:
- Never writes to config.yaml, auth.json, sessions, or credentials.
- Cron jobs ship paused with a [tmpl:<id>] name prefix.
- Skills install to a namespaced ~/.hermes/skills/templates/<slug>/ dir
  so they never collide with user-authored skills.
- Memory appendix is wrapped in scarf-template:<id>:begin/end markers
  for clean removal during uninstall.
- Download cap: 50 MB for URL-fetched templates, enforced on the actual
  on-disk file size after download so chunked transfers can't bypass it.

Tests: 22 tests in 7 suites cover manifest parsing, claim verification,
URL routing (scarf:// + file://), end-to-end install and uninstall
against a minimal bundle (projects registry is snapshotted + restored),
user-added file preservation, and exporter round-trip.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 00:35:46 +02:00
Alan Wizemann 7311320bfd Merge pull request #30 from awizemann/claude/issue-26-default-server
Let users pick the default server opened on launch (#26)
2026-04-22 22:52:33 +01:00
Alan Wizemann 4663697942 Merge pull request #29 from awizemann/claude/issue-26-sidebar-width
Persist sidebar width across launches (#26)
2026-04-22 22:44:57 +01:00
Claude 41635955b0 feat: let users pick the default server opened on launch (#26)
Repurposes the previously-unused ServerEntry.openOnLaunch flag so users
can nominate Local or any registered remote as the server Scarf opens
into when a fresh window has no prior binding (first launch or File →
New Window).

- ServerRegistry gains `defaultServerID` (returns the flagged entry's
  ID or falls back to Local) and `setDefaultServer(_:)` (flips the flag
  on the named entry and clears it elsewhere, then persists).
- ScarfApp's WindowGroup defaultValue closure now returns
  `registry.defaultServerID` instead of hardcoded `ServerContext.local.id`.
- ManageServersView gains a Local row at the top of the list plus a
  star button per row: filled yellow on the current default, outline on
  the others. Click to promote.

Backward compatible: the openOnLaunch field was already in the persisted
schema (default false), so existing servers.json files load unchanged —
Local remains the default until the user picks otherwise.

Refs #26
2026-04-22 11:00:32 +00:00
Claude 1989feee22 feat: persist sidebar width across launches (#26)
Wire an NSSplitView autosave name into NavigationSplitView's underlying
AppKit split view so the sidebar's drag-to-resize position is remembered
in UserDefaults and restored on next launch.

SplitViewAutosave.swift installs an invisible NSViewRepresentable that
walks up the view hierarchy from the sidebar, finds the enclosing
NSSplitView, and assigns autosaveName = "ScarfMainSidebar". AppKit
handles persistence from there — no manual UserDefaults or @AppStorage
plumbing needed.

ContentView also gets navigationSplitViewColumnWidth(min:ideal:max:)
bounds so first-launch (before any autosave exists) lands at a sensible
240pt ideal within a 180–360pt range.

Refs #26
2026-04-22 10:58:34 +00:00
Alan Wizemann 8773254d11 chore: accept safe parts of Xcode recommended-settings migration
Xcode 26.x suggested an upgrade pass that included a critical regression:
ENABLE_APP_SANDBOX = YES on the main app, which would silently break every
view that reads ~/.hermes/ (state.db, config.yaml, memory files, skills,
logs). Scarf is architected sandbox-off per CLAUDE.md — reverted.

Kept the benign pieces:

- DEAD_CODE_STRIPPING = YES on all targets (stock modern optimization)
- CLANG_ANALYZER_LOCALIZABILITY_NONLOCALIZED = YES at project level —
  static analyzer warning for un-localizable call sites; directly
  relevant to the i18n work in 2.1.0 and will flag regressions of the
  exact patterns just cleaned up
- STRING_CATALOG_GENERATE_SYMBOLS = YES hoisted to project level
  (was already set at target level; hoisting is a no-op functional
  change but Xcode prefers it inherited)
- Scheme file LastUpgradeVersion bumped to 2620 to match current Xcode

Rejected:
- ENABLE_APP_SANDBOX = YES (critical — would break app file access)
- ENABLE_RESOURCE_ACCESS_AUDIO_INPUT / RUNTIME_EXCEPTION_DISABLE_LIBRARY_VALIDATION
  build settings (Xcode's new form replacing the entitlements file;
  keeping the entitlements file as the single source of truth since
  every release 1.x → 2.1.0 shipped and notarized with that form)
- LastUpgradeCheck = 2620 (Xcode dropped 2630 → 2620; cosmetic revert)

v2.1.0 was released before this Xcode pass so no rebuild needed — the
downloaded zips and Sparkle appcast entry are unaffected.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-20 19:27:55 -07:00
Alan Wizemann a1aa653a33 chore: Bump version to 2.1.0 2026-04-20 18:46:47 -07:00
Alan Wizemann e256196397 chore: commit shared Xcode scheme
The scarf scheme existed in every local Xcode session (Xcode auto-creates
it from xcschememanagement's ^#shared#^ entry on first open), but was
never actually committed to the repo. Release v2.1.0 hit the resulting
"project contains no schemes" error on headless xcodebuild archive after
the build/ cache was cleaned. Committing the scheme itself so future
headless builds work from a fresh clone.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-20 18:46:36 -07:00
Alan Wizemann 50880efe81 docs: prep v2.1.0 release notes + README language badge
Pre-release prep so that when `./scripts/release.sh 2.1.0` runs on main,
the notes file is already in tree (script's `git add` is then a no-op,
bump commit contains only the pbxproj version change).

- README gains a 2.1 "What's New" section covering translations + the
  chat slash-menu; 2.0 moves down to "Previously".
- Badge row gains a language list line.
- Full release notes at releases/v2.1.0/RELEASE_NOTES.md — covers the
  three stacked i18n PRs (infra, audit burn-down, translations) and the
  chat slash-menu work merged in parallel.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-20 18:39:51 -07:00
Alan Wizemann b1bc7e8494 Merge pull request #25 from awizemann/translations-initial
feat(i18n): initial translations for 6 languages + contributor workflow
2026-04-20 18:37:06 -07:00
Alan Wizemann f47034d4ad fix(i18n): localize sidebar, settings tabs, and settings section titles
Three connected bugs where the Label/SettingsSection APIs took a `String`,
which routes through the StringProtocol overloads and bypasses localization
entirely. Identified by the user after testing zh-Hans / de / fr — the
sidebar menu items, Settings tab bar, and Settings section headers all
remained English under any App Language override.

- SidebarSection now exposes displayName: LocalizedStringResource; SidebarView
  builds Label via the Text/Image builders so the catalog key is actually
  used.
- SettingsTab gets the same displayName treatment; the .tabItem Label builds
  through the Text/Image builder too.
- SettingsSection.title changes from String → LocalizedStringKey so literal
  call sites (all ~20 of them) now extract into the catalog. Two call sites
  that were passing String variables (PlatformsView, CredentialPoolsView) are
  wrapped via LocalizedStringKey(...) — brand/provider names fall through to
  English as before. AuxiliaryTab's static task list gets a LocalizedStringKey
  column so its section titles extract too.

This change newly extracts 65 previously-invisible section-title keys into
the catalog; translations added for all six locales. Catalog: 575 → 644
source keys, each locale translated for 583 of them (brand names / protocol
names / format-only keys intentionally fall through).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-21 03:32:32 +02:00
Alan Wizemann 1726a613a5 feat(i18n): add translations for zh-Hans, de, fr, es, ja, pt-BR
Ships first-pass AI translations for six locales on top of the existing
English base, plus a simple JSON-per-locale contributor workflow so new
languages can land as a single PR.

- 518 keys translated per locale (proper nouns / brand names / format-
  only strings left to fall back to English by design — see the
  "Non-blocking (intentional verbatim)" section of scarf/docs/I18N.md).
- Per-locale source-of-truth lives in tools/translations/<locale>.json;
  tools/merge-translations.py writes them into Localizable.xcstrings
  and is idempotent (re-runnable as translators iterate).
- InfoPlist.xcstrings (macOS microphone permission prompt) translated
  for all six locales.
- knownRegions expanded: zh-Hans, de, fr now join by es, ja, pt-BR.
- CONTRIBUTING.md gains an "Adding a Language" section documenting the
  fork → JSON → merge → PR flow. Native-speaker reviews welcome.

Closes #13 (the original ask: Simplified Chinese support).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-20 18:16:41 -07:00
Alan Wizemann de34a80807 Merge pull request #24 from awizemann/multi-language
feat(i18n): close silently un-localizable sites (Phase 1b)
2026-04-20 17:52:18 -07:00
Alan Wizemann d9a25b3997 Merge pull request #22 from awizemann/claude/pedantic-kare-1edf13
feat(i18n): enable String Catalog + locale-aware numeric formatters
2026-04-20 17:51:09 -07:00
Alan Wizemann b40182f2da feat(i18n): close silently un-localizable sites from the audit
Burns down the follow-ups tracked in scarf/docs/I18N.md so that future
translation passes (Phase 2+) don't see English leak through ternary UI
copy, enum rawValue displays, or fixed-format strings.

- Ternary status copy: Text(cond ? "A" : "B") → cond ? Text("A") : Text("B")
  (each branch routes through LocalizedStringKey). Covers Health, Chat
  (voice/TTS/recording/ACP status), Profiles, MCPServer test result,
  SignalSetup, QuickCommands header.
- Enum .rawValue displays: LogFile, LogComponent, DashboardTab, Skills.Tab,
  InsightsPeriod, ToolKind, AuthType each expose a
  displayName: LocalizedStringResource. LogEntry.LogLevel stays verbatim
  (technical jargon — DEBUG/INFO/ERROR/… are industry-standard).
- displayName passthroughs: HermesToolPlatform, ServerRegistry.Entry,
  MCPServerPreset wrapped with Text(verbatim:) at call sites (brand names
  and user data, not UI chrome). MCPTransport.displayName promoted to
  LocalizedStringResource.
- Composite format strings: ModelPickerSheet "ctx" suffix, InsightsView
  "tokens" suffix and MCPServerTestResultView "%.1fs · %d tools" rewritten
  as Text("\(arg) suffix") LocalizedStringKey. Percent display uses
  .formatted(.percent) after /100.
- Day-of-week chart now sources from Calendar.current.shortWeekdaySymbols,
  re-indexed for the existing Mon=0 data model.
- ConnectionStatusPill's label + tooltip return Text (not String) so the
  .help(Text) / direct-render paths localize correctly.
- Catalog re-synced: 545 → 575 keys (+30 from new ternary branches and
  enum displayName values).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-20 17:40:56 -07:00
Alan Wizemann 6817c95681 chore(i18n): sync catalog after rebasing onto chat slash-menu work
Picks up 7 new Text("…") keys introduced by a68e0c5 and c8208de
(loading state copy, slash-menu empty states, argument-hint placeholder).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-20 17:25:16 -07:00
Alan Wizemann 89748fdfee feat(i18n): enable String Catalog + locale-aware numeric formatters
Lays the groundwork for zh-Hans / de / fr translations on an English base.
No user-visible English-locale behavior changes. See scarf/docs/I18N.md for
the full plan and remaining audit follow-ups.

- Localizable.xcstrings seeded with 538 keys auto-extracted via
  `xcstringstool sync` from the Swift sources
- InfoPlist.xcstrings carrying NSMicrophoneUsageDescription
- knownRegions += zh-Hans, de, fr
- Currency / byte-count / compact-number String(format:) sites migrated to
  Locale.current-aware .formatted() style (currency, byteCount(.file),
  compactName notation) — previously rendered POSIX separators + English
  unit names regardless of user locale

Refs #13.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-20 17:24:29 -07:00
Alan Wizemann c8208dedb1 fix(chat): slash-menu filter, auto-scroll on send/complete, loading state
- Slash menu: filter at the parent and pass the pre-filtered list to
  SlashCommandMenu (pure-prefix match, no description fallback). Adds
  `.id(menuQuery)` to force a fresh view on every query so SwiftUI can't
  render stale props — this was the cause of "typing /mo still shows
  /help" (the old description fallback plus a cached child view kept
  /help pinned regardless of query).
- Auto-scroll to bottom when the user submits a message and again when
  the prompt completes. `.defaultScrollAnchor(.bottom)` handles slow
  streaming fine, but rapid slash-command responses outran the anchor
  and left the response off-screen.
- Loading state: add `ChatViewModel.isPreparingSession` (true during
  Starting / Creating / Loading / Reconnecting). While true, the message
  list swaps its placeholder for a ProgressView — non-blocking, just a
  view inside the ScrollView.
- Center the empty-state placeholder properly: replace
  `.padding(.vertical, 80)` with Spacers inside
  `.containerRelativeFrame(.vertical)` so the placeholder sits in the
  true vertical center of the chat pane at any window size.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-20 17:15:46 -07:00
Alan Wizemann a68e0c5f42 feat(chat): slash-command menu + scroll/layout fixes
- Add floating slash-command menu driven by ACP available_commands_update
  and user-defined quick_commands from config.yaml. ↑/↓ navigate, Tab or
  Enter completes, Esc dismisses. Commands with argument hints insert a
  trailing space so the user can type the argument.
- New HermesSlashCommand model carries name/description/argumentHint/source;
  RichChatViewModel stores ACP + quick_commands separately and merges them
  for the menu. QuickCommandsViewModel exposes a reusable static loader.
- Menu renders as a sibling above the input HStack (not a popover or
  overlay) — guaranteed to render regardless of focus/z-order quirks.
- Hide the dedicated /compress button once the menu has more than one
  command; keep it as a fallback when only /compress is advertised.
- Fix long-standing "session loads with whitespace, must scroll up to see
  chat" bug by switching LazyVStack → VStack in RichChatMessageList.
  LazyVStack's estimated row heights were fooling .defaultScrollAnchor(.bottom)
  into overshooting real content; VStack measures every row upfront so the
  anchor has real heights to work with.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-20 16:35:53 -07:00
Alan Wizemann 0384c6ef17 chore: Bump version to 2.0.2 2026-04-20 15:46:07 -07:00
Alan Wizemann f36fb55ebe test(ssh): regression tests for ControlPath socket-limit invariants
Two tests pinning the invariants that were violated / introduced
by the #19 / PR #20 fix:

- controlDirPathFitsMacOSSocketLimit: asserts dir + '/' + 64-char
  %C hash + NUL <= 104 bytes. Would have caught the original
  Caches-based path landing at 105 bytes for users with longer
  $HOME strings.

- controlDirPathIsPerUser: asserts the path includes the current
  uid, pinning the per-user-isolation invariant against any future
  refactor that drops it (since /tmp is shared across all local
  users).

scarfTests was a stub before this — these are the suite's first
real tests.
2026-04-20 15:45:29 -07:00
Alan Wizemann 1823160546 fix(ssh): defensive ControlPath dir + sweep stale sockets
Layered hardening on top of the /tmp ControlPath move from #20:

- ensureControlDir uses POSIX mkdir(0700) + lstat instead of
  createDirectory + setAttributes. Closes the /tmp pre-creation
  TOCTOU: any local user can pre-create /tmp/scarf-ssh-<uid>, and
  the old code would silently fail to chmod a hostile dir back to
  0700 (since we wouldn't own it). Now we refuse to use a dir that
  isn't a real directory we own with mode 0700, and log via
  os.Logger.

- sweepStaleControlSockets removes ControlMaster socket files
  older than 30 minutes from controlDirPath() at app launch.
  Symmetric to sweepOrphanSnapshots — keeps /tmp/scarf-ssh-<uid>/
  from accumulating crashed-master / unclean-exit orphans
  indefinitely until reboot. The 30-min threshold (vs ControlPersist's
  10 min) ensures any concurrent Scarf instance's live sockets
  are untouched.
2026-04-20 15:45:20 -07:00
Alan Wizemann d2a447fcc4 docs: add GitHub wiki + scripts/wiki.sh helper with secret-scan
Public docs now live at https://github.com/awizemann/scarf/wiki (separate
git repo cloned to .wiki-worktree/, mirroring the .gh-pages-worktree/
pattern). Internal dev notes stay in scarf/docs/.

scripts/wiki.sh wraps pull/commit/push with a two-pass secret-scan: hard
patterns (token regexes + private-key headers + a user-maintained
scripts/wiki-blocklist.txt) abort with non-zero exit; soft assignment
patterns (api_key=…, password=…, token=…) warn and require --force-terms.

CLAUDE.md gains a Wiki section listing the update triggers (new feature,
new service, architecture change, Hermes version bump, full release,
keyboard/sidebar change) and the workflow. CONTRIBUTING.md points
external contributors at the wiki Edit button or a direct clone.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-20 15:32:47 -07:00
Alan Wizemann 76bfeb34d4 chore: Bump version to 2.0.1 2026-04-20 15:32:47 -07:00
Alan Wizemann 85a4ec0e14 Merge pull request #20 from aliatx2017/fix/controlpath-too-long
fix: ControlPath too long for Unix socket on macOS
2026-04-20 15:08:40 -07:00
Alan Wizemann 1453c7a841 Merge fix/issue-19-ssh-diagnostics into main — v2.0.1 hotfix
Closes #19 (remote SSH connections showed connected but every view
read as empty). Eight commits bring:
- Result-returning readers in HermesFileService that surface errors
  instead of silently returning nil
- HermesDataService.open records lastOpenError with humanized hints
- Dashboard orange banner when remote reads fail
- New Remote Diagnostics sheet (14-probe checklist, stethoscope icon)
- Yellow 'degraded' pill state for 'connected but can't read' case
- Auto-suggest remoteHome in Test Connection for systemd/Docker
  installs at /var/lib/hermes/.hermes etc.
- Log-noise suppression for expected 'No such file' reads
- Diagnostics script pipes via stdin to sh -s (not sh -c argv), so
  multi-line scripts run in one sh process with variable scope
- Pill UX: state-specific SF Symbol instead of dot, no custom
  background, centered via .principal
- README 'Remote setup requirements' + troubleshooting section

Investigation notes + deferred follow-ups recorded in the session
transcript. See releases/v2.0.1/RELEASE_NOTES.md for the full
user-facing breakdown.
2026-04-20 14:27:11 -07:00
Alan Wizemann bd21a539e6 docs: update v2.0.1 release notes for diagnostics fixes + pill UX
Reflect the three post-initial-commit fixes:
- log-noise suppression (skill.yaml / optional-file 'No such file'
  warnings no longer spam Console via the new Result-returning readers)
- diagnostics script now stdin-pipes to sh -s instead of sh -c <script>
  argv, so it runs as one sh process with variable scope preserved
- pill UX: replaced colored dot with state-specific SF Symbol
  (checkmark / stethoscope / arrows / triangle), removed custom
  background, kept .principal placement for centering

Also expanded the 'Known follow-ups' section so users know what's
explicitly deferred post-2.0.1.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-20 14:26:33 -07:00
Alan Wizemann d3055702ef fix: connection pill — revert to .principal, swap dot for state SF Symbol
Rolling back the .primaryAction placement (the pill shifted right and
lost its centered position in the toolbar). The "funny background with
shadow" visible in the toolbar is macOS's own .principal emphasis bezel
— not something Scarf draws, and not something we can cleanly hide
without disabling the toolbar surface itself. The native bezel is the
pill's frame; we just have to make the pill's interior read well inside
it.

Two changes to make the pill itself look like a toolbar tool inside
that bezel:

- Drop the colored dot, replace with a state-specific SF Symbol. The
  icon's shape signals clickability (looks like a tool button), and its
  color signals state (green/orange/yellow/red hierarchical). Less
  "status chip", more "toolbar button with status".
- Icons per state:
  - connected  → checkmark.circle.fill (click to re-probe)
  - degraded   → stethoscope (click to run diagnostics, matches the
                 stethoscope on the Manage Servers row)
  - idle       → arrow.triangle.2.circlepath (checking/retry)
  - error      → exclamationmark.triangle.fill (click for stderr)

Horizontal padding = 4 so the icon-and-label sit balanced inside the
bezel rather than pushed up against its edges.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-20 14:22:35 -07:00
Alan Wizemann ee1d705abc fix: move connection pill off .principal to drop the emphasis bezel
macOS applies a centered emphasis bezel (light capsule + drop shadow)
to ToolbarItem(placement: .principal) — visible in screenshots as a
doubly-framed "capsule behind the pill" look. The pill itself doesn't
own that background; the toolbar placement does.

.primaryAction (right side of the toolbar) has no decorative
background, so the pill renders as just the colored dot + label text
directly on the toolbar surface. Fits the intended minimal look.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-20 14:16:54 -07:00
Alan Wizemann 8e3dafe4c6 fix: remove the pill's own capsule background
The toolbar item already draws its own bezel for the principal-placement
slot; painting a `Color.secondary.opacity(0.08)` capsule on top gave the
pill a doubly-framed look. Drop the pill's background + the padding that
was only there to fit inside the capsule. The dot + label now sit
directly on the toolbar's native surface.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-20 14:14:12 -07:00
Alan Wizemann c51241dc72 fix: diagnostics script — pipe to sh via stdin, not sh -c argv
The previous fix (direct ssh argv, bypassing transport.runProcess) got
us from 0/14 to 7/14, but \$H was empty everywhere it was referenced —
the user's 7/14 report showed:
- probe 4 (hermesHomeConfigured): PASS with empty detail
- probe 5 (hermesDirExists FAIL): "not a directory:" (empty after colon)
- probe 11 (sqlite3CanOpenStateDB FAIL): 'unable to open "/state.db"'

Root cause: `ssh host -- /bin/sh -c <script>` doesn't travel as three
argv entries to the remote. ssh concatenates them with single spaces
into one command string and sends that to the remote's LOGIN shell.
The login shell then runs `$LOGIN_SHELL -c "$string"`, and bash's
parser treats unquoted newlines inside `$string` as command separators.
So the first newline splits the script: `/bin/sh -c H="..."` becomes
one command (which runs in an ephemeral sh subprocess that exits
immediately), and every subsequent line runs in the login shell with
no \$H set.

TestConnectionProbe happens to still work because its downstream lines
don't depend on an assignment from the first line — but the diagnostic
script's \$H is used everywhere, so the entire script is effectively
running with \$H="".

Fix: pipe the script into `/bin/sh -s` on stdin via ssh's own stdin
channel. `sh -s` reads a shell program from stdin and executes it in
one process, variable scope preserved. Implementation uses
Process.standardInput with a Pipe, writing the script after proc.run()
and closing the write end so sh sees EOF. Same as
`cat script.sh | ssh host -- /bin/sh -s` from the command line.

Also: raw-output disclosure panel in the diagnostics sheet now shows
whenever ANY probe fails, not only when all fail. Partial failures are
the most common failure mode and the raw stdout is the only way to see
why a specific detail came back the way it did.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-20 14:04:32 -07:00
Alan Wizemann ec03627bcd fix: diagnostics sheet — bypass transport.runProcess for shell script
First-run of diagnostics against a working Mardon returned 0/14 passing
with "(no output)" for every probe — including the trivial "emit
connectivity PASS" that the script emits unconditionally. That meant the
script wasn't executing as written; the parser saw `__END__` but no
probe lines.

Root cause: SSHTransport.runProcess wraps every argument through
`remotePathArg`, which is designed for PATHS (it rewrites `~/` to
`$HOME/` and double-quotes the result with backslash-escapes). Passing
a multi-line shell script with embedded `"$1"` / `"$2"` / `"$3"` and
`printf '\n'` escape sequences through that is corruption — the remote
sh -c receives a scrambled script and silently emits nothing.

TestConnectionProbe already works around this: it builds the ssh argv
directly (ssh host -- /bin/sh -c <script>) so the script travels as a
single opaque argv entry and ssh forwards it to the remote shell
unchanged.

Mirror that approach. RemoteDiagnosticsViewModel.execute now:
- For remote contexts: builds ssh argv directly (ControlMaster-aware,
  uses the same socket as SSHTransport so it's effectively free after
  the first connection), then passes /bin/sh -c <script> as argv.
- For local contexts: spawns /bin/sh -c <script> via Process directly.

Also surfaces raw stdout/stderr/exit-code in a disclosure panel at the
bottom of the sheet, visible only when ALL probes fail. Makes any
future transport-level breakage self-diagnosing: the user sees exactly
what the remote returned, not just "(no output)" rows.

Expose SSHTransport.controlDirPath (already static) as a public helper
so the diagnostics probe reuses the same ControlMaster socket as the
connection itself.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-20 13:56:09 -07:00
Alan Wizemann f8069a4481 fix: don't log 'No such file' as warnings on remote reads
The Result-returning readers I added for the v2.0.1 diagnostics surface
were logging EVERY failure, including routine "file doesn't exist" cases
— e.g. skill.yaml files under ~/.hermes/skills/*/ that are optional
metadata, gateway_state.json before Hermes has started, memories/USER.md
on fresh installs.

In practice this meant the Platforms view and similar feature loaders
that walk directories and read optional files now spam the Console with
warnings on every refresh. That's noisier than useful and actively hides
the signal (permission denied, connection failure, sqlite3 missing) we
added the logging to surface.

readFileDataResult now detects the "no such file" case via either:
- TransportError.fileIO(_, "No such file...") from SSHTransport
- NSCocoaErrorDomain code 260 (NSFileNoSuchFileError) from FileManager
- NSPOSIXErrorDomain code 2 (ENOENT)

and suppresses the warning log for those paths. The Result.failure is
still returned, so any caller that cares (Dashboard's banner, Remote
Diagnostics) can still distinguish missing from present-but-unreadable.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-20 13:52:28 -07:00
Alan Wizemann 110170d6e9 fix: v2.0.1 — surface remote SSH file-access errors (closes #19)
Three users reported on day-one of v2.0 that SSH connections showed a
green "Connected" pill but every data view read as empty / "not running"
/ "not configured". The common thread across Docker, homelab VM, and
Ubuntu VPS setups: file-access failures on the remote that Scarf
silently swallowed into nil/empty defaults.

Stop swallowing errors
- HermesFileService gains Result-returning variants for the four
  dashboard-critical readers: loadConfigResult, loadGatewayStateResult,
  hermesPIDResult, plus readFileResult / readFileDataResult as
  primitives. Each logs os.Logger warnings on failure. Legacy nil-
  returning signatures remain as thin forwarders.
- HermesDataService.open records lastOpenError with humanized hints
  for the top three failure modes — sqlite3 not installed, permission
  denied, file not found. Each maps to concrete remediation (`apt
  install sqlite3`, "check file perms", "set Hermes data directory").

Dashboard surfaces the error
- DashboardViewModel collects errors from every loader into
  lastReadError, only on remote contexts (local skips the banner).
- DashboardView renders an orange banner above the stats with the
  specific error text, a copy-selectable detail, and a "Run
  Diagnostics…" button.

New Remote Diagnostics sheet (stethoscope icon)
- RemoteDiagnosticsViewModel runs 14 checks in one SSH round-trip via
  a pipe-delimited "KEY|STATUS|DETAIL" protocol. Covers: SSH
  connectivity, remote user/$HOME, Hermes dir existence + readability,
  config.yaml readability + actual read (distinct from just `test -e`
  which can't detect permission issues), state.db readability, sqlite3
  binary presence, sqlite3 open test, hermes binary on non-login AND
  login PATH, pgrep availability.
- Each probe row shows a targeted hint on fail (e.g. "check perms on
  ~/.hermes", "apt install sqlite3", "move PATH export from .bashrc
  to .zshenv"). A Copy Full Report button dumps plain-text output
  for GitHub issues.
- Accessible from Manage Servers (stethoscope button per row) and
  directly from the yellow pill.

Yellow "degraded" connection state
- ConnectionStatusViewModel.Status gains .degraded(reason:) between
  .connected and .error. After tier-1 `true` passes, the probe runs
  tier-2 `test -r $HOME/.hermes/config.yaml` in the same SSH round-
  trip. On tier-2 fail, pill is orange with "Connected — can't read
  Hermes state" tooltip.
- Clicking a degraded pill opens Remote Diagnostics directly. Exactly
  the symptom in #19 is now one click from a specific answer.

Auto-suggest remoteHome for non-default installs
- TestConnectionProbe.TestResult.success gains suggestedRemoteHome:
  String?. When state.db isn't found at the configured path, the
  probe also checks /var/lib/hermes/.hermes, /opt/hermes/.hermes,
  /home/hermes/.hermes, /root/.hermes — the common alternates for
  systemd services, Docker containers, and single-user VPSes — and
  surfaces the first hit as a "Use this" suggestion in Add Server.
- AddServerSheet relabels "Remote ~/.hermes override" to "Hermes data
  directory" with an explanation of when you'd use it.

README
- New "Remote setup requirements" subsection lists the four concrete
  prereqs (SSH, sqlite3, pgrep, read access to ~/.hermes).
- New "Troubleshooting remote connections" paragraph describes the
  diagnostics sheet and remoteHome auto-suggest for the two most
  common failure modes.

Releases
- releases/v2.0.1/RELEASE_NOTES.md for the GitHub release body.
- Ship via `./scripts/release.sh 2.0.1`.

Closes #19.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-20 13:40:35 -07:00
Alex Maksimchuk 1293cfa23b fix: use short ControlPath to avoid Unix socket limit on macOS
The ControlMaster socket path ~/Library/Caches/scarf/ssh/%C can
exceed the 104-byte macOS Unix domain socket limit when the
username is long, causing ssh to silently exit 255 with
"unix_listener: path too long for Unix domain socket".

Switch to /tmp/scarf-ssh-<uid> which stays well within the limit.
2026-04-19 23:03:28 -05:00
Alan Wizemann ba8bf14ff0 chore: Bump version to 2.0.0 2026-04-19 13:07:49 -07:00
Alan Wizemann 4212200dca Merge remote-servers into main — v2.0 release
Brings multi-window + multi-server + remote-SSH support to main,
plus the full correctness/UX/concurrency polish pass.

Two commits land:
- 00ca722 feat: multi-window + remote SSH server support (Phases 0-4)
- 5920923 feat: v2.0 — correctness + UX polish on multi-server + remote SSH

See releases/v2.0.0/RELEASE_NOTES.md for the user-facing summary.
2026-04-19 13:07:22 -07:00
Alan Wizemann 5920923d92 feat: v2.0 — correctness + UX polish on multi-server + remote SSH
The multi-window / multi-server / remote-SSH work that landed in
00ca722 (feat: multi-window + remote SSH server support (Phases 0-4))
was feature-complete but accumulated rough edges during dogfooding
against a remote Mac mini. This commit finishes the 2.0 release:
correctness fixes on remote, a chat-view UX overhaul, and a Swift 6
complete-concurrency sweep across the service layer.

Correctness on remote
- Kill the WAL-error spam: snapshotSQLite now runs `PRAGMA
  journal_mode=DELETE` on the remote temp DB before scp, so the
  pulled file is self-contained. Open remote snapshots with
  `file:...?immutable=1` URI as defense-in-depth, and drop the
  pointless `PRAGMA journal_mode=WAL` from HermesDataService.open.
- loadSessionHistory and refreshMessages now force a fresh snapshot
  via refresh(), so resuming a session on a remote shows messages
  persisted since launch (previously stuck on the first snapshot).
- New SnapshotCoordinator actor dedupes concurrent snapshotSQLite
  calls per ServerID — Dashboard + Sessions + Activity no longer
  issue three parallel SSH backups for the same fetch.
- ACP cwd comes from the remote's $HOME (probed once, cached per
  server in UserHomeCache), not the local Mac's NSHomeDirectory().
- Typing into a blank Chat always creates a new session. The old
  auto-resume-most-recent fallback was picking up cron-spawned
  sessions that Hermes had already GC'd, producing silent prompt
  failures.
- handlePromptComplete surfaces non-success stopReasons ("refusal",
  "error", "max_tokens") as a system message so failed prompts no
  longer sit under a forever-spinning "Agent working…".

Chat UX
- Replace six racing onChange-driven scrollTo calls with
  `.defaultScrollAnchor(.bottom)` alone. Manual proxy.scrollTo
  against a LazyVStack that hadn't finished laying out was
  overshooting into whitespace. Layout-pass-integrated anchor
  behaves correctly at stream start and finish.
- Remove ContentUnavailableView swap in RichChatView — it tore down
  the whole ScrollView hierarchy on first message. Empty state now
  lives inside the scroll view.
- continueLastSession surfaces an acpError banner if open() fails,
  instead of silently returning.

Lifecycle hygiene
- ServerRegistry.removeServer closes the server's SSH ControlMaster
  (`ssh -O exit`), prunes its snapshot cache dir, and invalidates
  UserHomeCache for that ID. App launch sweeps orphan snapshot dirs
  whose UUIDs aren't in the registry anymore.
- NSWorkspace.activateFileViewerSelecting (backup-saved-to dialog)
  gated on !context.isRemote; remote surfaces the remote path in the
  saveMessage instead of silently no-op'ing on a nonexistent local
  path.

Swift 6 concurrency — 230 warnings → 1
- Mark ServerContext, HermesPathSet, ServerTransport (protocol),
  LocalTransport, SSHTransport, HermesFileService, and every value-
  type accessor as `nonisolated`. Prevents AppKit-import-driven
  MainActor inference from bleeding onto data-only types.
- Hand-written Codable conformances (vs. synthesized) for
  ACPRequest, ACPRawMessage, ACPError, GatewayState, PlatformState,
  HermesCronJob, CronSchedule, CronJobsFile, AuthFile, AuthEntry.
  Synthesized inits were inferred @MainActor by Swift 6's default-
  isolation rule; hand-written ones are explicitly nonisolated.
- Captured-var refactors in MCPServerEditorViewModel, PluginsView
  Model, LocalTransport.watchPaths. Thread.sleep → Task.sleep in
  TestConnectionProbe.
- Remaining warning is AnyCodable.value mutation in init(from:) —
  Any-typed storage can't be strictly Sendable; acknowledged via
  @unchecked Sendable.

ACP adapter upstream bug (not fixed here, but handled)
- Hermes's ACP adapter returns JSON-RPC success `{"result":{}}` for
  session/load on a missing session, logging the warning only to
  stderr. Scarf can't distinguish "loaded" from "silently missing"
  at that layer; the stopReason=refusal surfacing above catches the
  downstream symptom. Upstream issue worth filing.

Release docs
- releases/v2.0.0/RELEASE_NOTES.md with full user-facing breakdown.
- README.md "What's New" bumped to 2.0 with a multi-server section.
  Compatibility table adds v0.10.0 as verified.
- GitHub repo description updated (via `gh repo edit`) to call out
  multi-server + remote SSH.

35 files changed, +809/-350.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-19 13:02:40 -07:00
Alan Wizemann 00ca7229df feat: multi-window + remote SSH server support (Phases 0-4)
Adds the ability to manage multiple Hermes installations — local and
remote over SSH — from the same Scarf app, each in its own window.

Architecture:
- ServerContext value type carries per-server identity + paths through
  every VM and service. ContentView routes serverContext into each
  feature view's init; all 22 routed views thread it through to their
  @State VMs.
- ServerTransport protocol with LocalTransport (FileManager/Process/
  FSEvents) and SSHTransport (system ssh + scp + ControlMaster).
  Services were ported from direct Foundation I/O to transport-routed
  helpers so the same code runs against local or remote.
- WindowGroup(for: ServerID.self) gives each window its own
  AppCoordinator + HermesFileWatcher + ChatViewModel. File menu has
  Open Server commands with keyboard shortcuts (⌘1..⌘9). MenuBarExtra
  fans out per-server with start/stop/restart controls.
- ServerRegistry persists connections to ~/Library/Application
  Support/scarf/servers.json. Add Server sheet probes the remote with
  ssh -v to capture the full handshake on failure.
- Connection-status pill in remote-window toolbars with silent reconnect
  (3s retry on first failure, escalate to red after 2 consecutive),
  known-hosts-mismatch + ssh-add hint cards with copy buttons.

Concurrency / UX hardening (the parts learned the hard way during
dogfooding — captured in the feedback memory):
- ServerContext exposes context.readText / readData / writeText /
  fileExists / runHermes / openInLocalEditor as the canonical I/O
  surface. Every VM uses these; never raw FileManager / Process() /
  NSWorkspace.open with a Hermes path.
- SSHTransport.remotePathArg rewrites ~/foo to "$HOME/foo" so paths
  expand correctly inside the sh -c command we build (POSIX shells
  don't expand ~ inside any quotes).
- Heavy VM load() methods detach to a background task and commit
  results back via MainActor.run, so synchronous ssh round-trips don't
  beach-ball the UI. Applied to Dashboard, Memory, Settings,
  MCPServers, Cron, Plugins, Personalities, QuickCommands, Skills,
  Gateway, Health, CredentialPools.
- LoadingOverlay modifier shows a spinner over empty/stale section
  content during background reloads.
- enrichedShellEnv (zsh -l -i probe, up to 8s) is now warmed at app
  launch off-main so first MainActor caller doesn't block.
- Drop the file watcher's 5s heartbeat — FSEvents covers real changes
  and the heartbeat was triggering wasted reloads across every
  subscribing view.

Chat polish:
- ChatViewModel.hermesBinaryExists is a stored bool probed once at
  init, not a sync transport call evaluated on every body re-render.
- MessageGroupView identifies assistant bubbles by array offset rather
  than message.id, so the streaming → finalized id transition no
  longer destroys + recreates the bubble.
- Static scroll anchor in RichChatMessageList prevents two onChange
  handlers from racing on isWorking flips.

Branch state: feature complete, in active dogfooding. Plan + per-phase
status live at ~/.claude/plans/we-developed-an-application-harmonic-stroustrup.md;
the four hard-won transport/concurrency rules are saved in the
ServerContext-pattern feedback memory for future sessions.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-18 17:42:17 -07:00
Alan Wizemann 679dedf132 fix: release script — push main + tag before gh release create
The script was creating the GitHub release before pushing main, which
caused gh to auto-create the v<VERSION> tag at the then-current origin
HEAD (one commit behind the bump, since main hadn't been pushed yet).
The subsequent `git push origin v<VERSION>` was then rejected as
non-fast-forward, leaving the remote tag pointing at the wrong commit.

Caught during v1.6.2. The remote tag for v1.6.2 was force-corrected to
12610fa (the bump commit); the release artifacts themselves were always
correct.

New order: push main → tag main locally → push tag → gh release create.
Gh will now find the tag already on origin and attach to the right
commit. Non-destructive: a retry-safe release can always be resumed.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-17 17:24:31 -07:00
Alan Wizemann 12610faba0 chore: Bump version to 1.6.2 2026-04-17 17:18:33 -07:00
Alan Wizemann 73b44202ba fix: release script preflight allows pre-written RELEASE_NOTES.md
CLAUDE.md's release-notes convention says "write them to
releases/v<version>/RELEASE_NOTES.md BEFORE running the script" — but
the script's git-clean preflight rejected any working-tree state
including that exact file as untracked. Chicken-and-egg: you couldn't
follow the documented flow.

Preflight now whitelists releases/v<VERSION>/RELEASE_NOTES.md as the one
allowed untracked path. Everything else still fails the check.

Caught while running v1.6.2.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-17 17:18:23 -07:00
Alan Wizemann eed55cbb0f chore: Ignore release-artifact binaries
Stops the release script's git-clean preflight from tripping on the
zips + appcast-entry.xml that every release run produces under
releases/v<VERSION>/. GitHub Releases hosts the actual downloads; there's
no reason to commit ~30 MB of binaries per release into git history.

RELEASE_NOTES.md stays tracked — it's committed as part of the version
bump by the release script.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-17 17:16:10 -07:00
Alan Wizemann 14c97bee62 docs: CLAUDE.md — document the release flow + canonical prompts
Adds a Releases section so future Claude sessions (and teammates) don't
have to rediscover the release workflow. Documents:

- The single entry point: `./scripts/release.sh <ver> [--draft]`
- What the script does end-to-end
- The release notes convention (write them before running)
- A handful of canonical prompts the user can type
- Pointers to deeper prerequisite docs (README, script header)

Deliberately brief — detail lives in README and the personal auto-memory
at reference_release_process.md. CLAUDE.md's job here is just to make
the entry point discoverable on session start.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-17 17:13:15 -07:00
Alan Wizemann 8d3fe70e2c fix: Chat tab false-positive "no credentials" warning before session pick
The orange "No AI provider credentials detected" banner was firing on the
Chat tab whenever no session was selected, even for users whose
credentials were configured and working. The banner only disappeared
when a session started — not because credentials were actually found,
but because the banner's `!hasActiveProcess` gate flipped to false once
ACP launched.

Root cause: `HermesFileService.hasAnyAICredential()` inspected only the
shell environment and `~/.hermes/.env`, while Hermes itself resolves
credentials from two additional places Scarf had never learned about:

  - `~/.hermes/auth.json` — the Credential Pools file written by the
    Configure → Credential Pools UI (the blessed v1.6 flow)
  - `~/.hermes/config.yaml` — embedded `api_key:` under auxiliary.<task>
    and delegation

The preflight now checks all four locations. For auth.json we parse the
JSON and look for any `credential_pool.<provider>[*].access_token` that
is non-empty. For config.yaml we line-scan for `api_key:` leaves with a
non-empty value, matching the defensive style of the existing .env
scanner (no YAML parser needed in a nonisolated function).

Also updated the banner subtitle to point users at Credential Pools
before .env, since the former is the blessed in-app flow.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-17 17:10:51 -07:00
Alan Wizemann da88c98c7a feat: release script builds Universal + ARM64 variants
Each release now produces two distribution zips:
- Scarf-vX.X.X-Universal.zip  (arm64 + x86_64, recommended)
- Scarf-vX.X.X-ARM64.zip      (arm64 only, ~14% smaller)

Both are independently archived, exported with Developer ID, notarized,
and stapled via a new build_variant helper. The appcast still points at
the Universal zip since it works on all supported macs; ARM64 is an
alternative manual download for Apple Silicon users who want the smaller
file.

README updated to list both variants.

Prompted by the v1.6.1 release shipping only Universal; the ARM64 zip
for v1.6.1 was produced ad-hoc and uploaded to the existing release.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-16 20:10:17 -07:00
Alan Wizemann b7ad01f9da chore: Bump version to 1.6.1 2026-04-16 19:04:48 -07:00
Alan Wizemann 868e61979e chore: release script supports --draft + RELEASE_NOTES.md
Drafts skip the appcast push and main tag, so a draft release won't
show up in users' Sparkle update feed and v1.6.0 stays "latest" until
explicitly promoted. The signed appcast entry is saved to the release
dir for later manual promotion.

Also adds release notes file convention: releases/v<VERSION>/RELEASE_NOTES.md
is auto-included in the version-bump commit and used as the GitHub
release body.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-16 19:04:27 -07:00
Alan Wizemann 9bdd928469 fix: release script — rename scarf.app → Scarf.app after export
Xcode exports the bundle as scarf.app because PRODUCT_NAME = $TARGET_NAME
and the target is lowercase "scarf". Users expect Scarf.app in their
/Applications folder. Renaming the bundle wrapper preserves the
signature (codesign signs contents, not the wrapper directory name).

Caught during a build+sign+verify dry run before the first notarized
release.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-16 18:59:41 -07:00
Alan Wizemann 75e489e39c fix: chat works without a terminal hermes session; surface the real error when it doesn't
A fresh-install user reported Scarf chat only worked while `hermes chat`
was also running in Terminal. ACP connected successfully but sending a
message errored. `~/.hermes/logs/errors.log` showed the real cause:

  RuntimeError: No Anthropic credentials found. Set ANTHROPIC_TOKEN or
  ANTHROPIC_API_KEY, run 'claude setup-token', or authenticate with
  'claude /login'.

The terminal workaround masked the bug because the terminal-launched
`hermes` inherits the user's shell env (ANTHROPIC_* exports, Keychain
session) while a Finder/Dock-launched Scarf subprocess does not.
Scarf's previous PATH-only enrichment (commit b2a29ab) fixed binary
discovery but not credential propagation.

Five changes:

1. Propagate credential env vars from the login shell.
   HermesFileService.enrichedEnvironment() now harvests a conservative
   allowlist of AI-provider keys (ANTHROPIC_API_KEY/TOKEN/BASE_URL,
   OPENAI_*, OPENROUTER_*, GEMINI/GOOGLE/GROQ/MISTRAL/XAI API keys,
   CLAUDE_CODE_OAUTH_TOKEN) alongside PATH. Uses one `zsh` probe with
   null-delimited `printf` so values with newlines survive, cached for
   the process lifetime.

2. Two-attempt shell probe catches nvm/asdf/mise PATH.
   Previous `zsh -l` missed `.zshrc`-exported PATH (nvm). New probe
   first tries `zsh -l -i` (login + interactive, sources .zshrc) with
   prompt frameworks defanged (TERM=dumb, empty PS1/PROMPT,
   POWERLEVEL9K_INSTANT_PROMPT=off, STARSHIP_DISABLE=1,
   ZSH_DISABLE_COMPFIX=true) and a 5s timeout; falls back to `zsh -l`
   with 3s; finally to hardcoded defaults.

3. Resolve `hermes` binary across install locations.
   HermesPaths.hermesBinary is now computed, walking pipx
   (~/.local/bin), Apple Silicon brew (/opt/homebrew/bin), Intel brew
   / manual (/usr/local/bin), and ~/.hermes/bin. Returns the first
   executable match or the pipx default for "Expected at …"
   diagnostics. All 10+ callsites (ACPClient, scarfApp, Health /
   Gateway / Tools / Sessions / QuickCommands / Personalities /
   Settings / WhatsAppSetup / OAuthFlow / CredentialPools
   ViewModels) auto-migrate with zero edits.
   HermesFileService.hermesBinaryPath() shares the same candidate
   list as the source of truth.

4. Surface the real failure in the chat UI.
   ACPClient keeps a 50-line ring buffer of subprocess stderr
   (previously only sent to os_log). New ACPErrorHint.classify pattern-
   matches the common fresh-install failures — "No credentials found",
   "No such file or directory: 'npx'", rate-limit — and returns a short
   human hint. ChatView gains an errorBanner between toolbar and chat
   area showing the hint + raw message + a "Show details" disclosure
   with the stderr tail in a selectable monospaced view, plus a
   clipboard-copy button.

5. Preflight credential check.
   HermesFileService.hasAnyAICredential() scans the enriched env and
   ~/.hermes/.env for any known provider key. ChatViewModel exposes
   `missingCredentials`; the banner becomes a pre-emptive warning
   ("No AI provider credentials detected — add ANTHROPIC_API_KEY to
   ~/.hermes/.env or your shell profile") before the user even hits
   Send. HermesFileWatcher already watches ~/.hermes/.env, so edits
   re-trigger preflight automatically.

Incidental cleanup: recordACPFailure(_:client:context:) folds the
per-site `logger.error` calls, removing three `_ = msg` suppressions.
Dead `enrichedPath` alias removed.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-16 18:49:44 -07:00
Alan Wizemann 41ea3aeb83 feat: Sparkle auto-updates + Developer ID notarization pipeline
Adds Sparkle 2 auto-updates and a local release script that produces
signed, notarized, stapled builds for GitHub distribution. App Store
submission was rejected because Scarf spawns the user-installed hermes
binary and reads ~/.hermes/ directly — both forbidden by App Sandbox —
so we commit to the GitHub-release path properly.

- Sparkle SPM dep wired into the app target (link-only; hardened-runtime
  entitlement disable-library-validation lets Sparkle load at runtime).
- Tracked Info.plist with SUFeedURL, SUPublicEDKey, and daily check
  interval; replaces the auto-generated plist so Sparkle keys live in
  version control rather than pbxproj INFOPLIST_KEY_* noise.
- UpdaterService wraps SPUStandardUpdaterController and is injected via
  .environment(). Menu bar, standard app menu (CommandGroup after
  .appInfo), and a new Updates section in Settings → General each call
  updater.checkForUpdates().
- scripts/release.sh runs the full pipeline: version bump → universal
  archive → Developer ID export → notarytool submit (keychain profile
  scarf-notary) → staple → appcast EdDSA sign → gh-pages push → gh
  release → tag. scripts/ExportOptions.plist pins manual Developer ID
  signing for team 3Q6X2L86C4.
- README: removes the right-click-Open workaround (notarized builds
  don't need it), notes Sparkle, adds a Releases section describing
  the pipeline and signing prerequisites.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-16 18:42:20 -07:00
Alan Wizemann eb39dcfa61 docs: Restructure README + add v1.6.0 release binaries
Reorganize the Features section to match the app's sidebar (Monitor, Interact,
Configure, Manage, Project Dashboards, System) so readers find features the
same way they find them in the app. Add a "What's New in 1.6" callout with
links to the release notes.

Binaries: ARM64 (15 MB) and Universal (19 MB). Both signed with the Apple
Development identity (Team 3Q6X2L86C4). Universal contains both arm64 and
x86_64 slices verified with lipo.
2026-04-16 15:51:28 -07:00
Alan Wizemann 93ee194ba0 chore: Bump version to 1.6.0 2026-04-16 15:39:41 -07:00
Alan Wizemann b6d9113579 feat: Settings tabs, Platforms, Credential Pools, Model Picker, and Configure sidebar
Major expansion of Scarf's Hermes platform coverage. Settings is now a 10-tab
layout exposing ~60 previously hidden config fields. A new "Configure" sidebar
section groups per-platform setup, personality management, quick commands,
credential pools, plugins, webhooks, and profile switching.

## Highlights

- **Platforms feature** — Native GUI setup for all 13 messaging platforms
  (Telegram, Discord, Slack, WhatsApp, Signal, Email, Matrix, Mattermost,
  Feishu, iMessage, Home Assistant, Webhook, CLI). Per-platform forms write
  credentials to ~/.hermes/.env and behavior toggles to ~/.hermes/config.yaml.
  WhatsApp and Signal use an inline SwiftTerm terminal for QR/link pairing.

- **Credential Pools** — Provider-aware add/remove with proper type handling.
  OAuth flow uses Process + pipes to extract the authorization URL, open the
  browser explicitly, and accept the code via a form field. Fixes the Anthropic
  OAuth failure where the code had nowhere to be entered.

- **Model Picker** — Hierarchical provider -> model picker backed by
  ~/.hermes/models_dev_cache.json (111 providers, every major model). Used in
  Settings -> General and Delegation. "Custom..." escape hatch for unlisted IDs.

- **Settings as tabs** — 10 tabs (General, Display, Agent, Terminal, Browser,
  Voice, Memory, Aux Models, Security, Advanced). HermesConfig grew from 32 to
  ~90 fields via grouped sub-structs. All new fields round-trip through
  `hermes config set`.

- **Extended existing features** — Cron (create/edit/pause/resume/run-now/
  delete), Skills (Browse Hub + Updates tabs), Health (run `hermes dump` and
  `hermes debug share` with confirmation dialog), Sessions (rename/delete/
  export/export-all).

## Bug fixes

- Tools platform picker showed only CLI (was reading a nonexistent
  `platform_toolsets:` YAML section). Now enumerates KnownPlatforms.all with
  live connectivity dots from gateway_state.json.
- Credentials add with --api-key was triggering OAuth for providers like
  Anthropic because --type was missing. Now always passes --type api-key.
- Remove-by-index used 0-based indexing; hermes CLI expects 1-based. Fixed.
- Various CLI parser fragility issues (plugins, profiles, skills hub, webhooks)
  replaced with structured file reads or proper box-drawn table parsers.

## New core services

- HermesEnvService — reads/writes ~/.hermes/.env atomically, preserves
  comments, commented-out keys get enabled in-place on save, values with
  spaces/specials get quoted, unset commented out (non-destructive).
- ModelCatalogService — decodes the models.dev cache into typed providers and
  models with context/cost/release-date metadata.
- OAuthFlowController — manages the OAuth Process subprocess: extracts the
  auth URL via regex, opens the browser, pipes the code back via stdin,
  detects success/failure markers in output.

## New sidebar structure

Monitor / Projects / Interact / **Configure (new)** / Manage

The Configure section gathers the setup-style features that used to require
the CLI: Platforms, Personalities, Quick Commands, Credential Pools, Plugins,
Webhooks, Profiles.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-16 15:39:07 -07:00
Alan Wizemann b2a29ab68d fix: MCP test failures hidden as success; brew/nvm binaries not on PATH
Two related bugs surfaced when testing MCP servers that spawn npx, node,
python, etc. from Homebrew/nvm/asdf/mise installs.

1. MCP test reported success even when the connection failed.
   `hermes mcp test <server>` exits 0 even when the inner connection
   fails — it prints the error to stdout instead. Scarf trusted the
   exit code and rendered a green checkmark while the output said
   "✗ Connection failed: [Errno 2] No such file or directory: 'npx'".
   Fix: also scan output for ✗, "Connection failed", "No such file or
   directory", and "Error:" markers.

2. .app launches start with a minimal PATH that excludes Homebrew.
   When Scarf is launched from Finder/Dock, ProcessInfo's PATH is
   `/usr/bin:/bin:/usr/sbin:/sbin` — no /opt/homebrew/bin, no
   /usr/local/bin, no nvm/asdf/mise shims. Hermes inherits this and
   can't find npx/node/python when spawning MCP server subprocesses.
   Fix: query the user's login shell PATH once via `/bin/zsh -lc 'echo
   $PATH'`, cache it on HermesFileService, and inject it into both
   `runHermesCLI` and the ACP subprocess. Falls back to a sane default
   covering both Apple Silicon and Intel Homebrew if zsh query fails.

Bumps version to 1.5.8 (build 10). Includes signed Universal + ARM64
binaries.
2026-04-16 07:51:32 -07:00
Alan Wizemann 117a0ee9dd fix: MCP Servers — preserve all entries when patching config.yaml
Fixes a bug where adding a second MCP server caused the first to disappear
from the list view, and any args containing YAML reserved characters (e.g.
"@modelcontextprotocol/server-fetch") corrupted the config file.

Three root causes in HermesFileService MCP YAML patching:

1. extractMCPBlock extended through trailing comments to EOF when
   mcp_servers was the last top-level key in config.yaml. Trailing
   comments became part of the "block", so subsequent inserts landed
   at end-of-file rather than inside the entry.

2. patchMCPServerField's entry boundary similarly absorbed trailing
   blanks/comments, making the entry "own" everything until the next
   sibling — or until EOF for the last entry.

3. yamlScalar did not quote values starting with YAML reserved
   indicators (@ * & ? | > ! % , [ ] { } < ` ' "). Args like
   "@modelcontextprotocol/server-fetch" were written bare, producing
   invalid YAML that broke subsequent reads/writes.

Fix: trim trailing blanks/comments off both the block and the entry
in the locator/extractor; quote any scalar starting with a reserved
first character.

Bumps version to 1.5.7 (build 9). Includes signed Universal + ARM64
binaries.

Note: users with an already-corrupted ~/.hermes/config.yaml from the
1.5.6 bug should manually clean up their mcp_servers block (delete the
orphan args at end of file) before upgrading. New writes will be clean.
2026-04-16 07:38:49 -07:00
Alan Wizemann 61d59ba0e4 chore: Re-sign 1.5.6 release binaries
The first 1.5.6 zips contained `linker-signed` bundles with no Sealed
Resources, plus a stray nested scarf.app from a case-insensitive cp.
macOS Gatekeeper rejected the ARM64 download as "damaged"; the
Universal one ran only because the user had already trusted it.

Now both bundles are properly ad-hoc-signed (`Sealed Resources
version=2`) with the hardened runtime preserved. Sizes dropped
significantly (Universal 33MB→16MB, ARM64 27MB→13MB) because the
nested junk is gone.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-16 07:13:55 -07:00
Alan Wizemann 0a584f6722 chore: Bump version to 1.5.6 and add release binaries
Includes the MCP Servers management UI shipped in 219bca2:
- Add via curated presets (GitHub, Linear, Notion, Sentry, Stripe, …)
  or fully custom (stdio command + args, or HTTP URL with bearer auth)
- Per-server detail view: enable/disable, env + headers editor,
  tool include/exclude filters, resources/prompts toggles, request
  and connect timeouts, OAuth token detection + clearing
- One-click "Test Connection" runs `hermes mcp test` and surfaces
  the discovered tool list
- Gateway-restart banner after config changes that need a reload

README updated with the MCP Servers section, the new MCPServers/
feature module entry, and the `hermes mcp` + `mcp-tokens/` entries
in the Data Sources table.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-16 06:45:19 -07:00
Alan Wizemann 219bca264e feat: Add MCP Servers management UI
Full MCP server lifecycle: add (stdio + HTTP), edit, remove, test,
enable/disable. YAML config patching for args, env, headers, tool
filters, timeouts. OAuth token detection + deletion. Preset picker
for common MCP servers. Gateway restart banner after config changes.

New sidebar section "MCP Servers" under Manage.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-15 19:53:55 -07:00
Alan Wizemann c7e6a809ed chore: Bump version to 1.5.5 and add release binaries
Ship Hermes v0.9.0 compatibility plus new features (log component
filter, session pill, Fast Mode, Backup/Restore, iMessage, /compress,
Discord threads). README lists both universal and ARM64 downloads.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-15 11:32:16 -07:00
Alan Wizemann c5d6116f99 feat: Add Hermes v0.9.0 compatibility and new feature surfaces
- Log parser: session-ID tag in v0.9.0 log format is now an optional
  capture group; session pill renders inline and tap-filters the view.
- Logs: component filter (Gateway/Agent/Tools/CLI/Cron) and bounded
  logger column with middle truncation.
- Gateway stop: uses `hermes gateway stop` CLI (v0.9.0's launchctl
  bootout fix) with SIGTERM as fallback.
- HermesConfig: new keys for Fast Mode (service_tier), gateway notify
  interval, force IPv4, context engine, interim assistant messages,
  and Honcho eager init (camelCase per PR #6995).
- Settings: new Performance, Network, Advanced, and Backup & Restore
  sections that call `hermes backup` / `hermes import` off the main
  actor; robust zip-path extraction via regex.
- Platforms: iMessage (BlueBubbles) added to KnownPlatforms and
  icon map.
- Cron: Discord thread delivery (`discord:chat:thread`) renders as
  "Discord thread X in Y".
- Chat: `/compress <focus>` button appears when ACP advertises the
  command; optional focus sheet sends through existing prompt path.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-14 16:59:46 -07:00
Alan Wizemann 8672ed1e6c chore: Bump version to 1.5.2 and add universal release binary
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-10 02:53:21 -04:00
Alan Wizemann 46468890d5 feat: Track ACP token usage, improve chat scroll behavior, and show session costs
Add cumulative token tracking from ACP prompt results with fallback
display when DB has no data yet. Improve scroll-to-bottom reliability
with an external trigger for "Return to Active Session" and onAppear
auto-scroll. Show per-session cost in the dashboard session list.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-10 00:15:44 -04:00
Alan Wizemann cd503378e2 fix: Move Tools subprocess calls off main thread to fix toggle rendering
Synchronous Process.run()/waitUntilExit() calls on the main thread blocked
SwiftUI's render loop, causing toggle controls to appear as solid blue
rectangles instead of proper switches. All hermes subprocess and file I/O
calls are now async via Task.detached, toggle uses optimistic state update
for immediate visual feedback, and pipe file handles are properly closed.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 23:16:52 -04:00
Alan Wizemann 86762eab6d fix: Harden ACP session stability and recover messages on reconnection
Sessions were silently dying and losing chat history because:
- Pipe write errors (EPIPE) were completely undetected — broken pipe
  writes via Task.detached { handle.write() } failed silently, leaving
  the app unaware the subprocess had crashed
- Reconnection fell back to newSession() when loadSession() failed,
  creating a blank session and permanently losing all conversation context
- No message reconciliation after reconnect — DB-persisted messages
  were never re-fetched, so the UI stayed stale/incomplete
- Keepalive sent bare "\n" which caused json.loads("") parse errors
  in the ACP library every 30 seconds, destabilizing the connection
- TERM=xterm-256color was set on a pipe-based subprocess, risking
  terminal escape sequence pollution in the JSON-RPC stream

Fixes:
- Replace FileHandle.write() with POSIX Darwin.write() + SIGPIPE
  suppression for immediate broken-pipe detection at all write sites
- Send valid JSON-RPC notification {"jsonrpc":"2.0","method":"$/ping"}
  as keepalive instead of bare newlines
- Never fall back to newSession() during reconnection — try
  resumeSession then loadSession, fail visibly if both fail
- Add reconcileWithDB() to merge DB-persisted messages with local
  state after successful reconnection
- Finalize streaming messages immediately on disconnect so partial
  content is preserved before reconnection begins
- Use SIGINT instead of SIGTERM for graceful Python subprocess shutdown
- Remove TERM env var from ACP subprocess environment
- Consolidate disconnect cleanup into single idempotent method
- Add isHandlingDisconnect guard against double-handling
- Increase reconnect attempts from 3 to 5 with capped backoff
- Add "Reconnect" button to toolbar error state

Also: bump version to 1.5.1, set deployment target to macOS 14.6
(Sonoma), and update README with rich chat/ACP features, process
controls, skill editing, and corrected system requirements.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 13:13:34 -04:00
Alan Wizemann a7fd193770 feat: Add ACP real-time chat with stable connection management
Implement a rich chat interface powered by the Hermes ACP (Agent
Communication Protocol) over JSON-RPC stdio pipes, with comprehensive
connection stability:

- ACPClient actor: manages hermes acp subprocess lifecycle, JSON-RPC
  transport, event streaming via AsyncStream, and session management
- ACPMessages: full event parsing for message chunks, thought chunks,
  tool calls, permission requests, and prompt completion
- RichChatViewModel: streaming message display with live updates,
  tool result rendering, and message grouping
- ChatViewModel: ACP session orchestration, auto-start on first
  message, and terminal mode fallback

Connection stability fixes:
- Non-blocking pipe writes via Task.detached to prevent actor deadlock
- Read loop cleanup (handleReadLoopEnded) finishes event stream and
  fails pending requests on EOF instead of hanging silently
- 30s request timeouts on control messages via watchdog Task pattern
- Keepalive: writes \n to stdin every 30s to detect dead processes
  via EPIPE before the next user action
- Health monitor: polls process.isRunning every 5s as belt-and-suspenders
- Auto-reconnect: retries up to 3 times with exponential backoff
  (1s/2s/4s), restores session, only shows error after all retries fail
- connectionLost event displays system message in chat on failure
- Proper stderr pipe management: stored task reference, closed in stop()
- Idempotent cleanup across handleReadLoopEnded, handleTermination,
  and handleConnectionDied via actor serialization and nil guards

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 04:33:03 -04:00
Alan Wizemann 521c6d63fc refactor: Use MarkdownContentView in rich chat bubbles
Replace inline AttributedString(markdown:) in RichMessageBubble with
the shared MarkdownContentView for consistent styled rendering of
headers, lists, blockquotes, and inline formatting in chat messages.
Code blocks continue to use CodeBlockView with its copy button.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 00:13:17 -04:00
Alan Wizemann 66d04d838d Merge branch 'chat-interface' into main
Add rich chat interface with iMessage-style message bubbles, terminal
toggle, session info bar, code block rendering with copy button, and
tool call cards. Supports both terminal and rich chat display modes.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 00:09:31 -04:00
Alan Wizemann ad30c0a943 feat: Show tool output in Activity inspector (#12)
Add tool result display to the Activity detail pane. When selecting a
tool call, the inspector now shows Arguments → Output → Assistant
Message, giving full visibility into what was requested, what came back,
and how the assistant interpreted it.

- Add fetchToolResult(callId:) query to HermesDataService
- Fetch tool result on entry selection in ActivityViewModel
- Display output in styled monospaced box in detail pane
- Render assistant message with MarkdownContentView

Closes #12

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-08 23:52:42 -04:00
Alan Wizemann 44afa8f53b fix: Hide false external memory provider warning on fresh installs
The config.yaml uses YAML empty string literal (provider: '') which the
parser reads as the literal string '' rather than an empty string. Strip
surrounding quotes before checking so '' and "" are treated as empty.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-08 23:40:35 -04:00
Alan Wizemann 481b937c33 feat: Add rich markdown rendering and skill editing (#11)
Add a custom MarkdownContentView that renders markdown with visual
styling — large headers, styled code blocks with language labels,
bullet and numbered lists, blockquotes with colored borders, and
horizontal rules. YAML frontmatter in skill files is hidden.

Markdown rendering added to:
- Memory view (MEMORY.md, USER.md) with live preview in editor
- Skills view (.md files) with new edit/save capability
- Session messages (assistant responses)
- Dashboard text widgets

Other changes:
- Shared MarkdownRenderer utility for inline formatting
- Split-pane editors (raw markdown left, live preview right)
- saveSkillContent() in HermesFileService with path validation
- Line breaks preserved in non-markdown content (Key: Value format)

Closes #11

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-08 23:30:44 -04:00
Alan Wizemann 790efb585b feat: Add Hermes process start/stop/restart controls (#10)
- Add hermesPID() and stopHermes() to HermesFileService for process
  signal management via SIGTERM
- Add process control bar to Health view with running status, PID
  display, and Start/Stop/Restart buttons
- Add Start/Stop/Restart Hermes quick actions to menu bar
- Start launches gateway, stop sends SIGTERM, restart combines both

Closes #10

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-08 22:48:53 -04:00
Alan Wizemann 3acf95a824 feat: Add Hermes v0.8.0 compatibility and fix Tools tab toggling (#9)
Hermes v0.8.0 support:
- Filter subagent sessions from main list with parent session drill-down
- Add agent.log support (new default log file)
- Add Feishu and Mattermost platforms
- Add Google AI Studio, xAI, Ollama Cloud providers
- Expand cron job model (pre-run scripts, delivery tracking, timeouts, SILENT)
- Add Docker env, command allowlist, and memory profile to config
- Add profile-scoped memory with profile picker
- Add browser backend picker and credential removal to Settings
- Add skills required config warnings
- Consolidate platform icon resolution to single source of truth
- Filter Insights queries to exclude subagent sessions

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

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-08 22:12:13 -04:00
Alan Wizemann 7d69c82c2b Add rich chat interface with iMessage-style bubbles and terminal toggle
Introduce a new structured chat view as an alternative to the SwiftTerm
terminal. Users can switch between raw terminal and rich chat modes via a
segmented picker in the toolbar. The rich view polls state.db for messages
and renders them as conversation bubbles with markdown, code blocks,
expandable tool call cards, reasoning sections, and a live session info bar
showing tokens, cost, and model. The terminal process stays alive in both
modes — in rich mode it runs hidden while user input from the text field is
piped to its stdin.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-06 12:33:55 -04:00
Alan Wizemann ae2872e08f Add Hermes v0.7.0 compatibility: reasoning tokens, cost tracking, schema detection
- Auto-detect v0.7.0 database schema with backward compat for older DBs
- Surface reasoning tokens, actual cost, and billing provider from sessions
- Display model reasoning/thinking content in session message bubbles
- Add cost tracking to Dashboard, Insights, and session detail views
- Fix FTS5 search crash on dotted terms (e.g., "config.yaml", "v0.7.0")
- Add missing platforms: Home Assistant, Webhook, Matrix
- Consolidate platform icon mapping into shared KnownPlatforms.icon(for:)
- Map execute_code tool to ToolKind.execute
- Add Settings UI for reasoning effort, approval mode, show cost
- Show memory provider warning when external provider (Honcho) is active
- Replace fragile manual HermesSession init with withTitle() helper
- De-duplicate formatTokens utility function
- Bump version to 1.4.0

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-04 21:46:03 -04:00
Alan Wizemann 303f4502dd Fix version reporting: update MARKETING_VERSION to 1.3.0
All builds were reporting version 1.0 because the Xcode project version
was never updated from its default. Fixes #5, fixes #7.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-03 13:50:57 -04:00
601 changed files with 116237 additions and 3122 deletions
@@ -0,0 +1,42 @@
<!--
Use this template when submitting a new Scarf project template or updating
an existing one. For regular code/docs PRs, delete this template and write
your own summary.
Switch to this template by adding `?template=template-submission.md` to the
compare URL, or let GitHub pick it up automatically when you touch files
under templates/.
-->
## What's in this PR
- [ ] New template: `templates/<your-handle>/<your-template-name>/`
- [ ] Update to existing template: `templates/<author>/<name>/` (which one and why)
## One-line pitch
_What does this template do for its installers? Two sentences max._
## Checklist
- [ ] I wrote this template, or have the author's explicit permission to submit it.
- [ ] `AGENTS.md` is present and tells any cross-agent what the project does and how to run it.
- [ ] `README.md` includes install, customize, and uninstall instructions.
- [ ] The bundle's `template.json` `contents` claim matches what's actually in the zip.
- [ ] Cron jobs (if any) ship paused and use self-contained prompts.
- [ ] No secrets in any file (API keys, tokens, hostnames, IPs, credentials).
- [ ] No writes to `config.yaml`, `auth.json`, or credential paths — v1 installer will refuse.
- [ ] `python3 tools/build-catalog.py --check` passes locally.
- [ ] I installed + uninstalled this template on my machine and verified the `AGENTS.md` contract works end-to-end.
- [ ] I did **not** edit `templates/catalog.json` — the maintainer regenerates it post-merge.
## Testing notes
_What did you run, what did you see? Paste the log output of the cron job
firing once, or the chat transcript of asking the agent to do the main
thing. Reviewers don't have your machine — show, don't tell._
## Screenshots (optional)
_Drop screenshots of the installed dashboard, or the catalog detail page
rendered locally (`./scripts/catalog.sh preview && open /tmp/scarf-catalog-preview/templates/<slug>/index.html`)._
@@ -0,0 +1,74 @@
# Validates `.scarftemplate` bundles on PRs that touch templates/.
#
# Mirrors the invariants `ProjectTemplateService.verifyClaims` enforces at
# install time. Runs the same Python script the maintainer uses locally
# (tools/build-catalog.py --check) so a bundle can't reach main unless the
# validator is happy.
#
# Also runs tools/test_build_catalog.py so drift between the validator and
# its own test suite is caught on the same PR.
name: Validate template submissions
on:
pull_request:
paths:
- 'templates/**'
- 'tools/build-catalog.py'
- 'tools/test_build_catalog.py'
- '.github/workflows/validate-template-pr.yml'
permissions:
contents: read
pull-requests: write
jobs:
validate:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
with:
# Full clone so we can diff against the PR base and scope
# --only to just the changed templates if we want to later.
fetch-depth: 0
- name: Set up Python
uses: actions/setup-python@v5
with:
# The validator is stdlib-only and tested against 3.9+ (the
# system Python on current macOS, what most maintainers run
# locally). CI uses 3.11 for faster cold-cache times on
# GitHub Actions runners — same stdlib APIs, same code paths.
python-version: '3.11'
- name: Run validator unit tests
run: python3 tools/test_build_catalog.py -v
- name: Validate every template
id: validate
run: |
set -o pipefail
python3 tools/build-catalog.py --check 2>&1 | tee /tmp/validator.log
- name: Post failure comment
if: failure() && steps.validate.outcome == 'failure'
uses: actions/github-script@v7
with:
script: |
const fs = require('fs');
let body = '## Template validation failed\n\n';
try {
const log = fs.readFileSync('/tmp/validator.log', 'utf8');
body += '```\n' + log.slice(-3000) + '\n```\n';
} catch (e) {
body += 'See the failed job log for details.\n';
}
body += '\nFix the issues above and push again — the check reruns automatically.\n';
body += '\nLocal reproduction: `python3 tools/build-catalog.py --check`\n';
await github.rest.issues.createComment({
issue_number: context.issue.number,
owner: context.repo.owner,
repo: context.repo.repo,
body,
});
+15
View File
@@ -1,5 +1,7 @@
# Xcode
build/
.gh-pages-worktree/
.wiki-worktree/
DerivedData/
*.pbxuser
!default.pbxuser
@@ -19,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/
@@ -46,3 +53,11 @@ scarf/standards/backups/
# Scarf project dashboards (user-specific)
.scarf/
# Release artifacts — GitHub Releases hosts the binaries; no need to bloat git
# history. RELEASE_NOTES.md stays tracked (committed with the version bump).
releases/v*/*.zip
releases/v*/appcast-entry.xml
# Wiki helper: personal patterns (hostnames, IPs) blocked from the wiki push.
scripts/wiki-blocklist.txt
+229
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/`
@@ -38,3 +67,203 @@ scarf/scarf/ Xcode project root (PBXFileSystemSynchronizedRootGroup
```bash
xcodebuild -project scarf/scarf.xcodeproj -scheme scarf -configuration Debug build
```
## Releases
Shipped via a single local script. **Never run manual `xcodebuild archive` / `notarytool` / `gh release create` steps — use the script so nothing is skipped or misordered.**
```bash
./scripts/release.sh <version> # full release: notarize → appcast → gh-pages → tag
./scripts/release.sh <version> --draft # draft: everything builds + notarizes, but appcast/tag are skipped
```
The script bumps version, archives Universal (arm64 + x86_64) + ARM64-only variants, signs with Developer ID, notarizes via `xcrun notarytool` (keychain profile `scarf-notary`), staples, EdDSA-signs the appcast entry with Sparkle's key, pushes the appcast to `gh-pages`, and creates a GitHub release with both zips attached. Draft mode stops after the release is uploaded so the current version stays "latest" until explicitly promoted.
**Release notes convention:** write them to `releases/v<version>/RELEASE_NOTES.md` BEFORE running the script — it's auto-included in the version-bump commit and used as the GitHub release body. If absent, a placeholder is used.
**Canonical prompts (any of these trigger the flow):**
- "Release v1.6.2" — full release
- "Release v1.6.2 as draft" — draft mode
- "Prepare v1.6.2 release notes from recent commits, then release" — generate notes first, then run
**Prerequisites (one-time, already set up on Alan's machine):** Developer ID Application cert in login Keychain (team `3Q6X2L86C4`), notarytool keychain profile `scarf-notary`, Sparkle EdDSA private key in Keychain item `https://sparkle-project.org`, `gh-pages` branch + GitHub Pages enabled. See the header of [scripts/release.sh](scripts/release.sh) and the Releases section in [README.md](README.md) for details.
## Wiki
Public documentation lives in the GitHub wiki at https://github.com/awizemann/scarf/wiki. The wiki is a separate git repo cloned to `.wiki-worktree/` in the repo root (gitignored, sibling to `.gh-pages-worktree/`). Internal dev notes stay in `scarf/docs/`; the wiki is for public-facing reference.
**Update the wiki when:**
- A new feature module is added under `scarf/scarf/scarf/Features/` → extend the relevant User Guide page.
- A new core service is added under `Core/Services/` → extend `Core-Services.md`.
- Architecture changes (AppCoordinator, transport, MVVM-F rule, sandbox) → `Architecture-Overview.md` + the specific sub-page.
- Hermes version bumps in this file → `Hermes-Version-Compatibility.md`.
- `scripts/release.sh` completes a full (non-draft) release → bump latest-version on `Home.md` + append to `Release-Notes-Index.md`.
- Keyboard shortcut or sidebar section changes → `Keyboard-Shortcuts.md` / `Sidebar-and-Navigation.md`.
**Skip for:** bug fixes with no user-observable change, pure refactors, typos, test-only changes, internal cleanups.
```bash
./scripts/wiki.sh pull # always first
# edit .wiki-worktree/*.md with normal tools
./scripts/wiki.sh commit "docs: describe X" # runs secret-scan
./scripts/wiki.sh push # runs secret-scan again, then push
```
**Never** commit API keys, tokens, `.env` files, private keys, or real hostnames/IPs to the wiki. The script's two-pass secret-scan blocks common token patterns and a user-maintained blocklist at `scripts/wiki-blocklist.txt` (gitignored). Do not bypass without explicit approval. Full workflow on the wiki itself at `.wiki-worktree/Wiki-Maintenance.md`.
## Hermes Version
Targets Hermes v2026.4.30 (v0.12.0). Log lines may carry an optional `[session_id]` tag between the level and logger name — `HermesLogService.parseLine` treats the session tag as an optional capture group, so older untagged lines still parse.
**Capability gating.** Scarf detects the target's Hermes version once per server connection via [HermesCapabilities](scarf/Packages/ScarfCore/Sources/ScarfCore/Services/HermesCapabilities.swift) (`hermes --version` → semver + `YYYY.M.D` parse). The resulting `HermesCapabilitiesStore` is injected on `ContextBoundRoot` (Mac) and `ScarfGoTabRoot` (iOS) via `.environment(_:)` and `.hermesCapabilities(_:)`; UI that depends on a v0.12+ surface (Curator, Kanban, ACP image input, `auxiliary.curator`, `prompt_caching.cache_ttl`, Piper TTS, Vercel terminal) reads it through the typed environment key. Pre-v0.12 hosts gracefully hide the new affordances rather than throwing on unknown CLI subcommands. Add a new flag at the top of `HermesCapabilities` whenever Scarf gains a release-gated UI surface.
**v2026.4.30 (v0.12.0)** added (Scarf-relevant subset):
- **Autonomous Curator** — `hermes curator` self-prunes / -consolidates the skill library on a 7-day cycle. Reports land at `~/.hermes/logs/curator/run.json` + `REPORT.md`; paths exposed via `HermesPathSet.curatorLogsDir` (`logs/curator`) + `curatorStateFile` (`skills/.curator_state`), with the per-cycle `run.json` / `REPORT.md` resolved at runtime from the `last_report_path` field on the state file. Surfaced in Scarf as a dedicated "Curator" sidebar item under Interact (between Memory and Skills) on Mac, plus a read-mostly iOS panel with Run Now / Pause / Resume actions and inline pin toggles; both gated on `HermesCapabilities.hasCurator`.
- **5 new inference providers** — GMI Cloud, Azure AI Foundry, LM Studio (upgraded to first-class), MiniMax OAuth, Tencent Tokenhub. Mirrored in `ModelCatalogService.overlayOnlyProviders`; the model picker reaches all of them automatically.
- **`flush_memories` aux task removed (server side)** — `auxiliary.flush_memories` is gone from v0.12 Hermes config but remains alive on pre-v0.12 hosts. Scarf preserves `AuxiliarySettings.flushMemories: AuxiliaryModel`, the YAML reader still emits an `aux("flush_memories")` row, and `AuxiliaryTab` only renders the row when `HermesCapabilities.hasFlushMemoriesAux` is `true` (inverse semantics — pre-v0.12 only). v0.12 users never see the row; v0.11 users keep their edit surface.
- **`auxiliary.curator` aux task added** — Curator's review model is configurable independently of the main model. Surfaced in `Settings → Auxiliary` next to the other aux rows.
- **Multimodal ACP `session/prompt`** — ACP advertises and forwards image content blocks. Scarf chat composers (Mac drag/drop + paste; iOS PhotosPicker) attach images that flow through `ACPClient.sendPrompt(sessionId:text:images:)` as `[{"type":"text","text":...}, {"type":"image","data":"<base64>","mimeType":"image/jpeg"}]` — wire shape matches `acp.schema.ImageContentBlock`. `ImageEncoder` downsamples to 1568px long-edge JPEG q=0.85 detached (never blocks MainActor). Gated on `HermesCapabilities.hasACPImagePrompts`.
- **CLI additions:** `hermes -z <prompt>` (non-interactive one-shot), `hermes update --check` (preflight), `hermes fallback` (manage fallback providers), `hermes curator` (status / run / pause / resume / pin / unpin / restore), `hermes kanban` (full task-board CLI; multi-profile collab was reverted upstream so Scarf ships a read-only Kanban view only). All capability-gated.
- **Skills surface:** `hermes skills install <https-url>` direct-URL install (SkillsView "Install from URL…" toolbar button), reload via `hermes skills audit` (Skills "Reload" button — equivalent to the `/reload-skills` slash command for non-ACP contexts), enabled/disabled state read from `skills.disabled` in config.yaml (rendered as strikethrough + "OFF" pill), Curator pin badge from `~/.hermes/skills/.curator_state` (rendered as a pin glyph). The disable-toggle write path is deferred to v2.7 — Hermes only exposes `hermes skills config` as an interactive verb, and Scarf prefers reading accurately to risking a clobbered list.
- **Two new gateway platforms:** Microsoft Teams (19th, plugin-shipped) + Tencent 元宝 / Yuanbao (18th, native). Surfaced in the Mac Platforms tab.
- **Cron upgrades:** per-job `--workdir <abs-path>` (project-aware cwd that pulls AGENTS.md / CLAUDE.md / .cursorrules) is exposed in the editor sheet, gated on `HermesCapabilities.hasCronWorkdir` so pre-v0.12 hosts don't see the field (and a defensive override in `CronView` strips the value before calling `createJob`/`updateJob` even if it was hydrated from a pre-existing job). Pass an empty string on edit to clear an existing workdir, mirroring the `--script` shape. Hermes also added a `context_from` field for chaining cron outputs but only via YAML so far — Scarf reads it (HermesCronJob.contextFrom) but doesn't write it.
- **Settings deltas:** `prompt_caching.cache_ttl` (5m/1h picker), `redaction.enabled` toggle (off-by-default in v0.12 — toggle restores it), `agent.runtime_metadata_footer` toggle, Piper added to TTS provider list, `vercel` added to terminal backend list.
- **Bundled plugins:** Spotify, Google Meet, Langfuse observability, hermes-achievements (visible in Plugins tab).
- **iOS catch-up (Phase H):** read-only Webhooks / Plugins / Profiles tabs (`Scarf iOS/Webhooks/WebhooksView.swift`, `Plugins/PluginsView.swift`, `Profiles/ProfilesView.swift`) parity-match the Mac surfaces but skip mutating CLI verbs. `Scarf iOS/Components/HermesVersionBanner.swift` nudges pre-v0.12 hosts to upgrade (renders only when the connected target is below v0.12).
- **`hermes memory` providers:** honcho, openviking, mem0, hindsight, holographic, retaindb, byterover. `Settings → Memory` lists all providers in the picker; the existing "Run `hermes memory setup` in Terminal" hint stays — `hermes memory setup` is interactive (asks for tokens) so an in-app shellout would surface a frozen UI.
- **Schema is unchanged from v0.11** — same state.db columns (`messages.reasoning_content`, `sessions.api_call_count` introduced in v0.11 remain). No migration needed.
**v2026.4.23 (v0.11.0)** added (historical context, still consumed by Scarf when running against a pre-v0.12 host):
- `/steer <prompt>` — non-interruptive mid-run guidance slash command. Surfaced in Scarf chat menus via `RichChatViewModel.nonInterruptiveCommands`; `ChatViewModel.sendViaACP` (Mac) and `ChatController.send` (iOS) skip the "Agent working…" status flip and show a transient toast instead.
- New CLI subcommands: `hermes plugins` / `profile` / `webhook` / `insights` / `logs` / `memory reset` / `completion` / `dashboard`. Scarf v2.5 adopts **`hermes memory reset`** (toolbar button on MemoryView with destructive confirmation). The other CLIs are documented here for v2.6 — Scarf still reads `~/.hermes/plugins/`, `~/.hermes/profiles/` etc directly today; switching those paths to the canonical CLI is a forward-compatible change to make when bandwidth permits.
- New state.db columns: `messages.reasoning_content` + `sessions.api_call_count`. `HermesDataService.detectSchema` flips `hasV011Schema` only when both are present (partial migrations stay on v0.7 path). Surfaced as the "API" chip on session rows + a network-icon counter in DashboardView. `HermesMessage.preferredReasoning` picks the newer column when both reasoning channels are populated.
- New skills: `design-md` (Google's DESIGN.md authoring; needs `npx`/Node 18+ on host — checked via `SkillPrereqService` and surfaced as a yellow banner) and `spotify` (OAuth via `hermes auth spotify` — driven by `SpotifyAuthFlow` + `SpotifySignInSheet`, mirroring v2.3 Nous Portal pattern).
- 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
Scarf ships a `.scarftemplate` format (v1 as of 2.2.0) for sharing pre-packaged projects across users and machines. A bundle is a zip containing:
- `template.json` — manifest (id, name, version, `contents` claim)
- `README.md` — shown in the install preview sheet
- `AGENTS.md` — required; the [Linux Foundation cross-agent instructions standard](https://agents.md/) — every template is agent-portable out of the box
- `dashboard.json` — copied to `<project>/.scarf/dashboard.json`
- `instructions/…` — optional per-agent shims (`CLAUDE.md`, `GEMINI.md`, `.cursorrules`, `.github/copilot-instructions.md`)
- `skills/<name>/…` — optional; installed to `~/.hermes/skills/templates/<slug>/` (namespaced so uninstall is `rm -rf` on one folder)
- `cron/jobs.json` — optional; registered via `hermes cron create` with a `[tmpl:<id>] …` name prefix and immediately paused
- `memory/append.md` — optional; appended to `~/.hermes/memories/MEMORY.md` between `<!-- scarf-template:<id>:begin/end -->` markers
Key services: [ProjectTemplateService.swift](scarf/scarf/Core/Services/ProjectTemplateService.swift) (inspect + validate + plan), [ProjectTemplateInstaller.swift](scarf/scarf/Core/Services/ProjectTemplateInstaller.swift) (execute a plan), [ProjectTemplateExporter.swift](scarf/scarf/Core/Services/ProjectTemplateExporter.swift) (build a bundle from a project), [ProjectTemplateUninstaller.swift](scarf/scarf/Core/Services/ProjectTemplateUninstaller.swift) (reverse an install using the lock file). UI in [Features/Templates/](scarf/scarf/Features/Templates/). The `scarf://install?url=<https URL>` deep link + `file://` URLs for `.scarftemplate` files are handled by [TemplateURLRouter.swift](scarf/scarf/Core/Services/TemplateURLRouter.swift) and `onOpenURL` in `scarfApp.swift`. A `<project>/.scarf/template.lock.json` uninstall manifest is written after every install and drives the uninstall flow.
**Uninstall semantics:** driven by the lock file. Only files listed in `lock.projectFiles` are removed from the project dir; user-added files (e.g. a `sites.txt` created on first run) are preserved. If every file in the dir was installed by the template, the dir is removed too; otherwise the dir stays with just the user's files. Skills namespace is always removed wholesale (it's isolated). Cron jobs are removed via `hermes cron remove <id>` after resolving each lock-recorded name. Memory block is stripped between the `begin`/`end` markers, leaving the rest of MEMORY.md intact. No "undo" — uninstall is destructive; to re-install, run the install flow again. Uninstall UI lives on the project-list context menu and the dashboard header (only shown when the selected project has a lock file).
**Never** let a template write to `config.yaml`, `auth.json`, sessions, or any credential path — the v1 installer refuses. If you extend the format, treat the preview sheet as load-bearing: the user's only trust boundary is that the sheet is honest about everything that's about to be written.
### Template configuration (v2.3, schemaVersion 2)
Templates can declare a typed configuration schema in `template.json`'s new `config` block. The installer renders a **Configure** step between the parent-directory pick and the preview sheet; values land at `<project>/.scarf/config.json` (non-secret) and in the login Keychain (secret). A post-install **Configuration** button on the dashboard header (shown when `<project>/.scarf/manifest.json` exists) opens the same form pre-filled for editing.
Manifest shape:
```json
{
"schemaVersion": 2,
"contents": { "dashboard": true, "agentsMd": true, "config": 2 },
"config": {
"schema": [
{"key": "site_url", "type": "string", "label": "Site URL", "required": true},
{"key": "api_token", "type": "secret", "label": "API Token", "required": true}
],
"modelRecommendation": {
"preferred": "claude-sonnet-4.5",
"rationale": "Tool-heavy workload — reasoning helps."
}
}
}
```
Supported field types: `string`, `text`, `number`, `bool`, `enum` (with `options: [{value, label}]`), `list` (itemType `"string"` only in v1), `secret`. Type-specific constraints (`pattern`, `min`/`max`, `minLength`/`maxLength`, `minItems`/`maxItems`) are optional. `secret` fields **must not** declare a `default` — the validator refuses.
Key services: [TemplateConfig.swift](scarf/scarf/Core/Models/TemplateConfig.swift) (schema + value models + Keychain ref helpers), [ProjectConfigKeychain.swift](scarf/scarf/Core/Services/ProjectConfigKeychain.swift) (thin `SecItemAdd`/`Copy`/`Delete` wrapper; the only Keychain user in Scarf today), [ProjectConfigService.swift](scarf/scarf/Core/Services/ProjectConfigService.swift) (load/save config.json, resolve secrets, cache manifest, validate schema + values). UI in [Features/Templates/ViewModels/TemplateConfigViewModel.swift](scarf/scarf/Features/Templates/ViewModels/TemplateConfigViewModel.swift) + [Features/Templates/Views/TemplateConfigSheet.swift](scarf/scarf/Features/Templates/Views/TemplateConfigSheet.swift).
**Secret storage.** Keychain service name is `com.scarf.template.<slug>`, account is `<fieldKey>:<project-path-hash-short>`. The path-hash suffix means two installs of the same template in different dirs don't collide on Keychain entries. Values in `config.json` are `"keychain://service/account"` URIs — never plaintext. The bytes hit the Keychain only on form commit, so cancelling never leaves orphan entries.
**Uninstall.** `TemplateLock` v2 gains `config_keychain_items` and `config_fields` arrays. The uninstaller iterates each URI through `SecItemDelete` before removing the lock file. Absent items (user hand-cleaned) are no-ops.
**Exporter.** Carries the *schema* from `<project>/.scarf/manifest.json` through into exported bundles, never values. Exporting never leaks anyone's secrets. `schemaVersion` bumps to 2 only when a schema is forwarded; schema-less exports stay at 1.
**Catalog site.** [tools/build-catalog.py](tools/build-catalog.py) mirrors the Swift schema validator. Each v2 template's `template.json` is copied into `.gh-pages-worktree/templates/<slug>/manifest.json` and the site's `widgets.js` calls `ScarfWidgets.renderConfigSchema` to display the schema on the detail page (display-only — the form lives in-app).
**Schema is Swift-primary.** If `TemplateConfigField.FieldType` gains a new case, update in order: `TemplateConfig.swift` (model + validation), `tools/build-catalog.py` (`SUPPORTED_CONFIG_FIELD_TYPES` + type-specific rules), `widgets.js` (`summariseConstraint`), `TemplateConfigSheet.swift` (new control subview), tests on both sides. Schema drift between validator + installer is the kind of bug users only notice after shipping.
### Project-scoped chat + Scarf-managed AGENTS.md context (v2.3)
v2.3 adds a per-project Sessions tab and a "New Chat" button that spawns `hermes acp` with `cwd = project.path`. Session-to-project attribution is persisted in a Scarf-owned sidecar at `~/.hermes/scarf/session_project_map.json` — the ACP wire protocol has no project-metadata hook (extra params are silently dropped), and `state.db` has no cwd column, so the sidecar is Scarf's source of truth for "which project does this session belong to?" Managed by [SessionAttributionService.swift](scarf/scarf/Core/Services/SessionAttributionService.swift); read by the per-project [ProjectSessionsView.swift](scarf/scarf/Features/Projects/Views/ProjectSessionsView.swift).
**Giving the agent project awareness.** Hermes auto-reads a context file from the session's cwd at startup — priority order `.hermes.md``HERMES.md``AGENTS.md``CLAUDE.md``.cursorrules`, first match wins, 20KB cap. We lean on that by writing a Scarf-managed block into `<project>/AGENTS.md` before opening the session. Service: [ProjectAgentContextService.swift](scarf/scarf/Core/Services/ProjectAgentContextService.swift). Block shape:
```
<!-- scarf-project:begin -->
## Scarf project context
_Auto-generated by Scarf — do not edit between the begin/end markers._
You are operating inside a Scarf project named **"<Project Name>"**. …
- **Project directory:** `<absolute path>`
- **Dashboard:** `<path>/.scarf/dashboard.json`
- **Template:** `<author/id>` v<version> <!-- template-installed only -->
- **Configuration fields:** `field_a`, `field_b (secret — name only, value stored in Keychain)`
- **Registered cron jobs:** `[tmpl:<id>] <name>` — schedule …, currently paused|enabled
- **Uninstall manifest:** `<path>/.scarf/template.lock.json` <!-- when present -->
Any content below this block is template- or user-authored; preserve and defer to it.
<!-- scarf-project:end -->
```
**Invariants.**
- **Secret-safe.** Block surfaces field NAMES, never VALUES. A project with a Keychain-stored secret shows `api_token (secret — name only, …)`; the Keychain ref URI and any plaintext value never appear. Auditable by `refreshListsFieldNamesNotValues` in `ProjectAgentContextServiceTests`.
- **Idempotent.** Two refreshes with unchanged state produce byte-identical output. The write is skipped entirely when no delta, avoiding file-watcher churn.
- **Bounded.** Everything outside the markers is preserved on every refresh. Template-author AGENTS.md content lives safely below the block.
- **Non-fatal.** `ChatViewModel.startACPSession` calls refresh with `try?` + log — a failed write doesn't block the chat from starting; worst case is the session loses project awareness.
- **Refresh timing.** Called BEFORE `client.start()` so the block lands before Hermes's session-boot context scan. Skipping this ordering = the agent sees stale context from the previous refresh (or nothing, on fresh projects).
**Template-author contract.** A template shipped via the catalog should include an `AGENTS.md` with the template's operational instructions. Authors leave the `<!-- scarf-project -->` region alone — Scarf populates it at chat-start time. Everything below is template-owned and preserved.
**Known caveat.** If any parent directory of the project contains `.hermes.md` or `HERMES.md`, those shadow the project's `AGENTS.md` (higher in Hermes's priority order). No fix in v2.3 — deferred to v2.4 pending user input on how to handle authored `.hermes.md` files.
## Template Catalog
Shipped community templates live at `templates/<author>/<name>/` (one level down — `templates/CONTRIBUTING.md` explains the submission flow for authors). The catalog site is generated from this directory and served at `awizemann.github.io/scarf/templates/` alongside the Sparkle appcast — the two coexist on the `gh-pages` branch but touch completely disjoint paths.
Pipeline:
- **Validator + regenerator:** [tools/build-catalog.py](tools/build-catalog.py) is stdlib-only Python (3.9+). It walks `templates/*/*/`, validates every `.scarftemplate` against its manifest claim (mirrors the Swift `ProjectTemplateService.verifyClaims` invariants), enforces a 5 MB bundle-size cap, scans for high-confidence secret patterns, checks `staging/` matches the built bundle byte-for-byte, and emits `templates/catalog.json`. Tested by [tools/test_build_catalog.py](tools/test_build_catalog.py) — 16 tests covering every validation path.
- **Wrapper:** [scripts/catalog.sh](scripts/catalog.sh) mirrors the `scripts/wiki.sh` shape with `check / build / preview / serve / publish` subcommands. `publish` runs a second-pass secret-scan against the rendered site before committing + pushing `gh-pages`.
- **Site source:** `site/index.html.tmpl` + `site/template.html.tmpl` are `{{TOKEN}}`-substitution templates. `site/widgets.js` (~300 lines of vanilla JS) is the dogfood — renders a `ProjectDashboard` JSON into HTML using the same widget vocabulary the Swift app uses, so each template's detail page shows a live preview of its post-install dashboard.
- **Install-URL hosting:** raw-served from `main` at `https://raw.githubusercontent.com/awizemann/scarf/main/templates/<author>/<name>/<name>.scarftemplate`. No per-template Releases ceremony.
- **CI gate:** [.github/workflows/validate-template-pr.yml](.github/workflows/validate-template-pr.yml) runs the Python validator + its own test suite on every PR that touches `templates/`, the validator, or its tests. Failures post a comment on the PR with the last 3 KB of the validator log.
Maintainer workflow on merge to main:
```bash
./scripts/catalog.sh build # regenerate templates/catalog.json + .gh-pages-worktree/templates/
./scripts/catalog.sh publish # secret-scan rendered output + commit + push gh-pages
```
Same cadence as `scripts/release.sh` (manual, auditable, no auto-deploy). Runs stay isolated: release.sh only touches `appcast.xml` on gh-pages; catalog.sh only touches `templates/` on gh-pages. Never push catalog output on a release cadence or vice versa.
**Schema is Swift-primary.** When `ProjectDashboardWidget.type` gains a new case or `ProjectTemplateManifest` adds a field, update Swift first, then mirror into `tools/build-catalog.py` (`SUPPORTED_WIDGET_TYPES`, `_validate_manifest`, `_validate_contents_claim`) so the web validator stays honest. The Python test suite's real-bundle test catches drift on the example template but not on the full widget vocabulary — add a synthetic fixture to `test_build_catalog.py` for any new widget type.
+21
View File
@@ -33,6 +33,27 @@ Rules:
- The app only reads from `~/.hermes/state.db` (never writes). Memory files are the exception.
- Swift 6 strict concurrency: `@MainActor` default isolation, `nonisolated` for service methods.
## Documentation
Public docs live in the [GitHub wiki](https://github.com/awizemann/scarf/wiki). Small fixes (typos, clarifications) can be made via the "Edit" button on any wiki page — you need push access to the main repo. For larger changes, clone the wiki locally (`git clone git@github.com:awizemann/scarf.wiki.git`) or open an issue describing the proposed change.
## Adding a Language
Scarf ships with English + Simplified Chinese, German, French, Spanish, Japanese, and Brazilian Portuguese. To add another locale (or improve an existing one):
1. **Fork** the repo and create a branch.
2. **Add the locale to `knownRegions`** in `scarf/scarf.xcodeproj/project.pbxproj` — follow the existing list (e.g. add `it` after `"pt-BR"`).
3. **Drop a new JSON file at `tools/translations/<locale>.json`** — copy an existing one (say `tools/translations/es.json`) as a starting point. Each entry maps the English source string to your translation. Keys you omit fall back to English at runtime — do that for proper nouns (Scarf, Hermes, Anthropic, OAuth, SSH, …) and for anything technical that shouldn't translate.
4. **Preserve format specifiers exactly**: `%@`, `%lld`, `%d`, positional `%1$@` / `%2$lld`, etc. If word order needs to change in your language, use positional forms (`%1$@ … %2$@`).
5. **Add your locale to `tools/merge-translations.py`'s `LOCALES` list** and run `python3 tools/merge-translations.py` — this writes your translations into `scarf/scarf/Localizable.xcstrings`.
6. **Translate `scarf/scarf/InfoPlist.xcstrings`** (the macOS microphone-permission prompt) for your locale. Add a new `stringUnit` under `localizations`.
7. **Build** (`xcodebuild -project scarf/scarf.xcodeproj -scheme scarf build`) and **sanity-check in Xcode**: Scheme → Run → App Language → your locale. Walk the main views (Dashboard, Chat, Settings) and look for clipping or obvious leaks.
8. **Open a PR** including the new JSON file, the updated catalog, and the pbxproj / script changes. Mention which routes you spot-checked.
AI translation is fine for the first pass — it's how the initial six locales landed. Native-speaker review improves quality and is always welcome, either as a follow-up PR or as review comments on the initial one.
See [scarf/docs/I18N.md](scarf/docs/I18N.md) for deeper context on the String Catalog setup and which strings are intentionally kept verbatim.
## Reporting Issues
Open an issue with:
+188 -25
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>
@@ -10,44 +10,158 @@
</p>
<p align="center">
<img src="https://img.shields.io/badge/macOS-26.2+-blue" alt="macOS">
<img src="https://img.shields.io/badge/macOS-14.6+%20Sonoma-blue" alt="macOS">
<img src="https://img.shields.io/badge/Swift-6-orange" alt="Swift">
<img src="https://img.shields.io/badge/license-MIT-green" alt="License">
<br>
<em>Available in English, 简体中文, Deutsch, Français, Español, 日本語, and Português (Brasil).</em>
<br><br>
<a href="https://www.buymeacoffee.com/awizemann"><img src="https://cdn.buymeacoffee.com/buttons/v2/default-yellow.png" alt="Buy Me a Coffee" height="28"></a>
</p>
## What's New in 2.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. Multi-server, project-scoped chat, session resume, memory editor, cron list, skills tree, settings (read), all native iOS. Pure-Swift SSH (Citadel under the hood — no `ssh` binary needed on iOS). Per-project chat writes the same Scarf-managed `AGENTS.md` block the Mac app does, so the agent boots with the same project context regardless of which client opened the session.
**[Join the public TestFlight](https://testflight.apple.com/join/qCrRpcTz)** — the link is live now but only accepts new beta testers once Apple's Beta Review approves the first build. If you hit a "not accepting testers" splash, bookmark it and try again in 2448h.
<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>
<p align="center"><sub><em>Tap any thumbnail to view full size. Servers list · Chat · Project dashboard (Site Status Checker template) · Skills browser · System tab.</em></sub></p>
See the [ScarfGo wiki page](https://github.com/awizemann/scarf/wiki/ScarfGo) for the full feature tour, [ScarfGo Onboarding](https://github.com/awizemann/scarf/wiki/ScarfGo-Onboarding) for the SSH-key setup walkthrough, and [Platform Differences](https://github.com/awizemann/scarf/wiki/Platform-Differences) for what is and isn't shared between Mac and iOS.
### Everything else in 2.5
- **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.
See the full [v2.5.0 release notes](https://github.com/awizemann/scarf/releases/tag/v2.5.0).
**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.
## Connect ScarfGo to your Hermes server
ScarfGo speaks SSH directly — no companion service, no developer-controlled server in between. Onboarding takes about a minute:
1. **Install via TestFlight.** Open the [public TestFlight link](https://testflight.apple.com/join/qCrRpcTz) on your phone, accept the invite, install ScarfGo from TestFlight (just like any other beta).
2. **Tap Add Server.** Enter the host (IP or DNS), SSH user, port (default 22), and an optional nickname. Same details you'd type into `ssh user@host`.
3. **Generate Key.** ScarfGo creates a fresh Ed25519 keypair on the device. The private half lives in the iOS Keychain (`kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly`) and is excluded from iCloud sync — it never leaves the phone.
4. **Add the public key to your Hermes host.** Tap **Copy public key**, then on the host run:
```bash
cat >> ~/.ssh/authorized_keys <<'EOF'
<paste the line ScarfGo showed you>
EOF
chmod 600 ~/.ssh/authorized_keys
```
This is its own line per device — the convention any second SSH client uses. Mac (Scarf) keeps using your existing ssh-agent / `~/.ssh/config` and is unaffected.
5. **Tap Test connection.** ScarfGo opens an SSH session, probes for the `hermes` binary, and saves the server on success. If it can't find `hermes`, see the [troubleshooting section](https://github.com/awizemann/scarf/wiki/ScarfGo-Onboarding#troubleshooting) — it's almost always a `PATH` quirk on non-interactive SSH.
Done. Open the Dashboard tab and tap any session to resume it; tap the **+** in Chat to start a new project-scoped session.
## Multi-server, one window per server
Scarf 2.0 is a multi-window app. Each window is bound to exactly one Hermes server — your local `~/.hermes/` is synthesized automatically, and you can add remotes via **File → Open Server…** → **Add Server** (host, user, port, optional identity file). Open a second window for a different server and the two run side-by-side with independent state.
Remote Hermes is reached over system SSH — the same `~/.ssh/config`, ssh-agent, ProxyJump, and ControlMaster pooling your terminal uses. File I/O flows through `scp`/`sftp`; SQLite is served from atomic `sqlite3 .backup` snapshots cached under `~/Library/Caches/scarf/snapshots/<server-id>/`; chat (ACP) tunnels as `ssh -T host -- hermes acp` with JSON-RPC over stdio end-to-end. Everything in the feature list below works against remote identically to local.
### Remote setup requirements
The remote host must have:
1. **SSH access** — key-based auth via your local ssh-agent. Scarf never prompts for passphrases; run `ssh-add` once in Terminal before connecting.
2. **`sqlite3`** on the remote `$PATH` — needed for the atomic DB snapshots. Install on the remote with `apt install sqlite3` (Ubuntu/Debian), `yum install sqlite` (RHEL/Fedora), or `apk add sqlite` (Alpine).
3. **`pgrep`** on the remote `$PATH` — used by the Dashboard "is Hermes running" check. Standard on every distro; install `procps` if missing.
4. **`~/.hermes/` readable by the SSH user**. When Hermes runs as a separate user (systemd service, Docker container), the SSH user needs read access to `config.yaml` and `state.db`. Either (a) SSH as the Hermes user, (b) `chmod` Hermes's home to be group-readable and add your SSH user to that group, or (c) set the **Hermes data directory** field when adding the server to point at the right location (e.g. `/var/lib/hermes/.hermes`).
### Troubleshooting remote connections
If the connection pill is green but the Dashboard shows "Stopped", "unknown", or empty values, the SSH user can't read the Hermes state files. Open **Manage Servers → 🩺 Run Diagnostics** (or click the yellow "Can't read Hermes state" pill in the toolbar). The diagnostics sheet runs fourteen checks in one SSH session — connectivity, `sqlite3` presence, read access to `config.yaml` and `state.db`, the effective non-login `$PATH` — and tells you exactly which one fails and why, with remediation hints for each. Use the **Copy Full Report** button to paste the full output into a bug report.
For the common "Hermes isn't at the default path" case (systemd services, Docker), **Test Connection** in the Add Server sheet now probes `/var/lib/hermes/.hermes`, `/opt/hermes/.hermes`, `/home/hermes/.hermes`, and `/root/.hermes` when it can't find `state.db` at `~/.hermes/`, and offers a one-click fill if it finds any of them.
## Features
- **Dashboard** — System health, token usage, recent sessions with live refresh
- **Insights** — Usage analytics with token breakdown, model/platform stats, top tools bar chart, activity heatmaps, notable sessions, and time period filtering (7/30/90 days or all time)
- **Sessions Browser** — Full conversation history with message rendering, tool call inspection, full-text search, rename, delete, and JSONL export
- **Activity Feed** — Recent tool execution log with filtering by kind and session, detail inspector with pretty-printed arguments
- **Live Chat** — Embedded terminal running `hermes chat` with full ANSI color and Rich formatting via [SwiftTerm](https://github.com/migueldeicaza/SwiftTerm), session persistence across navigation, resume/continue previous sessions, and voice mode controls
- **Memory Viewer/Editor** — View and edit Hermes's MEMORY.md and USER.md with live file-watcher refresh
- **Skills Browser** — Browse all installed skills by category with file content viewer and file switcher
- **Tools Manager** — Enable/disable toolsets per platform (CLI, Telegram, Discord, etc.) with toggle switches, MCP server status
Scarf mirrors Hermes's surface area through a sidebar-based UI. Sections below map 1:1 to the app's sidebar.
### Monitor
- **Dashboard** — System health, token usage, cost tracking, recent sessions with live refresh
- **Insights** — Usage analytics with token breakdown (including reasoning tokens), cost tracking, model/platform stats, top tools bar chart, activity heatmaps, notable sessions, and time period filtering (7/30/90 days or all time)
- **Sessions Browser** — Full conversation history with message rendering, model reasoning/thinking display, tool call inspection, full-text search, rename, delete, and JSONL export. Subagent sessions are filtered from the main list and accessible via parent session drill-down
- **Activity Feed** — Recent tool execution log with filtering by kind and session, detail inspector with pretty-printed arguments and tool output display
### Interact
- **Live Chat** — Two modes: **Rich Chat** streams responses in real-time via the Agent Client Protocol (ACP) with iMessage-style bubbles, markdown rendering, tool call visualization, thinking/reasoning display, permission request dialogs, and a one-click `/compress` focus sheet (when Hermes advertises the command); **Terminal** runs `hermes chat` with full ANSI color and Rich formatting via [SwiftTerm](https://github.com/migueldeicaza/SwiftTerm). Both modes support session persistence, resume/continue previous sessions, auto-reconnection with session recovery, and voice mode controls
- **Memory Viewer/Editor** — View and edit Hermes's MEMORY.md and USER.md with live file-watcher refresh, external memory provider awareness (Honcho, Supermemory, etc.), and profile-scoped memory support with profile picker
- **Skills Browser** — Browse installed skills by category with file content viewer and required config warnings. **New in 1.6:** Browse the Skills Hub, search by registry (official, skills.sh, well-known, GitHub, ClawHub, LobeHub), install, check for updates, and uninstall — all from the app
### Configure *(new in 1.6)*
- **Platforms** — Native GUI setup for all 13 messaging platforms (Telegram, Discord, Slack, WhatsApp, Signal, Email, Matrix, Mattermost, Feishu, iMessage, Home Assistant, Webhook, CLI). Per-platform forms write credentials to `~/.hermes/.env` and behavior toggles to `~/.hermes/config.yaml`. WhatsApp and Signal pairing use an inline SwiftTerm terminal for QR scan and signal-cli daemon management
- **Personalities** — List defined personalities, pick the active one, and edit `SOUL.md` inline with markdown preview
- **Quick Commands** — Editor for custom `/command_name` shell shortcuts with dangerous-pattern detection (`rm -rf`, `mkfs`, etc.)
- **Credential Pools** — Per-provider credential rotation with a fixed OAuth flow (URL extraction + browser open + code paste) and proper `--type api-key` handling. API keys never stored in UI state — only last-4 preview. Strategy picker (fill_first / round_robin / least_used / random)
- **Plugins** — Install via Git URL or `owner/repo`, update, remove, enable/disable. Reads `~/.hermes/plugins/` directly for reliable state
- **Webhooks** — Create, list, test-fire, and remove webhook subscriptions. Detects the "platform not enabled" state and links to gateway setup
- **Profiles** — Switch between multiple isolated Hermes instances. Create, rename, delete, export (zip), import. Safe-switch warning reminds users to restart Scarf after activating a different profile
### Manage
- **Tools** — Enable/disable toolsets per platform with a connectivity-aware platform menu (green/orange/grey/red dots for connected/configured/offline/error). **Fixed in 1.6:** all 13 platforms now appear (was previously stuck on CLI)
- **MCP Servers** — Manage Model Context Protocol servers Hermes connects to. Add via curated presets (GitHub, Linear, Notion, Sentry, Stripe, and more) or fully custom (stdio command + args, or HTTP URL with optional bearer auth). Per-server detail view with enable/disable toggle, environment variable + header editor, tool-include/exclude filters, resources/prompts toggles, request and connect timeouts, OAuth token detection + clearing, and one-click "Test Connection" that runs `hermes mcp test` and surfaces the discovered tool list. Gateway-restart banner appears after config changes that require a reload
- **Gateway Control** — Start/stop/restart the messaging gateway, view platform connection status, manage user pairing (approve/revoke)
- **Cron Manager** — View scheduled jobs, their status, prompts, and output
- **Log Viewer** — Real-time log tailing with level filtering and text search
- **Project Dashboards** — Custom, agent-generated dashboards for any project. Define stat boxes, charts, tables, progress bars, checklists, rich text, and embedded web views in a simple JSON file — Scarf renders them with live refresh. Let your Hermes agent build and maintain project-specific visualizations automatically
- **Settings** — Structured config editor for all Hermes settings
- **Cron Manager** — View scheduled jobs with pre-run scripts, delivery failure tracking, timeout info, and `[SILENT]` job indicators. **New in 1.6:** full write support — create, edit, pause, resume, run-now, and delete jobs from the app
- **Health** — Component-level status and diagnostics. **New in 1.6:** inline "Run Dump" and "Share Debug Report" buttons (the latter with an upload-confirmation dialog before sending to Nous support)
- **Log Viewer** — Real-time log tailing for agent.log, errors.log, and gateway.log with level filtering, component filter (Gateway / Agent / Tools / CLI / Cron), clickable session-ID pills that filter to a single session, and text search
- **Settings** — **Restructured in 1.6** into a 10-tab layout: General, Display, Agent, Terminal, Browser, Voice, Memory, Aux Models, Security, Advanced. Exposes ~60 previously hidden config fields including all 8 auxiliary model tasks, container limits, full TTS/STT provider settings, human-delay simulation, compression thresholds, logging rotation, checkpoints, website blocklist, Tirith sandbox, and delegation. One-click **Backup & Restore** via `hermes backup` / `hermes import`. Model picker replaces the old free-text model field, backed by the models.dev cache (111 providers, all major models) with a "Custom…" escape hatch
### Project Dashboards
Custom, agent-generated dashboards for any project. Define stat boxes, charts, tables, progress bars, checklists, rich text, and embedded web views in a simple JSON file — Scarf renders them with live refresh. Let your Hermes agent build and maintain project-specific visualizations automatically. See [Project Dashboards](#project-dashboards-1) below for the full schema.
### System
- **Hermes Process Control** — Start, stop, and restart the Hermes agent directly from Scarf
- **Menu Bar** — Status icon showing Hermes running state with quick actions
## Requirements
- macOS 26.2+
- Xcode 26.3+
- [Hermes agent](https://github.com/hermes-ai/hermes-agent) v0.6.0+ installed at `~/.hermes/`
- macOS 14.6+ (Sonoma) for Scarf
- iOS 18.0+ for [ScarfGo](https://github.com/awizemann/scarf/wiki/ScarfGo) (the iPhone companion, public TestFlight from v2.5)
- Xcode 16.0+ to build from source
- [Hermes agent](https://github.com/hermes-ai/hermes-agent) v0.6.0+ installed at `~/.hermes/` on each target host (v0.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
Scarf reads Hermes's SQLite database (schema v6) and parses CLI output from `hermes status`, `hermes doctor`, `hermes tools`, `hermes sessions`, `hermes gateway`, and `hermes pairing`. Tested and verified against:
Scarf reads Hermes's SQLite database and parses CLI output from `hermes status`, `hermes doctor`, `hermes tools`, `hermes sessions`, `hermes gateway`, and `hermes pairing`. Automatic schema detection provides backward compatibility with older databases while supporting new features in newer Hermes versions.
| Hermes Version | Status |
|----------------|--------|
| v0.6.0 (2026-03-30) | Verified |
| v0.6.0 (2026-03-31, latest) | Verified |
| v0.7.0 (2026-04-03) | Verified |
| v0.8.0 (2026-04-08) | Verified |
| v0.9.0 (2026-04-13) | Verified |
| v0.10.0 (2026-04-16) | Verified (Tool Gateway introduced) |
| v0.11.0 (2026-04-23) | **Verified — current target (recommended for full v2.5 feature support)** |
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.
@@ -55,11 +169,29 @@ If a Hermes update changes the database schema or CLI output format, Scarf may n
### Pre-built Binary (no Xcode required)
Download the latest universal binary (Apple Silicon + Intel) from [Releases](https://github.com/awizemann/scarf/releases):
Download the latest build from [Releases](https://github.com/awizemann/scarf/releases):
1. Download `Scarf-vX.X.X-Universal.zip`
2. Unzip and drag **Scarf.app** to Applications
3. On first launch, right-click and choose **Open** (or go to System Settings → Privacy & Security → Open Anyway)
- `Scarf-vX.X.X-Universal.zip` — Apple Silicon + Intel (recommended)
- `Scarf-vX.X.X-ARM64.zip` — Apple Silicon only (smaller download)
1. Unzip and drag **Scarf.app** to Applications
2. Launch normally — builds are Developer ID signed and notarized, so Gatekeeper accepts them on first launch
Scarf checks for updates automatically on launch via [Sparkle](https://sparkle-project.org) and daily thereafter. You can disable automatic checks or trigger a manual check from **Settings → General → Updates** or the menu bar icon.
#### "Scarf.app is damaged" on first launch
If Gatekeeper rejects the app on first launch (occasionally happens on macOS 14+ for zip-distributed apps depending on extraction tool + quarantine state), the bundle itself is fine — every release is verified to pass `codesign --verify --strict --deep` and `spctl --assess --type execute` before it ships. The fix is to **only remove the quarantine attribute**, never strip all xattrs or re-sign:
```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
@@ -90,10 +222,11 @@ scarf/
Sessions/ Conversation browser with rename, delete, export
Activity/ Tool execution feed with inspector
Projects/ Agent-generated project dashboards with widget rendering
Chat/ Embedded terminal via SwiftTerm with voice controls
Chat/ Rich ACP chat and embedded terminal with voice controls
Memory/ Memory viewer and editor
Skills/ Skill browser by category
Tools/ Toolset management per platform
MCPServers/ MCP server registry, presets, OAuth, tool filters, test runner
Gateway/ Messaging gateway control and pairing
Cron/ Scheduled job viewer
Logs/ Real-time log viewer
@@ -114,11 +247,14 @@ Scarf reads Hermes data directly from `~/.hermes/`:
| `logs/*.log` | Text | Read-only |
| `gateway_state.json` | JSON | Read-only |
| `skills/` | Directory tree | Read-only |
| `hermes acp` | ACP subprocess (JSON-RPC stdio) | Real-time chat |
| `hermes chat` | Terminal subprocess | Interactive |
| `hermes tools` | CLI commands | Enable/Disable |
| `hermes sessions` | CLI commands | Rename/Delete/Export |
| `hermes gateway` | CLI commands | Start/Stop/Restart |
| `hermes pairing` | CLI commands | Approve/Revoke |
| `hermes mcp` | CLI commands | Add/Remove/Test MCP servers |
| `mcp-tokens/*.json` | JSON (per-server OAuth) | Detect/Delete |
| `.scarf/dashboard.json` | JSON (per-project) | Read-only |
| `scarf/projects.json` | JSON (registry) | Read/Write |
@@ -129,6 +265,7 @@ The app opens `state.db` in read-only mode to avoid WAL contention with Hermes.
| Package | Purpose |
|---------|---------|
| [SwiftTerm](https://github.com/migueldeicaza/SwiftTerm) | Terminal emulator for the Chat feature |
| [Sparkle](https://github.com/sparkle-project/Sparkle) | Auto-updates from the GitHub-hosted appcast |
Everything else uses system frameworks: SQLite3 C API, Foundation JSON, AttributedString markdown, SwiftUI Charts, GCD file watching.
@@ -136,7 +273,7 @@ Everything else uses system frameworks: SQLite3 C API, Foundation JSON, Attribut
Scarf watches `~/.hermes/` for file changes and queries the SQLite database for sessions, messages, and analytics. Views refresh automatically when Hermes writes new data.
The Chat tab spawns `hermes chat` as a subprocess in a pseudo-terminal, giving you the full interactive CLI experience with proper ANSI rendering. Sessions persist across navigation — switch tabs and come back without losing your conversation.
The Chat tab has two modes. **Rich Chat** communicates with Hermes via the Agent Client Protocol (ACP) — a JSON-RPC connection over stdio — streaming responses in real-time with automatic reconnection and session recovery on connection loss. **Terminal** mode spawns `hermes chat` in a pseudo-terminal for the full interactive CLI experience with proper ANSI rendering. Sessions persist across navigation in both modes — switch tabs and come back without losing your conversation.
Management actions (renaming sessions, toggling tools, editing memory) call the Hermes CLI or write directly to the appropriate files, keeping Scarf and Hermes in sync.
@@ -278,6 +415,30 @@ Your agent can update the dashboard as part of cron jobs, after builds, or whene
Each section defines a grid with 14 columns. Widgets flow left-to-right, wrapping to new rows. See [DASHBOARD_SCHEMA.md](scarf/docs/DASHBOARD_SCHEMA.md) for the full schema reference with examples of every widget type.
## Releases
Scarf ships through GitHub releases — the App Store is not supported because Scarf spawns the user-installed `hermes` binary and reads `~/.hermes/` directly, both of which App Sandbox forbids.
Each release goes through a single local script: [scripts/release.sh](scripts/release.sh). The script archives a universal binary, signs it with the Developer ID Application cert, submits to `notarytool`, staples the ticket, produces the distribution zip, signs an appcast entry with Sparkle's EdDSA key, pushes an updated `appcast.xml` to the `gh-pages` branch, creates the GitHub release, and tags `main`.
The Sparkle appcast is served from [awizemann.github.io/scarf/appcast.xml](https://awizemann.github.io/scarf/appcast.xml).
Signing prerequisites (one-time):
- `Developer ID Application` certificate in the login Keychain
- `scarf-notary` keychain profile registered via `xcrun notarytool store-credentials`
- Sparkle EdDSA private key in Keychain item `https://sparkle-project.org` (back this up — without it, shipped apps can never receive updates)
## Template Catalog
Community-contributed Scarf project templates live under [`templates/`](templates/) in this repo and are browsable at **[awizemann.github.io/scarf/templates/](https://awizemann.github.io/scarf/templates/)** with live dashboard previews and one-click `scarf://install?url=…` links.
- **Install from the web** — click "Install with Scarf" on any template's detail page; the app takes over from there.
- **Install from a local file** — Scarf → Projects → Templates → Install from File…, or double-click any `.scarftemplate` in Finder.
- **Author a template** — see [`templates/CONTRIBUTING.md`](templates/CONTRIBUTING.md) for the full walkthrough. Fork, drop a template under `templates/<your-github-handle>/<your-name>/`, open a PR; CI validates the bundle automatically.
The catalog's site is a static HTML + vanilla JS build generated by [`tools/build-catalog.py`](tools/build-catalog.py) and driven by [`scripts/catalog.sh`](scripts/catalog.sh) (check / build / preview / publish). Appcast and main landing page are independent — updating the catalog never disturbs Sparkle.
## Contributing
Contributions are welcome. Please open an issue to discuss what you'd like to change before submitting a PR.
@@ -288,6 +449,8 @@ Contributions are welcome. Please open an issue to discuss what you'd like to ch
4. Push to the branch (`git push origin feature/my-feature`)
5. Open a Pull Request
For template submissions, see [`templates/CONTRIBUTING.md`](templates/CONTRIBUTING.md) — same flow, with a catalog-specific checklist + automated CI validation.
## Support
If you find Scarf useful, consider buying me a coffee.
Binary file not shown.

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

Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+25
View File
@@ -0,0 +1,25 @@
## What's New in 1.6.1
### Auto-updates
Scarf now ships with [Sparkle](https://sparkle-project.org). On launch (and daily thereafter) it checks an EdDSA-signed appcast at [awizemann.github.io/scarf/appcast.xml](https://awizemann.github.io/scarf/appcast.xml). When a new version is available you'll get an in-app update prompt — no more manually downloading zips and dragging into Applications.
You can disable automatic checks or trigger a manual one from **Settings → General → Updates**, the menu bar icon, or the **Scarf → Check for Updates…** menu item.
### Notarized & Developer ID signed
This is the first release that's properly Developer ID signed and notarized by Apple. Gatekeeper accepts it on first launch — no more right-click → Open dance, no more "Scarf cannot be opened because the developer cannot be verified" warnings.
### Fixes
- Chat works correctly when no terminal hermes session is running, and surfaces the real error when it can't reach the agent (b6df…)
### Under the hood
- Tracked `Info.plist` (replacing auto-generation) so signing-relevant keys live in version control
- New `UpdaterService` wraps Sparkle and is injected via SwiftUI `.environment()`
- One-command release pipeline at [scripts/release.sh](https://github.com/awizemann/scarf/blob/main/scripts/release.sh) handles archive → sign → notarize → staple → appcast → GitHub release → tag
---
**Migrating from 1.6.0:** unzip and replace your existing `Scarf.app` in `/Applications`. After this release, future updates install in-place via Sparkle.
+13
View File
@@ -0,0 +1,13 @@
## What's New in 1.6.2
### Fixes
- **No more bogus "missing credentials" banner on Chat.** The orange "No AI provider credentials detected" warning was firing on the Chat tab whenever no session was selected, even for users whose credentials were configured and working. Root cause: the preflight check only inspected `~/.hermes/.env` and shell environment variables, missing the Credential Pools file at `~/.hermes/auth.json` (the in-app flow introduced in 1.6.0) and `api_key:` fields in `config.yaml`. The check now covers all four locations Hermes itself reads at runtime, so if you've added credentials via **Configure → Credential Pools**, the warning stays hidden.
### Polish
- Banner subtitle updated to point users at the in-app Credential Pools flow first, rather than prescribing `.env` edits.
---
**Upgrading from 1.6.1:** Sparkle will offer the update automatically. You can also trigger a check via **Scarf → Check for Updates…** or the menu bar icon.
+58
View File
@@ -0,0 +1,58 @@
## What's New in 2.0
Scarf now manages **multiple Hermes installations** — your local `~/.hermes/` plus any number of remote Hermes instances reached over SSH. Every feature that worked on your Mac now works against a Linux server, a Mac mini on the network, or whatever other host has Hermes installed.
This is a major version bump because the entire service layer was rewritten around a `ServerContext` + `ServerTransport` abstraction, and because the window model changed from single-window-single-server to multi-window-one-server-per-window.
### Multi-server
- **Manage Servers** sheet lets you add, rename, and remove remote servers. Each entry is an SSH target (`user@host`, port, optional identity file, optional `remoteHome` override if your install isn't at `~/.hermes/`).
- Each window is bound to exactly one server. Open a second window via **File → Open Server** → pick a different server, and the two run side-by-side with independent state — chat, dashboards, activity, sessions, the lot.
- The menu bar status icon shows a summary across all registered servers (green hare = any Hermes running anywhere).
- Window-state restoration: quit + relaunch re-opens every window you had open, each reconnected to its bound server.
### Remote over SSH
- **ControlMaster connection pooling** — after the first auth, each remote primitive is a ~5ms tunnel call. Uses the system `ssh`, `scp`, `sftp` so your `~/.ssh/config`, ssh-agent, 1Password/Secretive SSH agents, and ProxyJump all work unchanged.
- **DB access via atomic snapshots** — Scarf runs `sqlite3 .backup` on the remote (WAL-safe, won't corrupt), flips the snapshot out of WAL mode, and pulls it down with `scp`. Snapshots are cached under `~/Library/Caches/scarf/snapshots/<server-id>/` and re-pulled when the file watcher sees a change on the remote's `state.db`.
- **ACP chat over SSH** — the Agent Client Protocol tunnel runs `ssh -T host -- hermes acp`. JSON-RPC over stdio travels end-to-end unmodified, so Rich Chat, streaming, tool calls, permission dialogs, and compression all work against the remote agent identically to local.
- **File watcher** — local uses FSEvents (instant); remote polls `stat` mtime every 3s with ControlMaster keeping the cost bounded. Views auto-refresh on any tick.
- **Cleanup on server-remove** — deleting a remote closes its ControlMaster socket (`ssh -O exit`), prunes its snapshot cache, and invalidates any process-wide caches keyed to its ID. App launch also sweeps orphaned snapshot dirs whose UUIDs are no longer in the registry.
### Chat UX overhaul
All of these were visible bugs during remote dogfooding and are now fixed on both local and remote:
- **No more white-screen flash** on the first message of a session. `RichChatView` used to swap `ContentUnavailableView` out for the message list, which tore down and recreated the entire ScrollView hierarchy. The empty state now lives inside the ScrollView itself.
- **No more scroll-jumping to whitespace** at stream start/finish. Replaced six racing `onChange`-driven scroll calls with SwiftUI's built-in `.defaultScrollAnchor(.bottom)`, which is implemented inside the layout pass and doesn't overshoot LazyVStack content.
- **Resuming a session on a remote now shows its full history.** The DB snapshot is refreshed on session-load — previously it was pulled once on first open and never again, so any messages the remote wrote since launch were invisible.
- **"Continue from last session" surfaces errors** instead of silently doing nothing when SSH is down.
- **Typing into a blank Chat always creates a new session.** Previously it auto-resumed the most recently active session in the DB, which often picked up a cron-spawned session that Hermes had already garbage-collected — producing a silent prompt failure.
- **Failed prompts now explain themselves.** When the agent returns `stopReason: "refusal"`, `"error"`, or `"max_tokens"` with no assistant output, a system message appears under your prompt explaining what happened. No more spinning "Agent working…" forever.
### Correctness — remote SQLite
- The WAL-error spam (`cannot open file at line 51044 of [f0ca7bba1c] — os_unix.c:51044: (2) open(/Users/…/state.db-wal) - No such file or directory`) is gone. `sqlite3 .backup` preserves the source DB's journal mode; the scp'd copy used to try to open a WAL sidecar that doesn't exist. The snapshot script now runs `PRAGMA journal_mode=DELETE` after `.backup` on the remote, and Scarf opens remote snapshots with `file:…?immutable=1` as defense-in-depth.
- **Concurrent snapshot dedupe** — a new `SnapshotCoordinator` actor makes sure that when Dashboard + Sessions + Activity all ask for a fresh snapshot at the same moment (e.g. on a file-watcher tick), only one SSH backup runs; the other callers await the in-flight pull and share the result.
### Under the hood
- New `ServerContext` value type flows through `.environment()` to every view and ViewModel. Every file and process operation routes through `context.makeTransport()``LocalTransport` (`FileManager`, `Process`, FSEvents) or `SSHTransport` (ssh, scp, sftp, mtime polling). The protocol is small enough that each transport is ~400 lines.
- Swift 6 complete-concurrency sweep: ~230 warnings reduced to 1. `ServerContext`, `HermesPathSet`, `ServerTransport`, all service inits, and every value-type accessor are explicitly `nonisolated`. Hand-written `Codable` conformances for the nine types whose synthesized conformances were inferred `@MainActor` by Swift 6's default-isolation rule (`ACPRequest`, `ACPRawMessage`, `GatewayState`, `PlatformState`, `HermesCronJob`, `CronSchedule`, `CronJobsFile`, `AuthFile`, `AuthEntry`).
- ACP cwd now comes from the *remote* `$HOME`, probed once on first connect and cached per server. Previously it passed your local Mac's home path to the ACP adapter, which only worked by coincidence when the remote username matched.
### Compatibility
Hermes v0.10.0 is now verified alongside v0.6v0.9. Scarf builds its session/message `SELECT` columns based on an additive schema detection (`hasV07Schema`), so newer Hermes versions with extra columns don't break queries.
### Migration from 1.6.x
- Sparkle will offer the update automatically. Trigger manually via **Scarf → Check for Updates…** or the menu bar.
- Your local server is synthesized automatically — existing 1.6.x users see "Local" in the server list with no setup needed.
- `servers.json` is created on first add-remote. Location: `~/Library/Application Support/scarf/servers.json`.
- Nothing you configured in 1.6.x (OAuth tokens, credential pools, cron jobs, MCP servers, platform setup) is touched. Those live in `~/.hermes/` and remain the source of truth.
### Known limitations
- Remote file watching is 3s mtime polling (vs. FSEvents on local). If you need sub-second updates on a remote, that's a followup.
- The `session/load` ACP call against an already-deleted session returns success-with-no-body from the Hermes adapter — Scarf now detects the resulting `stopReason: "refusal"` and surfaces it, but the underlying Hermes behavior is an upstream-adapter bug that should also get a proper error response.
+68
View File
@@ -0,0 +1,68 @@
## What's New in 2.0.1
Hotfix for [#19](https://github.com/awizemann/scarf/issues/19) and the related reports from the first day of v2.0: users' remote SSH connections would show a green "Connected" pill but every view (Dashboard, Sessions, Activity, Chat) read as empty / "not running" / "not configured". Three distinct environments reported it — Docker Hermes on a LAN, homelab VM over Tailscale, Ubuntu VPS — and every one was a silent file-access failure on the remote that Scarf wasn't surfacing.
### Errors no longer disappear
Every remote read (`config.yaml`, `gateway_state.json`, `state.db`, `pgrep`) used to silently substitute an empty value on *any* failure — permission denied, missing file, `sqlite3` not installed, connection drop — they all looked identical to the UI. Now:
- Each failure logs a specific warning via `os.Logger` (visible in Console.app under subsystem `com.scarf`).
- The Dashboard shows an orange banner above the stats with the exact error (e.g. "Permission denied reading `~/.hermes/state.db`") and a **Run Diagnostics…** button.
- `HermesDataService` exposes a `lastOpenError` so views can explain *why* state.db couldn't be opened, rather than just rendering zeros.
- Routine "file doesn't exist" cases (optional `skill.yaml` metadata, `gateway_state.json` before Hermes starts, `memories/USER.md` on fresh installs) are detected and **not** logged as warnings — only real errors (permission denied, connection drops, `sqlite3` missing) hit the log. Prevents Console from filling with false-positive noise when directory walks encounter optional files.
### New Remote Diagnostics sheet
Accessible from **Manage Servers → 🩺** per-server button, or by clicking the orange connection pill when Scarf can see the server but can't read Hermes state. Runs fourteen checks in a single SSH session and shows pass/fail for each, plus a targeted hint per failure:
- SSH connectivity and auth
- Remote user identity and `$HOME` resolution
- `~/.hermes` directory existence and readability
- `config.yaml` readable (existence *and* actual read access — the old probe only checked existence)
- `state.db` readable
- `sqlite3` installed on the remote (required for the atomic snapshot Scarf pulls)
- `sqlite3` can actually open `state.db`
- `hermes` binary on the non-login `$PATH` (what runtime uses)
- `hermes` binary on the login `$PATH` (what the Test Connection probe uses)
- `pgrep` available (for the "is Hermes running" check)
One **Copy Full Report** button dumps every check as plain text for bug reports, and a raw-output disclosure panel shows the exact stdout/stderr the remote returned whenever any probe fails — so transport-level problems are self-diagnosing.
The diagnostics script is piped to `/bin/sh -s` on stdin rather than passed as `sh -c <script>` argv. The latter was getting split line-by-line by the remote's login shell (newlines parsed as command separators), which stranded variables set on line 1 in an ephemeral `sh` subprocess that exited before line 2 could use them. Stdin-piping runs the whole script in one `sh` process with variable scope preserved.
### Connection pill gains a "degraded" state
The pill used to be green as long as SSH connected; now after connectivity passes it runs a second-tier check (`test -r $HOME/.hermes/config.yaml`). If that fails, the pill turns **orange** with "Connected — can't read Hermes state" and clicking it opens Remote Diagnostics directly. This is the exact symptom mode in #19, and it's now one click away from a specific answer.
The pill's visual also got a pass: the colored dot is replaced with a state-specific SF Symbol (`checkmark.circle.fill` / `stethoscope` / `arrow.triangle.2.circlepath` / `exclamationmark.triangle.fill`), which reads more like a clickable toolbar tool and doubles as the status signal. No custom pill background anymore — the toolbar's native `.principal` bezel is the frame.
### Auto-suggest the correct `remoteHome` during Add Server
When Test Connection can't find `state.db` at the configured (or default) path, it now also probes the common alternate locations — `/var/lib/hermes/.hermes`, `/opt/hermes/.hermes`, `/home/hermes/.hermes`, `/root/.hermes` — and offers a one-click "Use this" fill if it finds one. Removes the need to know that systemd-installed Hermes lives at `/var/lib/hermes/.hermes` by convention.
### Clearer copy for the `remoteHome` field
The Add Server sheet field is now labeled "Hermes data directory" with a description explaining when you'd override it (systemd service installs, Docker sidecars) and noting that Test Connection auto-suggests.
### README has a new "Remote setup requirements" section
Four concrete prerequisites (SSH, `sqlite3`, `pgrep`, read access to `~/.hermes`) and a troubleshooting paragraph pointing at Remote Diagnostics.
### Migrating from 2.0.0
Sparkle will offer the update automatically. Settings and server list are preserved verbatim — this is purely additive (new diagnostics surface, new error banners, auto-suggest in Test Connection). If you were affected by #19, run Remote Diagnostics after updating; the sheet should pinpoint the specific file access issue and suggest a fix.
### Under the hood
- New types: `RemoteDiagnosticsViewModel`, `RemoteDiagnosticsView`. Both are local to Scarf; no new transport protocol.
- `HermesFileService` gains `loadConfigResult()`, `loadGatewayStateResult()`, `hermesPIDResult()`, `readFileResult()`, `readFileDataResult()` — Result-returning variants that preserve the error. Legacy `loadConfig()` etc. still exist as thin forwarders for callers that don't need diagnostics.
- `HermesDataService.open()` records `lastOpenError` with humanized hints for "sqlite3 not installed", "permission denied", and "file not found" — the three failure modes that produce 90% of issue #19 symptoms.
- `ConnectionStatusViewModel` status enum gains `.degraded(reason:)` between `.connected` and `.error`.
- `TestConnectionProbe` result enum gains `suggestedRemoteHome: String?` carrying any alternate-location hit.
### Known follow-ups (not in 2.0.1)
- `TestConnectionProbe` uses a direct-argv ssh invocation that's functionally correct but fragile (works by accident when split across the login shell). Should be ported to the stdin-pipe pattern the diagnostics sheet now uses.
- Remaining `try?`-swallowed read paths beyond the four Dashboard-surfacing ones — Cron, Memory, Skills, MCP Servers, Platforms still silently render empty on read errors. Same fix pattern applies, low priority.
- `hermesBinaryHint` is only populated when the user clicks Test Connection; if they skip it, ACP chat and CLI calls fall back to bare `hermes` which requires it on the non-interactive PATH (rarely true for `~/.local/bin` installs). The connection-pill's second-tier probe could auto-populate this.
- Docker-host support: when users SSH to a Docker host, `pgrep` and `~/.hermes/` on the host don't see what's inside the container. Needs a `docker exec` wrapping option per server.
+41
View File
@@ -0,0 +1,41 @@
## What's New in 2.0.2
The actual root cause of [#19](https://github.com/awizemann/scarf/issues/19), found and patched by Scarf's first external contributor. v2.0.1 added the diagnostics UI assuming file-perm root cause; v2.0.2 fixes the underlying bug for everyone, regardless of perms.
### macOS Unix domain socket path limit (the real #19)
OpenSSH's ControlMaster multiplexes our bursty stat/cat/cp traffic over one TCP session per host. The socket path is bound by `bind(2)` to a Unix domain socket — and macOS' `sun_path` is **104 bytes including the NUL terminator**.
Scarf's old socket path was `~/Library/Caches/scarf/ssh/<%C>` where `%C` is OpenSSH's 64-char SHA1 hash of `(local user, host, port, remote user)`. For a username like `alex.maksimchuk`, the full path landed at **105 bytes** — one byte over the limit. ssh exited 255 with `unix_listener: path "..." too long for Unix domain socket`. Our `LogLevel=QUIET` flag (set so ACP's line-delimited JSON stays binary-clean) suppressed the diagnostic, and the user just saw "Remote command exited 255" — which the UI rendered as the silent empty-data state every reporter in #19 described.
The fix is to use a much shorter path:
```swift
"/tmp/scarf-ssh-\(getuid())" // ~17 bytes + 64 hash + sep + NUL = ~83 bytes
```
Per-user uid suffix keeps two local users' sockets from colliding in the shared `/tmp`, and 0700 perms on the dir keep them inaccessible to other users.
**Massive thanks to Alex Maksimchuk ([@aliatx2017](https://github.com/aliatx2017)) — Scarf's first external PR contributor — for diagnosing and patching this in [#20](https://github.com/awizemann/scarf/pull/20).** That diagnosis only happened because Alex bothered to read the codebase, reproduce against multiple usernames including a Termux/Android instance, and walk back from the cryptic exit code to the actual `bind()` failure. This release wouldn't exist without that work.
### Hardening on top of the fix
Three additions on top of Alex's patch, layered in via separate commits to keep the original change reviewable:
- **Defensive ownership check on the socket dir.** `/tmp` is world-writable, so a malicious local user could pre-create `/tmp/scarf-ssh-<uid>` and trick Scarf into using a hostile directory (we'd silently fail to chmod it back to 0700, since we wouldn't own it). `ensureControlDir` now uses POSIX `mkdir(0700)` (atomic, sets perms at create time) and on `EEXIST` runs `lstat` to verify the entry is a directory we own with mode 0700 — symlink → refuse, wrong owner → refuse + log to `os.Logger`, wrong mode → repair. Closes the `/tmp` pre-creation hole that's the standard concern for any per-user `/tmp` path.
- **Launch-time sweep of stale sockets.** `ServerRegistry.sweepOrphanCaches` already prunes orphaned snapshot directories on launch; it now also removes ControlMaster socket files older than 30 minutes. Socket basenames are `%C` hashes (not ServerIDs), so we can't keep "still registered" sockets the way the snapshot sweep does — but `ControlPersist` is 600s, so anything older than 30 minutes is guaranteed to be a dead orphan from a crashed master, an unclean app exit, or a server removed while another Scarf instance was holding the dir. Keeps `/tmp/scarf-ssh-<uid>/` from accumulating indefinitely until reboot, while leaving any concurrent Scarf instance's live sockets untouched.
- **Regression test for the path-length invariant.** `scarfTests` was a stub — it now has two tests: one asserting `controlDirPath().utf8.count + 1 + 64 + 1 ≤ 104` (would have caught the original #19 bug in CI), one asserting the path includes the current uid (pins the per-user-isolation invariant against a future "simplification" that drops it).
### v2.0.1 diagnostics work is still useful
The diagnostics sheet, orange "degraded" pill, dashboard error banner, and `remoteHome` auto-suggest from v2.0.1 all still ship — they just turn out not to have been the right diagnosis for the original three reporters. They remain valuable for the *other* connection-failure modes they were designed to surface (missing `sqlite3` on the remote, real permission errors, container/host visibility gaps, custom Hermes data directories). If you upgrade to v2.0.2 and *still* see incomplete data, run Remote Diagnostics from **Manage Servers → 🩺** and the sheet will tell you why.
### Migrating from 2.0.0 / 2.0.1 / draft 2.0.1
Sparkle will offer the update automatically. Settings and server list are preserved verbatim. The first time v2.0.2 connects to a remote, it'll create `/tmp/scarf-ssh-<uid>/` with mode 0700; the old `~/Library/Caches/scarf/ssh/` directory becomes unused (you can delete it manually, or leave it — macOS will sweep it eventually).
The previous v2.0.1 draft download remains available for anyone who already grabbed it — it's still a valid build with the diagnostics work. v2.0.2 is the recommended upgrade path.
### Reporters of #19
@cmalpass, @flyespresso, @maikokan — please grab v2.0.2 and confirm the dashboard populates without needing to run Remote Diagnostics first. If it still doesn't, the diagnostics sheet should now have a much better chance of pinpointing what's left.
+52
View File
@@ -0,0 +1,52 @@
## What's New in 2.1.0
Scarf now speaks seven languages and has a proper slash-command menu in the chat. The language work closes [#13](https://github.com/awizemann/scarf/issues/13) and opens the door for community contributions of additional locales.
### Multi-language support
The UI is now fully translated to **Simplified Chinese, German, French, Spanish, Japanese, and Brazilian Portuguese** on top of the existing English. Scarf respects the system language by default; override per-app from **System Settings → Language & Region → Apps → Scarf**.
- **644 source strings** catalogued. **583 translated per locale** — the remaining ~60 are deliberate fall-throughs to English: proper nouns (Scarf, Hermes, OAuth, MCP, SSH), brand names (Docker, Daytona, Singularity, BlueBubbles), format-only tokens (`%lld`, `·`, `•`), and config-literal placeholders (`my_server`, `npx`, `sk-…`).
- **Locale-aware number and date formatting.** Previous builds hardcoded POSIX-style decimal separators (`$12.34`) and English unit names (`"MB"`, `"K"`, `"M"`). Currency now routes through `.formatted(.currency(code: "USD"))`, byte sizes through `.byteCount(style: .file)`, token counts through `.notation(.compactName)`, and the day-of-week chart through `Calendar.current.shortWeekdaySymbols` — so German users see `15,2 MB`, Japanese users see `15.5万 tokens`, and the activity heatmap starts on the locale's first weekday.
- **Microphone permission prompt localized** — the system dialog that appears the first time you enable voice chat now reads in the user's language.
#### How the translation work shipped
Three stacked PRs to keep each piece independently reviewable, all AI-translated with the bar explicitly set low so native speakers can iterate:
1. **[#22](https://github.com/awizemann/scarf/pull/22) — String Catalog infrastructure.** Added `Localizable.xcstrings` + `InfoPlist.xcstrings`, expanded `knownRegions` with the six new locales, and fixed the locale-aware number formatters mentioned above. No user-visible English-locale change; the groundwork only.
2. **[#24](https://github.com/awizemann/scarf/pull/24) — Audit burn-down.** Swept the codebase for "silently un-localizable" patterns that look fine in Xcode's catalog but leak English at runtime: `Text(cond ? "A" : "B")` routes through the String overload instead of `LocalizedStringKey`, as do `Label(stringVar, systemImage:)`, `.help(stringVar)`, and composite format strings with translatable text suffixes. ~40 sites refactored, covering Chat voice/TTS toggles, Logs pickers, Insights period + day names, MCPServer test result, Profiles, SignalSetup, QuickCommands, ConnectionStatusPill. Without this PR the translations would have landed but ~40 visible strings would still have rendered in English.
3. **[#25](https://github.com/awizemann/scarf/pull/25) — Translations + contributor path.** The six locale JSONs + a 90-line merge script + a "Adding a Language" section in `CONTRIBUTING.md`. The sidebar and Settings tab bar fix also shipped here after smoke-testing revealed they were still missed — `Label(section.rawValue, …)` goes to the String overload just like the audit cases.
#### Contributing a new language
Per-locale source of truth lives in [`tools/translations/<locale>.json`](https://github.com/awizemann/scarf/tree/main/tools/translations). Each entry is a plain `{ "English": "Translation" }` map — keys you omit fall through to English at runtime. Workflow is: fork, drop a JSON, run `python3 tools/merge-translations.py`, open a PR. The full bar is documented in [CONTRIBUTING.md → Adding a Language](https://github.com/awizemann/scarf/blob/main/CONTRIBUTING.md#adding-a-language).
Native-speaker review of the initial six locales is welcome — AI translation gets us most of the way, but idiom and tone are better with someone who actually uses the language. Post a PR against the relevant `<locale>.json` and it'll land as a follow-up.
### Chat slash-command menu
Type `/` in Rich Chat and a floating menu appears above the input with every command the connected agent has advertised via ACP's `available_commands_update`, plus any user-defined `quick_commands:` from `~/.hermes/config.yaml`. ↑/↓ to navigate, Tab or Enter to complete, Esc to dismiss. Commands with argument hints (e.g. `/compress <topic>`) insert a trailing space so you can start typing the argument immediately.
The filter uses pure-prefix match and re-renders on every query — the old menu had a description-fallback filter and a cached child view that together pinned `/help` on-screen regardless of what you typed. The dedicated `/compress` button is hidden once the menu has more than one command; it only surfaces when `/compress` is the single advertised slash command, preserving the v2.0 one-click compression flow for that case.
### Chat UX polish
- **Auto-scroll on send and on completion.** `.defaultScrollAnchor(.bottom)` handles slow streaming fine, but rapid slash-command responses (common once the menu lands) outran the anchor and left the reply off-screen. Now the list explicitly scrolls to the latest message when you submit and again when the prompt finishes.
- **Loading state.** `ChatViewModel.isPreparingSession` is true during Starting / Creating / Loading / Reconnecting. While true, the message list swaps its empty-state placeholder for a spinner — non-blocking, just a view inside the ScrollView.
- **Empty-state centering.** The "Start a new session or resume an existing one" placeholder was positioned with a fixed `.padding(.vertical, 80)` that looked wrong at extreme window sizes. Replaced with Spacers inside `.containerRelativeFrame(.vertical)` so it sits in the true vertical center of the chat pane.
- **Session-load whitespace bug.** Opening a session used to render a blank viewport you'd have to scroll up from — the fix was `LazyVStack``VStack` in `RichChatMessageList`. LazyVStack's estimated row heights were fooling `.defaultScrollAnchor(.bottom)` into overshooting real content; VStack measures every row upfront so the anchor has real heights to work with.
### Under the hood
- **String Catalog build pipeline.** `SWIFT_EMIT_LOC_STRINGS` + `STRING_CATALOG_GENERATE_SYMBOLS` are enabled; keys extract automatically on IDE build. Headless builds use `xcrun xcstringstool sync` to merge the per-source `.stringsdata` files into the catalog (wrapped by [`tools/merge-translations.py`](https://github.com/awizemann/scarf/blob/main/tools/merge-translations.py) when applying JSON translations).
- **New docs.** [`scarf/docs/I18N.md`](https://github.com/awizemann/scarf/blob/main/scarf/docs/I18N.md) covers the catalog setup, the patterns that silently bypass localization (and their fixes), and which strings are intentionally kept verbatim. Anyone adding UI copy should read the "Guardrails when writing new UI code" section to avoid re-introducing the leaks #24 cleaned up.
### Migrating from 2.0.x
Sparkle will offer the update automatically. No config migration needed. The first launch after update picks up the system locale — if you want English even on a non-English macOS, set **System Settings → Language & Region → Apps → Scarf → English**.
### Thanks
- [Onion3](https://github.com/Onion3) for filing [#13](https://github.com/awizemann/scarf/issues/13) back in April. The single-locale ask turned into a six-locale rollout.
- Future translators: if you spot a weird AI translation in your language, open a PR against `tools/translations/<locale>.json`. The bar is explicitly low — we'd rather have a 95%-correct translation shipped and iterated on than hold everything for perfection.
+94
View File
@@ -0,0 +1,94 @@
## What's New in 2.2.0
Scarf projects can now travel. This release introduces **Project Templates** — a shareable `.scarftemplate` bundle format that packages a project's dashboard, agent instructions, skills, cron jobs, and a typed configuration schema into a single file anyone can install with one click. Bundles are agent-portable by design: every template ships with a cross-agent [`AGENTS.md`](https://agents.md/) so the instructions work natively in Claude Code, Cursor, Codex, Aider, Jules, Copilot, Zed, and every other agent that reads the Linux Foundation standard.
This is also the first release to ship a public **template catalog website** — a static site generated from `templates/<author>/<name>/` in this repo, previewed at [awizemann.github.io/scarf/templates/](https://awizemann.github.io/scarf/templates/), with a CI-enforced validator for community submissions.
### Project Templates
- **Bundle format: `.scarftemplate`.** A zip carrying a `template.json` manifest, the project's dashboard, a required `AGENTS.md` (the [Linux Foundation cross-agent instructions standard](https://agents.md/) — reads natively in Claude Code, Cursor, Codex, Aider, Jules, Copilot, Zed, and more), a README shown in the installer, optional per-agent instruction shims (`CLAUDE.md`, `GEMINI.md`, `.cursorrules`, `.github/copilot-instructions.md`), optional namespaced skills, optional cron job definitions, and an optional memory appendix.
- **Install preview sheet.** Before anything touches disk, Scarf shows you the exact project directory that will be created, every file inside it, every skill that will be namespaced under `~/.hermes/skills/templates/<slug>/`, every cron job that will be registered (always paused — you enable each one manually), and a live diff of the memory appendix against your existing `MEMORY.md`. Markdown fields — the README, field descriptions, cron prompts — render inline. The manifest's content claim is cross-checked against the actual zip entries so a bundle can't hide files from the preview.
- **`scarf://install?url=…` deep links.** Register Scarf as the handler for the `scarf` URL scheme so a future catalog site can link one-click installs straight into the app. Only `https://` payloads are accepted; `file://`, `javascript:`, and `http://` are refused on principle. A 50 MB size cap keeps a malicious link from exhausting disk. The URL never auto-installs — the preview sheet is always user-confirmed.
- **Install-time token substitution.** Template authors use `{{PROJECT_DIR}}`, `{{TEMPLATE_ID}}`, and `{{TEMPLATE_SLUG}}` placeholders in cron prompts; the installer resolves them to absolute paths at install time so the registered cron job works regardless of where Hermes sets CWD.
- **Export any project as a template.** Select a project, open the new Templates menu in the Projects toolbar, fill in a handful of fields (id, name, version, description, optional author + category + tags), tick the skills and cron jobs you want to include, optionally drop in a memory snippet, and save. The exporter carries the authored configuration schema forward but **never** the user's values — exports are safe on projects with live config.
- **No-overwrite, reversible by design.** Installed templates drop a `<project>/.scarf/template.lock.json` recording exactly what they wrote — every project file, skill path, cron job name, memory block id, and Keychain reference. Installing the same template id twice is refused at the preview step so you don't accidentally double-append to `MEMORY.md`.
- **Safe globals.** Skills install to `~/.hermes/skills/templates/<slug>/<skill-name>/` so they never collide with your own skills. Cron jobs are prefixed with `[tmpl:<id>]` and start paused. The installer **never** touches `~/.hermes/config.yaml`, `auth.json`, sessions, or any credential-bearing path.
### Template Configuration (schemaVersion 2)
Templates can now declare a typed configuration schema that drives a form step during install — no more "edit a `sites.txt` file to get started."
- **Typed field vocabulary.** Seven field types: `string`, `text` (multiline), `number` (with `min`/`max`), `bool`, `enum` (with `{value, label}` options), `list` (of strings, with `minItems`/`maxItems`), and `secret` (routed to the macOS Keychain). Constraints per type — `pattern` for regex, `minLength`/`maxLength` for text, etc. — are enforced at install and at edit time.
- **Configure step in the install flow.** If the template declares a schema, a **Configure** screen is inserted between "pick parent directory" and the preview sheet. Non-secret values land in `<project>/.scarf/config.json`; secrets land in the macOS Keychain with a service name of `com.scarf.template.<slug>` and an account keyed to the project-directory hash (so two installs of the same template in different dirs don't collide on Keychain entries).
- **Post-install Configuration editor.** A slider icon in the dashboard header opens the same form pre-filled with the current values. Change a site, rotate a token, toggle a feature — the cron job picks up the new values on its next run. Secrets are never echoed back ("Saved in Keychain — leave empty to keep the stored value").
- **Model recommendations.** Templates can suggest a preferred model (`claude-sonnet-4.5`, `claude-haiku-4`, `gpt-4.1`, etc.) with a rationale. Scarf surfaces the recommendation in the configure sheet without auto-switching your active model — always your call.
- **Secrets are tracked in the lock file.** Uninstalling a template runs `SecItemDelete` on every Keychain ref recorded at install, so a full clean-up leaves nothing behind. Absent entries (user already cleaned them) are no-ops.
### Template Catalog
A Sparkle-style pipeline for community-contributed templates, living on the same `gh-pages` branch as the auto-update feed.
- **Static site.** [awizemann.github.io/scarf/templates/](https://awizemann.github.io/scarf/templates/) — generated from every `templates/<author>/<name>/` directory. Each template gets a detail page showing the README, a live preview of the post-install dashboard, and the configuration schema rendered with human-readable constraint summaries. One-click install via the `scarf://install?url=…` button.
- **Stdlib-only Python validator.** `tools/build-catalog.py` is a no-external-dependencies Python script that mirrors the Swift-side schema and validation invariants (supported widget types, supported field types, `contents` claim verification, secret-with-default rejection, bundle-size cap, high-confidence secret patterns). Run it locally with `./scripts/catalog.sh check` before submitting a PR.
- **CI gate on PRs.** [`.github/workflows/validate-template-pr.yml`](https://github.com/awizemann/scarf/blob/main/.github/workflows/validate-template-pr.yml) runs the validator + its 24-test suite on every PR touching `templates/`, the validator itself, or its tests. Failing PRs get an inline comment with the last 3 KB of the validator output; passing PRs get a tailored checklist naming the specific template directory being changed.
- **Install-URL hosting.** Bundles are raw-served from `main` at `https://raw.githubusercontent.com/awizemann/scarf/main/templates/<author>/<name>/<name>.scarftemplate`. No per-template GitHub Releases ceremony.
- **Dogfood: the site uses Scarf's dashboard format.** `site/widgets.js` is ~300 lines of vanilla JS that renders a `ProjectDashboard` JSON using the same widget vocabulary the app uses, so each detail page's "live preview" is the actual dashboard the user will get.
### Example template: `awizemann/site-status-checker`
Ships as the first catalog entry and exercises every v2.2 surface. [See it in the catalog →](https://awizemann.github.io/scarf/templates/awizemann-site-status-checker/)
- Configure step asks for a list of URLs and a per-URL timeout.
- A paused cron job runs daily at 09:00 (editable from the Cron sidebar), does HTTP GETs with 3-redirect follow, writes a timestamped results table to `status-log.md`, updates the dashboard's Sites Up / Sites Down / Last Checked stat widgets plus the Watched Sites list, and rewrites the Site tab's webview URL to the first configured site.
- Works in any agent — the `AGENTS.md` is the single source of truth; no per-agent shim needed.
### Site tab
A dashboard with at least one `webview` widget now exposes a **Site** tab next to Dashboard. Useful for templates that watch something renderable (a site, a preview endpoint, a Grafana panel). The `site-status-checker` example rewrites the webview URL to the first configured site on every cron run, so the tab stays in sync with live config.
### Using templates
- **Install from file:** Projects → Templates → *Install from File…*, pick a `.scarftemplate` from disk.
- **Install from URL:** Projects → Templates → *Install from URL…*, paste an https URL.
- **Install from the web:** click any `scarf://install?url=…` link in a browser.
- **Export:** select a project → Projects → Templates → *Export "&lt;name&gt;" as Template…*, fill the form, save.
- **Edit config post-install:** slider icon in the dashboard header.
- **Uninstall:** right-click the project in the sidebar → *Uninstall Template (remove installed files)…*, or click the uninstall icon in the dashboard header. The preview sheet lists every file, cron job, Keychain secret, and memory block that will be removed, plus every user-created file that will be preserved.
### UX clarifications
- **Remove from List vs. Uninstall Template.** Sidebar context-menu labels clarified so you can see at a glance whether a click is destructive. *Remove from List (keep files)…* is registry-only — nothing on disk is touched, cron jobs stay, Keychain secrets stay. A confirmation dialog spells this out before the click lands. *Uninstall Template (remove installed files)…* is the full, lock-driven cleanup.
- **Post-uninstall "folder kept" banner.** When the uninstaller preserves the project directory because the cron wrote a `status-log.md` (or the user dropped files in there), the success view now explicitly lists the preserved paths with a pointer to delete the folder from Finder if desired.
- **Run Now no longer blocks on agent runs.** The Cron sidebar's Run Now button used to show a "Run failed" toast whenever an agent job ran longer than 60 s — even when the job was finishing correctly in the background. Run Now now shows "Agent started — dashboard will update when it finishes" immediately and the dashboard watcher picks up the completed state when it lands (timeout bumped to 300 s for the catch-stuck-process case).
### Uninstall
- **One-click uninstall** driven by `template.lock.json`. The preview sheet lists every file, cron job, Keychain ref, and memory block that will be removed, and every user-created file that will be preserved.
- **User content is never removed.** Files you (or the agent) added to the project dir after install — like a `sites.txt` or `status-log.md` — are detected and listed as "keep" in the preview. The project directory itself is removed only if nothing user-owned is left inside.
- **Clean global state.** The isolated `~/.hermes/skills/templates/<slug>/` namespace is removed wholesale. Tagged cron jobs are removed via `hermes cron remove`. Every recorded Keychain ref is cleared via `SecItemDelete`. The memory block between the `<!-- scarf-template:<id>:begin/end -->` markers is stripped, leaving the rest of MEMORY.md intact. The project registry entry is removed last.
- **No undo.** Uninstall is destructive — to reinstall, run the install flow again.
### Under the hood
- New models in `Core/Models/ProjectTemplate.swift` (manifest, inspection, install plan, lock file v2) and `Core/Models/TemplateConfig.swift` (schema + typed values + Keychain ref model).
- `Core/Services/ProjectTemplateService.swift` unzips, parses, and validates; `ProjectTemplateInstaller.swift` executes the plan with preflight + fail-fast semantics; `ProjectTemplateUninstaller.swift` reverses an install driven by the lock file; `ProjectTemplateExporter.swift` builds bundles from a live project + selections.
- `Core/Services/ProjectConfigService.swift` owns load/save/validation of `<project>/.scarf/config.json` + secret resolution; `Core/Services/ProjectConfigKeychain.swift` is the thin `SecItemAdd`/`Copy`/`Delete` wrapper (the only Keychain consumer in Scarf today).
- `Core/Services/TemplateURLRouter.swift` is the process-wide landing pad for `scarf://` URLs so a cold-launch browser click still reaches the install sheet.
- New Swift Testing suites covering 57 tests across the service / installer / uninstaller / exporter / config / Keychain / URL-router paths.
- New Python validator (`tools/build-catalog.py`) + test suite (`tools/test_build_catalog.py`, 24 tests) mirrors the Swift invariants for the CI gate and the site generator. Schema is Swift-primary — additions go to Swift first, Python mirrors.
- `scripts/catalog.sh` wraps the validator with `check / build / preview / serve / publish` subcommands that parallel the `scripts/release.sh` shape.
### Migrating from 2.1.x
Sparkle will offer the update automatically. No config migration needed. Existing projects are untouched — templates are additive. If you had a v2.2.0-dev install of the earlier `project-templates` branch, uninstall and reinstall any previously-installed templates to pick up the schema-version-2 lock file.
### Documentation
- [Project Templates wiki page](https://github.com/awizemann/scarf/wiki/Project-Templates) — installing, exporting, configuring, authoring, uninstalling.
- [Catalog site](https://awizemann.github.io/scarf/templates/) — the public catalog with live dashboard previews.
- [`templates/CONTRIBUTING.md`](https://github.com/awizemann/scarf/blob/main/templates/CONTRIBUTING.md) — how to submit a template via PR.
- [Architecture notes in root `CLAUDE.md`](https://github.com/awizemann/scarf/blob/main/CLAUDE.md#project-templates) — service-layer map, Keychain scheme, schema-drift discipline.
### Thanks
Thanks to everyone who tested drafts of the install flow, caught the "Run Now blocks on agent" bug, and pushed on the Remove-vs-Uninstall UX until it was clear. A 2.3 follow-up will extend the catalog validator to enforce per-field-type constraints at PR-time (currently enforced on install but not at submission).
+38
View File
@@ -0,0 +1,38 @@
## What's New in 2.2.1
A patch release covering Template Configuration rendering fixes reported against v2.2.0, plus a new catalog template that packages a Hermes skill for scaffolding new Scarf projects.
### Configuration sheet — no more clipping
Two independent rendering fixes to the post-install Configuration editor and the install-flow Configure step:
- **Enum fields with long option labels.** An enum with three or four options whose labels exceeded ~20 characters — e.g. a Claude-model picker with labels like *"Claude Opus 4 (Recommended - Most Capable)"* — rendered as a segmented picker that sized to the intrinsic width of all labels concatenated. On macOS, `.pickerStyle(.segmented)` refuses to respect offered width, refuses to wrap, refuses to truncate. The result was a ~650pt picker that overflowed the sheet's 560pt viewport and clipped the entire form on both sides. Enum fields now always render as a dropdown Menu picker, which surfaces long labels in the popup list and respects the parent's offered width regardless of option count or label length.
- **Descriptions with unbreakable content.** Field descriptions rendered via inline AttributedString markdown can contain tokens SwiftUI's `Text` refuses to break mid-token (raw URLs, long paths). Added `.frame(maxWidth: .infinity, alignment: .leading)` on the sheet's inner VStack and on each field row as a secondary constraint, so description text wraps at whitespace boundaries instead of expanding the sheet width. Applied the same modifier to `TemplateInstallSheet`'s main preview VStack for symmetry — installs with README blocks or cron prompts containing long URLs now wrap cleanly too.
### New catalog entry — `awizemann/template-author`
A `.scarftemplate` whose only content is a Hermes skill (`scarf-template-author`) plus a minimal dashboard that points users at it. Installing the template drops the skill at `~/.hermes/skills/templates/awizemann-template-author/scarf-template-author/SKILL.md`, discoverable by Claude Code, Cursor, Codex, Aider, and every other agent that reads the standard `~/.hermes/skills/` directory.
The skill teaches agents how to scaffold a new Scarf-compatible project through a short interview — purpose, data source, cadence, widgets, config, secrets — then write `<project>/.scarf/dashboard.json`, `<project>/.scarf/manifest.json`, `<project>/AGENTS.md`, and `<project>/README.md`. Scaffolded projects are usable locally and cleanly exportable as `.scarftemplate` bundles via Scarf's Export flow later. [Catalog detail page →](https://awizemann.github.io/scarf/templates/awizemann-template-author/)
v1 is fully conversational / blank-slate. Pre-baked archetypes (monitor, dev-dashboard, personal-log) are deferred to a future release pending real usage data.
### Authoring guidance — SKILL.md
The `scarf-template-author` skill now tells scaffolding agents to prefer markdown link syntax (`[label](https://…)`) over raw URLs in schema field descriptions. Raw URLs work now (v2.2.1's description wrap fix above handles them gracefully), but `[Anthropic console](https://console.anthropic.com)` reads cleaner in the form than a dumped URL. Same rule extended to long paths or other unbreakable strings — wrap in inline code if they have to appear verbatim, prefer markdown links otherwise.
### Under the hood
- **`scripts/catalog.sh publish` fix.** The pre-flight `need_ghpages` check tested `[[ -d "$GHPAGES_DIR/.git" ]]` — "is `.git` a directory?" — which is true for a regular clone but false for a `git worktree add` worktree (where `.git` is a pointer file). `release.sh` creates and leaves the gh-pages worktree around, so after any release the subsequent catalog-publish call was rejected with a misleading "run `git worktree add`" error on a worktree that was already there and valid. Switched to `-e` (exists, either file or directory). Unblocks publishing the catalog immediately after a release.
### Migrating from 2.2.0
Sparkle will offer the update automatically. No config migration needed. Existing template installs are untouched.
If you've already installed `awizemann/template-author` from a pre-release build, no action needed — the catalog and bundle content are forward-compatible.
### Documentation
- [Project Templates wiki page](https://github.com/awizemann/scarf/wiki/Project-Templates) — installing, exporting, configuring, authoring, uninstalling.
- [Catalog site](https://awizemann.github.io/scarf/templates/) — two templates live: `awizemann/site-status-checker` and `awizemann/template-author`.
- [`templates/CONTRIBUTING.md`](https://github.com/awizemann/scarf/blob/main/templates/CONTRIBUTING.md) — how to submit a template via PR.
+124
View File
@@ -0,0 +1,124 @@
## What's New in 2.3.0
Two themes land together in this release. The projects sidebar stops being a flat list and becomes a workspace — folders, rename + archive + search + keyboard jumps, a per-project Sessions tab, and every project-scoped chat now automatically carries Scarf-managed context into the agent itself. And Scarf catches up to **Hermes v0.10.0's Tool Gateway**: paid Nous Portal subscribers can now route web search, image generation, TTS, and browser automation through their subscription without separate API keys — and they can sign in entirely from Scarf, no terminal needed.
### Projects sidebar grows up
- **Folders.** Group related projects with folders. Right-click any project → *Move to Folder…* — pick an existing folder or create a new one on the fly. Folders are soft: any folder name that isn't referenced by at least one project just disappears, so there's no "empty folder" state to clean up.
- **Rename** a project from the context menu. Preserves everything else — the path, folder assignment, archive flag, and any running cron attribution stay intact. Rejects duplicate names + empty input with an inline warning.
- **Archive / Unarchive.** Hide projects you don't actively use without deleting anything. The sidebar's bottom bar gains a Show Archived toggle so they're one click away when you need them.
- **Search.** ⌘F focuses a filter field at the top of the sidebar. Fuzzy-matches on name, path, and folder label, live as you type.
- **Keyboard jumps.** ⌘1 through ⌘9 jump to the first nine top-level projects. Pairs cleanly with Scarf's existing window-level shortcuts.
Registry migration is non-destructive — `~/.hermes/scarf/projects.json` gains two optional fields (`folder`, `archived`), and a file written by v2.3 is still parseable by v2.2.1 (unknown-keys are ignored), so downgrade works if you ever need it.
### Per-project Sessions tab
Every project now has a **Sessions** tab alongside Dashboard and Site. It shows chats attributed to this specific project — the sidecar at `~/.hermes/scarf/session_project_map.json` maintains the session-to-project mapping (Hermes's `state.db` has no column for this, so Scarf owns the record).
- **New Chat** — spawns `hermes acp` with the project's directory as the session's working directory, attributes the resulting session to the project, and takes you straight into the chat view.
- **Click any listed session to resume it** in the Chat tab; the project indicator comes along automatically.
- Forward-only attribution: sessions you've already started via the CLI or via the global Chat sidebar section continue to live in the global Sessions view unchanged; they simply aren't attributed to any project.
File descriptors are released cleanly on tab-disappear, matching Scarf's other Hermes-DB-reading VMs.
### Agent context injection via AGENTS.md
The architectural headline of this release. Hermes has no native "project" concept and ACP's wire protocol drops extra session params. But Hermes DOES auto-read `AGENTS.md` from the session's cwd at startup (priority: `.hermes.md``HERMES.md``AGENTS.md``CLAUDE.md``.cursorrules`, first match wins, 20KB cap). So Scarf leans on that.
Every time you start a project-scoped chat, Scarf writes a managed block into `<project>/AGENTS.md`:
```
<!-- scarf-project:begin -->
## Scarf project context
You are operating inside a Scarf project named "<Project Name>". …
- Project directory: …
- Dashboard: …
- Template: <id> v<version>
- Configuration fields: field_a, api_token (secret — name only, value stored in Keychain)
- Registered cron jobs: [tmpl:<id>] <name> — schedule …
<!-- scarf-project:end -->
```
Ask a fresh chat *"what project am I in?"* and the agent answers with the project name, dashboard path, template id, and current cron schedule — pulled from the block Hermes injected into its system prompt automatically.
**Invariants the block guarantees:**
- **Secret-safe.** Surfaces config field *names* with type hints; never values. A project whose config.json has Keychain-ref URIs renders the fields as `api_token (secret — name only, value stored in Keychain)`. Keychain URIs and plaintext values never appear in the block. Locked in by an explicit test (`refreshListsFieldNamesNotValues`).
- **Idempotent.** Two consecutive refreshes with unchanged state produce byte-identical output. The write is skipped entirely when no delta — no unnecessary file-watcher churn.
- **Bounded.** Everything outside the `<!-- scarf-project -->` markers is preserved across every refresh. Template-author AGENTS.md content lives safely below the block; hand-edits are never clobbered.
- **Non-fatal.** A failed block refresh doesn't block the chat from starting — logged + the session proceeds without the extra context.
- **Bare-project friendly.** Projects without an AGENTS.md (plain directories added via the + button) get one created with just the block. Agent awareness works even without template scaffolding.
**Template-author contract:** leave the `<!-- scarf-project -->` region alone in your bundled `AGENTS.md`. Put template-specific instructions below it so they're preserved across refreshes. The `scarf-template-author` scaffolding skill already teaches this pattern to future agents doing project scaffolding.
**Known caveat:** if any parent directory of your project contains a `.hermes.md` or `HERMES.md`, that file takes priority over the project's AGENTS.md in Hermes's discovery order — the Scarf block gets shadowed. No fix in 2.3 — planned for 2.4 pending design input on handling authored `.hermes.md` files.
### Chat UI — project awareness everywhere
Once the cwd, attribution, and AGENTS.md pieces land, the UI follows:
- **Folder chip in `SessionInfoBar`** at the start of the bar (before the working dot + title) shows the active project name with a folder icon.
- **Navigation title** reads `Chat · <ProjectName>` when scoped, plain `Chat` otherwise — macOS `Subject — Detail` convention.
- **Resumed sessions keep the indicator.** Whether you click a session in the project's Sessions tab or come in from a future deep-link, the attribution is looked up at resume time and the chip renders from the same state.
### Window-layout fixes
A pre-existing issue — untracked until v2.3's heavier Chat/Sessions content exposed it — where the window grew past the screen when you switched to content-heavy sections. Fixed by:
- Setting `WindowGroup.windowResizability(.contentMinSize)` so the window's floor (not ceiling) is derived from content.
- Capping `idealHeight` on `RichChatView` and `ProjectSessionsView` so their plain-VStack children (deliberate choice to dodge a LazyVStack whitespace bug) don't report screen-exceeding ideals upward through `NavigationSplitView.detail`.
Window now stays at a user-draggable size and persists across section switches.
### Under the hood
- New models: `SessionProjectMap``~/.hermes/scarf/session_project_map.json` serialization (`SessionAttributionService` manages it).
- New services: `SessionAttributionService` (reads + writes the sidecar), `ProjectAgentContextService` (writes the AGENTS.md marker block, tests cover prepend/replace/idempotency/secret-redaction).
- New view models: `ProjectSessionsViewModel` (per-project session list with attribution filter), `ChatViewModel` gains `currentProjectPath` + `currentProjectName`.
- `HermesFileWatcher` now watches the attribution sidecar — file-system events propagate through the VMs as they do for every other Scarf-written file.
- `ProjectsViewModel` gains `moveProject / renameProject / archiveProject / unarchiveProject / folders` — rename preserves selection; archive clears it; reorders driven by `localizedCaseInsensitiveCompare` for locale-aware ordering.
- **Tool Gateway services.** `NousSubscriptionService` reads `~/.hermes/auth.json` to detect the subscription state. `NousAuthFlow` spawns `hermes auth add nous --no-browser` (with `PYTHONUNBUFFERED=1` so the device-code block surfaces immediately — Python block-buffers otherwise), parses the verification URL + user code with two line-anchored regexes, auto-opens the approval page via `NSWorkspace`, and confirms success by re-reading `auth.json`. `NousSignInSheet` drives the four-state UI (starting / waiting-for-approval / success / failure-with-billing-link). `CredentialPoolsOAuthGate` is the testable helper that routes providers to the right OAuth flow based on their overlay auth-type.
- **Catalog overlay merge.** `ModelCatalogService` gains a static `overlayOnlyProviders` table mirroring the 6 entries from `HERMES_OVERLAYS` in `hermes-agent/hermes_cli/providers.py`. `HermesProviderInfo` carries `isOverlay` and `subscriptionGated` flags so the picker can render them distinctly.
- **Config parsing.** `HermesConfig` gains `platformToolsets: [String: [String]]`; `HermesFileService` parses the `platform_toolsets.<platform>` block from `config.yaml` as written by `hermes setup tools`.
- **36 new Swift tests** across `ProjectRegistryMigrationTests`, `ProjectsViewModelTests`, `SessionAttributionServiceTests`, `ProjectAgentContextServiceTests` (22 for v2.3 projects work) + `ToolGatewayTests`, `NousAuthFlowParserTests`, `CredentialPoolsGatingTests` (14 for Tool Gateway). Total: 120 tests, all green against v2.3-projects + Tool Gateway combined.
### Icon tweak
App icon files renamed from iOS-template suffixes to macOS-native filenames + paired `Contents.json` update. Pure naming; no visual change at any rendered size.
### Tool Gateway — Nous Portal support
Hermes v0.10.0 introduced a **Tool Gateway**: paid [Nous Portal](https://portal.nousresearch.com) subscribers route web search (Firecrawl), image generation (FAL / FLUX 2 Pro), text-to-speech (OpenAI TTS), and browser automation (Browser Use) through their subscription. No separate API keys, no credential pool juggling. Scarf 2.3 surfaces the whole flow natively.
- **Nous Portal appears in the model picker.** Our picker used to read only the models.dev cache, which doesn't list Nous — so it was invisible. Scarf now merges Hermes's `HERMES_OVERLAYS` table on top of the cache, surfacing **six previously-hidden providers**: Nous Portal, OpenAI Codex, Qwen OAuth, Google Gemini CLI, GitHub Copilot ACP, and Arcee. Subscription-gated providers sort first, with a **Subscription** pill so they're visually distinct from BYO-key providers.
- **In-app sign-in.** Click *Sign in to Nous Portal* in the picker (or in the Auxiliary tab's fallback, or Credential Pools for the `nous` provider) and Scarf runs the device-code flow: opens the approval page in your browser, shows the device code in a large monospaced badge you can copy, and auto-detects success by re-reading `~/.hermes/auth.json`. No six-step terminal dance. Subscription-required failures surface a **Subscribe** button that opens the portal's billing page directly.
- **Per-task gateway routing.** The Auxiliary tab's 8 sub-model tasks (vision, web_extract, compression, session_search, skills_hub, approval, mcp, flush_memories) each gain a "Nous Portal" toggle. Enabling it flips `auxiliary.<task>.provider` to `nous` — Hermes derives gateway routing from that, no separate `use_gateway` key needed.
- **Health surface.** A new **Tool Gateway** card in Health shows subscription state, `platform_toolsets` wiring, and which aux tasks are currently routed through Nous.
- **Credential Pools dead-end fixed.** Before: selecting `nous` in the Add Credential sheet and clicking *Start OAuth* silently stalled (the PKCE URL regex never matched the device-code output). Now the sheet detects Nous and routes to the dedicated sign-in flow. For the other non-PKCE providers (OpenAI Codex, Qwen OAuth, Google Gemini CLI, GitHub Copilot ACP), the button disables with an inline hint pointing to `hermes auth add <provider>` — no more silent failures. PKCE providers (Anthropic, etc.) behave exactly as before.
- **Messaging Gateway rename.** Scarf's pre-existing "Gateway" section (Slack / Discord / inbound messaging) is renamed throughout to **Messaging Gateway** to disambiguate from the new Tool Gateway. Same feature, clearer name. Sidebar, dashboard card, menu-bar status, log-source filter, and Settings → Agent section header all updated. Internal enum cases and file paths (`gateway_state.json`, `gateway.log`) are unchanged.
If you don't use Hermes v0.10.0 or don't have a Nous subscription, nothing in your flow changes — the Tool Gateway surface only activates when it's relevant. Sign-in state reads `~/.hermes/auth.json` in read-only mode; Scarf never writes to the credential file.
### Migrating from 2.2.x
Sparkle will offer the update automatically. No config migration needed. Existing template installs are untouched — the v2.3 additions (folders, archive, sidecar) are purely additive; a v2.2.1 projects.json loads cleanly.
If you had any chat sessions attributed to projects in a pre-release v2.3 build, the forward-only attribution model means those sidecar entries surface correctly in the new Sessions tab on first launch.
**Hermes version.** The Tool Gateway features target [Hermes v0.10.0](https://github.com/NousResearch/hermes-agent/releases/tag/v2026.4.16) or newer. If you're on v0.9.0 the rest of Scarf 2.3 works, but Nous Portal won't appear in the picker (it's sourced from `HERMES_OVERLAYS` in v0.10.0+) and the Tool Gateway card won't have subscription data to show. Updating Hermes is `pipx upgrade hermes-agent` or the equivalent for your install method.
### Documentation
- **[Project Templates wiki page](https://github.com/awizemann/scarf/wiki/Project-Templates)** — gained a "How the agent sees the project" section covering the AGENTS.md injection pattern.
- **[Hermes Version Compatibility](https://github.com/awizemann/scarf/wiki/Hermes-Version-Compatibility)** — bumped recommended minimum to v0.10.0, new subsection covering Tool Gateway feature gating.
- **[Core Services](https://github.com/awizemann/scarf/wiki/Core-Services)** — new rows for `NousSubscriptionService` and `NousAuthFlow`, updated `ModelCatalogService` entry noting overlay merge.
- **Root `CLAUDE.md`** — new subsection "Project-scoped chat + Scarf-managed AGENTS.md context (v2.3)" under Project Templates, plus the Tool Gateway subsection under Hermes Version covering the overlay table and per-task gateway contract.
- **`scarf-template-author` skill** — pitfall bullet added so future scaffolding agents preserve the marker region when authoring new templates.
### Thanks
Thanks to the users who exercised this release through several layout iterations, caught the `fetchSessions` short-circuit on a fresh VM, and pushed on the "agent doesn't know what project it's in" question until the AGENTS.md mechanism clicked. Several of these fixes are small on their own but add up to a much tighter per-project workflow.
+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.
+121
View File
@@ -0,0 +1,121 @@
## What's in 2.6.0
A major release tracking **Hermes v2026.4.30 (v0.12.0)** — the largest single Hermes update Scarf has had to follow since v0.10's Tool Gateway. Headline additions: the autonomous **Curator**, **multimodal image input** in chat, **5 new inference providers**, **Microsoft Teams + Yuanbao** gateway platforms, a **read-only Kanban** view, and ScarfGo gains read-only Webhooks/Plugins/Profiles plus a Hermes-version banner.
Pre-v0.12 Hermes hosts are fully supported. Every new surface is gated on a runtime capability detector (`hermes --version` → semver), so users on older Hermes installs see the v2.5 surface unchanged. UI doesn't appear until the underlying CLI subcommand exists.
### Curator (Mac + iOS)
Hermes v0.12's autonomous skill curator prunes / consolidates / archives agent-created skills on a 7-day schedule. Scarf adds a dedicated **Curator** sidebar item under Interact (Mac) and a Curator nav row under the System tab (iOS).
- **Status panel** — enabled/paused/disabled badge, last-run timestamp, last summary, run count, scheduling cadence (interval / stale-after / archive-after).
- **Run Now** button triggers `hermes curator run`; pause/resume from the kebab menu.
- **Three leaderboards** — least-recently-active, most-active, least-active. Each row carries activity / use / view / patch counters and an inline pin toggle.
- **Pin / unpin** — pinned skills are protected from auto-archive and rewrites. State pulled from `~/.hermes/skills/.curator_state` and surfaced as a pin glyph everywhere skills appear (Curator screen, Skills sidebar/list, SkillDetailView).
- **Restore archived** sheet calls `hermes curator restore <name>` to bring a previously-archived skill back.
- **Last report Markdown** — when present, the previous run's REPORT.md renders inline in mono.
Capability-gated; sidebar item disappears on pre-v0.12 hosts.
### Multimodal image input in chat (Mac + iOS)
Hermes v0.12 advertises `prompt_capabilities.image = true` on ACP and accepts image content blocks in `session/prompt`. Scarf wires the producer side on both targets:
- **Mac**: paperclip toolbar button on the chat composer opens NSOpenPanel multi-pick. Drag-and-drop and paste also work — drop an image (or a Finder file URL) onto the composer and it attaches. Capability-gated; the entire attachment surface is hidden on pre-v0.12 hosts.
- **iOS**: paperclip button opens PhotosPicker (multi-select up to 5 photos). Same byte-for-byte capability gate.
- **ImageEncoder** downsamples to 1568px long-edge (Anthropic's recommended ceiling) at JPEG q=0.85, so a 12 MP screenshot lands under ~300 KB on the wire. Detached only — never blocks MainActor.
- **Image-only sends are valid** — once at least one attachment is queued, the send button enables even with empty text. Vision models accept "describe this" with no caption.
- **Per-attachment chips** above the input field with thumbnail + filename tooltip + X to remove. 5-image-per-message cap; total payload stays under ~2 MB so cellular sends don't time out.
Hermes routes the resulting prompt to a vision-capable model automatically — no extra Scarf-side work to pick the right aux model.
### 5 new inference providers (Mac + iOS)
Five overlay-only providers added to `ModelCatalogService.overlayOnlyProviders`. The model picker reaches all of them; provider IDs match `HERMES_OVERLAYS` in `hermes_cli/providers.py` exactly so a typo here doesn't strand users with an unreachable provider.
- **GMI Cloud** (api_key) — `https://api.gmi-serving.com/v1`
- **Azure AI Foundry** (api_key) — base URL resolved from `AZURE_FOUNDRY_BASE_URL` per tenant
- **LM Studio** (api_key, first-class) — promoted from custom-endpoint alias to a real provider; defaults to `http://127.0.0.1:1234/v1`
- **MiniMax (OAuth)** (oauth_external) — `https://api.minimax.io/anthropic`
- **Tencent TokenHub** (api_key) — base URL resolved from `TOKENHUB_BASE_URL`
### `auxiliary.curator` aux task (Mac)
Hermes removed `auxiliary.flush_memories` entirely in v0.12 (the underlying memory pipeline was rewritten) and added `auxiliary.curator` so the curator's review fork can run on a separate model from the main agent. Settings → Auxiliary now surfaces a Curator row when the active host is v0.12+ (gated on `HermesCapabilities.hasCuratorAux`); the obsolete Flush Memories panel is gone.
The Tool Gateway health view in HealthView lost the flushMemories-routes-through-Nous row and gained a curator row, matching the new aux task list.
### Skills v0.12 surface (Mac + iOS)
Three new capabilities Scarf can now reach:
- **Direct-URL install** — `hermes skills install <https-url>` lets users pull a one-off skill without going through a registry. Mac SkillsView gains an "Install from URL…" toolbar button (capability-gated) opening a sheet with the URL field plus optional `--category` / `--name` overrides.
- **Reload** — `hermes skills audit` rescans the skills directory and refreshes the agent's view without a session restart. Wired to a "Reload" toolbar button next to the install button on Mac.
- **Enabled / disabled state** — `skills.disabled` in config.yaml is read at scan time. Disabled skills render strikethrough + an "OFF" pill on Mac and iOS rows; iOS detail view explains the state in plain text.
- **Curator pin badge** — pinned-skill names from `~/.hermes/skills/.curator_state` surface as a pin glyph on each row across Mac sidebar and iOS list, plus an explanatory chip on iOS detail view.
The disable-toggle write path is deferred to v2.7 — Hermes only exposes `hermes skills config` as an interactive verb today, and we'd rather read accurately than risk clobbering the user's list with a half-tested write.
### Cron — `--workdir` flag (Mac)
Hermes v0.12 cron jobs accept `--workdir <absolute-path>` to inject AGENTS.md / CLAUDE.md / .cursorrules from that directory and pin cwd for terminal/file/code_exec tools. Scarf's CronJobEditor now has a Workdir field; both create and edit paths forward the flag. Existing v0.11 jobs keep the no-cwd behaviour by leaving the field blank.
The `context_from` chaining field is read-only from Scarf this round (Hermes hasn't exposed a `--context-from` CLI flag yet, only YAML).
### Microsoft Teams + Yuanbao (Mac)
Two new gateway platforms. Microsoft Teams (the 19th platform) ships as a plugin; Yuanbao 元宝 (the 18th) is a native gateway adapter. Both surface in the Platforms tab with read-only setup panels — the OAuth dance for Yuanbao and the plugin install for Teams happen outside Scarf.
### Read-only Kanban (Mac)
Hermes v0.12 ships a SQLite-backed multi-tenant task board with a full CLI (`hermes kanban create / list / claim / dispatch / …`). The multi-profile *collaboration* layer was reverted upstream while the design is reworked, so v2.6 ships a **read-only** Kanban view: paginated table of `hermes kanban list --json` filtered by status, with status badges, meta chips (id / assignee / workspace / skills), and per-row metadata. 5-second polling while the view is foregrounded; suspended on disappear.
Create / claim / dispatch UI is deferred until upstream stabilizes — building the editor now would risk rework on a quarter-out timeline.
### Settings deltas (Mac)
A new **Caching & Redaction** section under Settings → Advanced with three v0.12 knobs (gated on capability):
- **Prompt cache TTL** picker — 5m default / 1h opt-in. Reduces cache writes on long agent loops with stable system prompts.
- **Redact secrets in patches** toggle — Hermes flipped this off by default in v0.12 because the substitution corrupted patches; security-sensitive users can flip it back on here.
- **Runtime metadata footer** toggle — opt-in compact footer on each final reply (provider/model/cost/turn count).
TTS provider list gains **piper** (native local TTS engine new in v0.12). Terminal backend list gains **vercel** (Vercel Sandbox backend for execute_code/terminal). Both ride along unconditionally — Hermes silently falls back when an older host doesn't recognize the value.
### iOS catch-up — Webhooks / Plugins / Profiles (read-only)
Three new System-tab nav rows in ScarfGo, all read-only:
- **Webhooks** — list of `hermes webhook list` output with description / deliver / events / route per row. "Platform not enabled" detection so a freshly-installed Hermes shows setup guidance instead of error noise.
- **Plugins** — filesystem-first scan over `~/.hermes/plugins/` with manifest reads (plugin.json or plugin.yaml). Enabled/disabled badge, version, source, path.
- **Profiles** — `hermes profile list` with active-profile highlighting from `~/.hermes/active_profile`. Tolerant of both Rich box-drawn and plain-text outputs.
None of the three are capability-gated — the underlying list verbs work on both v0.11 and v0.12. Create / edit / delete remain Mac-only since they touch enough state we keep them off the phone.
### Hermes-version banner (iOS)
Yellow banner at the top of the Dashboard tab when the active server is pre-v0.12. Lists the v0.12 capabilities the user is missing out on (curator, multimodal image input, new providers); one-tap session-dismiss; reappears on next app open. Hidden entirely on v0.12+ hosts.
### Internal — version-aware capability detection
The foundation of every gated surface above:
- `HermesCapabilities` value type parses `Hermes Agent v0.12.0 (2026.4.30)` from `hermes --version` output. Exposes booleans for each release-gated UI surface (`hasCurator`, `hasACPImagePrompts`, `hasKanban`, `hasOneShot`, `hasSkillURLInstall`, `hasFallbackCommand`, `hasUpdateCheck`, `hasPiperTTS`, `hasVercelTerminal`, `hasCuratorAux`, `hasTeamsPlatform`, `hasYuanbaoPlatform`, `hasCronWorkdir`, `hasPromptCacheTTL`, `hasRedactionToggle`, `hasFlushMemoriesAux`).
- `HermesCapabilitiesStore` (`@Observable @MainActor`) caches per-server capabilities. Injected on `ContextBoundRoot` (Mac) and `ScarfGoTabRoot` (iOS) via `.environment(_:)` and `.hermesCapabilities(_:)`.
- 12 parser tests + 6 curator-output parser tests lock the v0.12 / v0.11 / fallback flag matrices.
### Bug fixes
- **Test target compile** — `M5FeatureVMTests.ScriptedTransport` had drifted off the `ServerTransport` protocol after `cachedSnapshotPath` landed in v2.5.2; added the missing stub. `M0dViewModelsTests` got the `ConnectionStatusViewModel.Status.degraded` argument-name update. `CredentialPoolsGatingTests` got the missing `import ScarfCore`. The full `swift test` suite now runs (and passes — 215 tests across 17 suites).
- **iOS package compile** — `RemoteBackupService.zipDirectory` and `RemoteRestoreService.unzipArchive` used `Foundation.Process` unconditionally, breaking the iOS build entirely (Process is unavailable on the iOS SDK). Wrapped in `#if !os(iOS)` with iOS stubs that throw — backup/restore is Mac-only by design.
### Hermes version
Targets Hermes **v2026.4.30 (v0.12.0)**. v2026.4.23 (v0.11.0) hosts continue to work — every v0.12 surface is gated on capability detection, so Scarf v2.6 against v0.11 looks identical to Scarf v2.5.2 against v0.11. Update Hermes (`hermes update`) to unlock the new surfaces.
### Compatibility
- macOS 14+ (unchanged)
- iOS 17+ (unchanged)
- Hermes v0.11+ for the v2.5 surface; v0.12+ for the new features above.
- No data migrations.
+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,99 @@
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 }
/// Exit status of the underlying transport once it has terminated.
/// `nil` while the channel is still alive, or for transports that
/// don't have a meaningful integer exit code (Citadel SSH-exec).
/// Read by `ACPClient` when populating `processTerminated` so the
/// user-facing error can name the actual exit code (e.g. `exit
/// 255` for SSH connect failures, `exit 127` for missing remote
/// binary).
var lastExitCode: Int32? { get async }
}
public extension ACPChannel {
/// Default: channels that don't track an exit code report `nil`.
/// Concrete `ProcessACPChannel` overrides this.
var lastExitCode: Int32? {
get async { nil }
}
}
/// 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
}
}
}
@@ -0,0 +1,649 @@
import Foundation
#if canImport(os)
import os
#endif
/// 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
/// 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>] = [:]
private var readTask: Task<Void, Never>?
private var stderrTask: Task<Void, Never>?
private var keepaliveTask: Task<Void, Never>?
private var eventContinuation: AsyncStream<ACPEvent>.Continuation?
private var _eventStream: AsyncStream<ACPEvent>?
public private(set) var isConnected = false
public private(set) var currentSessionId: String?
public private(set) var statusMessage = ""
public let context: ServerContext
public init(
context: ServerContext = .local,
channelFactory: @escaping ChannelFactory
) {
self.context = context
self.channelFactory = channelFactory
}
/// 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 ACP channel, joined by newlines.
public var recentStderr: String {
stderrBuffer.joined(separator: "\n")
}
fileprivate func appendStderr(_ text: String) {
for line in text.split(separator: "\n", omittingEmptySubsequences: true) {
stderrBuffer.append(String(line))
}
if stderrBuffer.count > Self.stderrBufferMaxLines {
stderrBuffer.removeFirst(stderrBuffer.count - Self.stderrBufferMaxLines)
}
}
/// 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. 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
public func start() async throws {
guard channel == nil else { return }
// 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
statusMessage = "Starting hermes acp..."
let ch: any ACPChannel
do {
ch = try await channelFactory(context)
} catch {
statusMessage = "Failed to start: \(error.localizedDescription)"
#if canImport(os)
logger.error("Failed to open ACP channel: \(error.localizedDescription)")
#endif
continuation.finish()
throw error
}
self.channel = ch
self.isConnected = true
// 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.
let initParams: [String: AnyCodable] = [
"protocolVersion": AnyCodable(1),
"clientCapabilities": AnyCodable([String: Any]()),
"clientInfo": AnyCodable([
"name": "Scarf",
"version": "1.0",
] as [String: Any]),
]
_ = try await sendRequest(method: "initialize", params: initParams)
statusMessage = "Connected"
#if canImport(os)
logger.info("ACP connection initialized")
#endif
startKeepalive()
}
public func stop() async {
readTask?.cancel()
readTask = nil
stderrTask?.cancel()
stderrTask = nil
keepaliveTask?.cancel()
keepaliveTask = nil
eventContinuation?.finish()
eventContinuation = nil
_eventStream = nil
for (_, continuation) in pendingRequests {
continuation.resume(throwing: CancellationError())
}
pendingRequests.removeAll()
if let ch = channel {
await ch.close()
}
channel = nil
isConnected = false
currentSessionId = nil
statusMessage = "Disconnected"
#if canImport(os)
logger.info("ACP client stopped")
#endif
}
// MARK: - Keepalive
private func startKeepalive() {
keepaliveTask = Task { [weak self] in
while !Task.isCancelled {
try? await Task.sleep(nanoseconds: 30_000_000_000) // 30 seconds
guard !Task.isCancelled else { break }
await self?.sendKeepalive()
}
}
}
/// Valid JSON-RPC notification used as a keepalive probe. 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() async {
guard let ch = channel else { return }
do {
try await ch.send(Self.keepalivePayload)
} catch {
await handleWriteFailed()
}
}
// MARK: - Session Management
public func newSession(cwd: String) async throws -> String {
statusMessage = "Creating session..."
let params: [String: AnyCodable] = [
"cwd": AnyCodable(cwd),
"mcpServers": AnyCodable([Any]()),
]
let result = try await sendRequest(method: "session/new", params: params)
guard let dict = result?.dictValue,
let sessionId = dict["sessionId"] as? String
else {
throw ACPClientError.invalidResponse("Missing sessionId in session/new response")
}
currentSessionId = sessionId
statusMessage = "Session ready"
#if canImport(os)
logger.info("Created new ACP session: \(sessionId)")
#endif
return sessionId
}
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]()),
]
let result = try await sendRequest(method: "session/load", params: params)
// ACP returns {} on success (no sessionId echoed), or an error if
// not found. If we got here without throwing, the session was
// loaded use the ID we sent.
let loadedId = (result?.dictValue?["sessionId"] as? String) ?? sessionId
currentSessionId = loadedId
statusMessage = "Session loaded"
#if canImport(os)
logger.info("Loaded ACP session: \(loadedId)")
#endif
return loadedId
}
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]()),
]
let result = try await sendRequest(method: "session/resume", params: params)
guard let dict = result?.dictValue,
let resumedId = dict["sessionId"] as? String
else {
throw ACPClientError.invalidResponse("Missing sessionId in session/resume response")
}
currentSessionId = resumedId
statusMessage = "Session resumed"
#if canImport(os)
logger.info("Resumed ACP session: \(resumedId)")
#endif
return resumedId
}
// MARK: - Messaging
public func sendPrompt(sessionId: String, text: String) async throws -> ACPPromptResult {
try await sendPrompt(sessionId: sessionId, text: text, images: [])
}
/// v0.12+ overload: forward zero or more image attachments alongside
/// the user's text. Each attachment becomes a separate
/// `ImageContentBlock` in the ACP `prompt` content array matches
/// the shape Hermes' `acp_adapter/server.py` expects (text first,
/// then image blocks). Hermes routes the resulting payload to a
/// vision-capable model automatically; the producer side only has
/// to deliver the bytes.
///
/// Pre-v0.12 Hermes installs accepted only a single `text` block.
/// Callers gate this overload on
/// `HermesCapabilitiesStore.capabilities.hasACPImagePrompts` so we
/// don't send blocks an older agent would silently drop.
public func sendPrompt(
sessionId: String,
text: String,
images: [ChatImageAttachment]
) async throws -> ACPPromptResult {
statusMessage = "Sending prompt..."
let messageId = UUID().uuidString
// Always include the text block, even when empty keeps the
// server-side text-extraction path stable regardless of whether
// the user sent text alongside the image(s).
var promptBlocks: [[String: Any]] = [
["type": "text", "text": text] as [String: Any],
]
for image in images {
promptBlocks.append([
"type": "image",
"data": image.base64Data,
"mimeType": image.mimeType,
] as [String: Any])
}
let params: [String: AnyCodable] = [
"sessionId": AnyCodable(sessionId),
"messageId": AnyCodable(messageId),
"prompt": AnyCodable(promptBlocks as [Any]),
]
let result = try await sendRequest(method: "session/prompt", params: params)
let dict = result?.dictValue ?? [:]
let usage = dict["usage"] as? [String: Any] ?? [:]
statusMessage = "Ready"
return ACPPromptResult(
stopReason: dict["stopReason"] as? String ?? "end_turn",
inputTokens: usage["inputTokens"] as? Int ?? 0,
outputTokens: usage["outputTokens"] as? Int ?? 0,
thoughtTokens: usage["thoughtTokens"] as? Int ?? 0,
cachedReadTokens: usage["cachedReadTokens"] as? Int ?? 0
)
}
public func cancel(sessionId: String) async throws {
let params: [String: AnyCodable] = [
"sessionId": AnyCodable(sessionId),
]
_ = try await sendRequest(method: "session/cancel", params: params)
statusMessage = "Cancelled"
}
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],
]
await writeJSON(response)
}
// MARK: - JSON-RPC Transport
private func sendRequest(method: String, params: [String: AnyCodable]) async throws -> AnyCodable? {
let requestId = nextRequestId
nextRequestId += 1
let request = ACPRequest(id: requestId, method: method, params: params)
guard let data = try? JSONEncoder().encode(request),
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.
let timeoutTask: Task<Void, Error>? = if method != "session/prompt" {
Task { [weak self] in
try await Task.sleep(nanoseconds: 30 * 1_000_000_000)
await self?.timeoutRequest(id: requestId, method: method)
}
} else {
nil
}
defer { timeoutTask?.cancel() }
guard let ch = channel else {
throw ACPClientError.notConnected
}
return try await withCheckedThrowingContinuation { (continuation: CheckedContinuation<AnyCodable?, Error>) in
pendingRequests[requestId] = continuation
// 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
do {
try await ch.send(line)
} catch {
await self?.handleWriteFailedForRequest(id: requestId)
}
}
}
}
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]) async {
guard let ch = channel,
let data = try? JSONSerialization.data(withJSONObject: dict),
let line = String(data: data, encoding: .utf8)
else { return }
do {
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 {
#if canImport(os)
await self?.logParseFailure(error, line: line)
#endif
}
}
await self?.handleReadLoopEnded(cleanly: true)
} catch {
await self?.handleReadLoopEnded(cleanly: false, error: error)
}
}
// 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) {
eventContinuation?.yield(event)
}
} else if message.isRequest {
if message.method == "session/request_permission",
let event = ACPEventParser.parsePermissionRequest(message) {
statusMessage = "Permission required"
eventContinuation?.yield(event)
}
}
}
// MARK: - Disconnect Cleanup
/// Single idempotent cleanup path for all disconnect scenarios.
/// Captures the channel's exit code + recent stderr BEFORE we drop
/// the reference, so the `processTerminated` error rides with
/// diagnostics the user banner shows "exit 255 ssh: connect to
/// host : Connection refused" instead of a bare opaque timeout.
private func performDisconnectCleanup(reason: String) async {
guard isConnected else { return }
#if canImport(os)
logger.warning("ACP disconnecting: \(reason)")
#endif
let exitCode = await channel?.lastExitCode
let tail = recentStderr
isConnected = false
statusMessage = "Connection lost"
for (_, continuation) in pendingRequests {
continuation.resume(throwing: ACPClientError.processTerminated(
exitCode: exitCode,
stderrTail: tail
))
}
pendingRequests.removeAll()
eventContinuation?.finish()
eventContinuation = nil
}
private func handleReadLoopEnded(cleanly: Bool, error: Error? = nil) async {
let reason = cleanly ? "read loop ended (EOF)" : "read loop failed: \(error?.localizedDescription ?? "unknown")"
await performDisconnectCleanup(reason: reason)
}
private func handleWriteFailed() async {
await performDisconnectCleanup(reason: "write failed (broken pipe)")
}
private func handleWriteFailedForRequest(id: Int) async {
if let continuation = pendingRequests.removeValue(forKey: id) {
let exitCode = await channel?.lastExitCode
continuation.resume(throwing: ACPClientError.processTerminated(
exitCode: exitCode,
stderrTail: recentStderr
))
}
await performDisconnectCleanup(reason: "write failed (broken pipe)")
}
}
// MARK: - Errors
public enum ACPClientError: Error, LocalizedError {
case notConnected
case encodingFailed
case invalidResponse(String)
case rpcError(code: Int, message: String)
case processTerminated(exitCode: Int32?, stderrTail: String)
case requestTimeout(method: String)
public var errorDescription: String? {
switch self {
case .notConnected: return "ACP client is not connected"
case .encodingFailed: return "Failed to encode JSON-RPC request"
case .invalidResponse(let msg): return "Invalid ACP response: \(msg)"
case .rpcError(let code, let msg): return "ACP error \(code): \(msg)"
case .processTerminated(let exit, let tail):
let exitPart = exit.map { "exit \($0)" } ?? "no exit code"
let tailPart = Self.firstNonEmptyLine(in: tail).map { "\($0)" } ?? ""
return "ACP process terminated unexpectedly (\(exitPart))\(tailPart)"
case .requestTimeout(let method): return "ACP request '\(method)' timed out"
}
}
/// Pluck the first non-empty stderr line for the user-facing
/// summary. Full tail still rides through on `acpErrorDetails`,
/// but the description itself stays single-line.
private static func firstNonEmptyLine(in s: String) -> String? {
for raw in s.split(separator: "\n") {
let line = raw.trimmingCharacters(in: .whitespaces)
if !line.isEmpty { return line }
}
return nil
}
}
/// 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.
public enum ACPErrorHint {
public static func classify(errorMessage: String, stderrTail: String) -> String? {
let haystack = errorMessage + "\n" + stderrTail
// SSH-level failures come first they apply only to remote
// contexts and the patterns are unambiguous (system ssh prints
// them verbatim to stderr). Without these classifications a
// vanished droplet, a wrong key, or a missing remote `hermes`
// all surface as opaque "ACP process terminated" / "request
// timed out", and the user has no idea where to look.
if haystack.contains("Connection refused") {
return "Couldn't reach the remote host — the SSH port is closed or the droplet is down. Check the host is running and reachable."
}
if haystack.localizedCaseInsensitiveContains("Operation timed out")
|| haystack.localizedCaseInsensitiveContains("Connection timed out")
|| haystack.contains("Network is unreachable")
|| haystack.contains("No route to host") {
return "Couldn't reach the remote host — the network connection timed out. Check the host is running and your network is up."
}
if haystack.contains("Permission denied (publickey")
|| haystack.contains("Permission denied, please try again") {
return "SSH rejected the key. Make sure the right identity file is selected and that ssh-agent has the key loaded — open Terminal and run `ssh-add -l`."
}
if haystack.contains("Host key verification failed")
|| haystack.contains("REMOTE HOST IDENTIFICATION HAS CHANGED") {
return "The remote host's SSH key changed. If you just rebuilt the droplet, remove the old entry with `ssh-keygen -R <host>`, then try again."
}
if haystack.contains("Could not resolve hostname")
|| haystack.contains("Name or service not known") {
return "Couldn't resolve the host name. Check the host in this server's settings."
}
if haystack.localizedCaseInsensitiveContains("command not found")
|| haystack.contains("hermes: not found")
|| haystack.contains("exit 127") {
return "The remote shell couldn't find `hermes`. Either install Hermes on the remote (`pipx install hermes-agent`) or set an absolute binary path in this server's settings."
}
if haystack.range(of: #"No\s+(Anthropic|OpenAI|OpenRouter|Gemini|Google|Groq|Mistral|XAI)?\s*credentials\s+found"#,
options: .regularExpression) != nil
|| haystack.contains("ANTHROPIC_API_KEY")
|| haystack.contains("ANTHROPIC_TOKEN")
|| haystack.contains("claude setup-token")
|| haystack.contains("claude /login") {
return "Hermes can't find your AI provider credentials. Set `ANTHROPIC_API_KEY` (or similar) in `~/.hermes/.env` or your shell profile, then restart Scarf."
}
if let match = haystack.range(of: #"No such file or directory:\s*'([^']+)'"#,
options: .regularExpression) {
let matched = String(haystack[match])
if let nameStart = matched.range(of: "'"),
let nameEnd = matched.range(of: "'", range: nameStart.upperBound..<matched.endIndex) {
let name = String(matched[nameStart.upperBound..<nameEnd.lowerBound])
return "Hermes couldn't find `\(name)` on PATH. If you use nvm/asdf/mise, make sure it's exported in `~/.zprofile` (not only `~/.zshrc`), then restart Scarf."
}
return "Hermes couldn't find a required binary on PATH. Check that your shell's PATH is exported in `~/.zprofile`, then restart Scarf."
}
if haystack.localizedCaseInsensitiveContains("rate limit")
|| haystack.localizedCaseInsensitiveContains("429") {
return "Your AI provider returned a rate-limit error. Try again in a moment."
}
return nil
}
}
@@ -0,0 +1,281 @@
// 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>?
/// Read by `ACPClient` to fill in `processTerminated(exitCode:)`
/// so the error names the actual exit code rather than reporting a
/// bare timeout. Sourced directly from `Process` `Process` is
/// thread-safe for this read and reflects the actual reap state,
/// so we sidestep the race between the OS-side `terminationHandler`
/// callback and the EOF-driven disconnect cleanup that would
/// otherwise need an atomic to coordinate.
public var lastExitCode: Int32? {
process.isRunning ? nil : process.terminationStatus
}
/// 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)
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
startReaders()
installTerminationHandler()
}
/// 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)
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
startReaders()
installTerminationHandler()
}
/// Wire fresh stdin/stdout/stderr pipes (overwriting any the caller
/// set) and start the subprocess.
private static func launch(process: Process) async throws {
process.standardInput = Pipe()
process.standardOutput = Pipe()
process.standardError = Pipe()
do {
try process.run()
} catch {
throw ACPChannelError.launchFailed(error.localizedDescription)
}
}
/// Install a `terminationHandler` that closes the stdout read end
/// the moment the OS reaps the child. Without this, the reader
/// loop's `availableData` keeps blocking until the kernel tears
/// the pipe down on its own schedule visible to the user as a
/// 30s ACP `initialize` timeout where a fast SSH-side failure
/// (Connection refused, exit 127) should surface in under a
/// second. The exit code itself is read on demand from
/// `Process.terminationStatus` (see `lastExitCode`), so this
/// callback doesn't need to touch actor state.
private func installTerminationHandler() {
let stdoutFh = stdoutPipe.fileHandleForReading
process.terminationHandler = { _ in
try? stdoutFh.close()
}
}
/// 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)

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