Commit Graph

61 Commits

Author SHA1 Message Date
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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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
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