Commit Graph

361 Commits

Author SHA1 Message Date
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 v2.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 v2.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 v2.5.0 2026-04-25 17:34:02 +02:00