Commit Graph

349 Commits

Author SHA1 Message Date
Alan Wizemann 6cf59c8a44 feat(scarfmon): perf instrumentation plumbing for iOS + Mac (Phase 1)
ScarfMon lands the always-on perf instrumentation harness. Phase 1 ships
the plumbing only; Phase 2 wires the chat measure points.

Core (ScarfCore/Diagnostics/):
- ScarfMon — public API: measure / measureAsync / event with @inline(__always)
  short-circuit when the backend set is empty so the off path is one
  branch + return. Categories are an enum, names are StaticString so
  user content cannot leak through metric tags.
- ScarfMonRingBuffer — fixed-capacity (4096) lock-protected ring; one
  os_unfair_lock per record; summary() aggregates by (category, name)
  with nearest-rank p50/p95; exportJSON() emits a one-line-per-sample
  dump for the Copy as JSON button.
- ScarfMonSignpostBackend — emits os_signpost into a dedicated
  com.scarf.mon subsystem so Instruments → Points of Interest shows
  Scarf's own measure points without a debug build.
- ScarfMonLoggerBackend — Logger(.debug) sink for users running
  `log stream --predicate 'subsystem == \"com.scarf.mon\"'`.
- ScarfMonBoot — three modes (off / signpostOnly / full); persists the
  user's choice in UserDefaults under ScarfMonMode; configure() is
  idempotent and replaces the active backend set atomically.

Tests: 11 cases covering ring ordering / wrap / reset, summary
aggregation, p95 percentiles, event vs interval semantics, install /
isActive, measure + measureAsync (including the throw path), boot
mode transitions, and JSON export round-trip. @Suite(.serialized)
because the suite mutates process-wide backend state.

App wiring:
- ScarfIOSApp.init + ScarfApp.init call ScarfMonBoot.configure(mode:)
  with the persisted mode (default .signpostOnly).
- iOS Settings → Diagnostics → Performance row leads to a list-style
  panel with the segmented mode picker, top-20 stat rows by p95, Copy
  as JSON, and Reset.
- Mac Settings → Advanced gains a ScarfMonDiagnosticsSection with the
  same shape (NSPasteboard for copy).

Open-source by design — no remote upload, no analytics. The ring buffer
never leaves the device unless the user explicitly taps Copy as JSON.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-04 22:08:21 +02:00
Alan Wizemann 272da6a915 fix(transport,widgets): code-review fixes for v2.7 + iOS Citadel transport
- CronStatusWidgetView: include jobId + lineCount in `.task(id:)` so widget reload fires when dashboard.json changes either field, not only when the file watcher ticks
- CitadelServerTransport.runScript: enforce the timeout via withThrowingTaskGroup race; propagate transport-level Citadel errors as TransportError.other (so RemoteSQLiteBackend.query maps them to BackendError.transport instead of misclassifying as BackendError.sqlite via a fake -1 exit code); throw TransportError.timeout on the deadline branch with partial stdout preserved
- SSHScriptRunner: close fileHandleForReading on stdout/stderr Pipes in the timeout branch (success path already did); check Task.isCancelled inside the busy-wait so a cancelled parent task terminates the subprocess early instead of waiting out the full timeout. Both runOverSSH and runLocally fixed.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-04 21:40:07 +02:00
Alan Wizemann c7bcfd8655 feat(dashboards): v2.7 widget catalog — file-reading widgets, sparkline, typed status, project-wide watch
Major project-dashboard release. Five new widget types (markdown_file, log_tail,
cron_status, image, status_grid), inline sparkline on stat, typed status enum
shared by list + status_grid, structured WidgetErrorCard, and a project-wide
.scarf/ directory watch that picks up files cron jobs write next to dashboard.json.

- ProjectDashboard: extend DashboardWidget with path/lines/jobId/cells/gridColumns/sparkline; add StatusGridCell + ListItemStatus (lenient parse with synonyms)
- HermesFileWatcher: watch each project's .scarf/ dir alongside dashboard.json (local FSEvents + remote SSH mtime poll); updateProjectWatches signature now takes dashboardPaths + scarfDirs
- New widget views: CronStatus, Image, LogTail, MarkdownFile, StatusGrid, plus WidgetErrorCard for structured failure messaging; legacy "Unknown" placeholder replaced everywhere
- WidgetPathResolver: project-root-anchored path resolution that rejects absolute paths + ".." escapes pre and post canonicalization
- Stat widget gains optional inline sparkline (pure SwiftUI Path, no Charts dep); list widget rows route through typed status with semantic icons + ScarfColor tints
- iOS list widget + unsupported card adopt typed status + warning-toned error card (parity with Mac error styling); new widget types remain Mac-only
- Site mirror: widgets.js renders all five new types (file-reading widgets show annotated catalog placeholders), sparkline SVG, status-grid grid; styles.css adds typed-status palette + error-card + sparkline + grid styles
- Catalog validator: tools/widget-schema.json is the single source of truth; build-catalog.py loads it and enforces per-type required fields. 8 new test cases in test_build_catalog.py covering schema load, v2.7 additions, and missing-required rejection
- Template-author skill (SKILL.md) gains v2.7 Widget Catalog section + canonical status guidance; CONTRIBUTING.md points authors at widget-schema.json; template-author bundle rebuilt
- Localizable.xcstrings picks up auto-extracted strings for the previously-shipped OAuth keepalive feature
- Release notes drafted at releases/v2.7.0/RELEASE_NOTES.md

Backwards compatible — existing dashboard.json renders byte-identically, status synonyms (ok/up/down/active/etc.) keep working.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-04 21:16:29 +02:00
Alan Wizemann 9d945150e0 fix(chat): suppress 'stop' badge in metadata footer for normal turn ends
Every text-bearing assistant turn finalizes with `finishReason="stop"`
(set by `RichChatViewModel.finalizeStreamingMessage` line 881 — the
standard end-of-turn signal Hermes/ACP/OpenAI all emit). The
`metadataFooter` in `RichMessageBubble` was rendering it
unconditionally, so every assistant bubble carried a `· stop · TIME`
footer. Combined with terse model output (e.g. deepseek-v4-flash
emitting only a brief status line before ending the turn), the
badge created a misleading "the agent gave up" impression — there
was no warning, error, or actual failure.

Match the convention used by ChatGPT, Claude.ai, Cursor, etc.:
suppress the badge for normal end-of-turn (`stop` / `end_turn`),
reserve it for abnormal terminations the user actually wants to
see (`max_tokens`, `length`, `error`, `refusal`, `content_filter`,
…). When it does render, color it with severity tone — warning
yellow for "response cut short" cases, danger red for failures
and refusals, muted otherwise.

The existing `handlePromptComplete` system-message-injection path
(line 725-751) for non-`end_turn` stops still surfaces those cases
explicitly at the top of the chat — this change only trims the
always-on badge from the per-message footer.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-04 15:40:31 +02:00
Alan Wizemann fa15634381 fix(oauth-keepalive): drop unsupported --silent flag from cron create
`hermes cron create` only accepts --name, --deliver, --repeat,
--skill, --script, --workdir. The `silent: Bool?` field on
HermesCronJob exists in the JSON model but isn't exposed through
the CLI's create verb today — argparse rejected the unknown flag,
non-zero exit, toggle failed with the generic CLI hint.

Drops the flag; the keepalive runs with Hermes's default delivery.
Token-refresh side effect during session boot is unaffected.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-04 15:33:25 +02:00
Alan Wizemann 3271391506 fix(chat): debounce sidebar reloads so sessions list doesn't flicker mid-stream
ChatView's `.onChange(of: fileWatcher.lastChangeDate)` fired an
unconditional `Task { await viewModel.loadRecentSessions() }` on
every file-watcher tick. During an ACP message stream the watcher
fires 5–10 times per second (every message Hermes persists bumps
`state.db-wal`'s mtime), and each spawned task re-fetched sessions +
previews + project attribution and reassigned `recentSessions` even
though the data was identical. Each reassignment triggered an
@Observable re-render of the chat sidebar; the user saw the chats
list visibly disappear and reappear several times while typing the
first message in a new chat.

Two changes:

* Add `scheduleSessionsRefresh()` to ChatViewModel — coalesces rapid
  ticks into one trailing `loadRecentSessions()` ~500 ms after the
  last tick. ChatView's onChange now calls this instead. The 500 ms
  window is short enough that idle external changes (a session
  created from another `hermes` invocation, a rename from a
  different window) still appear "soon", and long enough to absorb
  a streaming-response burst.
* Add an explicit `await loadRecentSessions()` to
  `autoStartACPAndSend` after the new session id resolves — the
  debounce would otherwise delay the just-created chat from
  appearing in the sidebar by 500 ms after first send. Mirrors what
  `startACPSession` already does at line 619 for the explicit New /
  Resume paths.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-04 14:56:59 +02:00
Alan Wizemann 5afd391838 feat(sidebar): promote Projects to first section + move profile chip under server name
Two small UX tweaks to the macOS sidebar:

* Reorder sections so Projects is the top section above Monitor.
  Reflects how users actually start sessions in Scarf — they pick a
  project first, then drill into chat / sessions / etc. The previous
  order put the read-mostly Dashboard at the top, which made
  Projects feel like a secondary surface.
* Move the active-profile chip out of the top header HStack (where
  it competed for horizontal space with the server-name pill) and
  drop it into a second row right-aligned under the server name.
  Top row stays clean: `[icon] Scarf       <server>`. Second row:
  `                              profile: <name>` only on local
  contexts. Same click target, same .help, just better-anchored.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-04 14:37:29 +02:00
Alan Wizemann 2a368a04f7 feat(window): persist window size + position across app launches
SwiftUI's WindowGroup exposes `.defaultSize` and `.windowResizability`
but no built-in autosave for window frame across launches. The
documented escape hatch is AppKit's
`NSWindow.setFrameAutosaveName(_:)`, which writes the frame to
UserDefaults on resize/move and restores it on next open.

Add a small `WindowFrameAutosave` NSViewRepresentable that finds its
hosting NSWindow on first appear and stamps the autosave name. Apply
it to `ContextBoundRoot` keyed off `context.id` so each open server
window remembers its own geometry. New servers fall back to the
WindowGroup's `.defaultSize(1100, 700)` until the user resizes once.

A previous WIP attempt (dd4a61f) tried to use a fictional
`.windowFrameAutosaveName(...)` SwiftUI modifier that doesn't exist —
which is why it was never merged. This works because we go through
AppKit directly.

Also picks up Xcode's auto-extracted cron-related Localizable.xcstrings
entries that had been pending.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-04 14:34:08 +02:00
Alan Wizemann 9aa901a286 fix(credential-pools): refresh view after OAuth sheet dismiss
The sheet auto-closes 0.8s after `oauthFlow.succeeded` flips, but
the parent view didn't reload — so the expiry badge stayed red and
the `tokenTail` stayed stale until the user hit Reload. Hook
`viewModel.load()` + `probeKeepalive()` into the sheet's
`onDismiss` so the freshly-written `auth.json` lands on screen
immediately. Runs on every dismiss (success or cancel) — `load()`
is cheap and idempotent.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-04 14:33:22 +02:00
Alan Wizemann 111fe9bb67 feat(oauth): unblock remote re-auth + daily keepalive to prevent expiry
Two related fixes for OAuth subscriptions (Nous Portal, Anthropic
Claude OAuth, etc.):

- **Remote re-auth stall**: Both `NousAuthFlow` and
  `OAuthFlowController` set `PYTHONUNBUFFERED=1` only on local
  contexts. On remote, setting `proc.environment` only affects the
  local-side ssh process — not the remote python interpreter. ssh
  doesn't forward arbitrary env vars without `SendEnv` configured on
  both sides, so remote hermes ran with default block-buffered stdout
  and the device-code prompt never reached Scarf — the sheet hung at
  "Contacting Nous Portal" forever. Fix: when remote, wrap the
  command in `env PYTHONUNBUFFERED=1 …` to inject the var on the
  remote side regardless of ssh config.
- **Daily keepalive**: Hermes refreshes OAuth access tokens on agent
  startup but never proactively. If the user goes longer than the
  refresh-token lifetime (~30 days for Nous) without starting a
  session, the refresh token itself expires and full re-auth is
  required. New `OAuthKeepaliveCronService` registers a Scarf-owned
  daily cron job (`[scarf:oauth-keepalive] OAuth token refresh`) at
  4am that runs a minimal one-token prompt — booting the session is
  what triggers `resolve_nous_runtime_credentials()`. Wired as an
  opt-in toggle in the OAuth providers section of CredentialPoolsView.
  When `hermes auth refresh <provider>` lands upstream we'll swap the
  prompt for that verb; the surrounding wiring stays unchanged.
- **Stale-refresh nudge**: `NousSubscriptionState` gains
  `daysSinceLastRefresh()` + `hasStaleRefresh` (>= 14 days, half of
  Nous's 30-day refresh-token window). The keepalive section
  surfaces an inline orange warning when stale and the toggle is
  off — points the user at the toggle that would have prevented the
  problem.

Verification: scarfCore 263/263; Mac app builds clean. Manual repro
of remote stall against Digital Ocean droplet pending user test.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-04 14:32:06 +02:00
Alan Wizemann 6191c9f19f fix(remote-backend): pre-expand ~/ in Swift via resolvedUserHome
The previous fix (b8b426e) rewrote `~/.hermes/state.db` to
`"$HOME/.hermes/state.db"` and relied on the remote shell to expand
$HOME. That works on Mac SSHTransport (login shell with $HOME set in
the environment) but not reliably through Citadel's exec channel +
base64-decode + inner-/bin/sh pipeline on iOS — the user reports
"unable to open database \"~/.hermes/state.db\"" connecting from
ScarfGo (iOS Simulator) to 127.0.0.1, meaning the literal `~`
character reached sqlite3 untouched.

Switch to client-side expansion: probe remote $HOME once at
RemoteSQLiteBackend.open() via the existing
ServerContext.resolvedUserHome() helper (which uses transport.runProcess
to `echo $HOME` — same code path Hermes CLI calls already exercise
successfully on iOS). Cache the result. quoteForRemoteShell then
substitutes `~/` with the absolute path in Swift before single-
quoting, so sqlite3 receives `/Users/alan/.hermes/state.db` directly
— no nested-shell expansion required.

Falls back to the previous "$HOME/..."-quoted form when the home
probe fails (rare; covers the case where runProcess can't reach the
remote but the user happens to have a working streamScript path).

Mirrors how RemoteBackupService.expandTilde already handles the same
problem upstream.

Refs #74

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-04 13:40:33 +02:00
Alan Wizemann b8b426ed75 fix(remote-backend): expand ~/ to $HOME so sqlite3 finds the DB
Default-config remotes (Hetzner, Digital Ocean, anything where the
user hasn't overridden remoteHome on the SSHConfig) have
`paths.stateDB == "~/.hermes/state.db"`. The streaming backend was
single-quoting that path, which suppresses tilde expansion, and
sqlite3 itself doesn't expand `~` (that's a shell affordance). Result:
"Error: unable to open database \"~/.hermes/state.db\": unable to open
database file" — the path was reaching sqlite3 with a literal `~`
that it tried to interpret as a directory name.

Replace the single-quote-only `escape(_:)` with `quoteForRemoteShell(_:)`
that mirrors `SSHTransport.remotePathArg`'s pattern: rewrite leading
`~/` to `"$HOME/..."` (double-quoted so the shell expands `$HOME`,
backslash-escaping any embedded `\\`, `"`, `$`, ` to keep the literal
intact), bare `~` to `"$HOME"`, and absolute paths get the standard
single-quote-with-`'\''`-escape treatment.

Adds a regression test (`openWithDefaultTildeHomeExpands`) that
exercises the tilde-rewrite end-to-end against a real /bin/sh: places
a fixture state.db at `~/.hermes/state.db` (backing up the user's
real DB if present) and verifies open() + a query both succeed
through the streaming path.

Refs #74

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-04 13:34:20 +02:00
Alan Wizemann 593b4e62cb feat(remote): replace SQLite snapshot pipeline with SSH query streaming
The remote-DB pipeline pulled the entire state.db down via scp on
every refresh tick. For the issue #74 user (4.87 GB DB) that meant
~7-min per-snapshot wall time even with the size-aware-timeout fix,
~30 GB/hour upload, and data permanently 5–10 minutes stale. This
isn't a bug to patch — it's the wrong architecture for any non-trivial
remote DB.

Replace it with per-query streaming over SSH. Each SQL statement
becomes one ssh round-trip running `sqlite3 -readonly -json` against
the live remote DB. ControlMaster keeps the channel warm at ~5 ms
overhead; sqlite3 cold-start adds ~30–50 ms; total ~50–100 ms per
query vs. the old multi-minute snapshot. Bandwidth scales with query
result size, not DB size.

What changed:

* New `HermesQueryBackend` protocol and two implementations:
  `LocalSQLiteBackend` (libsqlite3 in-process — local performance
  unchanged) and `RemoteSQLiteBackend` (sqlite3 over SSH per query
  with batched-statement support for multi-query view loads).
* `SQLValue` and `Row` types as the typed boundary between backends
  and the row parsers. `SQLValueInliner` substitutes `?` placeholders
  with SQLite-escaped literals for the remote-CLI codepath (local
  backend keeps real `sqlite3_bind_*`).
* `ServerTransport` swaps `snapshotSQLite` + `cachedSnapshotPath` for
  `streamScript(_:timeout:)`. SSHTransport delegates to the existing
  `SSHScriptRunner`; CitadelServerTransport (iOS) base64-encodes the
  script + decodes remotely via Citadel's exec channel since stdin
  pipes aren't supported there yet.
* `HermesDataService` becomes a thin facade — every fetch* method
  routes through `backend.query(...)`. Public API is unchanged for
  view-model callers; `lastSnapshotMtime`/`isUsingStaleSnapshot`/
  `staleAge` removed (had zero UI consumers).
* New `dashboardSnapshot()` and `insightsSnapshot(since:)` batched
  calls turn Dashboard's 4-query and Insights' 5-query view loads
  into one SSH round-trip each (~80–100 ms total instead of ~280 ms
  naive). DashboardViewModel and InsightsViewModel updated to use
  them.
* One-time launch migration in `scarfApp` wipes the orphaned
  `~/Library/Caches/scarf/snapshots/` directory (could be 5 GB+ for
  the issue #74 user).

JSON parsing detail: sqlite3 -json preserves SELECT column order in
the raw bytes, but `[String: Any]` from NSJSONSerialization doesn't.
The remote backend extracts column ordering by walking the first
object's literal bytes — without this, every positional row read
(`row.string(at: 0)`) would silently return wrong columns.

Tests: 41 new across `SQLValueInlinerTests`, `HermesDataServiceBackendTests`
(mock backend) and `RemoteSQLiteBackendTests` (integration via local
sqlite3 binary). Full suite 262/262 passing.

Builds clean on Mac and iOS. Ships as part of v2.7.

Refs #74

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-04 13:09:06 +02:00
Alan Wizemann de36411a8d fix(remote): size-aware snapshot timeouts and partial-file cleanup (#74)
The remote-DB snapshot pipeline was hardcoded to a 120s scp timeout and
a 60s remote-backup timeout. For users with a multi-GB state.db (the
report cites 4.87 GB), 120s is wildly insufficient — at typical home
upload speeds (5-50 Mbps) a 5GB transfer takes 13 minutes to several
hours. scp gets killed mid-transfer, leaves a partially-written .db at
the cache path, and every subsequent attempt opens that corrupt file
with sqlite_open returning garbage. Symptom: SSH connects, all
diagnostics pass, but Dashboard / Sessions / Memory show no data.

Changes to SSHTransport.snapshotSQLite:

* Probe `stat` on the remote DB before starting. Drives both the
  timeout budget and a local-disk-space pre-flight (refuses to start
  if local Caches volume can't hold size + 500MB margin).
* Adaptive timeouts based on remote size:
  - backup: 60s base + 1s per 100MB, capped at 600s.
  - scp:    300s base + 0.5s per MB (≈2 MB/s minimum throughput),
            capped at 3600s.
  Defaults of 60s/300s when stat fails (still up from 120s on scp).
* Add `-C` to scp args. SQLite DBs have lots of zero-padded empty
  pages and typically compress 30-50% in transit.
* On any failure path, remove the partial local snapshot file so the
  next attempt starts fresh instead of opening a corrupt DB.
* Rewrite the generic "Command timed out after Ns" error into a
  specific "Snapshot transfer timed out after Ns pulling X.X GB
  state.db from <host>" so users on slow links know what hit the
  wall instead of seeing a meaningless number.

Cannot reproduce locally (no 5GB state.db on hand), but the failure
mode is unambiguous from code reading: hardcoded 120s vs. real-world
multi-GB transfer durations.

Closes #74

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-04 11:25:38 +02:00
Alan Wizemann 6a7ac21ebe chore: Bump version to 2.6.5 v2.6.5 2026-05-03 22:15:05 +02:00
Alan Wizemann 5be67282d8 test(layer-b): full Install → Configure → Open → Uninstall journey XCUITest (#73)
Closes the deferred Layer B install-drive that v2.7's smoke test
left as future work. The new test
(`testFullCatalogToInstallToDashboardJourney`) drives the full
install/uninstall pipeline end-to-end and validates 9 assertion
points along the way:

- Window surfaces under `--scarf-test-mode`
- Sidebar navigation to Projects
- Install sheet appears (URL handoff via launch arg)
- Parent-dir field accepts custom path + Continue
- Configure sheet renders + commit clicks
- Confirm Install runs the install pipeline
- Open Project advances to success view
- Project row appears in sidebar with uniquified name
- Right-click Uninstall + confirm Remove + Done removes the row

Runs in ~30s green on the dev Mac.

## What needed wiring up

**SwiftUI Menu / NSToolbarItem accessibility-bridging.** macOS
toolbar Menus don't propagate `.accessibilityIdentifier` through to
XCUITest — neither the menu trigger NOR the popup contents are
queryable by ID. Verified by tree-dump diagnostics. The test
sidesteps this entirely by routing the install URL through a new
`--scarf-test-install-url <https-url>` launch arg that calls
`TemplateURLRouter.shared.handle(scarf://install?url=...)` at App
init, gated on `TestModeFlags.shared.isTestMode`. Production
launches (no flag) untouched.

**Accessibility IDs added** on the new install/uninstall path:
- `templateConfig.commitButton`, `templateConfig.cancelButton`
- `projects.row.<name>`, `sidebar.section.<rawValue>`
- `projects.contextMenu.uninstallTemplate`
- `templateUninstall.confirmRemove`
- `templateInstall.success.openProject`
- `templateUninstall.success.done`

**Sandboxed-runner caveat.** The XCUITest runner's `/tmp` is
sandbox-protected (createDirectory throws EPERM); we use
`NSTemporaryDirectory()` which resolves to the runner's container
tmp (`~/Library/Containers/com.scarfUITests.xctrunner/Data/tmp/`),
which the unsandboxed Scarf app can read since it has full disk
access.

## Known cohabitation hazard (pre-existing uninstaller bug)

If the dev Mac already has a project from the same template
installed, the install pipeline uniquifies the new project's name
("HackerNews Daily Digest 2") but BOTH projects' cron jobs get
registered under the same `[tmpl:awizemann/hackernews-digest] Daily
HN digest` name. `ProjectTemplateUninstaller.loadUninstallPlan`
resolves cron jobs to remove by NAME and can target the wrong
project's job. The Layer B test surfaces this — manifests as: test
passes, the dev's real project's cron job disappears.

**Fix (separate work):** store cron-job IDs in
`<project>/.scarf/template.lock.json` at install time and resolve
by ID at uninstall time. Until then, the test docstring warns
about cohabitation; recovery is `hermes cron create` to recreate
the lost job.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 22:09:50 +02:00
Alan Wizemann c661945a1f feat(cron): auth-error banner + running indicator + per-job log tail (#72)
Cron rows now surface the same OAuth-refresh-revoked recovery flow as
chat instead of a generic red dot, plus three previously-missing
observability cues:

- ACPErrorHint.classify is reused on `job.lastError`. When it returns
  `oauthRefreshRevoked(provider)` the detail pane shows the human hint
  + a "Re-authenticate" button that drops the user into Credential
  Pools via `coordinator.pendingOAuthReauth = provider` — same wiring
  ChatView's banner uses. Unrecognized errors fall back to the legacy
  red `lastError` text (no regression).
- Row dot turns blue + pulses when `state == "running"` (taking
  precedence over disabled / error / success); the detail header gains
  a `ScarfBadge("running…", kind: .info)` next to active/paused. No new
  polling — `HermesFileWatcher.lastChangeDate` (already wired into
  ActivityView/Logs) drives `CronViewModel.load()` so state flips
  surface within a watcher tick.
- "LAST RUN OUTPUT" replaces the inline `LAST OUTPUT` block with a
  collapsible panel: a one-line summary (`<timestamp> — ok|error|running…`)
  always visible, full monospaced terminal-style scroll view on
  expand, auto-scrolls to bottom when new runs land.

Also fixes a pre-existing bug in `HermesFileService.loadCronOutput`:
Hermes nests per-run output under `~/.hermes/cron/output/<jobId>/<ts>.md`
but the loader treated the dir as flat, so the cron output panel never
rendered any content. The fix walks the per-job subdir + keeps the
legacy flat-file fallback for older Hermes layouts.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 22:09:21 +02:00
Alan Wizemann f5f8dc30b6 Dogfooding templates: HN Digest + in-app catalog browser + test harness (#71)
* feat(templates): hackernews-digest template + dogfooding test harness

First pass of the dogfooding-templates initiative. Each pre-release cycle
ships one new official `.scarftemplate` and uses installing/exercising
that template as the regression test. v1 lands the harness scaffolding
plus the first template under it.

- HackerNews Daily Digest template (`templates/awizemann/hackernews-digest/`):
  config-driven (min_score / max_items / topics) cron-only template.
  No secrets — keeps the harness minimal until the fake-Keychain shim
  lands. Bundle validates against `tools/build-catalog.py`; entry added
  to `templates/catalog.json`.
- `SCARF_HERMES_HOME` env-var override at `HermesProfileResolver` —
  the seam every Layer-B test relies on to drive Scarf against an
  isolated Hermes home. Bypasses cache + active_profile lookup; rejects
  relative paths. 5 unit tests + 3 ServerContext integration tests.
- `TestModeFlags.shared.isTestMode` — reads `--scarf-test-mode` once
  from `CommandLine.arguments`. Wiring only; gating sites (Sparkle,
  capability probe, first-run walkthrough) land as Layer-B exercises
  them.
- Layer A (`scarf/scarfTests/TemplateE2ETests.swift`): parses + plans
  the shipped HN bundle the way the app does at install time;
  asserts manifest, config schema, dashboard widgets, and cron prompt
  contract. Mirrors the existing site-status-checker coverage.
- Layer B scaffold (`scarf/scarfUITests/TemplateInstallUITests.swift`):
  proves the launch-arg + env-var plumbing reaches Scarf. Full install
  click-through deferred until fixture-Hermes-home and accessibility
  IDs land.

Wiki pages added separately on the `.wiki-worktree` branch:
- `Template-Ideas.md` — backlog of 9 v1-feasible templates +
  full-spec v3 epic for Project-Site-as-Living-Surface (eBay listings
  use case).
- `Test-Harness.md` — contributor guide for extending the harness.

Verification: scarfTests 124/124, ScarfCore 220/220, new Layer A 3/3,
Layer B scaffold 1/1, build-catalog.py + its 28 unit tests all green.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(test-harness): Layer B pivot to real ~/.hermes + a11y IDs + Sparkle gating

Discovered during Layer B work that XCUITest runners are sandboxed:
they can read ~/.hermes/ but writes throw NSFileWriteNoPermissionError.
That kills the SCARF_HERMES_HOME-based isolation pattern for UI tests —
snapshot/restore from inside the runner can't work. Pivot:

- Layer B drives the real ~/.hermes the dev Mac is already running
  against. The harness assumes a working Hermes install (XCTSkip if
  the binary isn't there). Cleanup is via the app's own UI flows
  (which have full disk access), not direct file I/O. Layer A keeps
  its env-var seam — those tests run inside the host app's address
  space and write freely.
- SwiftUI's WindowGroup(for: ServerID.self) doesn't auto-surface a
  window on a fresh XCUIApplication.launch(). The harness sends ⌘1
  (the "Open Server → Local" menu shortcut wired in scarfApp.swift's
  OpenServerCommands) to take the same code path real users hit via
  Dock click.
- Real user home resolved via getpwuid(getuid()) rather than
  NSHomeDirectory(), which inside the sandboxed runner returns
  ~/Library/Containers/com.scarfUITests.xctrunner/Data.
- 8 accessibility IDs added on the install path so the next iteration
  can drive the full Templates → Install from URL → Parent dir →
  Confirm Install flow without depending on view-tree label scraping:
  templates.toolbar.menu, templates.installFromFile,
  templates.installFromURL, templates.installURL.field,
  templates.installURL.confirm, templateInstall.parentDir.field,
  templateInstall.parentDir.continue, templateInstall.confirmInstall.
- TestModeFlags.shared.isTestMode now gates UpdaterService —
  --scarf-test-mode launches Sparkle inert so update prompts don't
  pop on top of an XCUITest-driven window. Production launches
  unchanged.

FixtureHermesHome.swift removed — the fixture-tmpdir approach is
abandoned in favour of using the real installation. Layer A's
SCARF_HERMES_HOME tests still pass; they just don't need a populated
home to exercise path derivation.

Verification: scarfTests 124/124, ScarfCore 220/220, Layer B smoke
1/1 (after fresh build — XCUITest is sensitive to stale binaries).
catalog.py --check still green.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(chat): clip placeholder to TextEditor bounds and clear it on focus

Two related bugs in the Mac chat composer's placeholder overlay:

* The "Message Hermes… / for commands · drag images to attach" hint had
  no width constraint, so on narrower window geometries it visibly
  overflowed past the rounded TextEditor boundary. Add `lineLimit(1)`,
  `truncationMode(.tail)`, and `frame(maxWidth: .infinity, alignment:
  .leading)` so it ellipsizes inside the field instead.
* The opacity formula `text.isEmpty ? 1 : 0` only hid the placeholder
  once content was typed, not when the field gained focus. Standard
  NSTextField / UITextField semantics clear the placeholder on focus.
  Switch to `(text.isEmpty && !isFocused) ? 1 : 0` so the hint
  disappears the moment the user clicks into the field.

The opaque-background ghosting mitigation from #65 is preserved
unchanged.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(chat): surface OAuth refresh-revoked errors with in-app re-auth

When an OAuth provider's refresh token was revoked, Hermes printed
"Refresh session has been revoked. Run `hermes model` to re-authenticate."
to stderr but Scarf swallowed it — the user saw a typing indicator that
silently disappeared with no banner, no system message, no actionable
hint. The error classifier had no pattern for OAuth revocation.

- `ACPErrorHint.classify` now returns a `Classification` struct
  carrying the hint plus an optional `oauthProvider` name. New
  patterns match "Refresh session has been revoked", "re-authenticate",
  and 401-with-OAuth-provider-name (whole-word so `anthropicapi`
  doesn't false-match `anthropic`). Provider extraction lets the UI
  dispatch the right re-auth flow.
- Chat error banner ([ChatView.swift]) gains a "Re-authenticate" button
  when an OAuth provider was identified — sets
  `AppCoordinator.pendingOAuthReauth` and routes to Credential Pools.
- Credential Pools view consumes the hand-off slot to auto-present
  AddCredentialSheet seeded with the affected provider, AND adds a
  per-row "Re-authenticate" button on every OAuth provider so users
  who go straight there don't have to retype the provider name.
- `AddCredentialSheet` accepts an optional `initialProvider` that
  pre-fills providerID + authType=.oauth; the existing Nous-vs-PKCE-
  vs-CLI gate dispatches re-auth identically to first-time setup —
  reuses the same `OAuthFlowController` / `NousSignInSheet` plumbing,
  no new flow code.

Verification: ScarfCore 221/221 (incl. new
errorHintsClassifyOAuthRefreshRevoked covering the four patterns +
word-boundary guard); Mac app builds clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(catalog): in-app template catalog browser + sentinel-marker test isolation

The v2.8 catalog browser surfaces every shipped .scarftemplate from
awizemann.github.io/scarf/templates/catalog.json directly in Scarf.
Users now discover and install templates without leaving the app.
Closes the gap that publishing the catalog updated the website but
nothing inside Scarf.

Architecture mirrors NousModelCatalogService 1:1: cache-first fetch,
24h TTL at ~/.hermes/scarf/catalog_cache.json, result enum (fresh /
cache / fallback) with bundled fallback so a fresh-install / offline
user still sees something. Search + category filter + sort
(awizemann official first). Detail page renders entry.config schema
preview without separate README fetch — what's in catalog.json is
what we render. Install hands the HTTPS URL to the existing
TemplateInstallerViewModel.openRemoteURL flow; nothing about the
installer itself changes.

Files:
- Core/Models/CatalogEntry.swift — Decodable mirror of catalog.json
  per-template shape. Identity-based Equatable/Hashable on `id`.
- Core/Services/CatalogService.swift — fetch + cache + fallback
- Core/Services/InstalledTemplatesIndex.swift — walks projects.json +
  template.lock.json to build [templateId: version] map; classify()
  helper for Installed / Update available / Not installed badges
- Features/Templates/ViewModels/CatalogViewModel.swift — @Observable
- Features/Templates/Views/{CatalogView,CatalogRowView,CatalogDetailView,CatalogCategoryFilter}.swift
- Packages/ScarfCore/.../HermesPathSet.swift — adds catalogCache path
- Features/Projects/Views/ProjectsView.swift — Templates toolbar
  menu now opens with "Browse Catalog…"; sheet binding.

Tests (20 new, all passing in isolation):
- CatalogServiceTests (6) — live catalog.json snapshot, cache lifecycle,
  staleness boundary, schema-version mismatch rejection, bundled fallback
- InstalledTemplatesIndexTests (5) — empty registry, templated project,
  ad-hoc project skip, corrupt lock skip, classify() branches
- CatalogViewModelTests (6) — search filter, category filter, official-first
  sort, deduped categories, install state, install URL pass-through

Accessibility IDs (6, on the catalog path): templates.browseCatalog,
catalog.searchField, catalog.refreshButton, catalog.row.<detailSlug>,
catalog.categoryFilter, catalogDetail.installButton.

## Sentinel-marker hardening on SCARF_HERMES_HOME (incident response)

While iterating on v2.8 tests, the env-var override pattern racing
under Swift Testing's parallel-suite scheduler caused
~/.hermes/scarf/projects.json to be overwritten with fixture data
from ProjectsViewModelTests. Recovered the user's projects from the
on-disk dirs they referenced + cron-job prompt paths (6 projects
restored).

To make this class of incident impossible going forward:
HermesProfileResolver.scarfHermesHomeOverride() now requires the
override path to contain a sentinel marker file
(`.scarf-test-home-marker`). Without the marker, the override is
ignored and Scarf falls through to the real ~/.hermes/. Even if a
test crashes mid-teardown leaving the env var set, even if the var
leaks to a non-test process, even if a misconfigured launchctl plist
exports it — the override only activates against directories that
explicitly opt in by carrying the marker. Tests drop the marker in
their tmpdir setUp; production never carries it.

HermesProfileResolverTests gains overrideIsIgnoredWhenMarkerMissing
which verifies the guard is load-bearing. All test files using
SCARF_HERMES_HOME (CatalogServiceTests, CatalogViewModelTests,
InstalledTemplatesIndexTests, TemplateE2ETests) now drop the marker
before setenv.

Verification: 20/20 v2.8 + v2.7 hardened tests pass; 45/45 adjacent
existing tests pass; ScarfCore package tests pass (221/221); catalog
validator clean (3 templates); wiki secret-scan clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(swift6): retroactive conformance + verbatim help text + xcstrings refresh

Three small Swift 6 compile-cleanups that landed during the
dogfooding-templates iteration:

- MessageSpeechService — drop `@preconcurrency` on the
  AVSpeechSynthesizerDelegate conformance now that the protocol's
  Sendable annotations are upstreamed.
- ChatView — mark `RichChatViewModel.PendingPermission: Identifiable`
  as `@retroactive`. We don't own either the type or the protocol; the
  Swift 6 compiler flags this so downstream breakage is loud if
  ScarfCore ever adds the conformance upstream.
- CredentialPoolsView — wrap the `.help(...)` string in
  `Text(verbatim:)` so the backticks render literally instead of being
  interpreted as markdown inline-code by the LocalizedStringKey
  overload (which `.help(_:)` rejects styled).

Localizable.xcstrings: auto-generated catalog refresh picking up the
new active-profile + chat error-hint strings landed in earlier
commits on this branch (acd3692, 301806d).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(catalog): error logging + MainActor I/O + semver pre-release + decoder fault tolerance

- InstalledTemplatesIndex: replace bare `try?` reads/decodes with logged
  do/catch so corrupt registry/lock files leave a breadcrumb instead of a
  silent nil.
- InstalledTemplatesIndex.isVersionNewer: handle pre-release suffixes per
  semver §11 — `1.0.0-beta` no longer reports as newer than `1.0.0`,
  preventing a ghost "Update available" that would downgrade users.
- CatalogViewModel.refresh: dispatch the synchronous index walk through
  `Task.detached` so registry + N lock-file reads don't run on
  @MainActor.
- Catalog decoder: per-element fault tolerance via custom `init(from:)` —
  one malformed catalog entry is dropped with a logged warning instead
  of failing the whole catalog decode (honors the per-entry doc-comment
  contract).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 20:04:13 +02:00
Alan Wizemann 34d315793b fix(chat): clip placeholder to TextEditor bounds and clear it on focus
Two related bugs in the Mac chat composer's placeholder overlay:

* The "Message Hermes… / for commands · drag images to attach" hint had
  no width constraint, so on narrower window geometries it visibly
  overflowed past the rounded TextEditor boundary. Add `lineLimit(1)`,
  `truncationMode(.tail)`, and `frame(maxWidth: .infinity, alignment:
  .leading)` so it ellipsizes inside the field instead.
* The opacity formula `text.isEmpty ? 1 : 0` only hid the placeholder
  once content was typed, not when the field gained focus. Standard
  NSTextField / UITextField semantics clear the placeholder on focus.
  Switch to `(text.isEmpty && !isFocused) ? 1 : 0` so the hint
  disappears the moment the user clicks into the field.

The opaque-background ghosting mitigation from #65 is preserved
unchanged.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 16:48:32 +02:00
Alan Wizemann acd3692faf fix(profiles): switch-and-relaunch flow + active-profile chip + structured logs
Profile selection had no apparent effect on Webhooks/Sessions/SOUL.md/Memory
even after restart in some user setups. The path-resolution code reads
~/.hermes/active_profile correctly on paper, so the failure mode is likely
environment-specific (HERMES_HOME exported in the shell, in-process state
that didn't reset on what the user perceived as a restart, etc). Layer a
defense that's correct regardless of root cause:

* New AppRelauncher helper spawns a fresh `open -n <bundleURL>` and asks
  the current process to terminate after a 250ms delay. Refuses to fire
  from Xcode/DerivedData (the .debugBuild guard) so debug sessions don't
  lose their attached debugger.
* ProfilesViewModel.switchAndRelaunch runs `hermes profile use`, calls
  HermesProfileResolver.invalidateCache(), then relaunches via the helper.
  Existing switchTo() also gains the cache-invalidation step so the
  context-menu "Set Active (no relaunch)" path stays self-consistent.
* ProfilesView replaces the passive "Restart Scarf after switching" text
  with a confirmation-gated `Switch & Relaunch` primary button on the
  detail pane plus the same item in each row's context menu. Confirmation
  dialog flags that all Scarf windows will close.
* SidebarView header gains a brand-tinted ScarfBadge showing the
  currently-active profile on local contexts. Click to jump to the
  Profiles tab. The chip refreshes on `selectedSection` change so a
  terminal-side `hermes profile use` is visible after the next nav.
* HermesProfileResolver success logs gain `name=…, home=…, source=…`
  key=value structure across all three resolution paths (file / file-default /
  default-no-file). `log show … | grep ProfileResolver` now answers
  "what did the resolver decide?" unambiguously for support requests.

Closes #70

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 13:18:10 +02:00
Alan Wizemann ab615f0c28 feat(ios-chat): redesign composer with HIG touch targets and clear disabled state
Send button is now a 44pt circular target with an explicit color swap
(rust accent → background-tertiary) on disable, instead of relying on
SwiftUI's default opacity dim — addresses the "first tap doesn't
register" complaint by making the inactive state visibly different in
both light and dark mode. Paperclip and text field both gain a 44pt
minimum height so the row feels modern and roomy.

The text field swaps `.roundedBorder` for a plain field with a
ScarfRadius.xl rounded fill (ScarfColor.backgroundSecondary) and a
borderStrong stroke. Outer paddings and HStack spacing migrate from
magic numbers to ScarfSpace tokens.

Preserves verbatim: the `.toolbar { ToolbarItemGroup(placement: .keyboard) }`
keyboard-dismiss chevron (issue #51), draft persistence, .submitLabel,
@FocusState, photo-picker wiring, attachment-strip rendering, and every
.disabled() predicate.

Closes #69

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 13:14:09 +02:00
Alan Wizemann 982ed7da92 chore: bump iOS build to 30 for TestFlight
iOS-only patch carrying the rotation lock + chat-start preflight
off-MainActor fixes from cb164f0. Mac side stays on the v2.6.0
binary already shipped (build 29 archive); this build number bump
only affects future Mac archives, not the one already notarized.

Uploaded to App Store Connect via altool — Apple processing now,
will land in TestFlight once the binary clears the post-upload
scan (typically 5–15 min).
2026-05-01 16:20:13 +02:00
Alan Wizemann cb164f07f9 fix(ios): lock iPhone to portrait + move chat-start preflight off MainActor
Two iOS-specific crash classes from the v2.5.1 TestFlight feedback
round:

**Rotation crash** — locked the iPhone target to
`UIInterfaceOrientationPortrait` only (was Portrait + LandscapeLeft
+ LandscapeRight). The phone can't rotate the app at all anymore,
so any layout path that wasn't audited for size-class transitions
is no longer reachable. iPad orientation list left alone (target
device family is iPhone-only anyway).

**"Crash while typing" / "trying to continue an existing
conversation"** — `ChatController.passModelPreflight()` was doing
a synchronous SSH read (`context.readText(configYAML)`) on
`@MainActor` during chat-start. On a remote ScarfGo context that
blocks the main thread for seconds; iOS's non-responsive-app
watchdog kills the process around 10s. To the user this surfaces
as a "crash" while they're typing — they kept tapping the keyboard
while the connect was hung. Move the read to `Task.detached` and
await it; the UI stays responsive while the SSH I/O drains. Three
callers (`start`, `start(projectPath:)`, `startResuming`) updated
to `await passModelPreflight(...)` — they were already async.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-01 16:03:28 +02:00
Alan Wizemann 1dbdf9d079 chore: ignore local crashes/ triage directory
TestFlight feedback / crash JSONs land here while we're working
through an iOS fix round. They carry tester PII (emails, carriers,
locales) and aren't meant for the public repo. Kept local-only;
deleted after the round closes.
2026-05-01 15:57:41 +02:00
Alan Wizemann 101488cd0d docs(readme): bump What's New to v2.6.0 + Hermes v0.12 catch-up
Replaces the 2.5 "What's New" block with a 2.6 summary that
covers the Hermes v0.12 surfaces (Curator, multimodal images, 5
new providers, Teams + Yuanbao, Kanban, Skills v0.12, cron
--workdir, settings deltas, ScarfGo Webhooks/Plugins/Profiles)
and the post-merge chat fix round (#67/#68/#65/#62/#63/#64/#66/
#61). Verified-versions table gains v0.12.0 as the current target;
recommended-Hermes line points at v0.12.0+ for full feature
support. ScarfGo block kept but de-emphasised since it shipped
in 2.5.
2026-05-01 15:55:16 +02:00
Alan Wizemann 03c996ee80 chore: Bump version to 2.6.0 v2.6.0 2026-05-01 15:42:48 +02:00
Alan Wizemann 8428cbff10 docs(v2.6.0): document post-merge issue fixes in RELEASE_NOTES
Adds a "Chat composer + transcript (post-merge round)" subsection
to the bug-fixes block covering #67, #68, #65, #62, #63, #64,
#66, and the partial #61 ACP-timeout bump. The pre-merge
test-target / iOS-build fixes stay grouped under "Pre-merge".

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-01 15:41:48 +02:00
Alan Wizemann 381adfd925 fix(acp): bump control-message timeout 30s→60s for db-contended hosts (#61)
Field-reported (#61): under realistic concurrency where the
Hermes gateway is also running, state.db lock contention
(Discord sync / skill registration / cron scheduling all
holding write locks) stalls ACP's `initialize` / `session/new` /
`session/load` past the previous 30s watchdog, surfacing as
"Starting…" indefinitely or an opaque timeout error.

SQLite contention on a healthy host clears in seconds, so 60s
gives the lock-resolution path room to breathe while still
surfacing genuinely broken transports promptly. `session/prompt`
remains untimed (it streams events and can run for minutes).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-01 15:40:33 +02:00
Alan Wizemann 254af46e93 feat(chat): per-message TTS playback in assistant bubbles (#66)
Adds a small speaker glyph to the metadata footer of each settled
assistant bubble. Tap to read the reply aloud through
`AVSpeechSynthesizer`; tap again (or any other bubble's button) to
stop. Picks up the user's macOS Spoken Content default voice
automatically — no Hermes dependency, works offline.

- New `MessageSpeechService` (`Core/Services/`) — shared
  `@Observable` synthesizer; `playingMessageId` drives icon
  state. Markdown control characters (asterisks, backticks,
  link syntax) are stripped before speech so the user doesn't
  hear "asterisk asterisk bold".
- `SpeakMessageButton` lives outside `RichMessageBubble.==` so
  the bubble's Equatable short-circuit doesn't freeze the icon
  when playback flips between messages.

The full Hermes-provider TTS pipeline (Edge / ElevenLabs /
OpenAI / NeuTTS / Piper from Settings → Voice) is a much bigger
follow-up — wiring per-provider audio fetching, caching, and
streamed playback is its own quarter. v2.6.0 ships the immediate
"listen while doing something else" affordance.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-01 15:38:22 +02:00
Alan Wizemann 596c844da5 feat(chat): notify when Hermes finishes a prompt in the background (#64)
Sending a long prompt and switching to other work — the canonical
async-agent flow — required polling the chat to know when the
response landed. Wire a local UNUserNotificationCenter notification
to fire when an ACP prompt completes while Scarf isn't the
foreground app.

- New `ChatNotificationService` (Core/Services) handles lazy
  authorization, foreground gating, and post.
- `ChatViewModel.sendViaACP` calls it on successful prompt
  completion with the assistant's first-line preview and the
  active session title.
- Settings → Display → Feedback adds a "Notify when Hermes finishes"
  toggle, default on. Skipped for `/steer`-style mid-run sends —
  those don't end a turn.

Dock badges and per-session unread state from the issue are
worthwhile follow-ups but out of scope for v2.6.0.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-01 15:35:55 +02:00
Alan Wizemann ec47d191a1 fix(chat): preserve local user messages across resume cycles (#63)
When a user sent a prompt and immediately switched to a different
session before Hermes flushed the row to state.db, `resumeSession`
ran `reset()` (which clears `messages`) and then
`loadSessionHistory` read the un-persisted DB and replaced the
array with an empty result. The user's bubble came back blank or
disappeared on return.

Hold local-only user messages (negative ids) in a per-session
cache that survives `reset()`. `loadSessionHistory` re-injects any
still-pending entries for the loaded session, dedups against any
DB row that finally caught up (matching content with persisted id
≥ 0), and clears the cache as the DB confirms each entry.

Cache is bounded by sessions sent-in during one app run; entries
clean themselves out as Hermes persists, and orphaned entries
(deleted sessions etc.) are tiny and never re-surface since
session ids are unique per session.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-01 15:33:37 +02:00
Alan Wizemann 31e6c31acf fix(chat): scope composer state to active session id (#62)
`RichChatInputBar`'s `@State` `text` and `attachments` survived
session switches because the surrounding view tree is structurally
identical across sessions — SwiftUI happily reused the same
instance and leaked the previous session's unsent draft into the
new one.

Bind the composer's identity to `richChat.sessionId` so SwiftUI
rebuilds the view (and its `@State`) on session change. A stable
fallback string covers the brief "no session selected" window;
using `UUID()` here would mint a fresh id on every render and
trash the composer per body re-eval.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-01 15:28:59 +02:00
Alan Wizemann fcfe1c89d6 fix(chat): stop placeholder ghosting in chat composer (#65)
`TextEditor`'s NSTextView surfaces a typed glyph one frame before
the SwiftUI binding propagates, so the bare `if text.isEmpty`
overlay rendered the translucent placeholder text directly on top
of the just-typed character — the "behind or around" ghost the
reporter described.

Two mitigations:

- Pin an opaque `ScarfColor.backgroundSecondary` rect behind the
  placeholder Text. During any single-frame binding lag the user
  now sees a clean placeholder rather than layered glyphs.
- Switch the conditional to `.opacity(text.isEmpty ? 1 : 0)` so the
  view tree stays stable per keystroke. Pairs with the composer
  perf fix from #67.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-01 15:27:53 +02:00
Alan Wizemann df1b9caabf fix(chat): scale rich chat content with the font-size slider (#68)
The chat font-size slider only set `\.dynamicTypeSize` on the chat
root, but ScarfFont tokens are fixed-point (`Font.system(size: 14, …)`)
so dynamic type didn't reach bubble text, reasoning, tool chips, code
blocks, or markdown headings. Slider moved between 85%–130% with
little visible effect.

Plumb a separate `\.chatFontScale: Double` env value from
`RichChatView` and have the chat content views read it:

- `RichMessageBubble` — user bubble body, reasoning (disclosure +
  inline), REASONING label, token chip, tool-chip name, metadata
  footer.
- `MarkdownContentView` — paragraphs (now pinned to a scaled body
  font instead of inheriting), headings (1..5), inline-rendered code
  blocks, code-language label.
- `CodeBlockView` — code body and language label.

`ChatFontScale.{body, callout, caption, captionStrong, caption2,
mono, monoSmall, codeBlock, codeInline}(_ scale:)` helpers mirror
`ScarfFont`'s base sizes so scale = 1.0 is byte-for-byte identical
to today's UI; the slider now actually moves the visible chat text.

Other surfaces (settings, sidebar, etc.) still use the static
ScarfFont tokens — chat scaling stays scoped to the chat surface.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-01 15:24:45 +02:00
Alan Wizemann a41c81c048 fix(chat): coalesce composer onChange writes to stop typing lag (#67)
Typing in the chat composer became unusably laggy because
`updateMenuState()` ran on every keystroke and unconditionally wrote
both `showMenu` and `selectedIndex`. Two state writes inside one
`onChange(of: text)` handler tripped SwiftUI's "action tried to
update multiple times per frame" warning, and each redundant write
forced a full body re-eval — visible as the slow-HID stalls and the
main-thread layout churn the reporter captured in sampling.

Two changes:

- Compute the new selection up front and write only the deltas. Same
  semantics; no spurious mutations.
- Short-circuit the whole handler when the user is composing normal
  text (no `/` prefix) and the menu is already hidden — the common
  case. Stops paying for `SlashCommandMenu.filter` on every keystroke
  of regular prose.
- Replace `.onChange(of: commands.map(\.id))` with
  `.onChange(of: commands.count)`. The mapped form allocated a fresh
  `[String]` on every body re-eval; counting is one int read.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-01 15:20:15 +02:00
Alan Wizemann 88add62997 Merge branch 'v12-updates'
Hermes v2026.4.30 (v0.12.0) compatibility — autonomous Curator (Mac +
iOS), multimodal image input in chat, 5 new inference providers,
Microsoft Teams + Yuanbao gateway platforms, read-only Kanban view,
Skills v0.12 surface (URL install / reload / pin / disable), Cron
--workdir flag, Settings deltas (cache TTL, redaction, runtime footer,
Piper, Vercel), iOS read-only Webhooks/Plugins/Profiles, and a
pre-v0.12 Hermes-version banner. All new surfaces capability-gated so
older Hermes hosts see the v2.5 surface unchanged.

Release notes: releases/v2.6.0/RELEASE_NOTES.md

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-01 15:17:36 +02:00
Alan Wizemann 80589b3f23 chore(i18n): pick up autogenerated v0.12 string keys
Xcode-autogenerated strings for the v12 surface — curator chip labels,
image attachment button + counter, archived-skill banner — that the
extractor produced while the v12-updates branch was being authored.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

    skills:
      disabled:
      - foo
      - bar

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

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

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

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

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

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

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

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

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

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

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

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

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

Components:

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-01 12:54:38 +02:00