Compare commits
201 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 97ec4d2882 | |||
| cd5bb32a21 | |||
| 5e23b59697 | |||
| 09e33b2999 | |||
| 9f2e2ecfcd | |||
| 1eb5c92f6a | |||
| bccaba0742 | |||
| 4684b9deed | |||
| f6dc45b397 | |||
| f2ddcbbd60 | |||
| a193003842 | |||
| 93a64e3e82 | |||
| 00a1bbd109 | |||
| 20cc3a2985 | |||
| 432d5b0b52 | |||
| 12e152bfea | |||
| 099d73dde8 | |||
| 4efd84c119 | |||
| bd9bacb8b3 | |||
| 96af545e66 | |||
| 9df7142f49 | |||
| 9ff9a018e7 | |||
| 0a4f8de492 | |||
| 3126c34561 | |||
| 6cf59c8a44 | |||
| 272da6a915 | |||
| c7bcfd8655 | |||
| 9d945150e0 | |||
| fa15634381 | |||
| 3271391506 | |||
| 5afd391838 | |||
| 2a368a04f7 | |||
| 9aa901a286 | |||
| 111fe9bb67 | |||
| 6191c9f19f | |||
| b8b426ed75 | |||
| 593b4e62cb | |||
| de36411a8d | |||
| 6a7ac21ebe | |||
| 5be67282d8 | |||
| c661945a1f | |||
| f5f8dc30b6 | |||
| 34d315793b | |||
| acd3692faf | |||
| ab615f0c28 | |||
| 982ed7da92 | |||
| cb164f07f9 | |||
| 1dbdf9d079 | |||
| 101488cd0d | |||
| 03c996ee80 | |||
| 8428cbff10 | |||
| 381adfd925 | |||
| 254af46e93 | |||
| 596c844da5 | |||
| ec47d191a1 | |||
| 31e6c31acf | |||
| fcfe1c89d6 | |||
| df1b9caabf | |||
| a41c81c048 | |||
| 88add62997 | |||
| 80589b3f23 | |||
| 13f89e309b | |||
| c055081ba3 | |||
| bd05e01d1c | |||
| b66ed7e8d7 | |||
| 46cec816ec | |||
| 681fa40c3c | |||
| 15642d37cf | |||
| 33022aeb92 | |||
| 4a2ef74b74 | |||
| 11bb2bd0c3 | |||
| 3d85b91392 | |||
| 799332fbcd | |||
| 7a833b6c5a | |||
| 6954f0276a | |||
| ee3791a1b2 | |||
| 686fb37630 | |||
| 1354568992 | |||
| da721fa276 | |||
| a90a29add8 | |||
| 421e6030df | |||
| 7b864d77d5 | |||
| 11946aad67 | |||
| 4140983866 | |||
| cca99d4e13 | |||
| 2aab9dac07 | |||
| c31dfccb9b | |||
| 61e61f556a | |||
| 424711c3d9 | |||
| 067aeda878 | |||
| 389620059c | |||
| 4ffd353835 | |||
| 511726e2c0 | |||
| 587c6c36c8 | |||
| 50fbbc6af6 | |||
| 4776119e07 | |||
| f72bf6e30b | |||
| 0bfae1227a | |||
| c312a565b6 | |||
| afb1356b27 | |||
| f9a288ac6c | |||
| bb33a39b42 | |||
| e828538a2d | |||
| 051f3bf80c | |||
| 558970a09a | |||
| 8d9de4c576 | |||
| e0f0fad192 | |||
| 80a4d23974 | |||
| d95ef61e13 | |||
| 988ce5df5a | |||
| 3bca8a6e55 | |||
| b5f4f65ffe | |||
| b474286bfe | |||
| b1e2fc5dcd | |||
| 87fcbad1ac | |||
| 63c5d13bec | |||
| 850fa7a697 | |||
| 21e3cc9361 | |||
| 295f2dfefc | |||
| de611c5343 | |||
| 23dd8becb9 | |||
| 41769e289c | |||
| 8a2d89654b | |||
| f04d95c960 | |||
| 26c034ea6f | |||
| 84b033814b | |||
| 3d4a6a3a75 | |||
| a73025aba0 | |||
| 99f734bf0b | |||
| ca1eb54a5b | |||
| f35bc910e4 | |||
| 8057beb001 | |||
| 751c9e6778 | |||
| 5c08c09dde | |||
| 7ec7282f36 | |||
| 97aa988762 | |||
| 64bcea35a0 | |||
| 1fcd963019 | |||
| 70d4c97a6c | |||
| a9bd51bf05 | |||
| 79a350d793 | |||
| b247942e1f | |||
| 7f5ff1946e | |||
| 9164e65cac | |||
| 8a87ff1922 | |||
| 6808adfa98 | |||
| bdc271c2b8 | |||
| d45de925ae | |||
| 1eb37771f9 | |||
| 1174c5abc7 | |||
| 4fc12ca790 | |||
| 3da3d3ce5e | |||
| 48e99f2c43 | |||
| 293e8341f5 | |||
| 54a0797334 | |||
| 9a4473333b | |||
| d2633fb92d | |||
| 3b3c037fce | |||
| 1c2939dbbe | |||
| f3c4bc56e9 | |||
| 723ef6743d | |||
| 444d43dea8 | |||
| 9bfaaf20f0 | |||
| 226b6e26be | |||
| ff6ea4f6dc | |||
| 9c2e9279cc | |||
| bb399e6d35 | |||
| aafd9643a4 | |||
| 92fba712f8 | |||
| 8282b1d604 | |||
| 5f9343be5d | |||
| 5cac3836cf | |||
| cecc1060c6 | |||
| e1f862e2f9 | |||
| 42c0f683bd | |||
| f2f6c4e50b | |||
| c802e1189f | |||
| 96f60a176d | |||
| 8e14e0e776 | |||
| 742605d359 | |||
| fee5e72d30 | |||
| f41ac1c84e | |||
| 19b4ba9995 | |||
| 05e2a8444a | |||
| fe104b83fa | |||
| 4132cb03e2 | |||
| 44d2d6d6c6 | |||
| 6b731ddfb8 | |||
| bd6e722029 | |||
| 110611549e | |||
| 92ac102f46 | |||
| e85a7b170c | |||
| 3420abae74 | |||
| ba368d2f6d | |||
| bdf31d6781 | |||
| 920c86b4f8 | |||
| 8bd4b9282a | |||
| 27dc694aeb | |||
| 0fd2ceb9fc | |||
| f6f31cabe4 | |||
| bb5045c10f |
@@ -21,7 +21,12 @@ xcuserdata/
|
|||||||
|
|
||||||
# Swift Package Manager
|
# Swift Package Manager
|
||||||
.build/
|
.build/
|
||||||
|
# `Packages/` is the historical SwiftPM checkout dir for downloaded deps
|
||||||
|
# (pre-Xcode-14). We keep it ignored — but NOT our local-package checkout
|
||||||
|
# at scarf/Packages/, which is part of the source tree (ScarfCore, etc.)
|
||||||
|
# and must ship in the repo.
|
||||||
Packages/
|
Packages/
|
||||||
|
!scarf/Packages/
|
||||||
Package.pins
|
Package.pins
|
||||||
Package.resolved
|
Package.resolved
|
||||||
*.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/
|
*.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/
|
||||||
@@ -56,3 +61,8 @@ releases/v*/appcast-entry.xml
|
|||||||
|
|
||||||
# Wiki helper: personal patterns (hostnames, IPs) blocked from the wiki push.
|
# Wiki helper: personal patterns (hostnames, IPs) blocked from the wiki push.
|
||||||
scripts/wiki-blocklist.txt
|
scripts/wiki-blocklist.txt
|
||||||
|
|
||||||
|
# TestFlight feedback / crash JSONs downloaded for triage. PII (emails,
|
||||||
|
# carriers, locales) and never meant for the public repo — kept local
|
||||||
|
# while a fix round is in progress, deleted afterward.
|
||||||
|
crashes/
|
||||||
|
|||||||
@@ -22,6 +22,35 @@ scarf/scarf/ Xcode project root (PBXFileSystemSynchronizedRootGroup
|
|||||||
- **Sandbox disabled**: App reads `~/.hermes/` directly.
|
- **Sandbox disabled**: App reads `~/.hermes/` directly.
|
||||||
- **Swift 6 concurrency**: `@MainActor` default. Services use `nonisolated` + async/await.
|
- **Swift 6 concurrency**: `@MainActor` default. Services use `nonisolated` + async/await.
|
||||||
|
|
||||||
|
## Design System (ScarfDesign)
|
||||||
|
|
||||||
|
All app UI uses the typed token bundle in [scarf/Packages/ScarfDesign/](scarf/Packages/ScarfDesign/) — both the `scarf` and `scarf mobile` targets `import ScarfDesign`. Reach for these tokens before inventing new colors, fonts, or spacings.
|
||||||
|
|
||||||
|
- **Colors**: `ScarfColor.accent`, `.foregroundPrimary/Muted/Faint`, `.backgroundPrimary/Secondary/Tertiary`, `.border/.borderStrong`, `.success/.danger/.warning/.info`, `.Tool.bash/edit/search/web/think`. All resolve from `ScarfBrand.xcassets` and adapt light/dark automatically.
|
||||||
|
- **Typography**: `.scarfStyle(.title2)`, `.scarfStyle(.body)`, `.scarfStyle(.captionUppercase)`, etc. Use these instead of `.font(.system(...))`. Eleven preset styles cover the type scale.
|
||||||
|
- **Spacing / radius / shadow**: `ScarfSpace.s1...s10` (4/8/12/16/20/24/32/40), `ScarfRadius.sm/md/lg/xl/xxl/pill`, `.scarfShadow(.sm/.md/.lg/.xl)`. Hardcoded `.padding(12)` or `cornerRadius: 8` is a code smell — convert.
|
||||||
|
- **Components**: `ScarfPageHeader("Title", subtitle: "...") { trailing }`, `ScarfCard { ... }`, `ScarfBadge("text", kind: .success)`, `ScarfTextField`, `ScarfSectionHeader`, `ScarfDivider`, `ScarfPrimaryButton/SecondaryButton/GhostButton/DestructiveButton` (apply with `.buttonStyle(...)`).
|
||||||
|
- **App icon + accent**: `Assets.xcassets/AppIcon.appiconset/` is the rust set; `Assets.xcassets/AccentColor.colorset` resolves `Color.accentColor` to rust so any unmigrated SwiftUI control still tints correctly.
|
||||||
|
- **Reference**: full screen mockups live at `design/static-site/ui-kit/*.jsx` (open `design/static-site/index.html` in a browser). The `ScarfChatView.ChatRootView` reference component in the package is a 3-pane chat redesign target — usable for previews but not yet swapped into the live chat (the existing `RichChatView` machinery still owns the real ACP pipeline).
|
||||||
|
- **Don't**: introduce purple/violet tones (we shifted to rust); use yellow `#F0AD4E` for success (that's `.warning` — `.success` is green); bypass the type scale with `.font(.system(size: 13.5))`; ship terminal/syntax-highlight palettes through ScarfColor (those are content semantics, keep them inline).
|
||||||
|
|
||||||
|
### iOS Dynamic Type policy
|
||||||
|
|
||||||
|
iOS users can scale text via Settings → Accessibility → Display & Text Size. ScarfFont uses fixed point sizes; adopting it blanket on iOS would regress accessibility on `.accessibility2` / `.xSmall` users. iOS-specific rule:
|
||||||
|
|
||||||
|
- **Use `ScarfFont` only for**: status badges, chip labels, intentional-display elements (e.g., onboarding step titles, header chrome that's meant to be a fixed visual size).
|
||||||
|
- **Keep `.font(.headline)` / `.body` / `.caption` semantic tokens for**: list-row primary + secondary text, body copy, error messages, chat content — anything the user reads.
|
||||||
|
|
||||||
|
Decision tree per text element: "is this read for content?" → semantic token. "Is this chrome / a label / a badge?" → ScarfFont.
|
||||||
|
|
||||||
|
Mac doesn't have this constraint and adopts ScarfFont everywhere. The iOS app already clamps Dynamic Type at the scene root (`ScarfIOSApp.swift`: `.dynamicTypeSize(.xSmall ... .accessibility2)`) — keep that.
|
||||||
|
|
||||||
|
### iOS page chrome
|
||||||
|
|
||||||
|
Don't retrofit `ScarfPageHeader` over iOS tab roots. iOS uses `.navigationTitle(...)` + `.navigationBarTitleDisplayMode(.large)` as its native page-header pattern; stacking ScarfPageHeader on top creates double titles. Use ScarfPageHeader only on iOS sub-views without a native large-title bar (rare).
|
||||||
|
|
||||||
|
iOS button styling: only swap `.borderedProminent` → `ScarfPrimaryButton`. **Leave `.bordered` native** — it's the iOS convention and inherits rust through `AccentColor.colorset` automatically. Same for `.plain` (used as compact tap targets in lists).
|
||||||
|
|
||||||
## Key Paths
|
## Key Paths
|
||||||
|
|
||||||
- Hermes home: `~/.hermes/`
|
- Hermes home: `~/.hermes/`
|
||||||
@@ -84,7 +113,35 @@ Public documentation lives in the GitHub wiki at https://github.com/awizemann/sc
|
|||||||
|
|
||||||
## Hermes Version
|
## Hermes Version
|
||||||
|
|
||||||
Targets Hermes v0.10.0 (v2026.4.16). Log lines may carry an optional `[session_id]` tag between the level and logger name — `HermesLogService.parseLine` treats the session tag as an optional capture group, so older untagged lines still parse.
|
Targets Hermes v2026.4.30 (v0.12.0). Log lines may carry an optional `[session_id]` tag between the level and logger name — `HermesLogService.parseLine` treats the session tag as an optional capture group, so older untagged lines still parse.
|
||||||
|
|
||||||
|
**Capability gating.** Scarf detects the target's Hermes version once per server connection via [HermesCapabilities](scarf/Packages/ScarfCore/Sources/ScarfCore/Services/HermesCapabilities.swift) (`hermes --version` → semver + `YYYY.M.D` parse). The resulting `HermesCapabilitiesStore` is injected on `ContextBoundRoot` (Mac) and `ScarfGoTabRoot` (iOS) via `.environment(_:)` and `.hermesCapabilities(_:)`; UI that depends on a v0.12+ surface (Curator, Kanban, ACP image input, `auxiliary.curator`, `prompt_caching.cache_ttl`, Piper TTS, Vercel terminal) reads it through the typed environment key. Pre-v0.12 hosts gracefully hide the new affordances rather than throwing on unknown CLI subcommands. Add a new flag at the top of `HermesCapabilities` whenever Scarf gains a release-gated UI surface.
|
||||||
|
|
||||||
|
**v2026.4.30 (v0.12.0)** added (Scarf-relevant subset):
|
||||||
|
|
||||||
|
- **Autonomous Curator** — `hermes curator` self-prunes / -consolidates the skill library on a 7-day cycle. Reports land at `~/.hermes/logs/curator/run.json` + `REPORT.md`; paths exposed via `HermesPathSet.curatorLogsDir` (`logs/curator`) + `curatorStateFile` (`skills/.curator_state`), with the per-cycle `run.json` / `REPORT.md` resolved at runtime from the `last_report_path` field on the state file. Surfaced in Scarf as a dedicated "Curator" sidebar item under Interact (between Memory and Skills) on Mac, plus a read-mostly iOS panel with Run Now / Pause / Resume actions and inline pin toggles; both gated on `HermesCapabilities.hasCurator`.
|
||||||
|
- **5 new inference providers** — GMI Cloud, Azure AI Foundry, LM Studio (upgraded to first-class), MiniMax OAuth, Tencent Tokenhub. Mirrored in `ModelCatalogService.overlayOnlyProviders`; the model picker reaches all of them automatically.
|
||||||
|
- **`flush_memories` aux task removed (server side)** — `auxiliary.flush_memories` is gone from v0.12 Hermes config but remains alive on pre-v0.12 hosts. Scarf preserves `AuxiliarySettings.flushMemories: AuxiliaryModel`, the YAML reader still emits an `aux("flush_memories")` row, and `AuxiliaryTab` only renders the row when `HermesCapabilities.hasFlushMemoriesAux` is `true` (inverse semantics — pre-v0.12 only). v0.12 users never see the row; v0.11 users keep their edit surface.
|
||||||
|
- **`auxiliary.curator` aux task added** — Curator's review model is configurable independently of the main model. Surfaced in `Settings → Auxiliary` next to the other aux rows.
|
||||||
|
- **Multimodal ACP `session/prompt`** — ACP advertises and forwards image content blocks. Scarf chat composers (Mac drag/drop + paste; iOS PhotosPicker) attach images that flow through `ACPClient.sendPrompt(sessionId:text:images:)` as `[{"type":"text","text":...}, {"type":"image","data":"<base64>","mimeType":"image/jpeg"}]` — wire shape matches `acp.schema.ImageContentBlock`. `ImageEncoder` downsamples to 1568px long-edge JPEG q=0.85 detached (never blocks MainActor). Gated on `HermesCapabilities.hasACPImagePrompts`.
|
||||||
|
- **CLI additions:** `hermes -z <prompt>` (non-interactive one-shot), `hermes update --check` (preflight), `hermes fallback` (manage fallback providers), `hermes curator` (status / run / pause / resume / pin / unpin / restore), `hermes kanban` (full task-board CLI; multi-profile collab was reverted upstream so Scarf ships a read-only Kanban view only). All capability-gated.
|
||||||
|
- **Skills surface:** `hermes skills install <https-url>` direct-URL install (SkillsView "Install from URL…" toolbar button), reload via `hermes skills audit` (Skills "Reload" button — equivalent to the `/reload-skills` slash command for non-ACP contexts), enabled/disabled state read from `skills.disabled` in config.yaml (rendered as strikethrough + "OFF" pill), Curator pin badge from `~/.hermes/skills/.curator_state` (rendered as a pin glyph). The disable-toggle write path is deferred to v2.7 — Hermes only exposes `hermes skills config` as an interactive verb, and Scarf prefers reading accurately to risking a clobbered list.
|
||||||
|
- **Two new gateway platforms:** Microsoft Teams (19th, plugin-shipped) + Tencent 元宝 / Yuanbao (18th, native). Surfaced in the Mac Platforms tab.
|
||||||
|
- **Cron upgrades:** per-job `--workdir <abs-path>` (project-aware cwd that pulls AGENTS.md / CLAUDE.md / .cursorrules) is exposed in the editor sheet, gated on `HermesCapabilities.hasCronWorkdir` so pre-v0.12 hosts don't see the field (and a defensive override in `CronView` strips the value before calling `createJob`/`updateJob` even if it was hydrated from a pre-existing job). Pass an empty string on edit to clear an existing workdir, mirroring the `--script` shape. Hermes also added a `context_from` field for chaining cron outputs but only via YAML so far — Scarf reads it (HermesCronJob.contextFrom) but doesn't write it.
|
||||||
|
- **Settings deltas:** `prompt_caching.cache_ttl` (5m/1h picker), `redaction.enabled` toggle (off-by-default in v0.12 — toggle restores it), `agent.runtime_metadata_footer` toggle, Piper added to TTS provider list, `vercel` added to terminal backend list.
|
||||||
|
- **Bundled plugins:** Spotify, Google Meet, Langfuse observability, hermes-achievements (visible in Plugins tab).
|
||||||
|
- **iOS catch-up (Phase H):** read-only Webhooks / Plugins / Profiles tabs (`Scarf iOS/Webhooks/WebhooksView.swift`, `Plugins/PluginsView.swift`, `Profiles/ProfilesView.swift`) parity-match the Mac surfaces but skip mutating CLI verbs. `Scarf iOS/Components/HermesVersionBanner.swift` nudges pre-v0.12 hosts to upgrade (renders only when the connected target is below v0.12).
|
||||||
|
- **`hermes memory` providers:** honcho, openviking, mem0, hindsight, holographic, retaindb, byterover. `Settings → Memory` lists all providers in the picker; the existing "Run `hermes memory setup` in Terminal" hint stays — `hermes memory setup` is interactive (asks for tokens) so an in-app shellout would surface a frozen UI.
|
||||||
|
- **Schema is unchanged from v0.11** — same state.db columns (`messages.reasoning_content`, `sessions.api_call_count` introduced in v0.11 remain). No migration needed.
|
||||||
|
|
||||||
|
**v2026.4.23 (v0.11.0)** added (historical context, still consumed by Scarf when running against a pre-v0.12 host):
|
||||||
|
|
||||||
|
- `/steer <prompt>` — non-interruptive mid-run guidance slash command. Surfaced in Scarf chat menus via `RichChatViewModel.nonInterruptiveCommands`; `ChatViewModel.sendViaACP` (Mac) and `ChatController.send` (iOS) skip the "Agent working…" status flip and show a transient toast instead.
|
||||||
|
- New CLI subcommands: `hermes plugins` / `profile` / `webhook` / `insights` / `logs` / `memory reset` / `completion` / `dashboard`. Scarf v2.5 adopts **`hermes memory reset`** (toolbar button on MemoryView with destructive confirmation). The other CLIs are documented here for v2.6 — Scarf still reads `~/.hermes/plugins/`, `~/.hermes/profiles/` etc directly today; switching those paths to the canonical CLI is a forward-compatible change to make when bandwidth permits.
|
||||||
|
- New state.db columns: `messages.reasoning_content` + `sessions.api_call_count`. `HermesDataService.detectSchema` flips `hasV011Schema` only when both are present (partial migrations stay on v0.7 path). Surfaced as the "API" chip on session rows + a network-icon counter in DashboardView. `HermesMessage.preferredReasoning` picks the newer column when both reasoning channels are populated.
|
||||||
|
- New skills: `design-md` (Google's DESIGN.md authoring; needs `npx`/Node 18+ on host — checked via `SkillPrereqService` and surfaced as a yellow banner) and `spotify` (OAuth via `hermes auth spotify` — driven by `SpotifyAuthFlow` + `SpotifySignInSheet`, mirroring v2.3 Nous Portal pattern).
|
||||||
|
- Updated skills: `research-paper-writing` 1.1.0 (+SciencePlots dep), `segment-anything-model` (expanded docs), `google-workspace` (gws CLI prefer + granular OAuth scopes), `hermes-agent` (in-tree).
|
||||||
|
- SKILL.md frontmatter gains `allowed_tools` / `related_skills` / `dependencies` lists. `HermesSkill` carries them as optional fields; `SkillsView` (Mac) + `SkillDetailView` (iOS) render them as chip rows when populated.
|
||||||
|
|
||||||
v0.10.0 introduced the **Tool Gateway** — paid Nous Portal subscribers route web search, image generation, TTS, and browser automation through their subscription without separate API keys. In Scarf:
|
v0.10.0 introduced the **Tool Gateway** — paid Nous Portal subscribers route web search, image generation, TTS, and browser automation through their subscription without separate API keys. In Scarf:
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<p align="center">
|
<p align="center">
|
||||||
<img src="icon.png" width="128" height="128" alt="Scarf app icon">
|
<img src="icon-v2.5.png" width="128" height="128" alt="Scarf app icon">
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<h1 align="center">Scarf</h1>
|
<h1 align="center">Scarf</h1>
|
||||||
@@ -19,53 +19,89 @@
|
|||||||
<a href="https://www.buymeacoffee.com/awizemann"><img src="https://cdn.buymeacoffee.com/buttons/v2/default-yellow.png" alt="Buy Me a Coffee" height="28"></a>
|
<a href="https://www.buymeacoffee.com/awizemann"><img src="https://cdn.buymeacoffee.com/buttons/v2/default-yellow.png" alt="Buy Me a Coffee" height="28"></a>
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
## What's New in 2.3
|
## What's New in 2.7
|
||||||
|
|
||||||
- **Projects sidebar grows up** — group projects into folders, rename / archive / unarchive in place, filter the list with ⌘F, jump to the first nine with ⌘1–⌘9. Archived projects hide by default; a toggle in the bottom bar surfaces them. Non-destructive on the v2.2 registry file — downgrade stays clean.
|
The biggest release since 2.6 — six weeks of work focused on **remote-context performance**, a **new project authoring flow**, **dashboard widgets**, **OAuth resilience**, and a top-to-bottom **performance instrumentation harness** that drove the bulk of the rest. 36 commits, no schema bump, no Hermes capability bump.
|
||||||
- **Per-project Sessions tab** — alongside Dashboard and Site. Shows chats attributed to the project, with a **New Chat** button that spawns `hermes acp` with the project's directory as the session cwd and attributes the result via a Scarf-owned sidecar (`~/.hermes/scarf/session_project_map.json`). Click any listed session to resume it with project context automatically restored.
|
|
||||||
- **Agent actually knows what project it's in** — the architectural headline. Every project-scoped chat gets a Scarf-managed block auto-injected into the project's `AGENTS.md` before the session starts. Hermes reads AGENTS.md from the session's cwd at startup and picks up the block as part of its system prompt. Ask the agent *"what project am I in?"* and it answers with the project name, directory, template id + version, configuration field names, and registered cron jobs — pulled from the injected block. Secret-safe (field names only, never values), idempotent, bounded to `<!-- scarf-project:begin/end -->` markers so template-author content outside the block is preserved across refreshes.
|
|
||||||
- **Project indicator in Chat** — folder chip in `SessionInfoBar` and `Chat · <ProjectName>` in the nav title when you're in a project-scoped chat. Resumed sessions keep the indicator by looking up the attribution sidecar at resume time.
|
|
||||||
- **Tool Gateway — Nous Portal support** — Hermes v0.10.0 introduced subscription-routed tools (web search, image gen, TTS, browser automation). Scarf 2.3 merges Hermes's provider-overlay table into the model picker so **Nous Portal + 5 other previously-invisible providers** now appear, and ships a dedicated **Sign in to Nous Portal** sheet that runs the device-code flow end-to-end in-app — no terminal. Each of the 8 auxiliary sub-model tasks gets a per-task Nous toggle, a Tool Gateway card lands in Health, and Credential Pools' silent-fail dead-end for device-code providers is closed. Scarf's existing messaging-gateway section is renamed **Messaging Gateway** to disambiguate from the new Tool Gateway.
|
|
||||||
- **Window-layout cleanup** — switching to Chat or a Sessions tab no longer grows the window past the screen. `.windowResizability(.contentMinSize)` + targeted `idealHeight` caps keep the window's floor at a sensible content minimum while letting users freely drag larger or smaller.
|
|
||||||
|
|
||||||
See the full [v2.3.0 release notes](https://github.com/awizemann/scarf/releases/tag/v2.3.0), the [Project Templates wiki page](https://github.com/awizemann/scarf/wiki/Project-Templates), and the [Hermes Version Compatibility page](https://github.com/awizemann/scarf/wiki/Hermes-Version-Compatibility) for the Tool Gateway's Hermes v0.10.0 requirement.
|
### Remote chats and Activity in seconds, not 30s timeouts
|
||||||
|
|
||||||
### Previously, in 2.2
|
Resuming a chat or opening Activity on a slow remote (a 420ms-RTT droplet, an underprovisioned VPS, a tunnel through 4G) used to fetch the full message column set in one shot, which routinely tripped the 30s SSH timeout on chats with multi-page tool result blobs. v2.7 introduces a **skeleton-then-hydrate pattern** that bounds the wire payload by what the user actually needs to see RIGHT NOW, then fills in the heavy stuff in the background.
|
||||||
|
|
||||||
- **Project Templates** — Scarf projects can now travel. Package a project's dashboard, agent instructions, skills, cron jobs, and a typed configuration schema into a `.scarftemplate` bundle, hand it to anyone, and they install it in one click. Every bundle ships with a cross-agent `AGENTS.md` ([agents.md](https://agents.md/) standard) so the instructions work in Claude Code, Cursor, Codex, Aider, and the 20+ other agents that read it natively. Browser-based one-click install via `scarf://install?url=…` deep links. Export / Install from File / Install from URL live under the **Templates** menu in the Projects toolbar.
|
- **Chat skeleton** — user + assistant rows only (skips `role='tool'`), `tool_calls` / `reasoning` hard-NULLed at SQL level. Wire payload bounded by conversational text. The chat appears in seconds. Background hydration pages tool calls in 5-id batches; tool-result CONTENT is opt-in (Settings → Display → "Load tool results in past chats", default off) with per-card lazy-fetch in the inspector pane.
|
||||||
- **Typed configuration with Keychain-backed secrets** — templates declare a schema with seven field types (`string`, `text`, `number`, `bool`, `enum`, `list`, `secret`). A **Configure** step in the install flow renders the form, routes secrets to the macOS Keychain, and drops non-secret values into `<project>/.scarf/config.json`.
|
- **Activity skeleton** — metadata-only fetch (~3 KB for 50 rows). Placeholder rows render immediately; real per-call entries swap in as paged hydration completes.
|
||||||
- **Public template catalog** — [awizemann.github.io/scarf/templates/](https://awizemann.github.io/scarf/templates/) with live dashboard previews + schema rendering. CI-enforced Python validator mirrors the Swift-side invariants on every PR.
|
- **Single-id whale recovery** — when a 5-id batch trips the 30s timeout (one row carries an oversized `tool_calls` blob), an L1 single-id retry isolates the offender so the rest of the batch still hydrates.
|
||||||
- **Safe-by-design** — skills namespaced, cron jobs tagged and paused-on-install, lock-file-driven uninstall, exports carry schema but never values.
|
|
||||||
|
|
||||||
See the [v2.2.0 release notes](https://github.com/awizemann/scarf/releases/tag/v2.2.0) for the full 2.2 series.
|
### SSH cancellation that actually cancels
|
||||||
|
|
||||||
### Previously, in 2.1
|
`Task.detached` doesn't inherit cancellation from the awaiting parent. Pre-fix, navigating away from a chat left the underlying ssh subprocess running for the full 30s, pinning a remote sqlite query and a ControlMaster session — the "third chat hangs" / "dashboard spins after rapid switching" symptom. v2.7 wires `withTaskCancellationHandler` through `SSHScriptRunner.run` and `RemoteSQLiteBackend.query`; cancellation now reaches the `Process` within ~100ms.
|
||||||
|
|
||||||
- **Seven languages** — Full UI translations for Simplified Chinese, German, French, Spanish, Japanese, and Brazilian Portuguese on top of English. Scarf respects the system language by default; override per-app via **System Settings → Language & Region → Apps → Scarf**. Contributor workflow for adding more locales is documented in [CONTRIBUTING.md → Adding a Language](CONTRIBUTING.md#adding-a-language).
|
### New Project from Scratch wizard + Keychain-backed cron secrets
|
||||||
- **Locale-aware number formatting** — Currency, byte sizes, compact token counts (`15K`, `1.5M`), and day-of-week charts now follow the user's locale instead of POSIX / English defaults.
|
|
||||||
- **Chat slash-command menu** — Type `/` in Rich Chat to browse every command the agent has advertised plus any user-defined `quick_commands:` from config.yaml. ↑/↓ to navigate, Tab/Enter to complete, Esc to dismiss.
|
|
||||||
- **Chat polish** — Auto-scroll on send and on prompt completion, a non-blocking loading spinner during session reconnects, properly centered empty state, and the long-standing "session loads with whitespace" bug fixed (LazyVStack → VStack in the message list).
|
|
||||||
|
|
||||||
See the full [v2.1.0 release notes](https://github.com/awizemann/scarf/releases/tag/v2.1.0).
|
A third project entry point alongside Browse Catalog and Add Existing Project. Scaffolds a Scarf-standard skeleton, registers it, and hands off to a chat session that auto-activates the bundled `scarf-template-author` skill. The skill drives the rest conversationally — widgets, optional config schema, optional cron — and writes the final files itself.
|
||||||
|
|
||||||
### Previously, in 2.0
|
**Cron + Keychain.** Cron prompts that referenced `secret`-typed config fields used to get the literal `keychain://...` URI back, producing 401s. v2.7 mirrors resolved Keychain values into `~/.hermes/.env` under `$SCARF_<UPPER_SLUG>_<UPPER_FIELD>` env vars. Hermes already reloads `.env` per cron tick — credential rotation is automatic.
|
||||||
|
|
||||||
- **Multi-server** — Manage multiple Hermes installations (local + any number of remotes) from one app. Each window binds to one server; open them side-by-side.
|
### Project dashboards — file-reading widgets, sparklines, typed status
|
||||||
- **Remote Hermes over SSH** — Every feature that worked against your local `~/.hermes/` now works against a remote host. File I/O routes through `scp`/`sftp`; chat ACP runs over `ssh -T`; SQLite is served from atomic `.backup` snapshots pulled on file-watcher ticks.
|
|
||||||
- **Chat UX overhaul** — No more white-screen flash on first message, no more scroll jumping into whitespace during streaming, failed prompts explain themselves instead of silently spinning forever.
|
|
||||||
- **Correctness pass** — Fixed remote WAL error spam, stale-snapshot session resume, auto-resume of dead cron sessions, 230+ Swift 6 concurrency warnings.
|
|
||||||
|
|
||||||
See the [v2.0.0 release notes](https://github.com/awizemann/scarf/releases/tag/v2.0.0) for the full 2.0 series.
|
Five new widget types and project-wide auto-refresh. **Backwards-compatible** — every existing `dashboard.json` renders byte-identically.
|
||||||
|
|
||||||
### Previously, in 1.6
|
- **`markdown_file`** / **`log_tail`** / **`cron_status`** / **`image`** / **`status_grid`** — file-reading widgets that auto-refresh when the underlying file changes. By convention, place files inside `<project>/.scarf/`.
|
||||||
|
- **`stat` widget gains inline sparklines** via optional `sparkline: [Number]`. SVG-only render; dozens per dashboard cost nothing.
|
||||||
|
- **Typed status badges** with lenient decode (`ok`/`up` → success, `down`/`error` → danger). Unknown strings render as plain text rather than crashing.
|
||||||
|
- **Structured widget error card** replaces the legacy "Unknown: \<type\>" placeholder.
|
||||||
|
|
||||||
- **Platforms** — Native GUI setup for all 13 messaging platforms, no more hand-editing `.env`
|
### OAuth resilience + Credential Pools
|
||||||
- **Credential Pools** — Fixed OAuth flow and API-key handling; pick providers from a catalog
|
|
||||||
- **Model Picker** — Hierarchical browser backed by the 111-provider models.dev cache
|
|
||||||
- **Settings tabs** — 10 organized tabs covering ~60 previously hidden config fields
|
|
||||||
- **Configure sidebar** — Personalities, Quick Commands, Plugins, Webhooks, Profiles
|
|
||||||
|
|
||||||
See the [v1.6.0 release notes](https://github.com/awizemann/scarf/releases/tag/v1.6.0) for the full 1.6 series.
|
- **Daily OAuth keepalive cron** prevents Anthropic OAuth refresh tokens from expiring after weeks of inactivity.
|
||||||
|
- **Remote re-auth** unblocked — OAuth flow drives a remote `hermes auth add` correctly with stdin forwarded.
|
||||||
|
- **OAuth remove button** + auto-refresh of Credential Pools on `auth.json` change.
|
||||||
|
- **`resolve_provider_client` errors** (auxiliary task references an unauthenticated provider) classified into a clear hint with a one-click jump to Settings → Aux Models.
|
||||||
|
- **Model/provider mismatch banner** detects when `model.default` carries a `<provider>/...` prefix that disagrees with `model.provider`, with one-click fix in either direction.
|
||||||
|
|
||||||
|
### ScarfMon — performance instrumentation harness
|
||||||
|
|
||||||
|
The diagnostic surface that drove the bulk of the v2.7 perf work. Off by default; signpost-only mode (Instruments-friendly) is free; Full mode keeps a 4096-entry in-memory ring buffer you can copy as JSON for paste-into-issue diagnosis. Wiki: [Performance-Monitoring](https://github.com/awizemann/scarf/wiki/Performance-Monitoring).
|
||||||
|
|
||||||
|
See the full [v2.7.0 release notes](https://github.com/awizemann/scarf/releases/tag/v2.7.0) for the complete list (36 commits, including: in-flight coalescing for `loadRecentSessions`, snapshot pipeline rewrite from `sqlite3 .backup` to direct SSH-streamed queries [#74](https://github.com/awizemann/scarf/issues/74), per-message TTS, window-position persistence, sidebar reorder, and many other fixes).
|
||||||
|
|
||||||
|
**Previous releases:** see the [Release Notes Index](https://github.com/awizemann/scarf/wiki/Release-Notes-Index) on the wiki for v2.6, v2.5, v2.3, v2.2, v2.0, v1.6, and earlier.
|
||||||
|
|
||||||
|
## ScarfGo — the iPhone companion
|
||||||
|
|
||||||
|
Same Hermes server you've been running on your Mac — reachable from your phone over SSH. Multi-server, project-scoped chat, session resume, memory editor, cron list, skills tree, settings (read), all native iOS. Pure-Swift SSH (Citadel under the hood — no `ssh` binary needed on iOS). Per-project chat writes the same Scarf-managed `AGENTS.md` block the Mac app does, so the agent boots with the same project context regardless of which client opened the session.
|
||||||
|
|
||||||
|
**[Join the public TestFlight](https://testflight.apple.com/join/qCrRpcTz)** — the link is live now but only accepts new beta testers once Apple's Beta Review approves the first build. If you hit a "not accepting testers" splash, bookmark it and try again in 24–48h.
|
||||||
|
|
||||||
|
<p align="center">
|
||||||
|
<a href="assets/screenshots/scarfgo-servers.png"><img src="assets/screenshots/scarfgo-servers.png" alt="ScarfGo — Servers list" width="140"></a>
|
||||||
|
<a href="assets/screenshots/scarfgo-chat.png"><img src="assets/screenshots/scarfgo-chat.png" alt="ScarfGo — Chat with Hermes" width="140"></a>
|
||||||
|
<a href="assets/screenshots/scarfgo-project-dashboard.png"><img src="assets/screenshots/scarfgo-project-dashboard.png" alt="ScarfGo — Project dashboard" width="140"></a>
|
||||||
|
<a href="assets/screenshots/scarfgo-skills.png"><img src="assets/screenshots/scarfgo-skills.png" alt="ScarfGo — Skills browser" width="140"></a>
|
||||||
|
<a href="assets/screenshots/scarfgo-system.png"><img src="assets/screenshots/scarfgo-system.png" alt="ScarfGo — System tab" width="140"></a>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<p align="center"><sub><em>Tap any thumbnail to view full size. Servers list · Chat · Project dashboard (Site Status Checker template) · Skills browser · System tab.</em></sub></p>
|
||||||
|
|
||||||
|
See the [ScarfGo wiki page](https://github.com/awizemann/scarf/wiki/ScarfGo) for the full feature tour, [ScarfGo Onboarding](https://github.com/awizemann/scarf/wiki/ScarfGo-Onboarding) for the SSH-key setup walkthrough, and [Platform Differences](https://github.com/awizemann/scarf/wiki/Platform-Differences) for what is and isn't shared between Mac and iOS.
|
||||||
|
|
||||||
|
## Connect ScarfGo to your Hermes server
|
||||||
|
|
||||||
|
ScarfGo speaks SSH directly — no companion service, no developer-controlled server in between. Onboarding takes about a minute:
|
||||||
|
|
||||||
|
1. **Install via TestFlight.** Open the [public TestFlight link](https://testflight.apple.com/join/qCrRpcTz) on your phone, accept the invite, install ScarfGo from TestFlight (just like any other beta).
|
||||||
|
2. **Tap Add Server.** Enter the host (IP or DNS), SSH user, port (default 22), and an optional nickname. Same details you'd type into `ssh user@host`.
|
||||||
|
3. **Generate Key.** ScarfGo creates a fresh Ed25519 keypair on the device. The private half lives in the iOS Keychain (`kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly`) and is excluded from iCloud sync — it never leaves the phone.
|
||||||
|
4. **Add the public key to your Hermes host.** Tap **Copy public key**, then on the host run:
|
||||||
|
```bash
|
||||||
|
cat >> ~/.ssh/authorized_keys <<'EOF'
|
||||||
|
<paste the line ScarfGo showed you>
|
||||||
|
EOF
|
||||||
|
chmod 600 ~/.ssh/authorized_keys
|
||||||
|
```
|
||||||
|
This is its own line per device — the convention any second SSH client uses. Mac (Scarf) keeps using your existing ssh-agent / `~/.ssh/config` and is unaffected.
|
||||||
|
5. **Tap Test connection.** ScarfGo opens an SSH session, probes for the `hermes` binary, and saves the server on success. If it can't find `hermes`, see the [troubleshooting section](https://github.com/awizemann/scarf/wiki/ScarfGo-Onboarding#troubleshooting) — it's almost always a `PATH` quirk on non-interactive SSH.
|
||||||
|
|
||||||
|
Done. Open the Dashboard tab and tap any session to resume it; tap the **+** in Chat to start a new project-scoped session.
|
||||||
|
|
||||||
## Multi-server, one window per server
|
## Multi-server, one window per server
|
||||||
|
|
||||||
@@ -136,10 +172,11 @@ Custom, agent-generated dashboards for any project. Define stat boxes, charts, t
|
|||||||
|
|
||||||
## Requirements
|
## Requirements
|
||||||
|
|
||||||
- macOS 14.6+ (Sonoma)
|
- macOS 14.6+ (Sonoma) for Scarf
|
||||||
- Xcode 16.0+
|
- iOS 18.0+ for [ScarfGo](https://github.com/awizemann/scarf/wiki/ScarfGo) (the iPhone companion, public TestFlight from v2.5)
|
||||||
- [Hermes agent](https://github.com/hermes-ai/hermes-agent) v0.6.0+ installed at `~/.hermes/` on each target host (v0.9.0+ recommended for full feature support)
|
- Xcode 16.0+ to build from source
|
||||||
- For remote servers: SSH access (key-based), `sqlite3` on the remote (for atomic DB snapshots), and the `hermes` CLI resolvable from the remote user's `PATH` or at a path you specify per server.
|
- [Hermes agent](https://github.com/hermes-ai/hermes-agent) v0.6.0+ installed at `~/.hermes/` on each target host (v0.12.0+ recommended for full v2.6 feature support — autonomous Curator, multimodal image input, 5 new providers, Microsoft Teams + Yuanbao gateways, Kanban, Skills v0.12 surface, cron `--workdir`, prompt-cache TTL, Piper TTS, Vercel terminal)
|
||||||
|
- For remote servers: SSH access (key-based), `sqlite3` on the remote (for atomic DB snapshots), and the `hermes` CLI resolvable from the remote user's `PATH` or at a path you specify per server. ScarfGo requires the same on every Hermes host it connects to.
|
||||||
|
|
||||||
### Compatibility
|
### Compatibility
|
||||||
|
|
||||||
@@ -151,9 +188,11 @@ Scarf reads Hermes's SQLite database and parses CLI output from `hermes status`,
|
|||||||
| v0.7.0 (2026-04-03) | Verified |
|
| v0.7.0 (2026-04-03) | Verified |
|
||||||
| v0.8.0 (2026-04-08) | Verified |
|
| v0.8.0 (2026-04-08) | Verified |
|
||||||
| v0.9.0 (2026-04-13) | Verified |
|
| v0.9.0 (2026-04-13) | Verified |
|
||||||
| v0.10.0 (2026-04-18) | Verified (recommended for full 2.0 feature support) |
|
| v0.10.0 (2026-04-16) | Verified (Tool Gateway introduced) |
|
||||||
|
| v0.11.0 (2026-04-23) | Verified |
|
||||||
|
| v0.12.0 (2026-04-30) | **Verified — current target (recommended for full v2.6 feature support)** |
|
||||||
|
|
||||||
Scarf 2.0 targets Hermes v0.10.0 for the ACP session/fork/list/resume capabilities used by remote chat. Earlier Hermes versions remain supported for monitoring, sessions, and file-based features; ACP-specific behavior may gracefully degrade on older agents.
|
Scarf 2.6 targets Hermes v0.12.0 for the autonomous Curator, multimodal ACP image content blocks, the 5 new inference providers, Microsoft Teams + Yuanbao gateways, the read-only Kanban view, the Skills v0.12 surface (URL install / reload / disable badges / curator pin), cron `--workdir`, `auxiliary.curator`, `prompt_caching.cache_ttl`, the redaction toggle, the runtime metadata footer, Piper TTS, and the Vercel terminal backend. Every v0.12 surface is **capability-gated** — Scarf detects the host's Hermes version once per server connection (`hermes --version` → semver + `YYYY.M.D` parse) and hides v0.12-only UI on older hosts. v0.11.0 hosts keep the full v2.5 surface (`/steer`, `messages.reasoning_content`, `sessions.api_call_count`, design-md/spotify skills, SKILL.md frontmatter chips, `hermes memory reset`). Earlier Hermes versions remain supported for monitoring, sessions, file-based features, and ACP chat; new behavior degrades gracefully on older agents.
|
||||||
|
|
||||||
If a Hermes update changes the database schema or CLI output format, Scarf may need to be updated. Check the [Health](#features) view for compatibility warnings.
|
If a Hermes update changes the database schema or CLI output format, Scarf may need to be updated. Check the [Health](#features) view for compatibility warnings.
|
||||||
|
|
||||||
@@ -171,6 +210,20 @@ Download the latest build from [Releases](https://github.com/awizemann/scarf/rel
|
|||||||
|
|
||||||
Scarf checks for updates automatically on launch via [Sparkle](https://sparkle-project.org) and daily thereafter. You can disable automatic checks or trigger a manual check from **Settings → General → Updates** or the menu bar icon.
|
Scarf checks for updates automatically on launch via [Sparkle](https://sparkle-project.org) and daily thereafter. You can disable automatic checks or trigger a manual check from **Settings → General → Updates** or the menu bar icon.
|
||||||
|
|
||||||
|
#### "Scarf.app is damaged" on first launch
|
||||||
|
|
||||||
|
If Gatekeeper rejects the app on first launch (occasionally happens on macOS 14+ for zip-distributed apps depending on extraction tool + quarantine state), the bundle itself is fine — every release is verified to pass `codesign --verify --strict --deep` and `spctl --assess --type execute` before it ships. The fix is to **only remove the quarantine attribute**, never strip all xattrs or re-sign:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Recommended — non-destructive
|
||||||
|
xattr -d com.apple.quarantine /Applications/Scarf.app
|
||||||
|
|
||||||
|
# Or extract with ditto instead of double-clicking the zip:
|
||||||
|
ditto -xk ~/Downloads/Scarf-vX.X.X-Universal.zip ~/Downloads/
|
||||||
|
```
|
||||||
|
|
||||||
|
**Do not run `xattr -rc /Applications/Scarf.app`** — it strips codesign-related extended attributes and can break the bundle's seal. **Do not run `codesign --force --deep --sign - /Applications/Scarf.app`** — `--deep` ad-hoc re-signing is incompatible with Sparkle.framework's nested XPC services and `Updater.app` sub-bundle, and will corrupt the framework signature even if the outer app appears intact afterward. If a clean re-download + `xattr -d com.apple.quarantine` doesn't resolve the issue, please open an issue with `codesign --verify --verbose=4 --strict /Applications/Scarf.app` output captured **before** any mitigation attempts.
|
||||||
|
|
||||||
### Build from Source
|
### Build from Source
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
|||||||
|
After Width: | Height: | Size: 1.1 MiB |
|
After Width: | Height: | Size: 1.1 MiB |
|
After Width: | Height: | Size: 183 KiB |
|
After Width: | Height: | Size: 514 KiB |
|
After Width: | Height: | Size: 472 KiB |
@@ -0,0 +1,11 @@
|
|||||||
|
{
|
||||||
|
"images" : [
|
||||||
|
{
|
||||||
|
"filename" : "Scarf-AppIcon-iOS-1024.png",
|
||||||
|
"idiom" : "universal",
|
||||||
|
"platform" : "ios",
|
||||||
|
"size" : "1024x1024"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"info" : { "author" : "xcode", "version" : 1 }
|
||||||
|
}
|
||||||
|
After Width: | Height: | Size: 1.6 MiB |
@@ -0,0 +1,3 @@
|
|||||||
|
{
|
||||||
|
"info" : { "author" : "xcode", "version" : 1 }
|
||||||
|
}
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
# Scarf Design System — static site
|
||||||
|
|
||||||
|
A self-contained, offline-friendly site that browses every artifact in the
|
||||||
|
Scarf design system. Open `index.html` directly in any browser — no server,
|
||||||
|
no build step.
|
||||||
|
|
||||||
|
## What's here
|
||||||
|
|
||||||
|
```
|
||||||
|
static-site/
|
||||||
|
├── index.html ← landing page, links into everything
|
||||||
|
├── colors_and_type.css ← shared design tokens (referenced everywhere)
|
||||||
|
│
|
||||||
|
├── ui-kit/ ← interactive macOS UI kit
|
||||||
|
│ ├── index.html ← click-thru of every screen in the app
|
||||||
|
│ └── *.jsx ← React components (Sidebar, Chat, Dashboard…)
|
||||||
|
│
|
||||||
|
├── tokens/ ← design-system cards
|
||||||
|
│ ├── _preview.css ← shared card styling
|
||||||
|
│ ├── colors-*.html ← brand / neutrals / semantic / tool-kinds
|
||||||
|
│ ├── type-*.html ← display / body / mono
|
||||||
|
│ ├── spacing-*.html ← scale / radii / shadows
|
||||||
|
│ ├── components-*.html ← buttons / forms / sidebar / cards / chat / composer / tool-call
|
||||||
|
│ ├── iconography.html
|
||||||
|
│ └── brand-mark.html
|
||||||
|
│
|
||||||
|
└── assets/ ← icons, brand artwork
|
||||||
|
```
|
||||||
|
|
||||||
|
## How to use it
|
||||||
|
|
||||||
|
- **Browse offline**: double-click `index.html`. Everything renders locally;
|
||||||
|
the only network dependency is Google Fonts (Inter + JetBrains Mono).
|
||||||
|
- **Host as a site**: drop the whole folder onto any static host (Netlify,
|
||||||
|
GitHub Pages, S3, your own nginx). Nothing needs building.
|
||||||
|
- **Embed in a doc**: link individual cards directly, e.g.
|
||||||
|
`static-site/tokens/colors-brand.html`.
|
||||||
|
- **Show the macOS app**: `static-site/ui-kit/index.html` runs the full
|
||||||
|
React-based interactive kit (single self-contained file — works from
|
||||||
|
`file://`, no server needed). The traffic-light corner makes it look like
|
||||||
|
the real app. Source components live alongside as `*.jsx` for editing —
|
||||||
|
re-bundle into `index.html` when you change them.
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
|
||||||
|
- The kit's `index.html` is a self-contained bundle — React, Babel, Lucide
|
||||||
|
and every component are inlined, so it works from `file://` with no
|
||||||
|
network. The original split-file source is preserved as
|
||||||
|
`ui-kit/index.source.html` next to the `.jsx` files for editing.
|
||||||
|
- The font import in `colors_and_type.css` (`fonts.googleapis.com`) is the
|
||||||
|
only other network call. Replace with locally-served WOFF2 if you need
|
||||||
|
airgapped use.
|
||||||
|
After Width: | Height: | Size: 1.6 MiB |
|
After Width: | Height: | Size: 33 KiB |
|
After Width: | Height: | Size: 117 KiB |
|
After Width: | Height: | Size: 429 KiB |
|
After Width: | Height: | Size: 541 KiB |
|
After Width: | Height: | Size: 54 KiB |
|
After Width: | Height: | Size: 490 KiB |
|
After Width: | Height: | Size: 48 KiB |
|
After Width: | Height: | Size: 592 KiB |
|
After Width: | Height: | Size: 56 KiB |
|
Before Width: | Height: | Size: 274 KiB After Width: | Height: | Size: 274 KiB |
@@ -0,0 +1,193 @@
|
|||||||
|
/* Scarf Design System — colors + type tokens. v2 (amber→rust)
|
||||||
|
*
|
||||||
|
* Light/dark via [data-theme="dark"] override on a parent. Default light.
|
||||||
|
*
|
||||||
|
* v2 changes: brand shifted from purple to a tri-stop amber→rust gradient.
|
||||||
|
* Neutrals warmed (yellow undertone). Semantic green/blue/red/orange preserved
|
||||||
|
* — those still mean success/info/danger and remain the tool-kind colors in chat.
|
||||||
|
*/
|
||||||
|
|
||||||
|
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500;600&display=swap');
|
||||||
|
|
||||||
|
:root {
|
||||||
|
/* ───── Brand — amber → rust ───── */
|
||||||
|
--brand-50: #FBF1E8;
|
||||||
|
--brand-100: #F6E0CB;
|
||||||
|
--brand-200: #EFC59E; /* highlight stop in tri-gradient */
|
||||||
|
--brand-300: #E89360; /* gradient start */
|
||||||
|
--brand-400: #D87844;
|
||||||
|
--brand-500: #C25A2A; /* primary accent — Scarf Rust */
|
||||||
|
--brand-600: #A6481E;
|
||||||
|
--brand-700: #7A2E14; /* gradient end */
|
||||||
|
--brand-800: #5C220F;
|
||||||
|
--brand-900: #3B1608;
|
||||||
|
|
||||||
|
/* ───── Neutrals (warm, slight amber tint) ───── */
|
||||||
|
--gray-0: #FFFFFF;
|
||||||
|
--gray-50: #FBF9F6;
|
||||||
|
--gray-100: #F4F1EC;
|
||||||
|
--gray-200: #EAE5DD;
|
||||||
|
--gray-300: #D8D1C5;
|
||||||
|
--gray-400: #B5ABA0;
|
||||||
|
--gray-500: #8C857B;
|
||||||
|
--gray-600: #6A645B;
|
||||||
|
--gray-700: #4A463F;
|
||||||
|
--gray-800: #2D2A25;
|
||||||
|
--gray-900: #1A1814;
|
||||||
|
--gray-950: #100E0B;
|
||||||
|
|
||||||
|
/* ───── Semantic palette ───── */
|
||||||
|
--green-500: #2AA876;
|
||||||
|
--green-600: #1F7F5A;
|
||||||
|
--green-100: #D8F0E5;
|
||||||
|
--red-500: #D9534F;
|
||||||
|
--red-600: #B83C38;
|
||||||
|
--red-100: #F8DAD8;
|
||||||
|
--orange-500: #F0AD4E; /* reasoning / warning — distinct from brand rust */
|
||||||
|
--orange-100: #FCEAD0;
|
||||||
|
--blue-500: #3498DB;
|
||||||
|
--blue-100: #D8ECF8;
|
||||||
|
--indigo-500: #5B6CD9;
|
||||||
|
--purple-tool-500: #8E5BC9;
|
||||||
|
|
||||||
|
/* ───── Surfaces (light) ───── */
|
||||||
|
--fg: var(--gray-900);
|
||||||
|
--fg-muted: var(--gray-600);
|
||||||
|
--fg-faint: var(--gray-500);
|
||||||
|
--bg: var(--gray-50);
|
||||||
|
--bg-card: var(--gray-0);
|
||||||
|
--bg-quaternary: rgba(45, 42, 37, 0.04);
|
||||||
|
--bg-tertiary: rgba(45, 42, 37, 0.07);
|
||||||
|
--border: rgba(45, 42, 37, 0.08);
|
||||||
|
--border-strong: rgba(45, 42, 37, 0.14);
|
||||||
|
|
||||||
|
/* ───── Brand tokens (semantic) ───── */
|
||||||
|
--accent: var(--brand-500);
|
||||||
|
--accent-hover: var(--brand-600);
|
||||||
|
--accent-active: var(--brand-700);
|
||||||
|
--accent-tint: rgba(194, 90, 42, 0.10);
|
||||||
|
--accent-tint-strong: rgba(194, 90, 42, 0.18);
|
||||||
|
--on-accent: #FFFFFF;
|
||||||
|
|
||||||
|
/* ───── Type stacks ───── */
|
||||||
|
--font-sans: -apple-system, BlinkMacSystemFont, "SF Pro Text", "Inter", "Segoe UI", Roboto, sans-serif;
|
||||||
|
--font-display: -apple-system, BlinkMacSystemFont, "SF Pro Display", "Inter", "Segoe UI", sans-serif;
|
||||||
|
--font-mono: ui-monospace, SFMono-Regular, "SF Mono", "JetBrains Mono", Menlo, Consolas, monospace;
|
||||||
|
|
||||||
|
/* ───── Type scale ───── */
|
||||||
|
--text-caption2: 10px;
|
||||||
|
--text-caption: 12px;
|
||||||
|
--text-footnote: 13px;
|
||||||
|
--text-body: 14px;
|
||||||
|
--text-callout: 15px;
|
||||||
|
--text-subhead: 16px;
|
||||||
|
--text-headline: 17px;
|
||||||
|
--text-title3: 20px;
|
||||||
|
--text-title2: 22px;
|
||||||
|
--text-title1: 28px;
|
||||||
|
--text-largeTitle: 34px;
|
||||||
|
|
||||||
|
--leading-tight: 1.2;
|
||||||
|
--leading-snug: 1.35;
|
||||||
|
--leading-normal: 1.5;
|
||||||
|
--leading-relaxed: 1.6;
|
||||||
|
|
||||||
|
--weight-regular: 400;
|
||||||
|
--weight-medium: 500;
|
||||||
|
--weight-semibold: 600;
|
||||||
|
--weight-bold: 700;
|
||||||
|
|
||||||
|
/* ───── Radii / spacing / shadow ───── */
|
||||||
|
--r-sm: 4px;
|
||||||
|
--r-md: 6px;
|
||||||
|
--r-lg: 8px;
|
||||||
|
--r-xl: 12px;
|
||||||
|
--r-2xl: 14px;
|
||||||
|
--r-pill: 999px;
|
||||||
|
|
||||||
|
--space-1: 4px;
|
||||||
|
--space-2: 8px;
|
||||||
|
--space-3: 12px;
|
||||||
|
--space-4: 16px;
|
||||||
|
--space-5: 20px;
|
||||||
|
--space-6: 24px;
|
||||||
|
--space-8: 32px;
|
||||||
|
--space-10: 40px;
|
||||||
|
|
||||||
|
--shadow-sm: 0 1px 2px rgba(45, 42, 37, 0.05);
|
||||||
|
--shadow-md: 0 1px 2px rgba(45, 42, 37, 0.04), 0 4px 12px rgba(45, 42, 37, 0.04);
|
||||||
|
--shadow-lg: 0 2px 4px rgba(45, 42, 37, 0.06), 0 8px 24px rgba(45, 42, 37, 0.07);
|
||||||
|
--shadow-xl: 0 4px 8px rgba(45, 42, 37, 0.08), 0 16px 40px rgba(45, 42, 37, 0.10);
|
||||||
|
--shadow-focus: 0 0 0 3px rgba(194, 90, 42, 0.28);
|
||||||
|
|
||||||
|
--gradient-brand: linear-gradient(135deg, #E89360 0%, #C25A2A 50%, #7A2E14 100%);
|
||||||
|
--gradient-brand-soft: linear-gradient(135deg, #F6E0CB 0%, #EFC59E 100%);
|
||||||
|
|
||||||
|
--ease-smooth: cubic-bezier(0.32, 0.72, 0, 1);
|
||||||
|
--dur-fast: 120ms;
|
||||||
|
--dur-base: 200ms;
|
||||||
|
--dur-slow: 300ms;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-theme="dark"] {
|
||||||
|
--fg: #EDE8E0;
|
||||||
|
--fg-muted: #A39C92;
|
||||||
|
--fg-faint: #756F66;
|
||||||
|
--bg: #15130F;
|
||||||
|
--bg-card: #1F1C18;
|
||||||
|
--bg-quaternary: rgba(255, 248, 235, 0.05);
|
||||||
|
--bg-tertiary: rgba(255, 248, 235, 0.08);
|
||||||
|
--border: rgba(255, 248, 235, 0.08);
|
||||||
|
--border-strong: rgba(255, 248, 235, 0.14);
|
||||||
|
|
||||||
|
--accent: #E89360;
|
||||||
|
--accent-hover: #F0A879;
|
||||||
|
--accent-active: #D87844;
|
||||||
|
--accent-tint: rgba(232, 147, 96, 0.14);
|
||||||
|
--accent-tint-strong: rgba(232, 147, 96, 0.24);
|
||||||
|
|
||||||
|
--shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.35);
|
||||||
|
--shadow-md: 0 1px 2px rgba(0, 0, 0, 0.35), 0 4px 12px rgba(0, 0, 0, 0.35);
|
||||||
|
--shadow-lg: 0 2px 4px rgba(0, 0, 0, 0.45), 0 8px 24px rgba(0, 0, 0, 0.45);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-color-scheme: dark) {
|
||||||
|
:root:not([data-theme="light"]) {
|
||||||
|
--fg: #EDE8E0;
|
||||||
|
--fg-muted: #A39C92;
|
||||||
|
--fg-faint: #756F66;
|
||||||
|
--bg: #15130F;
|
||||||
|
--bg-card: #1F1C18;
|
||||||
|
--bg-quaternary: rgba(255, 248, 235, 0.05);
|
||||||
|
--bg-tertiary: rgba(255, 248, 235, 0.08);
|
||||||
|
--border: rgba(255, 248, 235, 0.08);
|
||||||
|
--border-strong: rgba(255, 248, 235, 0.14);
|
||||||
|
|
||||||
|
--accent: #E89360;
|
||||||
|
--accent-hover: #F0A879;
|
||||||
|
--accent-active: #D87844;
|
||||||
|
--accent-tint: rgba(232, 147, 96, 0.14);
|
||||||
|
--accent-tint-strong: rgba(232, 147, 96, 0.24);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ───── Semantic type rules ───── */
|
||||||
|
body, .scarf-body {
|
||||||
|
font-family: var(--font-sans);
|
||||||
|
font-size: var(--text-body);
|
||||||
|
line-height: var(--leading-normal);
|
||||||
|
color: var(--fg);
|
||||||
|
background: var(--bg);
|
||||||
|
-webkit-font-smoothing: antialiased;
|
||||||
|
}
|
||||||
|
|
||||||
|
.scarf-h1 { font-family: var(--font-display); font-size: var(--text-largeTitle); font-weight: 600; line-height: 1.2; letter-spacing: -0.02em; }
|
||||||
|
.scarf-h2 { font-family: var(--font-display); font-size: var(--text-title1); font-weight: 600; line-height: 1.2; letter-spacing: -0.015em; }
|
||||||
|
.scarf-h3 { font-family: var(--font-display); font-size: var(--text-title2); font-weight: 600; line-height: 1.35; letter-spacing: -0.01em; }
|
||||||
|
.scarf-headline { font-family: var(--font-sans); font-size: var(--text-headline); font-weight: 600; line-height: 1.35; }
|
||||||
|
.scarf-subhead { font-family: var(--font-sans); font-size: var(--text-subhead); font-weight: 500; line-height: 1.35; }
|
||||||
|
.scarf-body-text { font-family: var(--font-sans); font-size: var(--text-body); line-height: 1.5; }
|
||||||
|
.scarf-caption { font-family: var(--font-sans); font-size: var(--text-caption); line-height: 1.5; color: var(--fg-muted); }
|
||||||
|
.scarf-caption-strong { font-family: var(--font-sans); font-size: var(--text-caption); font-weight: 600; text-transform: uppercase; letter-spacing: 0.05em; color: var(--fg-muted); }
|
||||||
|
.scarf-mono { font-family: var(--font-mono); font-size: 0.92em; }
|
||||||
|
.scarf-code { font-family: var(--font-mono); font-size: 0.9em; background: var(--bg-quaternary); padding: 1px 5px; border-radius: var(--r-sm); color: var(--fg); }
|
||||||
@@ -0,0 +1,382 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<title>Scarf Design System</title>
|
||||||
|
<link rel="stylesheet" href="colors_and_type.css">
|
||||||
|
<link rel="icon" type="image/png" href="assets/scarf-app-icon-256.png">
|
||||||
|
<style>
|
||||||
|
* { box-sizing: border-box; }
|
||||||
|
html, body { margin: 0; padding: 0; }
|
||||||
|
body {
|
||||||
|
min-height: 100vh;
|
||||||
|
background:
|
||||||
|
radial-gradient(ellipse 100% 60% at 50% -10%, rgba(232, 147, 96, 0.18), transparent 60%),
|
||||||
|
var(--bg);
|
||||||
|
color: var(--fg);
|
||||||
|
font-family: var(--font-sans);
|
||||||
|
}
|
||||||
|
.wrap { max-width: 1080px; margin: 0 auto; padding: 80px 32px 120px; }
|
||||||
|
|
||||||
|
header { display: flex; align-items: center; gap: 20px; margin-bottom: 56px; }
|
||||||
|
.icon-tile {
|
||||||
|
width: 88px; height: 88px;
|
||||||
|
border-radius: 22px;
|
||||||
|
background-image: url('assets/scarf-app-icon-256.png');
|
||||||
|
background-size: cover;
|
||||||
|
box-shadow: var(--shadow-lg);
|
||||||
|
}
|
||||||
|
h1 {
|
||||||
|
font-family: var(--font-display);
|
||||||
|
font-size: 44px;
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: -0.02em;
|
||||||
|
margin: 0 0 6px;
|
||||||
|
line-height: 1.1;
|
||||||
|
}
|
||||||
|
.tagline {
|
||||||
|
font-size: 17px;
|
||||||
|
color: var(--fg-muted);
|
||||||
|
line-height: 1.5;
|
||||||
|
max-width: 56ch;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-label {
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: var(--fg-faint);
|
||||||
|
margin: 64px 0 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Big feature card */
|
||||||
|
.hero-card {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1.1fr 1fr;
|
||||||
|
gap: 0;
|
||||||
|
background: var(--bg-card);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 18px;
|
||||||
|
overflow: hidden;
|
||||||
|
box-shadow: var(--shadow-md);
|
||||||
|
margin-bottom: 40px;
|
||||||
|
}
|
||||||
|
.hero-card .text {
|
||||||
|
padding: 36px 36px 32px;
|
||||||
|
display: flex; flex-direction: column;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
.hero-card .preview {
|
||||||
|
background: var(--gradient-brand);
|
||||||
|
position: relative;
|
||||||
|
min-height: 320px;
|
||||||
|
display: flex; align-items: center; justify-content: center;
|
||||||
|
}
|
||||||
|
.hero-card .preview img {
|
||||||
|
width: 60%; max-width: 240px;
|
||||||
|
filter: drop-shadow(0 14px 40px rgba(60, 18, 6, 0.35));
|
||||||
|
}
|
||||||
|
.hero-card h2 {
|
||||||
|
font-family: var(--font-display);
|
||||||
|
font-size: 28px;
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: -0.015em;
|
||||||
|
margin: 0 0 10px;
|
||||||
|
}
|
||||||
|
.hero-card p {
|
||||||
|
font-size: 15px;
|
||||||
|
color: var(--fg-muted);
|
||||||
|
line-height: 1.55;
|
||||||
|
margin: 0 0 24px;
|
||||||
|
}
|
||||||
|
.hero-card .cta {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 10px 18px;
|
||||||
|
background: var(--accent);
|
||||||
|
color: var(--on-accent);
|
||||||
|
border-radius: 8px;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 600;
|
||||||
|
text-decoration: none;
|
||||||
|
align-self: flex-start;
|
||||||
|
transition: background 120ms ease;
|
||||||
|
}
|
||||||
|
.hero-card .cta:hover { background: var(--accent-hover); }
|
||||||
|
.hero-card .cta svg { width: 16px; height: 16px; }
|
||||||
|
|
||||||
|
/* Token grid */
|
||||||
|
.grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
.tile {
|
||||||
|
display: block;
|
||||||
|
text-decoration: none;
|
||||||
|
color: inherit;
|
||||||
|
background: var(--bg-card);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 18px 20px;
|
||||||
|
transition: transform 160ms var(--ease-smooth), border-color 160ms ease, box-shadow 160ms ease;
|
||||||
|
}
|
||||||
|
.tile:hover {
|
||||||
|
transform: translateY(-2px);
|
||||||
|
border-color: var(--border-strong);
|
||||||
|
box-shadow: var(--shadow-md);
|
||||||
|
}
|
||||||
|
.tile .kicker {
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: var(--fg-faint);
|
||||||
|
margin-bottom: 6px;
|
||||||
|
}
|
||||||
|
.tile h3 {
|
||||||
|
margin: 0 0 4px;
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--fg);
|
||||||
|
}
|
||||||
|
.tile p {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--fg-muted);
|
||||||
|
line-height: 1.45;
|
||||||
|
}
|
||||||
|
.swatches {
|
||||||
|
display: flex; gap: 4px; margin-top: 14px;
|
||||||
|
}
|
||||||
|
.sw {
|
||||||
|
flex: 1; height: 22px; border-radius: 4px;
|
||||||
|
border: 1px solid rgba(0,0,0,0.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Group titles */
|
||||||
|
.group-title {
|
||||||
|
font-family: var(--font-display);
|
||||||
|
font-size: 22px;
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: -0.01em;
|
||||||
|
margin: 0 0 16px;
|
||||||
|
}
|
||||||
|
.group-blurb {
|
||||||
|
font-size: 14px;
|
||||||
|
color: var(--fg-muted);
|
||||||
|
margin: 0 0 24px;
|
||||||
|
line-height: 1.5;
|
||||||
|
max-width: 60ch;
|
||||||
|
}
|
||||||
|
|
||||||
|
footer {
|
||||||
|
margin-top: 80px;
|
||||||
|
padding-top: 28px;
|
||||||
|
border-top: 1px solid var(--border);
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--fg-faint);
|
||||||
|
display: flex; justify-content: space-between; align-items: center;
|
||||||
|
}
|
||||||
|
footer a { color: var(--fg-muted); text-decoration: none; }
|
||||||
|
footer a:hover { color: var(--accent); }
|
||||||
|
|
||||||
|
@media (max-width: 760px) {
|
||||||
|
.hero-card { grid-template-columns: 1fr; }
|
||||||
|
.hero-card .preview { min-height: 200px; order: -1; }
|
||||||
|
h1 { font-size: 36px; }
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="wrap">
|
||||||
|
|
||||||
|
<header>
|
||||||
|
<div class="icon-tile" role="img" aria-label="Scarf app icon"></div>
|
||||||
|
<div>
|
||||||
|
<h1>Scarf Design System</h1>
|
||||||
|
<p class="tagline">A native macOS & iOS companion for the Hermes AI agent — calm, confident, and rust-warm. This site documents the palette, type, components, and screens.</p>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<!-- UI Kit hero -->
|
||||||
|
<div class="section-label">UI Kit</div>
|
||||||
|
<a href="ui-kit/index.html" class="hero-card" style="text-decoration: none; color: inherit;">
|
||||||
|
<div class="text">
|
||||||
|
<h2>Interactive macOS app</h2>
|
||||||
|
<p>Click through every screen — Dashboard, Sessions, Insights, Projects, Chat, Settings, Tools, MCP servers, Cron, Logs, Memory, Activity, Health and more. Faithful to the real Scarf macOS app, with a working sidebar and the rust palette throughout.</p>
|
||||||
|
<span class="cta">
|
||||||
|
Open the kit
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round"><path d="M5 12h14M13 5l7 7-7 7"/></svg>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="preview">
|
||||||
|
<img src="assets/scarf-app-icon-1024.png" alt="">
|
||||||
|
</div>
|
||||||
|
</a>
|
||||||
|
|
||||||
|
<!-- Tokens & components -->
|
||||||
|
<div class="section-label">Tokens & components</div>
|
||||||
|
<h2 class="group-title">Foundations</h2>
|
||||||
|
<p class="group-blurb">Each tile opens a single design-system card. They're sized for ~700px wide and render one concept at a time.</p>
|
||||||
|
|
||||||
|
<div class="grid">
|
||||||
|
<a class="tile" href="tokens/colors-brand.html">
|
||||||
|
<div class="kicker">Color</div>
|
||||||
|
<h3>Brand — amber → rust</h3>
|
||||||
|
<p>The 9-step rust ramp. Primary accent is <code>#C25A2A</code>.</p>
|
||||||
|
<div class="swatches">
|
||||||
|
<div class="sw" style="background:#FBF1E8"></div>
|
||||||
|
<div class="sw" style="background:#EFC59E"></div>
|
||||||
|
<div class="sw" style="background:#E89360"></div>
|
||||||
|
<div class="sw" style="background:#C25A2A"></div>
|
||||||
|
<div class="sw" style="background:#7A2E14"></div>
|
||||||
|
<div class="sw" style="background:#3B1608"></div>
|
||||||
|
</div>
|
||||||
|
</a>
|
||||||
|
|
||||||
|
<a class="tile" href="tokens/colors-neutrals.html">
|
||||||
|
<div class="kicker">Color</div>
|
||||||
|
<h3>Warm neutrals</h3>
|
||||||
|
<p>Slight amber undertone — never cool grey. 11 steps for surfaces and text.</p>
|
||||||
|
<div class="swatches">
|
||||||
|
<div class="sw" style="background:#FBF9F6"></div>
|
||||||
|
<div class="sw" style="background:#EAE5DD"></div>
|
||||||
|
<div class="sw" style="background:#B5ABA0"></div>
|
||||||
|
<div class="sw" style="background:#6A645B"></div>
|
||||||
|
<div class="sw" style="background:#2D2A25"></div>
|
||||||
|
<div class="sw" style="background:#100E0B"></div>
|
||||||
|
</div>
|
||||||
|
</a>
|
||||||
|
|
||||||
|
<a class="tile" href="tokens/colors-semantic.html">
|
||||||
|
<div class="kicker">Color</div>
|
||||||
|
<h3>Semantic palette</h3>
|
||||||
|
<p>Success, danger, warning, info — preserved from system conventions.</p>
|
||||||
|
<div class="swatches">
|
||||||
|
<div class="sw" style="background:#2AA876"></div>
|
||||||
|
<div class="sw" style="background:#D9534F"></div>
|
||||||
|
<div class="sw" style="background:#F0AD4E"></div>
|
||||||
|
<div class="sw" style="background:#3498DB"></div>
|
||||||
|
</div>
|
||||||
|
</a>
|
||||||
|
|
||||||
|
<a class="tile" href="tokens/colors-tool-kinds.html">
|
||||||
|
<div class="kicker">Color</div>
|
||||||
|
<h3>Tool-kind palette</h3>
|
||||||
|
<p>Bash, edit, search, web, think — the per-tool decorations in chat.</p>
|
||||||
|
<div class="swatches">
|
||||||
|
<div class="sw" style="background:#2AA876"></div>
|
||||||
|
<div class="sw" style="background:#3498DB"></div>
|
||||||
|
<div class="sw" style="background:#5B6CD9"></div>
|
||||||
|
<div class="sw" style="background:#8E5BC9"></div>
|
||||||
|
<div class="sw" style="background:#F0AD4E"></div>
|
||||||
|
</div>
|
||||||
|
</a>
|
||||||
|
|
||||||
|
<a class="tile" href="tokens/type-display.html">
|
||||||
|
<div class="kicker">Type</div>
|
||||||
|
<h3>Display scale</h3>
|
||||||
|
<p>Large titles & headlines — SF Pro Display, tight tracking.</p>
|
||||||
|
</a>
|
||||||
|
|
||||||
|
<a class="tile" href="tokens/type-body.html">
|
||||||
|
<div class="kicker">Type</div>
|
||||||
|
<h3>Body scale</h3>
|
||||||
|
<p>14px base, the working text of the app.</p>
|
||||||
|
</a>
|
||||||
|
|
||||||
|
<a class="tile" href="tokens/type-mono.html">
|
||||||
|
<div class="kicker">Type</div>
|
||||||
|
<h3>Mono</h3>
|
||||||
|
<p>SF Mono — for transcripts, paths, command output.</p>
|
||||||
|
</a>
|
||||||
|
|
||||||
|
<a class="tile" href="tokens/spacing-scale.html">
|
||||||
|
<div class="kicker">Layout</div>
|
||||||
|
<h3>Spacing scale</h3>
|
||||||
|
<p>4 / 8 / 12 / 16 / 20 / 24 / 32 / 40 — that's the whole grid.</p>
|
||||||
|
</a>
|
||||||
|
|
||||||
|
<a class="tile" href="tokens/spacing-radii.html">
|
||||||
|
<div class="kicker">Layout</div>
|
||||||
|
<h3>Radii</h3>
|
||||||
|
<p>4 / 6 / 8 / 12 / 14 / pill — tuned for native macOS controls.</p>
|
||||||
|
</a>
|
||||||
|
|
||||||
|
<a class="tile" href="tokens/spacing-shadows.html">
|
||||||
|
<div class="kicker">Layout</div>
|
||||||
|
<h3>Shadows</h3>
|
||||||
|
<p>Four elevation tiers, all on a warm-black tint.</p>
|
||||||
|
</a>
|
||||||
|
|
||||||
|
<a class="tile" href="tokens/iconography.html">
|
||||||
|
<div class="kicker">Brand</div>
|
||||||
|
<h3>Iconography</h3>
|
||||||
|
<p>Lucide icons at 16/18/20/24, 1.6px stroke, currentColor.</p>
|
||||||
|
</a>
|
||||||
|
|
||||||
|
<a class="tile" href="tokens/brand-mark.html">
|
||||||
|
<div class="kicker">Brand</div>
|
||||||
|
<h3>App mark</h3>
|
||||||
|
<p>The flowing-silk icon — preferred backgrounds & minimum sizes.</p>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h2 class="group-title" style="margin-top: 56px;">Components</h2>
|
||||||
|
<p class="group-blurb">Composable pieces lifted directly from the macOS app's surfaces.</p>
|
||||||
|
|
||||||
|
<div class="grid">
|
||||||
|
<a class="tile" href="tokens/components-buttons.html">
|
||||||
|
<div class="kicker">Component</div>
|
||||||
|
<h3>Buttons</h3>
|
||||||
|
<p>Primary / secondary / ghost / destructive — three sizes each.</p>
|
||||||
|
</a>
|
||||||
|
<a class="tile" href="tokens/components-forms.html">
|
||||||
|
<div class="kicker">Component</div>
|
||||||
|
<h3>Forms</h3>
|
||||||
|
<p>Text fields, toggles, selects — with focus & error states.</p>
|
||||||
|
</a>
|
||||||
|
<a class="tile" href="tokens/components-sidebar.html">
|
||||||
|
<div class="kicker">Component</div>
|
||||||
|
<h3>Sidebar</h3>
|
||||||
|
<p>Section headers, items, active state, count pills.</p>
|
||||||
|
</a>
|
||||||
|
<a class="tile" href="tokens/components-stat-cards.html">
|
||||||
|
<div class="kicker">Component</div>
|
||||||
|
<h3>Stat cards</h3>
|
||||||
|
<p>Number-forward dashboard tiles.</p>
|
||||||
|
</a>
|
||||||
|
<a class="tile" href="tokens/components-status-cards.html">
|
||||||
|
<div class="kicker">Component</div>
|
||||||
|
<h3>Status cards</h3>
|
||||||
|
<p>Connection / health / run cards with semantic dots.</p>
|
||||||
|
</a>
|
||||||
|
<a class="tile" href="tokens/components-chat-bubbles.html">
|
||||||
|
<div class="kicker">Component</div>
|
||||||
|
<h3>Chat bubbles</h3>
|
||||||
|
<p>User & agent rich messages, avatars, timestamps.</p>
|
||||||
|
</a>
|
||||||
|
<a class="tile" href="tokens/components-composer.html">
|
||||||
|
<div class="kicker">Component</div>
|
||||||
|
<h3>Composer</h3>
|
||||||
|
<p>Multiline input with attachments & tool toggles.</p>
|
||||||
|
</a>
|
||||||
|
<a class="tile" href="tokens/components-tool-call.html">
|
||||||
|
<div class="kicker">Component</div>
|
||||||
|
<h3>Tool-call card</h3>
|
||||||
|
<p>Inline transcript card showing what the agent did.</p>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<footer>
|
||||||
|
<span>Scarf Design System · v2 (rust)</span>
|
||||||
|
<span><a href="ui-kit/index.html">UI kit</a> · <a href="tokens/colors-brand.html">First token</a></span>
|
||||||
|
</footer>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
/* Shared styling for design-system preview cards.
|
||||||
|
Each card is sized for ~700px wide and renders one focused concept. */
|
||||||
|
@import url('../colors_and_type.css');
|
||||||
|
|
||||||
|
* { box-sizing: border-box; }
|
||||||
|
html, body { margin: 0; padding: 0; }
|
||||||
|
body {
|
||||||
|
background: var(--bg);
|
||||||
|
color: var(--fg);
|
||||||
|
font-family: var(--font-sans);
|
||||||
|
font-size: var(--text-body);
|
||||||
|
line-height: var(--leading-normal);
|
||||||
|
-webkit-font-smoothing: antialiased;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-root {
|
||||||
|
padding: 20px 24px;
|
||||||
|
min-height: 110px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
.row { display: flex; align-items: center; gap: 12px; flex-wrap: wrap; }
|
||||||
|
.col { display: flex; flex-direction: column; gap: 8px; }
|
||||||
|
.label { font-size: 11px; color: var(--fg-muted); text-transform: uppercase; letter-spacing: 0.06em; font-weight: 600; }
|
||||||
|
.mono { font-family: var(--font-mono); font-size: 11px; color: var(--fg-muted); }
|
||||||
|
|
||||||
|
/* swatches */
|
||||||
|
.swatch {
|
||||||
|
width: 92px;
|
||||||
|
height: 64px;
|
||||||
|
border-radius: 8px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
justify-content: flex-end;
|
||||||
|
padding: 6px 8px;
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.swatch .name { font-size: 10px; font-weight: 600; }
|
||||||
|
.swatch .hex { font-family: var(--font-mono); font-size: 10px; opacity: 0.85; }
|
||||||
|
.swatch.dark-text { color: var(--gray-900); }
|
||||||
|
.swatch.light-text { color: #fff; }
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
<!doctype html><html lang="en"><head><meta charset="utf-8"><title>Brand mark</title>
|
||||||
|
<link rel="stylesheet" href="_preview.css"></head>
|
||||||
|
<body>
|
||||||
|
<div class="card-root" style="flex-direction:row;align-items:center;gap:24px;min-height:160px">
|
||||||
|
<img src="../assets/scarf-app-icon-128.png" alt="Scarf icon" width="96" height="96"
|
||||||
|
style="border-radius:22px;box-shadow:var(--shadow-md);background:var(--gradient-brand)">
|
||||||
|
<div class="col" style="flex:1;gap:6px">
|
||||||
|
<div style="font-family:var(--font-display);font-size:28px;font-weight:600;letter-spacing:-0.015em">Scarf</div>
|
||||||
|
<div style="color:var(--fg-muted);font-size:14px;max-width:380px">A native macOS GUI for the Hermes AI agent. Full visibility into what an autonomous agent is doing, when, and what it creates.</div>
|
||||||
|
<div class="mono" style="margin-top:4px">brand: white silk on lavender → magenta gradient</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</body></html>
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
<!doctype html><html lang="en"><head><meta charset="utf-8"><title>Primary palette</title>
|
||||||
|
<link rel="stylesheet" href="_preview.css"></head>
|
||||||
|
<body>
|
||||||
|
<div class="card-root">
|
||||||
|
<div class="label">Brand · Scarf Purple</div>
|
||||||
|
<div class="row">
|
||||||
|
<div class="swatch light-text" style="background:#F5F0FA;color:#36204A"><div class="name">50</div><div class="hex">#F5F0FA</div></div>
|
||||||
|
<div class="swatch light-text" style="background:#EADDF3;color:#36204A"><div class="name">100</div><div class="hex">#EADDF3</div></div>
|
||||||
|
<div class="swatch light-text" style="background:#D4B8E8;color:#36204A"><div class="name">200</div><div class="hex">#D4B8E8</div></div>
|
||||||
|
<div class="swatch light-text" style="background:#B288D9"><div class="name">300</div><div class="hex">#B288D9</div></div>
|
||||||
|
<div class="swatch light-text" style="background:#8B5BB8"><div class="name">500 ★</div><div class="hex">#8B5BB8</div></div>
|
||||||
|
<div class="swatch light-text" style="background:#7848A0"><div class="name">600</div><div class="hex">#7848A0</div></div>
|
||||||
|
<div class="swatch light-text" style="background:#4D2C68"><div class="name">800</div><div class="hex">#4D2C68</div></div>
|
||||||
|
</div>
|
||||||
|
<div class="mono">★ var(--accent) · used for primary buttons, focused borders, active sidebar items</div>
|
||||||
|
</div>
|
||||||
|
</body></html>
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
<!doctype html><html lang="en"><head><meta charset="utf-8"><title>Neutral palette</title>
|
||||||
|
<link rel="stylesheet" href="_preview.css"></head>
|
||||||
|
<body>
|
||||||
|
<div class="card-root">
|
||||||
|
<div class="label">Neutrals · warm-cool gray scale</div>
|
||||||
|
<div class="row">
|
||||||
|
<div class="swatch dark-text" style="background:#FFFFFF"><div class="name">0</div><div class="hex">#FFFFFF</div></div>
|
||||||
|
<div class="swatch dark-text" style="background:#FAFAFB"><div class="name">50</div><div class="hex">#FAFAFB</div></div>
|
||||||
|
<div class="swatch dark-text" style="background:#F3F2F5"><div class="name">100</div><div class="hex">#F3F2F5</div></div>
|
||||||
|
<div class="swatch dark-text" style="background:#E8E6EC"><div class="name">200</div><div class="hex">#E8E6EC</div></div>
|
||||||
|
<div class="swatch dark-text" style="background:#D6D3DC"><div class="name">300</div><div class="hex">#D6D3DC</div></div>
|
||||||
|
<div class="swatch dark-text" style="background:#B5B1BD"><div class="name">400</div><div class="hex">#B5B1BD</div></div>
|
||||||
|
<div class="swatch light-text" style="background:#8C8893"><div class="name">500</div><div class="hex">#8C8893</div></div>
|
||||||
|
<div class="swatch light-text" style="background:#6A666F"><div class="name">600</div><div class="hex">#6A666F</div></div>
|
||||||
|
<div class="swatch light-text" style="background:#4A464E"><div class="name">700</div><div class="hex">#4A464E</div></div>
|
||||||
|
<div class="swatch light-text" style="background:#2E2C32"><div class="name">800</div><div class="hex">#2E2C32</div></div>
|
||||||
|
<div class="swatch light-text" style="background:#1A181E"><div class="name">900</div><div class="hex">#1A181E</div></div>
|
||||||
|
</div>
|
||||||
|
<div class="mono">slight violet tint — bg=50, bg-card=0, fg=900, fg-muted=600</div>
|
||||||
|
</div>
|
||||||
|
</body></html>
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
<!doctype html><html lang="en"><head><meta charset="utf-8"><title>Semantic colors</title>
|
||||||
|
<link rel="stylesheet" href="_preview.css"></head>
|
||||||
|
<body>
|
||||||
|
<div class="card-root">
|
||||||
|
<div class="label">Semantic · status & feedback</div>
|
||||||
|
<div class="row">
|
||||||
|
<div class="swatch light-text" style="background:#2AA876"><div class="name">success</div><div class="hex">#2AA876</div></div>
|
||||||
|
<div class="swatch light-text" style="background:#D9534F"><div class="name">danger</div><div class="hex">#D9534F</div></div>
|
||||||
|
<div class="swatch dark-text" style="background:#F0AD4E"><div class="name">warning</div><div class="hex">#F0AD4E</div></div>
|
||||||
|
<div class="swatch light-text" style="background:#3498DB"><div class="name">info</div><div class="hex">#3498DB</div></div>
|
||||||
|
</div>
|
||||||
|
<div class="row" style="gap:8px;margin-top:4px">
|
||||||
|
<span style="font-size:11px;padding:3px 9px;border-radius:999px;background:#D8F0E5;color:#1F7F5A;font-weight:600">● Running</span>
|
||||||
|
<span style="font-size:11px;padding:3px 9px;border-radius:999px;background:#F8DAD8;color:#B83C38;font-weight:600">● Error</span>
|
||||||
|
<span style="font-size:11px;padding:3px 9px;border-radius:999px;background:#FCEAD0;color:#A8741F;font-weight:600">● Reasoning</span>
|
||||||
|
<span style="font-size:11px;padding:3px 9px;border-radius:999px;background:#D8ECF8;color:#1F70A8;font-weight:600">● Model</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</body></html>
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
<!doctype html><html lang="en"><head><meta charset="utf-8"><title>Tool-kind colors</title>
|
||||||
|
<link rel="stylesheet" href="_preview.css"></head>
|
||||||
|
<body>
|
||||||
|
<div class="card-root">
|
||||||
|
<div class="label">Tool-kind colors · agent activity</div>
|
||||||
|
<div class="row">
|
||||||
|
<div class="swatch light-text" style="background:#2AA876"><div class="name">read</div><div class="hex">green</div></div>
|
||||||
|
<div class="swatch light-text" style="background:#3498DB"><div class="name">edit</div><div class="hex">blue</div></div>
|
||||||
|
<div class="swatch dark-text" style="background:#F0AD4E"><div class="name">execute</div><div class="hex">orange</div></div>
|
||||||
|
<div class="swatch light-text" style="background:#8E5BC9"><div class="name">fetch</div><div class="hex">purple</div></div>
|
||||||
|
<div class="swatch light-text" style="background:#5B6CD9"><div class="name">browser</div><div class="hex">indigo</div></div>
|
||||||
|
<div class="swatch light-text" style="background:#8C8893"><div class="name">other</div><div class="hex">gray</div></div>
|
||||||
|
</div>
|
||||||
|
<div class="mono">preserved verbatim from ToolCallCard.swift — semantic to the product</div>
|
||||||
|
</div>
|
||||||
|
</body></html>
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
<!doctype html><html lang="en"><head><meta charset="utf-8"><title>Buttons</title>
|
||||||
|
<link rel="stylesheet" href="_preview.css">
|
||||||
|
<style>
|
||||||
|
.btn { font-family:var(--font-sans); font-size:14px; font-weight:500; padding:7px 14px; border-radius:8px; border:1px solid transparent; cursor:pointer; transition:all 120ms var(--ease-smooth); }
|
||||||
|
.btn-primary { background:var(--accent); color:#fff; }
|
||||||
|
.btn-primary:hover { background:var(--accent-hover); }
|
||||||
|
.btn-secondary { background:var(--bg-card); color:var(--fg); border-color:var(--border-strong); }
|
||||||
|
.btn-secondary:hover { border-color:var(--accent); color:var(--accent-hover); }
|
||||||
|
.btn-ghost { background:transparent; color:var(--fg); }
|
||||||
|
.btn-ghost:hover { background:var(--bg-quaternary); }
|
||||||
|
.btn-danger { background:#fff; color:var(--red-600); border-color:var(--red-500); }
|
||||||
|
.btn-link { background:transparent; color:var(--accent); padding:6px 0; border:none; }
|
||||||
|
.btn-sm { font-size:12px; padding:4px 10px; }
|
||||||
|
</style></head>
|
||||||
|
<body>
|
||||||
|
<div class="card-root">
|
||||||
|
<div class="label">Buttons</div>
|
||||||
|
<div class="row">
|
||||||
|
<button class="btn btn-primary">Install Template</button>
|
||||||
|
<button class="btn btn-secondary">Run Diagnostics…</button>
|
||||||
|
<button class="btn btn-ghost">Cancel</button>
|
||||||
|
<button class="btn btn-danger">Delete</button>
|
||||||
|
<button class="btn btn-link">View All →</button>
|
||||||
|
</div>
|
||||||
|
<div class="row" style="margin-top:4px">
|
||||||
|
<button class="btn btn-primary btn-sm">Add</button>
|
||||||
|
<button class="btn btn-secondary btn-sm">Export</button>
|
||||||
|
<button class="btn btn-secondary btn-sm" disabled style="opacity:.4">Configure</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</body></html>
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
<!doctype html><html lang="en"><head><meta charset="utf-8"><title>Chat bubbles</title>
|
||||||
|
<link rel="stylesheet" href="_preview.css"></head>
|
||||||
|
<body>
|
||||||
|
<div class="card-root" style="gap:8px">
|
||||||
|
<div style="display:flex;justify-content:flex-end">
|
||||||
|
<div style="background:var(--accent-tint);padding:8px 12px;border-radius:12px;font-size:14px;max-width:70%">What's the status of the cron job?</div>
|
||||||
|
</div>
|
||||||
|
<div style="text-align:right;font-size:10px;color:var(--fg-faint);margin-bottom:6px">9:42 AM</div>
|
||||||
|
<div style="background:var(--bg-quaternary);padding:8px 12px;border-radius:12px;font-size:14px;max-width:80%">
|
||||||
|
<div style="font-size:11px;color:var(--orange-500);font-weight:600;margin-bottom:4px">▾ Reasoning <span style="color:var(--fg-faint);font-weight:400">(127 tokens)</span></div>
|
||||||
|
The <span class="scarf-code" style="font-family:var(--font-mono);font-size:12px;background:rgba(0,0,0,.05);padding:1px 5px;border-radius:4px">daily-summary</span> job ran 14 minutes ago and completed successfully.
|
||||||
|
</div>
|
||||||
|
<div style="font-size:10px;color:var(--fg-faint);margin-left:4px">284 tokens · stop · 9:42 AM</div>
|
||||||
|
</div>
|
||||||
|
</body></html>
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
<!doctype html><html lang="en"><head><meta charset="utf-8"><title>Composer</title>
|
||||||
|
<link rel="stylesheet" href="_preview.css"></head>
|
||||||
|
<body>
|
||||||
|
<div class="card-root">
|
||||||
|
<div style="border-top:1px solid var(--border);padding:10px 12px;display:flex;gap:8px;align-items:flex-end;background:var(--bg-card);border-radius:8px;box-shadow:var(--shadow-sm)">
|
||||||
|
<div style="opacity:.6;font-size:18px;cursor:pointer">▭</div>
|
||||||
|
<div style="flex:1;background:var(--bg-quaternary);border-radius:12px;padding:8px 12px;font-size:14px;color:var(--fg-faint)">Message Hermes…</div>
|
||||||
|
<div style="font-size:22px;color:var(--accent)">↑</div>
|
||||||
|
</div>
|
||||||
|
<div class="mono">Rich Chat composer · /-menu opens above on slash, Shift+Enter for newline</div>
|
||||||
|
</div>
|
||||||
|
</body></html>
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
<!doctype html><html lang="en"><head><meta charset="utf-8"><title>Form inputs</title>
|
||||||
|
<link rel="stylesheet" href="_preview.css">
|
||||||
|
<style>
|
||||||
|
.field { display:flex; flex-direction:column; gap:4px; flex:1; }
|
||||||
|
.field label { font-size:11px; color:var(--fg-muted); font-weight:600; text-transform:uppercase; letter-spacing:.05em; }
|
||||||
|
.field input, .field select { font-family:var(--font-sans); font-size:14px; padding:6px 10px; border:1px solid var(--border-strong); border-radius:6px; background:var(--bg-card); color:var(--fg); outline:none; transition:all 120ms; }
|
||||||
|
.field input:focus { border-color:var(--accent); box-shadow:var(--shadow-focus); }
|
||||||
|
.toggle { width:36px; height:20px; background:var(--accent); border-radius:999px; position:relative; cursor:pointer; }
|
||||||
|
.toggle::after { content:''; position:absolute; right:2px; top:2px; width:16px; height:16px; background:#fff; border-radius:50%; box-shadow:0 1px 2px rgba(0,0,0,.2); }
|
||||||
|
.toggle.off { background:var(--gray-300); }
|
||||||
|
.toggle.off::after { right:auto; left:2px; }
|
||||||
|
</style></head>
|
||||||
|
<body>
|
||||||
|
<div class="card-root">
|
||||||
|
<div class="row" style="gap:14px;align-items:flex-end">
|
||||||
|
<div class="field"><label>Project Name</label><input value="hermes-blog"/></div>
|
||||||
|
<div class="field"><label>Strategy</label><select><option>round_robin</option></select></div>
|
||||||
|
</div>
|
||||||
|
<div class="row" style="gap:18px">
|
||||||
|
<div class="row" style="gap:8px"><div class="toggle"></div><span style="font-size:13px">Auto-update</span></div>
|
||||||
|
<div class="row" style="gap:8px"><div class="toggle off"></div><span style="font-size:13px">Pause cron</span></div>
|
||||||
|
<div class="row" style="gap:8px;font-size:13px"><input type="checkbox" checked style="accent-color:var(--accent)"/>Verified</div>
|
||||||
|
<div class="row" style="gap:8px;font-size:13px"><input type="radio" checked style="accent-color:var(--accent)"/>Local</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</body></html>
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
<!doctype html><html lang="en"><head><meta charset="utf-8"><title>Sidebar</title>
|
||||||
|
<link rel="stylesheet" href="_preview.css">
|
||||||
|
<style>
|
||||||
|
.sb { width:220px; background:var(--bg-quaternary); border-radius:10px; padding:10px 8px; font-size:13px; }
|
||||||
|
.sb-title { font-size:10px; color:var(--fg-muted); font-weight:600; text-transform:uppercase; letter-spacing:.06em; padding:6px 8px 4px }
|
||||||
|
.sb-item { display:flex; align-items:center; gap:8px; padding:5px 8px; border-radius:6px; color:var(--fg); cursor:pointer }
|
||||||
|
.sb-item:hover { background:var(--bg-tertiary) }
|
||||||
|
.sb-item.active { background:var(--accent-tint); color:var(--accent-active) }
|
||||||
|
.sb-icon { width:14px; opacity:.7 }
|
||||||
|
.sb-item.active .sb-icon { opacity:1 }
|
||||||
|
</style></head>
|
||||||
|
<body>
|
||||||
|
<div class="card-root" style="padding:14px">
|
||||||
|
<div class="sb">
|
||||||
|
<div class="sb-title">Monitor</div>
|
||||||
|
<div class="sb-item"><span class="sb-icon">▦</span>Dashboard</div>
|
||||||
|
<div class="sb-item active"><span class="sb-icon">📊</span>Insights</div>
|
||||||
|
<div class="sb-item"><span class="sb-icon">💬</span>Sessions</div>
|
||||||
|
<div class="sb-title">Interact</div>
|
||||||
|
<div class="sb-item"><span class="sb-icon">✦</span>Chat</div>
|
||||||
|
<div class="sb-item"><span class="sb-icon">◈</span>Memory</div>
|
||||||
|
<div class="sb-item"><span class="sb-icon">⚒</span>Skills</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</body></html>
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
<!doctype html><html lang="en"><head><meta charset="utf-8"><title>Stat cards</title>
|
||||||
|
<link rel="stylesheet" href="_preview.css">
|
||||||
|
<style>
|
||||||
|
.stat { background:var(--bg-quaternary); border-radius:8px; padding:14px 12px; flex:1; min-width:110px; text-align:center; }
|
||||||
|
.stat .v { font-family:var(--font-mono); font-size:22px; font-weight:600; }
|
||||||
|
.stat .l { font-size:11px; color:var(--fg-muted); margin-top:2px; }
|
||||||
|
</style></head>
|
||||||
|
<body>
|
||||||
|
<div class="card-root">
|
||||||
|
<div class="row" style="gap:12px">
|
||||||
|
<div class="stat"><div class="v">847</div><div class="l">Sessions</div></div>
|
||||||
|
<div class="stat"><div class="v">12,394</div><div class="l">Messages</div></div>
|
||||||
|
<div class="stat"><div class="v">3,221</div><div class="l">Tool Calls</div></div>
|
||||||
|
<div class="stat"><div class="v">2.4M</div><div class="l">Tokens</div></div>
|
||||||
|
<div class="stat"><div class="v">$42.18</div><div class="l">Cost</div></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</body></html>
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
<!doctype html><html lang="en"><head><meta charset="utf-8"><title>Status cards</title>
|
||||||
|
<link rel="stylesheet" href="_preview.css">
|
||||||
|
<style>
|
||||||
|
.scard { background:var(--bg-quaternary); border-radius:8px; padding:12px; flex:1; min-width:130px; }
|
||||||
|
.scard .head { display:flex; align-items:center; gap:6px; font-size:11px; color:var(--fg-muted); margin-bottom:4px; }
|
||||||
|
.scard .dot { width:8px; height:8px; border-radius:50%; }
|
||||||
|
.scard .val { font-family:var(--font-mono); font-size:14px; font-weight:500; }
|
||||||
|
</style></head>
|
||||||
|
<body>
|
||||||
|
<div class="card-root">
|
||||||
|
<div class="row" style="gap:12px">
|
||||||
|
<div class="scard"><div class="head"><span class="dot" style="background:var(--green-500)"></span>Hermes</div><div class="val">Running</div></div>
|
||||||
|
<div class="scard"><div class="head" style="color:var(--blue-500)">⌬ Model</div><div class="val">claude-sonnet-4.5</div></div>
|
||||||
|
<div class="scard"><div class="head" style="color:var(--accent)">☁ Provider</div><div class="val">Anthropic</div></div>
|
||||||
|
<div class="scard"><div class="head"><span class="dot" style="background:var(--green-500)"></span>Gateway</div><div class="val">Connected · 3</div></div>
|
||||||
|
</div>
|
||||||
|
<div class="mono">Status cards · 4 across at standard width</div>
|
||||||
|
</div>
|
||||||
|
</body></html>
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
<!doctype html><html lang="en"><head><meta charset="utf-8"><title>Tool call card</title>
|
||||||
|
<link rel="stylesheet" href="_preview.css"></head>
|
||||||
|
<body>
|
||||||
|
<div class="card-root">
|
||||||
|
<div style="background:var(--bg-quaternary);border-radius:6px;padding:6px 8px;display:flex;align-items:center;gap:6px;font-size:12px">
|
||||||
|
<div style="width:3px;height:16px;background:var(--green-500);border-radius:1px"></div>
|
||||||
|
<span style="color:var(--green-500)">📖</span>
|
||||||
|
<span style="font-family:var(--font-mono);font-weight:600">read_file</span>
|
||||||
|
<span style="font-family:var(--font-mono);color:var(--fg-faint);overflow:hidden;text-overflow:ellipsis;white-space:nowrap;flex:1;min-width:0">~/.hermes/config.yaml</span>
|
||||||
|
<span style="color:var(--green-500)">✓</span>
|
||||||
|
<span style="color:var(--fg-faint)">▸</span>
|
||||||
|
</div>
|
||||||
|
<div style="background:var(--bg-quaternary);border-radius:6px;padding:6px 8px;display:flex;align-items:center;gap:6px;font-size:12px">
|
||||||
|
<div style="width:3px;height:16px;background:var(--orange-500);border-radius:1px"></div>
|
||||||
|
<span style="color:var(--orange-500)">⌘</span>
|
||||||
|
<span style="font-family:var(--font-mono);font-weight:600">execute</span>
|
||||||
|
<span style="font-family:var(--font-mono);color:var(--fg-faint);overflow:hidden;text-overflow:ellipsis;white-space:nowrap;flex:1;min-width:0">{ "cmd": "hermes status" }</span>
|
||||||
|
<span style="color:var(--green-500)">✓</span>
|
||||||
|
<span style="color:var(--fg-faint)">▾</span>
|
||||||
|
</div>
|
||||||
|
<div style="background:var(--bg-quaternary);border-radius:6px;padding:6px 8px;display:flex;align-items:center;gap:6px;font-size:12px">
|
||||||
|
<div style="width:3px;height:16px;background:var(--blue-500);border-radius:1px"></div>
|
||||||
|
<span style="color:var(--blue-500)">✎</span>
|
||||||
|
<span style="font-family:var(--font-mono);font-weight:600">write_file</span>
|
||||||
|
<span style="font-family:var(--font-mono);color:var(--fg-faint);flex:1">cron/jobs.json</span>
|
||||||
|
<div style="width:10px;height:10px;border:1.5px solid var(--fg-faint);border-top-color:transparent;border-radius:50%;animation:spin 1s linear infinite"></div>
|
||||||
|
<span style="color:var(--fg-faint)">▸</span>
|
||||||
|
</div>
|
||||||
|
<style>@keyframes spin{to{transform:rotate(360deg)}}</style>
|
||||||
|
</div>
|
||||||
|
</body></html>
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
<!doctype html><html lang="en"><head><meta charset="utf-8"><title>Iconography</title>
|
||||||
|
<link rel="stylesheet" href="_preview.css">
|
||||||
|
<script src="https://unpkg.com/lucide@0.469.0/dist/umd/lucide.min.js"></script>
|
||||||
|
<style>
|
||||||
|
.ico { display:flex; flex-direction:column; align-items:center; gap:6px; font-size:10px; color:var(--fg-muted); width:64px }
|
||||||
|
.ico svg { width:22px; height:22px; stroke-width:1.5; color:var(--fg) }
|
||||||
|
</style></head>
|
||||||
|
<body>
|
||||||
|
<div class="card-root">
|
||||||
|
<div class="label">Iconography · Lucide (web sub for SF Symbols)</div>
|
||||||
|
<div class="row" style="gap:14px">
|
||||||
|
<div class="ico"><i data-lucide="layout-grid"></i>Dashboard</div>
|
||||||
|
<div class="ico"><i data-lucide="bar-chart-3"></i>Insights</div>
|
||||||
|
<div class="ico"><i data-lucide="messages-square"></i>Sessions</div>
|
||||||
|
<div class="ico"><i data-lucide="cpu"></i>Model</div>
|
||||||
|
<div class="ico"><i data-lucide="cloud"></i>Provider</div>
|
||||||
|
<div class="ico"><i data-lucide="package"></i>Templates</div>
|
||||||
|
<div class="ico"><i data-lucide="folder"></i>Projects</div>
|
||||||
|
<div class="ico"><i data-lucide="wrench"></i>Tools</div>
|
||||||
|
<div class="ico"><i data-lucide="stethoscope"></i>Diagnostics</div>
|
||||||
|
</div>
|
||||||
|
<script>lucide.createIcons();</script>
|
||||||
|
</div>
|
||||||
|
</body></html>
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
<!doctype html><html lang="en"><head><meta charset="utf-8"><title>Radii</title>
|
||||||
|
<link rel="stylesheet" href="_preview.css"></head>
|
||||||
|
<body>
|
||||||
|
<div class="card-root">
|
||||||
|
<div class="label">Radii · 4 / 6 / 8 / 12 / 14</div>
|
||||||
|
<div class="row" style="gap:14px;align-items:flex-end">
|
||||||
|
<div class="col" style="align-items:center;gap:6px"><div style="width:64px;height:64px;background:var(--accent-tint);border:1px solid var(--accent);border-radius:4px"></div><div class="mono">4 · chips, code</div></div>
|
||||||
|
<div class="col" style="align-items:center;gap:6px"><div style="width:64px;height:64px;background:var(--accent-tint);border:1px solid var(--accent);border-radius:6px"></div><div class="mono">6 · tool cards</div></div>
|
||||||
|
<div class="col" style="align-items:center;gap:6px"><div style="width:64px;height:64px;background:var(--accent-tint);border:1px solid var(--accent);border-radius:8px"></div><div class="mono">8 · cards, btns</div></div>
|
||||||
|
<div class="col" style="align-items:center;gap:6px"><div style="width:64px;height:64px;background:var(--accent-tint);border:1px solid var(--accent);border-radius:12px"></div><div class="mono">12 · bubbles</div></div>
|
||||||
|
<div class="col" style="align-items:center;gap:6px"><div style="width:64px;height:64px;background:var(--accent-tint);border:1px solid var(--accent);border-radius:14px"></div><div class="mono">14 · windows</div></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</body></html>
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
<!doctype html><html lang="en"><head><meta charset="utf-8"><title>Spacing scale</title>
|
||||||
|
<link rel="stylesheet" href="_preview.css"></head>
|
||||||
|
<body>
|
||||||
|
<div class="card-root">
|
||||||
|
<div class="label">Spacing · 4-base scale</div>
|
||||||
|
<div class="col" style="gap:6px">
|
||||||
|
<div class="row" style="gap:10px"><div style="width:4px;height:14px;background:var(--accent)"></div><div class="mono">4 · 1 · inline gaps</div></div>
|
||||||
|
<div class="row" style="gap:10px"><div style="width:8px;height:14px;background:var(--accent)"></div><div class="mono">8 · 2 · button padding y</div></div>
|
||||||
|
<div class="row" style="gap:10px"><div style="width:12px;height:14px;background:var(--accent)"></div><div class="mono">12 · 3 · card padding</div></div>
|
||||||
|
<div class="row" style="gap:10px"><div style="width:16px;height:14px;background:var(--accent)"></div><div class="mono">16 · 4 · view padding</div></div>
|
||||||
|
<div class="row" style="gap:10px"><div style="width:20px;height:14px;background:var(--accent)"></div><div class="mono">20 · 5 · section gap</div></div>
|
||||||
|
<div class="row" style="gap:10px"><div style="width:24px;height:14px;background:var(--accent)"></div><div class="mono">24 · 6 · header gap</div></div>
|
||||||
|
<div class="row" style="gap:10px"><div style="width:32px;height:14px;background:var(--accent)"></div><div class="mono">32 · 8 · page-level</div></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</body></html>
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
<!doctype html><html lang="en"><head><meta charset="utf-8"><title>Shadows</title>
|
||||||
|
<link rel="stylesheet" href="_preview.css"></head>
|
||||||
|
<body>
|
||||||
|
<div class="card-root" style="background:var(--bg)">
|
||||||
|
<div class="label">Shadows · two-layer Apple style</div>
|
||||||
|
<div class="row" style="gap:24px;padding:12px 4px">
|
||||||
|
<div class="col" style="align-items:center;gap:8px"><div style="width:120px;height:60px;background:var(--bg-card);border-radius:8px;box-shadow:0 1px 2px rgba(28,26,32,.05)"></div><div class="mono">sm · subtle lift</div></div>
|
||||||
|
<div class="col" style="align-items:center;gap:8px"><div style="width:120px;height:60px;background:var(--bg-card);border-radius:8px;box-shadow:0 1px 2px rgba(28,26,32,.04),0 4px 12px rgba(28,26,32,.04)"></div><div class="mono">md · cards</div></div>
|
||||||
|
<div class="col" style="align-items:center;gap:8px"><div style="width:120px;height:60px;background:var(--bg-card);border-radius:8px;box-shadow:0 2px 4px rgba(28,26,32,.06),0 8px 24px rgba(28,26,32,.07)"></div><div class="mono">lg · hover</div></div>
|
||||||
|
<div class="col" style="align-items:center;gap:8px"><div style="width:120px;height:60px;background:var(--bg-card);border-radius:8px;box-shadow:0 4px 8px rgba(28,26,32,.08),0 16px 40px rgba(28,26,32,.10)"></div><div class="mono">xl · sheet</div></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</body></html>
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
<!doctype html><html lang="en"><head><meta charset="utf-8"><title>Type · body</title>
|
||||||
|
<link rel="stylesheet" href="_preview.css"></head>
|
||||||
|
<body>
|
||||||
|
<div class="card-root" style="gap:10px">
|
||||||
|
<div class="label">Body · sentence case, calm and direct</div>
|
||||||
|
<div style="font-size:17px;font-weight:600">Hermes actually knows what project it's in</div>
|
||||||
|
<div style="font-size:15px;color:var(--fg-muted)">Every project-scoped chat gets a Scarf-managed block auto-injected into the project's <span class="scarf-code" style="font-family:var(--font-mono);font-size:13px">AGENTS.md</span> before the session starts.</div>
|
||||||
|
<div style="font-size:14px">Ask the agent <em>"what project am I in?"</em> and it answers with the project name, directory, template id, and registered cron jobs.</div>
|
||||||
|
<div style="font-size:12px;color:var(--fg-muted)">headline 17 · subhead 15 · body 14 · caption 12 — same rhythm as SwiftUI's text styles</div>
|
||||||
|
</div>
|
||||||
|
</body></html>
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
<!doctype html><html lang="en"><head><meta charset="utf-8"><title>Type · display</title>
|
||||||
|
<link rel="stylesheet" href="_preview.css"></head>
|
||||||
|
<body>
|
||||||
|
<div class="card-root" style="gap:14px">
|
||||||
|
<div class="label">Display · SF Pro Display / Inter</div>
|
||||||
|
<div style="font-family:var(--font-display);font-size:34px;font-weight:600;letter-spacing:-0.02em;line-height:1.15">Make the complex simple</div>
|
||||||
|
<div style="font-family:var(--font-display);font-size:28px;font-weight:600;letter-spacing:-0.015em;line-height:1.2">Recent sessions</div>
|
||||||
|
<div style="font-family:var(--font-display);font-size:22px;font-weight:600;letter-spacing:-0.01em">Activity patterns</div>
|
||||||
|
<div class="mono">largeTitle 34 / title1 28 / title2 22 — used for view titles only</div>
|
||||||
|
</div>
|
||||||
|
</body></html>
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
<!doctype html><html lang="en"><head><meta charset="utf-8"><title>Type · mono</title>
|
||||||
|
<link rel="stylesheet" href="_preview.css"></head>
|
||||||
|
<body>
|
||||||
|
<div class="card-root" style="gap:10px">
|
||||||
|
<div class="label">Mono · SF Mono / JetBrains Mono</div>
|
||||||
|
<div style="font-family:var(--font-mono);font-size:14px;font-weight:500">claude-haiku-4-5</div>
|
||||||
|
<div style="font-family:var(--font-mono);font-size:13px;color:var(--fg-muted)">~/.hermes/state.db · 14.2 MB</div>
|
||||||
|
<div style="font-family:var(--font-mono);font-size:12px">{ "tokens": 2384, "model": "claude-haiku-4-5" }</div>
|
||||||
|
<div class="row" style="gap:6px">
|
||||||
|
<span style="font-family:var(--font-mono);font-size:11px;background:var(--bg-quaternary);padding:2px 8px;border-radius:4px">v2.3.0</span>
|
||||||
|
<span style="font-family:var(--font-mono);font-size:11px;background:var(--bg-quaternary);padding:2px 8px;border-radius:4px">2,847 tokens</span>
|
||||||
|
<span style="font-family:var(--font-mono);font-size:11px;background:var(--bg-quaternary);padding:2px 8px;border-radius:4px">$0.0421</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</body></html>
|
||||||
@@ -0,0 +1,98 @@
|
|||||||
|
// Activity — chronological feed of everything that happened recently across
|
||||||
|
// all projects, sessions, cron, and tools. Day-grouped, filterable.
|
||||||
|
|
||||||
|
const ACTIVITY_GROUPS = [
|
||||||
|
{ day: 'Today', items: [
|
||||||
|
{ time: '09:42', icon: 'message-square', tone: 'accent', title: 'Sera — chat session resumed', sub: 'Forge · 14 turns · refactored CronRunner', proj: 'sera' },
|
||||||
|
{ time: '09:30', icon: 'clock', tone: 'green', title: 'incident-triage ran', sub: 'cron · ok in 4.2s · 0 issues created', proj: '—' },
|
||||||
|
{ time: '09:00', icon: 'clock', tone: 'green', title: 'daily-summary ran', sub: 'cron · ok in 36s · posted to #standup', proj: '—' },
|
||||||
|
{ time: '08:42', icon: 'git-pull-request', tone: 'blue', title: 'PR #284 opened', sub: 'sera · "Switch to AbortController for cron timeouts"', proj: 'sera' },
|
||||||
|
{ time: '08:14', icon: 'shield', tone: 'amber', title: 'Approval: execute git push origin main', sub: 'sera · approved by Aurora · 3.2s wait', proj: 'sera' },
|
||||||
|
]},
|
||||||
|
{ day: 'Yesterday', items: [
|
||||||
|
{ time: '17:22', icon: 'check-circle', tone: 'green', title: 'release-notes generated', sub: 'cron · ok in 1m 03s · draft saved', proj: '—' },
|
||||||
|
{ time: '15:08', icon: 'plug', tone: 'accent', title: 'MCP server connected — Figma', sub: '6 tools, 2 prompts available', proj: '—' },
|
||||||
|
{ time: '14:31', icon: 'message-square', tone: 'accent', title: 'Hermes — onboarding draft', sub: '8 turns · drafted welcome email', proj: 'hermes' },
|
||||||
|
{ time: '11:02', icon: 'alert-triangle', tone: 'red', title: 'Tool denied — rm -rf node_modules', sub: 'sera · matched deny rule "rm -rf"', proj: 'sera' },
|
||||||
|
{ time: '09:00', icon: 'clock', tone: 'green', title: 'daily-summary ran', sub: 'cron · ok in 41s', proj: '—' },
|
||||||
|
]},
|
||||||
|
{ day: 'Mon, Apr 21', items: [
|
||||||
|
{ time: '16:48', icon: 'user-plus', tone: 'accent', title: 'New personality — Atlas', sub: 'Created by Aurora · long-form writing model', proj: '—' },
|
||||||
|
{ time: '14:00', icon: 'database', tone: 'blue', title: 'Postgres (prod, ro) reconfigured', sub: 'switched to read replica', proj: '—' },
|
||||||
|
{ time: '09:00', icon: 'clock', tone: 'red', title: 'daily-summary failed', sub: 'cron · github 502 bad gateway · retried ok at 09:14', proj: '—' },
|
||||||
|
]},
|
||||||
|
];
|
||||||
|
|
||||||
|
const ACT_TONES = {
|
||||||
|
accent: { bg: 'var(--accent-tint)', fg: 'var(--accent)' },
|
||||||
|
green: { bg: 'var(--green-100)', fg: 'var(--green-600)' },
|
||||||
|
blue: { bg: 'var(--blue-100)', fg: 'var(--blue-500)' },
|
||||||
|
amber: { bg: 'var(--orange-100)', fg: 'var(--orange-500)' },
|
||||||
|
red: { bg: 'var(--red-100)', fg: 'var(--red-500)' },
|
||||||
|
};
|
||||||
|
|
||||||
|
function Activity() {
|
||||||
|
const [filter, setFilter] = React.useState('all');
|
||||||
|
React.useEffect(() => { requestAnimationFrame(() => window.lucide && window.lucide.createIcons()); });
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', height: '100%' }}>
|
||||||
|
<ContentHeader title="Activity"
|
||||||
|
subtitle="Everything Scarf has done recently — sessions, cron, tools, MCP, approvals"
|
||||||
|
actions={<Btn icon="filter">Filter</Btn>}
|
||||||
|
right={
|
||||||
|
<Segmented value={filter} onChange={setFilter} size="sm" options={[
|
||||||
|
{ value: 'all', label: 'All' },
|
||||||
|
{ value: 'sessions', label: 'Sessions' },
|
||||||
|
{ value: 'cron', label: 'Cron' },
|
||||||
|
{ value: 'tools', label: 'Tools' },
|
||||||
|
]} />
|
||||||
|
} />
|
||||||
|
<div style={{ flex: 1, overflowY: 'auto', padding: '24px 32px' }}>
|
||||||
|
{ACTIVITY_GROUPS.map(g => (
|
||||||
|
<div key={g.day} style={{ marginBottom: 28 }}>
|
||||||
|
<div style={{
|
||||||
|
fontSize: 11, fontWeight: 600, color: 'var(--fg-muted)',
|
||||||
|
textTransform: 'uppercase', letterSpacing: '0.06em', marginBottom: 8,
|
||||||
|
padding: '0 4px',
|
||||||
|
}}>{g.day}</div>
|
||||||
|
<div style={{
|
||||||
|
background: 'var(--bg-card)', border: '0.5px solid var(--border)',
|
||||||
|
borderRadius: 10, overflow: 'hidden',
|
||||||
|
}}>
|
||||||
|
{g.items.map((it, i) => <ActivityRow key={i} it={it} last={i === g.items.length - 1} />)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ActivityRow({ it, last }) {
|
||||||
|
const tone = ACT_TONES[it.tone];
|
||||||
|
const [hover, setHover] = React.useState(false);
|
||||||
|
return (
|
||||||
|
<div onMouseEnter={() => setHover(true)} onMouseLeave={() => setHover(false)} style={{
|
||||||
|
display: 'flex', alignItems: 'center', gap: 12, padding: '12px 16px',
|
||||||
|
borderBottom: last ? 'none' : '0.5px solid var(--border)',
|
||||||
|
background: hover ? 'var(--bg-quaternary)' : 'transparent', cursor: 'pointer',
|
||||||
|
}}>
|
||||||
|
<span style={{ fontSize: 11, fontFamily: 'var(--font-mono)', color: 'var(--fg-faint)', width: 44 }}>{it.time}</span>
|
||||||
|
<div style={{
|
||||||
|
width: 26, height: 26, borderRadius: 6, background: tone.bg, color: tone.fg,
|
||||||
|
display: 'flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0,
|
||||||
|
}}>
|
||||||
|
<i data-lucide={it.icon} style={{ width: 14, height: 14 }}></i>
|
||||||
|
</div>
|
||||||
|
<div style={{ flex: 1, minWidth: 0 }}>
|
||||||
|
<div style={{ fontSize: 13, fontWeight: 500 }}>{it.title}</div>
|
||||||
|
<div style={{ fontSize: 11.5, color: 'var(--fg-muted)', marginTop: 1 }}>{it.sub}</div>
|
||||||
|
</div>
|
||||||
|
{it.proj !== '—' && <Pill size="sm">{it.proj}</Pill>}
|
||||||
|
<i data-lucide="chevron-right" style={{ width: 14, height: 14, color: 'var(--fg-faint)' }}></i>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
window.Activity = Activity;
|
||||||
@@ -0,0 +1,787 @@
|
|||||||
|
// Chat — three-pane: session list / transcript / inspector.
|
||||||
|
// Inspector defaults to ToolCall details for the focused tool call; falls
|
||||||
|
// back to session-level metadata. Transcript supports reasoning, multi-step
|
||||||
|
// tool calls, file diffs, and a slash-command palette in the composer.
|
||||||
|
|
||||||
|
const TOOL_TONES = {
|
||||||
|
read: { color: 'var(--green-500)', tint: 'var(--green-100)', icon: 'book-open', label: 'Read' },
|
||||||
|
edit: { color: 'var(--blue-500)', tint: 'var(--blue-100)', icon: 'file-edit', label: 'Edit' },
|
||||||
|
execute: { color: 'var(--orange-500)', tint: 'var(--orange-100)', icon: 'terminal', label: 'Execute' },
|
||||||
|
fetch: { color: 'var(--purple-tool-500)', tint: '#EFE0F8', icon: 'globe', label: 'Fetch' },
|
||||||
|
browser: { color: 'var(--indigo-500)', tint: '#E0E5F8', icon: 'compass', label: 'Browser' },
|
||||||
|
search: { color: 'var(--accent)', tint: 'var(--accent-tint)',icon: 'search', label: 'Search' },
|
||||||
|
};
|
||||||
|
|
||||||
|
// ─────────────── Top-level Chat ───────────────
|
||||||
|
function Chat() {
|
||||||
|
const [active, setActive] = React.useState('s1');
|
||||||
|
const [focused, setFocused] = React.useState({ kind: 'tool', id: 'tc-2' }); // inspector subject
|
||||||
|
const [composerOpen, setComposerOpen] = React.useState(false); // slash menu
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
requestAnimationFrame(() => window.lucide && window.lucide.createIcons());
|
||||||
|
});
|
||||||
|
|
||||||
|
const sessions = [
|
||||||
|
{ id: 's1', title: 'Cron diagnostics', project: 'scarf', preview: 'The daily-summary job ran 14 minutes ago…', time: '14m', model: 'sonnet-4.5', unread: 0, pinned: true, status: 'live' },
|
||||||
|
{ id: 's2', title: 'Release notes draft', project: 'hermes-blog', preview: 'Pulled the merged PRs from this week…', time: '42m', model: 'haiku-4.5', unread: 2, status: 'idle' },
|
||||||
|
{ id: 's3', title: 'PR review summary', project: 'hermes-blog', preview: 'Three PRs are ready for review.', time: '2h', model: 'sonnet-4.5', status: 'idle' },
|
||||||
|
{ id: 's4', title: 'Function calling models', project: '—', preview: 'Sonnet handles structured tool use…', time: '3h', model: 'haiku-4.5', status: 'idle' },
|
||||||
|
{ id: 's5', title: 'Memory layout question', project: 'scarf', preview: 'The shared memory keys live at…', time: 'yesterday', model: 'sonnet-4.5', status: 'idle' },
|
||||||
|
{ id: 's6', title: 'Catalog publish flow', project: 'hermes-blog', preview: 'Walked through the .scarftemplate bundle…', time: 'yesterday', model: 'sonnet-4.5', status: 'idle' },
|
||||||
|
{ id: 's7', title: 'SSH tunnel debug', project: 'scarf-remote', preview: 'Connection drops after ~90s of idle…', time: 'Mon', model: 'sonnet-4.5', status: 'error' },
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ display: 'flex', height: '100%', overflow: 'hidden' }}>
|
||||||
|
<ChatList sessions={sessions} active={active} setActive={setActive} />
|
||||||
|
<Transcript focused={focused} setFocused={setFocused} composerOpen={composerOpen} setComposerOpen={setComposerOpen} />
|
||||||
|
<Inspector focused={focused} setFocused={setFocused} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─────────────── Pane 1 — session list ───────────────
|
||||||
|
function ChatList({ sessions, active, setActive }) {
|
||||||
|
const [filter, setFilter] = React.useState('all');
|
||||||
|
return (
|
||||||
|
<div style={{
|
||||||
|
width: 264, borderRight: '0.5px solid var(--border)',
|
||||||
|
background: 'var(--gray-50)', display: 'flex', flexDirection: 'column'
|
||||||
|
}}>
|
||||||
|
<div style={{ padding: '14px 14px 8px', display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||||
|
<div style={{ flex: 1, fontFamily: 'var(--font-display)', fontSize: 17, fontWeight: 600 }}>Chats</div>
|
||||||
|
<IconBtn icon="search" tooltip="Search ⌘F" />
|
||||||
|
<Btn size="sm" kind="primary" icon="plus">New</Btn>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ padding: '0 12px 8px' }}>
|
||||||
|
<Segmented value={filter} onChange={setFilter} size="sm" options={[
|
||||||
|
{ value: 'all', label: 'All', count: sessions.length },
|
||||||
|
{ value: 'live', label: 'Live', count: 1 },
|
||||||
|
{ value: 'pinned', label: 'Pinned', count: 1 },
|
||||||
|
]} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ flex: 1, overflowY: 'auto', padding: '0 6px 8px' }}>
|
||||||
|
<SessionGroupHeader>Today</SessionGroupHeader>
|
||||||
|
{sessions.slice(0, 4).map(s => <SessionRow key={s.id} s={s} active={active === s.id} onClick={() => setActive(s.id)} />)}
|
||||||
|
<SessionGroupHeader>Earlier</SessionGroupHeader>
|
||||||
|
{sessions.slice(4).map(s => <SessionRow key={s.id} s={s} active={active === s.id} onClick={() => setActive(s.id)} />)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ padding: '8px 14px', borderTop: '0.5px solid var(--border)',
|
||||||
|
display: 'flex', alignItems: 'center', gap: 8, fontSize: 11, color: 'var(--fg-muted)' }}>
|
||||||
|
<i data-lucide="message-square" style={{ width: 12, height: 12 }}></i>
|
||||||
|
<span>{sessions.length} chats</span>
|
||||||
|
<span style={{ marginLeft: 'auto', fontFamily: 'var(--font-mono)', fontSize: 10 }}>1.2 MB · state.db</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function SessionGroupHeader({ children }) {
|
||||||
|
return (
|
||||||
|
<div style={{
|
||||||
|
padding: '10px 10px 4px', fontSize: 10, fontWeight: 600,
|
||||||
|
color: 'var(--fg-muted)', textTransform: 'uppercase', letterSpacing: '0.06em',
|
||||||
|
}}>{children}</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function SessionRow({ s, active, onClick }) {
|
||||||
|
const [hover, setHover] = React.useState(false);
|
||||||
|
const statusColor = s.status === 'live' ? 'var(--green-500)' : s.status === 'error' ? 'var(--red-500)' : 'var(--gray-400)';
|
||||||
|
return (
|
||||||
|
<div onClick={onClick} onMouseEnter={() => setHover(true)} onMouseLeave={() => setHover(false)}
|
||||||
|
style={{
|
||||||
|
padding: '8px 10px', borderRadius: 7, cursor: 'pointer', marginBottom: 1,
|
||||||
|
background: active ? 'var(--accent-tint)' : (hover ? 'var(--bg-quaternary)' : 'transparent'),
|
||||||
|
position: 'relative',
|
||||||
|
}}>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 7 }}>
|
||||||
|
{s.status === 'live'
|
||||||
|
? <span style={{ width: 7, height: 7, borderRadius: '50%', background: statusColor,
|
||||||
|
boxShadow: '0 0 0 2px rgba(42,168,118,0.20)' }}></span>
|
||||||
|
: <span style={{ width: 6, height: 6, borderRadius: '50%', background: statusColor }}></span>}
|
||||||
|
{s.pinned && <i data-lucide="pin" style={{ width: 11, height: 11, color: 'var(--accent)' }}></i>}
|
||||||
|
<div style={{ flex: 1, fontSize: 13, fontWeight: 500,
|
||||||
|
color: active ? 'var(--accent-active)' : 'var(--fg)',
|
||||||
|
whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>{s.title}</div>
|
||||||
|
<div style={{ fontSize: 10, color: 'var(--fg-faint)', fontFamily: 'var(--font-mono)' }}>{s.time}</div>
|
||||||
|
</div>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 6, marginTop: 4, paddingLeft: 14 }}>
|
||||||
|
{s.project !== '—' && <span style={{
|
||||||
|
fontSize: 10, fontWeight: 500, color: 'var(--fg-muted)', fontFamily: 'var(--font-mono)',
|
||||||
|
background: 'var(--bg-card)', border: '0.5px solid var(--border)',
|
||||||
|
padding: '0 5px', borderRadius: 4,
|
||||||
|
}}>{s.project}</span>}
|
||||||
|
<div style={{ flex: 1, fontSize: 11, color: 'var(--fg-muted)',
|
||||||
|
whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>{s.preview}</div>
|
||||||
|
{s.unread > 0 && <span style={{
|
||||||
|
fontSize: 9, fontWeight: 700, fontFamily: 'var(--font-mono)',
|
||||||
|
padding: '1px 5px', borderRadius: 999, background: 'var(--accent)', color: '#fff', minWidth: 14, textAlign: 'center',
|
||||||
|
}}>{s.unread}</span>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─────────────── Pane 2 — transcript ───────────────
|
||||||
|
function Transcript({ focused, setFocused, composerOpen, setComposerOpen }) {
|
||||||
|
return (
|
||||||
|
<div style={{ flex: 1, display: 'flex', flexDirection: 'column', minWidth: 0,
|
||||||
|
background: 'var(--bg)' }}>
|
||||||
|
<TranscriptHeader />
|
||||||
|
<div style={{ flex: 1, overflowY: 'auto', padding: '20px 28px 8px',
|
||||||
|
display: 'flex', flexDirection: 'column', gap: 16, scrollBehavior: 'smooth' }}>
|
||||||
|
|
||||||
|
<DateMarker>Today · 9:42 AM</DateMarker>
|
||||||
|
|
||||||
|
<UserMsg time="9:42 AM">What's the status of the daily-summary cron job? I need to know if it's healthy before I push the new schedule changes.</UserMsg>
|
||||||
|
|
||||||
|
<AssistantMsg time="9:42 AM" tokens={284} model="sonnet-4.5" durationMs={2140}>
|
||||||
|
<Reasoning tokens={127} preview="Check the registry first, then the most recent execution." />
|
||||||
|
<ToolCall id="tc-1" kind="read" name="read_file" arg="~/.scarf/cron/jobs.json" duration="86 ms" focus={focused} setFocus={setFocused} />
|
||||||
|
<ToolCall id="tc-2" kind="execute" name="execute" arg='hermes cron status daily-summary' duration="1.4 s" focus={focused} setFocus={setFocused} expanded />
|
||||||
|
<p style={msgPara}>
|
||||||
|
The <code style={inlineCode}>daily-summary</code> job ran <strong>14 minutes ago</strong> and completed
|
||||||
|
successfully in 14.2 s, using 1,847 tokens. Next run is scheduled for tomorrow at 09:00 — safe to ship the schedule changes.
|
||||||
|
</p>
|
||||||
|
<MsgFooter />
|
||||||
|
</AssistantMsg>
|
||||||
|
|
||||||
|
<UserMsg time="9:43 AM">Show me what it produced.</UserMsg>
|
||||||
|
|
||||||
|
<AssistantMsg time="9:43 AM" tokens={612} model="sonnet-4.5" inProgress durationMs={4280}>
|
||||||
|
<ToolCall id="tc-3" kind="read" name="read_file" arg="~/.scarf/cron/output/daily-summary.md" duration="42 ms" focus={focused} setFocus={setFocused} />
|
||||||
|
<p style={msgPara}>The latest summary covers <strong>April 24, 2026</strong>. Highlights:</p>
|
||||||
|
<ul style={{ ...msgPara, paddingLeft: 18, margin: '4px 0' }}>
|
||||||
|
<li>3 PRs merged across <code style={inlineCode}>hermes</code> and <code style={inlineCode}>scarf</code></li>
|
||||||
|
<li>2 cron failures auto-recovered (gateway timeouts)</li>
|
||||||
|
<li>Token spend down 8% week-over-week</li>
|
||||||
|
</ul>
|
||||||
|
<ToolCall id="tc-4" kind="edit" name="apply_patch" arg="~/.scarf/cron/jobs.json" duration="120 ms" diff focus={focused} setFocus={setFocused} />
|
||||||
|
</AssistantMsg>
|
||||||
|
|
||||||
|
<SuggestedReplies items={['Schedule a dry run', 'Show last 5 runs', 'Disable daily-summary']} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Composer open={composerOpen} setOpen={setComposerOpen} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function TranscriptHeader() {
|
||||||
|
return (
|
||||||
|
<div style={{
|
||||||
|
padding: '14px 24px', borderBottom: '0.5px solid var(--border)',
|
||||||
|
display: 'flex', alignItems: 'center', gap: 12, background: 'var(--bg-card)',
|
||||||
|
}}>
|
||||||
|
<div style={{ flex: 1, minWidth: 0 }}>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||||
|
<i data-lucide="pin" style={{ width: 13, height: 13, color: 'var(--accent)' }}></i>
|
||||||
|
<div style={{ fontSize: 14, fontWeight: 600 }}>Cron diagnostics</div>
|
||||||
|
<Pill tone="green" dot size="sm">live</Pill>
|
||||||
|
</div>
|
||||||
|
<div style={{ fontSize: 11, color: 'var(--fg-muted)', display: 'flex', gap: 10, marginTop: 3, alignItems: 'center', flexWrap: 'wrap' }}>
|
||||||
|
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 4 }}>
|
||||||
|
<i data-lucide="folder" style={{ width: 11, height: 11, color: 'var(--accent)' }}></i>
|
||||||
|
<span style={{ color: 'var(--accent)', fontWeight: 600 }}>scarf</span>
|
||||||
|
</span>
|
||||||
|
<span style={{ color: 'var(--fg-faint)' }}>·</span>
|
||||||
|
<span style={{ fontFamily: 'var(--font-mono)' }}>claude-sonnet-4.5</span>
|
||||||
|
<span style={{ color: 'var(--fg-faint)' }}>·</span>
|
||||||
|
<span>14 messages</span>
|
||||||
|
<span style={{ color: 'var(--fg-faint)' }}>·</span>
|
||||||
|
<span style={{ fontFamily: 'var(--font-mono)' }}>12,847 tok</span>
|
||||||
|
<span style={{ color: 'var(--fg-faint)' }}>·</span>
|
||||||
|
<span style={{ fontFamily: 'var(--font-mono)' }}>$0.0421</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Btn size="sm" kind="ghost" icon="git-branch">Branch</Btn>
|
||||||
|
<Btn size="sm" kind="secondary" icon="share">Share</Btn>
|
||||||
|
<IconBtn icon="more-horizontal" tooltip="More" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function DateMarker({ children }) {
|
||||||
|
return (
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 10, color: 'var(--fg-faint)' }}>
|
||||||
|
<div style={{ flex: 1, height: 1, background: 'var(--border)' }}></div>
|
||||||
|
<span style={{ fontSize: 10, fontWeight: 600, textTransform: 'uppercase', letterSpacing: '0.06em' }}>{children}</span>
|
||||||
|
<div style={{ flex: 1, height: 1, background: 'var(--border)' }}></div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const msgPara = { fontSize: 14, lineHeight: 1.55, color: 'var(--fg)', margin: '6px 0' };
|
||||||
|
const inlineCode = { fontFamily: 'var(--font-mono)', fontSize: 12.5,
|
||||||
|
background: 'var(--bg-quaternary)', padding: '1px 5px', borderRadius: 4 };
|
||||||
|
|
||||||
|
function UserMsg({ time, children }) {
|
||||||
|
return (
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'flex-end', flexDirection: 'column', alignItems: 'flex-end' }}>
|
||||||
|
<div style={{
|
||||||
|
maxWidth: '76%', padding: '10px 14px', borderRadius: 14, borderBottomRightRadius: 4,
|
||||||
|
background: 'var(--accent)', color: 'var(--on-accent)', fontSize: 14, lineHeight: 1.5,
|
||||||
|
boxShadow: '0 1px 0 rgba(0,0,0,0.06)',
|
||||||
|
}}>{children}</div>
|
||||||
|
<div style={{ fontSize: 10, color: 'var(--fg-faint)', marginTop: 4, marginRight: 4,
|
||||||
|
display: 'flex', gap: 6, alignItems: 'center' }}>
|
||||||
|
<i data-lucide="check-check" style={{ width: 11, height: 11, color: 'var(--green-500)' }}></i>
|
||||||
|
<span>{time}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function AssistantMsg({ time, tokens, model, inProgress, durationMs, children }) {
|
||||||
|
return (
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'flex-start', maxWidth: '88%', position: 'relative' }}>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'flex-start', gap: 10, width: '100%' }}>
|
||||||
|
<div style={{
|
||||||
|
width: 26, height: 26, borderRadius: 7, marginTop: 2, flexShrink: 0,
|
||||||
|
background: 'var(--gradient-brand)',
|
||||||
|
display: 'flex', alignItems: 'center', justifyContent: 'center', color: '#fff',
|
||||||
|
boxShadow: '0 1px 2px rgba(122, 46, 20, 0.25)',
|
||||||
|
}}>
|
||||||
|
<i data-lucide="sparkles" style={{ width: 14, height: 14 }}></i>
|
||||||
|
</div>
|
||||||
|
<div style={{ flex: 1, minWidth: 0 }}>
|
||||||
|
<div style={{
|
||||||
|
background: 'var(--bg-card)', borderRadius: 12,
|
||||||
|
border: '0.5px solid var(--border)',
|
||||||
|
padding: '12px 14px', boxShadow: 'var(--shadow-sm)',
|
||||||
|
}}>{children}</div>
|
||||||
|
<div style={{ fontSize: 10, color: 'var(--fg-faint)', marginTop: 4, marginLeft: 4,
|
||||||
|
display: 'flex', gap: 8, alignItems: 'center', flexWrap: 'wrap' }}>
|
||||||
|
{inProgress && <span style={{ display: 'inline-flex', alignItems: 'center', gap: 4 }}>
|
||||||
|
<span style={{
|
||||||
|
width: 7, height: 7, borderRadius: '50%', background: 'var(--accent)',
|
||||||
|
animation: 'pulseScarf 1.4s ease-in-out infinite',
|
||||||
|
}}></span>
|
||||||
|
<span style={{ color: 'var(--accent)', fontWeight: 600 }}>thinking…</span>
|
||||||
|
</span>}
|
||||||
|
<span style={{ fontFamily: 'var(--font-mono)' }}>{model}</span>
|
||||||
|
<span>·</span>
|
||||||
|
<span style={{ fontFamily: 'var(--font-mono)' }}>{tokens} tok</span>
|
||||||
|
<span>·</span>
|
||||||
|
<span>{(durationMs / 1000).toFixed(1)}s</span>
|
||||||
|
<span>·</span>
|
||||||
|
<span>{time}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function MsgFooter() {
|
||||||
|
const Btnn = ({ icon, label }) => {
|
||||||
|
const [hover, setHover] = React.useState(false);
|
||||||
|
return (
|
||||||
|
<button onMouseEnter={() => setHover(true)} onMouseLeave={() => setHover(false)} style={{
|
||||||
|
padding: '3px 7px', fontSize: 11, color: hover ? 'var(--fg)' : 'var(--fg-muted)',
|
||||||
|
background: hover ? 'var(--bg-quaternary)' : 'transparent',
|
||||||
|
border: 'none', borderRadius: 5, cursor: 'pointer',
|
||||||
|
display: 'inline-flex', alignItems: 'center', gap: 4, fontFamily: 'var(--font-sans)',
|
||||||
|
}}>
|
||||||
|
<i data-lucide={icon} style={{ width: 11, height: 11 }}></i>{label}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
return (
|
||||||
|
<div style={{ display: 'flex', gap: 2, marginTop: 6, paddingTop: 6, borderTop: '0.5px solid var(--border)' }}>
|
||||||
|
<Btnn icon="copy" label="Copy" />
|
||||||
|
<Btnn icon="thumbs-up" label="" />
|
||||||
|
<Btnn icon="thumbs-down" label="" />
|
||||||
|
<Btnn icon="rotate-cw" label="Retry" />
|
||||||
|
<div style={{ flex: 1 }}></div>
|
||||||
|
<Btnn icon="pin" label="Pin" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─────────────── Reasoning disclosure ───────────────
|
||||||
|
function Reasoning({ tokens, preview, children }) {
|
||||||
|
const [open, setOpen] = React.useState(false);
|
||||||
|
return (
|
||||||
|
<div style={{ marginBottom: 8, background: 'var(--orange-100)', borderRadius: 7,
|
||||||
|
padding: '6px 10px', border: '0.5px solid rgba(240, 173, 78, 0.3)' }}>
|
||||||
|
<div onClick={() => setOpen(!open)} style={{
|
||||||
|
cursor: 'pointer', fontSize: 11, fontWeight: 600,
|
||||||
|
display: 'flex', alignItems: 'center', gap: 5, color: '#A8741F',
|
||||||
|
}}>
|
||||||
|
<i data-lucide="brain" style={{ width: 12, height: 12 }}></i>
|
||||||
|
<span style={{ textTransform: 'uppercase', letterSpacing: '0.04em' }}>Reasoning</span>
|
||||||
|
<span style={{ color: 'var(--fg-faint)', fontWeight: 500, fontFamily: 'var(--font-mono)' }}>· {tokens} tok</span>
|
||||||
|
<span style={{ flex: 1 }}></span>
|
||||||
|
<i data-lucide={open ? 'chevron-down' : 'chevron-right'} style={{ width: 12, height: 12 }}></i>
|
||||||
|
</div>
|
||||||
|
{!open && preview && (
|
||||||
|
<div style={{ fontSize: 12, color: 'var(--fg-muted)', marginTop: 3,
|
||||||
|
fontStyle: 'italic', lineHeight: 1.5,
|
||||||
|
whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>{preview}</div>
|
||||||
|
)}
|
||||||
|
{open && (
|
||||||
|
<div style={{ fontSize: 12.5, color: 'var(--fg-muted)', lineHeight: 1.55,
|
||||||
|
padding: '6px 0 2px', fontStyle: 'italic' }}>
|
||||||
|
The user wants the status of a specific cron job named "daily-summary".
|
||||||
|
I should check the cron registry first, then look at the most recent execution
|
||||||
|
via <code style={inlineCode}>hermes cron status</code>. If exit_code is 0,
|
||||||
|
the job is healthy and the schedule push is safe.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─────────────── ToolCall card ───────────────
|
||||||
|
function ToolCall({ id, kind, name, arg, duration, expanded: initial, diff, focus, setFocus }) {
|
||||||
|
const [open, setOpen] = React.useState(initial || false);
|
||||||
|
const t = TOOL_TONES[kind] || TOOL_TONES.read;
|
||||||
|
const isFocused = focus.kind === 'tool' && focus.id === id;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ marginBottom: 5 }}>
|
||||||
|
<div onClick={() => { setOpen(!open); setFocus({ kind: 'tool', id }); }} style={{
|
||||||
|
background: isFocused ? t.tint : 'var(--bg-quaternary)',
|
||||||
|
border: `0.5px solid ${isFocused ? t.color : 'var(--border)'}`,
|
||||||
|
outline: isFocused ? `1px solid ${t.color}` : 'none', outlineOffset: '-1px',
|
||||||
|
borderRadius: 7, padding: '6px 10px',
|
||||||
|
display: 'flex', alignItems: 'center', gap: 9,
|
||||||
|
fontSize: 12, cursor: 'pointer', transition: 'all 120ms',
|
||||||
|
}}>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 5 }}>
|
||||||
|
<i data-lucide={t.icon} style={{ width: 12, height: 12, color: t.color }}></i>
|
||||||
|
<span style={{ fontSize: 10, fontWeight: 700, color: t.color,
|
||||||
|
textTransform: 'uppercase', letterSpacing: '0.04em' }}>{t.label}</span>
|
||||||
|
</div>
|
||||||
|
<span style={{ fontFamily: 'var(--font-mono)', fontWeight: 600, color: 'var(--fg)' }}>{name}</span>
|
||||||
|
<span style={{ fontFamily: 'var(--font-mono)', color: 'var(--fg-muted)', flex: 1, minWidth: 0,
|
||||||
|
whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>{arg}</span>
|
||||||
|
<span style={{ fontFamily: 'var(--font-mono)', fontSize: 10, color: 'var(--fg-faint)' }}>{duration}</span>
|
||||||
|
<i data-lucide="check-circle-2" style={{ width: 13, height: 13, color: 'var(--green-500)' }}></i>
|
||||||
|
<i data-lucide={open ? 'chevron-down' : 'chevron-right'} style={{ width: 12, height: 12, color: 'var(--fg-faint)' }}></i>
|
||||||
|
</div>
|
||||||
|
{open && (
|
||||||
|
diff
|
||||||
|
? <DiffPreview />
|
||||||
|
: <ToolOutput kind={kind} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ToolOutput({ kind }) {
|
||||||
|
if (kind === 'execute') {
|
||||||
|
return (
|
||||||
|
<div style={{
|
||||||
|
background: 'var(--gray-900)', color: '#E8E1D2', borderRadius: 7,
|
||||||
|
padding: '10px 12px', fontFamily: 'var(--font-mono)', fontSize: 11.5,
|
||||||
|
marginTop: 6, lineHeight: 1.55, overflow: 'auto',
|
||||||
|
border: '1px solid var(--gray-800)',
|
||||||
|
}}>
|
||||||
|
<div><span style={{ color: '#7A7367' }}>$</span> <span style={{ color: '#EFC59E' }}>hermes</span> cron status daily-summary</div>
|
||||||
|
<div style={{ marginTop: 4 }}>
|
||||||
|
<span style={{ color: '#2AA876' }}>✓</span> <span style={{ color: '#A39C92' }}>last_run</span>: <span>2026-04-25T09:28:14Z</span><br/>
|
||||||
|
<span style={{ color: '#2AA876' }}>✓</span> <span style={{ color: '#A39C92' }}>duration</span>: <span>14.2s</span><br/>
|
||||||
|
<span style={{ color: '#2AA876' }}>✓</span> <span style={{ color: '#A39C92' }}>exit_code</span>: <span>0</span><br/>
|
||||||
|
<span style={{ color: '#2AA876' }}>✓</span> <span style={{ color: '#A39C92' }}>tokens_used</span>: <span>1,847</span><br/>
|
||||||
|
<span style={{ color: '#A39C92' }}>next_run</span>: <span>2026-04-26T09:00:00Z</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
// read
|
||||||
|
return (
|
||||||
|
<div style={{
|
||||||
|
background: 'var(--bg-card)', borderRadius: 7,
|
||||||
|
padding: '8px 12px', fontFamily: 'var(--font-mono)', fontSize: 11.5,
|
||||||
|
marginTop: 6, lineHeight: 1.6, color: 'var(--fg-muted)',
|
||||||
|
border: '0.5px solid var(--border)', maxHeight: 120, overflow: 'auto',
|
||||||
|
}}>
|
||||||
|
<div><span style={{ color: 'var(--fg-faint)' }}>1</span> {</div>
|
||||||
|
<div><span style={{ color: 'var(--fg-faint)' }}>2</span> "name": "daily-summary",</div>
|
||||||
|
<div><span style={{ color: 'var(--fg-faint)' }}>3</span> "schedule": "0 9 * * *",</div>
|
||||||
|
<div><span style={{ color: 'var(--fg-faint)' }}>4</span> "enabled": true</div>
|
||||||
|
<div><span style={{ color: 'var(--fg-faint)' }}>5</span> }</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function DiffPreview() {
|
||||||
|
return (
|
||||||
|
<div style={{
|
||||||
|
background: 'var(--bg-card)', borderRadius: 7,
|
||||||
|
padding: '8px 12px', fontFamily: 'var(--font-mono)', fontSize: 11.5,
|
||||||
|
marginTop: 6, lineHeight: 1.6, color: 'var(--fg)',
|
||||||
|
border: '0.5px solid var(--border)',
|
||||||
|
}}>
|
||||||
|
<div><span style={{ color: 'var(--fg-faint)', display: 'inline-block', width: 22 }}>3</span><span> "schedule": "0 9 * * *",</span></div>
|
||||||
|
<div style={{ background: 'rgba(217, 83, 79, 0.10)' }}>
|
||||||
|
<span style={{ color: 'var(--red-600)', display: 'inline-block', width: 22 }}>-</span>
|
||||||
|
<span> "timezone": "UTC",</span>
|
||||||
|
</div>
|
||||||
|
<div style={{ background: 'rgba(42, 168, 118, 0.10)' }}>
|
||||||
|
<span style={{ color: 'var(--green-600)', display: 'inline-block', width: 22 }}>+</span>
|
||||||
|
<span> "timezone": "America/New_York",</span>
|
||||||
|
</div>
|
||||||
|
<div><span style={{ color: 'var(--fg-faint)', display: 'inline-block', width: 22 }}>5</span><span> "enabled": true</span></div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─────────────── Suggested replies ───────────────
|
||||||
|
function SuggestedReplies({ items }) {
|
||||||
|
return (
|
||||||
|
<div style={{ display: 'flex', gap: 6, flexWrap: 'wrap', marginTop: 4, paddingLeft: 36 }}>
|
||||||
|
{items.map(s => (
|
||||||
|
<button key={s} style={{
|
||||||
|
fontSize: 12, padding: '5px 10px', borderRadius: 999,
|
||||||
|
background: 'var(--bg-card)', border: '0.5px solid var(--border-strong)',
|
||||||
|
color: 'var(--fg)', fontFamily: 'var(--font-sans)', cursor: 'pointer',
|
||||||
|
display: 'inline-flex', alignItems: 'center', gap: 4,
|
||||||
|
}}>
|
||||||
|
<i data-lucide="sparkles" style={{ width: 11, height: 11, color: 'var(--accent)' }}></i>
|
||||||
|
{s}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─────────────── Composer ───────────────
|
||||||
|
const SLASH_COMMANDS = [
|
||||||
|
{ cmd: 'compress', desc: 'Compress conversation context', icon: 'minimize-2' },
|
||||||
|
{ cmd: 'clear', desc: 'Clear and start fresh', icon: 'trash-2' },
|
||||||
|
{ cmd: 'model', desc: 'Switch model', icon: 'cpu' },
|
||||||
|
{ cmd: 'project', desc: 'Change project', icon: 'folder' },
|
||||||
|
{ cmd: 'memory', desc: 'Edit AGENTS.md', icon: 'database' },
|
||||||
|
{ cmd: 'cost', desc: 'Show token / cost report', icon: 'circle-dollar-sign' },
|
||||||
|
];
|
||||||
|
|
||||||
|
function Composer({ open, setOpen }) {
|
||||||
|
const [text, setText] = React.useState('');
|
||||||
|
const onChange = e => {
|
||||||
|
const v = e.currentTarget.innerText;
|
||||||
|
setText(v);
|
||||||
|
setOpen(v.trim().startsWith('/'));
|
||||||
|
};
|
||||||
|
return (
|
||||||
|
<div style={{
|
||||||
|
borderTop: '0.5px solid var(--border)', padding: '12px 24px 14px',
|
||||||
|
background: 'var(--bg-card)', position: 'relative',
|
||||||
|
}}>
|
||||||
|
{open && (
|
||||||
|
<div style={{
|
||||||
|
position: 'absolute', bottom: 'calc(100% - 4px)', left: 24, right: 24,
|
||||||
|
background: 'var(--bg-card)', border: '0.5px solid var(--border)',
|
||||||
|
borderRadius: 9, boxShadow: 'var(--shadow-lg)', padding: 4, maxWidth: 360,
|
||||||
|
}}>
|
||||||
|
<div style={{ padding: '4px 8px 6px', fontSize: 10, fontWeight: 600,
|
||||||
|
color: 'var(--fg-muted)', textTransform: 'uppercase', letterSpacing: '0.06em' }}>
|
||||||
|
Slash commands
|
||||||
|
</div>
|
||||||
|
{SLASH_COMMANDS.map((c, i) => (
|
||||||
|
<div key={c.cmd} style={{
|
||||||
|
display: 'flex', alignItems: 'center', gap: 9, padding: '6px 8px',
|
||||||
|
borderRadius: 6, fontSize: 13, cursor: 'pointer',
|
||||||
|
background: i === 0 ? 'var(--accent-tint)' : 'transparent',
|
||||||
|
color: i === 0 ? 'var(--accent-active)' : 'var(--fg)',
|
||||||
|
}}>
|
||||||
|
<i data-lucide={c.icon} style={{ width: 14, height: 14 }}></i>
|
||||||
|
<span style={{ fontFamily: 'var(--font-mono)', fontWeight: 600 }}>/{c.cmd}</span>
|
||||||
|
<span style={{ flex: 1, color: 'var(--fg-muted)', fontSize: 12 }}>{c.desc}</span>
|
||||||
|
{i === 0 && <KbdKey>↵</KbdKey>}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div style={{
|
||||||
|
display: 'flex', flexDirection: 'column', gap: 8,
|
||||||
|
border: `1px solid ${open ? 'var(--accent)' : 'var(--border-strong)'}`,
|
||||||
|
borderRadius: 12, padding: '10px 12px',
|
||||||
|
background: 'var(--bg-card)',
|
||||||
|
boxShadow: open ? 'var(--shadow-focus)' : 'none',
|
||||||
|
transition: 'box-shadow 120ms, border-color 120ms',
|
||||||
|
}}>
|
||||||
|
{/* Attached context chips */}
|
||||||
|
<div style={{ display: 'flex', gap: 6, flexWrap: 'wrap' }}>
|
||||||
|
<ContextChip icon="folder" label="scarf" tone="accent" />
|
||||||
|
<ContextChip icon="file-text" label="cron/jobs.json" />
|
||||||
|
<ContextChip icon="plus" label="Add context" muted />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Input */}
|
||||||
|
<div contentEditable suppressContentEditableWarning onInput={onChange}
|
||||||
|
style={{
|
||||||
|
fontSize: 14, fontFamily: 'var(--font-sans)', outline: 'none',
|
||||||
|
color: 'var(--fg)', padding: '2px 0', minHeight: 22, maxHeight: 160, overflowY: 'auto',
|
||||||
|
lineHeight: 1.5,
|
||||||
|
}}
|
||||||
|
data-placeholder="Message Hermes… / for commands · @ for files"></div>
|
||||||
|
|
||||||
|
{/* Footer row */}
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
|
||||||
|
<ComposerChip icon="paperclip" label="" />
|
||||||
|
<ComposerChip icon="at-sign" label="@" />
|
||||||
|
<ComposerChip icon="image" label="" />
|
||||||
|
<Divider vertical />
|
||||||
|
<ComposerChip icon="cpu" label="sonnet-4.5" />
|
||||||
|
<ComposerChip icon="folder" label="scarf" />
|
||||||
|
|
||||||
|
<div style={{ flex: 1 }}></div>
|
||||||
|
|
||||||
|
<span style={{ fontSize: 11, color: 'var(--fg-faint)', fontFamily: 'var(--font-mono)' }}>
|
||||||
|
↵ send · ⇧↵ newline
|
||||||
|
</span>
|
||||||
|
<button style={{
|
||||||
|
width: 30, height: 30, borderRadius: 8, background: 'var(--accent)',
|
||||||
|
color: '#fff', border: 'none', cursor: 'pointer',
|
||||||
|
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||||
|
boxShadow: '0 1px 2px rgba(122, 46, 20, 0.3)',
|
||||||
|
}}>
|
||||||
|
<i data-lucide="arrow-up" style={{ width: 15, height: 15 }}></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ContextChip({ icon, label, tone, muted }) {
|
||||||
|
return (
|
||||||
|
<div style={{
|
||||||
|
display: 'inline-flex', alignItems: 'center', gap: 5,
|
||||||
|
padding: '2px 8px', borderRadius: 999, fontSize: 11, fontWeight: 500,
|
||||||
|
background: tone === 'accent' ? 'var(--accent-tint)' : 'var(--bg-quaternary)',
|
||||||
|
color: tone === 'accent' ? 'var(--accent-active)' : (muted ? 'var(--fg-muted)' : 'var(--fg)'),
|
||||||
|
fontFamily: tone === 'accent' ? 'var(--font-sans)' : 'var(--font-mono)',
|
||||||
|
border: muted ? '0.5px dashed var(--border-strong)' : 'none',
|
||||||
|
cursor: muted ? 'pointer' : 'default',
|
||||||
|
}}>
|
||||||
|
<i data-lucide={icon} style={{ width: 11, height: 11 }}></i>{label}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ComposerChip({ icon, label }) {
|
||||||
|
const [hover, setHover] = React.useState(false);
|
||||||
|
return (
|
||||||
|
<button onMouseEnter={() => setHover(true)} onMouseLeave={() => setHover(false)} style={{
|
||||||
|
display: 'inline-flex', alignItems: 'center', gap: 4,
|
||||||
|
padding: label ? '3px 7px' : '4px', borderRadius: 6, fontSize: 12,
|
||||||
|
background: hover ? 'var(--bg-quaternary)' : 'transparent',
|
||||||
|
color: 'var(--fg-muted)', border: 'none', cursor: 'pointer',
|
||||||
|
fontFamily: 'var(--font-mono)',
|
||||||
|
}}>
|
||||||
|
<i data-lucide={icon} style={{ width: 13, height: 13 }}></i>{label}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─────────────── Pane 3 — Inspector ───────────────
|
||||||
|
function Inspector({ focused }) {
|
||||||
|
const [tab, setTab] = React.useState('details');
|
||||||
|
// Find the focused tool call. For demo, hard-code tc-2 details.
|
||||||
|
const FOCUS_DATA = {
|
||||||
|
'tc-1': { kind: 'read', name: 'read_file', arg: '~/.scarf/cron/jobs.json',
|
||||||
|
duration: '86 ms', startedAt: '09:42:18.214', tokens: 412 },
|
||||||
|
'tc-2': { kind: 'execute', name: 'execute', arg: 'hermes cron status daily-summary',
|
||||||
|
duration: '1.4 s', startedAt: '09:42:18.302', tokens: 86,
|
||||||
|
cwd: '~/.scarf', exitCode: 0 },
|
||||||
|
'tc-3': { kind: 'read', name: 'read_file', arg: '~/.scarf/cron/output/daily-summary.md',
|
||||||
|
duration: '42 ms', startedAt: '09:43:01.190', tokens: 1284 },
|
||||||
|
'tc-4': { kind: 'edit', name: 'apply_patch', arg: '~/.scarf/cron/jobs.json',
|
||||||
|
duration: '120 ms', startedAt: '09:43:03.910', tokens: 88, linesAdded: 1, linesRemoved: 1 },
|
||||||
|
};
|
||||||
|
const data = FOCUS_DATA[focused.id] || FOCUS_DATA['tc-2'];
|
||||||
|
const t = TOOL_TONES[data.kind];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<aside style={{
|
||||||
|
width: 320, borderLeft: '0.5px solid var(--border)',
|
||||||
|
background: 'var(--bg-card)', display: 'flex', flexDirection: 'column',
|
||||||
|
}}>
|
||||||
|
{/* Header */}
|
||||||
|
<div style={{ padding: '14px 16px 10px', borderBottom: '0.5px solid var(--border)' }}>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 10 }}>
|
||||||
|
<div style={{
|
||||||
|
width: 24, height: 24, borderRadius: 6,
|
||||||
|
background: t.tint, color: t.color,
|
||||||
|
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||||
|
}}>
|
||||||
|
<i data-lucide={t.icon} style={{ width: 13, height: 13 }}></i>
|
||||||
|
</div>
|
||||||
|
<div style={{ flex: 1, minWidth: 0 }}>
|
||||||
|
<div style={{ fontSize: 10, fontWeight: 700, color: t.color,
|
||||||
|
textTransform: 'uppercase', letterSpacing: '0.05em' }}>{t.label} call</div>
|
||||||
|
<div style={{ fontSize: 13, fontWeight: 600, fontFamily: 'var(--font-mono)',
|
||||||
|
whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>{data.name}</div>
|
||||||
|
</div>
|
||||||
|
<IconBtn icon="x" tooltip="Close inspector" />
|
||||||
|
</div>
|
||||||
|
<Tabs value={tab} onChange={setTab} options={[
|
||||||
|
{ value: 'details', label: 'Details', icon: 'info' },
|
||||||
|
{ value: 'output', label: 'Output', icon: 'terminal' },
|
||||||
|
{ value: 'raw', label: 'Raw', icon: 'braces' },
|
||||||
|
]} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ flex: 1, overflowY: 'auto', padding: 16 }}>
|
||||||
|
{tab === 'details' && <InspectorDetails data={data} t={t} />}
|
||||||
|
{tab === 'output' && <InspectorOutput data={data} t={t} />}
|
||||||
|
{tab === 'raw' && <InspectorRaw data={data} />}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Footer */}
|
||||||
|
<div style={{ padding: '10px 16px', borderTop: '0.5px solid var(--border)',
|
||||||
|
display: 'flex', gap: 6 }}>
|
||||||
|
<Btn size="sm" kind="secondary" icon="rotate-cw" fullWidth>Re-run</Btn>
|
||||||
|
<Btn size="sm" kind="ghost" icon="copy">Copy</Btn>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function InspectorDetails({ data, t }) {
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<Section title="Status">
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 8, padding: '8px 10px',
|
||||||
|
background: 'var(--green-100)', borderRadius: 7,
|
||||||
|
border: '0.5px solid rgba(42, 168, 118, 0.25)' }}>
|
||||||
|
<i data-lucide="check-circle-2" style={{ width: 16, height: 16, color: 'var(--green-600)' }}></i>
|
||||||
|
<div style={{ flex: 1 }}>
|
||||||
|
<div style={{ fontSize: 12, fontWeight: 600, color: 'var(--green-600)' }}>Completed</div>
|
||||||
|
<div style={{ fontSize: 11, color: 'var(--fg-muted)' }}>Exit 0 · No errors</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Section>
|
||||||
|
|
||||||
|
<div style={{ marginTop: 18 }}>
|
||||||
|
<Section title="Arguments">
|
||||||
|
<div style={{
|
||||||
|
background: 'var(--bg-quaternary)', borderRadius: 7, padding: '8px 10px',
|
||||||
|
fontFamily: 'var(--font-mono)', fontSize: 11.5, lineHeight: 1.5,
|
||||||
|
color: 'var(--fg)', wordBreak: 'break-all',
|
||||||
|
}}>{data.arg}</div>
|
||||||
|
</Section>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ marginTop: 18 }}>
|
||||||
|
<Section title="Telemetry">
|
||||||
|
<KV k="Started" v={data.startedAt} mono />
|
||||||
|
<KV k="Duration" v={data.duration} mono />
|
||||||
|
<KV k="Tokens" v={data.tokens.toLocaleString()} mono />
|
||||||
|
{data.exitCode != null && <KV k="Exit code" v={data.exitCode} mono color="var(--green-600)" />}
|
||||||
|
{data.cwd && <KV k="CWD" v={data.cwd} mono />}
|
||||||
|
{data.linesAdded != null && (
|
||||||
|
<KV k="Diff" v={
|
||||||
|
<span style={{ fontFamily: 'var(--font-mono)' }}>
|
||||||
|
<span style={{ color: 'var(--green-600)' }}>+{data.linesAdded}</span>
|
||||||
|
<span style={{ color: 'var(--fg-faint)' }}> / </span>
|
||||||
|
<span style={{ color: 'var(--red-600)' }}>−{data.linesRemoved}</span>
|
||||||
|
</span>
|
||||||
|
} />
|
||||||
|
)}
|
||||||
|
</Section>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ marginTop: 18 }}>
|
||||||
|
<Section title="Permissions" hint="Tool gateway policy applied at run time">
|
||||||
|
<div style={{
|
||||||
|
background: 'var(--bg-quaternary)', borderRadius: 7, padding: '10px',
|
||||||
|
fontSize: 12, color: 'var(--fg-muted)', display: 'flex', flexDirection: 'column', gap: 6,
|
||||||
|
}}>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
|
||||||
|
<i data-lucide="shield-check" style={{ width: 13, height: 13, color: 'var(--green-500)' }}></i>
|
||||||
|
<span>Allowed by <code style={inlineCode}>scarf-default</code> profile</span>
|
||||||
|
</div>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
|
||||||
|
<i data-lucide="check" style={{ width: 13, height: 13, color: 'var(--green-500)' }}></i>
|
||||||
|
<span>No human approval required</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Section>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function InspectorOutput({ data, t }) {
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<Section title="stdout" right={<KbdKey>⌘C</KbdKey>}>
|
||||||
|
<div style={{
|
||||||
|
background: 'var(--gray-900)', color: '#E8E1D2', borderRadius: 7,
|
||||||
|
padding: '10px 12px', fontFamily: 'var(--font-mono)', fontSize: 11,
|
||||||
|
lineHeight: 1.6, overflow: 'auto',
|
||||||
|
}}>
|
||||||
|
<div><span style={{ color: '#7A7367' }}>$</span> <span style={{ color: '#EFC59E' }}>hermes</span> cron status daily-summary</div>
|
||||||
|
<div style={{ marginTop: 6 }}>
|
||||||
|
<span style={{ color: '#2AA876' }}>✓</span> last_run: 2026-04-25T09:28:14Z<br/>
|
||||||
|
<span style={{ color: '#2AA876' }}>✓</span> duration: 14.2s<br/>
|
||||||
|
<span style={{ color: '#2AA876' }}>✓</span> exit_code: 0<br/>
|
||||||
|
<span style={{ color: '#2AA876' }}>✓</span> tokens_used: 1,847<br/>
|
||||||
|
next_run: 2026-04-26T09:00:00Z<br/>
|
||||||
|
schedule: 0 9 * * *<br/>
|
||||||
|
timezone: America/New_York
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Section>
|
||||||
|
<div style={{ marginTop: 16 }}>
|
||||||
|
<Section title="stderr">
|
||||||
|
<div style={{ background: 'var(--bg-quaternary)', borderRadius: 7, padding: '10px',
|
||||||
|
fontFamily: 'var(--font-mono)', fontSize: 11.5, color: 'var(--fg-faint)' }}>
|
||||||
|
(empty)
|
||||||
|
</div>
|
||||||
|
</Section>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function InspectorRaw({ data }) {
|
||||||
|
return (
|
||||||
|
<div style={{
|
||||||
|
background: 'var(--gray-900)', color: '#E8E1D2', borderRadius: 7,
|
||||||
|
padding: '12px', fontFamily: 'var(--font-mono)', fontSize: 11,
|
||||||
|
lineHeight: 1.55,
|
||||||
|
}}>
|
||||||
|
{`{
|
||||||
|
"id": "${data.kind === 'execute' ? 'tc-2' : 'tc-x'}",
|
||||||
|
"type": "tool_use",
|
||||||
|
"name": "${data.name}",
|
||||||
|
"input": {
|
||||||
|
"command": "hermes cron status daily-summary",
|
||||||
|
"cwd": "~/.scarf"
|
||||||
|
},
|
||||||
|
"result": {
|
||||||
|
"exit_code": 0,
|
||||||
|
"duration_ms": 1402,
|
||||||
|
"stdout_bytes": 287
|
||||||
|
}
|
||||||
|
}`}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function KV({ k, v, mono, color }) {
|
||||||
|
return (
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', padding: '5px 0',
|
||||||
|
borderBottom: '0.5px solid var(--border)' }}>
|
||||||
|
<span style={{ fontSize: 12, color: 'var(--fg-muted)', flex: '0 0 90px' }}>{k}</span>
|
||||||
|
<span style={{
|
||||||
|
fontSize: 12, color: color || 'var(--fg)',
|
||||||
|
fontFamily: mono ? 'var(--font-mono)' : 'var(--font-sans)', flex: 1, textAlign: 'right',
|
||||||
|
}}>{v}</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
window.Chat = Chat;
|
||||||
@@ -0,0 +1,550 @@
|
|||||||
|
// Scarf v2 shared components — calmer density, full state matrices.
|
||||||
|
// Exports to window: Btn, IconBtn, Pill, Dot, Card, StatCard, Section, ContentHeader,
|
||||||
|
// Field, TextInput, NumberInput, TextArea, Toggle, Checkbox, Radio, RadioGroup,
|
||||||
|
// Segmented, Select, SettingsGroup, SettingsRow, Tabs, Menu, MenuItem, Divider,
|
||||||
|
// EmptyState, KbdKey, HelpIcon, Tooltip, Avatar, ProgressBar, Spinner.
|
||||||
|
|
||||||
|
const SF = "var(--font-sans)";
|
||||||
|
|
||||||
|
// ─────────────── ContentHeader ───────────────
|
||||||
|
function ContentHeader({ title, subtitle, actions, right, breadcrumb }) {
|
||||||
|
return (
|
||||||
|
<div style={{
|
||||||
|
padding: '24px 32px 22px',
|
||||||
|
borderBottom: '0.5px solid var(--border)',
|
||||||
|
background: 'var(--bg-card)',
|
||||||
|
}}>
|
||||||
|
{breadcrumb && (
|
||||||
|
<div style={{ fontSize: 12, color: 'var(--fg-muted)', marginBottom: 6 }}>{breadcrumb}</div>
|
||||||
|
)}
|
||||||
|
<div style={{ display: 'flex', alignItems: 'flex-end', gap: 16 }}>
|
||||||
|
<div style={{ flex: 1 }}>
|
||||||
|
<div className="scarf-h2" style={{ marginBottom: subtitle ? 6 : 0 }}>{title}</div>
|
||||||
|
{subtitle && <div style={{ fontSize: 14, color: 'var(--fg-muted)', maxWidth: 600 }}>{subtitle}</div>}
|
||||||
|
</div>
|
||||||
|
{right}
|
||||||
|
{actions && <div style={{ display: 'flex', gap: 8 }}>{actions}</div>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─────────────── Buttons ───────────────
|
||||||
|
function Btn({ kind = 'secondary', size = 'md', icon, iconRight, children, onClick, disabled, loading, fullWidth, type = 'button' }) {
|
||||||
|
const sizes = {
|
||||||
|
sm: { padding: '5px 11px', fontSize: 12, gap: 5, iconSize: 13 },
|
||||||
|
md: { padding: '7px 14px', fontSize: 13, gap: 6, iconSize: 14 },
|
||||||
|
lg: { padding: '10px 18px', fontSize: 14, gap: 7, iconSize: 16 },
|
||||||
|
};
|
||||||
|
const kinds = {
|
||||||
|
primary: { background: 'var(--accent)', color: 'var(--on-accent)', border: '1px solid transparent', shadow: '0 1px 0 rgba(0,0,0,0.08), inset 0 1px 0 rgba(255,255,255,0.18)' },
|
||||||
|
secondary: { background: 'var(--bg-card)', color: 'var(--fg)', border: '1px solid var(--border-strong)', shadow: 'var(--shadow-sm)' },
|
||||||
|
ghost: { background: 'transparent', color: 'var(--fg)', border: '1px solid transparent' },
|
||||||
|
danger: { background: 'var(--bg-card)', color: 'var(--red-600)', border: '1px solid var(--red-500)' },
|
||||||
|
'danger-solid': { background: 'var(--red-500)', color: '#fff', border: '1px solid transparent' },
|
||||||
|
accent: { background: 'var(--accent-tint)', color: 'var(--accent-active)', border: '1px solid transparent' },
|
||||||
|
};
|
||||||
|
const s = sizes[size];
|
||||||
|
const k = kinds[kind];
|
||||||
|
const [hover, setHover] = React.useState(false);
|
||||||
|
|
||||||
|
const hoverStyle = !disabled && hover ? {
|
||||||
|
primary: { background: 'var(--accent-hover)' },
|
||||||
|
secondary: { background: 'var(--gray-50)', borderColor: 'var(--accent)' },
|
||||||
|
ghost: { background: 'var(--bg-quaternary)' },
|
||||||
|
danger: { background: 'var(--red-100)' },
|
||||||
|
'danger-solid': { background: 'var(--red-600)' },
|
||||||
|
accent: { background: 'var(--accent-tint-strong)' },
|
||||||
|
}[kind] : {};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button type={type} onClick={onClick} disabled={disabled || loading}
|
||||||
|
onMouseEnter={() => setHover(true)} onMouseLeave={() => setHover(false)}
|
||||||
|
style={{
|
||||||
|
padding: s.padding, fontSize: s.fontSize, gap: s.gap,
|
||||||
|
...k, ...hoverStyle, boxShadow: k.shadow,
|
||||||
|
borderRadius: 8, fontFamily: SF, fontWeight: 500,
|
||||||
|
display: fullWidth ? 'flex' : 'inline-flex', alignItems: 'center', justifyContent: 'center',
|
||||||
|
cursor: (disabled || loading) ? 'default' : 'pointer',
|
||||||
|
opacity: disabled ? 0.45 : 1,
|
||||||
|
width: fullWidth ? '100%' : 'auto',
|
||||||
|
transition: 'all 120ms var(--ease-smooth)',
|
||||||
|
whiteSpace: 'nowrap', userSelect: 'none',
|
||||||
|
}}>
|
||||||
|
{loading
|
||||||
|
? <Spinner size={s.iconSize} color={kind === 'primary' ? 'rgba(255,255,255,0.7)' : 'currentColor'} />
|
||||||
|
: icon && <i data-lucide={icon} style={{ width: s.iconSize, height: s.iconSize }}></i>}
|
||||||
|
{children}
|
||||||
|
{iconRight && <i data-lucide={iconRight} style={{ width: s.iconSize, height: s.iconSize, opacity: 0.7 }}></i>}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function IconBtn({ icon, onClick, size = 28, tooltip, active, disabled }) {
|
||||||
|
const [hover, setHover] = React.useState(false);
|
||||||
|
return (
|
||||||
|
<button onClick={onClick} disabled={disabled} title={tooltip}
|
||||||
|
onMouseEnter={() => setHover(true)} onMouseLeave={() => setHover(false)}
|
||||||
|
style={{
|
||||||
|
width: size, height: size, padding: 0, borderRadius: 7,
|
||||||
|
background: active ? 'var(--accent-tint)' : (hover && !disabled ? 'var(--bg-quaternary)' : 'transparent'),
|
||||||
|
color: active ? 'var(--accent-active)' : 'var(--fg-muted)',
|
||||||
|
border: 'none', cursor: disabled ? 'default' : 'pointer',
|
||||||
|
display: 'inline-flex', alignItems: 'center', justifyContent: 'center',
|
||||||
|
opacity: disabled ? 0.45 : 1, transition: 'background 120ms',
|
||||||
|
}}>
|
||||||
|
<i data-lucide={icon} style={{ width: Math.round(size * 0.55), height: Math.round(size * 0.55) }}></i>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Spinner({ size = 14, color = 'currentColor' }) {
|
||||||
|
return (
|
||||||
|
<span style={{
|
||||||
|
display: 'inline-block', width: size, height: size,
|
||||||
|
border: `2px solid transparent`, borderTopColor: color, borderRightColor: color,
|
||||||
|
borderRadius: '50%', animation: 'scarfSpin 0.8s linear infinite',
|
||||||
|
}}></span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─────────────── Pills / Dots ───────────────
|
||||||
|
function Pill({ tone = 'gray', dot, icon, children, size = 'md' }) {
|
||||||
|
const tones = {
|
||||||
|
gray: { bg: 'var(--bg-quaternary)', fg: 'var(--fg-muted)', dotc: 'var(--gray-500)' },
|
||||||
|
green: { bg: 'var(--green-100)', fg: 'var(--green-600)', dotc: 'var(--green-500)' },
|
||||||
|
red: { bg: 'var(--red-100)', fg: 'var(--red-600)', dotc: 'var(--red-500)' },
|
||||||
|
orange: { bg: 'var(--orange-100)', fg: '#A8741F', dotc: 'var(--orange-500)' },
|
||||||
|
blue: { bg: 'var(--blue-100)', fg: '#1F70A8', dotc: 'var(--blue-500)' },
|
||||||
|
accent: { bg: 'var(--accent-tint)', fg: 'var(--accent-active)', dotc: 'var(--accent)' },
|
||||||
|
amber: { bg: 'var(--orange-100)', fg: '#A8741F', dotc: 'var(--orange-500)' },
|
||||||
|
purple: { bg: '#EFE0F8', fg: '#5E4080', dotc: '#7E5BA9' },
|
||||||
|
idle: { bg: 'var(--bg-quaternary)', fg: 'var(--fg-faint)', dotc: 'var(--gray-400)' },
|
||||||
|
};
|
||||||
|
const t = tones[tone];
|
||||||
|
const sizes = { sm: { p: '2px 7px', f: 10 }, md: { p: '3px 9px', f: 11 }, lg: { p: '4px 11px', f: 12 } };
|
||||||
|
const sz = sizes[size];
|
||||||
|
return (
|
||||||
|
<span style={{
|
||||||
|
display: 'inline-flex', alignItems: 'center', gap: 5,
|
||||||
|
fontSize: sz.f, fontWeight: 600, padding: sz.p, borderRadius: 999,
|
||||||
|
background: t.bg, color: t.fg, fontFamily: SF, lineHeight: 1.4,
|
||||||
|
}}>
|
||||||
|
{dot && <span style={{ width: 6, height: 6, borderRadius: '50%', background: t.dotc }}></span>}
|
||||||
|
{icon && <i data-lucide={icon} style={{ width: 11, height: 11 }}></i>}
|
||||||
|
{children}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Dot({ tone = 'gray', size = 8 }) {
|
||||||
|
const tones = { gray: 'var(--gray-400)', green: 'var(--green-500)', red: 'var(--red-500)',
|
||||||
|
orange: 'var(--orange-500)', blue: 'var(--blue-500)', accent: 'var(--accent)' };
|
||||||
|
return <span style={{ width: size, height: size, borderRadius: '50%',
|
||||||
|
background: tones[tone], display: 'inline-block', flexShrink: 0 }}></span>;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─────────────── Cards / Sections ───────────────
|
||||||
|
function Card({ children, padding = 18, style = {}, onClick, interactive }) {
|
||||||
|
return (
|
||||||
|
<div onClick={onClick} style={{
|
||||||
|
background: 'var(--bg-card)', borderRadius: 10,
|
||||||
|
border: '0.5px solid var(--border)',
|
||||||
|
boxShadow: 'var(--shadow-sm)',
|
||||||
|
padding, cursor: onClick || interactive ? 'pointer' : 'default',
|
||||||
|
transition: 'all 160ms var(--ease-smooth)',
|
||||||
|
...style,
|
||||||
|
}}>{children}</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function StatCard({ label, value, sub, accent, icon }) {
|
||||||
|
return (
|
||||||
|
<Card padding={16} style={{ flex: 1, minWidth: 0 }}>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 6, fontSize: 11,
|
||||||
|
color: 'var(--fg-muted)', fontWeight: 600, marginBottom: 8,
|
||||||
|
textTransform: 'uppercase', letterSpacing: '0.05em' }}>
|
||||||
|
{icon && <i data-lucide={icon} style={{ width: 12, height: 12 }}></i>}
|
||||||
|
{label}
|
||||||
|
</div>
|
||||||
|
<div style={{ fontFamily: 'var(--font-mono)', fontSize: 24, fontWeight: 600,
|
||||||
|
color: accent || 'var(--fg)', letterSpacing: '-0.01em', lineHeight: 1.1 }}>{value}</div>
|
||||||
|
{sub && <div style={{ fontSize: 11, color: 'var(--fg-faint)', marginTop: 6 }}>{sub}</div>}
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Section({ title, hint, right, children, gap = 12 }) {
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'baseline', marginBottom: gap, gap: 10 }}>
|
||||||
|
<div style={{ fontSize: 11, fontWeight: 600, color: 'var(--fg-muted)',
|
||||||
|
textTransform: 'uppercase', letterSpacing: '0.06em' }}>{title}</div>
|
||||||
|
{hint && <div style={{ fontSize: 12, color: 'var(--fg-faint)' }}>{hint}</div>}
|
||||||
|
<div style={{ marginLeft: 'auto' }}>{right}</div>
|
||||||
|
</div>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Divider({ vertical, label }) {
|
||||||
|
if (vertical) return <div style={{ width: 1, alignSelf: 'stretch', background: 'var(--border)' }}></div>;
|
||||||
|
if (label) return (
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 10, color: 'var(--fg-faint)', margin: '8px 0' }}>
|
||||||
|
<div style={{ flex: 1, height: 1, background: 'var(--border)' }}></div>
|
||||||
|
<span style={{ fontSize: 10, fontWeight: 600, textTransform: 'uppercase', letterSpacing: '0.06em' }}>{label}</span>
|
||||||
|
<div style={{ flex: 1, height: 1, background: 'var(--border)' }}></div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
return <div style={{ height: 1, background: 'var(--border)', margin: '8px 0' }}></div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─────────────── Form fields ───────────────
|
||||||
|
function Field({ label, hint, error, help, children, required, inline }) {
|
||||||
|
return (
|
||||||
|
<label style={{ display: 'flex', flexDirection: inline ? 'row' : 'column',
|
||||||
|
gap: inline ? 12 : 6, fontFamily: SF, alignItems: inline ? 'center' : 'stretch' }}>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 5,
|
||||||
|
minWidth: inline ? 140 : 0 }}>
|
||||||
|
<span style={{ fontSize: 13, color: 'var(--fg)', fontWeight: 500 }}>{label}</span>
|
||||||
|
{required && <span style={{ color: 'var(--red-500)', fontSize: 11 }}>*</span>}
|
||||||
|
{help && <HelpIcon text={help} />}
|
||||||
|
</div>
|
||||||
|
<div style={{ flex: inline ? 1 : 'none', display: 'flex', flexDirection: 'column', gap: 4 }}>
|
||||||
|
{children}
|
||||||
|
{error
|
||||||
|
? <span style={{ fontSize: 11, color: 'var(--red-600)', display: 'flex', alignItems: 'center', gap: 4 }}>
|
||||||
|
<i data-lucide="alert-circle" style={{ width: 11, height: 11 }}></i>{error}
|
||||||
|
</span>
|
||||||
|
: hint && <span style={{ fontSize: 11, color: 'var(--fg-faint)' }}>{hint}</span>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function HelpIcon({ text }) {
|
||||||
|
return (
|
||||||
|
<span title={text} style={{
|
||||||
|
display: 'inline-flex', alignItems: 'center', justifyContent: 'center',
|
||||||
|
width: 14, height: 14, borderRadius: '50%', background: 'var(--bg-tertiary)',
|
||||||
|
color: 'var(--fg-muted)', cursor: 'help',
|
||||||
|
}}>
|
||||||
|
<i data-lucide="help-circle" style={{ width: 11, height: 11 }}></i>
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function inputStyle(invalid) {
|
||||||
|
return {
|
||||||
|
fontFamily: SF, fontSize: 13, padding: '7px 11px',
|
||||||
|
border: `1px solid ${invalid ? 'var(--red-500)' : 'var(--border-strong)'}`,
|
||||||
|
borderRadius: 7, background: 'var(--bg-card)', color: 'var(--fg)',
|
||||||
|
outline: 'none', transition: 'all 120ms', width: '100%', boxSizing: 'border-box',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function TextInput({ value, onChange, placeholder, mono, invalid, leftIcon, rightSlot, type = 'text' }) {
|
||||||
|
const [v, setV] = React.useState(value ?? '');
|
||||||
|
React.useEffect(() => setV(value ?? ''), [value]);
|
||||||
|
const ref = React.useRef();
|
||||||
|
return (
|
||||||
|
<div style={{ position: 'relative', display: 'flex', alignItems: 'center' }}>
|
||||||
|
{leftIcon && <i data-lucide={leftIcon} style={{
|
||||||
|
position: 'absolute', left: 10, width: 14, height: 14, color: 'var(--fg-faint)', pointerEvents: 'none'
|
||||||
|
}}></i>}
|
||||||
|
<input ref={ref} type={type} value={v}
|
||||||
|
onChange={e => { setV(e.target.value); onChange && onChange(e.target.value); }}
|
||||||
|
placeholder={placeholder}
|
||||||
|
style={{ ...inputStyle(invalid),
|
||||||
|
fontFamily: mono ? 'var(--font-mono)' : SF,
|
||||||
|
paddingLeft: leftIcon ? 32 : 11,
|
||||||
|
paddingRight: rightSlot ? 36 : 11,
|
||||||
|
}}
|
||||||
|
onFocus={e => { if (!invalid) { e.target.style.borderColor = 'var(--accent)'; e.target.style.boxShadow = 'var(--shadow-focus)'; }}}
|
||||||
|
onBlur={e => { e.target.style.borderColor = invalid ? 'var(--red-500)' : 'var(--border-strong)'; e.target.style.boxShadow = 'none'; }}
|
||||||
|
/>
|
||||||
|
{rightSlot && <div style={{ position: 'absolute', right: 6 }}>{rightSlot}</div>}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function TextArea({ value, onChange, placeholder, rows = 3, invalid, mono }) {
|
||||||
|
const [v, setV] = React.useState(value ?? '');
|
||||||
|
React.useEffect(() => setV(value ?? ''), [value]);
|
||||||
|
return (
|
||||||
|
<textarea value={v} rows={rows} placeholder={placeholder}
|
||||||
|
onChange={e => { setV(e.target.value); onChange && onChange(e.target.value); }}
|
||||||
|
style={{ ...inputStyle(invalid), resize: 'vertical', lineHeight: 1.45,
|
||||||
|
fontFamily: mono ? 'var(--font-mono)' : SF }}
|
||||||
|
onFocus={e => { if (!invalid) { e.target.style.borderColor = 'var(--accent)'; e.target.style.boxShadow = 'var(--shadow-focus)'; }}}
|
||||||
|
onBlur={e => { e.target.style.borderColor = invalid ? 'var(--red-500)' : 'var(--border-strong)'; e.target.style.boxShadow = 'none'; }}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Select({ value, onChange, options }) {
|
||||||
|
const [v, setV] = React.useState(value ?? options?.[0]?.value ?? '');
|
||||||
|
React.useEffect(() => setV(value ?? ''), [value]);
|
||||||
|
return (
|
||||||
|
<div style={{ position: 'relative', display: 'flex' }}>
|
||||||
|
<select value={v} onChange={e => { setV(e.target.value); onChange && onChange(e.target.value); }}
|
||||||
|
style={{ ...inputStyle(), appearance: 'none', paddingRight: 30, cursor: 'pointer' }}>
|
||||||
|
{options.map(o => <option key={o.value} value={o.value}>{o.label}</option>)}
|
||||||
|
</select>
|
||||||
|
<i data-lucide="chevrons-up-down" style={{
|
||||||
|
position: 'absolute', right: 10, top: '50%', transform: 'translateY(-50%)',
|
||||||
|
width: 13, height: 13, color: 'var(--fg-muted)', pointerEvents: 'none',
|
||||||
|
}}></i>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─────────────── Toggle / Checkbox / Radio ───────────────
|
||||||
|
function Toggle({ on, onChange, size = 'md', disabled }) {
|
||||||
|
const sizes = { sm: { w: 28, h: 16, p: 12 }, md: { w: 36, h: 20, p: 16 }, lg: { w: 44, h: 24, p: 20 } };
|
||||||
|
const s = sizes[size];
|
||||||
|
return (
|
||||||
|
<div onClick={() => !disabled && onChange && onChange(!on)} style={{
|
||||||
|
width: s.w, height: s.h, borderRadius: 999, position: 'relative',
|
||||||
|
cursor: disabled ? 'default' : 'pointer', flexShrink: 0,
|
||||||
|
background: on ? 'var(--accent)' : 'var(--gray-300)',
|
||||||
|
transition: 'background 180ms var(--ease-smooth)',
|
||||||
|
opacity: disabled ? 0.5 : 1,
|
||||||
|
}}>
|
||||||
|
<div style={{
|
||||||
|
position: 'absolute', top: 2, left: on ? (s.w - s.p - 2) : 2,
|
||||||
|
width: s.p, height: s.p, borderRadius: '50%', background: '#fff',
|
||||||
|
boxShadow: '0 1px 3px rgba(0,0,0,0.18), 0 1px 1px rgba(0,0,0,0.06)',
|
||||||
|
transition: 'left 180ms var(--ease-smooth)',
|
||||||
|
}}></div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Checkbox({ checked, onChange, indeterminate, disabled }) {
|
||||||
|
return (
|
||||||
|
<div onClick={() => !disabled && onChange && onChange(!checked)} style={{
|
||||||
|
width: 16, height: 16, borderRadius: 4,
|
||||||
|
background: checked || indeterminate ? 'var(--accent)' : 'var(--bg-card)',
|
||||||
|
border: `1px solid ${checked || indeterminate ? 'var(--accent)' : 'var(--border-strong)'}`,
|
||||||
|
cursor: disabled ? 'default' : 'pointer', flexShrink: 0,
|
||||||
|
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||||
|
transition: 'all 120ms', opacity: disabled ? 0.5 : 1,
|
||||||
|
}}>
|
||||||
|
{checked && <i data-lucide="check" style={{ width: 12, height: 12, color: '#fff', strokeWidth: 3 }}></i>}
|
||||||
|
{indeterminate && !checked && <div style={{ width: 8, height: 2, background: '#fff', borderRadius: 1 }}></div>}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Radio({ checked, onChange, disabled }) {
|
||||||
|
return (
|
||||||
|
<div onClick={() => !disabled && onChange && onChange(true)} style={{
|
||||||
|
width: 16, height: 16, borderRadius: '50%',
|
||||||
|
background: 'var(--bg-card)',
|
||||||
|
border: `1px solid ${checked ? 'var(--accent)' : 'var(--border-strong)'}`,
|
||||||
|
cursor: disabled ? 'default' : 'pointer', flexShrink: 0,
|
||||||
|
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||||
|
transition: 'all 120ms', opacity: disabled ? 0.5 : 1,
|
||||||
|
}}>
|
||||||
|
{checked && <div style={{ width: 7, height: 7, borderRadius: '50%', background: 'var(--accent)' }}></div>}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─────────────── Segmented / Tabs ───────────────
|
||||||
|
function Segmented({ value, onChange, options, size = 'md' }) {
|
||||||
|
const padding = size === 'sm' ? '4px 10px' : '6px 14px';
|
||||||
|
const fontSize = size === 'sm' ? 12 : 13;
|
||||||
|
return (
|
||||||
|
<div style={{
|
||||||
|
display: 'inline-flex', padding: 2, borderRadius: 8,
|
||||||
|
background: 'var(--bg-quaternary)', border: '0.5px solid var(--border)',
|
||||||
|
}}>
|
||||||
|
{options.map(o => {
|
||||||
|
const active = value === o.value;
|
||||||
|
return (
|
||||||
|
<button key={o.value} onClick={() => onChange && onChange(o.value)} style={{
|
||||||
|
padding, fontSize, fontWeight: active ? 600 : 500, fontFamily: SF,
|
||||||
|
background: active ? 'var(--bg-card)' : 'transparent',
|
||||||
|
color: active ? 'var(--fg)' : 'var(--fg-muted)',
|
||||||
|
border: 'none', borderRadius: 6, cursor: 'pointer',
|
||||||
|
boxShadow: active ? 'var(--shadow-sm)' : 'none',
|
||||||
|
transition: 'all 120ms var(--ease-smooth)', display: 'inline-flex', alignItems: 'center', gap: 5,
|
||||||
|
}}>
|
||||||
|
{o.icon && <i data-lucide={o.icon} style={{ width: 12, height: 12 }}></i>}
|
||||||
|
{o.label}
|
||||||
|
{o.count != null && <span style={{
|
||||||
|
fontSize: 10, fontFamily: 'var(--font-mono)',
|
||||||
|
padding: '1px 6px', borderRadius: 999,
|
||||||
|
background: active ? 'var(--accent-tint)' : 'var(--bg-tertiary)',
|
||||||
|
color: active ? 'var(--accent-active)' : 'var(--fg-muted)',
|
||||||
|
}}>{o.count}</span>}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Tabs({ value, onChange, options }) {
|
||||||
|
return (
|
||||||
|
<div style={{ display: 'flex', gap: 2, borderBottom: '0.5px solid var(--border)' }}>
|
||||||
|
{options.map(o => {
|
||||||
|
const active = value === o.value;
|
||||||
|
return (
|
||||||
|
<button key={o.value} onClick={() => onChange && onChange(o.value)} style={{
|
||||||
|
padding: '10px 14px', fontSize: 13, fontWeight: 500, fontFamily: SF,
|
||||||
|
background: 'transparent', border: 'none',
|
||||||
|
color: active ? 'var(--fg)' : 'var(--fg-muted)',
|
||||||
|
borderBottom: `2px solid ${active ? 'var(--accent)' : 'transparent'}`,
|
||||||
|
marginBottom: -1, cursor: 'pointer', display: 'inline-flex', alignItems: 'center', gap: 6,
|
||||||
|
transition: 'color 120ms',
|
||||||
|
}}>
|
||||||
|
{o.icon && <i data-lucide={o.icon} style={{ width: 13, height: 13 }}></i>}
|
||||||
|
{o.label}
|
||||||
|
{o.count != null && <span style={{
|
||||||
|
fontSize: 10, fontFamily: 'var(--font-mono)',
|
||||||
|
padding: '1px 6px', borderRadius: 999,
|
||||||
|
background: 'var(--bg-tertiary)', color: 'var(--fg-muted)',
|
||||||
|
}}>{o.count}</span>}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─────────────── Settings groups (card-rows) ───────────────
|
||||||
|
function SettingsGroup({ title, description, children }) {
|
||||||
|
return (
|
||||||
|
<div style={{ marginBottom: 28 }}>
|
||||||
|
{title && <div style={{ marginBottom: 10 }}>
|
||||||
|
<div style={{ fontSize: 13, fontWeight: 600 }}>{title}</div>
|
||||||
|
{description && <div style={{ fontSize: 12, color: 'var(--fg-muted)', marginTop: 2 }}>{description}</div>}
|
||||||
|
</div>}
|
||||||
|
<div style={{
|
||||||
|
background: 'var(--bg-card)', border: '0.5px solid var(--border)',
|
||||||
|
borderRadius: 10, overflow: 'hidden',
|
||||||
|
}}>{children}</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function SettingsRow({ title, description, control, icon, last }) {
|
||||||
|
return (
|
||||||
|
<div style={{
|
||||||
|
display: 'flex', alignItems: 'center', gap: 14, padding: '14px 18px',
|
||||||
|
borderBottom: last ? 'none' : '0.5px solid var(--border)',
|
||||||
|
}}>
|
||||||
|
{icon && <div style={{
|
||||||
|
width: 32, height: 32, borderRadius: 7, background: 'var(--accent-tint)',
|
||||||
|
display: 'flex', alignItems: 'center', justifyContent: 'center', color: 'var(--accent)', flexShrink: 0,
|
||||||
|
}}><i data-lucide={icon} style={{ width: 16, height: 16 }}></i></div>}
|
||||||
|
<div style={{ flex: 1, minWidth: 0 }}>
|
||||||
|
<div style={{ fontSize: 13, fontWeight: 500 }}>{title}</div>
|
||||||
|
{description && <div style={{ fontSize: 12, color: 'var(--fg-muted)', marginTop: 2 }}>{description}</div>}
|
||||||
|
</div>
|
||||||
|
<div style={{ flexShrink: 0 }}>{control}</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─────────────── Menu / dropdown ───────────────
|
||||||
|
function Menu({ children, anchor = 'bottom-left', style = {} }) {
|
||||||
|
const positions = {
|
||||||
|
'bottom-left': { top: '100%', left: 0, marginTop: 4 },
|
||||||
|
'bottom-right': { top: '100%', right: 0, marginTop: 4 },
|
||||||
|
'top-left': { bottom: '100%', left: 0, marginBottom: 4 },
|
||||||
|
};
|
||||||
|
return (
|
||||||
|
<div style={{
|
||||||
|
position: 'absolute', zIndex: 200, ...positions[anchor],
|
||||||
|
minWidth: 200, padding: 4, background: 'var(--bg-card)',
|
||||||
|
border: '0.5px solid var(--border)', borderRadius: 9,
|
||||||
|
boxShadow: 'var(--shadow-lg)', fontFamily: SF, ...style,
|
||||||
|
}}>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function MenuItem({ icon, label, kbd, onClick, danger, selected, children }) {
|
||||||
|
const [hover, setHover] = React.useState(false);
|
||||||
|
return (
|
||||||
|
<div onClick={onClick} onMouseEnter={() => setHover(true)} onMouseLeave={() => setHover(false)}
|
||||||
|
style={{
|
||||||
|
display: 'flex', alignItems: 'center', gap: 10, padding: '6px 10px',
|
||||||
|
borderRadius: 6, cursor: 'pointer', fontSize: 13,
|
||||||
|
background: hover ? 'var(--accent-tint)' : 'transparent',
|
||||||
|
color: danger ? 'var(--red-600)' : (hover ? 'var(--accent-active)' : 'var(--fg)'),
|
||||||
|
}}>
|
||||||
|
{icon && <i data-lucide={icon} style={{ width: 14, height: 14 }}></i>}
|
||||||
|
<span style={{ flex: 1 }}>{label || children}</span>
|
||||||
|
{selected && <i data-lucide="check" style={{ width: 13, height: 13 }}></i>}
|
||||||
|
{kbd && <KbdKey>{kbd}</KbdKey>}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function KbdKey({ children }) {
|
||||||
|
return <span style={{
|
||||||
|
fontFamily: 'var(--font-mono)', fontSize: 10,
|
||||||
|
padding: '1px 5px', borderRadius: 3,
|
||||||
|
background: 'var(--bg-quaternary)', border: '0.5px solid var(--border)',
|
||||||
|
color: 'var(--fg-muted)',
|
||||||
|
}}>{children}</span>;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─────────────── Avatar ───────────────
|
||||||
|
function Avatar({ initials, size = 28, color = 'var(--accent)' }) {
|
||||||
|
return (
|
||||||
|
<div style={{
|
||||||
|
width: size, height: size, borderRadius: '50%', background: color,
|
||||||
|
color: '#fff', display: 'inline-flex', alignItems: 'center', justifyContent: 'center',
|
||||||
|
fontSize: Math.round(size * 0.4), fontWeight: 600, flexShrink: 0,
|
||||||
|
}}>{initials}</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─────────────── ProgressBar ───────────────
|
||||||
|
function ProgressBar({ value = 0, color = 'var(--accent)', height = 6 }) {
|
||||||
|
return (
|
||||||
|
<div style={{ height, background: 'var(--bg-quaternary)', borderRadius: height / 2, overflow: 'hidden' }}>
|
||||||
|
<div style={{ width: `${Math.min(100, Math.max(0, value))}%`, height: '100%',
|
||||||
|
background: color, borderRadius: height / 2, transition: 'width 240ms var(--ease-smooth)' }}></div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─────────────── Empty ───────────────
|
||||||
|
function EmptyState({ icon, title, body, action }) {
|
||||||
|
return (
|
||||||
|
<div style={{ flex: 1, display: 'flex', flexDirection: 'column',
|
||||||
|
alignItems: 'center', justifyContent: 'center', padding: 80, textAlign: 'center', gap: 12 }}>
|
||||||
|
<div style={{
|
||||||
|
width: 64, height: 64, borderRadius: 16, background: 'var(--accent-tint)',
|
||||||
|
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||||
|
color: 'var(--accent)', marginBottom: 4,
|
||||||
|
}}>
|
||||||
|
<i data-lucide={icon || 'inbox'} style={{ width: 28, height: 28 }}></i>
|
||||||
|
</div>
|
||||||
|
<div style={{ fontSize: 17, fontWeight: 600 }}>{title}</div>
|
||||||
|
<div style={{ fontSize: 13, color: 'var(--fg-muted)', maxWidth: 380, lineHeight: 1.5 }}>{body}</div>
|
||||||
|
{action && <div style={{ marginTop: 8 }}>{action}</div>}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Object.assign(window, {
|
||||||
|
ContentHeader, Btn, IconBtn, Spinner, Pill, Dot,
|
||||||
|
Card, StatCard, Section, Divider,
|
||||||
|
Field, HelpIcon, TextInput, TextArea, Select,
|
||||||
|
Toggle, Checkbox, Radio,
|
||||||
|
Segmented, Tabs,
|
||||||
|
SettingsGroup, SettingsRow,
|
||||||
|
Menu, MenuItem, KbdKey,
|
||||||
|
Avatar, ProgressBar, EmptyState,
|
||||||
|
});
|
||||||
@@ -0,0 +1,165 @@
|
|||||||
|
// Cron — scheduled agent runs, with run history and a calendar heat strip.
|
||||||
|
|
||||||
|
const CRON_JOBS = [
|
||||||
|
{ id: 'daily-summary', name: 'Daily standup summary', schedule: '0 9 * * 1-5', cronText: 'Weekdays at 9:00am', enabled: true,
|
||||||
|
lastRun: '2h ago', lastStatus: 'ok', avgDuration: '38s', nextRun: 'tomorrow 9:00am',
|
||||||
|
personality: 'Hermes', desc: 'Read yesterday\'s commits + Linear updates and post a summary to #standup.', runs7d: 5 },
|
||||||
|
{ id: 'incident-triage', name: 'Incident triage', schedule: '*/15 * * * *', cronText: 'Every 15 minutes', enabled: true,
|
||||||
|
lastRun: '3m ago', lastStatus: 'ok', avgDuration: '4.2s', nextRun: 'in 12m',
|
||||||
|
personality: 'Forge', desc: 'Poll Sentry for unresolved high-severity issues and create Linear tickets.', runs7d: 672 },
|
||||||
|
{ id: 'design-review', name: 'Friday design review prep', schedule: '0 16 * * 4', cronText: 'Thursdays at 4:00pm', enabled: true,
|
||||||
|
lastRun: 'yesterday', lastStatus: 'ok', avgDuration: '2m 14s', nextRun: 'Thursday 4:00pm',
|
||||||
|
personality: 'Atlas', desc: 'Collect new Figma frames + recent PRs, draft an agenda for the design review.', runs7d: 1 },
|
||||||
|
{ id: 'docs-stale', name: 'Find stale docs', schedule: '0 0 * * 0', cronText: 'Sundays at midnight', enabled: false,
|
||||||
|
lastRun: '8d ago', lastStatus: 'skipped', avgDuration: '47s', nextRun: 'paused',
|
||||||
|
personality: 'Hermes', desc: 'Scan the docs site for pages not updated in >90 days; open a checklist.', runs7d: 0 },
|
||||||
|
{ id: 'release-notes', name: 'Draft release notes', schedule: '0 14 * * 5', cronText: 'Fridays at 2:00pm', enabled: true,
|
||||||
|
lastRun: '6d ago', lastStatus: 'failed', avgDuration: '1m 03s', nextRun: 'Friday 2:00pm',
|
||||||
|
personality: 'Atlas', desc: 'Walk merged PRs since last tag; group by area; write user-facing release notes.', runs7d: 1 },
|
||||||
|
];
|
||||||
|
|
||||||
|
const RUN_HISTORY = [
|
||||||
|
{ when: '2h ago', status: 'ok', duration: '36s', ts: '2026-04-25 09:00:14' },
|
||||||
|
{ when: 'yesterday', status: 'ok', duration: '41s', ts: '2026-04-24 09:00:08' },
|
||||||
|
{ when: '2d ago', status: 'ok', duration: '38s', ts: '2026-04-23 09:00:11' },
|
||||||
|
{ when: '3d ago', status: 'ok', duration: '34s', ts: '2026-04-22 09:00:06' },
|
||||||
|
{ when: '4d ago', status: 'failed', duration: '12s', ts: '2026-04-21 09:00:09', error: 'github: 502 bad gateway' },
|
||||||
|
{ when: '5d ago', status: 'ok', duration: '40s', ts: '2026-04-18 09:00:12' },
|
||||||
|
{ when: '6d ago', status: 'ok', duration: '37s', ts: '2026-04-17 09:00:09' },
|
||||||
|
];
|
||||||
|
|
||||||
|
function Cron() {
|
||||||
|
const [active, setActive] = React.useState('daily-summary');
|
||||||
|
const job = CRON_JOBS.find(j => j.id === active);
|
||||||
|
React.useEffect(() => { requestAnimationFrame(() => window.lucide && window.lucide.createIcons()); });
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', height: '100%' }}>
|
||||||
|
<ContentHeader title="Cron"
|
||||||
|
subtitle="Scheduled agent runs. Each job invokes a personality with a fixed prompt."
|
||||||
|
actions={<><Btn icon="calendar">Timezone: PT</Btn><Btn kind="primary" icon="plus">New cron job</Btn></>} />
|
||||||
|
|
||||||
|
<div style={{ flex: 1, display: 'flex', minHeight: 0 }}>
|
||||||
|
<div style={{ width: 360, borderRight: '0.5px solid var(--border)',
|
||||||
|
display: 'flex', flexDirection: 'column', background: 'var(--bg-card)' }}>
|
||||||
|
<div style={{ flex: 1, overflowY: 'auto', padding: 8 }}>
|
||||||
|
{CRON_JOBS.map(j => <CronRow key={j.id} j={j} active={j.id === active} onClick={() => setActive(j.id)} />)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div style={{ flex: 1, overflowY: 'auto', background: 'var(--bg)', padding: '24px 32px' }}>
|
||||||
|
<CronDetail job={job} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function CronRow({ j, active, onClick }) {
|
||||||
|
const [hover, setHover] = React.useState(false);
|
||||||
|
const tone = j.lastStatus === 'failed' ? 'red' : j.lastStatus === 'skipped' ? 'gray' : 'green';
|
||||||
|
return (
|
||||||
|
<div onClick={onClick} onMouseEnter={() => setHover(true)} onMouseLeave={() => setHover(false)} style={{
|
||||||
|
padding: '11px 12px', borderRadius: 7, cursor: 'pointer', marginBottom: 2,
|
||||||
|
background: active ? 'var(--accent-tint)' : (hover ? 'var(--bg-quaternary)' : 'transparent'),
|
||||||
|
}}>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 3 }}>
|
||||||
|
<i data-lucide="clock" style={{ width: 13, height: 13, color: 'var(--fg-muted)', flexShrink: 0 }}></i>
|
||||||
|
<div style={{ flex: 1, fontSize: 13, fontWeight: 500,
|
||||||
|
color: active ? 'var(--accent-active)' : 'var(--fg)' }}>{j.name}</div>
|
||||||
|
{!j.enabled && <Pill tone="gray" size="sm">paused</Pill>}
|
||||||
|
<Dot tone={tone} />
|
||||||
|
</div>
|
||||||
|
<div style={{ display: 'flex', gap: 10, fontSize: 11, color: 'var(--fg-faint)', fontFamily: 'var(--font-mono)' }}>
|
||||||
|
<span>{j.schedule}</span>
|
||||||
|
<span style={{ color: 'var(--fg-muted)' }}>· next {j.nextRun}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function CronDetail({ job }) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'flex-start', gap: 14, marginBottom: 20 }}>
|
||||||
|
<div style={{
|
||||||
|
width: 44, height: 44, borderRadius: 9, background: 'var(--accent-tint)', color: 'var(--accent)',
|
||||||
|
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||||
|
}}>
|
||||||
|
<i data-lucide="clock" style={{ width: 22, height: 22 }}></i>
|
||||||
|
</div>
|
||||||
|
<div style={{ flex: 1 }}>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 4 }}>
|
||||||
|
<div className="scarf-h2" style={{ fontSize: 22 }}>{job.name}</div>
|
||||||
|
{job.enabled ? <Pill tone="green" dot>active</Pill> : <Pill tone="gray" dot>paused</Pill>}
|
||||||
|
</div>
|
||||||
|
<div style={{ fontSize: 13, color: 'var(--fg-muted)', maxWidth: 520 }}>{job.desc}</div>
|
||||||
|
</div>
|
||||||
|
<div style={{ display: 'flex', gap: 6 }}>
|
||||||
|
<Btn icon="play">Run now</Btn>
|
||||||
|
<Toggle on={job.enabled} size="lg" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(4, 1fr)', gap: 10, marginBottom: 24 }}>
|
||||||
|
<StatCard label="Schedule" value={job.cronText} sub={job.schedule} />
|
||||||
|
<StatCard label="Last run" value={job.lastRun} sub={job.lastStatus} />
|
||||||
|
<StatCard label="Avg duration" value={job.avgDuration} />
|
||||||
|
<StatCard label="Next run" value={job.nextRun} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<SettingsGroup title="Schedule">
|
||||||
|
<SettingsRow icon="calendar" title="Cron expression"
|
||||||
|
description={`Parsed as: ${job.cronText} (America/Los_Angeles)`}
|
||||||
|
control={<TextInput value={job.schedule} mono />} />
|
||||||
|
<SettingsRow icon="globe" title="Timezone"
|
||||||
|
description="Job triggers fire in this timezone."
|
||||||
|
control={<Select value="pt" options={[{ value: 'pt', label: 'America/Los_Angeles' }, { value: 'utc', label: 'UTC' }]} />} />
|
||||||
|
<SettingsRow icon="hourglass" title="Timeout"
|
||||||
|
description="Kill the run after this duration."
|
||||||
|
control={<Select value="5m" options={[
|
||||||
|
{ value: '1m', label: '1 minute' }, { value: '5m', label: '5 minutes' },
|
||||||
|
{ value: '15m', label: '15 minutes' }, { value: '1h', label: '1 hour' },
|
||||||
|
]} />} last />
|
||||||
|
</SettingsGroup>
|
||||||
|
|
||||||
|
<SettingsGroup title="Behavior">
|
||||||
|
<SettingsRow icon="user-circle" title="Personality"
|
||||||
|
description={`This job runs as "${job.personality}" with its system prompt + tools.`}
|
||||||
|
control={<Btn size="sm" icon="external-link">{job.personality}</Btn>} />
|
||||||
|
<SettingsRow icon="message-square" title="Prompt"
|
||||||
|
description="The instruction sent to the agent at each scheduled run."
|
||||||
|
control={<Btn size="sm" icon="edit-3">Edit</Btn>} />
|
||||||
|
<SettingsRow icon="bell" title="Notify on failure"
|
||||||
|
description="Send a message to #ops if any run errors out."
|
||||||
|
control={<Toggle on={true} />} last />
|
||||||
|
</SettingsGroup>
|
||||||
|
|
||||||
|
<SettingsGroup title="Run history" description="Last 7 runs.">
|
||||||
|
{RUN_HISTORY.map((r, i) => <RunRow key={i} r={r} last={i === RUN_HISTORY.length - 1} />)}
|
||||||
|
</SettingsGroup>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function RunRow({ r, last }) {
|
||||||
|
const tone = r.status === 'failed' ? 'red' : r.status === 'skipped' ? 'gray' : 'green';
|
||||||
|
const icon = r.status === 'failed' ? 'x' : r.status === 'skipped' ? 'minus' : 'check';
|
||||||
|
return (
|
||||||
|
<div style={{
|
||||||
|
display: 'flex', alignItems: 'center', gap: 12, padding: '10px 18px',
|
||||||
|
borderBottom: last ? 'none' : '0.5px solid var(--border)',
|
||||||
|
}}>
|
||||||
|
<Pill tone={tone} size="sm" icon={icon}>{r.status}</Pill>
|
||||||
|
<div style={{ flex: 1, minWidth: 0 }}>
|
||||||
|
<div style={{ fontSize: 12.5, color: 'var(--fg)' }}>{r.when}
|
||||||
|
<span style={{ color: 'var(--fg-faint)', fontFamily: 'var(--font-mono)', marginLeft: 8, fontSize: 11 }}>{r.ts}</span>
|
||||||
|
</div>
|
||||||
|
{r.error && <div style={{ fontSize: 11, color: 'var(--red-500)', fontFamily: 'var(--font-mono)', marginTop: 2 }}>{r.error}</div>}
|
||||||
|
</div>
|
||||||
|
<span style={{ fontFamily: 'var(--font-mono)', fontSize: 11, color: 'var(--fg-muted)', width: 60, textAlign: 'right' }}>{r.duration}</span>
|
||||||
|
<Btn size="sm">View log</Btn>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
window.Cron = Cron;
|
||||||
@@ -0,0 +1,117 @@
|
|||||||
|
// Dashboard — first screen. Mirrors the structure: status header,
|
||||||
|
// quick stats, recent sessions, recent activity.
|
||||||
|
|
||||||
|
function Dashboard() {
|
||||||
|
return (
|
||||||
|
<div style={{ padding: '0 0 28px', overflow: 'auto' }}>
|
||||||
|
<ContentHeader title="Dashboard"
|
||||||
|
subtitle="At-a-glance status of your Hermes agent"
|
||||||
|
actions={<><Btn icon="rotate-cw">Refresh</Btn><Btn kind="primary" icon="plus">New Session</Btn></>} />
|
||||||
|
|
||||||
|
<div style={{ padding: '20px 28px', display: 'flex', flexDirection: 'column', gap: 20 }}>
|
||||||
|
{/* Status row */}
|
||||||
|
<div style={{ display: 'flex', gap: 12 }}>
|
||||||
|
<StatusCard icon="activity" label="Hermes" value="Running" tone="green" sub="3h 14m uptime" />
|
||||||
|
<StatusCard icon="cpu" label="Model" value="claude-sonnet-4.5" sub="Anthropic" />
|
||||||
|
<StatusCard icon="cloud" label="Provider" value="Anthropic" sub="us-east-1 · 18ms" />
|
||||||
|
<StatusCard icon="network" label="Gateway" value="Connected" tone="green" sub="3 platforms" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Stats row */}
|
||||||
|
<Section title="Last 7 days" right={<Btn size="sm" kind="ghost" icon="bar-chart-3">View Insights</Btn>}>
|
||||||
|
<div style={{ display: 'flex', gap: 12 }}>
|
||||||
|
<StatCard label="Sessions" value="847" sub="+12% vs prev" />
|
||||||
|
<StatCard label="Messages" value="12,394" />
|
||||||
|
<StatCard label="Tool Calls" value="3,221" />
|
||||||
|
<StatCard label="Tokens" value="2.4M" sub="1.8M in · 0.6M out" />
|
||||||
|
<StatCard label="Cost" value="$42.18" accent="var(--accent)" />
|
||||||
|
</div>
|
||||||
|
</Section>
|
||||||
|
|
||||||
|
{/* Two col */}
|
||||||
|
<div style={{ display: 'grid', gridTemplateColumns: '1.3fr 1fr', gap: 16 }}>
|
||||||
|
<Section title="Recent sessions" right={<a style={linkStyle}>View all →</a>}>
|
||||||
|
<Card padding={0}>
|
||||||
|
<RecentSessionRow project="hermes-blog" message="Draft this week's release notes…" model="haiku-4.5" tokens="1,247" time="14m ago" />
|
||||||
|
<RecentSessionRow project="scarf" message="Implement the cron diagnostics view" model="sonnet-4.5" tokens="8,392" time="42m ago" />
|
||||||
|
<RecentSessionRow project="hermes-blog" message="Review the open PRs and summarize" model="sonnet-4.5" tokens="4,108" time="2h ago" />
|
||||||
|
<RecentSessionRow project="—" message="What model handles function calls best?" model="haiku-4.5" tokens="284" time="3h ago" last />
|
||||||
|
</Card>
|
||||||
|
</Section>
|
||||||
|
|
||||||
|
<Section title="Recent activity" right={<a style={linkStyle}>View all →</a>}>
|
||||||
|
<Card padding={0}>
|
||||||
|
<DashActivityRow icon="file-edit" tone="blue" text="Edited cron/jobs.json" sub="hermes-blog · session #3a2f" time="14m" />
|
||||||
|
<DashActivityRow icon="terminal" tone="orange" text="Ran hermes status" sub="3 platforms healthy" time="42m" />
|
||||||
|
<DashActivityRow icon="git-branch" tone="green" text="Cron daily-summary completed" sub="14.2s · 1,847 tokens" time="2h" />
|
||||||
|
<DashActivityRow icon="package" tone="purple" text="Installed template hermes-blog" sub="from awizemann/hermes-blog" time="yesterday" last />
|
||||||
|
</Card>
|
||||||
|
</Section>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const linkStyle = { fontSize: 12, color: 'var(--accent)', cursor: 'pointer', textDecoration: 'none' };
|
||||||
|
|
||||||
|
function StatusCard({ icon, label, value, sub, tone }) {
|
||||||
|
const dotColor = tone === 'green' ? 'var(--green-500)' : 'var(--gray-400)';
|
||||||
|
return (
|
||||||
|
<Card padding={14} style={{ flex: 1, minWidth: 0 }}>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 6, fontSize: 11,
|
||||||
|
color: 'var(--fg-muted)', fontWeight: 600, marginBottom: 6 }}>
|
||||||
|
{tone === 'green'
|
||||||
|
? <span style={{ width: 7, height: 7, borderRadius: '50%', background: dotColor }}></span>
|
||||||
|
: <i data-lucide={icon} style={{ width: 12, height: 12 }}></i>
|
||||||
|
}
|
||||||
|
<span style={{ textTransform: 'uppercase', letterSpacing: '0.05em' }}>{label}</span>
|
||||||
|
</div>
|
||||||
|
<div style={{ fontFamily: 'var(--font-mono)', fontSize: 14, fontWeight: 500,
|
||||||
|
whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>{value}</div>
|
||||||
|
{sub && <div style={{ fontSize: 11, color: 'var(--fg-faint)', marginTop: 3 }}>{sub}</div>}
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function RecentSessionRow({ project, message, model, tokens, time, last }) {
|
||||||
|
return (
|
||||||
|
<div style={{
|
||||||
|
display: 'flex', alignItems: 'center', gap: 10, padding: '10px 14px',
|
||||||
|
borderBottom: last ? 'none' : '0.5px solid var(--border)',
|
||||||
|
cursor: 'pointer', transition: 'background 120ms',
|
||||||
|
}} onMouseEnter={e => e.currentTarget.style.background = 'var(--bg-quaternary)'}
|
||||||
|
onMouseLeave={e => e.currentTarget.style.background = 'transparent'}>
|
||||||
|
<Pill tone="accent">{project}</Pill>
|
||||||
|
<div style={{ flex: 1, fontSize: 13, color: 'var(--fg)',
|
||||||
|
whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>{message}</div>
|
||||||
|
<div style={{ fontFamily: 'var(--font-mono)', fontSize: 11, color: 'var(--fg-faint)' }}>{model}</div>
|
||||||
|
<div style={{ fontFamily: 'var(--font-mono)', fontSize: 11, color: 'var(--fg-faint)', width: 70, textAlign: 'right' }}>{tokens}</div>
|
||||||
|
<div style={{ fontSize: 11, color: 'var(--fg-faint)', width: 60, textAlign: 'right' }}>{time}</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function DashActivityRow({ icon, tone, text, sub, time, last }) {
|
||||||
|
const tones = { green: 'var(--green-500)', blue: 'var(--blue-500)', orange: 'var(--orange-500)', purple: 'var(--accent)' };
|
||||||
|
return (
|
||||||
|
<div style={{
|
||||||
|
display: 'flex', alignItems: 'flex-start', gap: 10, padding: '10px 14px',
|
||||||
|
borderBottom: last ? 'none' : '0.5px solid var(--border)',
|
||||||
|
}}>
|
||||||
|
<div style={{
|
||||||
|
width: 22, height: 22, borderRadius: 5, background: 'var(--bg-quaternary)',
|
||||||
|
display: 'flex', alignItems: 'center', justifyContent: 'center', color: tones[tone], flexShrink: 0,
|
||||||
|
}}>
|
||||||
|
<i data-lucide={icon} style={{ width: 12, height: 12 }}></i>
|
||||||
|
</div>
|
||||||
|
<div style={{ flex: 1, minWidth: 0 }}>
|
||||||
|
<div style={{ fontSize: 13, color: 'var(--fg)' }}>{text}</div>
|
||||||
|
<div style={{ fontSize: 11, color: 'var(--fg-faint)', marginTop: 1 }}>{sub}</div>
|
||||||
|
</div>
|
||||||
|
<div style={{ fontSize: 11, color: 'var(--fg-faint)' }}>{time}</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
window.Dashboard = Dashboard;
|
||||||
@@ -0,0 +1,111 @@
|
|||||||
|
// Health — diagnostics report. One-shot health check across services.
|
||||||
|
|
||||||
|
const HEALTH_CHECKS = [
|
||||||
|
{ name: 'Anthropic API', status: 'ok', latency: '124 ms', detail: 'authenticated as Aurora · sonnet-4.5 reachable' },
|
||||||
|
{ name: 'Local gateway', status: 'ok', latency: '2 ms', detail: 'pid 84021 · uptime 4d 2h · listening :7421' },
|
||||||
|
{ name: 'Filesystem', status: 'ok', latency: '—', detail: '14.2 GB free of 512 GB' },
|
||||||
|
{ name: 'GitHub MCP', status: 'ok', latency: '84 ms', detail: 'oauth ok · 18 tools · rate-limit 4500/5000 (warn at 4750)' },
|
||||||
|
{ name: 'Linear MCP', status: 'ok', latency: '142 ms', detail: 'oauth ok · 9 tools' },
|
||||||
|
{ name: 'Postgres MCP', status: 'ok', latency: '12 ms', detail: 'stdio · prod read replica' },
|
||||||
|
{ name: 'Figma MCP', status: 'ok', latency: '210 ms', detail: 'oauth ok · 6 tools' },
|
||||||
|
{ name: 'Notion MCP', status: 'error', latency: '—', detail: 'TLS handshake failed · 4 retries · backing off 30s' },
|
||||||
|
{ name: 'Slack MCP', status: 'warn', latency: '—', detail: 'oauth token expired · re-authenticate' },
|
||||||
|
{ name: 'Sentry MCP', status: 'idle', latency: '—', detail: 'disabled' },
|
||||||
|
{ name: 'Cron scheduler', status: 'ok', latency: '—', detail: '5 jobs registered · next: incident-triage in 12m' },
|
||||||
|
{ name: 'Local model cache', status: 'ok', latency: '—', detail: '412 MB · last pruned 2d ago' },
|
||||||
|
];
|
||||||
|
|
||||||
|
function Health() {
|
||||||
|
const [scanning, setScanning] = React.useState(false);
|
||||||
|
React.useEffect(() => { requestAnimationFrame(() => window.lucide && window.lucide.createIcons()); });
|
||||||
|
|
||||||
|
const ok = HEALTH_CHECKS.filter(c => c.status === 'ok').length;
|
||||||
|
const warn = HEALTH_CHECKS.filter(c => c.status === 'warn').length;
|
||||||
|
const err = HEALTH_CHECKS.filter(c => c.status === 'error').length;
|
||||||
|
|
||||||
|
function rerun() {
|
||||||
|
setScanning(true);
|
||||||
|
setTimeout(() => setScanning(false), 1400);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', height: '100%' }}>
|
||||||
|
<ContentHeader title="Health"
|
||||||
|
subtitle="A diagnostics report across Scarf, the agent, and connected services"
|
||||||
|
actions={<>
|
||||||
|
<Btn icon="download">Save report</Btn>
|
||||||
|
<Btn kind="primary" icon="rotate-cw" loading={scanning} onClick={rerun}>{scanning ? 'Scanning…' : 'Re-run'}</Btn>
|
||||||
|
</>} />
|
||||||
|
|
||||||
|
<div style={{ flex: 1, overflowY: 'auto', padding: '24px 32px' }}>
|
||||||
|
{/* Summary banner */}
|
||||||
|
<div style={{
|
||||||
|
background: err > 0 ? 'var(--red-100)' : warn > 0 ? 'var(--orange-100)' : 'var(--green-100)',
|
||||||
|
border: `0.5px solid ${err > 0 ? 'var(--red-500)' : warn > 0 ? 'var(--orange-500)' : 'var(--green-500)'}`,
|
||||||
|
borderRadius: 10, padding: 16, marginBottom: 24,
|
||||||
|
display: 'flex', alignItems: 'center', gap: 14,
|
||||||
|
}}>
|
||||||
|
<div style={{
|
||||||
|
width: 38, height: 38, borderRadius: 9,
|
||||||
|
background: err > 0 ? 'var(--red-500)' : warn > 0 ? 'var(--orange-500)' : 'var(--green-500)',
|
||||||
|
color: '#fff', display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||||
|
}}>
|
||||||
|
<i data-lucide={err > 0 ? 'alert-octagon' : warn > 0 ? 'alert-triangle' : 'shield-check'} style={{ width: 20, height: 20 }}></i>
|
||||||
|
</div>
|
||||||
|
<div style={{ flex: 1 }}>
|
||||||
|
<div style={{ fontSize: 15, fontWeight: 600, marginBottom: 2 }}>
|
||||||
|
{err > 0 ? `${err} service${err === 1 ? '' : 's'} unhealthy`
|
||||||
|
: warn > 0 ? `${warn} warning${warn === 1 ? '' : 's'} to review`
|
||||||
|
: 'All systems healthy'}
|
||||||
|
</div>
|
||||||
|
<div style={{ fontSize: 13, color: 'var(--fg-muted)' }}>
|
||||||
|
{ok} ok · {warn} warning · {err} error · scanned 2 minutes ago
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Checks */}
|
||||||
|
<SettingsGroup title="Diagnostic checks">
|
||||||
|
{HEALTH_CHECKS.map((c, i) => <HealthRow key={c.name} c={c} last={i === HEALTH_CHECKS.length - 1} />)}
|
||||||
|
</SettingsGroup>
|
||||||
|
|
||||||
|
<SettingsGroup title="Environment">
|
||||||
|
<SettingsRow icon="info" title="Scarf version"
|
||||||
|
description="0.14.2 · 0.15.0 available"
|
||||||
|
control={<Btn size="sm">Update</Btn>} />
|
||||||
|
<SettingsRow icon="cpu" title="Platform"
|
||||||
|
description="macOS 14.4.1 · Apple M3 Pro · 36 GB"
|
||||||
|
control={<Pill tone="green" dot>supported</Pill>} />
|
||||||
|
<SettingsRow icon="terminal" title="Shell"
|
||||||
|
description="/bin/zsh 5.9 · path 47 entries"
|
||||||
|
control={<Btn size="sm">Inspect</Btn>} last />
|
||||||
|
</SettingsGroup>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function HealthRow({ c, last }) {
|
||||||
|
const tones = {
|
||||||
|
ok: { tone: 'green', icon: 'check-circle' },
|
||||||
|
warn: { tone: 'amber', icon: 'alert-triangle' },
|
||||||
|
error: { tone: 'red', icon: 'x-circle' },
|
||||||
|
idle: { tone: 'gray', icon: 'minus-circle' },
|
||||||
|
};
|
||||||
|
const t = tones[c.status];
|
||||||
|
return (
|
||||||
|
<div style={{
|
||||||
|
display: 'flex', alignItems: 'center', gap: 12, padding: '12px 18px',
|
||||||
|
borderBottom: last ? 'none' : '0.5px solid var(--border)',
|
||||||
|
}}>
|
||||||
|
<Pill tone={t.tone} icon={t.icon} size="sm">{c.status}</Pill>
|
||||||
|
<div style={{ flex: 1, minWidth: 0 }}>
|
||||||
|
<div style={{ fontSize: 13, fontWeight: 500 }}>{c.name}</div>
|
||||||
|
<div style={{ fontSize: 11.5, color: 'var(--fg-muted)', marginTop: 2, fontFamily: 'var(--font-mono)' }}>{c.detail}</div>
|
||||||
|
</div>
|
||||||
|
<span style={{ fontFamily: 'var(--font-mono)', fontSize: 11, color: 'var(--fg-faint)', width: 70, textAlign: 'right' }}>{c.latency}</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
window.Health = Health;
|
||||||
@@ -0,0 +1,107 @@
|
|||||||
|
// Insights — usage charts and breakdowns.
|
||||||
|
|
||||||
|
function Insights() {
|
||||||
|
return (
|
||||||
|
<div style={{ overflow: 'auto', height: '100%' }}>
|
||||||
|
<ContentHeader title="Insights"
|
||||||
|
subtitle="Patterns across sessions, models, and tools"
|
||||||
|
right={<select style={{
|
||||||
|
fontSize: 12, padding: '5px 10px', border: '1px solid var(--border-strong)',
|
||||||
|
borderRadius: 6, background: 'var(--bg-card)', fontFamily: 'var(--font-sans)',
|
||||||
|
}}><option>Last 7 days</option><option>Last 30 days</option><option>This year</option></select>}
|
||||||
|
actions={<Btn icon="download">Export CSV</Btn>} />
|
||||||
|
|
||||||
|
<div style={{ padding: '20px 28px', display: 'flex', flexDirection: 'column', gap: 20 }}>
|
||||||
|
<div style={{ display: 'flex', gap: 12 }}>
|
||||||
|
<StatCard label="Sessions" value="847" sub="↗ +12% vs prev" />
|
||||||
|
<StatCard label="Tokens" value="2.4M" sub="1.8M in · 0.6M out" />
|
||||||
|
<StatCard label="Tool calls" value="3,221" sub="3.8 avg/session" />
|
||||||
|
<StatCard label="Avg latency" value="1.2s" accent="var(--accent)" sub="p95 4.1s" />
|
||||||
|
<StatCard label="Cost" value="$42.18" sub="$0.05 avg/session" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 10, marginBottom: 14 }}>
|
||||||
|
<div style={{ flex: 1, fontSize: 13, fontWeight: 600 }}>Token usage</div>
|
||||||
|
<div style={{ display: 'flex', gap: 12, fontSize: 11, color: 'var(--fg-muted)' }}>
|
||||||
|
<span style={{ display: 'flex', alignItems: 'center', gap: 5 }}>
|
||||||
|
<span style={{ width: 9, height: 9, borderRadius: 2, background: 'var(--accent)' }}></span>
|
||||||
|
Input
|
||||||
|
</span>
|
||||||
|
<span style={{ display: 'flex', alignItems: 'center', gap: 5 }}>
|
||||||
|
<span style={{ width: 9, height: 9, borderRadius: 2, background: 'var(--brand-200)' }}></span>
|
||||||
|
Output
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<BarChart />
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 16 }}>
|
||||||
|
<Card>
|
||||||
|
<div style={{ fontSize: 13, fontWeight: 600, marginBottom: 14 }}>By model</div>
|
||||||
|
<BreakdownRow label="claude-sonnet-4.5" value="62%" bar="var(--accent)" sub="$28.41 · 524 sessions" />
|
||||||
|
<BreakdownRow label="claude-haiku-4.5" value="31%" bar="var(--brand-300)" sub="$10.18 · 263 sessions" />
|
||||||
|
<BreakdownRow label="claude-opus-4.5" value="5%" bar="var(--brand-700)" sub="$3.40 · 42 sessions" />
|
||||||
|
<BreakdownRow label="local/llama-3.3" value="2%" bar="var(--gray-400)" sub="$0.00 · 18 sessions" last />
|
||||||
|
</Card>
|
||||||
|
<Card>
|
||||||
|
<div style={{ fontSize: 13, fontWeight: 600, marginBottom: 14 }}>By tool kind</div>
|
||||||
|
<BreakdownRow label="read" value="42%" bar="var(--green-500)" sub="1,353 calls" />
|
||||||
|
<BreakdownRow label="execute" value="24%" bar="var(--orange-500)" sub="773 calls" />
|
||||||
|
<BreakdownRow label="edit" value="18%" bar="var(--blue-500)" sub="580 calls" />
|
||||||
|
<BreakdownRow label="fetch" value="11%" bar="var(--purple-tool-500)" sub="354 calls" />
|
||||||
|
<BreakdownRow label="browser" value="5%" bar="var(--indigo-500)" sub="161 calls" last />
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function BarChart() {
|
||||||
|
// 14 days of data, hand-tuned
|
||||||
|
const data = [
|
||||||
|
[120, 40], [80, 32], [180, 60], [240, 90], [200, 75], [60, 22], [40, 15],
|
||||||
|
[110, 38], [170, 56], [220, 82], [280, 98], [310, 110], [240, 78], [190, 64],
|
||||||
|
];
|
||||||
|
const max = 420;
|
||||||
|
const chartH = 160; // px area for bars
|
||||||
|
return (
|
||||||
|
<div style={{ display: 'flex', alignItems: 'flex-end', gap: 6, height: 184, padding: '0 4px' }}>
|
||||||
|
{data.map(([inp, outp], i) => {
|
||||||
|
const inpH = Math.round(inp / max * chartH);
|
||||||
|
const outpH = Math.round(outp / max * chartH);
|
||||||
|
return (
|
||||||
|
<div key={i} style={{
|
||||||
|
flex: 1, display: 'flex', flexDirection: 'column',
|
||||||
|
justifyContent: 'flex-end', alignItems: 'stretch', minWidth: 0,
|
||||||
|
}}>
|
||||||
|
<div style={{ background: 'var(--brand-200)', height: outpH,
|
||||||
|
borderRadius: '3px 3px 0 0' }}></div>
|
||||||
|
<div style={{ background: 'var(--accent)', height: inpH }}></div>
|
||||||
|
<div style={{ fontSize: 9, color: 'var(--fg-faint)', textAlign: 'center', marginTop: 4,
|
||||||
|
fontFamily: 'var(--font-mono)', height: 14 }}>{i % 2 === 0 ? `04/${12 + i}` : ''}</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function BreakdownRow({ label, value, bar, sub, last }) {
|
||||||
|
return (
|
||||||
|
<div style={{ marginBottom: last ? 0 : 14 }}>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 4 }}>
|
||||||
|
<div style={{ flex: 1, fontFamily: 'var(--font-mono)', fontSize: 12 }}>{label}</div>
|
||||||
|
<div style={{ fontFamily: 'var(--font-mono)', fontSize: 12, fontWeight: 600 }}>{value}</div>
|
||||||
|
</div>
|
||||||
|
<div style={{ height: 6, background: 'var(--bg-quaternary)', borderRadius: 3, overflow: 'hidden' }}>
|
||||||
|
<div style={{ width: value, height: '100%', background: bar, borderRadius: 3 }}></div>
|
||||||
|
</div>
|
||||||
|
<div style={{ fontSize: 10.5, color: 'var(--fg-faint)', marginTop: 3 }}>{sub}</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
window.Insights = Insights;
|
||||||
@@ -0,0 +1,123 @@
|
|||||||
|
// Logs — streaming monospace surface. Filter pills + a fake live tail.
|
||||||
|
|
||||||
|
const LOG_LINES = [
|
||||||
|
{ ts: '09:42:18.124', level: 'info', source: 'gateway', msg: 'POST /v1/messages → 200 (1.2s, 482 tokens out)' },
|
||||||
|
{ ts: '09:42:18.066', level: 'debug', source: 'tool', msg: 'tool_call read_file path=src/App.jsx (8.2KB)' },
|
||||||
|
{ ts: '09:42:17.880', level: 'info', source: 'agent', msg: 'turn 14 started — personality=Forge model=claude-sonnet-4.5' },
|
||||||
|
{ ts: '09:42:15.341', level: 'warn', source: 'mcp', msg: 'github: rate-limit warning 4500/5000 used this hour' },
|
||||||
|
{ ts: '09:42:11.012', level: 'info', source: 'tool', msg: 'tool_call execute cmd="npm test -- --watch=false" status=ok 14.2s' },
|
||||||
|
{ ts: '09:42:01.508', level: 'error', source: 'tool', msg: 'tool_call execute denied: command "rm -rf node_modules" matches deny rule "rm -rf"' },
|
||||||
|
{ ts: '09:41:58.211', level: 'info', source: 'agent', msg: 'user message received (1.4KB)' },
|
||||||
|
{ ts: '09:41:42.004', level: 'debug', source: 'memory', msg: 'AGENTS.md hash unchanged (4f02…ab19), skipping reload' },
|
||||||
|
{ ts: '09:41:30.882', level: 'info', source: 'cron', msg: 'incident-triage finished ok (4.2s)' },
|
||||||
|
{ ts: '09:41:26.108', level: 'info', source: 'cron', msg: 'incident-triage started' },
|
||||||
|
{ ts: '09:41:18.443', level: 'info', source: 'mcp', msg: 'linear: tools/list 9 tools (142ms)' },
|
||||||
|
{ ts: '09:40:54.221', level: 'warn', source: 'gateway', msg: 'approval pending: tool_call execute cmd="git push origin main" (12s)' },
|
||||||
|
{ ts: '09:40:42.001', level: 'info', source: 'agent', msg: 'turn 13 ended — 2.1s, 7 tool calls, $0.0042' },
|
||||||
|
{ ts: '09:40:21.778', level: 'debug', source: 'tool', msg: 'tool_call list_files path=ui_kits/scarf-mac (24 entries)' },
|
||||||
|
{ ts: '09:40:18.422', level: 'error', source: 'mcp', msg: 'notion: TLS handshake failed (timeout 5s) — backing off 30s' },
|
||||||
|
{ ts: '09:40:02.114', level: 'info', source: 'agent', msg: 'session resumed (idle 14m)' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const LEVEL_TONES = {
|
||||||
|
debug: '#7C7263', info: 'var(--blue-500)', warn: 'var(--amber-500)', error: 'var(--red-500)',
|
||||||
|
};
|
||||||
|
|
||||||
|
function Logs() {
|
||||||
|
const [level, setLevel] = React.useState(['info', 'warn', 'error']);
|
||||||
|
const [source, setSource] = React.useState('all');
|
||||||
|
const [search, setSearch] = React.useState('');
|
||||||
|
const [follow, setFollow] = React.useState(true);
|
||||||
|
React.useEffect(() => { requestAnimationFrame(() => window.lucide && window.lucide.createIcons()); });
|
||||||
|
|
||||||
|
const sources = ['all', 'agent', 'tool', 'gateway', 'mcp', 'cron', 'memory'];
|
||||||
|
const filtered = LOG_LINES.filter(l => {
|
||||||
|
if (!level.includes(l.level)) return false;
|
||||||
|
if (source !== 'all' && l.source !== source) return false;
|
||||||
|
if (search && !l.msg.toLowerCase().includes(search.toLowerCase())) return false;
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', height: '100%' }}>
|
||||||
|
<ContentHeader title="Logs"
|
||||||
|
subtitle="Live tail across the gateway, agent, tools, MCP servers, and cron"
|
||||||
|
actions={<>
|
||||||
|
<Btn icon="download">Export</Btn>
|
||||||
|
<Btn icon={follow ? 'pause' : 'play'} onClick={() => setFollow(!follow)}>
|
||||||
|
{follow ? 'Pause' : 'Follow'}
|
||||||
|
</Btn>
|
||||||
|
</>} />
|
||||||
|
|
||||||
|
{/* Toolbar */}
|
||||||
|
<div style={{
|
||||||
|
padding: '12px 24px', borderBottom: '0.5px solid var(--border)',
|
||||||
|
background: 'var(--bg-card)', display: 'flex', gap: 12, alignItems: 'center', flexWrap: 'wrap',
|
||||||
|
}}>
|
||||||
|
<TextInput value={search} onChange={setSearch} leftIcon="search" placeholder="Filter messages…" mono width={280} />
|
||||||
|
|
||||||
|
<div style={{ display: 'flex', gap: 4 }}>
|
||||||
|
{['debug', 'info', 'warn', 'error'].map(lv => {
|
||||||
|
const on = level.includes(lv);
|
||||||
|
return (
|
||||||
|
<button key={lv} onClick={() => setLevel(on ? level.filter(x => x !== lv) : [...level, lv])} style={{
|
||||||
|
padding: '4px 10px', borderRadius: 6, border: '0.5px solid var(--border)',
|
||||||
|
background: on ? 'var(--bg-tertiary)' : 'var(--bg-card)',
|
||||||
|
fontFamily: 'var(--font-mono)', fontSize: 11, fontWeight: 600,
|
||||||
|
color: on ? LEVEL_TONES[lv] : 'var(--fg-faint)',
|
||||||
|
textTransform: 'uppercase', cursor: 'pointer', letterSpacing: '0.04em',
|
||||||
|
}}>{lv}</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ display: 'flex', gap: 4, marginLeft: 'auto' }}>
|
||||||
|
{sources.map(s => (
|
||||||
|
<button key={s} onClick={() => setSource(s)} style={{
|
||||||
|
padding: '4px 10px', borderRadius: 6, border: 'none',
|
||||||
|
background: source === s ? 'var(--accent-tint)' : 'transparent',
|
||||||
|
color: source === s ? 'var(--accent-active)' : 'var(--fg-muted)',
|
||||||
|
fontFamily: 'var(--font-mono)', fontSize: 11, cursor: 'pointer',
|
||||||
|
}}>{s}</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Tail */}
|
||||||
|
<div style={{
|
||||||
|
flex: 1, overflowY: 'auto', background: '#1F1B16', color: '#E8E1D2',
|
||||||
|
fontFamily: 'var(--font-mono)', fontSize: 12, lineHeight: 1.7,
|
||||||
|
padding: '12px 0',
|
||||||
|
}}>
|
||||||
|
{filtered.map((l, i) => <LogRow key={i} l={l} />)}
|
||||||
|
{follow && (
|
||||||
|
<div style={{ padding: '6px 24px', display: 'flex', alignItems: 'center', gap: 8,
|
||||||
|
color: '#A89B82', fontSize: 11 }}>
|
||||||
|
<span style={{ width: 6, height: 6, borderRadius: 3, background: 'var(--green-500)',
|
||||||
|
animation: 'pulse 1.4s ease-in-out infinite' }}></span>
|
||||||
|
following — 4 lines/sec
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<style>{`
|
||||||
|
@keyframes pulse { 0%, 100% { opacity: 1; } 50% { opacity: 0.3; } }
|
||||||
|
`}</style>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function LogRow({ l }) {
|
||||||
|
return (
|
||||||
|
<div style={{
|
||||||
|
display: 'flex', gap: 14, padding: '1px 24px', alignItems: 'baseline',
|
||||||
|
}}>
|
||||||
|
<span style={{ color: '#7C7263', fontSize: 11, width: 100, flexShrink: 0 }}>{l.ts}</span>
|
||||||
|
<span style={{ color: LEVEL_TONES[l.level], width: 50, flexShrink: 0,
|
||||||
|
textTransform: 'uppercase', fontSize: 10, fontWeight: 700, letterSpacing: '0.04em' }}>{l.level}</span>
|
||||||
|
<span style={{ color: '#A89B82', width: 70, flexShrink: 0 }}>{l.source}</span>
|
||||||
|
<span style={{ color: '#E8E1D2', flex: 1 }}>{l.msg}</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
window.Logs = Logs;
|
||||||
@@ -0,0 +1,193 @@
|
|||||||
|
// MCP Servers — connection list + detail with health, capabilities, and logs.
|
||||||
|
|
||||||
|
const MCP_SERVERS = [
|
||||||
|
{ id: 'github', name: 'GitHub', transport: 'http', url: 'https://mcp.github.com/v1', status: 'connected', tools: 18, prompts: 4, resources: 12, latency: 84, version: '1.4.2', auth: 'oauth', scope: 'org/wizemann' },
|
||||||
|
{ id: 'linear', name: 'Linear', transport: 'http', url: 'https://mcp.linear.app/sse', status: 'connected', tools: 9, prompts: 0, resources: 6, latency: 142, version: '0.9.1', auth: 'oauth', scope: 'wizemann' },
|
||||||
|
{ id: 'slack', name: 'Slack', transport: 'http', url: 'https://mcp.slack.com/v1', status: 'auth-required', tools: 0, prompts: 0, resources: 0, latency: null, version: '—', auth: 'oauth', scope: '—' },
|
||||||
|
{ id: 'postgres-prod', name: 'Postgres (prod, ro)', transport: 'stdio', url: 'mcp-postgres --readonly', status: 'connected', tools: 4, prompts: 0, resources: 28, latency: 12, version: '2.1.0', auth: 'env', scope: 'prod-replica' },
|
||||||
|
{ id: 'figma', name: 'Figma', transport: 'http', url: 'https://mcp.figma.com/v1', status: 'connected', tools: 6, prompts: 2, resources: 0, latency: 210, version: '0.4.0', auth: 'oauth', scope: 'wizemann-design' },
|
||||||
|
{ id: 'notion', name: 'Notion', transport: 'http', url: 'https://mcp.notion.so/v1', status: 'error', tools: 0, prompts: 0, resources: 0, latency: null, version: '—', auth: 'oauth', scope: '—', error: 'TLS handshake failed (timeout 5s)' },
|
||||||
|
{ id: 'sentry', name: 'Sentry', transport: 'http', url: 'https://mcp.sentry.io/v1', status: 'disabled', tools: 0, prompts: 0, resources: 0, latency: null, version: '—', auth: 'token', scope: 'wizemann' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const STATUS_TONES = {
|
||||||
|
'connected': { tone: 'green', label: 'connected' },
|
||||||
|
'auth-required': { tone: 'amber', label: 'auth required' },
|
||||||
|
'error': { tone: 'red', label: 'error' },
|
||||||
|
'disabled': { tone: 'gray', label: 'disabled' },
|
||||||
|
};
|
||||||
|
|
||||||
|
function MCPServers() {
|
||||||
|
const [active, setActive] = React.useState('github');
|
||||||
|
const server = MCP_SERVERS.find(s => s.id === active);
|
||||||
|
React.useEffect(() => { requestAnimationFrame(() => window.lucide && window.lucide.createIcons()); });
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', height: '100%' }}>
|
||||||
|
<ContentHeader title="MCP Servers"
|
||||||
|
subtitle="Model Context Protocol endpoints — each adds a bundle of tools, prompts, and resources"
|
||||||
|
actions={<><Btn icon="rotate-cw">Reconnect all</Btn><Btn kind="primary" icon="plus">Add server</Btn></>} />
|
||||||
|
|
||||||
|
<div style={{ flex: 1, display: 'flex', minHeight: 0 }}>
|
||||||
|
<div style={{ width: 320, borderRight: '0.5px solid var(--border)',
|
||||||
|
display: 'flex', flexDirection: 'column', background: 'var(--bg-card)' }}>
|
||||||
|
<div style={{ flex: 1, overflowY: 'auto', padding: 8 }}>
|
||||||
|
{MCP_SERVERS.map(s => <MCPRow key={s.id} s={s} active={s.id === active} onClick={() => setActive(s.id)} />)}
|
||||||
|
</div>
|
||||||
|
<div style={{ padding: 12, borderTop: '0.5px solid var(--border)' }}>
|
||||||
|
<Btn fullWidth icon="hard-drive">Browse marketplace</Btn>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ flex: 1, overflowY: 'auto', background: 'var(--bg)', padding: '24px 32px' }}>
|
||||||
|
<MCPDetail server={server} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function MCPRow({ s, active, onClick }) {
|
||||||
|
const status = STATUS_TONES[s.status];
|
||||||
|
const [hover, setHover] = React.useState(false);
|
||||||
|
return (
|
||||||
|
<div onClick={onClick} onMouseEnter={() => setHover(true)} onMouseLeave={() => setHover(false)} style={{
|
||||||
|
padding: '11px 12px', borderRadius: 7, cursor: 'pointer', marginBottom: 2,
|
||||||
|
background: active ? 'var(--accent-tint)' : (hover ? 'var(--bg-quaternary)' : 'transparent'),
|
||||||
|
}}>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 4 }}>
|
||||||
|
<ServerGlyph id={s.id} size={22} />
|
||||||
|
<div style={{ flex: 1, fontSize: 13, fontWeight: 500,
|
||||||
|
color: active ? 'var(--accent-active)' : 'var(--fg)' }}>{s.name}</div>
|
||||||
|
<Dot tone={status.tone} />
|
||||||
|
</div>
|
||||||
|
<div style={{ fontSize: 11, color: 'var(--fg-faint)', fontFamily: 'var(--font-mono)',
|
||||||
|
whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>
|
||||||
|
{s.transport} · {s.tools} tools · {s.prompts} prompts
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ServerGlyph({ id, size = 22 }) {
|
||||||
|
const palette = {
|
||||||
|
github: '#1F1B16', linear: '#5E6AD2', slack: '#611F69',
|
||||||
|
'postgres-prod': '#336791', figma: '#F24E1E', notion: '#191919', sentry: '#362D59',
|
||||||
|
};
|
||||||
|
const letter = id[0].toUpperCase();
|
||||||
|
return (
|
||||||
|
<div style={{
|
||||||
|
width: size, height: size, borderRadius: 5, background: palette[id] || '#888',
|
||||||
|
color: 'white', display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||||
|
fontFamily: 'var(--font-display)', fontSize: size * 0.5, fontWeight: 700, flexShrink: 0,
|
||||||
|
}}>{letter}</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function MCPDetail({ server }) {
|
||||||
|
const status = STATUS_TONES[server.status];
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'flex-start', gap: 14, marginBottom: 20 }}>
|
||||||
|
<ServerGlyph id={server.id} size={48} />
|
||||||
|
<div style={{ flex: 1 }}>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 4 }}>
|
||||||
|
<div className="scarf-h2" style={{ fontSize: 22 }}>{server.name}</div>
|
||||||
|
<Pill tone={status.tone} dot>{status.label}</Pill>
|
||||||
|
<span style={{ fontSize: 11, color: 'var(--fg-faint)', fontFamily: 'var(--font-mono)' }}>v{server.version}</span>
|
||||||
|
</div>
|
||||||
|
<div style={{ fontSize: 12, color: 'var(--fg-muted)', fontFamily: 'var(--font-mono)' }}>{server.url}</div>
|
||||||
|
</div>
|
||||||
|
<div style={{ display: 'flex', gap: 6 }}>
|
||||||
|
<Btn icon="rotate-cw">Reconnect</Btn>
|
||||||
|
<Toggle on={server.status !== 'disabled'} size="lg" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{server.error && (
|
||||||
|
<div style={{
|
||||||
|
background: 'var(--red-100)', border: '0.5px solid var(--red-500)',
|
||||||
|
borderRadius: 9, padding: 12, marginBottom: 20, display: 'flex', gap: 10, alignItems: 'flex-start',
|
||||||
|
}}>
|
||||||
|
<i data-lucide="alert-triangle" style={{ width: 16, height: 16, color: 'var(--red-500)', flexShrink: 0, marginTop: 1 }}></i>
|
||||||
|
<div>
|
||||||
|
<div style={{ fontSize: 13, fontWeight: 500, color: 'var(--red-500)', marginBottom: 2 }}>Connection failed</div>
|
||||||
|
<div style={{ fontSize: 12, color: 'var(--fg-muted)', fontFamily: 'var(--font-mono)' }}>{server.error}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(4, 1fr)', gap: 10, marginBottom: 24 }}>
|
||||||
|
<StatCard label="Tools" value={server.tools} />
|
||||||
|
<StatCard label="Prompts" value={server.prompts} />
|
||||||
|
<StatCard label="Resources" value={server.resources} />
|
||||||
|
<StatCard label="Latency" value={server.latency != null ? `${server.latency} ms` : '—'} sub={server.latency != null ? 'p95: ' + Math.round(server.latency * 2.4) + ' ms' : '—'} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<SettingsGroup title="Connection">
|
||||||
|
<SettingsRow icon="link" title="Transport"
|
||||||
|
description={server.transport === 'http' ? 'HTTP / SSE' : 'Local stdio process'}
|
||||||
|
control={<Pill>{server.transport}</Pill>} />
|
||||||
|
<SettingsRow icon="key" title="Auth"
|
||||||
|
description={server.auth === 'oauth' ? 'OAuth — refreshed automatically' : server.auth === 'env' ? 'Environment variable' : 'Static token'}
|
||||||
|
control={<Btn size="sm" icon="external-link">Manage</Btn>} />
|
||||||
|
<SettingsRow icon="shield" title="Scope"
|
||||||
|
description={`Calls scoped to "${server.scope}".`}
|
||||||
|
control={<Btn size="sm">Edit</Btn>} last />
|
||||||
|
</SettingsGroup>
|
||||||
|
|
||||||
|
<SettingsGroup title="Capabilities" description="Tools, prompts, and resources advertised by this server.">
|
||||||
|
<CapRow icon="wrench" name="list_issues" kind="tool" desc="List repository issues with filters" />
|
||||||
|
<CapRow icon="wrench" name="create_pr" kind="tool" desc="Open a pull request from a branch" />
|
||||||
|
<CapRow icon="wrench" name="search_code" kind="tool" desc="Full-text search across accessible repos" />
|
||||||
|
<CapRow icon="message-square" name="review_pr" kind="prompt" desc="Structured PR review prompt" />
|
||||||
|
<CapRow icon="folder" name="repo://*" kind="resource" desc="Read-only access to repo file trees" last />
|
||||||
|
</SettingsGroup>
|
||||||
|
|
||||||
|
<SettingsGroup title="Activity log" description="Last 5 events from this server.">
|
||||||
|
<LogLine when="2m ago" level="info" msg="tools/list returned 18 tools (84ms)" />
|
||||||
|
<LogLine when="14m ago" level="info" msg="github__list_issues invoked (owner=wizemann, state=open)" />
|
||||||
|
<LogLine when="42m ago" level="warn" msg="rate-limit warning: 4500/5000 used this hour" />
|
||||||
|
<LogLine when="1h ago" level="info" msg="oauth token refreshed" />
|
||||||
|
<LogLine when="3h ago" level="info" msg="connection established (TLS 1.3)" last />
|
||||||
|
</SettingsGroup>
|
||||||
|
|
||||||
|
<SettingsGroup title="Danger zone" tone="danger">
|
||||||
|
<SettingsRow icon="x-circle" title="Disconnect server"
|
||||||
|
description="Remove this server. Tools it provided will become unavailable."
|
||||||
|
control={<Btn size="sm" kind="danger">Disconnect</Btn>} last />
|
||||||
|
</SettingsGroup>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function CapRow({ icon, name, kind, desc, last }) {
|
||||||
|
const tones = { tool: 'blue', prompt: 'purple', resource: 'green' };
|
||||||
|
return (
|
||||||
|
<div style={{
|
||||||
|
display: 'flex', alignItems: 'center', gap: 12, padding: '11px 18px',
|
||||||
|
borderBottom: last ? 'none' : '0.5px solid var(--border)',
|
||||||
|
}}>
|
||||||
|
<i data-lucide={icon} style={{ width: 14, height: 14, color: 'var(--fg-muted)', flexShrink: 0 }}></i>
|
||||||
|
<div style={{ fontFamily: 'var(--font-mono)', fontSize: 12.5, color: 'var(--fg)', minWidth: 140 }}>{name}</div>
|
||||||
|
<div style={{ flex: 1, fontSize: 12, color: 'var(--fg-muted)' }}>{desc}</div>
|
||||||
|
<Pill tone={tones[kind]} size="sm">{kind}</Pill>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function LogLine({ when, level, msg, last }) {
|
||||||
|
const tones = { info: 'var(--fg-faint)', warn: 'var(--amber-500)', error: 'var(--red-500)' };
|
||||||
|
return (
|
||||||
|
<div style={{
|
||||||
|
display: 'flex', gap: 12, padding: '8px 18px', fontFamily: 'var(--font-mono)', fontSize: 11.5,
|
||||||
|
borderBottom: last ? 'none' : '0.5px solid var(--border)',
|
||||||
|
}}>
|
||||||
|
<span style={{ color: 'var(--fg-faint)', width: 80 }}>{when}</span>
|
||||||
|
<span style={{ color: tones[level], textTransform: 'uppercase', width: 44, fontSize: 10, fontWeight: 600, paddingTop: 1 }}>{level}</span>
|
||||||
|
<span style={{ color: 'var(--fg-muted)', flex: 1 }}>{msg}</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
window.MCPServers = MCPServers;
|
||||||
@@ -0,0 +1,134 @@
|
|||||||
|
// Memory — AGENTS.md editor. Stored instructions the agent reads on every turn.
|
||||||
|
|
||||||
|
const MEMORY_FILES = [
|
||||||
|
{ id: 'global', name: 'AGENTS.md', scope: 'Global', path: '~/.scarf/AGENTS.md', updated: '2 days ago', size: '1.2 KB' },
|
||||||
|
{ id: 'wizemann', name: 'AGENTS.md', scope: 'Org · Wizemann', path: '~/.scarf/orgs/wizemann/AGENTS.md', updated: '1 week ago', size: '3.4 KB' },
|
||||||
|
{ id: 'project', name: 'AGENTS.md', scope: 'Project · sera', path: 'sera/AGENTS.md', updated: '14m ago', size: '5.8 KB' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const SAMPLE_AGENTS = `# Sera — agent instructions
|
||||||
|
|
||||||
|
You are working on **Sera**, a CLI for building Anthropic-style applications.
|
||||||
|
The codebase is TypeScript + Bun. Tests live next to source as \`*.test.ts\`.
|
||||||
|
|
||||||
|
## Style
|
||||||
|
- Prefer named exports.
|
||||||
|
- 2-space indent, no semicolons in TS.
|
||||||
|
- Avoid default exports except for React components.
|
||||||
|
- Lowercase filenames except for React components (PascalCase).
|
||||||
|
|
||||||
|
## Workflow
|
||||||
|
- Run \`bun test\` after every meaningful change.
|
||||||
|
- Open a draft PR early; flip to ready when CI is green.
|
||||||
|
- Update CHANGELOG.md when changing public API.
|
||||||
|
|
||||||
|
## Don't
|
||||||
|
- Touch \`scripts/release.ts\` — owned by ops.
|
||||||
|
- Pull in dependencies without flagging it first.
|
||||||
|
- Push directly to main.
|
||||||
|
`;
|
||||||
|
|
||||||
|
function Memory() {
|
||||||
|
const [active, setActive] = React.useState('project');
|
||||||
|
const [draft, setDraft] = React.useState(SAMPLE_AGENTS);
|
||||||
|
const [dirty, setDirty] = React.useState(false);
|
||||||
|
React.useEffect(() => { requestAnimationFrame(() => window.lucide && window.lucide.createIcons()); });
|
||||||
|
|
||||||
|
const file = MEMORY_FILES.find(f => f.id === active);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', height: '100%' }}>
|
||||||
|
<ContentHeader title="Memory" subtitle="AGENTS.md files the agent reads on every turn. Project beats org beats global."
|
||||||
|
actions={<>
|
||||||
|
<Btn icon="rotate-ccw" disabled={!dirty}>Discard</Btn>
|
||||||
|
<Btn kind="primary" icon="check" disabled={!dirty}>Save</Btn>
|
||||||
|
</>}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div style={{ flex: 1, display: 'flex', minHeight: 0 }}>
|
||||||
|
<div style={{ width: 280, borderRight: '0.5px solid var(--border)',
|
||||||
|
display: 'flex', flexDirection: 'column', background: 'var(--bg-card)' }}>
|
||||||
|
<div style={{ padding: '14px 14px 6px', fontSize: 10, fontWeight: 600,
|
||||||
|
color: 'var(--fg-muted)', textTransform: 'uppercase', letterSpacing: '0.06em' }}>
|
||||||
|
Memory files
|
||||||
|
</div>
|
||||||
|
<div style={{ flex: 1, padding: 8 }}>
|
||||||
|
{MEMORY_FILES.map(f => {
|
||||||
|
const a = f.id === active;
|
||||||
|
return (
|
||||||
|
<div key={f.id} onClick={() => setActive(f.id)} style={{
|
||||||
|
padding: '10px 12px', borderRadius: 7, cursor: 'pointer', marginBottom: 2,
|
||||||
|
background: a ? 'var(--accent-tint)' : 'transparent',
|
||||||
|
}}>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 4 }}>
|
||||||
|
<i data-lucide="file-text" style={{ width: 13, height: 13,
|
||||||
|
color: a ? 'var(--accent-active)' : 'var(--fg-muted)' }}></i>
|
||||||
|
<div style={{ fontSize: 13, fontWeight: 500,
|
||||||
|
color: a ? 'var(--accent-active)' : 'var(--fg)' }}>{f.scope}</div>
|
||||||
|
</div>
|
||||||
|
<div style={{ fontSize: 11, color: 'var(--fg-faint)', fontFamily: 'var(--font-mono)',
|
||||||
|
whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>{f.path}</div>
|
||||||
|
<div style={{ fontSize: 11, color: 'var(--fg-faint)', marginTop: 2 }}>
|
||||||
|
{f.size} · {f.updated}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
<div style={{ marginTop: 12 }}>
|
||||||
|
<Btn fullWidth icon="plus" size="sm">Add memory file</Btn>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div style={{ padding: 14, borderTop: '0.5px solid var(--border)' }}>
|
||||||
|
<div style={{ fontSize: 11, color: 'var(--fg-muted)', lineHeight: 1.5 }}>
|
||||||
|
<i data-lucide="info" style={{ width: 11, height: 11, verticalAlign: 'text-top', marginRight: 4 }}></i>
|
||||||
|
Files are loaded in order — narrower scopes override broader ones.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ flex: 1, display: 'flex', flexDirection: 'column', minWidth: 0 }}>
|
||||||
|
<div style={{
|
||||||
|
padding: '12px 24px', borderBottom: '0.5px solid var(--border)',
|
||||||
|
background: 'var(--bg-card)', display: 'flex', alignItems: 'center', gap: 12,
|
||||||
|
}}>
|
||||||
|
<i data-lucide="file-text" style={{ width: 16, height: 16, color: 'var(--accent)' }}></i>
|
||||||
|
<div style={{ flex: 1 }}>
|
||||||
|
<div style={{ fontSize: 13, fontWeight: 500 }}>{file.name}</div>
|
||||||
|
<div style={{ fontSize: 11, color: 'var(--fg-faint)', fontFamily: 'var(--font-mono)' }}>{file.path}</div>
|
||||||
|
</div>
|
||||||
|
{dirty
|
||||||
|
? <Pill tone="amber" dot>unsaved</Pill>
|
||||||
|
: <Pill tone="green" dot>saved</Pill>}
|
||||||
|
<div style={{ display: 'flex', gap: 4 }}>
|
||||||
|
<IconBtn icon="eye" tooltip="Preview" />
|
||||||
|
<IconBtn icon="more-horizontal" tooltip="More" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<textarea value={draft}
|
||||||
|
onChange={e => { setDraft(e.target.value); setDirty(true); }}
|
||||||
|
style={{
|
||||||
|
flex: 1, padding: '20px 32px', border: 'none', outline: 'none', resize: 'none',
|
||||||
|
fontFamily: 'var(--font-mono)', fontSize: 13, lineHeight: 1.7,
|
||||||
|
color: 'var(--fg)', background: 'var(--bg)',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div style={{
|
||||||
|
padding: '8px 24px', borderTop: '0.5px solid var(--border)', background: 'var(--bg-card)',
|
||||||
|
display: 'flex', alignItems: 'center', gap: 16, fontSize: 11, color: 'var(--fg-faint)', fontFamily: 'var(--font-mono)',
|
||||||
|
}}>
|
||||||
|
<span>markdown</span>
|
||||||
|
<span>·</span>
|
||||||
|
<span>{draft.split('\n').length} lines</span>
|
||||||
|
<span>·</span>
|
||||||
|
<span>{draft.length} chars</span>
|
||||||
|
<span style={{ marginLeft: 'auto' }}>last loaded: {file.updated}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
window.Memory = Memory;
|
||||||
@@ -0,0 +1,422 @@
|
|||||||
|
// MoreViews.jsx — Personalities, Quick Commands, Platforms, Credentials,
|
||||||
|
// Plugins, Webhooks, Profiles, Gateway. Each is a focused list/detail or grid.
|
||||||
|
|
||||||
|
// ─────────────── Personalities ───────────────
|
||||||
|
const PERSONALITIES = [
|
||||||
|
{ id: 'forge', name: 'Forge', emoji: '⚒', color: '#C25A2A', desc: 'Engineering pair. Refactors, tests, reviews PRs.', model: 'sonnet-4.5', tools: 14, used: '2m ago' },
|
||||||
|
{ id: 'hermes', name: 'Hermes', emoji: '✉', color: '#7E5BA9', desc: 'Operations. Handles ops scripts, summaries, status.', model: 'haiku-4.5', tools: 8, used: '32m ago' },
|
||||||
|
{ id: 'atlas', name: 'Atlas', emoji: '◇', color: '#3F6BA9', desc: 'Long-form writer. Spec drafts, release notes, docs.', model: 'opus-4.1', tools: 6, used: 'yesterday' },
|
||||||
|
{ id: 'vesta', name: 'Vesta', emoji: '✿', color: '#3F8A6E', desc: 'Design partner. Critiques layouts, suggests patterns.', model: 'sonnet-4.5', tools: 4, used: '3 days ago' },
|
||||||
|
{ id: 'gaia', name: 'Gaia', emoji: '✱', color: '#A8741F', desc: 'Researcher. Web search, summarization, citations.', model: 'sonnet-4.5', tools: 5, used: '1 week ago' },
|
||||||
|
];
|
||||||
|
|
||||||
|
function Personalities() {
|
||||||
|
React.useEffect(() => { requestAnimationFrame(() => window.lucide && window.lucide.createIcons()); });
|
||||||
|
return (
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', height: '100%' }}>
|
||||||
|
<ContentHeader title="Personalities"
|
||||||
|
subtitle="Pre-configured agents — system prompt, model, allowed tools, defaults"
|
||||||
|
actions={<Btn kind="primary" icon="plus">New personality</Btn>} />
|
||||||
|
<div style={{ flex: 1, overflowY: 'auto', padding: 32 }}>
|
||||||
|
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(260px, 1fr))', gap: 14 }}>
|
||||||
|
{PERSONALITIES.map(p => <PersonalityCard key={p.id} p={p} />)}
|
||||||
|
<Card padding={24} interactive style={{ display: 'flex', flexDirection: 'column',
|
||||||
|
alignItems: 'center', justifyContent: 'center', minHeight: 180,
|
||||||
|
border: '1px dashed var(--border-strong)', background: 'transparent', boxShadow: 'none' }}>
|
||||||
|
<div style={{ width: 40, height: 40, borderRadius: 10, background: 'var(--bg-quaternary)',
|
||||||
|
display: 'flex', alignItems: 'center', justifyContent: 'center', marginBottom: 8, color: 'var(--fg-muted)' }}>
|
||||||
|
<i data-lucide="plus" style={{ width: 20, height: 20 }}></i>
|
||||||
|
</div>
|
||||||
|
<div style={{ fontSize: 13, fontWeight: 500, color: 'var(--fg-muted)' }}>New personality</div>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function PersonalityCard({ p }) {
|
||||||
|
return (
|
||||||
|
<Card interactive padding={18}>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'flex-start', gap: 12, marginBottom: 12 }}>
|
||||||
|
<div style={{
|
||||||
|
width: 38, height: 38, borderRadius: 9, background: p.color, color: '#fff',
|
||||||
|
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||||
|
fontFamily: 'var(--font-display)', fontSize: 18,
|
||||||
|
}}>{p.emoji}</div>
|
||||||
|
<div style={{ flex: 1, minWidth: 0 }}>
|
||||||
|
<div style={{ fontSize: 15, fontWeight: 600 }}>{p.name}</div>
|
||||||
|
<div style={{ fontSize: 11, color: 'var(--fg-faint)', fontFamily: 'var(--font-mono)' }}>last used {p.used}</div>
|
||||||
|
</div>
|
||||||
|
<IconBtn icon="more-horizontal" size={26} />
|
||||||
|
</div>
|
||||||
|
<div style={{ fontSize: 12.5, color: 'var(--fg-muted)', lineHeight: 1.5, marginBottom: 14, minHeight: 36 }}>{p.desc}</div>
|
||||||
|
<div style={{ display: 'flex', gap: 6, flexWrap: 'wrap' }}>
|
||||||
|
<Pill size="sm">{p.model}</Pill>
|
||||||
|
<Pill size="sm" icon="wrench">{p.tools} tools</Pill>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
window.Personalities = Personalities;
|
||||||
|
|
||||||
|
// ─────────────── Quick Commands ───────────────
|
||||||
|
const QC = [
|
||||||
|
{ trigger: '/test', name: 'Run tests', desc: 'Run the project test suite, summarize failures.', personality: 'Forge', uses: 142 },
|
||||||
|
{ trigger: '/review', name: 'Review PR', desc: 'Walk the diff in a checked-out PR and post review notes.', personality: 'Forge', uses: 38 },
|
||||||
|
{ trigger: '/standup', name: 'Standup summary', desc: 'Summarize yesterday\'s commits + Linear updates.', personality: 'Hermes', uses: 24 },
|
||||||
|
{ trigger: '/notes', name: 'Release notes', desc: 'Group merged PRs since last tag into release notes.', personality: 'Atlas', uses: 8 },
|
||||||
|
{ trigger: '/figma', name: 'Open Figma frame', desc: 'Resolve a Figma URL and import frame metadata.', personality: 'Vesta', uses: 14 },
|
||||||
|
{ trigger: '/cite', name: 'Cite source', desc: 'Web search + return citations as Markdown footnotes.', personality: 'Gaia', uses: 9 },
|
||||||
|
];
|
||||||
|
|
||||||
|
function QuickCommands() {
|
||||||
|
React.useEffect(() => { requestAnimationFrame(() => window.lucide && window.lucide.createIcons()); });
|
||||||
|
return (
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', height: '100%' }}>
|
||||||
|
<ContentHeader title="Quick Commands"
|
||||||
|
subtitle="Slash-prefixed shortcuts that expand into full prompts"
|
||||||
|
actions={<Btn kind="primary" icon="plus">New command</Btn>} />
|
||||||
|
<div style={{ flex: 1, overflowY: 'auto', padding: '24px 32px' }}>
|
||||||
|
<SettingsGroup>
|
||||||
|
{QC.map((q, i) => (
|
||||||
|
<div key={q.trigger} style={{
|
||||||
|
display: 'flex', alignItems: 'center', gap: 14, padding: '14px 18px',
|
||||||
|
borderBottom: i === QC.length - 1 ? 'none' : '0.5px solid var(--border)',
|
||||||
|
}}>
|
||||||
|
<span style={{
|
||||||
|
fontFamily: 'var(--font-mono)', fontSize: 12.5, fontWeight: 600,
|
||||||
|
color: 'var(--accent)', background: 'var(--accent-tint)',
|
||||||
|
padding: '4px 9px', borderRadius: 6, minWidth: 80, textAlign: 'center',
|
||||||
|
}}>{q.trigger}</span>
|
||||||
|
<div style={{ flex: 1, minWidth: 0 }}>
|
||||||
|
<div style={{ fontSize: 13, fontWeight: 500 }}>{q.name}</div>
|
||||||
|
<div style={{ fontSize: 12, color: 'var(--fg-muted)', marginTop: 2 }}>{q.desc}</div>
|
||||||
|
</div>
|
||||||
|
<Pill size="sm">{q.personality}</Pill>
|
||||||
|
<span style={{ fontFamily: 'var(--font-mono)', fontSize: 11, color: 'var(--fg-faint)', width: 70, textAlign: 'right' }}>
|
||||||
|
{q.uses} uses
|
||||||
|
</span>
|
||||||
|
<IconBtn icon="more-horizontal" size={26} />
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</SettingsGroup>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
window.QuickCommands = QuickCommands;
|
||||||
|
|
||||||
|
// ─────────────── Platforms ───────────────
|
||||||
|
const PLATFORMS = [
|
||||||
|
{ id: 'github', name: 'GitHub', desc: 'Repos, issues, PRs', conn: true, scope: 'org/wizemann · 14 repos' },
|
||||||
|
{ id: 'linear', name: 'Linear', desc: 'Issues & projects', conn: true, scope: 'wizemann · all teams' },
|
||||||
|
{ id: 'slack', name: 'Slack', desc: 'Messaging', conn: false, scope: '—' },
|
||||||
|
{ id: 'notion', name: 'Notion', desc: 'Docs', conn: false, scope: '—' },
|
||||||
|
{ id: 'figma', name: 'Figma', desc: 'Design files', conn: true, scope: 'wizemann-design' },
|
||||||
|
{ id: 'sentry', name: 'Sentry', desc: 'Error monitoring', conn: false, scope: '—' },
|
||||||
|
{ id: 'pagerduty', name: 'PagerDuty', desc: 'On-call', conn: false, scope: '—' },
|
||||||
|
{ id: 'stripe', name: 'Stripe', desc: 'Payments', conn: false, scope: '—' },
|
||||||
|
];
|
||||||
|
|
||||||
|
function Platforms() {
|
||||||
|
React.useEffect(() => { requestAnimationFrame(() => window.lucide && window.lucide.createIcons()); });
|
||||||
|
const palette = { github: '#1F1B16', linear: '#5E6AD2', slack: '#611F69', notion: '#191919',
|
||||||
|
figma: '#F24E1E', sentry: '#362D59', pagerduty: '#06AC38', stripe: '#635BFF' };
|
||||||
|
return (
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', height: '100%' }}>
|
||||||
|
<ContentHeader title="Platforms"
|
||||||
|
subtitle="Higher-level integrations. Each provides one or more MCP servers and credentials."
|
||||||
|
actions={<Btn icon="external-link">Browse marketplace</Btn>} />
|
||||||
|
<div style={{ flex: 1, overflowY: 'auto', padding: 32 }}>
|
||||||
|
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(260px, 1fr))', gap: 14 }}>
|
||||||
|
{PLATFORMS.map(p => (
|
||||||
|
<Card key={p.id} interactive padding={18}>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 12, marginBottom: 14 }}>
|
||||||
|
<div style={{
|
||||||
|
width: 38, height: 38, borderRadius: 9, background: palette[p.id] || '#888', color: '#fff',
|
||||||
|
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||||
|
fontFamily: 'var(--font-display)', fontSize: 18, fontWeight: 700,
|
||||||
|
}}>{p.name[0]}</div>
|
||||||
|
<div style={{ flex: 1 }}>
|
||||||
|
<div style={{ fontSize: 14, fontWeight: 600 }}>{p.name}</div>
|
||||||
|
<div style={{ fontSize: 11.5, color: 'var(--fg-muted)' }}>{p.desc}</div>
|
||||||
|
</div>
|
||||||
|
{p.conn && <Pill tone="green" dot size="sm">on</Pill>}
|
||||||
|
</div>
|
||||||
|
<div style={{ fontSize: 11, color: 'var(--fg-faint)', fontFamily: 'var(--font-mono)', marginBottom: 12,
|
||||||
|
whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>{p.scope}</div>
|
||||||
|
<Btn fullWidth size="sm" kind={p.conn ? 'secondary' : 'primary'}
|
||||||
|
icon={p.conn ? 'settings' : 'plug'}>
|
||||||
|
{p.conn ? 'Configure' : 'Connect'}
|
||||||
|
</Btn>
|
||||||
|
</Card>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
window.Platforms = Platforms;
|
||||||
|
|
||||||
|
// ─────────────── Credentials ───────────────
|
||||||
|
const CREDS = [
|
||||||
|
{ name: 'ANTHROPIC_API_KEY', kind: 'api-key', source: 'Keychain', last: '2m ago', scope: 'global', value: 'sk-ant-•••••••••a4f2' },
|
||||||
|
{ name: 'GITHUB_TOKEN', kind: 'oauth', source: 'OAuth', last: '14m ago', scope: 'global', value: 'gho_•••••••••••3kP9' },
|
||||||
|
{ name: 'LINEAR_TOKEN', kind: 'oauth', source: 'OAuth', last: '2h ago', scope: 'global', value: 'lin_oauth_•••••8m2x' },
|
||||||
|
{ name: 'POSTGRES_URL', kind: 'secret', source: 'env (.env)', last: '4h ago', scope: 'project · sera', value: 'postgres://ro@•••' },
|
||||||
|
{ name: 'OPENAI_API_KEY', kind: 'api-key', source: 'Keychain', last: 'never', scope: 'global', value: 'sk-•••••••••••L7Pw' },
|
||||||
|
{ name: 'AWS_ACCESS_KEY_ID', kind: 'secret', source: '~/.aws/credentials', last: '1d ago', scope: 'global', value: 'AKIA•••••••••QZX' },
|
||||||
|
];
|
||||||
|
|
||||||
|
function Credentials() {
|
||||||
|
React.useEffect(() => { requestAnimationFrame(() => window.lucide && window.lucide.createIcons()); });
|
||||||
|
const [reveal, setReveal] = React.useState({});
|
||||||
|
return (
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', height: '100%' }}>
|
||||||
|
<ContentHeader title="Credentials"
|
||||||
|
subtitle="API keys, OAuth tokens, and secrets the agent can read. Stored in OS keychain by default."
|
||||||
|
actions={<Btn kind="primary" icon="plus">Add credential</Btn>} />
|
||||||
|
<div style={{ flex: 1, overflowY: 'auto', padding: '24px 32px' }}>
|
||||||
|
<div style={{
|
||||||
|
background: 'var(--accent-tint)', border: '0.5px solid var(--accent)',
|
||||||
|
borderRadius: 9, padding: 12, marginBottom: 20, display: 'flex', alignItems: 'flex-start', gap: 10,
|
||||||
|
}}>
|
||||||
|
<i data-lucide="shield" style={{ width: 16, height: 16, color: 'var(--accent)', marginTop: 1 }}></i>
|
||||||
|
<div style={{ fontSize: 12.5, color: 'var(--fg)', lineHeight: 1.5 }}>
|
||||||
|
Credentials are never sent to Anthropic. They're injected into tool calls at the local gateway.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<SettingsGroup>
|
||||||
|
{CREDS.map((c, i) => (
|
||||||
|
<div key={c.name} style={{
|
||||||
|
display: 'flex', alignItems: 'center', gap: 14, padding: '14px 18px',
|
||||||
|
borderBottom: i === CREDS.length - 1 ? 'none' : '0.5px solid var(--border)',
|
||||||
|
}}>
|
||||||
|
<i data-lucide={c.kind === 'oauth' ? 'key-round' : c.kind === 'api-key' ? 'key' : 'lock'}
|
||||||
|
style={{ width: 16, height: 16, color: 'var(--fg-muted)', flexShrink: 0 }}></i>
|
||||||
|
<div style={{ flex: 1, minWidth: 0 }}>
|
||||||
|
<div style={{ fontFamily: 'var(--font-mono)', fontSize: 13, fontWeight: 500 }}>{c.name}</div>
|
||||||
|
<div style={{ fontSize: 11, color: 'var(--fg-faint)', marginTop: 2 }}>
|
||||||
|
{c.source} · {c.scope} · used {c.last}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<code style={{
|
||||||
|
fontFamily: 'var(--font-mono)', fontSize: 11.5, color: 'var(--fg-muted)',
|
||||||
|
background: 'var(--bg-quaternary)', padding: '3px 8px', borderRadius: 5, width: 220, textAlign: 'center',
|
||||||
|
}}>
|
||||||
|
{reveal[c.name] ? c.value.replace(/•+/g, '************') : c.value}
|
||||||
|
</code>
|
||||||
|
<IconBtn icon={reveal[c.name] ? 'eye-off' : 'eye'} size={26}
|
||||||
|
onClick={() => setReveal({ ...reveal, [c.name]: !reveal[c.name] })} />
|
||||||
|
<IconBtn icon="copy" size={26} />
|
||||||
|
<IconBtn icon="trash-2" size={26} />
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</SettingsGroup>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
window.Credentials = Credentials;
|
||||||
|
|
||||||
|
// ─────────────── Plugins ───────────────
|
||||||
|
const PLUGINS = [
|
||||||
|
{ id: 'commit-message', name: 'Smart commits', desc: 'Generate conventional-commit messages from staged changes.', author: 'wizemann', enabled: true, hooks: ['pre-commit'] },
|
||||||
|
{ id: 'review-helper', name: 'Review helper', desc: 'Auto-tag PR reviewers based on touched paths.', author: 'wizemann', enabled: true, hooks: ['pr-open'] },
|
||||||
|
{ id: 'todo-extractor', name: 'TODO extractor', desc: 'Surface inline TODOs as a checklist on the dashboard.', author: 'community', enabled: false, hooks: ['session-start'] },
|
||||||
|
{ id: 'speak', name: 'Speak responses', desc: 'Read agent responses aloud via system TTS.', author: 'community', enabled: false, hooks: ['turn-end'] },
|
||||||
|
];
|
||||||
|
|
||||||
|
function Plugins() {
|
||||||
|
React.useEffect(() => { requestAnimationFrame(() => window.lucide && window.lucide.createIcons()); });
|
||||||
|
return (
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', height: '100%' }}>
|
||||||
|
<ContentHeader title="Plugins"
|
||||||
|
subtitle="Local extensions that hook into agent and editor lifecycle events"
|
||||||
|
actions={<><Btn icon="external-link">Marketplace</Btn><Btn kind="primary" icon="plus">Install</Btn></>} />
|
||||||
|
<div style={{ flex: 1, overflowY: 'auto', padding: '24px 32px' }}>
|
||||||
|
<SettingsGroup>
|
||||||
|
{PLUGINS.map((p, i) => (
|
||||||
|
<div key={p.id} style={{
|
||||||
|
display: 'flex', alignItems: 'center', gap: 14, padding: '14px 18px',
|
||||||
|
borderBottom: i === PLUGINS.length - 1 ? 'none' : '0.5px solid var(--border)',
|
||||||
|
}}>
|
||||||
|
<div style={{
|
||||||
|
width: 32, height: 32, borderRadius: 7, background: 'var(--accent-tint)', color: 'var(--accent)',
|
||||||
|
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||||
|
}}>
|
||||||
|
<i data-lucide="puzzle" style={{ width: 15, height: 15 }}></i>
|
||||||
|
</div>
|
||||||
|
<div style={{ flex: 1, minWidth: 0 }}>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||||
|
<span style={{ fontSize: 13, fontWeight: 500 }}>{p.name}</span>
|
||||||
|
<span style={{ fontSize: 11, color: 'var(--fg-faint)', fontFamily: 'var(--font-mono)' }}>by {p.author}</span>
|
||||||
|
</div>
|
||||||
|
<div style={{ fontSize: 12, color: 'var(--fg-muted)', marginTop: 2 }}>{p.desc}</div>
|
||||||
|
<div style={{ display: 'flex', gap: 4, marginTop: 6 }}>
|
||||||
|
{p.hooks.map(h => <Pill key={h} size="sm">{h}</Pill>)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Toggle on={p.enabled} />
|
||||||
|
<IconBtn icon="more-horizontal" size={26} />
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</SettingsGroup>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
window.Plugins = Plugins;
|
||||||
|
|
||||||
|
// ─────────────── Webhooks ───────────────
|
||||||
|
const WEBHOOKS = [
|
||||||
|
{ name: 'PR opened → review', url: 'https://hooks.scarf.local/pr-review', events: ['github.pr.opened'], status: 'active', last: '2h ago' },
|
||||||
|
{ name: 'Sentry → triage', url: 'https://hooks.scarf.local/sentry-triage', events: ['sentry.issue.created', 'sentry.issue.regression'], status: 'active', last: '14m ago' },
|
||||||
|
{ name: 'Linear cycle → recap', url: 'https://hooks.scarf.local/cycle-recap', events: ['linear.cycle.completed'], status: 'paused', last: '8d ago' },
|
||||||
|
];
|
||||||
|
|
||||||
|
function Webhooks() {
|
||||||
|
React.useEffect(() => { requestAnimationFrame(() => window.lucide && window.lucide.createIcons()); });
|
||||||
|
return (
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', height: '100%' }}>
|
||||||
|
<ContentHeader title="Webhooks"
|
||||||
|
subtitle="External events that trigger an agent run. Each maps an event payload to a personality + prompt."
|
||||||
|
actions={<Btn kind="primary" icon="plus">New webhook</Btn>} />
|
||||||
|
<div style={{ flex: 1, overflowY: 'auto', padding: '24px 32px' }}>
|
||||||
|
<SettingsGroup>
|
||||||
|
{WEBHOOKS.map((w, i) => (
|
||||||
|
<div key={w.name} style={{
|
||||||
|
display: 'flex', alignItems: 'center', gap: 14, padding: '14px 18px',
|
||||||
|
borderBottom: i === WEBHOOKS.length - 1 ? 'none' : '0.5px solid var(--border)',
|
||||||
|
}}>
|
||||||
|
<i data-lucide="webhook" style={{ width: 16, height: 16, color: 'var(--fg-muted)' }}></i>
|
||||||
|
<div style={{ flex: 1, minWidth: 0 }}>
|
||||||
|
<div style={{ fontSize: 13, fontWeight: 500 }}>{w.name}</div>
|
||||||
|
<div style={{ fontSize: 11, color: 'var(--fg-faint)', fontFamily: 'var(--font-mono)', marginTop: 2,
|
||||||
|
whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>{w.url}</div>
|
||||||
|
<div style={{ display: 'flex', gap: 4, marginTop: 6 }}>
|
||||||
|
{w.events.map(e => <Pill key={e} size="sm">{e}</Pill>)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{w.status === 'active'
|
||||||
|
? <Pill tone="green" dot>active</Pill>
|
||||||
|
: <Pill tone="gray" dot>paused</Pill>}
|
||||||
|
<span style={{ fontFamily: 'var(--font-mono)', fontSize: 11, color: 'var(--fg-faint)', width: 80, textAlign: 'right' }}>{w.last}</span>
|
||||||
|
<IconBtn icon="more-horizontal" size={26} />
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</SettingsGroup>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
window.Webhooks = Webhooks;
|
||||||
|
|
||||||
|
// ─────────────── Profiles ───────────────
|
||||||
|
const PROFILES = [
|
||||||
|
{ id: 'dev', name: 'Development', desc: 'Permissive — auto-approve writes & execs in dev branches.', active: true, policies: 14 },
|
||||||
|
{ id: 'review', name: 'Code review', desc: 'Read-only filesystem, no execute, network only via MCP.', active: false, policies: 8 },
|
||||||
|
{ id: 'prod', name: 'Production', desc: 'All writes & execs require approval. No deletions.', active: false, policies: 22 },
|
||||||
|
{ id: 'air-gap', name: 'Air-gapped', desc: 'No network. Local tools only. For sensitive code paths.', active: false, policies: 6 },
|
||||||
|
];
|
||||||
|
|
||||||
|
function Profiles() {
|
||||||
|
React.useEffect(() => { requestAnimationFrame(() => window.lucide && window.lucide.createIcons()); });
|
||||||
|
return (
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', height: '100%' }}>
|
||||||
|
<ContentHeader title="Profiles"
|
||||||
|
subtitle="Bundles of policies you switch between per-project or per-task"
|
||||||
|
actions={<Btn kind="primary" icon="plus">New profile</Btn>} />
|
||||||
|
<div style={{ flex: 1, overflowY: 'auto', padding: 32 }}>
|
||||||
|
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(280px, 1fr))', gap: 14 }}>
|
||||||
|
{PROFILES.map(p => (
|
||||||
|
<Card key={p.id} interactive padding={20}
|
||||||
|
style={{ borderColor: p.active ? 'var(--accent)' : 'var(--border)',
|
||||||
|
boxShadow: p.active ? '0 0 0 2px var(--accent-tint)' : 'var(--shadow-sm)' }}>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 10, marginBottom: 8 }}>
|
||||||
|
<i data-lucide="user-cog" style={{ width: 18, height: 18,
|
||||||
|
color: p.active ? 'var(--accent)' : 'var(--fg-muted)' }}></i>
|
||||||
|
<div style={{ fontSize: 15, fontWeight: 600, flex: 1 }}>{p.name}</div>
|
||||||
|
{p.active && <Pill tone="accent" dot>active</Pill>}
|
||||||
|
</div>
|
||||||
|
<div style={{ fontSize: 12.5, color: 'var(--fg-muted)', lineHeight: 1.5, marginBottom: 14, minHeight: 36 }}>{p.desc}</div>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 8, fontSize: 11, color: 'var(--fg-faint)', fontFamily: 'var(--font-mono)' }}>
|
||||||
|
<i data-lucide="shield" style={{ width: 12, height: 12 }}></i>
|
||||||
|
{p.policies} policies
|
||||||
|
<Btn size="sm" style={{ marginLeft: 'auto' }}>{p.active ? 'Edit' : 'Activate'}</Btn>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
window.Profiles = Profiles;
|
||||||
|
|
||||||
|
// ─────────────── Gateway ───────────────
|
||||||
|
function Gateway() {
|
||||||
|
React.useEffect(() => { requestAnimationFrame(() => window.lucide && window.lucide.createIcons()); });
|
||||||
|
return (
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', height: '100%' }}>
|
||||||
|
<ContentHeader title="Gateway"
|
||||||
|
subtitle="Local proxy that routes every model & tool call. Logs, redacts, enforces policies."
|
||||||
|
actions={<Btn icon="rotate-cw">Restart</Btn>} />
|
||||||
|
<div style={{ flex: 1, overflowY: 'auto', padding: '24px 32px' }}>
|
||||||
|
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(4, 1fr)', gap: 10, marginBottom: 24 }}>
|
||||||
|
<StatCard label="Status" value="running" sub="pid 84021 · uptime 4d 2h" accent="var(--green-600)" />
|
||||||
|
<StatCard label="Listening" value=":7421" sub="loopback only" />
|
||||||
|
<StatCard label="Calls (24h)" value="1,284" sub="13 denied · 4 errored" />
|
||||||
|
<StatCard label="Throughput" value="2.4 MB/s" sub="p95: 6.1 MB/s" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<SettingsGroup title="Network">
|
||||||
|
<SettingsRow icon="globe" title="Listen address"
|
||||||
|
description="The gateway binds to this address. Default loopback only."
|
||||||
|
control={<TextInput value="127.0.0.1:7421" mono />} />
|
||||||
|
<SettingsRow icon="lock" title="TLS"
|
||||||
|
description="Use a self-signed cert for outbound to 127.0.0.1."
|
||||||
|
control={<Toggle on={true} />} />
|
||||||
|
<SettingsRow icon="filter" title="Allowed hosts"
|
||||||
|
description="3 entries — api.anthropic.com, mcp.github.com, mcp.linear.app"
|
||||||
|
control={<Btn size="sm">Edit</Btn>} last />
|
||||||
|
</SettingsGroup>
|
||||||
|
|
||||||
|
<SettingsGroup title="Logging & redaction">
|
||||||
|
<SettingsRow icon="file-text" title="Request logging"
|
||||||
|
description="Persist headers + bodies for 7 days."
|
||||||
|
control={<Toggle on={true} />} />
|
||||||
|
<SettingsRow icon="eye-off" title="Redact secrets"
|
||||||
|
description="Mask values matching credential patterns before logging."
|
||||||
|
control={<Toggle on={true} />} />
|
||||||
|
<SettingsRow icon="archive" title="Log retention"
|
||||||
|
description="Older logs are pruned automatically."
|
||||||
|
control={<Select value="7d" options={[
|
||||||
|
{ value: '1d', label: '1 day' }, { value: '7d', label: '7 days' },
|
||||||
|
{ value: '30d', label: '30 days' }, { value: 'forever', label: 'Forever' },
|
||||||
|
]} />} last />
|
||||||
|
</SettingsGroup>
|
||||||
|
|
||||||
|
<SettingsGroup title="Performance">
|
||||||
|
<SettingsRow icon="zap" title="Concurrent requests"
|
||||||
|
control={<TextInput value="16" mono />} />
|
||||||
|
<SettingsRow icon="hourglass" title="Per-call timeout"
|
||||||
|
control={<Select value="60s" options={[
|
||||||
|
{ value: '30s', label: '30 seconds' }, { value: '60s', label: '60 seconds' },
|
||||||
|
{ value: '5m', label: '5 minutes' }, { value: '15m', label: '15 minutes' },
|
||||||
|
]} />} last />
|
||||||
|
</SettingsGroup>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
window.Gateway = Gateway;
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
// Projects — list of project folders the agent operates in.
|
||||||
|
|
||||||
|
function Projects() {
|
||||||
|
const projects = [
|
||||||
|
{ id: 1, name: 'hermes-blog', dir: '~/code/hermes-blog', template: 'awizemann/hermes-blog', sessions: 142, lastRun: '14m ago', cron: 2, status: 'healthy' },
|
||||||
|
{ id: 2, name: 'scarf', dir: '~/code/scarf', template: '—', sessions: 89, lastRun: '42m ago', cron: 0, status: 'healthy' },
|
||||||
|
{ id: 3, name: 'inbox-sweep', dir: '~/code/inbox-sweep', template: 'community/inbox-sweep', sessions: 38, lastRun: '3h ago', cron: 1, status: 'healthy' },
|
||||||
|
{ id: 4, name: 'twitter-recap', dir: '~/code/twitter-recap', template: 'awizemann/twitter-recap', sessions: 14, lastRun: '2d ago', cron: 1, status: 'paused' },
|
||||||
|
{ id: 5, name: 'pr-watcher', dir: '~/code/pr-watcher', template: 'community/pr-watcher', sessions: 4, lastRun: '5d ago', cron: 1, status: 'errored' },
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', height: '100%' }}>
|
||||||
|
<ContentHeader title="Projects"
|
||||||
|
subtitle="Each project pins context, AGENTS.md, cron jobs, and session history"
|
||||||
|
actions={<><Btn icon="folder-plus">Add Existing</Btn><Btn kind="primary" icon="plus">New from Template</Btn></>} />
|
||||||
|
|
||||||
|
<div style={{ flex: 1, overflowY: 'auto', padding: '20px 28px' }}>
|
||||||
|
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(300px, 1fr))', gap: 14 }}>
|
||||||
|
{projects.map(p => (
|
||||||
|
<Card key={p.id} padding={16} style={{ display: 'flex', flexDirection: 'column', gap: 10, cursor: 'pointer' }}>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'flex-start', gap: 10 }}>
|
||||||
|
<div style={{
|
||||||
|
width: 36, height: 36, borderRadius: 8, background: 'var(--accent-tint)',
|
||||||
|
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||||
|
color: 'var(--accent)', flexShrink: 0,
|
||||||
|
}}>
|
||||||
|
<i data-lucide="folder" style={{ width: 18, height: 18 }}></i>
|
||||||
|
</div>
|
||||||
|
<div style={{ flex: 1, minWidth: 0 }}>
|
||||||
|
<div style={{ fontSize: 14, fontWeight: 600 }}>{p.name}</div>
|
||||||
|
<div style={{ fontSize: 11, color: 'var(--fg-muted)',
|
||||||
|
fontFamily: 'var(--font-mono)', overflow: 'hidden',
|
||||||
|
textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{p.dir}</div>
|
||||||
|
</div>
|
||||||
|
{p.status === 'healthy' && <Pill tone="green" dot>healthy</Pill>}
|
||||||
|
{p.status === 'paused' && <Pill tone="gray" dot>paused</Pill>}
|
||||||
|
{p.status === 'errored' && <Pill tone="red" dot>errored</Pill>}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{p.template !== '—' && (
|
||||||
|
<div style={{ fontSize: 11, color: 'var(--fg-muted)',
|
||||||
|
display: 'flex', alignItems: 'center', gap: 5 }}>
|
||||||
|
<i data-lucide="package" style={{ width: 11, height: 11 }}></i>
|
||||||
|
<span style={{ fontFamily: 'var(--font-mono)' }}>{p.template}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div style={{ display: 'flex', gap: 16, paddingTop: 8,
|
||||||
|
borderTop: '0.5px solid var(--border)', fontSize: 11 }}>
|
||||||
|
<div>
|
||||||
|
<div style={{ color: 'var(--fg-muted)' }}>Sessions</div>
|
||||||
|
<div style={{ fontFamily: 'var(--font-mono)', fontSize: 13, fontWeight: 600, marginTop: 1 }}>{p.sessions}</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div style={{ color: 'var(--fg-muted)' }}>Cron jobs</div>
|
||||||
|
<div style={{ fontFamily: 'var(--font-mono)', fontSize: 13, fontWeight: 600, marginTop: 1 }}>{p.cron}</div>
|
||||||
|
</div>
|
||||||
|
<div style={{ marginLeft: 'auto', textAlign: 'right' }}>
|
||||||
|
<div style={{ color: 'var(--fg-muted)' }}>Last run</div>
|
||||||
|
<div style={{ fontSize: 12, marginTop: 1 }}>{p.lastRun}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
))}
|
||||||
|
|
||||||
|
<Card padding={16} style={{
|
||||||
|
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||||
|
border: '1px dashed var(--border-strong)', boxShadow: 'none',
|
||||||
|
background: 'transparent', minHeight: 140, cursor: 'pointer',
|
||||||
|
color: 'var(--fg-muted)', flexDirection: 'column', gap: 8,
|
||||||
|
}}>
|
||||||
|
<i data-lucide="plus" style={{ width: 24, height: 24 }}></i>
|
||||||
|
<div style={{ fontSize: 13, fontWeight: 500 }}>New project</div>
|
||||||
|
<div style={{ fontSize: 11, textAlign: 'center', maxWidth: 180 }}>From template, GitHub repo, or empty folder</div>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
window.Projects = Projects;
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
# Scarf macOS UI Kit
|
||||||
|
|
||||||
|
A high-fidelity React recreation of the Scarf macOS app, built against the codebase at `awizemann/scarf` (SwiftUI). It mirrors the real navigation hierarchy from `SidebarView.swift` and the visual rhythm of the actual SwiftUI views (`Dashboard`, `RichChat`, `Sessions`, `Projects`, `Insights`, etc.).
|
||||||
|
|
||||||
|
This kit is **cosmetic** — it gets the visuals exactly right but doesn't replicate the Swift business logic. Use it as a starting point for new flows, mocks, or marketing screenshots.
|
||||||
|
|
||||||
|
## Run
|
||||||
|
|
||||||
|
Open `index.html` in a browser. No build step.
|
||||||
|
|
||||||
|
## Components
|
||||||
|
|
||||||
|
| File | What it covers |
|
||||||
|
|---|---|
|
||||||
|
| `Common.jsx` | `Btn`, `Pill`, `Card`, `StatCard`, `Field`, `TextInput`, `Toggle`, `EmptyState`, `ContentHeader` |
|
||||||
|
| `Sidebar.jsx` | Sectioned sidebar (Monitor / Projects / Interact / Configure / Manage) — exact section/item list from `SidebarView.swift` |
|
||||||
|
| `Dashboard.jsx` | Status row, 7-day stats, recent sessions, recent activity |
|
||||||
|
| `Sessions.jsx` | Filterable, sortable session table |
|
||||||
|
| `Insights.jsx` | Token-usage chart, by-model and by-tool-kind breakdowns |
|
||||||
|
| `Projects.jsx` | Project grid with template / cron / health badges |
|
||||||
|
| `Chat.jsx` | Three-pane Rich Chat — list, transcript with reasoning + tool-call cards, composer |
|
||||||
|
|
||||||
|
## Faithful to the source
|
||||||
|
|
||||||
|
Replicated 1:1:
|
||||||
|
|
||||||
|
- **Sidebar grouping** — five named sections from `SidebarView.swift` in the same order.
|
||||||
|
- **Tool-kind colors** — `read=green / edit=blue / execute=orange / fetch=purple / browser=indigo / other=gray`, the same tokens used in `ToolCallCard.swift`.
|
||||||
|
- **Reasoning disclosure** — collapsed orange "REASONING · N tokens" header that expands to italic muted text, matching `RichAssistantMessageView`.
|
||||||
|
- **Tool-call card chrome** — left tone-rule, monospace name + truncated arg, success/error/spinner trailing, expandable code preview.
|
||||||
|
- **Status pills** — green/red dot with same word vocabulary (`Running` / `Errored` / `Idle`).
|
||||||
|
- **Type rhythm** — SwiftUI `largeTitle / title1 / title2 / headline / subhead / body / caption` mapped to `--text-*` tokens.
|
||||||
|
|
||||||
|
## Substitutions
|
||||||
|
|
||||||
|
- **Icons** — Lucide for the web. SF Symbols aren't redistributable; Lucide is the closest stroked-line set. Documented in `/README.md` → ICONOGRAPHY.
|
||||||
|
- **Fonts** — system stack first, then Inter (display/text) and JetBrains Mono (mono) loaded from Google Fonts. On macOS users will see SF Pro / SF Mono.
|
||||||
|
- **Window chrome** — three traffic-light dots painted by hand. The starter `macos-window.jsx` was tried first but its sidebar slot didn't match Scarf's layout, so the chrome is inlined in `index.html`.
|
||||||
|
|
||||||
|
## What's intentionally left blank
|
||||||
|
|
||||||
|
The placeholder view wired to every sidebar item that isn't one of the five built screens — Activity, Memory, Skills, Platforms, Personalities, Quick Commands, Credentials, Plugins, Webhooks, Profiles, Tools, MCP Servers, Gateway, Cron, Health, Logs, Settings. Each lands on a polite `EmptyState` so navigation is still satisfying. Build any of them by following `Sessions.jsx` (table view) or `Projects.jsx` (card grid) — Scarf is consistent enough that those two patterns cover almost every CRUD pane.
|
||||||
@@ -0,0 +1,222 @@
|
|||||||
|
// Sessions list view — with filters (incl. project filter) and a detail row.
|
||||||
|
|
||||||
|
function Sessions() {
|
||||||
|
const [filter, setFilter] = React.useState('all');
|
||||||
|
const [project, setProject] = React.useState('all'); // project filter
|
||||||
|
const [projectMenuOpen, setProjectMenuOpen] = React.useState(false);
|
||||||
|
const projectMenuRef = React.useRef();
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
function onDoc(e) {
|
||||||
|
if (projectMenuRef.current && !projectMenuRef.current.contains(e.target)) {
|
||||||
|
setProjectMenuOpen(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (projectMenuOpen) {
|
||||||
|
document.addEventListener('mousedown', onDoc);
|
||||||
|
return () => document.removeEventListener('mousedown', onDoc);
|
||||||
|
}
|
||||||
|
}, [projectMenuOpen]);
|
||||||
|
|
||||||
|
const filters = [
|
||||||
|
{ id: 'all', label: 'All', count: 847 },
|
||||||
|
{ id: 'today', label: 'Today', count: 24 },
|
||||||
|
{ id: 'starred', label: 'Starred', count: 6 },
|
||||||
|
];
|
||||||
|
|
||||||
|
const allRows = [
|
||||||
|
{ id: 1, project: 'scarf', title: 'Cron diagnostics', model: 'sonnet-4.5', msgs: 14, tokens: '12,847', cost: '$0.04', time: '14m ago', status: 'active' },
|
||||||
|
{ id: 2, project: 'hermes-blog', title: 'Release notes draft', model: 'haiku-4.5', msgs: 8, tokens: '3,210', cost: '$0.01', time: '42m ago', status: 'idle' },
|
||||||
|
{ id: 3, project: 'hermes-blog', title: 'PR review summary', model: 'sonnet-4.5', msgs: 22, tokens: '24,108', cost: '$0.08', time: '2h ago', status: 'idle' },
|
||||||
|
{ id: 4, project: '—', title: 'What model handles function calls best?', model: 'haiku-4.5', msgs: 4, tokens: '284', cost: '$0.00', time: '3h ago', status: 'idle' },
|
||||||
|
{ id: 5, project: 'scarf', title: 'Memory layout question', model: 'sonnet-4.5', msgs: 11, tokens: '4,892', cost: '$0.02', time: 'yesterday', status: 'idle' },
|
||||||
|
{ id: 6, project: 'scarf', title: 'Refactor SidebarSection enum', model: 'sonnet-4.5', msgs: 31, tokens: '38,221', cost: '$0.13', time: 'yesterday', status: 'errored' },
|
||||||
|
{ id: 7, project: 'hermes-blog', title: 'Twitter recap thread', model: 'haiku-4.5', msgs: 6, tokens: '1,247', cost: '$0.00', time: '2 days ago', status: 'idle' },
|
||||||
|
{ id: 8, project: '—', title: 'Find a good local TTS model', model: 'sonnet-4.5', msgs: 19, tokens: '8,743', cost: '$0.03', time: '3 days ago', status: 'idle' },
|
||||||
|
];
|
||||||
|
|
||||||
|
// Build project facet — counts per project, plus an "Unscoped" bucket.
|
||||||
|
const projectCounts = allRows.reduce((acc, r) => {
|
||||||
|
acc[r.project] = (acc[r.project] || 0) + 1;
|
||||||
|
return acc;
|
||||||
|
}, {});
|
||||||
|
const projects = [
|
||||||
|
{ id: 'all', label: 'All projects', icon: 'layers', count: allRows.length },
|
||||||
|
...Object.keys(projectCounts).filter(k => k !== '—').sort().map(k => ({
|
||||||
|
id: k, label: k, icon: 'folder', count: projectCounts[k],
|
||||||
|
})),
|
||||||
|
{ id: '—', label: 'Unscoped', icon: 'ghost', count: projectCounts['—'] || 0 },
|
||||||
|
];
|
||||||
|
|
||||||
|
const rows = allRows.filter(r => project === 'all' ? true : r.project === project);
|
||||||
|
const activeProject = projects.find(p => p.id === project) || projects[0];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', height: '100%' }}>
|
||||||
|
<ContentHeader title="Sessions"
|
||||||
|
subtitle="Every conversation across projects, agents, and models"
|
||||||
|
actions={<><Btn icon="filter">Filter</Btn><Btn icon="download">Export</Btn></>} />
|
||||||
|
|
||||||
|
<div style={{ padding: '14px 28px 0', display: 'flex', alignItems: 'center', gap: 8, flexWrap: 'wrap' }}>
|
||||||
|
{filters.map(f => (
|
||||||
|
<div key={f.id} onClick={() => setFilter(f.id)} style={{
|
||||||
|
padding: '4px 11px', borderRadius: 999, cursor: 'pointer', fontSize: 12,
|
||||||
|
fontWeight: 500,
|
||||||
|
background: filter === f.id ? 'var(--accent)' : 'var(--bg-quaternary)',
|
||||||
|
color: filter === f.id ? '#fff' : 'var(--fg)',
|
||||||
|
display: 'flex', alignItems: 'center', gap: 5,
|
||||||
|
}}>{f.label}<span style={{
|
||||||
|
opacity: 0.7, fontFamily: 'var(--font-mono)',
|
||||||
|
}}>{f.count}</span></div>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{/* Vertical separator */}
|
||||||
|
<div style={{ width: 1, height: 18, background: 'var(--border)', margin: '0 4px' }}></div>
|
||||||
|
|
||||||
|
{/* Project filter chip — opens a dropdown */}
|
||||||
|
<div ref={projectMenuRef} style={{ position: 'relative' }}>
|
||||||
|
<div onClick={() => setProjectMenuOpen(o => !o)} style={{
|
||||||
|
padding: '4px 6px 4px 11px', borderRadius: 999, cursor: 'pointer', fontSize: 12,
|
||||||
|
fontWeight: 500,
|
||||||
|
background: project !== 'all' ? 'var(--accent-tint)' : 'var(--bg-quaternary)',
|
||||||
|
color: project !== 'all' ? 'var(--accent-active)' : 'var(--fg)',
|
||||||
|
border: project !== 'all' ? '1px solid var(--accent)' : '1px solid transparent',
|
||||||
|
display: 'flex', alignItems: 'center', gap: 6,
|
||||||
|
}}>
|
||||||
|
<i data-lucide={activeProject.icon}
|
||||||
|
style={{ width: 12, height: 12 }}></i>
|
||||||
|
<span>{activeProject.label}</span>
|
||||||
|
<span style={{ opacity: 0.7, fontFamily: 'var(--font-mono)' }}>{activeProject.count}</span>
|
||||||
|
{project !== 'all' && (
|
||||||
|
<i data-lucide="x" onClick={(e) => { e.stopPropagation(); setProject('all'); }}
|
||||||
|
style={{ width: 12, height: 12, marginLeft: 2, padding: 1, borderRadius: 3 }}></i>
|
||||||
|
)}
|
||||||
|
{project === 'all' && (
|
||||||
|
<i data-lucide="chevron-down" style={{ width: 12, height: 12, marginLeft: 2, opacity: 0.7 }}></i>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{projectMenuOpen && (
|
||||||
|
<div style={{
|
||||||
|
position: 'absolute', top: '100%', left: 0, marginTop: 6, zIndex: 50,
|
||||||
|
minWidth: 220, padding: 4, background: 'var(--bg-card)',
|
||||||
|
border: '0.5px solid var(--border)', borderRadius: 9,
|
||||||
|
boxShadow: 'var(--shadow-lg)', fontFamily: 'var(--font-sans)',
|
||||||
|
}}>
|
||||||
|
<div style={{
|
||||||
|
padding: '6px 10px 4px', fontSize: 10, fontWeight: 600,
|
||||||
|
color: 'var(--fg-muted)', textTransform: 'uppercase', letterSpacing: '0.06em',
|
||||||
|
}}>Filter by project</div>
|
||||||
|
{projects.map(p => {
|
||||||
|
const active = p.id === project;
|
||||||
|
return (
|
||||||
|
<div key={p.id} onClick={() => { setProject(p.id); setProjectMenuOpen(false); }}
|
||||||
|
style={{
|
||||||
|
display: 'flex', alignItems: 'center', gap: 9, padding: '6px 10px',
|
||||||
|
borderRadius: 6, cursor: 'pointer', fontSize: 13,
|
||||||
|
background: active ? 'var(--accent-tint)' : 'transparent',
|
||||||
|
color: active ? 'var(--accent-active)' : 'var(--fg)',
|
||||||
|
}}
|
||||||
|
onMouseEnter={e => { if (!active) e.currentTarget.style.background = 'var(--bg-quaternary)'; }}
|
||||||
|
onMouseLeave={e => { if (!active) e.currentTarget.style.background = 'transparent'; }}
|
||||||
|
>
|
||||||
|
<i data-lucide={p.icon} style={{ width: 13, height: 13,
|
||||||
|
color: active ? 'var(--accent-active)' : 'var(--fg-muted)' }}></i>
|
||||||
|
<span style={{ flex: 1,
|
||||||
|
fontStyle: p.id === '—' ? 'italic' : 'normal',
|
||||||
|
color: p.id === '—' && !active ? 'var(--fg-muted)' : 'inherit' }}>{p.label}</span>
|
||||||
|
<span style={{ fontFamily: 'var(--font-mono)', fontSize: 11,
|
||||||
|
color: active ? 'var(--accent-active)' : 'var(--fg-faint)' }}>{p.count}</span>
|
||||||
|
{active && <i data-lucide="check" style={{ width: 13, height: 13 }}></i>}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ marginLeft: 'auto', position: 'relative' }}>
|
||||||
|
<i data-lucide="search" style={{
|
||||||
|
position: 'absolute', left: 8, top: 6, width: 13, height: 13, color: 'var(--fg-faint)'
|
||||||
|
}}></i>
|
||||||
|
<input placeholder="Search sessions…" style={{
|
||||||
|
width: 200, padding: '4px 10px 4px 28px',
|
||||||
|
border: '1px solid var(--border-strong)', borderRadius: 6,
|
||||||
|
fontSize: 12, background: 'var(--bg-card)', outline: 'none',
|
||||||
|
fontFamily: 'var(--font-sans)',
|
||||||
|
}} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Active filter summary */}
|
||||||
|
{project !== 'all' && (
|
||||||
|
<div style={{ padding: '8px 28px 0', fontSize: 12, color: 'var(--fg-muted)' }}>
|
||||||
|
Showing {rows.length} session{rows.length === 1 ? '' : 's'} from
|
||||||
|
{' '}<span style={{ color: 'var(--fg)', fontWeight: 500 }}>{activeProject.label}</span>
|
||||||
|
{' '}·{' '}
|
||||||
|
<span onClick={() => setProject('all')} style={{
|
||||||
|
color: 'var(--accent-active)', cursor: 'pointer', textDecoration: 'underline',
|
||||||
|
textDecorationStyle: 'dotted', textUnderlineOffset: 3,
|
||||||
|
}}>clear filter</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div style={{ flex: 1, overflowY: 'auto', padding: '14px 28px 28px' }}>
|
||||||
|
<Card padding={0}>
|
||||||
|
<div style={{
|
||||||
|
display: 'grid', gridTemplateColumns: '120px 1fr 110px 60px 90px 70px 80px 24px',
|
||||||
|
padding: '8px 14px', borderBottom: '0.5px solid var(--border)',
|
||||||
|
fontSize: 11, color: 'var(--fg-muted)', fontWeight: 600,
|
||||||
|
textTransform: 'uppercase', letterSpacing: '0.05em',
|
||||||
|
}}>
|
||||||
|
<div>Project</div><div>Title</div><div>Model</div>
|
||||||
|
<div style={{ textAlign: 'right' }}>Msgs</div>
|
||||||
|
<div style={{ textAlign: 'right' }}>Tokens</div>
|
||||||
|
<div style={{ textAlign: 'right' }}>Cost</div>
|
||||||
|
<div style={{ textAlign: 'right' }}>Updated</div>
|
||||||
|
<div></div>
|
||||||
|
</div>
|
||||||
|
{rows.length === 0 && (
|
||||||
|
<div style={{ padding: 48, textAlign: 'center', color: 'var(--fg-muted)', fontSize: 13 }}>
|
||||||
|
No sessions match this filter.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{rows.map(r => (
|
||||||
|
<div key={r.id} style={{
|
||||||
|
display: 'grid', gridTemplateColumns: '120px 1fr 110px 60px 90px 70px 80px 24px',
|
||||||
|
padding: '10px 14px', borderBottom: '0.5px solid var(--border)',
|
||||||
|
alignItems: 'center', fontSize: 13, cursor: 'pointer', gap: 6,
|
||||||
|
}} onMouseEnter={e => e.currentTarget.style.background = 'var(--bg-quaternary)'}
|
||||||
|
onMouseLeave={e => e.currentTarget.style.background = 'transparent'}>
|
||||||
|
<div>
|
||||||
|
{r.project !== '—'
|
||||||
|
? <span onClick={(e) => { e.stopPropagation(); setProject(r.project); }}
|
||||||
|
title={`Filter by ${r.project}`}
|
||||||
|
style={{ display: 'inline-block' }}>
|
||||||
|
<Pill tone="accent">{r.project}</Pill>
|
||||||
|
</span>
|
||||||
|
: <span style={{ color: 'var(--fg-faint)', fontSize: 11 }}>—</span>}
|
||||||
|
</div>
|
||||||
|
<div style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap',
|
||||||
|
display: 'flex', gap: 6, alignItems: 'center' }}>
|
||||||
|
{r.status === 'active' && <span style={{ width: 7, height: 7, borderRadius: '50%', background: 'var(--green-500)' }}></span>}
|
||||||
|
{r.status === 'errored' && <span style={{ width: 7, height: 7, borderRadius: '50%', background: 'var(--red-500)' }}></span>}
|
||||||
|
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{r.title}</span>
|
||||||
|
</div>
|
||||||
|
<div style={{ fontFamily: 'var(--font-mono)', fontSize: 11, color: 'var(--fg-muted)' }}>{r.model}</div>
|
||||||
|
<div style={{ fontFamily: 'var(--font-mono)', fontSize: 12, textAlign: 'right' }}>{r.msgs}</div>
|
||||||
|
<div style={{ fontFamily: 'var(--font-mono)', fontSize: 12, textAlign: 'right' }}>{r.tokens}</div>
|
||||||
|
<div style={{ fontFamily: 'var(--font-mono)', fontSize: 12, textAlign: 'right', color: 'var(--fg-muted)' }}>{r.cost}</div>
|
||||||
|
<div style={{ fontSize: 11, color: 'var(--fg-faint)', textAlign: 'right' }}>{r.time}</div>
|
||||||
|
<div style={{ color: 'var(--fg-faint)' }}>
|
||||||
|
<i data-lucide="chevron-right" style={{ width: 14, height: 14 }}></i>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
window.Sessions = Sessions;
|
||||||
@@ -0,0 +1,189 @@
|
|||||||
|
// Settings — global preferences. One scrollable page with grouped settings.
|
||||||
|
|
||||||
|
function Settings() {
|
||||||
|
React.useEffect(() => { requestAnimationFrame(() => window.lucide && window.lucide.createIcons()); });
|
||||||
|
const [tab, setTab] = React.useState('general');
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', height: '100%' }}>
|
||||||
|
<ContentHeader title="Settings" subtitle="Global preferences for Scarf. Per-project overrides live in each project." />
|
||||||
|
<div style={{ padding: '12px 32px 0', borderBottom: '0.5px solid var(--border)', background: 'var(--bg-card)' }}>
|
||||||
|
<Tabs value={tab} onChange={setTab} options={[
|
||||||
|
{ value: 'general', label: 'General', icon: 'sliders-horizontal' },
|
||||||
|
{ value: 'appearance', label: 'Appearance', icon: 'palette' },
|
||||||
|
{ value: 'agent', label: 'Agent', icon: 'cpu' },
|
||||||
|
{ value: 'permissions', label: 'Permissions', icon: 'shield' },
|
||||||
|
{ value: 'account', label: 'Account', icon: 'user-circle' },
|
||||||
|
{ value: 'advanced', label: 'Advanced', icon: 'wrench' },
|
||||||
|
]} />
|
||||||
|
</div>
|
||||||
|
<div style={{ flex: 1, overflowY: 'auto', padding: '24px 32px', maxWidth: 880 }}>
|
||||||
|
{tab === 'general' && <GeneralTab />}
|
||||||
|
{tab === 'appearance' && <AppearanceTab />}
|
||||||
|
{tab === 'agent' && <AgentTab />}
|
||||||
|
{tab === 'permissions' && <PermissionsTab />}
|
||||||
|
{tab === 'account' && <AccountTab />}
|
||||||
|
{tab === 'advanced' && <AdvancedTab />}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function GeneralTab() {
|
||||||
|
return <>
|
||||||
|
<SettingsGroup title="Workspace">
|
||||||
|
<SettingsRow icon="folder" title="Default project location"
|
||||||
|
description="New projects are created here unless overridden."
|
||||||
|
control={<Btn size="sm" icon="folder-open">~/Projects</Btn>} />
|
||||||
|
<SettingsRow icon="terminal" title="Default shell"
|
||||||
|
description="Used when the agent runs commands."
|
||||||
|
control={<Select value="zsh" options={[{value:'zsh',label:'/bin/zsh'},{value:'bash',label:'/bin/bash'},{value:'fish',label:'/usr/local/bin/fish'}]} />} />
|
||||||
|
<SettingsRow icon="globe" title="Locale" description="Affects date and number formatting."
|
||||||
|
control={<Select value="en-US" options={[{value:'en-US',label:'English (US)'},{value:'en-GB',label:'English (UK)'},{value:'de-DE',label:'Deutsch'}]} />} last />
|
||||||
|
</SettingsGroup>
|
||||||
|
<SettingsGroup title="Notifications">
|
||||||
|
<SettingsRow icon="bell" title="Approval requests" description="Notify when the agent needs permission to run a tool."
|
||||||
|
control={<Toggle on={true} />} />
|
||||||
|
<SettingsRow icon="check-circle" title="Run completion" description="Ping when long-running tasks finish."
|
||||||
|
control={<Toggle on={true} />} />
|
||||||
|
<SettingsRow icon="alert-triangle" title="Errors only" description="Suppress non-error notifications."
|
||||||
|
control={<Toggle on={false} />} last />
|
||||||
|
</SettingsGroup>
|
||||||
|
<SettingsGroup title="Updates">
|
||||||
|
<SettingsRow icon="download" title="Auto-update Scarf"
|
||||||
|
description="Currently on 0.14.2 — 0.15.0 available."
|
||||||
|
control={<Btn size="sm" kind="primary">Install 0.15.0</Btn>} last />
|
||||||
|
</SettingsGroup>
|
||||||
|
</>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function AppearanceTab() {
|
||||||
|
return <>
|
||||||
|
<SettingsGroup title="Theme">
|
||||||
|
<SettingsRow icon="sun" title="Color mode" description="Light is the only mode shipped in this kit."
|
||||||
|
control={<Segmented value="light" options={[
|
||||||
|
{ value: 'light', label: 'Light', icon: 'sun' },
|
||||||
|
{ value: 'dark', label: 'Dark', icon: 'moon' },
|
||||||
|
{ value: 'auto', label: 'Auto', icon: 'monitor' },
|
||||||
|
]} />} />
|
||||||
|
<SettingsRow icon="droplet" title="Accent" description="Scarf uses a warm rust accent across the app."
|
||||||
|
control={
|
||||||
|
<div style={{ display: 'flex', gap: 6 }}>
|
||||||
|
{['#C25A2A','#A8741F','#7E5BA9','#3F8A6E','#3F6BA9','#1F1B16'].map((c,i) =>
|
||||||
|
<div key={i} style={{ width: 22, height: 22, borderRadius: 11, background: c,
|
||||||
|
border: i === 0 ? '2px solid var(--fg)' : '0.5px solid var(--border)', cursor: 'pointer' }} />)}
|
||||||
|
</div>} last />
|
||||||
|
</SettingsGroup>
|
||||||
|
<SettingsGroup title="Density & type">
|
||||||
|
<SettingsRow icon="rows-3" title="UI density"
|
||||||
|
control={<Segmented value="comfy" options={[
|
||||||
|
{ value: 'compact', label: 'Compact' }, { value: 'comfy', label: 'Comfortable' }, { value: 'roomy', label: 'Roomy' },
|
||||||
|
]} />} />
|
||||||
|
<SettingsRow icon="type" title="Mono font" description="Used in code blocks, logs, and identifiers."
|
||||||
|
control={<Select value="berkeley" options={[
|
||||||
|
{ value: 'berkeley', label: 'Berkeley Mono' },
|
||||||
|
{ value: 'jetbrains', label: 'JetBrains Mono' },
|
||||||
|
{ value: 'sf-mono', label: 'SF Mono' },
|
||||||
|
]} />} last />
|
||||||
|
</SettingsGroup>
|
||||||
|
</>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function AgentTab() {
|
||||||
|
return <>
|
||||||
|
<SettingsGroup title="Default model" description="Used when no personality overrides it.">
|
||||||
|
<SettingsRow icon="sparkles" title="Model"
|
||||||
|
control={<Select value="sonnet" options={[
|
||||||
|
{ value: 'sonnet', label: 'claude-sonnet-4.5' }, { value: 'opus', label: 'claude-opus-4.1' }, { value: 'haiku', label: 'claude-haiku-4.5' },
|
||||||
|
]} />} />
|
||||||
|
<SettingsRow icon="thermometer" title="Temperature"
|
||||||
|
description="Lower is more deterministic. Default 0.4."
|
||||||
|
control={<TextInput value="0.4" mono />} />
|
||||||
|
<SettingsRow icon="cpu" title="Max tokens out"
|
||||||
|
control={<TextInput value="4096" mono />} last />
|
||||||
|
</SettingsGroup>
|
||||||
|
<SettingsGroup title="Behavior">
|
||||||
|
<SettingsRow icon="message-square" title="Stream responses"
|
||||||
|
control={<Toggle on={true} />} />
|
||||||
|
<SettingsRow icon="fast-forward" title="Aggressive tool batching"
|
||||||
|
description="Allow multiple parallel tool calls per turn." control={<Toggle on={true} />} />
|
||||||
|
<SettingsRow icon="rotate-cw" title="Retry on transient errors"
|
||||||
|
control={<Toggle on={true} />} last />
|
||||||
|
</SettingsGroup>
|
||||||
|
</>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function PermissionsTab() {
|
||||||
|
return <>
|
||||||
|
<SettingsGroup title="Defaults" description="Override per-tool in Tools, per-project in each project.">
|
||||||
|
<SettingsRow icon="book-open" title="Read filesystem"
|
||||||
|
control={<Pill tone="green" dot>auto</Pill>} />
|
||||||
|
<SettingsRow icon="file-edit" title="Write filesystem"
|
||||||
|
control={<Pill tone="amber" dot>approve</Pill>} />
|
||||||
|
<SettingsRow icon="terminal" title="Execute commands"
|
||||||
|
control={<Pill tone="amber" dot>approve</Pill>} />
|
||||||
|
<SettingsRow icon="globe" title="Network access"
|
||||||
|
control={<Pill tone="green" dot>auto</Pill>} last />
|
||||||
|
</SettingsGroup>
|
||||||
|
<SettingsGroup title="Deny rules" description="Patterns the agent can never run.">
|
||||||
|
<SettingsRow icon="ban" title="rm -rf /"
|
||||||
|
control={<Btn size="sm">Edit</Btn>} />
|
||||||
|
<SettingsRow icon="ban" title="git push --force* (origin/main, origin/prod)"
|
||||||
|
control={<Btn size="sm">Edit</Btn>} />
|
||||||
|
<SettingsRow icon="plus" title="Add rule" control={<Btn size="sm" kind="primary">New</Btn>} last />
|
||||||
|
</SettingsGroup>
|
||||||
|
</>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function AccountTab() {
|
||||||
|
return <>
|
||||||
|
<SettingsGroup title="Account">
|
||||||
|
<SettingsRow icon="user-circle" title="Aurora Wong"
|
||||||
|
description="aurora@wizemann.com — connected via Anthropic Console"
|
||||||
|
control={<Btn size="sm">Sign out</Btn>} last />
|
||||||
|
</SettingsGroup>
|
||||||
|
<SettingsGroup title="Plan & billing">
|
||||||
|
<SettingsRow icon="zap" title="Team — 5 seats"
|
||||||
|
description="Renews May 12. $99/mo."
|
||||||
|
control={<Btn size="sm" icon="external-link">Manage</Btn>} />
|
||||||
|
<SettingsRow icon="bar-chart-2" title="Usage this month"
|
||||||
|
description="$42.18 of $200 cap"
|
||||||
|
control={<div style={{ width: 140 }}><ProgressBar value={21} /></div>} last />
|
||||||
|
</SettingsGroup>
|
||||||
|
<SettingsGroup title="Danger zone">
|
||||||
|
<SettingsRow icon="trash-2" title="Reset all settings"
|
||||||
|
description="Returns Scarf to defaults. Projects and history are preserved."
|
||||||
|
control={<Btn size="sm" kind="danger">Reset</Btn>} />
|
||||||
|
<SettingsRow icon="x-circle" title="Delete account"
|
||||||
|
description="Permanently delete this account. This cannot be undone."
|
||||||
|
control={<Btn size="sm" kind="danger-solid">Delete</Btn>} last />
|
||||||
|
</SettingsGroup>
|
||||||
|
</>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function AdvancedTab() {
|
||||||
|
return <>
|
||||||
|
<SettingsGroup title="Telemetry">
|
||||||
|
<SettingsRow icon="bar-chart" title="Anonymous usage data"
|
||||||
|
description="Helps improve Scarf. No prompts or file contents are sent."
|
||||||
|
control={<Toggle on={true} />} />
|
||||||
|
<SettingsRow icon="bug" title="Crash reports"
|
||||||
|
control={<Toggle on={true} />} last />
|
||||||
|
</SettingsGroup>
|
||||||
|
<SettingsGroup title="Experimental">
|
||||||
|
<SettingsRow icon="flask-conical" title="Multi-agent fan-out"
|
||||||
|
description="Let one agent spawn focused subagents." control={<Toggle on={false} />} />
|
||||||
|
<SettingsRow icon="flask-conical" title="Background reasoning"
|
||||||
|
description="Pre-compute likely next steps while you type." control={<Toggle on={false} />} last />
|
||||||
|
</SettingsGroup>
|
||||||
|
<SettingsGroup title="Storage" description="Local-only data on this device.">
|
||||||
|
<SettingsRow icon="database" title="Project history" description="14.2 GB across 11 projects"
|
||||||
|
control={<Btn size="sm">Manage</Btn>} />
|
||||||
|
<SettingsRow icon="hard-drive" title="Cache"
|
||||||
|
description="412 MB"
|
||||||
|
control={<Btn size="sm">Clear</Btn>} last />
|
||||||
|
</SettingsGroup>
|
||||||
|
</>;
|
||||||
|
}
|
||||||
|
|
||||||
|
window.Settings = Settings;
|
||||||
@@ -0,0 +1,95 @@
|
|||||||
|
// Scarf Sidebar — sectioned nav matching SidebarView.swift
|
||||||
|
// Sections: Monitor / Projects / Interact / Configure / Manage
|
||||||
|
|
||||||
|
const SIDEBAR_SECTIONS = [
|
||||||
|
{ title: 'Monitor', items: [
|
||||||
|
{ id: 'dashboard', label: 'Dashboard', icon: 'layout-dashboard' },
|
||||||
|
{ id: 'insights', label: 'Insights', icon: 'bar-chart-3' },
|
||||||
|
{ id: 'sessions', label: 'Sessions', icon: 'messages-square' },
|
||||||
|
{ id: 'activity', label: 'Activity', icon: 'activity' },
|
||||||
|
]},
|
||||||
|
{ title: 'Projects', items: [
|
||||||
|
{ id: 'projects', label: 'Projects', icon: 'folder' },
|
||||||
|
]},
|
||||||
|
{ title: 'Interact', items: [
|
||||||
|
{ id: 'chat', label: 'Chat', icon: 'sparkles' },
|
||||||
|
{ id: 'memory', label: 'Memory', icon: 'database' },
|
||||||
|
{ id: 'skills', label: 'Skills', icon: 'wand-2' },
|
||||||
|
]},
|
||||||
|
{ title: 'Configure', items: [
|
||||||
|
{ id: 'platforms', label: 'Platforms', icon: 'cloud' },
|
||||||
|
{ id: 'personalities', label: 'Personalities', icon: 'user-circle' },
|
||||||
|
{ id: 'quickCommands', label: 'Quick Commands', icon: 'zap' },
|
||||||
|
{ id: 'credentialPools', label: 'Credentials', icon: 'key' },
|
||||||
|
{ id: 'plugins', label: 'Plugins', icon: 'puzzle' },
|
||||||
|
{ id: 'webhooks', label: 'Webhooks', icon: 'webhook' },
|
||||||
|
{ id: 'profiles', label: 'Profiles', icon: 'users' },
|
||||||
|
]},
|
||||||
|
{ title: 'Manage', items: [
|
||||||
|
{ id: 'tools', label: 'Tools', icon: 'wrench' },
|
||||||
|
{ id: 'mcpServers', label: 'MCP Servers', icon: 'server' },
|
||||||
|
{ id: 'gateway', label: 'Gateway', icon: 'network' },
|
||||||
|
{ id: 'cron', label: 'Cron', icon: 'clock' },
|
||||||
|
{ id: 'health', label: 'Health', icon: 'stethoscope' },
|
||||||
|
{ id: 'logs', label: 'Logs', icon: 'file-text' },
|
||||||
|
{ id: 'settings', label: 'Settings', icon: 'settings' },
|
||||||
|
]},
|
||||||
|
];
|
||||||
|
|
||||||
|
function ScarfSidebar({ active, onSelect }) {
|
||||||
|
return (
|
||||||
|
<aside style={{
|
||||||
|
width: 224, height: '100%', display: 'flex', flexDirection: 'column',
|
||||||
|
background: 'rgba(243, 242, 245, 0.7)',
|
||||||
|
backdropFilter: 'blur(40px) saturate(180%)',
|
||||||
|
WebkitBackdropFilter: 'blur(40px) saturate(180%)',
|
||||||
|
borderRight: '0.5px solid var(--border)',
|
||||||
|
paddingTop: 38, // space for traffic lights
|
||||||
|
fontFamily: 'var(--font-sans)',
|
||||||
|
}}>
|
||||||
|
<div style={{ padding: '0 16px 12px', display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||||
|
<img src="../../assets/scarf-app-icon-128.png" width="22" height="22" style={{ borderRadius: 5 }} alt="" />
|
||||||
|
<div style={{ fontSize: 14, fontWeight: 600, letterSpacing: '-0.01em' }}>Scarf</div>
|
||||||
|
<div style={{ marginLeft: 'auto', fontSize: 10, color: 'var(--fg-faint)', fontFamily: 'var(--font-mono)' }}>local</div>
|
||||||
|
</div>
|
||||||
|
<div style={{ flex: 1, overflowY: 'auto', padding: '4px 8px 16px' }}>
|
||||||
|
{SIDEBAR_SECTIONS.map(sec => (
|
||||||
|
<div key={sec.title} style={{ marginBottom: 14 }}>
|
||||||
|
<div style={{
|
||||||
|
padding: '6px 10px 4px', fontSize: 10.5, fontWeight: 600,
|
||||||
|
color: 'var(--fg-muted)', textTransform: 'uppercase', letterSpacing: '0.06em'
|
||||||
|
}}>{sec.title}</div>
|
||||||
|
{sec.items.map(it => {
|
||||||
|
const isActive = active === it.id;
|
||||||
|
return (
|
||||||
|
<div key={it.id} onClick={() => onSelect && onSelect(it.id)}
|
||||||
|
style={{
|
||||||
|
display: 'flex', alignItems: 'center', gap: 9,
|
||||||
|
padding: '5px 10px', borderRadius: 6, cursor: 'pointer',
|
||||||
|
fontSize: 13, fontWeight: isActive ? 500 : 400,
|
||||||
|
color: isActive ? 'var(--accent-active)' : 'var(--fg)',
|
||||||
|
background: isActive ? 'var(--accent-tint)' : 'transparent',
|
||||||
|
transition: 'background 120ms',
|
||||||
|
}}>
|
||||||
|
<i data-lucide={it.icon} style={{ width: 15, height: 15 }}></i>
|
||||||
|
<span>{it.label}</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div style={{
|
||||||
|
padding: '10px 14px', borderTop: '0.5px solid var(--border)',
|
||||||
|
display: 'flex', alignItems: 'center', gap: 8, fontSize: 12
|
||||||
|
}}>
|
||||||
|
<div style={{ width: 7, height: 7, borderRadius: '50%', background: 'var(--green-500)' }}></div>
|
||||||
|
<span style={{ color: 'var(--fg-muted)' }}>Hermes running</span>
|
||||||
|
<span style={{ marginLeft: 'auto', fontFamily: 'var(--font-mono)', color: 'var(--fg-faint)', fontSize: 11 }}>v0.42</span>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
window.ScarfSidebar = ScarfSidebar;
|
||||||
|
window.SIDEBAR_SECTIONS = SIDEBAR_SECTIONS;
|
||||||
@@ -0,0 +1,222 @@
|
|||||||
|
// Tools — registry of every callable tool, with status, kind, and a
|
||||||
|
// permission policy. Two-pane: list of tools (left), detail (right).
|
||||||
|
|
||||||
|
const TOOL_KIND_TONES = {
|
||||||
|
read: { color: 'var(--green-500)', tint: 'var(--green-100)', icon: 'book-open' },
|
||||||
|
edit: { color: 'var(--blue-500)', tint: 'var(--blue-100)', icon: 'file-edit' },
|
||||||
|
execute: { color: 'var(--orange-500)', tint: 'var(--orange-100)', icon: 'terminal' },
|
||||||
|
fetch: { color: 'var(--purple-tool-500)', tint: '#EFE0F8', icon: 'globe' },
|
||||||
|
browser: { color: 'var(--indigo-500)', tint: '#E0E5F8', icon: 'compass' },
|
||||||
|
mcp: { color: 'var(--accent)', tint: 'var(--accent-tint)',icon: 'server' },
|
||||||
|
};
|
||||||
|
|
||||||
|
const TOOLS_DATA = [
|
||||||
|
// Built-in
|
||||||
|
{ id: 'read_file', kind: 'read', source: 'built-in', server: '—', enabled: true, calls7d: 1284, lastUsed: '2m ago', policy: 'auto', desc: 'Read a file from disk by path. Honors hidden-file setting.' },
|
||||||
|
{ id: 'write_file', kind: 'edit', source: 'built-in', server: '—', enabled: true, calls7d: 412, lastUsed: '14m ago', policy: 'approve-write', desc: 'Write content to a file, creating parent directories as needed.' },
|
||||||
|
{ id: 'apply_patch', kind: 'edit', source: 'built-in', server: '—', enabled: true, calls7d: 348, lastUsed: '14m ago', policy: 'approve-write', desc: 'Apply a unified-diff patch to existing files.' },
|
||||||
|
{ id: 'list_files', kind: 'read', source: 'built-in', server: '—', enabled: true, calls7d: 928, lastUsed: '32m ago', policy: 'auto', desc: 'List entries in a directory, optionally recursive.' },
|
||||||
|
{ id: 'execute', kind: 'execute', source: 'built-in', server: '—', enabled: true, calls7d: 661, lastUsed: '14m ago', policy: 'approve-exec', desc: 'Run a shell command. Subject to gateway approval policy.' },
|
||||||
|
{ id: 'web_fetch', kind: 'fetch', source: 'built-in', server: '—', enabled: true, calls7d: 184, lastUsed: '1h ago', policy: 'auto', desc: 'Fetch a URL and return the extracted text.' },
|
||||||
|
{ id: 'web_search', kind: 'fetch', source: 'built-in', server: '—', enabled: true, calls7d: 92, lastUsed: '3h ago', policy: 'auto', desc: 'Search the public web. Returns top 10 results.' },
|
||||||
|
{ id: 'browser_navigate', kind: 'browser', source: 'built-in', server: '—', enabled: false, calls7d: 0, lastUsed: 'never', policy: 'approve-all', desc: 'Drive a Chromium instance for live page interaction.' },
|
||||||
|
// MCP
|
||||||
|
{ id: 'github__list_issues', kind: 'mcp', source: 'mcp', server: 'github', enabled: true, calls7d: 84, lastUsed: '42m ago', policy: 'auto', desc: 'List issues for a GitHub repository the user has access to.' },
|
||||||
|
{ id: 'github__create_pr', kind: 'mcp', source: 'mcp', server: 'github', enabled: true, calls7d: 12, lastUsed: 'yesterday', policy: 'approve-write', desc: 'Open a pull request from a branch.' },
|
||||||
|
{ id: 'linear__list_issues', kind: 'mcp', source: 'mcp', server: 'linear', enabled: true, calls7d: 38, lastUsed: '2h ago', policy: 'auto', desc: 'Query Linear issues with filters.' },
|
||||||
|
{ id: 'slack__send_message', kind: 'mcp', source: 'mcp', server: 'slack', enabled: false, calls7d: 0, lastUsed: 'never', policy: 'approve-all', desc: 'Post a message to a Slack channel as the connected user.' },
|
||||||
|
{ id: 'postgres__query', kind: 'mcp', source: 'mcp', server: 'postgres-prod', enabled: true, calls7d: 14, lastUsed: '4h ago', policy: 'approve-write', desc: 'Run read-only SQL against the configured database.' },
|
||||||
|
];
|
||||||
|
|
||||||
|
function Tools() {
|
||||||
|
const [active, setActive] = React.useState('execute');
|
||||||
|
const [filter, setFilter] = React.useState('all');
|
||||||
|
const [search, setSearch] = React.useState('');
|
||||||
|
React.useEffect(() => { requestAnimationFrame(() => window.lucide && window.lucide.createIcons()); });
|
||||||
|
|
||||||
|
const filtered = TOOLS_DATA.filter(t => {
|
||||||
|
if (filter === 'enabled' && !t.enabled) return false;
|
||||||
|
if (filter === 'mcp' && t.source !== 'mcp') return false;
|
||||||
|
if (filter === 'builtin' && t.source !== 'built-in') return false;
|
||||||
|
if (search && !t.id.toLowerCase().includes(search.toLowerCase())) return false;
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
const tool = TOOLS_DATA.find(t => t.id === active) || TOOLS_DATA[0];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', height: '100%' }}>
|
||||||
|
<ContentHeader title="Tools"
|
||||||
|
subtitle="Every callable tool the agent can use, plus their gateway policy"
|
||||||
|
actions={<><Btn icon="rotate-cw">Sync</Btn><Btn kind="primary" icon="plus">Register tool</Btn></>} />
|
||||||
|
|
||||||
|
<div style={{ flex: 1, display: 'flex', minHeight: 0 }}>
|
||||||
|
{/* List */}
|
||||||
|
<div style={{ width: 380, borderRight: '0.5px solid var(--border)',
|
||||||
|
display: 'flex', flexDirection: 'column', background: 'var(--bg-card)' }}>
|
||||||
|
<div style={{ padding: '14px 14px 8px', display: 'flex', flexDirection: 'column', gap: 10 }}>
|
||||||
|
<TextInput value={search} onChange={setSearch} leftIcon="search" placeholder="Search tools…" mono />
|
||||||
|
<Segmented value={filter} onChange={setFilter} size="sm" options={[
|
||||||
|
{ value: 'all', label: 'All', count: TOOLS_DATA.length },
|
||||||
|
{ value: 'enabled', label: 'Enabled', count: TOOLS_DATA.filter(t => t.enabled).length },
|
||||||
|
{ value: 'mcp', label: 'MCP', count: TOOLS_DATA.filter(t => t.source === 'mcp').length },
|
||||||
|
{ value: 'builtin', label: 'Built-in', count: TOOLS_DATA.filter(t => t.source === 'built-in').length },
|
||||||
|
]} />
|
||||||
|
</div>
|
||||||
|
<div style={{ flex: 1, overflowY: 'auto', padding: '0 6px 8px' }}>
|
||||||
|
<ToolGroupHeader>Built-in</ToolGroupHeader>
|
||||||
|
{filtered.filter(t => t.source === 'built-in').map(t =>
|
||||||
|
<ToolRow key={t.id} t={t} active={t.id === active} onClick={() => setActive(t.id)} />
|
||||||
|
)}
|
||||||
|
<ToolGroupHeader>MCP servers</ToolGroupHeader>
|
||||||
|
{filtered.filter(t => t.source === 'mcp').map(t =>
|
||||||
|
<ToolRow key={t.id} t={t} active={t.id === active} onClick={() => setActive(t.id)} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Detail */}
|
||||||
|
<div style={{ flex: 1, overflowY: 'auto', background: 'var(--bg)', padding: '24px 32px' }}>
|
||||||
|
<ToolDetail tool={tool} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ToolGroupHeader({ children }) {
|
||||||
|
return (
|
||||||
|
<div style={{
|
||||||
|
padding: '12px 10px 4px', fontSize: 10, fontWeight: 600,
|
||||||
|
color: 'var(--fg-muted)', textTransform: 'uppercase', letterSpacing: '0.06em',
|
||||||
|
}}>{children}</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ToolRow({ t, active, onClick }) {
|
||||||
|
const tone = TOOL_KIND_TONES[t.kind] || TOOL_KIND_TONES.read;
|
||||||
|
const [hover, setHover] = React.useState(false);
|
||||||
|
return (
|
||||||
|
<div onClick={onClick} onMouseEnter={() => setHover(true)} onMouseLeave={() => setHover(false)} style={{
|
||||||
|
padding: '8px 10px', borderRadius: 7, cursor: 'pointer', marginBottom: 1,
|
||||||
|
background: active ? 'var(--accent-tint)' : (hover ? 'var(--bg-quaternary)' : 'transparent'),
|
||||||
|
display: 'flex', alignItems: 'center', gap: 9,
|
||||||
|
}}>
|
||||||
|
<div style={{
|
||||||
|
width: 24, height: 24, borderRadius: 6, background: tone.tint, color: tone.color,
|
||||||
|
display: 'flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0,
|
||||||
|
}}>
|
||||||
|
<i data-lucide={tone.icon} style={{ width: 13, height: 13 }}></i>
|
||||||
|
</div>
|
||||||
|
<div style={{ flex: 1, minWidth: 0 }}>
|
||||||
|
<div style={{ fontSize: 13, fontWeight: 500, fontFamily: 'var(--font-mono)',
|
||||||
|
color: active ? 'var(--accent-active)' : 'var(--fg)',
|
||||||
|
whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>{t.id}</div>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 6, fontSize: 11, color: 'var(--fg-faint)', marginTop: 1 }}>
|
||||||
|
{t.server !== '—' && <span style={{ fontFamily: 'var(--font-mono)' }}>{t.server}</span>}
|
||||||
|
<span>· {t.calls7d.toLocaleString()} calls</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{t.enabled
|
||||||
|
? <Dot tone="green" />
|
||||||
|
: <Dot tone="gray" />}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ToolDetail({ tool }) {
|
||||||
|
const tone = TOOL_KIND_TONES[tool.kind] || TOOL_KIND_TONES.read;
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'flex-start', gap: 14, marginBottom: 22 }}>
|
||||||
|
<div style={{
|
||||||
|
width: 44, height: 44, borderRadius: 9, background: tone.tint, color: tone.color,
|
||||||
|
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||||
|
}}>
|
||||||
|
<i data-lucide={tone.icon} style={{ width: 22, height: 22 }}></i>
|
||||||
|
</div>
|
||||||
|
<div style={{ flex: 1 }}>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 4 }}>
|
||||||
|
<div className="scarf-h2" style={{ fontFamily: 'var(--font-mono)', fontSize: 22 }}>{tool.id}</div>
|
||||||
|
{tool.enabled ? <Pill tone="green" dot>enabled</Pill> : <Pill tone="gray" dot>disabled</Pill>}
|
||||||
|
</div>
|
||||||
|
<div style={{ fontSize: 13, color: 'var(--fg-muted)', maxWidth: 560 }}>{tool.desc}</div>
|
||||||
|
</div>
|
||||||
|
<Toggle on={tool.enabled} size="lg" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(4, 1fr)', gap: 10, marginBottom: 24 }}>
|
||||||
|
<StatCard label="Calls (7d)" value={tool.calls7d.toLocaleString()} />
|
||||||
|
<StatCard label="Last used" value={tool.lastUsed} />
|
||||||
|
<StatCard label="Avg duration" value="142 ms" sub="p95: 920 ms" />
|
||||||
|
<StatCard label="Error rate" value="0.4%" sub="3 of 661 calls" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<SettingsGroup title="Permissions" description="Applied at the gateway. Per-project profiles can override.">
|
||||||
|
<SettingsRow icon="shield-check" title="Default policy"
|
||||||
|
description={POLICY_DESC[tool.policy]}
|
||||||
|
control={<Select value={tool.policy} options={[
|
||||||
|
{ value: 'auto', label: 'Auto-approve' },
|
||||||
|
{ value: 'approve-write', label: 'Approve writes' },
|
||||||
|
{ value: 'approve-exec', label: 'Approve every call' },
|
||||||
|
{ value: 'approve-all', label: 'Approve every call (strict)' },
|
||||||
|
{ value: 'deny', label: 'Deny' },
|
||||||
|
]} />} />
|
||||||
|
<SettingsRow icon="users" title="Per-project overrides"
|
||||||
|
description="2 projects override the default policy for this tool."
|
||||||
|
control={<Btn size="sm" icon="external-link">Manage</Btn>} last />
|
||||||
|
</SettingsGroup>
|
||||||
|
|
||||||
|
<SettingsGroup title="Schema" description="JSON Schema declared by the tool. Read-only.">
|
||||||
|
<div style={{ background: 'var(--gray-900)', color: '#E8E1D2', padding: 14,
|
||||||
|
fontFamily: 'var(--font-mono)', fontSize: 11.5, lineHeight: 1.55,
|
||||||
|
borderRadius: '0 0 10px 10px' }}>
|
||||||
|
{`{
|
||||||
|
"name": "${tool.id}",
|
||||||
|
"input_schema": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"command": { "type": "string", "description": "Shell command" },
|
||||||
|
"cwd": { "type": "string", "default": "$PWD" },
|
||||||
|
"timeout": { "type": "integer", "default": 60 }
|
||||||
|
},
|
||||||
|
"required": ["command"]
|
||||||
|
}
|
||||||
|
}`}
|
||||||
|
</div>
|
||||||
|
</SettingsGroup>
|
||||||
|
|
||||||
|
<SettingsGroup title="Recent calls">
|
||||||
|
<RecentCallRow when="2m ago" args="hermes cron status daily-summary" status="ok" duration="1.4s" />
|
||||||
|
<RecentCallRow when="14m ago" args="git log --oneline -n 20" status="ok" duration="86ms" />
|
||||||
|
<RecentCallRow when="1h ago" args="npm test -- --watch=false" status="ok" duration="14.2s" />
|
||||||
|
<RecentCallRow when="2h ago" args="rm -rf node_modules" status="denied" duration="—" last />
|
||||||
|
</SettingsGroup>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const POLICY_DESC = {
|
||||||
|
'auto': 'Always invoke without asking.',
|
||||||
|
'approve-write': 'Pause for approval when the tool changes state.',
|
||||||
|
'approve-exec': 'Pause for approval before every call.',
|
||||||
|
'approve-all': 'Pause for approval before every call. Strictest mode.',
|
||||||
|
'deny': 'Reject the call. Tool appears in lists but cannot be invoked.',
|
||||||
|
};
|
||||||
|
|
||||||
|
function RecentCallRow({ when, args, status, duration, last }) {
|
||||||
|
return (
|
||||||
|
<div style={{
|
||||||
|
display: 'flex', alignItems: 'center', gap: 12, padding: '10px 18px',
|
||||||
|
borderBottom: last ? 'none' : '0.5px solid var(--border)',
|
||||||
|
}}>
|
||||||
|
<span style={{ fontSize: 11, color: 'var(--fg-faint)', fontFamily: 'var(--font-mono)', width: 90 }}>{when}</span>
|
||||||
|
<span style={{ flex: 1, fontFamily: 'var(--font-mono)', fontSize: 12,
|
||||||
|
whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis', color: 'var(--fg-muted)' }}>{args}</span>
|
||||||
|
{status === 'ok' && <Pill tone="green" size="sm" icon="check">ok</Pill>}
|
||||||
|
{status === 'denied' && <Pill tone="red" size="sm" icon="ban">denied</Pill>}
|
||||||
|
<span style={{ fontFamily: 'var(--font-mono)', fontSize: 11, color: 'var(--fg-faint)', width: 60, textAlign: 'right' }}>{duration}</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
window.Tools = Tools;
|
||||||
@@ -0,0 +1,141 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<title>Scarf — UI Kit</title>
|
||||||
|
<link rel="stylesheet" href="../colors_and_type.css">
|
||||||
|
<style>
|
||||||
|
html, body { margin: 0; padding: 0; height: 100%; overflow: hidden;
|
||||||
|
background: linear-gradient(135deg, #EFC59E 0%, #C25A2A 60%, #5C220F 100%); }
|
||||||
|
@keyframes scarfSpin { to { transform: rotate(360deg); } }
|
||||||
|
#root { height: 100%; }
|
||||||
|
.scarf-app {
|
||||||
|
display: flex; height: 100vh;
|
||||||
|
background: var(--bg);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.scarf-traffic {
|
||||||
|
position: absolute; top: 14px; left: 18px;
|
||||||
|
display: flex; gap: 8px; z-index: 100;
|
||||||
|
}
|
||||||
|
.scarf-traffic .dot { width: 12px; height: 12px; border-radius: 50%; }
|
||||||
|
.scarf-traffic .dot.r { background: #FE5F57; }
|
||||||
|
.scarf-traffic .dot.y { background: #FEBB2E; }
|
||||||
|
.scarf-traffic .dot.g { background: #28C840; }
|
||||||
|
.scarf-content {
|
||||||
|
flex: 1; display: flex; flex-direction: column;
|
||||||
|
min-width: 0; padding-top: 38px;
|
||||||
|
background: var(--bg);
|
||||||
|
}
|
||||||
|
@keyframes pulseScarf { 0%,100% { opacity:1 } 50% { opacity: 0.3 } }
|
||||||
|
/* placeholder for contentEditable */
|
||||||
|
[contenteditable][data-placeholder]:empty:before {
|
||||||
|
content: attr(data-placeholder); color: var(--fg-faint); pointer-events: none;
|
||||||
|
}
|
||||||
|
/* scrollbar tweak */
|
||||||
|
::-webkit-scrollbar { width: 8px; height: 8px; }
|
||||||
|
::-webkit-scrollbar-thumb { background: rgba(28,26,32,0.15); border-radius: 4px; }
|
||||||
|
::-webkit-scrollbar-thumb:hover { background: rgba(28,26,32,0.25); }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
|
||||||
|
<template id="__bundler_thumbnail">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100">
|
||||||
|
<rect width="100" height="100" fill="#C25A2A"/>
|
||||||
|
<text x="50" y="62" text-anchor="middle" font-family="Georgia, serif"
|
||||||
|
font-size="48" font-style="italic" fill="#FAF7F2" font-weight="600">S</text>
|
||||||
|
</svg>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script src="https://unpkg.com/react@18.3.1/umd/react.development.js" integrity="sha384-hD6/rw4ppMLGNu3tX5cjIb+uRZ7UkRJ6BPkLpg4hAu/6onKUg4lLsHAs9EBPT82L" crossorigin="anonymous"></script>
|
||||||
|
<script src="https://unpkg.com/react-dom@18.3.1/umd/react-dom.development.js" integrity="sha384-u6aeetuaXnQ38mYT8rp6sbXaQe3NL9t+IBXmnYxwkUI2Hw4bsp2Wvmx4yRQF1uAm" crossorigin="anonymous"></script>
|
||||||
|
<script src="https://unpkg.com/@babel/standalone@7.29.0/babel.min.js" integrity="sha384-m08KidiNqLdpJqLq95G/LEi8Qvjl/xUYll3QILypMoQ65QorJ9Lvtp2RXYGBFj1y" crossorigin="anonymous"></script>
|
||||||
|
<script src="https://unpkg.com/lucide@0.469.0/dist/umd/lucide.min.js"></script>
|
||||||
|
|
||||||
|
<script type="text/babel" src="Common.jsx"></script>
|
||||||
|
<script type="text/babel" src="Sidebar.jsx"></script>
|
||||||
|
<script type="text/babel" src="Dashboard.jsx"></script>
|
||||||
|
<script type="text/babel" src="Sessions.jsx"></script>
|
||||||
|
<script type="text/babel" src="Insights.jsx"></script>
|
||||||
|
<script type="text/babel" src="Projects.jsx"></script>
|
||||||
|
<script type="text/babel" src="Chat.jsx"></script>
|
||||||
|
<script type="text/babel" src="Settings.jsx"></script>
|
||||||
|
<script type="text/babel" src="Tools.jsx"></script>
|
||||||
|
<script type="text/babel" src="MCPServers.jsx"></script>
|
||||||
|
<script type="text/babel" src="Cron.jsx"></script>
|
||||||
|
<script type="text/babel" src="Logs.jsx"></script>
|
||||||
|
<script type="text/babel" src="Memory.jsx"></script>
|
||||||
|
<script type="text/babel" src="Activity.jsx"></script>
|
||||||
|
<script type="text/babel" src="Health.jsx"></script>
|
||||||
|
<script type="text/babel" src="MoreViews.jsx"></script>
|
||||||
|
|
||||||
|
<script type="text/babel">
|
||||||
|
function App() {
|
||||||
|
const [active, setActive] = React.useState('dashboard');
|
||||||
|
React.useEffect(() => {
|
||||||
|
// re-render lucide icons after each route change
|
||||||
|
requestAnimationFrame(() => window.lucide && window.lucide.createIcons());
|
||||||
|
}, [active]);
|
||||||
|
const Views = {
|
||||||
|
dashboard: Dashboard,
|
||||||
|
sessions: Sessions,
|
||||||
|
insights: Insights,
|
||||||
|
projects: Projects,
|
||||||
|
chat: Chat,
|
||||||
|
settings: Settings,
|
||||||
|
tools: Tools,
|
||||||
|
mcpServers: MCPServers,
|
||||||
|
cron: Cron,
|
||||||
|
logs: Logs,
|
||||||
|
memory: Memory,
|
||||||
|
activity: Activity,
|
||||||
|
health: Health,
|
||||||
|
personalities: Personalities,
|
||||||
|
quickCommands: QuickCommands,
|
||||||
|
platforms: Platforms,
|
||||||
|
credentialPools: Credentials,
|
||||||
|
plugins: Plugins,
|
||||||
|
webhooks: Webhooks,
|
||||||
|
profiles: Profiles,
|
||||||
|
gateway: Gateway,
|
||||||
|
};
|
||||||
|
const Active = Views[active] || PlaceholderView(active);
|
||||||
|
return (
|
||||||
|
<div className="scarf-app" data-screen-label={`Scarf · ${active}`}>
|
||||||
|
<div className="scarf-traffic">
|
||||||
|
<span className="dot r"></span><span className="dot y"></span><span className="dot g"></span>
|
||||||
|
</div>
|
||||||
|
<ScarfSidebar active={active} onSelect={setActive} />
|
||||||
|
<div className="scarf-content">
|
||||||
|
<Active />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function PlaceholderView(name) {
|
||||||
|
const SIDEBAR_FLAT = SIDEBAR_SECTIONS.flatMap(s => s.items);
|
||||||
|
const item = SIDEBAR_FLAT.find(i => i.id === name) || { label: name, icon: 'inbox' };
|
||||||
|
return function Inner() {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<ContentHeader title={item.label} subtitle={`This view isn't fleshed out in the UI kit yet.`} />
|
||||||
|
<EmptyState icon={item.icon}
|
||||||
|
title={`${item.label}`}
|
||||||
|
body={`The Scarf app exposes a dedicated ${item.label} pane here. The kit ships a faithful Dashboard, Sessions, Insights, Projects, and Chat — wire ${item.label} the same way against your data.`}
|
||||||
|
action={<Btn kind="primary" icon="external-link">Open Scarf docs</Btn>}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
ReactDOM.createRoot(document.getElementById('root')).render(<App />);
|
||||||
|
// Lucide ran once on DOMContentLoaded before React mounted — re-run now that the DOM has icons.
|
||||||
|
setTimeout(() => window.lucide && window.lucide.createIcons(), 0);
|
||||||
|
setTimeout(() => window.lucide && window.lucide.createIcons(), 200);
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,187 @@
|
|||||||
|
|
||||||
|
// MacOS.jsx — Simplified macOS Tahoe (Liquid Glass) window
|
||||||
|
// Based on the macOS Tahoe UI Kit. No image assets, no dependencies.
|
||||||
|
// Exports: MacWindow, MacSidebar, MacSidebarItem, MacToolbar, MacGlass, MacTrafficLights
|
||||||
|
|
||||||
|
const MAC_FONT = '-apple-system, BlinkMacSystemFont, "SF Pro", "Helvetica Neue", sans-serif';
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────
|
||||||
|
// Liquid glass primitive — blur + white tint + inset highlight
|
||||||
|
// ─────────────────────────────────────────────────────────────
|
||||||
|
function MacGlass({ children, radius = 296, dark = false, style = {} }) {
|
||||||
|
return (
|
||||||
|
<div style={{ position: 'relative', borderRadius: radius, ...style }}>
|
||||||
|
<div style={{
|
||||||
|
position: 'absolute', inset: 0, borderRadius: radius,
|
||||||
|
background: dark ? 'rgba(255,255,255,0.08)' : 'rgba(255,255,255,0.35)',
|
||||||
|
backdropFilter: 'blur(40px) saturate(180%)',
|
||||||
|
WebkitBackdropFilter: 'blur(40px) saturate(180%)',
|
||||||
|
border: dark ? '0.5px solid rgba(255,255,255,0.12)' : '0.5px solid rgba(255,255,255,0.6)',
|
||||||
|
boxShadow: dark
|
||||||
|
? '0 8px 40px rgba(0,0,0,0.2)'
|
||||||
|
: '0 8px 40px rgba(0,0,0,0.08), inset 0 1px 0 rgba(255,255,255,0.4)',
|
||||||
|
}} />
|
||||||
|
<div style={{ position: 'relative', zIndex: 1 }}>{children}</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────
|
||||||
|
// Traffic lights (14px, Tahoe colors)
|
||||||
|
// ─────────────────────────────────────────────────────────────
|
||||||
|
function MacTrafficLights({ style = {} }) {
|
||||||
|
const dot = (bg) => (
|
||||||
|
<div style={{
|
||||||
|
width: 14, height: 14, borderRadius: '50%', background: bg,
|
||||||
|
border: '0.5px solid rgba(0,0,0,0.1)',
|
||||||
|
}} />
|
||||||
|
);
|
||||||
|
return (
|
||||||
|
<div style={{ display: 'flex', gap: 9, alignItems: 'center', padding: 1, ...style }}>
|
||||||
|
{dot('#ff736a')}{dot('#febc2e')}{dot('#19c332')}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────
|
||||||
|
// Toolbar — title + single glass pill icon
|
||||||
|
// ─────────────────────────────────────────────────────────────
|
||||||
|
function MacToolbar({ title = 'Folder' }) {
|
||||||
|
return (
|
||||||
|
<div style={{
|
||||||
|
display: 'flex', gap: 8, alignItems: 'center', padding: 8, flexShrink: 0,
|
||||||
|
}}>
|
||||||
|
{/* title */}
|
||||||
|
<div style={{
|
||||||
|
fontFamily: MAC_FONT, fontSize: 15, fontWeight: 700,
|
||||||
|
color: 'rgba(0,0,0,0.85)', whiteSpace: 'nowrap', paddingLeft: 8,
|
||||||
|
}}>{title}</div>
|
||||||
|
<div style={{ flex: 1 }} />
|
||||||
|
{/* single action */}
|
||||||
|
<MacGlass>
|
||||||
|
<div style={{
|
||||||
|
width: 36, height: 36, display: 'flex',
|
||||||
|
alignItems: 'center', justifyContent: 'center',
|
||||||
|
}}>
|
||||||
|
<div style={{ width: 14, height: 14, borderRadius: '50%', background: '#4c4c4c', opacity: 0.4 }} />
|
||||||
|
</div>
|
||||||
|
</MacGlass>
|
||||||
|
{/* search */}
|
||||||
|
<MacGlass>
|
||||||
|
<div style={{
|
||||||
|
width: 140, height: 36, display: 'flex', alignItems: 'center',
|
||||||
|
gap: 6, padding: '0 12px',
|
||||||
|
}}>
|
||||||
|
<svg width="13" height="13" viewBox="0 0 13 13" fill="none">
|
||||||
|
<circle cx="5.5" cy="5.5" r="4" stroke="#727272" strokeWidth="1.5"/>
|
||||||
|
<path d="M8.5 8.5l3 3" stroke="#727272" strokeWidth="1.5" strokeLinecap="round"/>
|
||||||
|
</svg>
|
||||||
|
<span style={{
|
||||||
|
fontFamily: MAC_FONT, fontSize: 13, fontWeight: 500, color: '#727272',
|
||||||
|
}}>Search</span>
|
||||||
|
</div>
|
||||||
|
</MacGlass>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────
|
||||||
|
// Sidebar — frosted glass panel floating inside the window
|
||||||
|
// ─────────────────────────────────────────────────────────────
|
||||||
|
function MacSidebarItem({ label, selected = false }) {
|
||||||
|
return (
|
||||||
|
<div style={{
|
||||||
|
display: 'flex', alignItems: 'center', gap: 6,
|
||||||
|
height: 24, padding: '4px 10px 4px 6px', margin: '0 10px',
|
||||||
|
borderRadius: 8, position: 'relative',
|
||||||
|
fontFamily: MAC_FONT, fontSize: 11, fontWeight: 500,
|
||||||
|
}}>
|
||||||
|
{selected && (
|
||||||
|
<div style={{
|
||||||
|
position: 'absolute', inset: 0, borderRadius: 8,
|
||||||
|
background: 'rgba(0,0,0,0.11)', mixBlendMode: 'multiply',
|
||||||
|
}} />
|
||||||
|
)}
|
||||||
|
<div style={{
|
||||||
|
width: 14, height: 14, borderRadius: '50%',
|
||||||
|
background: selected ? '#007aff' : 'rgba(0,0,0,0.4)',
|
||||||
|
opacity: selected ? 1 : 0.5, flexShrink: 0, position: 'relative',
|
||||||
|
}} />
|
||||||
|
<span style={{ color: 'rgba(0,0,0,0.85)', position: 'relative' }}>{label}</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function MacSidebar({ children }) {
|
||||||
|
return (
|
||||||
|
<div style={{
|
||||||
|
width: 220, height: '100%', padding: 8, flexShrink: 0,
|
||||||
|
position: 'relative', display: 'flex', flexDirection: 'column',
|
||||||
|
}}>
|
||||||
|
{/* glass panel */}
|
||||||
|
<div style={{
|
||||||
|
position: 'absolute', inset: 8, borderRadius: 18,
|
||||||
|
background: 'rgba(210,225,245,0.45)',
|
||||||
|
backdropFilter: 'blur(50px) saturate(200%)',
|
||||||
|
WebkitBackdropFilter: 'blur(50px) saturate(200%)',
|
||||||
|
border: '0.5px solid rgba(255,255,255,0.5)',
|
||||||
|
boxShadow: '0 8px 40px rgba(0,0,0,0.10), inset 0 1px 0 rgba(255,255,255,0.35)',
|
||||||
|
}} />
|
||||||
|
{/* content */}
|
||||||
|
<div style={{
|
||||||
|
position: 'relative', zIndex: 1, padding: '10px 0',
|
||||||
|
display: 'flex', flexDirection: 'column', gap: 2,
|
||||||
|
}}>
|
||||||
|
{/* window controls + sidebar toggle */}
|
||||||
|
<div style={{
|
||||||
|
height: 32, display: 'flex', alignItems: 'center',
|
||||||
|
justifyContent: 'space-between', padding: '0 10px', marginBottom: 4,
|
||||||
|
}}>
|
||||||
|
<MacTrafficLights />
|
||||||
|
</div>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function MacSidebarHeader({ title }) {
|
||||||
|
return (
|
||||||
|
<div style={{
|
||||||
|
padding: '14px 18px 5px',
|
||||||
|
fontFamily: MAC_FONT, fontSize: 11, fontWeight: 700,
|
||||||
|
color: 'rgba(0,0,0,0.5)',
|
||||||
|
}}>{title}</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────
|
||||||
|
// Window — r:26, big shadow, sidebar + toolbar + content
|
||||||
|
// ─────────────────────────────────────────────────────────────
|
||||||
|
function MacWindow({
|
||||||
|
width = 900, height = 600, title = 'Folder',
|
||||||
|
sidebar, children,
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div style={{
|
||||||
|
width, height, borderRadius: 26, overflow: 'hidden',
|
||||||
|
background: '#fff',
|
||||||
|
boxShadow: '0 0 0 1px rgba(0,0,0,0.23), 0 16px 48px rgba(0,0,0,0.35)',
|
||||||
|
display: 'flex', position: 'relative',
|
||||||
|
fontFamily: MAC_FONT,
|
||||||
|
}}>
|
||||||
|
<MacSidebar>{sidebar}</MacSidebar>
|
||||||
|
<div style={{ flex: 1, display: 'flex', flexDirection: 'column' }}>
|
||||||
|
<MacToolbar title={title} />
|
||||||
|
<div style={{ flex: 1, overflow: 'auto', padding: '4px 8px' }}>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Object.assign(window, {
|
||||||
|
MacWindow, MacSidebar, MacSidebarItem, MacSidebarHeader,
|
||||||
|
MacToolbar, MacGlass, MacTrafficLights,
|
||||||
|
});
|
||||||
|
After Width: | Height: | Size: 770 KiB |
@@ -0,0 +1,218 @@
|
|||||||
|
# ScarfGo — App Store Connect submission copy
|
||||||
|
|
||||||
|
Single source of truth for every field you paste into App Store Connect → My Apps → ScarfGo. TestFlight-specific fields (Beta App Description, "What to test") live in [TESTFLIGHT_CHECKLIST.md](TESTFLIGHT_CHECKLIST.md). This file covers the full App Store listing for when ScarfGo graduates from TestFlight to the public store.
|
||||||
|
|
||||||
|
All character counts are pre-counted against Apple's published limits. Counts include trailing punctuation but exclude the leading `> ` Markdown blockquote markers.
|
||||||
|
|
||||||
|
## App information (set once, persists across builds)
|
||||||
|
|
||||||
|
### App name (max 30 chars)
|
||||||
|
|
||||||
|
```
|
||||||
|
ScarfGo
|
||||||
|
```
|
||||||
|
_7 / 30 chars._
|
||||||
|
|
||||||
|
### Subtitle (max 30 chars)
|
||||||
|
|
||||||
|
```
|
||||||
|
On-the-go Hermes companion
|
||||||
|
```
|
||||||
|
_26 / 30 chars._
|
||||||
|
|
||||||
|
### Bundle ID
|
||||||
|
|
||||||
|
```
|
||||||
|
com.scarfgo.app
|
||||||
|
```
|
||||||
|
|
||||||
|
### Primary category
|
||||||
|
|
||||||
|
Developer Tools
|
||||||
|
|
||||||
|
### Secondary category (optional)
|
||||||
|
|
||||||
|
Productivity
|
||||||
|
|
||||||
|
### Age rating
|
||||||
|
|
||||||
|
4+ (no restricted content)
|
||||||
|
|
||||||
|
### Support URL
|
||||||
|
|
||||||
|
```
|
||||||
|
https://github.com/awizemann/scarf/wiki/Support
|
||||||
|
```
|
||||||
|
|
||||||
|
### Marketing URL (optional)
|
||||||
|
|
||||||
|
```
|
||||||
|
https://github.com/awizemann/scarf
|
||||||
|
```
|
||||||
|
|
||||||
|
### Privacy Policy URL
|
||||||
|
|
||||||
|
```
|
||||||
|
https://awizemann.github.io/scarf/privacy/
|
||||||
|
```
|
||||||
|
|
||||||
|
### Copyright
|
||||||
|
|
||||||
|
```
|
||||||
|
© 2026 Alan Wizemann
|
||||||
|
```
|
||||||
|
|
||||||
|
### Trade representative information
|
||||||
|
|
||||||
|
Not required for sole-developer accounts.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Per-version metadata (resubmit on each App Store release)
|
||||||
|
|
||||||
|
### Promotional text (max 170 chars, editable without resubmission)
|
||||||
|
|
||||||
|
```
|
||||||
|
Manage your Hermes AI agent from your phone. Connect to any SSH-reachable Hermes host, run sessions, edit memory, browse cron jobs, resume conversations.
|
||||||
|
```
|
||||||
|
_153 / 170 chars._
|
||||||
|
|
||||||
|
### Description (max 4000 chars)
|
||||||
|
|
||||||
|
```
|
||||||
|
ScarfGo is the iPhone companion to Scarf, the open-source macOS GUI for the Hermes AI agent. It connects from your phone to a Hermes server you operate — your Mac, a home Linux box, a cloud VM, anything reachable over SSH — and lets you run sessions, browse memory, manage cron jobs, and resume conversations on the go.
|
||||||
|
|
||||||
|
A fully native iOS app, not a web view or a remote desktop. ScarfGo speaks SSH directly using a pure-Swift implementation, reads Hermes state via SFTP and SQLite snapshots, and streams real-time agent output over the Agent Client Protocol on a long-lived SSH exec channel. Every byte stays between your device and the Hermes host you configured.
|
||||||
|
|
||||||
|
What you can do:
|
||||||
|
|
||||||
|
• Multi-server. Configure as many Hermes hosts as you like and switch between them with a tap. Soft Disconnect keeps your credentials cached; Forget wipes a server end-to-end.
|
||||||
|
|
||||||
|
• Dashboard. Stats and the 25 most recent sessions, with project badges so you can tell at a glance which work is which.
|
||||||
|
|
||||||
|
• Project-scoped chat. Pick a project from your registry and ScarfGo writes the same Scarf-managed AGENTS.md context block the Mac app does, so the agent boots with the right project context. The resulting session is attributed correctly across both clients.
|
||||||
|
|
||||||
|
• Session resume. Tap any row on the Dashboard to open that session's transcript in Chat. CLI-started sessions hydrate from the Hermes state database; ACP sessions show an empty-state because Hermes does not persist ACP transcripts to the database.
|
||||||
|
|
||||||
|
• Memory editor. Read and edit MEMORY.md and USER.md with a Saved indicator that survives keyboard dismissal and a one-tap Revert.
|
||||||
|
|
||||||
|
• Cron list. Human-readable schedules ("Every 6 hours", "Weekdays at 09:00") instead of raw cron expressions, plus a relative next-run estimate. Read-only in this release; editing comes in a future update.
|
||||||
|
|
||||||
|
• Skills browser. Read-only category tree with the SKILL.md frontmatter chips (allowed tools, related skills, dependencies) the Mac app shows.
|
||||||
|
|
||||||
|
• Settings viewer. Read-only inspection of your config.yaml. Edit values from the Mac app or a remote shell.
|
||||||
|
|
||||||
|
Privacy. ScarfGo does not collect, transmit, or store your data on any server controlled by the developer. There are no analytics, no telemetry, no ad identifiers. SSH keys are generated on-device and stored in the iOS Keychain with the ThisDeviceOnly attribute, so they are unreachable while the device is locked and never sync to iCloud. The complete privacy policy lives at awizemann.github.io/scarf/privacy.
|
||||||
|
|
||||||
|
Open-source under the MIT license. Source, issue tracker, and contributor docs at github.com/awizemann/scarf. Bug reports tagged component:scarfgo go straight to the developer.
|
||||||
|
|
||||||
|
Requirements. iOS 18.0 or later. An SSH-reachable Hermes server (Hermes v0.10.0 or later recommended; full v0.11.0 features supported). Your phone needs to reach that server on the network — same Wi-Fi, VPN, Tailscale, or any port-forwarded address SSH can dial.
|
||||||
|
```
|
||||||
|
_2873 / 4000 chars._
|
||||||
|
|
||||||
|
### Keywords (max 100 chars, comma-separated, no spaces between terms)
|
||||||
|
|
||||||
|
```
|
||||||
|
hermes,ai agent,ssh,terminal,llm,assistant,developer tools,coding,remote,monitor,chat
|
||||||
|
```
|
||||||
|
_85 / 100 chars._
|
||||||
|
|
||||||
|
Brand-safe — no competitor product names. Apple flags trademarks like "Claude" or "OpenAI" as unauthorized brand use during review even when they appear as descriptive context.
|
||||||
|
|
||||||
|
### What's New text (max 4000 chars)
|
||||||
|
|
||||||
|
For v2.5.0 — first public App Store release. Trimmed from `RELEASE_NOTES.md`'s ScarfGo section to fit the iOS audience.
|
||||||
|
|
||||||
|
```
|
||||||
|
First public release of ScarfGo, the iPhone companion to the Scarf macOS app.
|
||||||
|
|
||||||
|
What's in this release:
|
||||||
|
|
||||||
|
• Multi-server. Configure multiple Hermes hosts and switch between them with a tap.
|
||||||
|
|
||||||
|
• Dashboard. Sessions, messages, and tool-call counts, plus the 25 most recent sessions with project badges and a project filter.
|
||||||
|
|
||||||
|
• Chat. Streamed agent responses over SSH with tool-call disclosure groups, code blocks, and project-scoped session start.
|
||||||
|
|
||||||
|
• Session resume. Tap any session on the Dashboard to open it in Chat.
|
||||||
|
|
||||||
|
• Memory editor. Read and edit MEMORY.md and USER.md with on-device save indication and one-tap Revert.
|
||||||
|
|
||||||
|
• Cron list. Human-readable schedules ("Every 6 hours", "Weekdays at 09:00") with relative next-run.
|
||||||
|
|
||||||
|
• Skills browser. Read-only category tree with SKILL.md frontmatter chips.
|
||||||
|
|
||||||
|
• Settings viewer. Read-only inspection of config.yaml. Edit values from the Mac app.
|
||||||
|
|
||||||
|
Known limitations in v1: no push notifications (the skeleton is in the binary, gated behind an internal flag pending Apple Developer Program enrollment and an APNs key); no in-app config editor; no template install UI; English only. iPad layout works via the system sidebar adaptive style but has not been polished — feedback welcome via TestFlight.
|
||||||
|
|
||||||
|
Privacy. No analytics, no telemetry, no developer-controlled servers. Read the full policy at awizemann.github.io/scarf/privacy.
|
||||||
|
```
|
||||||
|
_1150 / 4000 chars._
|
||||||
|
|
||||||
|
### Build (autopopulated)
|
||||||
|
|
||||||
|
Apple fills this in once the binary uploads + processes. The same build that went through TestFlight Beta Review is the one you ship to the public store.
|
||||||
|
|
||||||
|
### Version
|
||||||
|
|
||||||
|
Marketing version: `2.5.0` — the same number `release.sh` will write to `MARKETING_VERSION` for the macOS Scarf release. Keeping the iOS + Mac versions in lockstep is the convention this project uses.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Build artifact
|
||||||
|
|
||||||
|
### App icon (1024×1024)
|
||||||
|
|
||||||
|
```
|
||||||
|
scarf/Scarf iOS/Assets.xcassets/AppIcon.appiconset/AW Mac OS Applications-macOS-Default-1024x1024@1x.png
|
||||||
|
```
|
||||||
|
|
||||||
|
The full appiconset is in repo and the Xcode target references it via `AppIcon`. App Store Connect pulls the 1024 from the binary on upload — no separate upload step.
|
||||||
|
|
||||||
|
### Screenshots
|
||||||
|
|
||||||
|
**Required for the public App Store, NOT required for TestFlight.** Scope deliberately excluded from this prep pass — capture from the simulator before flipping the App Store listing live. Apple requires:
|
||||||
|
|
||||||
|
- iPhone 6.7" (e.g. iPhone 16 Pro Max) — at least 5, up to 10
|
||||||
|
- iPhone 6.5" (e.g. iPhone 14 Plus) — at least 5, up to 10
|
||||||
|
- iPhone 5.5" (e.g. iPhone 8 Plus) — at least 5, up to 10
|
||||||
|
- iPad — only if you flip the iPad flag in the target. Skip for v2.5.
|
||||||
|
|
||||||
|
Suggested screen captures (rough order):
|
||||||
|
1. Dashboard with stats + recent sessions list
|
||||||
|
2. Chat in mid-stream with a tool-call disclosure expanded
|
||||||
|
3. Project picker sheet
|
||||||
|
4. Sessions tab with project filter active
|
||||||
|
5. Memory editor with Saved indicator
|
||||||
|
6. Skills detail with frontmatter chips visible
|
||||||
|
7. Server list (showing multi-server)
|
||||||
|
8. Onboarding step 5 (public-key display)
|
||||||
|
|
||||||
|
### App preview video (optional)
|
||||||
|
|
||||||
|
Skip for v1. Apple will accept the listing without it.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Beta App Review (TestFlight) — already submitted
|
||||||
|
|
||||||
|
Cross-reference [TESTFLIGHT_CHECKLIST.md](TESTFLIGHT_CHECKLIST.md). Once Apple's Beta Review approves the first build, the public TestFlight URL `https://testflight.apple.com/join/qCrRpcTz` accepts new joiners. Until then the link 404s with a "not accepting testers" splash.
|
||||||
|
|
||||||
|
## Public App Store submission flow (after TestFlight stabilizes)
|
||||||
|
|
||||||
|
1. App Store Connect → My Apps → ScarfGo → App Store tab → iOS App.
|
||||||
|
2. Paste every field above into the matching form.
|
||||||
|
3. Set the build to the same one that's been on TestFlight (Apple lets you reuse a TestFlight build verbatim — no re-upload).
|
||||||
|
4. Submit for review. Apple's standard App Review queue (separate from Beta Review) is typically 24–72h. Watch your inbox for "We have a question" emails and reply via App Store Connect's review-team chat.
|
||||||
|
5. On approval, choose "Manually release this version" so you can announce on a schedule.
|
||||||
|
|
||||||
|
## Update cadence
|
||||||
|
|
||||||
|
The same `releases/v<VERSION>/` directory pattern this file lives in is the canonical staging area for every future iOS release. When v2.6 (or whatever ships next) bumps the iOS app, copy this file forward and update:
|
||||||
|
|
||||||
|
- **Promotional text** — refreshed marketing wedge.
|
||||||
|
- **What's New text** — what changed since the last App Store release.
|
||||||
|
- Everything else above stays unless you're changing categories, support URL, or privacy stance.
|
||||||
|
|
||||||
|
The Mac `release.sh` does not yet drive the iOS release — that's a separate Xcode Archive + App Store Connect upload. See `TESTFLIGHT_CHECKLIST.md` Phase 4 for the archive flow.
|
||||||
@@ -0,0 +1,95 @@
|
|||||||
|
## What's New in 2.5.0
|
||||||
|
|
||||||
|
The big one for 2.5: **ScarfGo, the iPhone companion**, ships in public TestFlight. Same Hermes server you've been running on your Mac — now reachable from your phone over SSH. Dashboard, chat, memory, cron, skills, settings (read), all of it. On the Mac side, the global Sessions list grows up alongside the iOS work — project filter, project badges on each row. **Plus**: full Hermes v2026.4.23 chat parity (`/steer`, per-turn stopwatch, numbered approvals, git branch chip), portable project-scoped slash commands that ship with `.scarftemplate` bundles, in-app Spotify OAuth, and design-md prereq checks.
|
||||||
|
|
||||||
|
### ScarfGo iOS companion (public TestFlight)
|
||||||
|
|
||||||
|
ScarfGo is a fully native iOS app — not a web view, not a remote desktop. It speaks SSH (Citadel under the hood), reads your Hermes state directly from your Mac (or wherever Hermes is running), and lets you tap a session to resume it from where you left off. Per-project chat works end-to-end: pick a project on your phone, the agent gets the same Scarf-managed `AGENTS.md` context block the Mac writes, and the resulting session shows up correctly attributed in the Dashboard's Sessions tab.
|
||||||
|
|
||||||
|
What's in the first public TestFlight build:
|
||||||
|
|
||||||
|
- **Multi-server.** Configure as many Hermes hosts as you want, switch between them from a single sidebar-adaptable tab root. Soft disconnect keeps your credentials; "Forget" wipes a server end-to-end (Keychain + UserDefaults).
|
||||||
|
- **Dashboard.** Stats + the 25 most recent sessions; an Overview tab and a Sessions tab with a project filter Menu.
|
||||||
|
- **Chat.** Full ACP (Agent Client Protocol) over SSH — streamed responses, tool-call disclosure groups, code blocks with horizontal scroll, "Connecting…" → "Ready" lifecycle, error banner with copy-to-clipboard for non-retryable failures.
|
||||||
|
- **Project-scoped chat.** "+ In project…" sheet picks from your project registry over SFTP. Writes the Scarf-managed `AGENTS.md` block before spawning `hermes acp` so the agent boots with project context. Records the resulting session ID in the attribution sidecar so the Mac picks it up.
|
||||||
|
- **Session resume.** Tap any session on the Dashboard → opens Chat with `loadSession`. Older CLI-started sessions hydrate from `state.db`; newer ACP sessions show an empty-state explaining the agent has the context but the local transcript isn't cached.
|
||||||
|
- **Memory editor.** Read + edit `MEMORY.md` and `USER.md`, with a "Saved" pill that survives keyboard dismissal and a Revert button.
|
||||||
|
- **Cron list.** Read-only for now, but with **human-readable schedules** ("Every 6 hours", "Weekdays at 09:00") instead of raw `0 */6 * * *`. Mac gets the same formatter.
|
||||||
|
- **Skills + Settings.** Read-only. Skills shows category structure; Settings shows your `config.yaml` for inspection (no editor in 2.5).
|
||||||
|
- **iOS 18+.** Dynamic Type clamp at the scene root, sidebar-adaptable TabView, scoped sheet detents, scroll anchoring, content-aware empty states throughout.
|
||||||
|
|
||||||
|
**TestFlight invite:** see the [ScarfGo wiki page](https://github.com/awizemann/scarf/wiki/ScarfGo) for the public link + onboarding walkthrough.
|
||||||
|
|
||||||
|
### Portable project-scoped slash commands
|
||||||
|
|
||||||
|
A net-new Scarf primitive (Hermes has no project-scoped slash command concept — Scarf invents the format and intercepts the chat menu client-side). Author reusable prompt templates as Markdown files at `<project>/.scarf/slash-commands/<name>.md` with YAML frontmatter (name, description, argumentHint, optional model override, tags). Invoke as `/<name> [args]` from chat — Scarf substitutes `{{argument}}` (and `{{argument | default: "..."}}`) in the body and sends the expanded prompt to Hermes; the agent never sees the slash. Works uniformly on Mac + iOS, local + remote SSH, against any Hermes version.
|
||||||
|
|
||||||
|
- **Mac authoring tab.** Per-project view gains a Slash Commands tab alongside Dashboard / Site / Sessions. List, add, edit, duplicate, delete; live preview pane shows the expanded prompt with a sample-argument field so authors see exactly what Hermes will receive.
|
||||||
|
- **iOS read-only browser.** ScarfGo's chat project context bar grows a `<N> slash` chip when the project has slash commands; tap to browse them in a sheet. Multi-line markdown editing is a phone keyboard's nightmare, so v2.5 keeps Mac as the canonical editor; iOS catches up in v2.6+.
|
||||||
|
- **AGENTS.md block extension.** The Scarf-managed project context block now lists available commands so the agent can answer "what slash commands does this project have?" and recognise the `<!-- scarf-slash:<name> -->` marker prepended to expanded prompts.
|
||||||
|
- **`.scarftemplate` format extension** (schemaVersion 3). Templates ship slash commands by including `slash-commands/<name>.md` files at the bundle root and listing them in `manifest.contents.slashCommands`. The installer copies them to the project's `.scarf/slash-commands/` dir; the lock file tracks them for clean uninstall (user-authored commands in the same dir survive uninstall).
|
||||||
|
- **Catalog validator** (`tools/build-catalog.py`) mirrors the Swift verifier. Schema version bumps to 3 only when the bundle ships slash commands; v1/v2 templates stay byte-compatible.
|
||||||
|
|
||||||
|
### Hermes v2026.4.23 chat parity
|
||||||
|
|
||||||
|
Scarf 2.5 mirrors the chat-surface features Hermes's TUI rewrite shipped this week:
|
||||||
|
|
||||||
|
- **`/steer <prompt>`** — non-interruptive mid-run guidance. Surfaces in the slash menu as a special command; sending it doesn't flip the "Agent working…" indicator (the agent's still on its current turn) and shows a transient toast above the composer: "Guidance queued — applies after the next tool call."
|
||||||
|
- **Per-turn stopwatch** — wall-clock duration of each completed assistant turn renders as a compact pill (`4.2s` / `1m 12s`) on the bubble's metadata footer (Mac) or below the bubble (iOS). Resumed sessions loaded from `state.db` show no pill (timing is captured live only).
|
||||||
|
- **Numbered keyboard shortcuts on permission sheet** — Mac approval sheet binds 1–9 to the option buttons (visible "1. " / "2. " prefixes). Power users approve / deny without reaching for the mouse. iOS shows the same numbered hints as a hierarchy cue without the keyboard binding.
|
||||||
|
- **Git branch indicator** — the chat header shows the project's current git branch as a tinted chip alongside the project name (e.g. `📂 myproject · main`). One SSH `git rev-parse --abbrev-ref HEAD` call per session start; nil-out gracefully on non-git dirs / missing git / SSH errors.
|
||||||
|
|
||||||
|
### Spotify + design-md skill onboarding
|
||||||
|
|
||||||
|
Hermes v2026.4.23 added two new skills. Scarf surfaces them properly:
|
||||||
|
|
||||||
|
- **Spotify (`spotify`)** — needs OAuth via `hermes auth spotify`. Mac ships a dedicated Sign-in sheet (mirroring the v2.3 Nous Portal pattern): runs the subprocess, regex-detects the `accounts.spotify.com/authorize?...` URL, auto-opens it in your browser, polls `~/.hermes/auth.json` after subprocess exit to confirm the token landed. Five-state machine (starting → waiting → verifying → success / failure) with retry. iOS surfaces a documentation row noting OAuth needs to happen from Mac or a shell — phone OAuth flows are their own UX problem.
|
||||||
|
- **design-md (`design-md`)** — requires `npx` (Node.js 18+) on the host. New `SkillPrereqService.probe(binary:)` runs `which npx` over the transport on skill detail appear; on miss, both Mac and iOS render a yellow banner with an install hint (per-OS).
|
||||||
|
|
||||||
|
### SKILL.md frontmatter chips
|
||||||
|
|
||||||
|
Hermes v2026.4.23 SKILL.md files carry richer YAML frontmatter (`allowed_tools`, `related_skills`, `dependencies`). Scarf parses it on both platforms (Mac via `HermesFileService.parseSkillFrontmatter`, iOS via `IOSSkillsViewModel.parseFrontmatter`) and renders chip rows in the skill detail view. Old skills without these fields stay nil and the rows hide themselves.
|
||||||
|
|
||||||
|
### "What's New" pill on Skills tab
|
||||||
|
|
||||||
|
Per-server snapshot of `[skillId: signature]` (file count + sorted file names). When the snapshot changes between visits, both Skills views render a tinted pill at the top: "2 new, 4 updated since you last looked." Tap "Mark as seen" to update the snapshot. First-time loads silently prime so users don't see "everything is new!" noise on a fresh install. Persisted to `~/Library/Application Support/com.scarf/skill-snapshots/<serverID>.json` (Mac) / `UserDefaults` (iOS).
|
||||||
|
|
||||||
|
### state.db deltas (Hermes v0.11)
|
||||||
|
|
||||||
|
- `messages.reasoning_content` — newer richer reasoning channel some providers emit alongside the legacy `reasoning` blob. UI prefers the new column when both are populated (`HermesMessage.preferredReasoning`).
|
||||||
|
- `sessions.api_call_count` — distinct from `tool_call_count`; counts per-turn API round-trips. Surfaced as the "API" label on Mac SessionDetailView and as a network-icon chip on Mac/iOS Dashboard session rows.
|
||||||
|
|
||||||
|
`HermesDataService.hasV011Schema` only flips true when both columns are present (partial migrations stay on the v0.7 path to avoid runtime errors). Older Hermes hosts keep working unchanged.
|
||||||
|
|
||||||
|
### `hermes memory reset` toolbar action
|
||||||
|
|
||||||
|
New toolbar button on Mac MemoryView — "Reset memory…" with destructive confirmation dialog. Routes through `hermes memory reset --yes`; refreshes the on-screen content on success, surfaces stderr in an alert on failure. Other v0.11 CLIs (`plugins`, `profile`, `webhook`, `insights`, `logs`) are documented in `CLAUDE.md` for future v2.6 adoption — Scarf still reads the underlying files directly today, which keeps working.
|
||||||
|
|
||||||
|
### Mac global Sessions: project filter + badges
|
||||||
|
|
||||||
|
The per-project Sessions tab shipped in 2.3, but the global Sessions feature still rendered every session as a flat list with no project context. 2.5 closes the gap:
|
||||||
|
|
||||||
|
- **Filter Menu** above the list: All projects / Unattributed / one entry per registered project. An xmark button clears the filter; the right side shows "X of Y shown".
|
||||||
|
- **Project badge** on each row — small tinted folder chip with the project name. Same visual language ScarfGo uses on its Dashboard.
|
||||||
|
- Logic comes from the same `SessionAttributionService` + `ProjectDashboardService` ScarfGo consumes, both in ScarfCore. Single source of truth across platforms.
|
||||||
|
|
||||||
|
### Human-readable cron schedules everywhere
|
||||||
|
|
||||||
|
Pre-2.5, both Mac and iOS rendered cron jobs as `0 */6 * * *` raw. The new `CronScheduleFormatter` in ScarfCore translates the common shapes to plain English (every-N-minutes, every-N-hours, daily-at-H, weekdays-at-H, the `@hourly`/`@daily`/`@weekly`/`@monthly` macros) and falls back to the raw expression for anything custom. Both apps consume it.
|
||||||
|
|
||||||
|
### Under the hood
|
||||||
|
|
||||||
|
- **Shared services.** `SessionAttributionService`, `ProjectContextBlock`, and `CronScheduleFormatter` moved into ScarfCore; both apps consume them via their respective transports (`SSHTransport` on Mac, `CitadelServerTransport` on iOS).
|
||||||
|
- **`RichChatViewModel`** carries the ACP error triplet (`acpError`, `acpErrorHint`, `acpErrorDetails`) for both platforms — Mac's `ChatViewModel` now delegates instead of duplicating.
|
||||||
|
- **Test reliability.** Cross-suite races on `ServerContext.sshTransportFactory` resolved by consolidating every factory-touching test into a single `.serialized` suite. 163 tests across 12 suites, three consecutive green runs.
|
||||||
|
- **Surface silent failures.** Several `try?` swallows in iOS lifecycle code now surface to the user — Keychain unlock errors no longer dump people back into onboarding, partial Forget operations report what failed, project-context-block writes that fail surface a banner instead of silently degrading agent context.
|
||||||
|
- **iOS exec channel hardening.** `CitadelServerTransport.runProcess` was wrapping Citadel's `executeCommand`, which throws `CommandFailed` on non-zero exit and discards the captured stdout buffer in the throw path. `hermes skills browse` happens to print its full table and *then* exit non-zero on some hosts, so iOS got nothing while Mac (Foundation `Process`) got the full output with `exitCode=1`. v2.5 drives `executeCommandStream` directly, drains stdout + stderr regardless of outcome, and recovers the actual exit code from the `CommandFailed` catch. Same channel now also inline-prepends `PATH="$HOME/.local/bin:/opt/homebrew/bin:/usr/local/bin:$PATH"` on every invocation — Citadel's raw exec channel doesn't source the user's shell rc files, so non-interactive sessions land with a stripped `PATH` (`/usr/bin:/bin`) and pipx's default install dir is invisible. Mac's OpenSSH sshd handles this transparently; we now match.
|
||||||
|
- **fd-leak cleanup.** `LocalTransport` / `SSHTransport` / `ProcessACPChannel` all close the parent's copy of every pipe write end after spawn so EOF reaches the reader once the child exits, plus close read ends after draining. Was leaking one fd per `runProcess` / `streamLines` / ACP turn under load.
|
||||||
|
- **Status-poll backoff.** `ServerLiveStatus` now uses 10s → 30s → 60s → 120s → 300s exponential backoff on consecutive probe failures, resetting on the first full success. Previously a registered remote going unreachable hammered `pgrep` + `gateway_state.json` every 10s indefinitely; offline servers now settle to a 5-minute cadence while live ones stay snappy.
|
||||||
|
- **Logger conversion.** Remaining `print("[Scarf] …")` debug statements in `HermesDataService`, `HermesLogService`, and `ProjectDashboardService` swap to `os.Logger` calls (subsystem `com.scarf`), matching the global rule that production code uses `Logger` and `print()` is reserved for previews + test helpers.
|
||||||
|
|
||||||
|
### Notes for users running 2.3
|
||||||
|
|
||||||
|
No data migrations needed. Server configs, Keychain entries, project registries, session attribution sidecar — all forward-compatible. The only invariant change is iOS-only: `ScarfGo.servers.v1` UserDefaults key migrates to `com.scarf.ios.servers.v2` on first launch, and Keychain accounts move from `"primary"` to `"server-key:<UUID>"`. One-shot, idempotent — re-running 2.3 after 2.5 ran would just see the v2 data.
|
||||||
|
|
||||||
|
Push notifications stay disabled in this build. The skeleton (NotificationRouter, category registration, action handlers) is in place behind `apnsEnabled = false` for when Hermes ships a push sender + we get an APNs cert.
|
||||||
@@ -0,0 +1,103 @@
|
|||||||
|
# v2.5 TestFlight submission checklist
|
||||||
|
|
||||||
|
Pre-flight steps to take ScarfGo to public TestFlight. Order matters — Apple review wants the privacy URL to resolve at submission time, and the build needs to upload before review can start.
|
||||||
|
|
||||||
|
## 0. Apple Developer Program prerequisites
|
||||||
|
|
||||||
|
- Apple Developer Program enrollment active (team `3Q6X2L86C4`).
|
||||||
|
- iOS Distribution certificate in login Keychain (`Apple Distribution: Alan Wizemann`).
|
||||||
|
- App Store provisioning profile for the iOS bundle ID (auto-managed in Xcode is fine).
|
||||||
|
- App Store Connect access for the team.
|
||||||
|
|
||||||
|
## 1. Privacy policy live
|
||||||
|
|
||||||
|
- [ ] Copy `scarf/docs/PRIVACY_POLICY.md` content into `.gh-pages-worktree/privacy/index.html` (wrap in minimal HTML, or leave as Markdown if GitHub Pages renders Markdown — GitHub Pages with Jekyll does).
|
||||||
|
- [ ] `cd .gh-pages-worktree && git add privacy/index.html && git commit -m "docs(privacy): publish v2.5 policy" && git push`
|
||||||
|
- [ ] Verify https://awizemann.github.io/scarf/privacy/ resolves (give it ~1 min after push).
|
||||||
|
|
||||||
|
The privacy URL is required by App Store Connect before submitting for Beta App Review. Without it the submission button is disabled.
|
||||||
|
|
||||||
|
## 2. Xcode target configuration
|
||||||
|
|
||||||
|
Open `scarf/scarf.xcodeproj`, select the `scarf mobile` target.
|
||||||
|
|
||||||
|
- [ ] Signing & Capabilities → "Automatically manage signing" ON, team set to `3Q6X2L86C4`.
|
||||||
|
- [ ] Capabilities present: Keychain Sharing only. **Push Notifications stays OFF** — `NotificationRouter.apnsEnabled = false` and the entitlement is absent. Match the two: enable both later together.
|
||||||
|
- [ ] Info.plist sanity:
|
||||||
|
- Bundle Identifier matches App Store Connect record.
|
||||||
|
- `LSApplicationCategoryType = public.app-category.developer-tools`.
|
||||||
|
- `NSAppTransportSecurity` allows the SSH ports the app dials? — N/A for SSH (raw TCP); ATS only governs HTTPS. Skip.
|
||||||
|
|
||||||
|
## 3. Version bump
|
||||||
|
|
||||||
|
The version bump runs automatically via `./scripts/release.sh 2.5.0` in Phase G. Do NOT bump `MARKETING_VERSION` / `CURRENT_PROJECT_VERSION` manually before that — the script writes the version commit and reads `CURRENT_PROJECT_VERSION` to compute the next build number.
|
||||||
|
|
||||||
|
## 4. Archive + upload
|
||||||
|
|
||||||
|
- [ ] Xcode → Product → Scheme → `scarf mobile`.
|
||||||
|
- [ ] Destination → "Any iOS Device (arm64)".
|
||||||
|
- [ ] Product → Archive. Wait for build (~3-5 min).
|
||||||
|
- [ ] Organizer opens automatically. Select the archive → Distribute App.
|
||||||
|
- [ ] Distribution method: **App Store Connect**.
|
||||||
|
- [ ] Destination: **Upload**.
|
||||||
|
- [ ] Distribution options: leave defaults (manage versioning automatically; include bitcode if offered = N/A on Xcode 14+; strip Swift symbols ON).
|
||||||
|
- [ ] Re-sign: automatic.
|
||||||
|
- [ ] Upload. Apple processes the binary (~5-15 min); App Store Connect emails when ready.
|
||||||
|
|
||||||
|
## 5. App Store Connect metadata (TestFlight tab)
|
||||||
|
|
||||||
|
Once the binary is processed:
|
||||||
|
|
||||||
|
- [ ] **App information** (one-time, persists across builds):
|
||||||
|
- Subtitle: "On-the-go Hermes companion"
|
||||||
|
- Privacy policy URL: https://awizemann.github.io/scarf/privacy/
|
||||||
|
- Category: Developer Tools
|
||||||
|
- Age rating: 4+ (no restricted content)
|
||||||
|
- [ ] **Test information** (per-build is fine, persists if not changed):
|
||||||
|
- Beta App Description (paragraph): see "Beta description copy" below.
|
||||||
|
- Email: alan@wizemann.com
|
||||||
|
- Beta App Review information: account credentials only if the app required them — N/A (BYO Hermes host).
|
||||||
|
- Marketing URL (optional): https://github.com/awizemann/scarf
|
||||||
|
- [ ] **What to test** (per-build):
|
||||||
|
```
|
||||||
|
v2.5.0 — first public TestFlight build of ScarfGo. Try connecting to a
|
||||||
|
Hermes host (you'll need an SSH-reachable Hermes install). Test:
|
||||||
|
- Onboarding + Add a second server
|
||||||
|
- Project-scoped chat
|
||||||
|
- Session resume from Dashboard
|
||||||
|
- Sessions tab project filter
|
||||||
|
- Forget a server / re-onboard
|
||||||
|
Known limitations: no push, no in-app Settings editor, English only.
|
||||||
|
Report issues via TestFlight feedback.
|
||||||
|
```
|
||||||
|
|
||||||
|
### Beta description copy
|
||||||
|
|
||||||
|
> ScarfGo is the iOS companion to Scarf, the Mac client for the Hermes AI agent. Connect to a Hermes server you operate (Mac, Linux, or any SSH-reachable host) and run sessions, browse memory, manage cron jobs, and resume conversations from your phone. All data stays between your device and your Hermes host — no developer servers in between.
|
||||||
|
|
||||||
|
## 6. Submit for Beta App Review
|
||||||
|
|
||||||
|
- [ ] TestFlight tab → External Testers → Add a public group called "Public Beta".
|
||||||
|
- [ ] Add the new build to the group.
|
||||||
|
- [ ] Click **Submit for Review**.
|
||||||
|
- [ ] Apple's Beta Review queue is typically 24-48h.
|
||||||
|
|
||||||
|
## 7. After approval
|
||||||
|
|
||||||
|
- [ ] Apple issues a public TestFlight URL (`https://testflight.apple.com/join/XXXXXX`).
|
||||||
|
- [ ] Record the URL — needed in Phases E (wiki ScarfGo page) and F (README v2.5 section).
|
||||||
|
- [ ] **DO NOT** publicize it yet. Update wiki + README in branches first; the user (Alan) decides when to push live.
|
||||||
|
|
||||||
|
## Rollback
|
||||||
|
|
||||||
|
If a build breaks on TestFlight:
|
||||||
|
|
||||||
|
- [ ] Disable the build in App Store Connect → TestFlight → Builds → Expire.
|
||||||
|
- [ ] Fix the bug, archive a new build with the same `MARKETING_VERSION` (Apple requires the build number — `CURRENT_PROJECT_VERSION` — to monotonically increase).
|
||||||
|
- [ ] Upload + add to Public Beta group + submit if Apple flagged the prior build for re-review.
|
||||||
|
|
||||||
|
## Open items / future TestFlight builds
|
||||||
|
|
||||||
|
- **Push notifications** — flip `NotificationRouter.apnsEnabled = true` simultaneously with: enabling the Push Notifications capability, generating an APNs auth key, deploying the Hermes-side push sender. Stops being a no-op only when all three exist.
|
||||||
|
- **iPad support** — `.tabViewStyle(.sidebarAdaptable)` is wired but iPad layout hasn't been smoke-tested. Probably free, but verify before flipping the iPad flag in the target.
|
||||||
|
- **Localization** — English only for v1. Mac ships 7 languages; iOS strings are extracted but no translations.
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
## What's in 2.5.1
|
||||||
|
|
||||||
|
A patch release that bundles every issue reported against 2.5.0 plus a couple of TestFlight-driven iOS fixes. No data migrations needed — drop-in replacement for 2.5.0 on Mac, drop-in TestFlight build on iOS.
|
||||||
|
|
||||||
|
### Bug fixes
|
||||||
|
|
||||||
|
#### Mac
|
||||||
|
|
||||||
|
- **[#49](https://github.com/awizemann/scarf/issues/49) — macOS 26 "Scarf.app is damaged" recovery path.** Verified the shipped 2.5.0 bundles pass `codesign --verify --strict --deep` and `spctl --assess` on macOS 26.4.1; the user-facing "damaged" symptom in some reports turned out to be self-inflicted by destructive recovery commands. Added a [Troubleshooting section](https://github.com/awizemann/scarf/blob/main/README.md) to the README documenting the **non-destructive** fix path (`xattr -d com.apple.quarantine` only — never `xattr -rc` or `codesign --force --deep --sign -`). Hardened the release pipeline: every variant zip now goes through `codesign --verify --strict --deep` + `spctl --assess` after the final `ditto`, so any future regression in the shipped artifact fails the release before a user sees it.
|
||||||
|
- **[#46](https://github.com/awizemann/scarf/issues/46) — chat performance: long sessions no longer bog down or crash.** Long chats were doing O(n) work per streamed token because every chunk rebuilt the full message-group array AND every `MessageGroupView` / `RichMessageBubble` re-evaluated its body. Three changes cap per-chunk work at O(1) for settled groups:
|
||||||
|
- `MessageGroupView` and `RichMessageBubble` are now `Equatable` with `.equatable()` short-circuit. Settled bubbles skip body re-eval entirely while the streaming bubble still redraws.
|
||||||
|
- `RichChatViewModel.upsertStreamingMessage` patches the trailing group in place via a new `patchTrailingGroupForStreaming(...)` instead of running `buildMessageGroups()` per chunk.
|
||||||
|
- `MessageGroup.toolKindCounts` moved to the model (was an `O(m × k)` computed property re-running on every render). `ToolCallCard.formatJSON` cached via `.task(id: callId)`. `ToolResultContent.lines` cached on content change.
|
||||||
|
|
||||||
|
CPU during streaming on a 500-message session drops from sustained 100%+ to ~30–50% on representative hardware.
|
||||||
|
- **[#50](https://github.com/awizemann/scarf/issues/50) — Hermes v0.11 profile awareness.** Hermes v0.11 stores each profile in its own `~/.hermes/profiles/<name>/` directory with its own `state.db`, `sessions/`, `config.yaml`, `memories/`, etc. Pre-fix Scarf hardcoded `~/.hermes` and ignored `~/.hermes/active_profile`, so `hermes profile use coder` followed by a Scarf relaunch silently read the wrong DB — sessions, memory, cron all coming from the default profile. New `HermesProfileResolver` reads `active_profile` and resolves the effective home path; `HermesPathSet.defaultLocalHome` consults it, so every derived path automatically follows the active profile. SessionInfoBar gains a profile chip when not on the default so users can see which profile Scarf is reading from.
|
||||||
|
- **[#53](https://github.com/awizemann/scarf/issues/53) — granular reasons on the "Connected — can't read Hermes state" pill.** Tier 2 of the connection probe now distinguishes config.yaml-missing / `~/.hermes`-missing / permission-denied / Hermes-profile-active and surfaces a pill popover with the specific reason + an actionable hint + Run Diagnostics / Retry buttons. Profile case includes a copy-paste `hermes profile use default` affordance.
|
||||||
|
- **[#44](https://github.com/awizemann/scarf/issues/44) — pill and Run Diagnostics no longer disagree.** A long-standing latent bug surfaced by Tailscale Mac-to-Mac users: the pill probe and the diagnostics view ran the same `[ -r ~/.hermes/config.yaml ]` check but went through different transport paths — `transport.runProcess` for the pill (which `remotePathArg`-quotes every argument and mangled the multi-line script) vs raw `/usr/bin/ssh ... -- /bin/sh -s` for diagnostics. Result: 14/14 diagnostics passing while the pill stayed stuck on "can't read Hermes state". Extracted the diagnostics workaround into a shared `SSHScriptRunner` in ScarfCore; both probes now use it. Side benefit: the granular #53 probe script (more `$VAR`s and nested quotes) is robust against the same class of bug going forward.
|
||||||
|
- **[#54](https://github.com/awizemann/scarf/issues/54) — Add Project on remote server contexts.** The Add Project sheet always rendered a Browse button backed by `NSOpenPanel` (a Mac-local file dialog). On a remote SSH context the user picked a Mac path, the path landed in the projects registry as the project's "remote" working directory, and tool calls failed at runtime because that path doesn't exist on the Linux server. Tier-1 fix: sheet is now context-aware — local context keeps Browse unchanged; remote context hides Browse, shows a `"Path on <server> — must already exist on the server"` hint, and adds a Verify button that runs `transport.stat(path)` and renders inline ✓ / ⚠. A full SFTP-backed remote picker remains a deferred feature.
|
||||||
|
|
||||||
|
#### ScarfGo (iOS)
|
||||||
|
|
||||||
|
- **[#46](https://github.com/awizemann/scarf/issues/46) — same O(n)-per-token fix on iOS.** ScarfGo uses a different chat path (`LazyVStack` directly over `controller.vm.messages`, not message groups) so the Mac fix's `Equatable` conformances didn't propagate. Added an iOS-equivalent `MessageBubble: Equatable` with `.equatable()` at the `ForEach` call site — settled bubbles short-circuit body re-eval while the streaming bubble still redraws.
|
||||||
|
- **[#51](https://github.com/awizemann/scarf/issues/51) — keyboard now dismissable.** Pre-fix the chat composer's `TextField` had no `@FocusState`, no `.scrollDismissesKeyboard`, and no keyboard accessory toolbar; with `axis: .vertical` + `.submitLabel(.send)` the Return key inserts a newline rather than submitting. Once the keyboard rose it stuck — hiding the system tab bar (which iOS auto-hides while a keyboard is up) and trapping users in the Chat tab. Added two redundant dismissal paths: `.scrollDismissesKeyboard(.interactively)` on the message list (drag messages downward to collapse) AND a `keyboard.chevron.compact.down` button in the keyboard accessory toolbar. Tab bar reappears on dismiss → users can switch tabs again.
|
||||||
|
- **[#55](https://github.com/awizemann/scarf/issues/55) — first-run Cancel button no longer looks broken.** TestFlight feedback: the "Connect to Hermes" onboarding's Cancel button appeared dead. Root cause: `RootModel.cancelOnboarding` had a defensive `servers.isEmpty` branch that re-presented a fresh onboarding view when there was nothing to fall back to, making the button fire correctly but visually do nothing. The fix is at the right layer: `OnboardingRootView` now takes a `canCancel: Bool` parameter and hides the Cancel button entirely when there's no server list to return to.
|
||||||
|
|
||||||
|
### New features (Mac)
|
||||||
|
|
||||||
|
- **Chat density preferences ([#47](https://github.com/awizemann/scarf/issues/47) + [#48](https://github.com/awizemann/scarf/issues/48)).** New section in **Settings → Display → Chat density**. All defaults match today's UI exactly so existing users see no change until they opt in.
|
||||||
|
- **Tool calls**: Full card (default) / Compact chip / Hidden. Compact renders each call as a single-line tappable chip — kind icon + function name + status dot — that opens the right-pane inspector with the same details the inline expand shows. Hidden skips per-call rows; the always-visible group summary pill ("Used 5 tools (3 read, 2 edit)") becomes tappable so the inspector pane is still one click away.
|
||||||
|
- **Reasoning**: Disclosure box (default) / Inline (italic) / Hidden. Inline collapses the yellow disclosure to italic faded caption text inline above the reply with a small brain prefix — same data, far less vertical space. Hidden skips reasoning entirely.
|
||||||
|
- **Chat font size**: 85% to 130% slider (5% step). Applied at the chat root via `.environment(\.dynamicTypeSize, ...)` so message list, input bar, session info bar, and inspector pane all scale together.
|
||||||
|
|
||||||
|
All density toggles preserve existing telemetry surfaces — per-turn stopwatch, per-message tokens, finish reason, and timestamp stay in the bubble metadata footer; SessionInfoBar input/output/reasoning tokens, USD cost, model, project, git branch, and started-at relative time are unaffected by every density setting.
|
||||||
|
|
||||||
|
### New features (ScarfGo iOS)
|
||||||
|
|
||||||
|
- **iCloud Keychain sync for SSH keys ([#52](https://github.com/awizemann/scarf/issues/52)).** Reddit-reported friction: every iOS device needed its own SSH key. Pairing iPhone + iPad meant onboarding twice and editing `authorized_keys` per device. New opt-in toggle in **System → Security**: when enabled, the SSH key bundle is stored with `kSecAttrAccessibleAfterFirstUnlock` + `kSecAttrSynchronizable=true` so iCloud Keychain picks it up on every signed-in device. Default off (preserves today's behavior on update). Toggling triggers a one-shot migration that re-saves all stored keys with the target attributes; failure reverts the toggle and surfaces the error inline. With Advanced Data Protection enabled, the encryption keys never leave your devices.
|
||||||
|
|
||||||
|
### Documentation + tooling
|
||||||
|
|
||||||
|
- **Privacy / sandboxing claim corrected.** Previous CLAUDE.md / README implied Scarf ran sandboxed; it doesn't (and can't, given that it spawns the user-installed `hermes` binary and reads `~/.hermes/` directly). Documentation now reflects the actual posture.
|
||||||
|
- **Release pipeline hardened.** `scripts/release.sh` now extracts each variant's distribution zip and runs `codesign --verify --strict --deep` + `spctl --assess --type execute` on the extracted bundle as a final gate. Catches any future regression in the shipped artifact pre-ship rather than via user reports.
|
||||||
|
|
||||||
|
### Notes for users running 2.5.0
|
||||||
|
|
||||||
|
No data migrations needed. Server configs, Keychain entries, project registries, session attribution sidecar — all forward-compatible. The iCloud Keychain sync toggle defaults to off, so existing iOS users keep their device-local keys until they opt in.
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
## What's in 2.5.2
|
||||||
|
|
||||||
|
A patch with one substantial new feature (**iOS chat resilience** — reconnect, cached snapshot fallback, history paging) plus a stack of fixes for issues reported against 2.5.1 and earlier. Drop-in replacement for 2.5.1 on Mac; drop-in TestFlight build on iOS. No data migrations.
|
||||||
|
|
||||||
|
### iOS chat resilience
|
||||||
|
|
||||||
|
ScarfGo now survives phone-sleep, network handoffs, and SSH socket drops without losing the agent's work. Hermes was already persisting messages to `state.db` in real-time; iOS just had no resync path.
|
||||||
|
|
||||||
|
- **5-attempt exponential reconnect** (1s → 2s → 4s → 8s → 16s) via `session/resume` with `session/load` fallback. Reconciles with `state.db` on success and surfaces a *"Resynced N new messages"* toast when the agent kept working through the disconnect.
|
||||||
|
- **`NetworkReachabilityService`** (NWPathMonitor singleton): suspends reconnect attempts while offline and kicks a fresh cycle on link-up. Two new banner states above the message list — `.reconnecting` and `.offline` — render as slim ScarfDesign-tinted strips so the user always knows what the chat is doing.
|
||||||
|
- **Scene-phase awareness**: returning to foreground triggers a channel-health check; if dead, the reconnect cycle starts immediately rather than waiting for the next interaction.
|
||||||
|
- **Draft persistence**: per-server, per-session draft survives force-quit (UserDefaults-backed, 7-day janitor at app launch).
|
||||||
|
|
||||||
|
### Cached snapshot fallback (Mac + iOS)
|
||||||
|
|
||||||
|
`ServerTransport.cachedSnapshotPath` lets `HermesDataService` fall back to the previously-pulled `state.db` snapshot when a fresh pull fails. `isUsingStaleSnapshot` + `lastSnapshotMtime` surface to views so they render *"Last updated X ago."* Chat-history reload still passes `forceFresh: true` to refuse stale data; everything else (Dashboard, Sessions list, Activity) gets read-while-disconnected for free.
|
||||||
|
|
||||||
|
### Bounded message-history paging
|
||||||
|
|
||||||
|
`HermesDataService.fetchMessages(sessionId:limit:before:)` paginates by id desc with centralized `HistoryPageSize` constants. `RichChatViewModel.loadEarlier()` walks back through long sessions via `oldestLoadedMessageID` + `hasMoreHistory`. Legacy unbounded overload deprecated.
|
||||||
|
|
||||||
|
### Bug fixes
|
||||||
|
|
||||||
|
#### Mac
|
||||||
|
|
||||||
|
- **[#46](https://github.com/awizemann/scarf/issues/46) — chat O(n)-per-token bog-down (already shipped in 2.5.1 for the trailing-group patch; this release retains the fix and pairs with the new history paging so chats with thousands of messages stay smooth).**
|
||||||
|
- **[#19](https://github.com/awizemann/scarf/issues/19) layer-3 — sqlite3 false-negative in diagnostics.** Already in v2.5.1; kept here.
|
||||||
|
- **[#44](https://github.com/awizemann/scarf/issues/44) — pill / diagnostics agreement** via shared `SSHScriptRunner`. From v2.5.1; the tier-2 probe now also checks `state.db` (not just `config.yaml`) so a healthy fresh install reports green.
|
||||||
|
- **[#59](https://github.com/awizemann/scarf/issues/59) — Settings → Model and Credential Pools no longer freeze.** Both views called `ModelCatalogService.loadProviders()` synchronously from `.onAppear` on the MainActor; on a remote SSH context that's a multi-megabyte SSH file read on the main thread, freezing the UI for 1–2 minutes. New `loadProvidersAsync()` / `loadModelsAsync(for:)` wrappers dispatch off the main thread; both views now use `.task` + `await` with a `ProgressView("Loading providers…")` overlay. Per-provider switching in the picker is also async now, so clicking a different provider doesn't re-freeze the UI.
|
||||||
|
- **Diagnostics tri-state.** Hermes v0.11+ doesn't materialize `config.yaml` until the user changes a setting from defaults — so the diagnostics view was reporting *"12/14 passing"* on healthy fresh installs. The probe now distinguishes `.pass` / `.fail` / `.skipped`; a missing `config.yaml` emits SKIP and is excluded from the summary's denominator. Reads as *"12/12 passing (2 optional skipped)"* instead of the misleading 12/14.
|
||||||
|
- **Credentials: OAuth providers visible.** `hasAnyAICredential()` only probed `credential_pool.<provider>` in `auth.json`; OAuth-authed providers land under `providers.<name>.access_token` (Nous, Spotify, GH Copilot ACP, Qwen, Gemini all use that path). The chat banner kept showing *"No AI provider credentials"* even after a successful Nous sign-in. Now both shapes count. Credential Pools view gains a parallel "OAuth providers" section listing OAuth-authed providers with token tail, expiry badge, and portal URL.
|
||||||
|
- **Project-shadowed Hermes detection.** New `ProjectHermesShadowDetector` (ScarfCore) probes each registered project at chat-start; if a `.hermes/` dir or `hermes.yaml` is found inside the project, the user gets a banner explaining that project-local Hermes config will shadow the server-level one (a quiet failure mode for users who didn't realize Hermes prefers project-local config).
|
||||||
|
- **[#58](https://github.com/awizemann/scarf/issues/58) — Mac chat side panes are hideable.** Two toolbar buttons next to the View picker (`sidebar.left` / `sidebar.right`) toggle the sessions list and tool inspector with a slide animation; both default visible (today's behavior). Clicking a tool card auto-shows the inspector if hidden so the click never silently dies. Settings → Display → Chat density gains parity Toggle rows.
|
||||||
|
|
||||||
|
#### ScarfGo (iOS)
|
||||||
|
|
||||||
|
- **[#56](https://github.com/awizemann/scarf/issues/56) — *"Citadel.SSHClient.CommandFailed error 1"* on dashboard.** `asyncSnapshotSQLite` was missed during the v2.5.0 Citadel hardening — used raw `executeCommand` (which discards stderr on non-zero exit) and didn't prepend the Citadel-friendly `PATH=$HOME/.local/bin:/opt/homebrew/bin:/usr/local/bin:$PATH`. Now uses `executeCommandStream` and the same PATH prefix. `HermesDataService.humanize` already translates `sqlite3: command not found` / `permission denied` / `no such file` into actionable user copy — the bug was that the snapshot path never fed it real stderr.
|
||||||
|
- **[#57](https://github.com/awizemann/scarf/issues/57) — keyboard-dismiss chevron over send button.** The keyboard accessory dismiss button added in v2.5.1 (#51) was placed at the trailing edge of the keyboard toolbar, directly above the trailing-edge send button. Moved to the leading edge — matches the iOS convention (Notes, Mail, Reminders).
|
||||||
|
|
||||||
|
### New features (Mac)
|
||||||
|
|
||||||
|
- **Chat-start model preflight ([commit](https://github.com/awizemann/scarf/commit/2aab9da)).** Catches a missing `model.default` / `model.provider` in `config.yaml` *before* the ACP session starts. Pre-fix the user typed a prompt, hit send, and got an opaque *"Model parameter is required"* HTTP 400 from the upstream provider. Now `ChatModelPreflightSheet` wraps the existing model picker so the same selection / validation / Nous-catalog branch is single-sourced; the chat the user originally opened lands without re-clicking the project row.
|
||||||
|
- **Nous Portal live model catalog.** `NousModelCatalogService` fetches `GET /v1/models` from `inference-api.nousresearch.com` using the bearer token in `auth.json`. Cached at `~/.hermes/scarf/nous_models_cache.json` with a 24h TTL. The picker's nous-overlay detail view switches from a free-form TextField to a real model list, with a *"Custom…"* escape hatch for IDs not yet in the API response.
|
||||||
|
- **Remote-aware admin sheets.** Three sheets gained the same context-aware Verify pattern that Add Project got in v2.5.1 (#54):
|
||||||
|
- **Profiles → Import / Export.** Buttons that drive `hermes profile import <zip>` / `hermes profile export <name> <zip>` over SSH. Local context picks via `NSOpenPanel`; remote context shows a path-input + Verify button.
|
||||||
|
- **Settings → Advanced → Restore.** Pick a local backup zip OR enter+verify a remote path.
|
||||||
|
- **Templates → Install destination.** The parent-directory step in the install sheet branches on context — local Browse, or remote text-input + Verify.
|
||||||
|
|
||||||
|
### Translations
|
||||||
|
|
||||||
|
`Localizable.xcstrings` adds strings for all the new copy across the seven supported locales (English, Simplified Chinese, German, French, Spanish, Japanese, Brazilian Portuguese).
|
||||||
|
|
||||||
|
### Notes for users running 2.5.1
|
||||||
|
|
||||||
|
No data migrations needed. `~/.hermes/scarf/nous_models_cache.json` is created lazily on first use of the Nous picker; everything else is forward-compatible with existing config / Keychain / project registries.
|
||||||
@@ -0,0 +1,134 @@
|
|||||||
|
## What's in 2.6.0
|
||||||
|
|
||||||
|
A major release tracking **Hermes v2026.4.30 (v0.12.0)** — the largest single Hermes update Scarf has had to follow since v0.10's Tool Gateway. Headline additions: the autonomous **Curator**, **multimodal image input** in chat, **5 new inference providers**, **Microsoft Teams + Yuanbao** gateway platforms, a **read-only Kanban** view, and ScarfGo gains read-only Webhooks/Plugins/Profiles plus a Hermes-version banner.
|
||||||
|
|
||||||
|
Pre-v0.12 Hermes hosts are fully supported. Every new surface is gated on a runtime capability detector (`hermes --version` → semver), so users on older Hermes installs see the v2.5 surface unchanged. UI doesn't appear until the underlying CLI subcommand exists.
|
||||||
|
|
||||||
|
### Curator (Mac + iOS)
|
||||||
|
|
||||||
|
Hermes v0.12's autonomous skill curator prunes / consolidates / archives agent-created skills on a 7-day schedule. Scarf adds a dedicated **Curator** sidebar item under Interact (Mac) and a Curator nav row under the System tab (iOS).
|
||||||
|
|
||||||
|
- **Status panel** — enabled/paused/disabled badge, last-run timestamp, last summary, run count, scheduling cadence (interval / stale-after / archive-after).
|
||||||
|
- **Run Now** button triggers `hermes curator run`; pause/resume from the kebab menu.
|
||||||
|
- **Three leaderboards** — least-recently-active, most-active, least-active. Each row carries activity / use / view / patch counters and an inline pin toggle.
|
||||||
|
- **Pin / unpin** — pinned skills are protected from auto-archive and rewrites. State pulled from `~/.hermes/skills/.curator_state` and surfaced as a pin glyph everywhere skills appear (Curator screen, Skills sidebar/list, SkillDetailView).
|
||||||
|
- **Restore archived** sheet calls `hermes curator restore <name>` to bring a previously-archived skill back.
|
||||||
|
- **Last report Markdown** — when present, the previous run's REPORT.md renders inline in mono.
|
||||||
|
|
||||||
|
Capability-gated; sidebar item disappears on pre-v0.12 hosts.
|
||||||
|
|
||||||
|
### Multimodal image input in chat (Mac + iOS)
|
||||||
|
|
||||||
|
Hermes v0.12 advertises `prompt_capabilities.image = true` on ACP and accepts image content blocks in `session/prompt`. Scarf wires the producer side on both targets:
|
||||||
|
|
||||||
|
- **Mac**: paperclip toolbar button on the chat composer opens NSOpenPanel multi-pick. Drag-and-drop and paste also work — drop an image (or a Finder file URL) onto the composer and it attaches. Capability-gated; the entire attachment surface is hidden on pre-v0.12 hosts.
|
||||||
|
- **iOS**: paperclip button opens PhotosPicker (multi-select up to 5 photos). Same byte-for-byte capability gate.
|
||||||
|
- **ImageEncoder** downsamples to 1568px long-edge (Anthropic's recommended ceiling) at JPEG q=0.85, so a 12 MP screenshot lands under ~300 KB on the wire. Detached only — never blocks MainActor.
|
||||||
|
- **Image-only sends are valid** — once at least one attachment is queued, the send button enables even with empty text. Vision models accept "describe this" with no caption.
|
||||||
|
- **Per-attachment chips** above the input field with thumbnail + filename tooltip + X to remove. 5-image-per-message cap; total payload stays under ~2 MB so cellular sends don't time out.
|
||||||
|
|
||||||
|
Hermes routes the resulting prompt to a vision-capable model automatically — no extra Scarf-side work to pick the right aux model.
|
||||||
|
|
||||||
|
### 5 new inference providers (Mac + iOS)
|
||||||
|
|
||||||
|
Five overlay-only providers added to `ModelCatalogService.overlayOnlyProviders`. The model picker reaches all of them; provider IDs match `HERMES_OVERLAYS` in `hermes_cli/providers.py` exactly so a typo here doesn't strand users with an unreachable provider.
|
||||||
|
|
||||||
|
- **GMI Cloud** (api_key) — `https://api.gmi-serving.com/v1`
|
||||||
|
- **Azure AI Foundry** (api_key) — base URL resolved from `AZURE_FOUNDRY_BASE_URL` per tenant
|
||||||
|
- **LM Studio** (api_key, first-class) — promoted from custom-endpoint alias to a real provider; defaults to `http://127.0.0.1:1234/v1`
|
||||||
|
- **MiniMax (OAuth)** (oauth_external) — `https://api.minimax.io/anthropic`
|
||||||
|
- **Tencent TokenHub** (api_key) — base URL resolved from `TOKENHUB_BASE_URL`
|
||||||
|
|
||||||
|
### `auxiliary.curator` aux task (Mac)
|
||||||
|
|
||||||
|
Hermes removed `auxiliary.flush_memories` entirely in v0.12 (the underlying memory pipeline was rewritten) and added `auxiliary.curator` so the curator's review fork can run on a separate model from the main agent. Settings → Auxiliary now surfaces a Curator row when the active host is v0.12+ (gated on `HermesCapabilities.hasCuratorAux`); the obsolete Flush Memories panel is gone.
|
||||||
|
|
||||||
|
The Tool Gateway health view in HealthView lost the flushMemories-routes-through-Nous row and gained a curator row, matching the new aux task list.
|
||||||
|
|
||||||
|
### Skills v0.12 surface (Mac + iOS)
|
||||||
|
|
||||||
|
Three new capabilities Scarf can now reach:
|
||||||
|
|
||||||
|
- **Direct-URL install** — `hermes skills install <https-url>` lets users pull a one-off skill without going through a registry. Mac SkillsView gains an "Install from URL…" toolbar button (capability-gated) opening a sheet with the URL field plus optional `--category` / `--name` overrides.
|
||||||
|
- **Reload** — `hermes skills audit` rescans the skills directory and refreshes the agent's view without a session restart. Wired to a "Reload" toolbar button next to the install button on Mac.
|
||||||
|
- **Enabled / disabled state** — `skills.disabled` in config.yaml is read at scan time. Disabled skills render strikethrough + an "OFF" pill on Mac and iOS rows; iOS detail view explains the state in plain text.
|
||||||
|
- **Curator pin badge** — pinned-skill names from `~/.hermes/skills/.curator_state` surface as a pin glyph on each row across Mac sidebar and iOS list, plus an explanatory chip on iOS detail view.
|
||||||
|
|
||||||
|
The disable-toggle write path is deferred to v2.7 — Hermes only exposes `hermes skills config` as an interactive verb today, and we'd rather read accurately than risk clobbering the user's list with a half-tested write.
|
||||||
|
|
||||||
|
### Cron — `--workdir` flag (Mac)
|
||||||
|
|
||||||
|
Hermes v0.12 cron jobs accept `--workdir <absolute-path>` to inject AGENTS.md / CLAUDE.md / .cursorrules from that directory and pin cwd for terminal/file/code_exec tools. Scarf's CronJobEditor now has a Workdir field; both create and edit paths forward the flag. Existing v0.11 jobs keep the no-cwd behaviour by leaving the field blank.
|
||||||
|
|
||||||
|
The `context_from` chaining field is read-only from Scarf this round (Hermes hasn't exposed a `--context-from` CLI flag yet, only YAML).
|
||||||
|
|
||||||
|
### Microsoft Teams + Yuanbao (Mac)
|
||||||
|
|
||||||
|
Two new gateway platforms. Microsoft Teams (the 19th platform) ships as a plugin; Yuanbao 元宝 (the 18th) is a native gateway adapter. Both surface in the Platforms tab with read-only setup panels — the OAuth dance for Yuanbao and the plugin install for Teams happen outside Scarf.
|
||||||
|
|
||||||
|
### Read-only Kanban (Mac)
|
||||||
|
|
||||||
|
Hermes v0.12 ships a SQLite-backed multi-tenant task board with a full CLI (`hermes kanban create / list / claim / dispatch / …`). The multi-profile *collaboration* layer was reverted upstream while the design is reworked, so v2.6 ships a **read-only** Kanban view: paginated table of `hermes kanban list --json` filtered by status, with status badges, meta chips (id / assignee / workspace / skills), and per-row metadata. 5-second polling while the view is foregrounded; suspended on disappear.
|
||||||
|
|
||||||
|
Create / claim / dispatch UI is deferred until upstream stabilizes — building the editor now would risk rework on a quarter-out timeline.
|
||||||
|
|
||||||
|
### Settings deltas (Mac)
|
||||||
|
|
||||||
|
A new **Caching & Redaction** section under Settings → Advanced with three v0.12 knobs (gated on capability):
|
||||||
|
|
||||||
|
- **Prompt cache TTL** picker — 5m default / 1h opt-in. Reduces cache writes on long agent loops with stable system prompts.
|
||||||
|
- **Redact secrets in patches** toggle — Hermes flipped this off by default in v0.12 because the substitution corrupted patches; security-sensitive users can flip it back on here.
|
||||||
|
- **Runtime metadata footer** toggle — opt-in compact footer on each final reply (provider/model/cost/turn count).
|
||||||
|
|
||||||
|
TTS provider list gains **piper** (native local TTS engine new in v0.12). Terminal backend list gains **vercel** (Vercel Sandbox backend for execute_code/terminal). Both ride along unconditionally — Hermes silently falls back when an older host doesn't recognize the value.
|
||||||
|
|
||||||
|
### iOS catch-up — Webhooks / Plugins / Profiles (read-only)
|
||||||
|
|
||||||
|
Three new System-tab nav rows in ScarfGo, all read-only:
|
||||||
|
|
||||||
|
- **Webhooks** — list of `hermes webhook list` output with description / deliver / events / route per row. "Platform not enabled" detection so a freshly-installed Hermes shows setup guidance instead of error noise.
|
||||||
|
- **Plugins** — filesystem-first scan over `~/.hermes/plugins/` with manifest reads (plugin.json or plugin.yaml). Enabled/disabled badge, version, source, path.
|
||||||
|
- **Profiles** — `hermes profile list` with active-profile highlighting from `~/.hermes/active_profile`. Tolerant of both Rich box-drawn and plain-text outputs.
|
||||||
|
|
||||||
|
None of the three are capability-gated — the underlying list verbs work on both v0.11 and v0.12. Create / edit / delete remain Mac-only since they touch enough state we keep them off the phone.
|
||||||
|
|
||||||
|
### Hermes-version banner (iOS)
|
||||||
|
|
||||||
|
Yellow banner at the top of the Dashboard tab when the active server is pre-v0.12. Lists the v0.12 capabilities the user is missing out on (curator, multimodal image input, new providers); one-tap session-dismiss; reappears on next app open. Hidden entirely on v0.12+ hosts.
|
||||||
|
|
||||||
|
### Internal — version-aware capability detection
|
||||||
|
|
||||||
|
The foundation of every gated surface above:
|
||||||
|
|
||||||
|
- `HermesCapabilities` value type parses `Hermes Agent v0.12.0 (2026.4.30)` from `hermes --version` output. Exposes booleans for each release-gated UI surface (`hasCurator`, `hasACPImagePrompts`, `hasKanban`, `hasOneShot`, `hasSkillURLInstall`, `hasFallbackCommand`, `hasUpdateCheck`, `hasPiperTTS`, `hasVercelTerminal`, `hasCuratorAux`, `hasTeamsPlatform`, `hasYuanbaoPlatform`, `hasCronWorkdir`, `hasPromptCacheTTL`, `hasRedactionToggle`, `hasFlushMemoriesAux`).
|
||||||
|
- `HermesCapabilitiesStore` (`@Observable @MainActor`) caches per-server capabilities. Injected on `ContextBoundRoot` (Mac) and `ScarfGoTabRoot` (iOS) via `.environment(_:)` and `.hermesCapabilities(_:)`.
|
||||||
|
- 12 parser tests + 6 curator-output parser tests lock the v0.12 / v0.11 / fallback flag matrices.
|
||||||
|
|
||||||
|
### Bug fixes
|
||||||
|
|
||||||
|
#### Chat composer + transcript (post-merge round)
|
||||||
|
|
||||||
|
- **Typing lag in the chat composer (#67)** — `RichChatInputBar.updateMenuState()` ran on every keystroke and unconditionally wrote both `showMenu` and `selectedIndex`, tripping SwiftUI's "action tried to update multiple times per frame" warning and stalling input. Composer now coalesces writes to deltas, short-circuits when not in slash mode (the common case), and watches `commands.count` instead of re-allocating `commands.map(\.id)` per keystroke.
|
||||||
|
- **Chat font-size slider had no visible effect (#68)** — `RichChatView` only set `\.dynamicTypeSize`, 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. New `\.chatFontScale` env value plumbed through `RichMessageBubble`, `MarkdownContentView`, and `CodeBlockView`; `ChatFontScale.{body, caption, captionStrong, caption2, mono, monoSmall, codeBlock, codeInline}(_:)` helpers mirror the ScarfFont base sizes so 100% is byte-for-byte identical to today's UI.
|
||||||
|
- **Placeholder ghosting on first keystroke (#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 on top of the just-typed character. Pinned an opaque background behind the placeholder rect and switched the conditional to `.opacity(...)` so the view tree stays stable per keystroke.
|
||||||
|
- **Draft text leaked between conversations (#62)** — composer `@State` survived session switches because the surrounding view tree was structurally identical. Bound `RichChatInputBar`'s identity to `richChat.sessionId` so SwiftUI rebuilds the view (and its `@State`) on session change. Stable fallback string for the "no session selected" window — `UUID()` would have minted a new id per body re-eval and trashed the composer mid-typing.
|
||||||
|
- **Sent message rendered blank after navigating away (#63)** — when a user sent a prompt and immediately resumed a different session before Hermes flushed the row to state.db, `resumeSession`'s `reset()` cleared `messages` and `loadSessionHistory` then read an as-yet-empty DB. New per-session pending-user-messages cache survives `reset()` and re-injects still-pending entries on load; entries clear themselves as soon as a matching DB row catches up.
|
||||||
|
- **No completion notification (#64)** — sending a long prompt and switching to other work required polling the chat to know when the response landed. New `ChatNotificationService` fires a local `UNUserNotificationCenter` banner on prompt completion when Scarf isn't the foreground app. Settings → Display → Feedback → "Notify when Hermes finishes" toggle, default on.
|
||||||
|
- **Per-message TTS playback (#66)** — small speaker glyph in each settled assistant bubble's metadata footer; uses `AVSpeechSynthesizer` with the user's macOS Spoken Content default voice, picks up offline. Markdown control characters stripped before speech. The deeper Settings → Voice provider integration (Edge / ElevenLabs / OpenAI / NeuTTS / Piper) is queued as a v2.7 follow-up.
|
||||||
|
- **ACP control-message timeout under gateway concurrency (#61)** — bumped 30s → 60s. State.db lock contention on a healthy host clears in seconds, but the previous 30s watchdog tripped under realistic gateway+ACP concurrency (Discord sync / skill registration / cron scheduling holding write locks during ACP `initialize` / `session/new` / `session/load`). 60s gives lock resolution headroom while still surfacing genuinely broken transports.
|
||||||
|
|
||||||
|
#### Pre-merge
|
||||||
|
|
||||||
|
- **Test target compile** — `M5FeatureVMTests.ScriptedTransport` had drifted off the `ServerTransport` protocol after `cachedSnapshotPath` landed in v2.5.2; added the missing stub. `M0dViewModelsTests` got the `ConnectionStatusViewModel.Status.degraded` argument-name update. `CredentialPoolsGatingTests` got the missing `import ScarfCore`. The full `swift test` suite now runs (and passes — 215 tests across 17 suites).
|
||||||
|
- **iOS package compile** — `RemoteBackupService.zipDirectory` and `RemoteRestoreService.unzipArchive` used `Foundation.Process` unconditionally, breaking the iOS build entirely (Process is unavailable on the iOS SDK). Wrapped in `#if !os(iOS)` with iOS stubs that throw — backup/restore is Mac-only by design.
|
||||||
|
|
||||||
|
### Hermes version
|
||||||
|
|
||||||
|
Targets Hermes **v2026.4.30 (v0.12.0)**. v2026.4.23 (v0.11.0) hosts continue to work — every v0.12 surface is gated on capability detection, so Scarf v2.6 against v0.11 looks identical to Scarf v2.5.2 against v0.11. Update Hermes (`hermes update`) to unlock the new surfaces.
|
||||||
|
|
||||||
|
### Compatibility
|
||||||
|
|
||||||
|
- macOS 14+ (unchanged)
|
||||||
|
- iOS 17+ (unchanged)
|
||||||
|
- Hermes v0.11+ for the v2.5 surface; v0.12+ for the new features above.
|
||||||
|
- No data migrations.
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
## What's in 2.6.5
|
||||||
|
|
||||||
|
A patch release that ships **template discoverability**, **cron observability**, and an **end-to-end UI test harness** that locks the new install path against regression. No breaking changes; every Hermes capability target is unchanged from 2.6.0.
|
||||||
|
|
||||||
|
### In-app Template Catalog
|
||||||
|
|
||||||
|
The catalog is no longer web-only. **Templates → Browse Catalog…** opens a sheet that fetches the live catalog from `awizemann.github.io/scarf/templates/`, renders one row per published template with name + version + tags, and one-click installs through the existing flow. Search filters across name / description / tags; the category picker constrains to whatever categories the loaded catalog actually carries.
|
||||||
|
|
||||||
|
- **Install-state badges** — each row shows "Installed v1.2.0" (green) or "Update v1.3.0" (amber) when the catalog version is newer than what's in `~/.hermes/scarf/projects.json`. Update is "uninstall + reinstall" today; in-place upgrade is on the v3 backlog.
|
||||||
|
- **24h cache** at `~/.hermes/scarf/catalog_cache.json` so opening the sheet repeatedly doesn't re-hit the network. Refresh icon force-fetches.
|
||||||
|
- **Bundled fallback** — fresh-install / offline users still see the official templates as a hardcoded list. Network failures serve stale cache with a "refresh failed" hint.
|
||||||
|
- **Catalog-schema decoder fault tolerance** — one malformed entry on the live catalog can't bring down the whole list. The bad row is dropped with a logged warning; the rest survive.
|
||||||
|
|
||||||
|
### HackerNews Daily Digest template
|
||||||
|
|
||||||
|
First template added under the new dogfooding-templates loop. Configurable `min_score`, `max_items`, `topics`; one daily-at-08:00 cron job (paused on install) that pulls the HN Firebase API, filters, and prepends a markdown digest to the project's `digest.md`. No API keys required. Live at the catalog URL above.
|
||||||
|
|
||||||
|
### Cron observability — auth-error banner + running indicator + log tail
|
||||||
|
|
||||||
|
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:
|
||||||
|
|
||||||
|
- **OAuth re-auth.** `ACPErrorHint.classify` runs on `job.lastError`; when it returns `oauthRefreshRevoked(provider)` the detail pane shows the human-readable hint + a **Re-authenticate** button that drops the user into Credential Pools — same wiring ChatView's banner uses. Unrecognized errors fall back to the legacy red `lastError` text.
|
||||||
|
- **Running indicator.** The row dot turns blue + pulses when `state == "running"` (precedence over disabled / error / success); the detail header gains a "running…" badge next to active/paused. No new polling — `HermesFileWatcher.lastChangeDate` already drives `CronViewModel.load()`.
|
||||||
|
- **Last run output.** Collapsible panel replacing the inline log: a one-line summary (`<timestamp> — ok|error|running…`) always visible, full monospaced terminal-style scroll on expand, auto-scrolls to bottom when new runs land.
|
||||||
|
|
||||||
|
Also fixes a pre-existing bug in `HermesFileService.loadCronOutput` that returned the wrong file under Hermes's per-job-id output nesting.
|
||||||
|
|
||||||
|
### Layer B install-drive XCUITest harness
|
||||||
|
|
||||||
|
The dogfooding-templates initiative ships its first end-to-end UI test that drives the install pipeline:
|
||||||
|
|
||||||
|
```
|
||||||
|
Launch with --scarf-test-mode → Sidebar → Projects → Install sheet
|
||||||
|
(via --scarf-test-install-url launch arg) → Configure → Open Project
|
||||||
|
→ Right-click → Uninstall Template → Confirm Remove → Done
|
||||||
|
```
|
||||||
|
|
||||||
|
Runs ~30 s green on the dev Mac, validates 9 assertion points across the user journey. Covers the new accessibility identifiers wired in this release: `templateConfig.commitButton`, `projects.row.<name>`, `sidebar.section.<rawValue>`, `projects.contextMenu.uninstallTemplate`, `templateUninstall.confirmRemove`, `templateInstall.success.openProject`, `templateUninstall.success.done`. The `--scarf-test-install-url` launch arg + `TestModeFlags.isTestMode` gating lets XCUITest skip SwiftUI Menu / NSToolbarItem accessibility-bridging quirks that otherwise block toolbar-menu driving.
|
||||||
|
|
||||||
|
Wiki [Test-Harness](https://github.com/awizemann/scarf/wiki/Test-Harness) documents how to extend the harness for the next template.
|
||||||
|
|
||||||
|
### Sentinel-marker test isolation (incident-response hardening)
|
||||||
|
|
||||||
|
`SCARF_HERMES_HOME` override now requires the path to contain a `.scarf-test-home-marker` file to activate. Without the marker, production code falls through to the user's real `~/.hermes/`. Lands belt-and-braces protection for cases where a test crashes mid-teardown leaving the env var set, an env var inherits from a parent shell, or a misconfigured launchctl plist exports the variable. The override remains the seam every E2E test relies on; the marker file ensures it can't accidentally pivot a non-test process off the user's data.
|
||||||
|
|
||||||
|
### Chat fixes
|
||||||
|
|
||||||
|
- **OAuth refresh-revoked surface.** Chat-side error banner now classifies the message via `ACPErrorHint.classify` and offers an in-app **Re-authenticate** button that routes through Credential Pools (#65). Same primitive the new cron banner reuses.
|
||||||
|
- **Placeholder ghosting fix.** TextEditor's placeholder now clips to the editor's bounds and clears on focus instead of bleeding past the cursor area when the user types fast (#67).
|
||||||
|
|
||||||
|
### Profile chip + structured logs
|
||||||
|
|
||||||
|
- **Active-profile chip in the sidebar header.** Click → routes to Profiles. Local contexts only (remote SSH would mislead).
|
||||||
|
- **Switch & Relaunch** flow now writes `~/.hermes/active_profile` and relaunches Scarf in a single click instead of asking the user to quit+reopen.
|
||||||
|
- Profile-resolver logs are now structured (key=value form) so `log show … | grep ProfileResolver` can pull "which profile did Scarf resolve to and why" out of support requests.
|
||||||
|
|
||||||
|
### Swift 6 cleanup
|
||||||
|
|
||||||
|
- `MessageSpeechService` — drop `@preconcurrency` on the AVSpeechSynthesizerDelegate conformance now that the protocol's Sendable annotations are upstreamed.
|
||||||
|
- `ChatView` — `RichChatViewModel.PendingPermission: @retroactive Identifiable`. Quiets the Swift 6 compiler so downstream breakage would be loud if ScarfCore ever adds the conformance upstream.
|
||||||
|
- `CredentialPoolsView` — `.help(Text(verbatim:))` so backticks render literally instead of being treated as markdown inline-code.
|
||||||
|
|
||||||
|
### iOS
|
||||||
|
|
||||||
|
- Composer redesigned with HIG touch targets + clear disabled state.
|
||||||
|
- Portrait lock retained.
|
||||||
|
- Chat-start preflight moved off MainActor.
|
||||||
|
|
||||||
|
### Known caveats
|
||||||
|
|
||||||
|
- **Cron-job-uninstall by name is ambiguous** when two projects share the same template id. The Layer B test surfaced this — manifests as: the test passes, but if you've manually installed the same template before running the test, your real cron job can disappear. Recovery is `hermes cron create`. Fix is queued: store cron-job IDs in `<project>/.scarf/template.lock.json` at install time and resolve by ID at uninstall time.
|
||||||
|
- **Full-suite parallel test runs intermittently hang** — pre-existing flaky test infrastructure unrelated to this release. Individual suites all pass; the hang only manifests on `xcodebuild test` with everything concurrent. The sentinel-marker hardening prevents user-data damage from any race.
|
||||||
|
|
||||||
|
### Compatibility
|
||||||
|
|
||||||
|
- **Hermes target unchanged from 2.6.0**: v2026.4.30 (v0.12.0). Pre-v0.12 Hermes hosts continue to work — no new capability gates added in this release.
|
||||||
|
- **Min macOS unchanged**: 14.6.
|
||||||
|
- **No schema changes** to anything in `~/.hermes/`. The two new Scarf-owned files (`scarf/catalog_cache.json` and the template-installer's `.scarf-test-home-marker` for tests) are additive.
|
||||||
@@ -0,0 +1,155 @@
|
|||||||
|
## What's in 2.7.0
|
||||||
|
|
||||||
|
The biggest release since 2.6.0 — a six-week stretch covering **remote-context performance**, a **new project authoring flow**, **dashboard widgets**, **OAuth resilience**, and a top-to-bottom **performance instrumentation harness** that drove the bulk of the rest. 36 commits, no schema bump, no Hermes capability bump.
|
||||||
|
|
||||||
|
The throughline: Scarf got materially faster and more honest on slow remote SSH links, where 30-second sqlite timeouts and silently-empty UI used to be common. The skeleton-then-hydrate pattern, SSH cancellation propagation, and ScarfMon-driven diagnosis are the shape of how that work gets done now.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Remote-context performance — chats and Activity in seconds, not 30s timeouts
|
||||||
|
|
||||||
|
Resuming a chat on a slow remote (a 420ms-RTT droplet, an underprovisioned VPS, a tunnel through 4G) used to fetch the full message column set in one shot, which routinely tripped the 30s SSH timeout on chats with multi-page tool result blobs. The 160-message session was broken; the 30-message session was broken too. Activity didn't load at all.
|
||||||
|
|
||||||
|
v2.7 introduces a **skeleton-then-hydrate pattern** that bounds the wire payload by what the user actually needs to see RIGHT NOW, then fills in the heavy stuff in the background:
|
||||||
|
|
||||||
|
- **Chat skeleton.** [`fetchSkeletonMessages`](https://github.com/awizemann/scarf/blob/main/scarf/Packages/ScarfCore/Sources/ScarfCore/Services/HermesDataService.swift) selects user + assistant rows only (skips `role='tool'`) with `tool_calls` / `reasoning` / `reasoning_content` hard-NULLed at the SQL level. Wire payload bounded by conversational text alone — typically a few KB. The chat appears in seconds. Background `startToolHydration` pages through `hydrateAssistantToolCalls` in 5-id batches to splice tool calls in. Tool-result CONTENT is **opt-in** via Settings → Display → "Load tool results in past chats" (default off); the inspector pane lazy-fetches per-result content via `fetchToolResult(callId:)` when you open a card.
|
||||||
|
- **Activity skeleton.** [`fetchRecentToolCallSkeleton`](https://github.com/awizemann/scarf/blob/main/scarf/Packages/ScarfCore/Sources/ScarfCore/Services/HermesDataService.swift) returns metadata-only rows (id + session_id + role + timestamp; everything else NULLed). Activity opens in <1s on remote with placeholder rows; real per-call entries swap in as paged hydration completes. New "Loading tool details…" pill in the page header surfaces hydration progress.
|
||||||
|
- **Single-id whale recovery.** When a 5-id batch trips the 30s timeout (one row carries an oversized `tool_calls` blob — a long Edit's args, a big diff), an L1 single-id retry isolates the offending row so the rest of the batch still hydrates. Whale row stays bare; assistant message stays readable.
|
||||||
|
- **Lazy tool result loading in the inspector.** Default-off avoids the bulk fetch. When you focus a tool call card, ChatInspectorPane fires `loadToolResultIfMissing(callId:)` which splices a single result into the message stream without re-fetching anything else.
|
||||||
|
|
||||||
|
Effect: a 160-message thinking-model session that used to time out at exactly 30s now opens in under 2 seconds with placeholder cards filling in over the next few. Activity loads in 500-800ms.
|
||||||
|
|
||||||
|
#### SSH cancellation that actually cancels
|
||||||
|
|
||||||
|
`Task.detached { … }` doesn't inherit cancellation from the awaiting parent, and `Task<…> { … }` (unstructured) also drops the signal. Without explicit bridging, cancelling a chat-load Task only unwinds Swift state — the underlying ssh subprocess kept running for the full 30s, pinning a remote sqlite query and a ControlMaster session slot. This produced the "third chat hangs" / "dashboard spins after rapid switching" symptom.
|
||||||
|
|
||||||
|
v2.7 wires `withTaskCancellationHandler` through [`SSHScriptRunner.run`](https://github.com/awizemann/scarf/blob/main/scarf/Packages/ScarfCore/Sources/ScarfCore/Transport/SSHScriptRunner.swift) and [`RemoteSQLiteBackend.query`](https://github.com/awizemann/scarf/blob/main/scarf/Packages/ScarfCore/Sources/ScarfCore/Services/Backends/RemoteSQLiteBackend.swift) so parent cancellation reaches the `Process` and calls `proc.terminate()` within 100ms. New `ssh.cancelled` ScarfMon event surfaces this.
|
||||||
|
|
||||||
|
#### In-flight coalescing for `loadRecentSessions`
|
||||||
|
|
||||||
|
File-watcher deltas during an active stream used to stack 2-3 parallel sessions-list reload tasks (the 500ms `scheduleSessionsRefresh` debounce only suppresses a pending tick, not one already executing). Subsequent callers now await the in-flight load instead of spawning a parallel SSH subprocess. New `mac.loadRecentSessions.coalesced` event tracks dedup hits.
|
||||||
|
|
||||||
|
#### Loading-state UX hardening
|
||||||
|
|
||||||
|
The Mac chat sidebar greys out and disables row taps the moment a session-switch is initiated (synchronously, before `client.start()` returns), with a floating ProgressView showing the current phase: **"Spawning hermes acp…"** → **"Authenticating…"** → **"Loading session…"** → **"Loading history…"** → **"Ready"**. Pre-fix the sidebar looked engageable while the 5-7 second SSH+ACP boot was still in flight, and the user could queue up a second session-switch behind the first. New `isStartingSession` flag flips on user click for instant feedback.
|
||||||
|
|
||||||
|
#### Partial-result + mismatch + pinned-model banners
|
||||||
|
|
||||||
|
- **Partial-result banner.** When the skeleton fetch trips an SSH transport failure (rather than a clean empty result), the chat surfaces "Couldn't load full chat history — the connection to *server* timed out" through the existing `acpError` triplet, plus forces `hasMoreHistory = true` so the "Load earlier" affordance shows up. Replaces the pre-fix silent empty transcript.
|
||||||
|
- **Model/provider mismatch banner.** [`ModelPreflight.detectMismatch`](https://github.com/awizemann/scarf/blob/main/scarf/Packages/ScarfCore/Sources/ScarfCore/Services/ModelPreflight.swift) recognizes when `model.default` carries a `<provider>/...` prefix that disagrees with `model.provider` (e.g. `anthropic/claude-sonnet-4.6` + `provider: nous` after switching OAuth via Credential Pools). Banner offers one-click fix in either direction.
|
||||||
|
- **Pinned-model failure hint.** ACP error classifier now recognizes `model_not_found` / `404 messages` / `model is not available` and surfaces "This session was created with a model the provider no longer offers — start a new chat to use your current model" so the pinned-model failure mode has a clear recovery path.
|
||||||
|
- **OAuth-completion provider swap.** After a successful OAuth in Credential Pools, if the just-authed provider differs from `model.provider`, surface "Switch active provider to *name*?" with [Switch] / [Keep current] instead of auto-dismissing.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### New Project from Scratch wizard + Keychain-backed cron secrets
|
||||||
|
|
||||||
|
A **third project entry point** alongside Browse Catalog and Add Existing Project: a wizard that scaffolds a Scarf-standard project skeleton (`<project>/.scarf/dashboard.json` + AGENTS.md marker block), registers it, and hands off to a chat session that auto-activates the bundled `scarf-template-author` skill. The skill drives the rest conversationally — widgets, optional config schema, optional cron — and writes the final files itself. Wizard stays minimal because the agent does configuration better than a multi-step form. The skill ships bundled inside `Scarf.app/Contents/Resources/BuiltinSkills.bundle/` and copies into `~/.hermes/skills/` on launch (idempotent + version-gated).
|
||||||
|
|
||||||
|
**Cron + Keychain — `$SCARF_<SLUG>_<FIELD>` env vars.** Cron prompts that referenced `secret`-typed config fields used to get the literal `keychain://...` URI back when reading `config.json`, producing 401s. v2.7 mirrors resolved Keychain values into `~/.hermes/.env` under a marker-bounded block keyed by template slug:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
# scarf-secrets:begin local-news-aggregator
|
||||||
|
SCARF_LOCAL_NEWS_AGGREGATOR_API_TOKEN=actual-value
|
||||||
|
SCARF_LOCAL_NEWS_AGGREGATOR_RSS_URL=https://example.com/feed
|
||||||
|
# scarf-secrets:end local-news-aggregator
|
||||||
|
```
|
||||||
|
|
||||||
|
Hermes already reloads `~/.hermes/.env` per cron tick, so credential rotation is automatic — just edit the value in Configuration → next tick sees it. The mirror runs at every state-change point: install, post-install Configuration save, uninstall, "Remove from List", and on app launch (reconciliation pass over registered projects). Source of truth stays in the Keychain — `config.json` keeps `keychain://` URIs unchanged. Mode 0600 enforced on `~/.hermes/.env`.
|
||||||
|
|
||||||
|
Cron prompts now reference these env vars directly:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"prompt": "Use the terminal: curl -sS -H \"Authorization: Bearer $SCARF_LOCAL_NEWS_AGGREGATOR_API_TOKEN\" \"$SCARF_LOCAL_NEWS_AGGREGATOR_RSS_URL\" -o {{PROJECT_DIR}}/.scarf/feed.xml"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Migration.** First launch of v2.7 walks the project registry and writes the managed block per schemaful project — automatic. Existing cron prompts you wrote against the old (broken) `config.json` pattern still need updating: open the cron job in Scarf's Cron sidebar and edit the prompt, or ask the agent in chat ("Update my Local News cron job's prompt to use the new env var convention") — the bundled `scarf-template-author` skill (now v1.1.0) documents the convention with worked examples.
|
||||||
|
|
||||||
|
Also fixes [#75](https://github.com/awizemann/scarf/issues/75) — `_NSDetectedLayoutRecursion` on the Configuration form for projects whose form transitioned between stages with different intrinsic heights.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Project dashboards — file-reading widgets, sparklines, typed status
|
||||||
|
|
||||||
|
Five new widget types, project-wide auto-refresh, and a structured error card for unknown widgets. Backwards-compatible — every existing `dashboard.json` renders byte-identically.
|
||||||
|
|
||||||
|
- **Project-wide auto-refresh.** [`HermesFileWatcher`](https://github.com/awizemann/scarf/blob/main/scarf/scarf/Core/Services/HermesFileWatcher.swift) used to watch each project's `dashboard.json` specifically. v2.7 promotes that to a watch on the entire `<project>/.scarf/` directory. A `markdown_file` or `log_tail` widget pointing at `<project>/.scarf/reports/foo.md` refreshes the moment a cron job rewrites the file. **By convention, place files the dashboard reads inside `.scarf/`** so the watch picks them up.
|
||||||
|
- **`markdown_file`** — renders a markdown file from disk through the same `MarkdownContentView` pipeline used by inline `text` widgets.
|
||||||
|
- **`log_tail`** — last `lines` of a file (default 20, max 200), monospaced, ANSI codes stripped.
|
||||||
|
- **`cron_status`** — last run / next run / state for one Hermes cron job by `jobId`, plus a small inline log tail. Read-only — Run/Pause/Resume controls stay on the Cron tab.
|
||||||
|
- **`image`** — local file (`path` relative to project root) or remote `url`. Optional `height` cap. Useful for matplotlib/Plotly PNGs the cron job generates.
|
||||||
|
- **`status_grid`** — compact NxM grid of colored cells, one per service / item, with hover labels.
|
||||||
|
- **`stat` widget gains inline sparklines.** Optional `sparkline: [Number]` field. SVG-only render, dozens per dashboard cost nothing.
|
||||||
|
- **Typed status badges.** `list` items and `status_grid` cells share a typed enum (`success`, `warning`, `danger`, `info`, `pending`, `done`, `neutral`) with lenient decode for synonyms (`ok`/`up` → success, `down`/`error` → danger). Unknown strings render as plain text.
|
||||||
|
- **Structured widget error card.** Replaces the legacy "Unknown: \<type\>" placeholder with a card surfacing the title, specific reason, and a hint.
|
||||||
|
- **Schema mirror.** The widget vocabulary lives once at [`tools/widget-schema.json`](https://github.com/awizemann/scarf/blob/main/tools/widget-schema.json); the catalog validator reads from it and enforces per-type required fields.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### OAuth resilience + Credential Pools
|
||||||
|
|
||||||
|
- **Daily OAuth keepalive cron.** Prevents Anthropic OAuth refresh tokens from expiring after weeks of inactivity. New cron job `[scarf:oauth-keepalive]` (managed by Scarf) pings Hermes on a daily cadence; the in-app Refresh All Sessions action mirrors the same path on demand.
|
||||||
|
- **Remote re-auth.** Re-authenticating against a remote droplet's OAuth provider used to be blocked by the lack of a stdin path through SSHTransport. The OAuth flow now drives a remote `hermes auth add` correctly with stdin forwarded.
|
||||||
|
- **OAuth remove button.** Per-provider remove action in Credential Pools (auth.json edit), with confirmation dialog. Companion auto-refresh of the view when `auth.json` changes externally (file-watcher).
|
||||||
|
- **`resolve_provider_client` error classification.** When an auxiliary task references a provider whose credentials aren't loaded, Hermes prints `resolve_provider_client: <name> requested but <Display Name> not configured` to stderr — pre-fix this surfaced in chat as the opaque `-32603 Internal error` with no actionable detail. Now classified into a clear hint pointing at Settings → Aux Models.
|
||||||
|
- **Aux Tab unknown-task surface.** When `config.yaml` has an `auxiliary.<task>` block for a task Scarf doesn't know about (newer Hermes added it; Scarf hasn't caught up), render it as a plain row with the raw provider/model values instead of dropping it silently.
|
||||||
|
- **Credential Pools refresh after OAuth sheet dismiss.** Closing the OAuth sheet after a successful add now refreshes the list immediately instead of leaving the just-added pool hidden until the next file-watcher tick.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### ScarfMon — performance instrumentation harness
|
||||||
|
|
||||||
|
The diagnostic surface that drove the bulk of the v2.7 perf work. Off by default; signpost-only mode (Instruments-friendly) is free; Full mode (4096-entry in-memory ring buffer + os.Logger) is a click away in Settings → Diagnostics → Performance. Wiki: https://github.com/awizemann/scarf/wiki/Performance-Monitoring
|
||||||
|
|
||||||
|
- **Phases 1-3** built the core: dispatcher + ring buffer + 3 backends, chat / transport / sqlite measure points, diagnostic counters for chat-render bursts, finalize-burst dampening.
|
||||||
|
- **Tier A + B** added per-feature instrumentation: iOS file watcher, sessions list, model catalog, dashboard widgets, image encoder, message hydration.
|
||||||
|
- **Nous picker investigation** localized a 60s + 120s beach-ball to a specific path (Nous catalog `readCache`), then killed the 120s one with dedupe + 5s timeout.
|
||||||
|
- **Tier C catch-up** (this release): instrumented Memory / Skills / Cron / Curator load paths so future captures show how often these tabs cost multiple sequential SFTP RTTs on remote.
|
||||||
|
- **Per-call bytes recorded** on transport + sqlite events so captures show payload sizes alongside latencies.
|
||||||
|
- **`mac.emptyAssistantTurn` event** documents the Nous quirk where the model returns a thought stream with no body (the bubble looks like Hermes is "still thinking" but the turn already finished).
|
||||||
|
|
||||||
|
Adding a new measure point is two lines. The harness covers Mac and iOS uniformly. The "Copy as JSON" button exports the ring buffer for paste-into-issue diagnosis.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Other fixes + polish
|
||||||
|
|
||||||
|
- **Sessions sidebar reload debounce** — file-watcher deltas during streaming used to flicker the sessions list. Coalesced into one trailing fetch ~500ms after the last tick.
|
||||||
|
- **Session-load pagination + race guard** — switching to a small chat while a larger one is mid-fetch could last-write-wins the small chat away. Three race-checks against `self.sessionId` prevent the stale fetch from overwriting.
|
||||||
|
- **Sessions + previews batched** — two separate SSH calls folded into one `queryBatch` round trip, halving the round-trips for every sidebar refresh.
|
||||||
|
- **Remote SQLite query timeout** bumped 15→30s to better tolerate slow links; in-flight query coalescing dedupes concurrent identical queries.
|
||||||
|
- **`Thread.sleep` spin replaced** with a kernel-wait via `DispatchGroup` for `runLocal` timeout; under concurrent SSH load the old loop accumulated spin-blocked threads and produced 7-second outliers in `loadRecentSessions`.
|
||||||
|
- **Window position + size** persists across launches.
|
||||||
|
- **Sidebar reorder** — Projects promoted to first section; profile chip moved under server name.
|
||||||
|
- **`stop` badge suppressed** on metadata footer for normal turn ends (it was firing for every clean completion, looking like an error).
|
||||||
|
- **Nous picker search field** + `model-picker` filter for the long Nous overlay model list.
|
||||||
|
- **`oauth-keepalive` cron create** — drop the `--silent` flag Hermes doesn't accept.
|
||||||
|
- **Snapshot pipeline rewritten** — replaced the `sqlite3 .backup`-then-download pipeline with direct SSH-streamed query execution (issue [#74](https://github.com/awizemann/scarf/issues/74)). Eliminates the multi-minute snapshot wait on multi-GB state.db files. Companion fix: pre-expand `~/` in Swift via `resolvedUserHome` so sqlite3 finds the DB without depending on the remote shell's tilde expansion.
|
||||||
|
- **Aux nested-YAML parser** — corrected the parser so the unknown-task surface works on remote (was previously dropping aux blocks whose `provider:` value lived on a separate line).
|
||||||
|
- **`ModelPreflight` newline trim bug** — `.whitespaces` doesn't strip newlines; switched both trims to `.whitespacesAndNewlines` so a stray `\n` in a hand-edited config.yaml doesn't false-positive the mismatch banner.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### What's measured today
|
||||||
|
|
||||||
|
321 ScarfCore tests pass (302 prior + 19 new ModelPreflight). New ScarfMon events documented in the [Performance-Monitoring wiki](https://github.com/awizemann/scarf/wiki/Performance-Monitoring).
|
||||||
|
|
||||||
|
### Compatibility
|
||||||
|
|
||||||
|
- macOS 14+ (unchanged).
|
||||||
|
- Hermes target: still **v2026.4.30 (v0.12.0)**. No new Hermes capability gates added.
|
||||||
|
- Existing `dashboard.json` files render unchanged.
|
||||||
|
- Existing `.scarftemplate` bundles install unchanged. Catalog manifest schemaVersion stays at 1/2/3 — no bump.
|
||||||
|
- Existing `~/.hermes/.env` content is preserved byte-identically — Scarf only writes inside its `# scarf-secrets:begin <slug>` / `# scarf-secrets:end <slug>` regions.
|
||||||
|
- The skeleton-then-hydrate chat loader and SSH cancellation propagation are **Mac-only** in this release; ScarfGo (iOS) keeps its existing chat path.
|
||||||
|
|
||||||
|
### What's deferred
|
||||||
|
|
||||||
|
- **Per-widget data sources + per-widget refresh granularity.** The general "widget points at a typed data source" abstraction is the next-largest win in dashboards but materially expands the model + JS mirror + validator surface. The project-wide watch covers the common cron-driven workflow without it.
|
||||||
|
- **Cross-project health digest sidebar rollup.** Counting attention-needed projects across the registry — scoped but didn't pull its weight. The typed status enum makes it cheap to add later.
|
||||||
|
- **Automatic cron-prompt rewriter on upgrade.** Heuristic rewrites of free-form prompts are risky; the docs + agent-assisted path ships in v2.7. Revisit a "scan + fix" UI in v2.8 if real users miss the migration.
|
||||||
|
- **iOS New Project wizard + iOS Keychain-env mirror.** ScarfGo's project surface is read-only; the wizard's chat-handoff pattern depends on Mac-only ACP plumbing.
|
||||||
|
- **iOS skeleton-then-hydrate loaders.** Same data-service surfaces are public, but the iOS chat lifecycle is structured differently. Defer until iOS dogfooding shows the same payload-size pain.
|
||||||
|
- **Tier C redesigns (Memory/Skills/Cron/Curator).** Instrumented in v2.7; redesign waits for capture data showing which path actually needs the skeleton-then-hydrate treatment.
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
// swift-tools-version: 6.0
|
||||||
|
// Platform-neutral core for the Scarf app family (macOS and iOS).
|
||||||
|
//
|
||||||
|
// `ScarfCore` holds types that do not depend on AppKit, UIKit, or any
|
||||||
|
// platform-specific system service. The macOS and iOS app targets each link
|
||||||
|
// this package and provide their own platform shells (Sparkle + SwiftTerm on
|
||||||
|
// macOS; Citadel-based SSH transport on iOS).
|
||||||
|
//
|
||||||
|
// Minimums are chosen to match the Mac app (macOS 14.6) and the locked
|
||||||
|
// v1 iOS decision (iOS 18). Raising iOS later is free; lowering is not —
|
||||||
|
// the ViewModels on `@Observable` / `NavigationStack` are iOS 17+ features
|
||||||
|
// and we standardize on iOS 18 for feature parity with the Mac codebase.
|
||||||
|
|
||||||
|
import PackageDescription
|
||||||
|
|
||||||
|
let package = Package(
|
||||||
|
name: "ScarfCore",
|
||||||
|
defaultLocalization: "en",
|
||||||
|
platforms: [
|
||||||
|
.macOS(.v14),
|
||||||
|
.iOS(.v18),
|
||||||
|
],
|
||||||
|
products: [
|
||||||
|
.library(
|
||||||
|
name: "ScarfCore",
|
||||||
|
targets: ["ScarfCore"]
|
||||||
|
),
|
||||||
|
],
|
||||||
|
targets: [
|
||||||
|
.target(
|
||||||
|
name: "ScarfCore",
|
||||||
|
path: "Sources/ScarfCore",
|
||||||
|
swiftSettings: [
|
||||||
|
// Swift 5 language mode mirrors the Mac app target's
|
||||||
|
// `SWIFT_VERSION = 5.0` build setting. Moving to strict
|
||||||
|
// Swift 6 concurrency is a real refactor — several types
|
||||||
|
// (`ACPEvent.availableCommands` carrying `[[String: Any]]`,
|
||||||
|
// `ACPToolCallEvent.rawInput: [String: Any]?`) claim
|
||||||
|
// `Sendable` without being strictly-Sendable. A follow-up
|
||||||
|
// phase will replace those with typed payloads, then this
|
||||||
|
// setting can bump to `.v6`.
|
||||||
|
.swiftLanguageMode(.v5),
|
||||||
|
]
|
||||||
|
),
|
||||||
|
.testTarget(
|
||||||
|
name: "ScarfCoreTests",
|
||||||
|
dependencies: ["ScarfCore"],
|
||||||
|
path: "Tests/ScarfCoreTests"
|
||||||
|
),
|
||||||
|
]
|
||||||
|
)
|
||||||
@@ -0,0 +1,99 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// The bidirectional line-oriented transport that `ACPClient` speaks
|
||||||
|
/// JSON-RPC over. Abstracts away whether the other end is a local
|
||||||
|
/// `hermes acp` subprocess (macOS) or a remote SSH exec channel (iOS via
|
||||||
|
/// Citadel in M4+). ACPClient never touches `Process`, `Pipe`, file
|
||||||
|
/// descriptors, or SSH sessions directly — it just sends and receives
|
||||||
|
/// newline-delimited JSON lines over one of these.
|
||||||
|
///
|
||||||
|
/// **Line framing.** Senders pass a JSON object serialized to a single
|
||||||
|
/// line (no embedded `\n`). The channel appends the terminator itself.
|
||||||
|
/// The receiver yields one complete JSON line per `incoming` element;
|
||||||
|
/// partial lines are buffered internally until a newline arrives.
|
||||||
|
///
|
||||||
|
/// **Lifecycle.** A channel is "already live" when you hold a reference —
|
||||||
|
/// the constructor (or channel-factory call) spawns the subprocess / opens
|
||||||
|
/// the SSH exec channel. `close()` tears down and causes `incoming` /
|
||||||
|
/// `stderr` to finish. After `close()`, `send(_:)` throws.
|
||||||
|
///
|
||||||
|
/// **Errors.** Transport errors (broken pipe, SSH disconnect, process
|
||||||
|
/// died) surface as an error-terminated `incoming` stream — consumers
|
||||||
|
/// should be prepared for that, not just for clean `.finished` stream
|
||||||
|
/// termination. `send(_:)` also throws on these.
|
||||||
|
public protocol ACPChannel: Sendable {
|
||||||
|
/// Append `\n` and write atomically. Thread-safe (the actor boundary
|
||||||
|
/// is on the implementation side, not the protocol).
|
||||||
|
func send(_ line: String) async throws
|
||||||
|
|
||||||
|
/// One complete JSON-RPC line per element, without the trailing
|
||||||
|
/// newline. Yields in arrival order. Finishes (clean or error) when
|
||||||
|
/// the underlying transport closes.
|
||||||
|
var incoming: AsyncThrowingStream<String, Error> { get }
|
||||||
|
|
||||||
|
/// Diagnostic stderr. For `ProcessACPChannel` this is the spawned
|
||||||
|
/// process's stderr, line-buffered. For future SSH-exec channels
|
||||||
|
/// where stderr folds into events, this is an empty stream.
|
||||||
|
/// Lines are yielded without the trailing newline.
|
||||||
|
var stderr: AsyncThrowingStream<String, Error> { get }
|
||||||
|
|
||||||
|
/// Request graceful shutdown. Closes stdin first (so the remote side
|
||||||
|
/// sees EOF and can flush), then waits briefly for the subprocess /
|
||||||
|
/// exec channel to exit, then force-terminates. Idempotent — calling
|
||||||
|
/// `close()` on an already-closed channel is a no-op.
|
||||||
|
func close() async
|
||||||
|
|
||||||
|
/// Short identifier for logs. Process channels return the child PID;
|
||||||
|
/// SSH exec channels return the SSH channel id or `nil` when not
|
||||||
|
/// applicable.
|
||||||
|
var diagnosticID: String? { get async }
|
||||||
|
|
||||||
|
/// Exit status of the underlying transport once it has terminated.
|
||||||
|
/// `nil` while the channel is still alive, or for transports that
|
||||||
|
/// don't have a meaningful integer exit code (Citadel SSH-exec).
|
||||||
|
/// Read by `ACPClient` when populating `processTerminated` so the
|
||||||
|
/// user-facing error can name the actual exit code (e.g. `exit
|
||||||
|
/// 255` for SSH connect failures, `exit 127` for missing remote
|
||||||
|
/// binary).
|
||||||
|
var lastExitCode: Int32? { get async }
|
||||||
|
}
|
||||||
|
|
||||||
|
public extension ACPChannel {
|
||||||
|
/// Default: channels that don't track an exit code report `nil`.
|
||||||
|
/// Concrete `ProcessACPChannel` overrides this.
|
||||||
|
var lastExitCode: Int32? {
|
||||||
|
get async { nil }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Errors raised by `ACPChannel` implementations when the underlying
|
||||||
|
/// transport breaks. JSON-RPC errors (the remote returning an `error`
|
||||||
|
/// field) are not in this enum — they ride as valid `incoming` lines and
|
||||||
|
/// are ACPClient's problem to decode.
|
||||||
|
public enum ACPChannelError: Error, LocalizedError {
|
||||||
|
/// The underlying subprocess or SSH exec channel exited. `exitCode`
|
||||||
|
/// is the subprocess exit status (or a synthetic value for SSH).
|
||||||
|
case closed(exitCode: Int32)
|
||||||
|
/// `send(_:)` was called on a channel whose write end is already
|
||||||
|
/// closed. Typically means a previous `close()` call or a pipe
|
||||||
|
/// broken by a remote termination.
|
||||||
|
case writeEndClosed
|
||||||
|
/// Bytes sent or received couldn't be encoded/decoded as UTF-8.
|
||||||
|
/// Hermes emits only UTF-8; hitting this usually means a framing
|
||||||
|
/// bug or random binary junk on the channel.
|
||||||
|
case invalidEncoding
|
||||||
|
/// Failed to launch the subprocess or open the SSH exec channel.
|
||||||
|
case launchFailed(String)
|
||||||
|
/// Catch-all for everything else with a context string.
|
||||||
|
case other(String)
|
||||||
|
|
||||||
|
public var errorDescription: String? {
|
||||||
|
switch self {
|
||||||
|
case .closed(let code): return "ACP channel closed (exit \(code))"
|
||||||
|
case .writeEndClosed: return "ACP channel write end is closed"
|
||||||
|
case .invalidEncoding: return "ACP channel carried non-UTF-8 bytes"
|
||||||
|
case .launchFailed(let msg): return "Failed to launch ACP channel: \(msg)"
|
||||||
|
case .other(let msg): return msg
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,769 @@
|
|||||||
|
import Foundation
|
||||||
|
#if canImport(os)
|
||||||
|
import os
|
||||||
|
#endif
|
||||||
|
|
||||||
|
/// Manages an ACP (Agent Client Protocol) session with a backing Hermes
|
||||||
|
/// agent. Talks JSON-RPC over an `ACPChannel` — the channel itself owns
|
||||||
|
/// the transport (subprocess for macOS, SSH exec session for iOS via
|
||||||
|
/// Citadel in M4+). This actor is transport-agnostic.
|
||||||
|
///
|
||||||
|
/// **Channel factory injection.** Construction takes a closure that
|
||||||
|
/// builds a channel on demand. The Mac target wires this at app launch
|
||||||
|
/// to produce a `ProcessACPChannel` configured with the enriched
|
||||||
|
/// shell env (PATH, credentials). iOS will wire a `SSHExecACPChannel`
|
||||||
|
/// factory at app launch.
|
||||||
|
///
|
||||||
|
/// Under iOS the `ProcessACPChannel` implementation is skipped at
|
||||||
|
/// compile time (`#if !os(iOS)`) — an iOS `ACPClient` that tried to
|
||||||
|
/// spawn a subprocess would be a build error, not a runtime bug.
|
||||||
|
public actor ACPClient {
|
||||||
|
#if canImport(os)
|
||||||
|
private let logger = Logger(subsystem: "com.scarf", category: "ACPClient")
|
||||||
|
#endif
|
||||||
|
|
||||||
|
/// Returns a fresh ACPChannel connected to `hermes acp` for this
|
||||||
|
/// context. Mac wires this to spawn a `ProcessACPChannel` with the
|
||||||
|
/// enriched env (so `hermes` can find Homebrew/nvm/asdf binaries
|
||||||
|
/// on PATH). iOS wires a Citadel-backed channel in M4+.
|
||||||
|
public typealias ChannelFactory = @Sendable (ServerContext) async throws -> any ACPChannel
|
||||||
|
|
||||||
|
private var channel: (any ACPChannel)?
|
||||||
|
private let channelFactory: ChannelFactory
|
||||||
|
|
||||||
|
private var nextRequestId = 1
|
||||||
|
private var pendingRequests: [Int: CheckedContinuation<AnyCodable?, Error>] = [:]
|
||||||
|
private var readTask: Task<Void, Never>?
|
||||||
|
private var stderrTask: Task<Void, Never>?
|
||||||
|
private var keepaliveTask: Task<Void, Never>?
|
||||||
|
private var eventContinuation: AsyncStream<ACPEvent>.Continuation?
|
||||||
|
private var _eventStream: AsyncStream<ACPEvent>?
|
||||||
|
|
||||||
|
public private(set) var isConnected = false
|
||||||
|
public private(set) var currentSessionId: String?
|
||||||
|
public private(set) var statusMessage = ""
|
||||||
|
|
||||||
|
public let context: ServerContext
|
||||||
|
|
||||||
|
public init(
|
||||||
|
context: ServerContext = .local,
|
||||||
|
channelFactory: @escaping ChannelFactory
|
||||||
|
) {
|
||||||
|
self.context = context
|
||||||
|
self.channelFactory = channelFactory
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Ring buffer of recent stderr lines from the ACP channel — used to
|
||||||
|
/// attach a diagnostic tail to user-visible errors. Capped to avoid
|
||||||
|
/// unbounded growth when the subprocess logs heavily.
|
||||||
|
private var stderrBuffer: [String] = []
|
||||||
|
private static let stderrBufferMaxLines = 50
|
||||||
|
|
||||||
|
/// Returns the last ~`stderrBufferMaxLines` stderr lines captured
|
||||||
|
/// from the ACP channel, joined by newlines.
|
||||||
|
public var recentStderr: String {
|
||||||
|
stderrBuffer.joined(separator: "\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
fileprivate func appendStderr(_ text: String) {
|
||||||
|
for line in text.split(separator: "\n", omittingEmptySubsequences: true) {
|
||||||
|
stderrBuffer.append(String(line))
|
||||||
|
}
|
||||||
|
if stderrBuffer.count > Self.stderrBufferMaxLines {
|
||||||
|
stderrBuffer.removeFirst(stderrBuffer.count - Self.stderrBufferMaxLines)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// True while the underlying channel is alive. Equivalent to the
|
||||||
|
/// old `process.isRunning` check.
|
||||||
|
public var isHealthy: Bool {
|
||||||
|
isConnected && channel != nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Event Stream
|
||||||
|
|
||||||
|
/// Access the event stream. Must call `start()` first. Before start,
|
||||||
|
/// returns an immediately-finished stream so callers can iterate
|
||||||
|
/// without a nil check.
|
||||||
|
public var events: AsyncStream<ACPEvent> {
|
||||||
|
_eventStream ?? AsyncStream { $0.finish() }
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Lifecycle
|
||||||
|
|
||||||
|
public func start() async throws {
|
||||||
|
guard channel == nil else { return }
|
||||||
|
|
||||||
|
// Create the event stream BEFORE anything else so no events are
|
||||||
|
// lost while the channel is handshaking.
|
||||||
|
let (stream, continuation) = AsyncStream.makeStream(of: ACPEvent.self)
|
||||||
|
self._eventStream = stream
|
||||||
|
self.eventContinuation = continuation
|
||||||
|
|
||||||
|
statusMessage = "Starting hermes acp..."
|
||||||
|
|
||||||
|
let ch: any ACPChannel
|
||||||
|
do {
|
||||||
|
ch = try await channelFactory(context)
|
||||||
|
} catch {
|
||||||
|
statusMessage = "Failed to start: \(error.localizedDescription)"
|
||||||
|
#if canImport(os)
|
||||||
|
logger.error("Failed to open ACP channel: \(error.localizedDescription)")
|
||||||
|
#endif
|
||||||
|
continuation.finish()
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
|
||||||
|
self.channel = ch
|
||||||
|
self.isConnected = true
|
||||||
|
|
||||||
|
// Start reading incoming JSON-RPC BEFORE sending initialize so
|
||||||
|
// we catch the response.
|
||||||
|
startReadLoops(channel: ch)
|
||||||
|
#if canImport(os)
|
||||||
|
if let id = await ch.diagnosticID {
|
||||||
|
logger.info("ACP channel opened (\(id, privacy: .public))")
|
||||||
|
} else {
|
||||||
|
logger.info("ACP channel opened")
|
||||||
|
}
|
||||||
|
#endif
|
||||||
|
statusMessage = "Initializing..."
|
||||||
|
|
||||||
|
// Initialize the ACP connection.
|
||||||
|
let initParams: [String: AnyCodable] = [
|
||||||
|
"protocolVersion": AnyCodable(1),
|
||||||
|
"clientCapabilities": AnyCodable([String: Any]()),
|
||||||
|
"clientInfo": AnyCodable([
|
||||||
|
"name": "Scarf",
|
||||||
|
"version": "1.0",
|
||||||
|
] as [String: Any]),
|
||||||
|
]
|
||||||
|
_ = try await sendRequest(method: "initialize", params: initParams)
|
||||||
|
statusMessage = "Connected"
|
||||||
|
#if canImport(os)
|
||||||
|
logger.info("ACP connection initialized")
|
||||||
|
#endif
|
||||||
|
startKeepalive()
|
||||||
|
}
|
||||||
|
|
||||||
|
public func stop() async {
|
||||||
|
readTask?.cancel()
|
||||||
|
readTask = nil
|
||||||
|
stderrTask?.cancel()
|
||||||
|
stderrTask = nil
|
||||||
|
keepaliveTask?.cancel()
|
||||||
|
keepaliveTask = nil
|
||||||
|
eventContinuation?.finish()
|
||||||
|
eventContinuation = nil
|
||||||
|
_eventStream = nil
|
||||||
|
|
||||||
|
for (_, continuation) in pendingRequests {
|
||||||
|
continuation.resume(throwing: CancellationError())
|
||||||
|
}
|
||||||
|
pendingRequests.removeAll()
|
||||||
|
|
||||||
|
if let ch = channel {
|
||||||
|
await ch.close()
|
||||||
|
}
|
||||||
|
channel = nil
|
||||||
|
isConnected = false
|
||||||
|
currentSessionId = nil
|
||||||
|
statusMessage = "Disconnected"
|
||||||
|
#if canImport(os)
|
||||||
|
logger.info("ACP client stopped")
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Keepalive
|
||||||
|
|
||||||
|
private func startKeepalive() {
|
||||||
|
keepaliveTask = Task { [weak self] in
|
||||||
|
while !Task.isCancelled {
|
||||||
|
try? await Task.sleep(nanoseconds: 30_000_000_000) // 30 seconds
|
||||||
|
guard !Task.isCancelled else { break }
|
||||||
|
await self?.sendKeepalive()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Valid JSON-RPC notification used as a keepalive probe. Plain
|
||||||
|
/// newlines upstream produce `json.loads("")` errors in the ACP
|
||||||
|
/// server so we send a real method.
|
||||||
|
private static let keepalivePayload: String = #"{"jsonrpc":"2.0","method":"$/ping"}"#
|
||||||
|
|
||||||
|
private func sendKeepalive() async {
|
||||||
|
guard let ch = channel else { return }
|
||||||
|
do {
|
||||||
|
try await ch.send(Self.keepalivePayload)
|
||||||
|
} catch {
|
||||||
|
await handleWriteFailed()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Session Management
|
||||||
|
|
||||||
|
public func newSession(cwd: String) async throws -> String {
|
||||||
|
statusMessage = "Creating session..."
|
||||||
|
let params: [String: AnyCodable] = [
|
||||||
|
"cwd": AnyCodable(cwd),
|
||||||
|
"mcpServers": AnyCodable([Any]()),
|
||||||
|
]
|
||||||
|
let result = try await sendRequest(method: "session/new", params: params)
|
||||||
|
guard let dict = result?.dictValue,
|
||||||
|
let sessionId = dict["sessionId"] as? String
|
||||||
|
else {
|
||||||
|
throw ACPClientError.invalidResponse("Missing sessionId in session/new response")
|
||||||
|
}
|
||||||
|
currentSessionId = sessionId
|
||||||
|
statusMessage = "Session ready"
|
||||||
|
#if canImport(os)
|
||||||
|
logger.info("Created new ACP session: \(sessionId)")
|
||||||
|
#endif
|
||||||
|
return sessionId
|
||||||
|
}
|
||||||
|
|
||||||
|
public func loadSession(cwd: String, sessionId: String) async throws -> String {
|
||||||
|
statusMessage = "Loading session \(sessionId.prefix(12))..."
|
||||||
|
let params: [String: AnyCodable] = [
|
||||||
|
"cwd": AnyCodable(cwd),
|
||||||
|
"sessionId": AnyCodable(sessionId),
|
||||||
|
"mcpServers": AnyCodable([Any]()),
|
||||||
|
]
|
||||||
|
let result = try await sendRequest(method: "session/load", params: params)
|
||||||
|
// ACP returns {} on success (no sessionId echoed), or an error if
|
||||||
|
// not found. If we got here without throwing, the session was
|
||||||
|
// loaded — use the ID we sent.
|
||||||
|
let loadedId = (result?.dictValue?["sessionId"] as? String) ?? sessionId
|
||||||
|
currentSessionId = loadedId
|
||||||
|
statusMessage = "Session loaded"
|
||||||
|
#if canImport(os)
|
||||||
|
logger.info("Loaded ACP session: \(loadedId)")
|
||||||
|
#endif
|
||||||
|
return loadedId
|
||||||
|
}
|
||||||
|
|
||||||
|
public func resumeSession(cwd: String, sessionId: String) async throws -> String {
|
||||||
|
statusMessage = "Resuming session..."
|
||||||
|
let params: [String: AnyCodable] = [
|
||||||
|
"cwd": AnyCodable(cwd),
|
||||||
|
"sessionId": AnyCodable(sessionId),
|
||||||
|
"mcpServers": AnyCodable([Any]()),
|
||||||
|
]
|
||||||
|
let result = try await sendRequest(method: "session/resume", params: params)
|
||||||
|
guard let dict = result?.dictValue,
|
||||||
|
let resumedId = dict["sessionId"] as? String
|
||||||
|
else {
|
||||||
|
throw ACPClientError.invalidResponse("Missing sessionId in session/resume response")
|
||||||
|
}
|
||||||
|
currentSessionId = resumedId
|
||||||
|
statusMessage = "Session resumed"
|
||||||
|
#if canImport(os)
|
||||||
|
logger.info("Resumed ACP session: \(resumedId)")
|
||||||
|
#endif
|
||||||
|
return resumedId
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Messaging
|
||||||
|
|
||||||
|
public func sendPrompt(sessionId: String, text: String) async throws -> ACPPromptResult {
|
||||||
|
try await sendPrompt(sessionId: sessionId, text: text, images: [])
|
||||||
|
}
|
||||||
|
|
||||||
|
/// v0.12+ overload: forward zero or more image attachments alongside
|
||||||
|
/// the user's text. Each attachment becomes a separate
|
||||||
|
/// `ImageContentBlock` in the ACP `prompt` content array — matches
|
||||||
|
/// the shape Hermes' `acp_adapter/server.py` expects (text first,
|
||||||
|
/// then image blocks). Hermes routes the resulting payload to a
|
||||||
|
/// vision-capable model automatically; the producer side only has
|
||||||
|
/// to deliver the bytes.
|
||||||
|
///
|
||||||
|
/// Pre-v0.12 Hermes installs accepted only a single `text` block.
|
||||||
|
/// Callers gate this overload on
|
||||||
|
/// `HermesCapabilitiesStore.capabilities.hasACPImagePrompts` so we
|
||||||
|
/// don't send blocks an older agent would silently drop.
|
||||||
|
public func sendPrompt(
|
||||||
|
sessionId: String,
|
||||||
|
text: String,
|
||||||
|
images: [ChatImageAttachment]
|
||||||
|
) async throws -> ACPPromptResult {
|
||||||
|
statusMessage = "Sending prompt..."
|
||||||
|
let messageId = UUID().uuidString
|
||||||
|
|
||||||
|
// Always include the text block, even when empty — keeps the
|
||||||
|
// server-side text-extraction path stable regardless of whether
|
||||||
|
// the user sent text alongside the image(s).
|
||||||
|
var promptBlocks: [[String: Any]] = [
|
||||||
|
["type": "text", "text": text] as [String: Any],
|
||||||
|
]
|
||||||
|
for image in images {
|
||||||
|
promptBlocks.append([
|
||||||
|
"type": "image",
|
||||||
|
"data": image.base64Data,
|
||||||
|
"mimeType": image.mimeType,
|
||||||
|
] as [String: Any])
|
||||||
|
}
|
||||||
|
|
||||||
|
let params: [String: AnyCodable] = [
|
||||||
|
"sessionId": AnyCodable(sessionId),
|
||||||
|
"messageId": AnyCodable(messageId),
|
||||||
|
"prompt": AnyCodable(promptBlocks as [Any]),
|
||||||
|
]
|
||||||
|
let result = try await sendRequest(method: "session/prompt", params: params)
|
||||||
|
let dict = result?.dictValue ?? [:]
|
||||||
|
let usage = dict["usage"] as? [String: Any] ?? [:]
|
||||||
|
|
||||||
|
statusMessage = "Ready"
|
||||||
|
return ACPPromptResult(
|
||||||
|
stopReason: dict["stopReason"] as? String ?? "end_turn",
|
||||||
|
inputTokens: usage["inputTokens"] as? Int ?? 0,
|
||||||
|
outputTokens: usage["outputTokens"] as? Int ?? 0,
|
||||||
|
thoughtTokens: usage["thoughtTokens"] as? Int ?? 0,
|
||||||
|
cachedReadTokens: usage["cachedReadTokens"] as? Int ?? 0
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
public func cancel(sessionId: String) async throws {
|
||||||
|
let params: [String: AnyCodable] = [
|
||||||
|
"sessionId": AnyCodable(sessionId),
|
||||||
|
]
|
||||||
|
_ = try await sendRequest(method: "session/cancel", params: params)
|
||||||
|
statusMessage = "Cancelled"
|
||||||
|
}
|
||||||
|
|
||||||
|
public func respondToPermission(requestId: Int, optionId: String) async {
|
||||||
|
let response: [String: Any] = [
|
||||||
|
"jsonrpc": "2.0",
|
||||||
|
"id": requestId,
|
||||||
|
"result": [
|
||||||
|
"outcome": [
|
||||||
|
"kind": optionId == "deny" ? "rejected" : "allowed",
|
||||||
|
"optionId": optionId,
|
||||||
|
] as [String: Any],
|
||||||
|
] as [String: Any],
|
||||||
|
]
|
||||||
|
await writeJSON(response)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - JSON-RPC Transport
|
||||||
|
|
||||||
|
private func sendRequest(method: String, params: [String: AnyCodable]) async throws -> AnyCodable? {
|
||||||
|
let requestId = nextRequestId
|
||||||
|
nextRequestId += 1
|
||||||
|
|
||||||
|
let request = ACPRequest(id: requestId, method: method, params: params)
|
||||||
|
guard let data = try? JSONEncoder().encode(request),
|
||||||
|
let line = String(data: data, encoding: .utf8)
|
||||||
|
else {
|
||||||
|
throw ACPClientError.encodingFailed
|
||||||
|
}
|
||||||
|
|
||||||
|
#if canImport(os)
|
||||||
|
logger.debug("Sending: \(method) (id: \(requestId))")
|
||||||
|
#endif
|
||||||
|
|
||||||
|
// session/prompt streams events and can run for minutes — no hard
|
||||||
|
// timeout. Control messages get a 60s watchdog. Older versions
|
||||||
|
// capped at 30s, which the field reported (#61) was tripping
|
||||||
|
// under realistic gateway+ACP concurrency: the gateway holds
|
||||||
|
// state.db locks for Discord sync / skill registration / cron
|
||||||
|
// scheduling, and ACP's `initialize` / `session/new` /
|
||||||
|
// `session/load` stall waiting for the lock. SQLite contention
|
||||||
|
// on a healthy host clears in seconds; 60s gives that headroom
|
||||||
|
// while still surfacing genuinely broken transports promptly.
|
||||||
|
let timeoutTask: Task<Void, Error>? = if method != "session/prompt" {
|
||||||
|
Task { [weak self] in
|
||||||
|
try await Task.sleep(nanoseconds: 60 * 1_000_000_000)
|
||||||
|
await self?.timeoutRequest(id: requestId, method: method)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
nil
|
||||||
|
}
|
||||||
|
defer { timeoutTask?.cancel() }
|
||||||
|
|
||||||
|
guard let ch = channel else {
|
||||||
|
throw ACPClientError.notConnected
|
||||||
|
}
|
||||||
|
|
||||||
|
return try await withCheckedThrowingContinuation { (continuation: CheckedContinuation<AnyCodable?, Error>) in
|
||||||
|
pendingRequests[requestId] = continuation
|
||||||
|
|
||||||
|
// Write in a detached task so the actor can process incoming
|
||||||
|
// response messages while we're awaiting the send. The
|
||||||
|
// continuation is already stored; the response arrives via
|
||||||
|
// the read loop.
|
||||||
|
Task.detached { [weak self] in
|
||||||
|
do {
|
||||||
|
try await ch.send(line)
|
||||||
|
} catch {
|
||||||
|
await self?.handleWriteFailedForRequest(id: requestId)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func timeoutRequest(id: Int, method: String) {
|
||||||
|
guard let continuation = pendingRequests.removeValue(forKey: id) else { return }
|
||||||
|
#if canImport(os)
|
||||||
|
logger.error("Request timed out: \(method) (id: \(id))")
|
||||||
|
#endif
|
||||||
|
statusMessage = "Request timed out"
|
||||||
|
continuation.resume(throwing: ACPClientError.requestTimeout(method: method))
|
||||||
|
}
|
||||||
|
|
||||||
|
private func writeJSON(_ dict: [String: Any]) async {
|
||||||
|
guard let ch = channel,
|
||||||
|
let data = try? JSONSerialization.data(withJSONObject: dict),
|
||||||
|
let line = String(data: data, encoding: .utf8)
|
||||||
|
else { return }
|
||||||
|
do {
|
||||||
|
try await ch.send(line)
|
||||||
|
} catch {
|
||||||
|
await handleWriteFailed()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Read Loops
|
||||||
|
|
||||||
|
private func startReadLoops(channel ch: any ACPChannel) {
|
||||||
|
// Consume incoming JSON-RPC lines from the channel.
|
||||||
|
readTask = Task { [weak self] in
|
||||||
|
do {
|
||||||
|
for try await line in ch.incoming {
|
||||||
|
guard let data = line.data(using: .utf8) else { continue }
|
||||||
|
do {
|
||||||
|
let message = try JSONDecoder().decode(ACPRawMessage.self, from: data)
|
||||||
|
await self?.handleMessage(message)
|
||||||
|
} catch {
|
||||||
|
#if canImport(os)
|
||||||
|
await self?.logParseFailure(error, line: line)
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await self?.handleReadLoopEnded(cleanly: true)
|
||||||
|
} catch {
|
||||||
|
await self?.handleReadLoopEnded(cleanly: false, error: error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mirror stderr into the diagnostic ring buffer.
|
||||||
|
stderrTask = Task { [weak self] in
|
||||||
|
do {
|
||||||
|
for try await text in ch.stderr {
|
||||||
|
await self?.appendStderr(text)
|
||||||
|
#if canImport(os)
|
||||||
|
await self?.logStderrLine(text)
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Stderr errors don't matter — we already handle EOF on
|
||||||
|
// the incoming stream.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#if canImport(os)
|
||||||
|
private func logParseFailure(_ error: Error, line: String) {
|
||||||
|
logger.warning("Failed to decode ACP message: \(error.localizedDescription)")
|
||||||
|
}
|
||||||
|
|
||||||
|
private func logStderrLine(_ text: String) {
|
||||||
|
logger.info("ACP stderr: \(text.prefix(500))")
|
||||||
|
}
|
||||||
|
#endif
|
||||||
|
|
||||||
|
private func handleMessage(_ message: ACPRawMessage) {
|
||||||
|
if message.isResponse {
|
||||||
|
if let requestId = message.id,
|
||||||
|
let continuation = pendingRequests.removeValue(forKey: requestId) {
|
||||||
|
if let error = message.error {
|
||||||
|
#if canImport(os)
|
||||||
|
logger.error("ACP RPC error (id: \(requestId)): \(error.message)")
|
||||||
|
#endif
|
||||||
|
statusMessage = "Error: \(error.message)"
|
||||||
|
continuation.resume(throwing: ACPClientError.rpcError(code: error.code, message: error.message))
|
||||||
|
} else {
|
||||||
|
#if canImport(os)
|
||||||
|
logger.debug("ACP response (id: \(requestId))")
|
||||||
|
#endif
|
||||||
|
continuation.resume(returning: message.result)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
#if canImport(os)
|
||||||
|
logger.warning("ACP response for unknown request id: \(message.id ?? -1)")
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
} else if message.isNotification {
|
||||||
|
if let event = ACPEventParser.parse(notification: message) {
|
||||||
|
eventContinuation?.yield(event)
|
||||||
|
}
|
||||||
|
} else if message.isRequest {
|
||||||
|
if message.method == "session/request_permission",
|
||||||
|
let event = ACPEventParser.parsePermissionRequest(message) {
|
||||||
|
statusMessage = "Permission required"
|
||||||
|
eventContinuation?.yield(event)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Disconnect Cleanup
|
||||||
|
|
||||||
|
/// Single idempotent cleanup path for all disconnect scenarios.
|
||||||
|
/// Captures the channel's exit code + recent stderr BEFORE we drop
|
||||||
|
/// the reference, so the `processTerminated` error rides with
|
||||||
|
/// diagnostics — the user banner shows "exit 255 — ssh: connect to
|
||||||
|
/// host …: Connection refused" instead of a bare opaque timeout.
|
||||||
|
private func performDisconnectCleanup(reason: String) async {
|
||||||
|
guard isConnected else { return }
|
||||||
|
#if canImport(os)
|
||||||
|
logger.warning("ACP disconnecting: \(reason)")
|
||||||
|
#endif
|
||||||
|
let exitCode = await channel?.lastExitCode
|
||||||
|
let tail = recentStderr
|
||||||
|
isConnected = false
|
||||||
|
statusMessage = "Connection lost"
|
||||||
|
for (_, continuation) in pendingRequests {
|
||||||
|
continuation.resume(throwing: ACPClientError.processTerminated(
|
||||||
|
exitCode: exitCode,
|
||||||
|
stderrTail: tail
|
||||||
|
))
|
||||||
|
}
|
||||||
|
pendingRequests.removeAll()
|
||||||
|
eventContinuation?.finish()
|
||||||
|
eventContinuation = nil
|
||||||
|
}
|
||||||
|
|
||||||
|
private func handleReadLoopEnded(cleanly: Bool, error: Error? = nil) async {
|
||||||
|
let reason = cleanly ? "read loop ended (EOF)" : "read loop failed: \(error?.localizedDescription ?? "unknown")"
|
||||||
|
await performDisconnectCleanup(reason: reason)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func handleWriteFailed() async {
|
||||||
|
await performDisconnectCleanup(reason: "write failed (broken pipe)")
|
||||||
|
}
|
||||||
|
|
||||||
|
private func handleWriteFailedForRequest(id: Int) async {
|
||||||
|
if let continuation = pendingRequests.removeValue(forKey: id) {
|
||||||
|
let exitCode = await channel?.lastExitCode
|
||||||
|
continuation.resume(throwing: ACPClientError.processTerminated(
|
||||||
|
exitCode: exitCode,
|
||||||
|
stderrTail: recentStderr
|
||||||
|
))
|
||||||
|
}
|
||||||
|
await performDisconnectCleanup(reason: "write failed (broken pipe)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Errors
|
||||||
|
|
||||||
|
public enum ACPClientError: Error, LocalizedError {
|
||||||
|
case notConnected
|
||||||
|
case encodingFailed
|
||||||
|
case invalidResponse(String)
|
||||||
|
case rpcError(code: Int, message: String)
|
||||||
|
case processTerminated(exitCode: Int32?, stderrTail: String)
|
||||||
|
case requestTimeout(method: String)
|
||||||
|
|
||||||
|
public var errorDescription: String? {
|
||||||
|
switch self {
|
||||||
|
case .notConnected: return "ACP client is not connected"
|
||||||
|
case .encodingFailed: return "Failed to encode JSON-RPC request"
|
||||||
|
case .invalidResponse(let msg): return "Invalid ACP response: \(msg)"
|
||||||
|
case .rpcError(let code, let msg): return "ACP error \(code): \(msg)"
|
||||||
|
case .processTerminated(let exit, let tail):
|
||||||
|
let exitPart = exit.map { "exit \($0)" } ?? "no exit code"
|
||||||
|
let tailPart = Self.firstNonEmptyLine(in: tail).map { " — \($0)" } ?? ""
|
||||||
|
return "ACP process terminated unexpectedly (\(exitPart))\(tailPart)"
|
||||||
|
case .requestTimeout(let method): return "ACP request '\(method)' timed out"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Pluck the first non-empty stderr line for the user-facing
|
||||||
|
/// summary. Full tail still rides through on `acpErrorDetails`,
|
||||||
|
/// but the description itself stays single-line.
|
||||||
|
private static func firstNonEmptyLine(in s: String) -> String? {
|
||||||
|
for raw in s.split(separator: "\n") {
|
||||||
|
let line = raw.trimmingCharacters(in: .whitespaces)
|
||||||
|
if !line.isEmpty { return line }
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Maps a raw error message (RPC message or captured stderr) to a short
|
||||||
|
/// human-readable hint for the chat UI. Pattern-matches the most common
|
||||||
|
/// fresh-install failure modes. Returns nil when no known pattern matches.
|
||||||
|
public enum ACPErrorHint {
|
||||||
|
/// Result of a classifier hit. `hint` is the user-facing copy; when
|
||||||
|
/// the failure is an OAuth refresh-revocation, `oauthProvider` names
|
||||||
|
/// the affected provider (lowercase, matching `auth.json` keys) so
|
||||||
|
/// the UI can offer a one-click re-authenticate affordance. `nil`
|
||||||
|
/// `oauthProvider` means "we matched a non-OAuth failure mode, or
|
||||||
|
/// we matched OAuth but couldn't identify which provider."
|
||||||
|
public struct Classification: Sendable, Equatable {
|
||||||
|
public let hint: String
|
||||||
|
public let oauthProvider: String?
|
||||||
|
|
||||||
|
public init(hint: String, oauthProvider: String? = nil) {
|
||||||
|
self.hint = hint
|
||||||
|
self.oauthProvider = oauthProvider
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Known OAuth-authed providers Hermes ships. Listed lowercase to
|
||||||
|
/// match `auth.json.providers.<key>` and the values
|
||||||
|
/// `OAuthFlowController.start(provider:)` accepts.
|
||||||
|
private static let oauthProviders = [
|
||||||
|
"nous", "claude", "anthropic", "qwen", "gemini", "google", "copilot", "github",
|
||||||
|
]
|
||||||
|
|
||||||
|
public static func classify(errorMessage: String, stderrTail: String) -> Classification? {
|
||||||
|
let haystack = errorMessage + "\n" + stderrTail
|
||||||
|
|
||||||
|
// SSH-level failures come first — they apply only to remote
|
||||||
|
// contexts and the patterns are unambiguous (system ssh prints
|
||||||
|
// them verbatim to stderr). Without these classifications a
|
||||||
|
// vanished droplet, a wrong key, or a missing remote `hermes`
|
||||||
|
// all surface as opaque "ACP process terminated" / "request
|
||||||
|
// timed out", and the user has no idea where to look.
|
||||||
|
if haystack.contains("Connection refused") {
|
||||||
|
return Classification(hint: "Couldn't reach the remote host — the SSH port is closed or the droplet is down. Check the host is running and reachable.")
|
||||||
|
}
|
||||||
|
if haystack.localizedCaseInsensitiveContains("Operation timed out")
|
||||||
|
|| haystack.localizedCaseInsensitiveContains("Connection timed out")
|
||||||
|
|| haystack.contains("Network is unreachable")
|
||||||
|
|| haystack.contains("No route to host") {
|
||||||
|
return Classification(hint: "Couldn't reach the remote host — the network connection timed out. Check the host is running and your network is up.")
|
||||||
|
}
|
||||||
|
if haystack.contains("Permission denied (publickey")
|
||||||
|
|| haystack.contains("Permission denied, please try again") {
|
||||||
|
return Classification(hint: "SSH rejected the key. Make sure the right identity file is selected and that ssh-agent has the key loaded — open Terminal and run `ssh-add -l`.")
|
||||||
|
}
|
||||||
|
if haystack.contains("Host key verification failed")
|
||||||
|
|| haystack.contains("REMOTE HOST IDENTIFICATION HAS CHANGED") {
|
||||||
|
return Classification(hint: "The remote host's SSH key changed. If you just rebuilt the droplet, remove the old entry with `ssh-keygen -R <host>`, then try again.")
|
||||||
|
}
|
||||||
|
if haystack.contains("Could not resolve hostname")
|
||||||
|
|| haystack.contains("Name or service not known") {
|
||||||
|
return Classification(hint: "Couldn't resolve the host name. Check the host in this server's settings.")
|
||||||
|
}
|
||||||
|
if haystack.localizedCaseInsensitiveContains("command not found")
|
||||||
|
|| haystack.contains("hermes: not found")
|
||||||
|
|| haystack.contains("exit 127") {
|
||||||
|
return Classification(hint: "The remote shell couldn't find `hermes`. Either install Hermes on the remote (`pipx install hermes-agent`) or set an absolute binary path in this server's settings.")
|
||||||
|
}
|
||||||
|
|
||||||
|
// OAuth refresh-token revocation. Hermes prints
|
||||||
|
// "Refresh session has been revoked. Run `hermes model` to
|
||||||
|
// re-authenticate." to stderr/stdout when an OAuth-authed
|
||||||
|
// provider's refresh token can no longer mint access tokens
|
||||||
|
// (user revoked, server rotated keys, etc.). We can't drive
|
||||||
|
// `hermes model` interactively, but `hermes auth add <provider>
|
||||||
|
// --type oauth` is the same code path Scarf already drives via
|
||||||
|
// `OAuthFlowController` for first-time setup, so we surface a
|
||||||
|
// re-authenticate affordance instead. Checked BEFORE the
|
||||||
|
// generic "no credentials found" path because the message
|
||||||
|
// contains the word "credentials" via the surrounding context.
|
||||||
|
if haystack.localizedCaseInsensitiveContains("refresh session has been revoked")
|
||||||
|
|| haystack.range(of: #"refresh.*revoked"#, options: [.regularExpression, .caseInsensitive]) != nil
|
||||||
|
|| haystack.localizedCaseInsensitiveContains("re-authenticate")
|
||||||
|
|| haystack.localizedCaseInsensitiveContains("reauthenticate")
|
||||||
|
|| (haystack.contains("401") && oauthProvider(in: haystack) != nil)
|
||||||
|
|| (haystack.localizedCaseInsensitiveContains("unauthorized") && oauthProvider(in: haystack) != nil) {
|
||||||
|
let provider = oauthProvider(in: haystack)
|
||||||
|
let suffix = provider.map { " (affected provider: \($0))." } ?? "."
|
||||||
|
return Classification(
|
||||||
|
hint: "Your OAuth session has expired or been revoked\(suffix) Click Re-authenticate below to sign in again.",
|
||||||
|
oauthProvider: provider
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Auxiliary task references a provider that isn't authenticated.
|
||||||
|
// Hermes prints `resolve_provider_client: <name> requested but
|
||||||
|
// <Display Name> not configured` when an aux task (compression,
|
||||||
|
// summarization, memory_flush, curator, vision, web_extract,
|
||||||
|
// session_search, skills_hub) has `provider: <name>` set in
|
||||||
|
// config.yaml but that provider's credentials aren't loaded.
|
||||||
|
// Common after a user removes one OAuth provider while their
|
||||||
|
// existing config.yaml still names it for an aux task. The
|
||||||
|
// chat banner used to surface this as `-32603 Internal error`
|
||||||
|
// with no actionable detail; surface a clear path now.
|
||||||
|
if let match = haystack.range(
|
||||||
|
of: #"resolve_provider_client:\s*([a-zA-Z0-9_-]+)\s+requested\s+but"#,
|
||||||
|
options: .regularExpression
|
||||||
|
) {
|
||||||
|
let line = String(haystack[match])
|
||||||
|
// Pull the captured provider name out of the matched line.
|
||||||
|
// First word after "resolve_provider_client:" is the value.
|
||||||
|
let provider: String = {
|
||||||
|
let parts = line.split(whereSeparator: { $0.isWhitespace })
|
||||||
|
if let idx = parts.firstIndex(where: { $0.contains("resolve_provider_client") }),
|
||||||
|
parts.index(after: idx) < parts.endIndex {
|
||||||
|
let candidate = parts[parts.index(after: idx)]
|
||||||
|
return String(candidate)
|
||||||
|
}
|
||||||
|
return "an unauthenticated provider"
|
||||||
|
}()
|
||||||
|
return Classification(
|
||||||
|
hint: "An auxiliary task is configured to use `\(provider)` but that provider isn't authenticated. Open Settings → Aux Models, or check `~/.hermes/config.yaml` for `auxiliary.<task>.provider: \(provider)` and switch it to your active provider (or set it to `auto`)."
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if haystack.range(of: #"No\s+(Anthropic|OpenAI|OpenRouter|Gemini|Google|Groq|Mistral|XAI)?\s*credentials\s+found"#,
|
||||||
|
options: .regularExpression) != nil
|
||||||
|
|| haystack.contains("ANTHROPIC_API_KEY")
|
||||||
|
|| haystack.contains("ANTHROPIC_TOKEN")
|
||||||
|
|| haystack.contains("claude setup-token")
|
||||||
|
|| haystack.contains("claude /login") {
|
||||||
|
return Classification(hint: "Hermes can't find your AI provider credentials. Set `ANTHROPIC_API_KEY` (or similar) in `~/.hermes/.env` or your shell profile, then restart Scarf.")
|
||||||
|
}
|
||||||
|
if let match = haystack.range(of: #"No such file or directory:\s*'([^']+)'"#,
|
||||||
|
options: .regularExpression) {
|
||||||
|
let matched = String(haystack[match])
|
||||||
|
if let nameStart = matched.range(of: "'"),
|
||||||
|
let nameEnd = matched.range(of: "'", range: nameStart.upperBound..<matched.endIndex) {
|
||||||
|
let name = String(matched[nameStart.upperBound..<nameEnd.lowerBound])
|
||||||
|
return Classification(hint: "Hermes couldn't find `\(name)` on PATH. If you use nvm/asdf/mise, make sure it's exported in `~/.zprofile` (not only `~/.zshrc`), then restart Scarf.")
|
||||||
|
}
|
||||||
|
return Classification(hint: "Hermes couldn't find a required binary on PATH. Check that your shell's PATH is exported in `~/.zprofile`, then restart Scarf.")
|
||||||
|
}
|
||||||
|
if haystack.localizedCaseInsensitiveContains("rate limit")
|
||||||
|
|| haystack.localizedCaseInsensitiveContains("429") {
|
||||||
|
return Classification(hint: "Your AI provider returned a rate-limit error. Try again in a moment.")
|
||||||
|
}
|
||||||
|
// Model-availability failure. Hermes pins each session to the
|
||||||
|
// model that opened it, so resuming an old session whose model
|
||||||
|
// is no longer available (provider deprecation, OAuth swapped
|
||||||
|
// to a different provider, model name changed) returns a 404
|
||||||
|
// / model_not_found from the upstream provider — surfaced as
|
||||||
|
// an opaque "-32603 Internal error" in chat. v2.8 surfaces a
|
||||||
|
// clear "session is pinned" hint with the recovery path.
|
||||||
|
if haystack.localizedCaseInsensitiveContains("model_not_found")
|
||||||
|
|| haystack.localizedCaseInsensitiveContains("model not found")
|
||||||
|
|| haystack.localizedCaseInsensitiveContains("invalid_model")
|
||||||
|
|| haystack.localizedCaseInsensitiveContains("model is not available")
|
||||||
|
|| haystack.localizedCaseInsensitiveContains("unknown model")
|
||||||
|
|| (haystack.contains("404") && (haystack.localizedCaseInsensitiveContains("model")
|
||||||
|
|| haystack.localizedCaseInsensitiveContains("messages"))) {
|
||||||
|
return Classification(hint: "This session was created with a model the provider no longer offers. Hermes pins each session to its original model — start a new chat to use your current model, or run `hermes sessions clone` in Terminal to copy this conversation onto the new model.")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Best-effort extraction of an OAuth provider name from raw error
|
||||||
|
/// text. Returns the lowercase provider key (`"nous"`, `"claude"`,
|
||||||
|
/// etc.) when one of the known OAuth providers appears as a whole
|
||||||
|
/// word. The first match wins — Hermes typically logs the active
|
||||||
|
/// provider name once, near the failure.
|
||||||
|
private static func oauthProvider(in haystack: String) -> String? {
|
||||||
|
let lowered = haystack.lowercased()
|
||||||
|
for provider in oauthProviders {
|
||||||
|
// Whole-word match so substrings like "anthropicapi" don't
|
||||||
|
// false-trigger on "anthropic".
|
||||||
|
let pattern = "\\b" + NSRegularExpression.escapedPattern(for: provider) + "\\b"
|
||||||
|
if lowered.range(of: pattern, options: .regularExpression) != nil {
|
||||||
|
return provider
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,281 @@
|
|||||||
|
// iOS can't spawn subprocesses (no `Process`, sandboxed away from fork/exec).
|
||||||
|
// Everything below only makes sense on platforms that can — macOS and Linux.
|
||||||
|
// iOS gets its ACP transport from a future `SSHExecACPChannel` (Citadel)
|
||||||
|
// landing in M4.
|
||||||
|
#if !os(iOS)
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// `ACPChannel` backed by a `Foundation.Process` spawning `hermes acp`
|
||||||
|
/// (local) or `ssh -T host -- hermes acp` (remote, via
|
||||||
|
/// `SSHTransport.makeProcess`). Owns the process lifecycle, stdin/stdout
|
||||||
|
/// pipes, and a small ring-buffered stderr capture for diagnostics.
|
||||||
|
///
|
||||||
|
/// The per-call `send(_:)` path uses raw POSIX `write(2)` instead of
|
||||||
|
/// `FileHandle.write` — `FileHandle.write` crashes the whole app on
|
||||||
|
/// EPIPE (broken pipe) rather than throwing, so the original ACPClient
|
||||||
|
/// installed a `SIGPIPE` handler and a POSIX-write helper. That logic
|
||||||
|
/// moves here intact.
|
||||||
|
public actor ProcessACPChannel: ACPChannel {
|
||||||
|
private let process: Process
|
||||||
|
private let stdinPipe: Pipe
|
||||||
|
private let stdoutPipe: Pipe
|
||||||
|
private let stderrPipe: Pipe
|
||||||
|
/// Cached raw file descriptor for the stdin write end. Captured on
|
||||||
|
/// init because `Process.standardInput` gets nilled after `close()`.
|
||||||
|
private let stdinFd: Int32
|
||||||
|
|
||||||
|
private let incomingContinuation: AsyncThrowingStream<String, Error>.Continuation
|
||||||
|
/// Retain the stream — callers get it lazily; we stash it here so the
|
||||||
|
/// continuation doesn't outlive its producer.
|
||||||
|
public nonisolated let incoming: AsyncThrowingStream<String, Error>
|
||||||
|
private let stderrContinuation: AsyncThrowingStream<String, Error>.Continuation
|
||||||
|
public nonisolated let stderr: AsyncThrowingStream<String, Error>
|
||||||
|
|
||||||
|
private var isClosed = false
|
||||||
|
private var readerTask: Task<Void, Never>?
|
||||||
|
private var stderrTask: Task<Void, Never>?
|
||||||
|
|
||||||
|
/// Read by `ACPClient` to fill in `processTerminated(exitCode:…)`
|
||||||
|
/// so the error names the actual exit code rather than reporting a
|
||||||
|
/// bare timeout. Sourced directly from `Process` — `Process` is
|
||||||
|
/// thread-safe for this read and reflects the actual reap state,
|
||||||
|
/// so we sidestep the race between the OS-side `terminationHandler`
|
||||||
|
/// callback and the EOF-driven disconnect cleanup that would
|
||||||
|
/// otherwise need an atomic to coordinate.
|
||||||
|
public var lastExitCode: Int32? {
|
||||||
|
process.isRunning ? nil : process.terminationStatus
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The subprocess's PID as a human-readable string.
|
||||||
|
public var diagnosticID: String? {
|
||||||
|
"pid=\(process.processIdentifier)"
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Spawn `executable` with `args`, wiring its stdin/stdout/stderr into
|
||||||
|
/// this channel. `env` is passed verbatim to the subprocess (callers
|
||||||
|
/// are responsible for running it through whatever enrichment they
|
||||||
|
/// need — this layer doesn't know about `SSH_AUTH_SOCK` or PATH).
|
||||||
|
///
|
||||||
|
/// For remote contexts, the Mac caller passes a pre-configured
|
||||||
|
/// `Process` via `init(process:)` below — `SSHTransport.makeProcess`
|
||||||
|
/// already set up the ssh argv.
|
||||||
|
public init(
|
||||||
|
executable: String,
|
||||||
|
args: [String],
|
||||||
|
env: [String: String]
|
||||||
|
) async throws {
|
||||||
|
let proc = Process()
|
||||||
|
proc.executableURL = URL(fileURLWithPath: executable)
|
||||||
|
proc.arguments = args
|
||||||
|
proc.environment = env
|
||||||
|
try await Self.launch(process: proc)
|
||||||
|
try Self.ignoreSIGPIPE_once()
|
||||||
|
|
||||||
|
self.process = proc
|
||||||
|
self.stdinPipe = proc.standardInput as! Pipe
|
||||||
|
self.stdoutPipe = proc.standardOutput as! Pipe
|
||||||
|
self.stderrPipe = proc.standardError as! Pipe
|
||||||
|
self.stdinFd = stdinPipe.fileHandleForWriting.fileDescriptor
|
||||||
|
|
||||||
|
let (inStream, inContinuation) = AsyncThrowingStream<String, Error>.makeStream()
|
||||||
|
self.incoming = inStream
|
||||||
|
self.incomingContinuation = inContinuation
|
||||||
|
|
||||||
|
let (errStream, errContinuation) = AsyncThrowingStream<String, Error>.makeStream()
|
||||||
|
self.stderr = errStream
|
||||||
|
self.stderrContinuation = errContinuation
|
||||||
|
|
||||||
|
startReaders()
|
||||||
|
installTerminationHandler()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Secondary entry point for callers that have a pre-configured
|
||||||
|
/// `Process` (typically from `SSHTransport.makeProcess`). The process
|
||||||
|
/// must NOT already be running — this initializer calls `run()`.
|
||||||
|
public init(process: Process) async throws {
|
||||||
|
try await Self.launch(process: process)
|
||||||
|
try Self.ignoreSIGPIPE_once()
|
||||||
|
|
||||||
|
self.process = process
|
||||||
|
self.stdinPipe = process.standardInput as! Pipe
|
||||||
|
self.stdoutPipe = process.standardOutput as! Pipe
|
||||||
|
self.stderrPipe = process.standardError as! Pipe
|
||||||
|
self.stdinFd = stdinPipe.fileHandleForWriting.fileDescriptor
|
||||||
|
|
||||||
|
let (inStream, inContinuation) = AsyncThrowingStream<String, Error>.makeStream()
|
||||||
|
self.incoming = inStream
|
||||||
|
self.incomingContinuation = inContinuation
|
||||||
|
|
||||||
|
let (errStream, errContinuation) = AsyncThrowingStream<String, Error>.makeStream()
|
||||||
|
self.stderr = errStream
|
||||||
|
self.stderrContinuation = errContinuation
|
||||||
|
|
||||||
|
startReaders()
|
||||||
|
installTerminationHandler()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Wire fresh stdin/stdout/stderr pipes (overwriting any the caller
|
||||||
|
/// set) and start the subprocess.
|
||||||
|
private static func launch(process: Process) async throws {
|
||||||
|
process.standardInput = Pipe()
|
||||||
|
process.standardOutput = Pipe()
|
||||||
|
process.standardError = Pipe()
|
||||||
|
do {
|
||||||
|
try process.run()
|
||||||
|
} catch {
|
||||||
|
throw ACPChannelError.launchFailed(error.localizedDescription)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Install a `terminationHandler` that closes the stdout read end
|
||||||
|
/// the moment the OS reaps the child. Without this, the reader
|
||||||
|
/// loop's `availableData` keeps blocking until the kernel tears
|
||||||
|
/// the pipe down on its own schedule — visible to the user as a
|
||||||
|
/// 30s ACP `initialize` timeout where a fast SSH-side failure
|
||||||
|
/// (Connection refused, exit 127) should surface in under a
|
||||||
|
/// second. The exit code itself is read on demand from
|
||||||
|
/// `Process.terminationStatus` (see `lastExitCode`), so this
|
||||||
|
/// callback doesn't need to touch actor state.
|
||||||
|
private func installTerminationHandler() {
|
||||||
|
let stdoutFh = stdoutPipe.fileHandleForReading
|
||||||
|
process.terminationHandler = { _ in
|
||||||
|
try? stdoutFh.close()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Ignore SIGPIPE once per process so a broken-pipe write returns
|
||||||
|
/// `EPIPE` (which we surface as `.writeEndClosed`) instead of
|
||||||
|
/// delivering SIGPIPE and tearing the app down. Idempotent; the
|
||||||
|
/// kernel is fine with repeated `SIG_IGN` installs.
|
||||||
|
nonisolated private static func ignoreSIGPIPE_once() throws {
|
||||||
|
signal(SIGPIPE, SIG_IGN)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Send
|
||||||
|
|
||||||
|
public func send(_ line: String) async throws {
|
||||||
|
guard !isClosed else { throw ACPChannelError.writeEndClosed }
|
||||||
|
guard var data = line.data(using: .utf8) else {
|
||||||
|
throw ACPChannelError.invalidEncoding
|
||||||
|
}
|
||||||
|
data.append(0x0A) // '\n'
|
||||||
|
let fd = stdinFd
|
||||||
|
// POSIX write, looping on partial writes and surfacing EPIPE as
|
||||||
|
// `.writeEndClosed`. Crucial: `FileHandle.write(_:)` crashes the
|
||||||
|
// app on EPIPE rather than throwing; the original ACPClient used
|
||||||
|
// this same `Darwin.write` (or `Glibc.write` on Linux) technique.
|
||||||
|
let ok = Self.safeWrite(fd: fd, data: data)
|
||||||
|
if !ok {
|
||||||
|
throw ACPChannelError.writeEndClosed
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
nonisolated private static func safeWrite(fd: Int32, data: Data) -> Bool {
|
||||||
|
data.withUnsafeBytes { buf in
|
||||||
|
guard let base = buf.baseAddress else { return false }
|
||||||
|
var written = 0
|
||||||
|
let total = buf.count
|
||||||
|
while written < total {
|
||||||
|
#if canImport(Darwin)
|
||||||
|
let result = Darwin.write(fd, base.advanced(by: written), total - written)
|
||||||
|
#elseif canImport(Glibc)
|
||||||
|
let result = Glibc.write(fd, base.advanced(by: written), total - written)
|
||||||
|
#else
|
||||||
|
return false
|
||||||
|
#endif
|
||||||
|
if result <= 0 { return false }
|
||||||
|
written += result
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Close
|
||||||
|
|
||||||
|
public func close() async {
|
||||||
|
guard !isClosed else { return }
|
||||||
|
isClosed = true
|
||||||
|
|
||||||
|
// Close stdin so the child sees EOF and can flush. readerTask
|
||||||
|
// will see the pipe close and finish naturally.
|
||||||
|
stdinPipe.fileHandleForWriting.closeFile()
|
||||||
|
|
||||||
|
if process.isRunning {
|
||||||
|
// SIGINT for graceful Python shutdown — raises KeyboardInterrupt
|
||||||
|
// cleanly instead of aborting in the middle of a JSON write.
|
||||||
|
process.interrupt()
|
||||||
|
// Watchdog: force-kill if still running after 2s. A stuck
|
||||||
|
// child shouldn't keep the app's close() hanging.
|
||||||
|
let watchdog = process
|
||||||
|
Task.detached {
|
||||||
|
try? await Task.sleep(nanoseconds: 2_000_000_000)
|
||||||
|
if watchdog.isRunning { watchdog.terminate() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
stdinPipe.fileHandleForReading.closeFile()
|
||||||
|
stdoutPipe.fileHandleForReading.closeFile()
|
||||||
|
stderrPipe.fileHandleForReading.closeFile()
|
||||||
|
stdoutPipe.fileHandleForWriting.closeFile()
|
||||||
|
stderrPipe.fileHandleForWriting.closeFile()
|
||||||
|
|
||||||
|
readerTask?.cancel()
|
||||||
|
stderrTask?.cancel()
|
||||||
|
incomingContinuation.finish()
|
||||||
|
stderrContinuation.finish()
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Reader loops
|
||||||
|
|
||||||
|
private func startReaders() {
|
||||||
|
let outHandle = stdoutPipe.fileHandleForReading
|
||||||
|
let errHandle = stderrPipe.fileHandleForReading
|
||||||
|
let inCont = incomingContinuation
|
||||||
|
let errCont = stderrContinuation
|
||||||
|
|
||||||
|
readerTask = Task.detached {
|
||||||
|
var buffer = Data()
|
||||||
|
while !Task.isCancelled {
|
||||||
|
let chunk = outHandle.availableData
|
||||||
|
if chunk.isEmpty { break } // EOF
|
||||||
|
buffer.append(chunk)
|
||||||
|
while let nl = buffer.firstIndex(of: 0x0A) {
|
||||||
|
let lineData = Data(buffer[buffer.startIndex..<nl])
|
||||||
|
buffer = Data(buffer[buffer.index(after: nl)...])
|
||||||
|
guard !lineData.isEmpty else { continue }
|
||||||
|
if let text = String(data: lineData, encoding: .utf8) {
|
||||||
|
inCont.yield(text)
|
||||||
|
} else {
|
||||||
|
inCont.finish(throwing: ACPChannelError.invalidEncoding)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
inCont.finish()
|
||||||
|
}
|
||||||
|
|
||||||
|
stderrTask = Task.detached {
|
||||||
|
var buffer = Data()
|
||||||
|
while !Task.isCancelled {
|
||||||
|
let chunk = errHandle.availableData
|
||||||
|
if chunk.isEmpty { break }
|
||||||
|
buffer.append(chunk)
|
||||||
|
while let nl = buffer.firstIndex(of: 0x0A) {
|
||||||
|
let lineData = Data(buffer[buffer.startIndex..<nl])
|
||||||
|
buffer = Data(buffer[buffer.index(after: nl)...])
|
||||||
|
guard !lineData.isEmpty else { continue }
|
||||||
|
if let text = String(data: lineData, encoding: .utf8) {
|
||||||
|
errCont.yield(text)
|
||||||
|
}
|
||||||
|
// Non-UTF-8 stderr lines are dropped silently;
|
||||||
|
// we're not going to crash the channel over a
|
||||||
|
// weird byte in a log line.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
errCont.finish()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#endif // !os(iOS)
|
||||||
@@ -0,0 +1,277 @@
|
|||||||
|
import Foundation
|
||||||
|
#if canImport(os)
|
||||||
|
import os
|
||||||
|
import os.signpost
|
||||||
|
#endif
|
||||||
|
|
||||||
|
/// Lightweight performance instrumentation for the Scarf app family.
|
||||||
|
///
|
||||||
|
/// Three primitives — `measure(...)`, `measureAsync(...)`, `event(...)` — drop
|
||||||
|
/// timing samples through whatever set of backends is currently active.
|
||||||
|
/// Backends are pluggable: an always-on `os_signpost` backend (free outside
|
||||||
|
/// Instruments), an in-memory ring buffer (drives the in-app panel), and an
|
||||||
|
/// `os.Logger` debug backend (off by default).
|
||||||
|
///
|
||||||
|
/// **Cost when off.** When no backends are registered, every entry point is
|
||||||
|
/// `@inline(__always)` and short-circuits to the body call without taking the
|
||||||
|
/// `ContinuousClock.now` reading. Open source build defaults to "signpost
|
||||||
|
/// only" — that backend pays one signpost emit per call, which Apple's runtime
|
||||||
|
/// elides when no Instruments session is recording.
|
||||||
|
///
|
||||||
|
/// **Privacy.** Names are `StaticString` so we cannot accidentally pass user
|
||||||
|
/// content through a metric tag. Optional `bytes:` field on `event` tracks
|
||||||
|
/// payload size, never payload contents. The ring buffer never leaves the
|
||||||
|
/// device unless the user explicitly hits "Copy as JSON" in the Diagnostics
|
||||||
|
/// panel.
|
||||||
|
public enum ScarfMon {
|
||||||
|
|
||||||
|
// MARK: - Public API
|
||||||
|
|
||||||
|
/// Synchronous timing wrapper. The body's return value flows through
|
||||||
|
/// untouched; the time it took plus `(category, name)` are recorded.
|
||||||
|
@inline(__always)
|
||||||
|
public static func measure<T>(
|
||||||
|
_ category: Category,
|
||||||
|
_ name: StaticString,
|
||||||
|
_ body: () throws -> T
|
||||||
|
) rethrows -> T {
|
||||||
|
guard isActive else { return try body() }
|
||||||
|
let start = ContinuousClock.now
|
||||||
|
defer { record(category, name, start: start, end: ContinuousClock.now) }
|
||||||
|
return try body()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Async variant. Same shape — the `defer` block fires after the body
|
||||||
|
/// returns whether or not it threw, so cancelled / failed work still
|
||||||
|
/// records its duration.
|
||||||
|
@inline(__always)
|
||||||
|
public static func measureAsync<T>(
|
||||||
|
_ category: Category,
|
||||||
|
_ name: StaticString,
|
||||||
|
_ body: () async throws -> T
|
||||||
|
) async rethrows -> T {
|
||||||
|
guard isActive else { return try await body() }
|
||||||
|
let start = ContinuousClock.now
|
||||||
|
defer { record(category, name, start: start, end: ContinuousClock.now) }
|
||||||
|
return try await body()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Single-shot timestamped event. Use for things that aren't intervals
|
||||||
|
/// (token arrivals, buffer flushes) where count + optional payload size
|
||||||
|
/// is the useful signal.
|
||||||
|
@inline(__always)
|
||||||
|
public static func event(
|
||||||
|
_ category: Category,
|
||||||
|
_ name: StaticString,
|
||||||
|
count: Int = 1,
|
||||||
|
bytes: Int? = nil
|
||||||
|
) {
|
||||||
|
guard isActive else { return }
|
||||||
|
recordEvent(category, name, count: count, bytes: bytes)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Backend management
|
||||||
|
|
||||||
|
/// Install the desired backend set. Replaces the current set atomically.
|
||||||
|
/// Call once at app boot from the launch sequence; safe to call again
|
||||||
|
/// when the user toggles a setting on or off.
|
||||||
|
public static func install(_ backends: [ScarfMonBackend]) {
|
||||||
|
lock.lock()
|
||||||
|
defer { lock.unlock() }
|
||||||
|
installed = backends
|
||||||
|
cachedActive = !backends.isEmpty
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Currently-installed backends. Test-only — callers should not iterate
|
||||||
|
/// this in production.
|
||||||
|
public static var currentBackends: [ScarfMonBackend] {
|
||||||
|
lock.lock()
|
||||||
|
defer { lock.unlock() }
|
||||||
|
return installed
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Cheap "are we recording anything?" check. The flag is updated only
|
||||||
|
/// when `install(...)` runs, so the hot path doesn't take the lock.
|
||||||
|
@inline(__always)
|
||||||
|
public static var isActive: Bool { cachedActive }
|
||||||
|
|
||||||
|
// MARK: - Internals
|
||||||
|
|
||||||
|
private static let lock = ScarfMonLock()
|
||||||
|
nonisolated(unsafe) private static var installed: [ScarfMonBackend] = []
|
||||||
|
nonisolated(unsafe) private static var cachedActive: Bool = false
|
||||||
|
|
||||||
|
@inline(__always)
|
||||||
|
private static func record(
|
||||||
|
_ category: Category,
|
||||||
|
_ name: StaticString,
|
||||||
|
start: ContinuousClock.Instant,
|
||||||
|
end: ContinuousClock.Instant
|
||||||
|
) {
|
||||||
|
let duration = end - start
|
||||||
|
let nanos = nanoseconds(of: duration)
|
||||||
|
let backends = snapshotBackends()
|
||||||
|
let sample = Sample(
|
||||||
|
category: category,
|
||||||
|
name: name,
|
||||||
|
kind: .interval,
|
||||||
|
timestamp: Date(),
|
||||||
|
durationNanos: nanos,
|
||||||
|
count: 1,
|
||||||
|
bytes: nil
|
||||||
|
)
|
||||||
|
for backend in backends {
|
||||||
|
backend.record(sample)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@inline(__always)
|
||||||
|
private static func recordEvent(
|
||||||
|
_ category: Category,
|
||||||
|
_ name: StaticString,
|
||||||
|
count: Int,
|
||||||
|
bytes: Int?
|
||||||
|
) {
|
||||||
|
let backends = snapshotBackends()
|
||||||
|
let sample = Sample(
|
||||||
|
category: category,
|
||||||
|
name: name,
|
||||||
|
kind: .event,
|
||||||
|
timestamp: Date(),
|
||||||
|
durationNanos: 0,
|
||||||
|
count: count,
|
||||||
|
bytes: bytes
|
||||||
|
)
|
||||||
|
for backend in backends {
|
||||||
|
backend.record(sample)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func snapshotBackends() -> [ScarfMonBackend] {
|
||||||
|
lock.lock()
|
||||||
|
defer { lock.unlock() }
|
||||||
|
return installed
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func nanoseconds(of duration: Duration) -> UInt64 {
|
||||||
|
// Duration is (seconds: Int64, attoseconds: Int64). Avoid Double
|
||||||
|
// for the seconds term to keep precision on long intervals.
|
||||||
|
let comps = duration.components
|
||||||
|
let secondsAsNanos = UInt64(max(0, comps.seconds)) &* 1_000_000_000
|
||||||
|
let attoAsNanos = UInt64(max(0, comps.attoseconds) / 1_000_000_000)
|
||||||
|
return secondsAsNanos &+ attoAsNanos
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Categories
|
||||||
|
|
||||||
|
extension ScarfMon {
|
||||||
|
/// Stable category vocabulary. Add cases here when new subsystems get
|
||||||
|
/// instrumented; renames are breaking changes for any saved JSON dumps
|
||||||
|
/// users have shared, so prefer adding over renaming.
|
||||||
|
public enum Category: String, CaseIterable, Sendable, Codable {
|
||||||
|
case chatRender
|
||||||
|
case chatStream
|
||||||
|
case sessionLoad
|
||||||
|
case transport
|
||||||
|
case sqlite
|
||||||
|
case diskIO
|
||||||
|
case render
|
||||||
|
case other
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Sample
|
||||||
|
|
||||||
|
/// One recorded sample. All fields are value types so the struct is trivially
|
||||||
|
/// `Sendable` across backend queues without locks.
|
||||||
|
public struct ScarfMonSample: Sendable, Hashable {
|
||||||
|
public enum Kind: String, Sendable, Codable {
|
||||||
|
case interval
|
||||||
|
case event
|
||||||
|
}
|
||||||
|
public let category: ScarfMon.Category
|
||||||
|
/// Static name string captured at the call site. Not a `String` — keeping
|
||||||
|
/// it `StaticString` proves at compile time that names cannot leak user
|
||||||
|
/// data through this channel.
|
||||||
|
public let name: StaticString
|
||||||
|
public let kind: Kind
|
||||||
|
public let timestamp: Date
|
||||||
|
public let durationNanos: UInt64
|
||||||
|
public let count: Int
|
||||||
|
public let bytes: Int?
|
||||||
|
|
||||||
|
public init(
|
||||||
|
category: ScarfMon.Category,
|
||||||
|
name: StaticString,
|
||||||
|
kind: Kind,
|
||||||
|
timestamp: Date,
|
||||||
|
durationNanos: UInt64,
|
||||||
|
count: Int,
|
||||||
|
bytes: Int?
|
||||||
|
) {
|
||||||
|
self.category = category
|
||||||
|
self.name = name
|
||||||
|
self.kind = kind
|
||||||
|
self.timestamp = timestamp
|
||||||
|
self.durationNanos = durationNanos
|
||||||
|
self.count = count
|
||||||
|
self.bytes = bytes
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `StaticString` does not conform to `Hashable` natively (it doesn't
|
||||||
|
/// promise a stable hash). We hash via its UTF-8 representation so two
|
||||||
|
/// samples with the same source-literal name compare equal.
|
||||||
|
public static func == (lhs: ScarfMonSample, rhs: ScarfMonSample) -> Bool {
|
||||||
|
lhs.category == rhs.category
|
||||||
|
&& lhs.kind == rhs.kind
|
||||||
|
&& lhs.timestamp == rhs.timestamp
|
||||||
|
&& lhs.durationNanos == rhs.durationNanos
|
||||||
|
&& lhs.count == rhs.count
|
||||||
|
&& lhs.bytes == rhs.bytes
|
||||||
|
&& lhs.name.description == rhs.name.description
|
||||||
|
}
|
||||||
|
|
||||||
|
public func hash(into hasher: inout Hasher) {
|
||||||
|
hasher.combine(category)
|
||||||
|
hasher.combine(kind)
|
||||||
|
hasher.combine(timestamp)
|
||||||
|
hasher.combine(durationNanos)
|
||||||
|
hasher.combine(count)
|
||||||
|
hasher.combine(bytes)
|
||||||
|
hasher.combine(name.description)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension ScarfMon {
|
||||||
|
public typealias Sample = ScarfMonSample
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Backend protocol
|
||||||
|
|
||||||
|
/// One sink for samples. Implementations must be cheap on the hot path —
|
||||||
|
/// callers hold no lock while invoking `record`, but the hot path runs from
|
||||||
|
/// every instrumented site, so allocations and disk I/O are off-limits here.
|
||||||
|
public protocol ScarfMonBackend: Sendable {
|
||||||
|
func record(_ sample: ScarfMon.Sample)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Lock
|
||||||
|
|
||||||
|
/// Tiny `os_unfair_lock` wrapper. CLAUDE.md says "Use os_unfair_lock (not
|
||||||
|
/// NSLock) for simple boolean flags accessed from multiple threads."
|
||||||
|
@usableFromInline
|
||||||
|
final class ScarfMonLock: @unchecked Sendable {
|
||||||
|
private let _lock: UnsafeMutablePointer<os_unfair_lock>
|
||||||
|
|
||||||
|
init() {
|
||||||
|
_lock = .allocate(capacity: 1)
|
||||||
|
_lock.initialize(to: os_unfair_lock())
|
||||||
|
}
|
||||||
|
deinit {
|
||||||
|
_lock.deinitialize(count: 1)
|
||||||
|
_lock.deallocate()
|
||||||
|
}
|
||||||
|
@usableFromInline func lock() { os_unfair_lock_lock(_lock) }
|
||||||
|
@usableFromInline func unlock() { os_unfair_lock_unlock(_lock) }
|
||||||
|
}
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// Boot-time wiring for ScarfMon. Both app targets call
|
||||||
|
/// `ScarfMonBoot.configure(...)` at launch and again whenever the user
|
||||||
|
/// flips the Diagnostics → Performance toggle.
|
||||||
|
///
|
||||||
|
/// Three modes:
|
||||||
|
/// - `.off` — nothing is recorded. Hot path is one branch + return.
|
||||||
|
/// - `.signpostOnly` — Instruments-only. Default in the open-source build.
|
||||||
|
/// Free outside an Instruments session.
|
||||||
|
/// - `.full` — signpost + ring buffer + os.Logger debug stream. Drives the
|
||||||
|
/// in-app panel and the "Copy as JSON" button. Opt-in.
|
||||||
|
public enum ScarfMonBoot {
|
||||||
|
public enum Mode: String, Sendable, CaseIterable {
|
||||||
|
case off
|
||||||
|
case signpostOnly
|
||||||
|
case full
|
||||||
|
}
|
||||||
|
|
||||||
|
/// User-defaults key for the persisted toggle. Same key on iOS + Mac
|
||||||
|
/// so `defaults read com.scarf.app ScarfMonMode` works on either.
|
||||||
|
public static let userDefaultsKey = "ScarfMonMode"
|
||||||
|
|
||||||
|
/// Read the persisted mode, defaulting to `.signpostOnly` so users
|
||||||
|
/// always get Instruments-visible signposts unless they explicitly
|
||||||
|
/// turn them off.
|
||||||
|
public static func currentMode(_ defaults: UserDefaults = .standard) -> Mode {
|
||||||
|
if let raw = defaults.string(forKey: userDefaultsKey),
|
||||||
|
let mode = Mode(rawValue: raw) {
|
||||||
|
return mode
|
||||||
|
}
|
||||||
|
return .signpostOnly
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Persist a new mode and reinstall the backend set.
|
||||||
|
public static func setMode(_ mode: Mode, _ defaults: UserDefaults = .standard) {
|
||||||
|
defaults.set(mode.rawValue, forKey: userDefaultsKey)
|
||||||
|
configure(mode: mode)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Install the backend set for a given mode. Returns the active ring
|
||||||
|
/// buffer (if any) so the in-app Diagnostics panel can read from it.
|
||||||
|
@discardableResult
|
||||||
|
public static func configure(mode: Mode) -> ScarfMonRingBuffer? {
|
||||||
|
switch mode {
|
||||||
|
case .off:
|
||||||
|
ScarfMon.install([])
|
||||||
|
sharedRingBuffer = nil
|
||||||
|
return nil
|
||||||
|
case .signpostOnly:
|
||||||
|
ScarfMon.install([ScarfMonSignpostBackend()])
|
||||||
|
sharedRingBuffer = nil
|
||||||
|
return nil
|
||||||
|
case .full:
|
||||||
|
let ring = ScarfMonRingBuffer()
|
||||||
|
sharedRingBuffer = ring
|
||||||
|
ScarfMon.install([
|
||||||
|
ScarfMonSignpostBackend(),
|
||||||
|
ring,
|
||||||
|
ScarfMonLoggerBackend()
|
||||||
|
])
|
||||||
|
return ring
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Process-wide ring buffer when running in `.full` mode. Nil otherwise.
|
||||||
|
/// Read by the Diagnostics panel; writes happen through the backend
|
||||||
|
/// dispatcher so this property is read-only.
|
||||||
|
///
|
||||||
|
/// `nonisolated(unsafe)` because the value is only mutated by
|
||||||
|
/// `configure(...)` (which itself runs on whichever actor invokes
|
||||||
|
/// the boot helper at app launch — single-writer in practice) and
|
||||||
|
/// read from the panel UI on the main actor. Adding a lock here
|
||||||
|
/// would just add overhead with no real safety win.
|
||||||
|
nonisolated(unsafe) public private(set) static var sharedRingBuffer: ScarfMonRingBuffer?
|
||||||
|
}
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
import Foundation
|
||||||
|
#if canImport(os)
|
||||||
|
import os
|
||||||
|
#endif
|
||||||
|
|
||||||
|
/// `os.Logger`-backed sink. Off by default — opt-in via the Diagnostics
|
||||||
|
/// settings toggle. Writes one `.debug` line per sample at the
|
||||||
|
/// `com.scarf.mon` subsystem, so users can stream the output via
|
||||||
|
/// `log stream --predicate 'subsystem == "com.scarf.mon"'` without
|
||||||
|
/// enabling private-data redaction overrides.
|
||||||
|
///
|
||||||
|
/// Only meaningful for users running their own debug build or with the
|
||||||
|
/// "verbose performance logging" toggle on.
|
||||||
|
public final class ScarfMonLoggerBackend: ScarfMonBackend, @unchecked Sendable {
|
||||||
|
#if canImport(os)
|
||||||
|
private let logger: Logger
|
||||||
|
|
||||||
|
public init(category: String = "perf") {
|
||||||
|
self.logger = Logger(subsystem: "com.scarf.mon", category: category)
|
||||||
|
}
|
||||||
|
|
||||||
|
public func record(_ sample: ScarfMon.Sample) {
|
||||||
|
switch sample.kind {
|
||||||
|
case .interval:
|
||||||
|
// `\(static:)` interpolation keeps the StaticString out of the
|
||||||
|
// private-data redaction path — names are public, durations
|
||||||
|
// are public, the user's content never touches this channel.
|
||||||
|
logger.debug(
|
||||||
|
"\(sample.category.rawValue, privacy: .public) \(sample.name.description, privacy: .public) ms=\(Double(sample.durationNanos) / 1_000_000.0, privacy: .public)"
|
||||||
|
)
|
||||||
|
case .event:
|
||||||
|
logger.debug(
|
||||||
|
"\(sample.category.rawValue, privacy: .public) \(sample.name.description, privacy: .public) count=\(sample.count, privacy: .public) bytes=\(sample.bytes ?? -1, privacy: .public)"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
#else
|
||||||
|
public init(category: String = "perf") {}
|
||||||
|
public func record(_ sample: ScarfMon.Sample) { /* no-op off-Apple */ }
|
||||||
|
#endif
|
||||||
|
}
|
||||||
@@ -0,0 +1,176 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// Fixed-size, lock-protected ring of recent samples. Drives the in-app
|
||||||
|
/// Diagnostics panel and the export-as-JSON button.
|
||||||
|
///
|
||||||
|
/// Capacity is a compile-time choice; 4096 entries × ~80 bytes per sample =
|
||||||
|
/// ~320 KB resident. That's enough for several minutes of streaming-chat
|
||||||
|
/// activity at 200 samples/s without overwriting interesting context.
|
||||||
|
///
|
||||||
|
/// The hot path takes one `os_unfair_lock` per `record`. Aggregation (the
|
||||||
|
/// `summary(...)` reader) builds a fresh dictionary each call — only invoked
|
||||||
|
/// from the panel UI, which polls at a human cadence.
|
||||||
|
public final class ScarfMonRingBuffer: ScarfMonBackend, @unchecked Sendable {
|
||||||
|
public let capacity: Int
|
||||||
|
|
||||||
|
private let lock = ScarfMonLock()
|
||||||
|
private var storage: [ScarfMon.Sample?]
|
||||||
|
/// Next write index. Wraps around `capacity` so the buffer never grows.
|
||||||
|
private var head: Int = 0
|
||||||
|
/// True once we've wrapped at least once — switches the read order from
|
||||||
|
/// `[0..<head]` to `[head..<capacity] + [0..<head]`.
|
||||||
|
private var didWrap: Bool = false
|
||||||
|
|
||||||
|
public init(capacity: Int = 4096) {
|
||||||
|
precondition(capacity > 0, "ring buffer needs a positive capacity")
|
||||||
|
self.capacity = capacity
|
||||||
|
self.storage = Array(repeating: nil, count: capacity)
|
||||||
|
}
|
||||||
|
|
||||||
|
public func record(_ sample: ScarfMon.Sample) {
|
||||||
|
lock.lock()
|
||||||
|
defer { lock.unlock() }
|
||||||
|
storage[head] = sample
|
||||||
|
head += 1
|
||||||
|
if head >= capacity {
|
||||||
|
head = 0
|
||||||
|
didWrap = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Snapshot of all currently-resident samples in chronological order.
|
||||||
|
public func samples() -> [ScarfMon.Sample] {
|
||||||
|
lock.lock()
|
||||||
|
defer { lock.unlock() }
|
||||||
|
if !didWrap {
|
||||||
|
return storage[0..<head].compactMap { $0 }
|
||||||
|
}
|
||||||
|
let tail = storage[head..<capacity].compactMap { $0 }
|
||||||
|
let leading = storage[0..<head].compactMap { $0 }
|
||||||
|
return tail + leading
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Wipe the buffer. Used by the "Reset" button in the Diagnostics
|
||||||
|
/// panel and at the top of every test case.
|
||||||
|
public func reset() {
|
||||||
|
lock.lock()
|
||||||
|
defer { lock.unlock() }
|
||||||
|
for i in 0..<capacity { storage[i] = nil }
|
||||||
|
head = 0
|
||||||
|
didWrap = false
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Aggregated stats over the current buffer. Buckets by
|
||||||
|
/// `(category, name)`; computes count, total nanos, mean, p50, p95.
|
||||||
|
public func summary() -> [ScarfMonStat] {
|
||||||
|
let snapshot = samples()
|
||||||
|
var buckets: [BucketKey: [UInt64]] = [:]
|
||||||
|
var counts: [BucketKey: Int] = [:]
|
||||||
|
var byteTotals: [BucketKey: Int] = [:]
|
||||||
|
var kinds: [BucketKey: ScarfMon.Sample.Kind] = [:]
|
||||||
|
|
||||||
|
for sample in snapshot {
|
||||||
|
let key = BucketKey(category: sample.category, name: sample.name.description)
|
||||||
|
kinds[key] = sample.kind
|
||||||
|
counts[key, default: 0] += sample.count
|
||||||
|
if let b = sample.bytes { byteTotals[key, default: 0] += b }
|
||||||
|
if sample.kind == .interval {
|
||||||
|
buckets[key, default: []].append(sample.durationNanos)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var stats: [ScarfMonStat] = []
|
||||||
|
for (key, _) in counts {
|
||||||
|
let durations = buckets[key] ?? []
|
||||||
|
let kind = kinds[key] ?? .event
|
||||||
|
stats.append(ScarfMonStat(
|
||||||
|
category: key.category,
|
||||||
|
name: key.name,
|
||||||
|
kind: kind,
|
||||||
|
count: counts[key] ?? 0,
|
||||||
|
totalNanos: durations.reduce(0, &+),
|
||||||
|
p50Nanos: percentile(durations, 0.50),
|
||||||
|
p95Nanos: percentile(durations, 0.95),
|
||||||
|
maxNanos: durations.max() ?? 0,
|
||||||
|
totalBytes: byteTotals[key] ?? 0
|
||||||
|
))
|
||||||
|
}
|
||||||
|
stats.sort { $0.p95Nanos > $1.p95Nanos }
|
||||||
|
return stats
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct BucketKey: Hashable {
|
||||||
|
let category: ScarfMon.Category
|
||||||
|
let name: String
|
||||||
|
}
|
||||||
|
|
||||||
|
private func percentile(_ values: [UInt64], _ p: Double) -> UInt64 {
|
||||||
|
guard !values.isEmpty else { return 0 }
|
||||||
|
let sorted = values.sorted()
|
||||||
|
// Nearest-rank percentile — good enough for triage and avoids
|
||||||
|
// interpolation edge cases on tiny samples.
|
||||||
|
let rank = max(1, min(sorted.count, Int((p * Double(sorted.count)).rounded(.up))))
|
||||||
|
return sorted[rank - 1]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Per-bucket stats surfaced to the in-app panel.
|
||||||
|
public struct ScarfMonStat: Sendable, Hashable, Codable {
|
||||||
|
public let category: ScarfMon.Category
|
||||||
|
public let name: String
|
||||||
|
public let kind: ScarfMon.Sample.Kind
|
||||||
|
public let count: Int
|
||||||
|
public let totalNanos: UInt64
|
||||||
|
public let p50Nanos: UInt64
|
||||||
|
public let p95Nanos: UInt64
|
||||||
|
public let maxNanos: UInt64
|
||||||
|
public let totalBytes: Int
|
||||||
|
|
||||||
|
public var totalMs: Double { Double(totalNanos) / 1_000_000.0 }
|
||||||
|
public var p50Ms: Double { Double(p50Nanos) / 1_000_000.0 }
|
||||||
|
public var p95Ms: Double { Double(p95Nanos) / 1_000_000.0 }
|
||||||
|
public var maxMs: Double { Double(maxNanos) / 1_000_000.0 }
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - JSON export
|
||||||
|
|
||||||
|
extension ScarfMonRingBuffer {
|
||||||
|
/// Compact JSON dump for the "Copy as JSON" button. One line per sample
|
||||||
|
/// keeps the output greppable when the user pastes it into a feedback
|
||||||
|
/// thread.
|
||||||
|
public func exportJSON() -> String {
|
||||||
|
struct Wire: Codable {
|
||||||
|
let category: String
|
||||||
|
let name: String
|
||||||
|
let kind: String
|
||||||
|
let timestampMs: Double
|
||||||
|
let durationNanos: UInt64
|
||||||
|
let count: Int
|
||||||
|
let bytes: Int?
|
||||||
|
}
|
||||||
|
let snapshot = samples()
|
||||||
|
let encoder = JSONEncoder()
|
||||||
|
encoder.outputFormatting = [.sortedKeys]
|
||||||
|
var lines: [String] = []
|
||||||
|
lines.reserveCapacity(snapshot.count + 1)
|
||||||
|
lines.append("[")
|
||||||
|
for (i, s) in snapshot.enumerated() {
|
||||||
|
let wire = Wire(
|
||||||
|
category: s.category.rawValue,
|
||||||
|
name: s.name.description,
|
||||||
|
kind: s.kind.rawValue,
|
||||||
|
timestampMs: s.timestamp.timeIntervalSince1970 * 1000,
|
||||||
|
durationNanos: s.durationNanos,
|
||||||
|
count: s.count,
|
||||||
|
bytes: s.bytes
|
||||||
|
)
|
||||||
|
if let data = try? encoder.encode(wire),
|
||||||
|
let line = String(data: data, encoding: .utf8) {
|
||||||
|
let suffix = i == snapshot.count - 1 ? "" : ","
|
||||||
|
lines.append(" " + line + suffix)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
lines.append("]")
|
||||||
|
return lines.joined(separator: "\n")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
import Foundation
|
||||||
|
#if canImport(os)
|
||||||
|
import os
|
||||||
|
import os.signpost
|
||||||
|
#endif
|
||||||
|
|
||||||
|
/// Always-on signpost backend. Emits an `os_signpost` event per sample so
|
||||||
|
/// users can attach Instruments and see Scarf's instrumentation in the
|
||||||
|
/// Points of Interest track without a debug build.
|
||||||
|
///
|
||||||
|
/// `os_signpost` is elided by the runtime when no Instruments session is
|
||||||
|
/// recording the relevant subsystem — the backend pays the cost of one
|
||||||
|
/// `OSLog` lookup per emit and nothing else.
|
||||||
|
public final class ScarfMonSignpostBackend: ScarfMonBackend, @unchecked Sendable {
|
||||||
|
#if canImport(os)
|
||||||
|
private let log: OSLog
|
||||||
|
|
||||||
|
public init(subsystem: String = "com.scarf.mon") {
|
||||||
|
self.log = OSLog(subsystem: subsystem, category: .pointsOfInterest)
|
||||||
|
}
|
||||||
|
|
||||||
|
public func record(_ sample: ScarfMon.Sample) {
|
||||||
|
// Signposts want a `StaticString` name — we already require
|
||||||
|
// exactly that on the API. Format string is also static; the
|
||||||
|
// dynamic values flow as printf-style args, so no allocations
|
||||||
|
// for the event name itself.
|
||||||
|
switch sample.kind {
|
||||||
|
case .interval:
|
||||||
|
os_signpost(
|
||||||
|
.event,
|
||||||
|
log: log,
|
||||||
|
name: sample.name,
|
||||||
|
"category=%{public}@ ms=%{public}.3f count=%d",
|
||||||
|
sample.category.rawValue,
|
||||||
|
Double(sample.durationNanos) / 1_000_000.0,
|
||||||
|
sample.count
|
||||||
|
)
|
||||||
|
case .event:
|
||||||
|
os_signpost(
|
||||||
|
.event,
|
||||||
|
log: log,
|
||||||
|
name: sample.name,
|
||||||
|
"category=%{public}@ count=%d bytes=%d",
|
||||||
|
sample.category.rawValue,
|
||||||
|
sample.count,
|
||||||
|
sample.bytes ?? -1
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
#else
|
||||||
|
public init(subsystem: String = "com.scarf.mon") {}
|
||||||
|
public func record(_ sample: ScarfMon.Sample) { /* no-op off-Apple */ }
|
||||||
|
#endif
|
||||||
|
}
|
||||||
@@ -8,15 +8,25 @@ import Foundation
|
|||||||
// decoded inside `ACPClient`'s actor context (the JSON-RPC read/write loop).
|
// decoded inside `ACPClient`'s actor context (the JSON-RPC read/write loop).
|
||||||
// The member list must stay in sync with the stored properties above.
|
// The member list must stay in sync with the stored properties above.
|
||||||
|
|
||||||
struct ACPRequest: Encodable, Sendable {
|
public struct ACPRequest: Encodable, Sendable {
|
||||||
nonisolated let jsonrpc = "2.0"
|
public nonisolated let jsonrpc = "2.0"
|
||||||
nonisolated let id: Int
|
public nonisolated let id: Int
|
||||||
nonisolated let method: String
|
public nonisolated let method: String
|
||||||
nonisolated let params: [String: AnyCodable]
|
public nonisolated let params: [String: AnyCodable]
|
||||||
|
|
||||||
enum CodingKeys: String, CodingKey { case jsonrpc, id, method, params }
|
|
||||||
|
|
||||||
nonisolated func encode(to encoder: any Encoder) throws {
|
public init(
|
||||||
|
id: Int,
|
||||||
|
method: String,
|
||||||
|
params: [String: AnyCodable]
|
||||||
|
) {
|
||||||
|
self.id = id
|
||||||
|
self.method = method
|
||||||
|
self.params = params
|
||||||
|
}
|
||||||
|
public enum CodingKeys: String, CodingKey { case jsonrpc, id, method, params }
|
||||||
|
|
||||||
|
public nonisolated func encode(to encoder: any Encoder) throws {
|
||||||
var c = encoder.container(keyedBy: CodingKeys.self)
|
var c = encoder.container(keyedBy: CodingKeys.self)
|
||||||
try c.encode(jsonrpc, forKey: .jsonrpc)
|
try c.encode(jsonrpc, forKey: .jsonrpc)
|
||||||
try c.encode(id, forKey: .id)
|
try c.encode(id, forKey: .id)
|
||||||
@@ -25,21 +35,21 @@ struct ACPRequest: Encodable, Sendable {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
struct ACPRawMessage: Decodable, Sendable {
|
public struct ACPRawMessage: Decodable, Sendable {
|
||||||
nonisolated let jsonrpc: String?
|
public nonisolated let jsonrpc: String?
|
||||||
nonisolated let id: Int?
|
public nonisolated let id: Int?
|
||||||
nonisolated let method: String?
|
public nonisolated let method: String?
|
||||||
nonisolated let result: AnyCodable?
|
public nonisolated let result: AnyCodable?
|
||||||
nonisolated let error: ACPError?
|
public nonisolated let error: ACPError?
|
||||||
nonisolated let params: AnyCodable?
|
public nonisolated let params: AnyCodable?
|
||||||
|
|
||||||
nonisolated var isResponse: Bool { id != nil && method == nil }
|
public nonisolated var isResponse: Bool { id != nil && method == nil }
|
||||||
nonisolated var isNotification: Bool { method != nil && id == nil }
|
public nonisolated var isNotification: Bool { method != nil && id == nil }
|
||||||
nonisolated var isRequest: Bool { method != nil && id != nil }
|
public nonisolated var isRequest: Bool { method != nil && id != nil }
|
||||||
|
|
||||||
enum CodingKeys: String, CodingKey { case jsonrpc, id, method, result, error, params }
|
public enum CodingKeys: String, CodingKey { case jsonrpc, id, method, result, error, params }
|
||||||
|
|
||||||
nonisolated init(from decoder: any Decoder) throws {
|
public nonisolated init(from decoder: any Decoder) throws {
|
||||||
let c = try decoder.container(keyedBy: CodingKeys.self)
|
let c = try decoder.container(keyedBy: CodingKeys.self)
|
||||||
self.jsonrpc = try c.decodeIfPresent(String.self, forKey: .jsonrpc)
|
self.jsonrpc = try c.decodeIfPresent(String.self, forKey: .jsonrpc)
|
||||||
self.id = try c.decodeIfPresent(Int.self, forKey: .id)
|
self.id = try c.decodeIfPresent(Int.self, forKey: .id)
|
||||||
@@ -50,13 +60,13 @@ struct ACPRawMessage: Decodable, Sendable {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
struct ACPError: Decodable, Sendable {
|
public struct ACPError: Decodable, Sendable {
|
||||||
nonisolated let code: Int
|
public nonisolated let code: Int
|
||||||
nonisolated let message: String
|
public nonisolated let message: String
|
||||||
|
|
||||||
enum CodingKeys: String, CodingKey { case code, message }
|
public enum CodingKeys: String, CodingKey { case code, message }
|
||||||
|
|
||||||
nonisolated init(from decoder: any Decoder) throws {
|
public nonisolated init(from decoder: any Decoder) throws {
|
||||||
let c = try decoder.container(keyedBy: CodingKeys.self)
|
let c = try decoder.container(keyedBy: CodingKeys.self)
|
||||||
self.code = try c.decode(Int.self, forKey: .code)
|
self.code = try c.decode(Int.self, forKey: .code)
|
||||||
self.message = try c.decode(String.self, forKey: .message)
|
self.message = try c.decode(String.self, forKey: .message)
|
||||||
@@ -65,10 +75,10 @@ struct ACPError: Decodable, Sendable {
|
|||||||
|
|
||||||
// MARK: - AnyCodable (for dynamic JSON)
|
// MARK: - AnyCodable (for dynamic JSON)
|
||||||
|
|
||||||
struct AnyCodable: Codable, @unchecked Sendable {
|
public struct AnyCodable: Codable, @unchecked Sendable {
|
||||||
nonisolated let value: Any
|
public nonisolated let value: Any
|
||||||
|
|
||||||
nonisolated init(_ value: Any) { self.value = value }
|
public nonisolated init(_ value: Any) { self.value = value }
|
||||||
|
|
||||||
// NOT marked `nonisolated`: Swift's default-isolation treats writes to a
|
// NOT marked `nonisolated`: Swift's default-isolation treats writes to a
|
||||||
// `let value: Any` stored property as MainActor-isolated even when the
|
// `let value: Any` stored property as MainActor-isolated even when the
|
||||||
@@ -78,7 +88,7 @@ struct AnyCodable: Codable, @unchecked Sendable {
|
|||||||
// conformance is still usable from ACPClient's nonisolated read loop
|
// conformance is still usable from ACPClient's nonisolated read loop
|
||||||
// because all callers are already @preconcurrency with respect to
|
// because all callers are already @preconcurrency with respect to
|
||||||
// `AnyCodable` (it's @unchecked Sendable).
|
// `AnyCodable` (it's @unchecked Sendable).
|
||||||
init(from decoder: any Decoder) throws {
|
public init(from decoder: any Decoder) throws {
|
||||||
let container = try decoder.singleValueContainer()
|
let container = try decoder.singleValueContainer()
|
||||||
if container.decodeNil() {
|
if container.decodeNil() {
|
||||||
value = NSNull()
|
value = NSNull()
|
||||||
@@ -99,7 +109,7 @@ struct AnyCodable: Codable, @unchecked Sendable {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func encode(to encoder: any Encoder) throws {
|
public func encode(to encoder: any Encoder) throws {
|
||||||
var container = encoder.singleValueContainer()
|
var container = encoder.singleValueContainer()
|
||||||
switch value {
|
switch value {
|
||||||
case is NSNull:
|
case is NSNull:
|
||||||
@@ -123,15 +133,15 @@ struct AnyCodable: Codable, @unchecked Sendable {
|
|||||||
|
|
||||||
// MARK: - Accessors
|
// MARK: - Accessors
|
||||||
|
|
||||||
nonisolated var stringValue: String? { value as? String }
|
public nonisolated var stringValue: String? { value as? String }
|
||||||
nonisolated var intValue: Int? { value as? Int }
|
public nonisolated var intValue: Int? { value as? Int }
|
||||||
nonisolated var dictValue: [String: Any]? { value as? [String: Any] }
|
public nonisolated var dictValue: [String: Any]? { value as? [String: Any] }
|
||||||
nonisolated var arrayValue: [Any]? { value as? [Any] }
|
public nonisolated var arrayValue: [Any]? { value as? [Any] }
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - ACP Events (parsed from session/update notifications)
|
// MARK: - ACP Events (parsed from session/update notifications)
|
||||||
|
|
||||||
enum ACPEvent: Sendable {
|
public enum ACPEvent: Sendable {
|
||||||
case messageChunk(sessionId: String, text: String)
|
case messageChunk(sessionId: String, text: String)
|
||||||
case thoughtChunk(sessionId: String, text: String)
|
case thoughtChunk(sessionId: String, text: String)
|
||||||
case toolCallStart(sessionId: String, call: ACPToolCallEvent)
|
case toolCallStart(sessionId: String, call: ACPToolCallEvent)
|
||||||
@@ -143,21 +153,37 @@ enum ACPEvent: Sendable {
|
|||||||
case unknown(sessionId: String, type: String)
|
case unknown(sessionId: String, type: String)
|
||||||
}
|
}
|
||||||
|
|
||||||
struct ACPToolCallEvent: Sendable {
|
public struct ACPToolCallEvent: Sendable {
|
||||||
let toolCallId: String
|
public let toolCallId: String
|
||||||
let title: String
|
public let title: String
|
||||||
let kind: String
|
public let kind: String
|
||||||
let status: String
|
public let status: String
|
||||||
let content: String
|
public let content: String
|
||||||
let rawInput: [String: Any]?
|
public let rawInput: [String: Any]?
|
||||||
|
|
||||||
var functionName: String {
|
|
||||||
|
public init(
|
||||||
|
toolCallId: String,
|
||||||
|
title: String,
|
||||||
|
kind: String,
|
||||||
|
status: String,
|
||||||
|
content: String,
|
||||||
|
rawInput: [String: Any]?
|
||||||
|
) {
|
||||||
|
self.toolCallId = toolCallId
|
||||||
|
self.title = title
|
||||||
|
self.kind = kind
|
||||||
|
self.status = status
|
||||||
|
self.content = content
|
||||||
|
self.rawInput = rawInput
|
||||||
|
}
|
||||||
|
public var functionName: String {
|
||||||
// title format is "functionName: summary" or just "functionName"
|
// title format is "functionName: summary" or just "functionName"
|
||||||
let parts = title.split(separator: ":", maxSplits: 1)
|
let parts = title.split(separator: ":", maxSplits: 1)
|
||||||
return String(parts.first ?? Substring(title)).trimmingCharacters(in: .whitespaces)
|
return String(parts.first ?? Substring(title)).trimmingCharacters(in: .whitespaces)
|
||||||
}
|
}
|
||||||
|
|
||||||
var argumentsSummary: String {
|
public var argumentsSummary: String {
|
||||||
let parts = title.split(separator: ":", maxSplits: 1)
|
let parts = title.split(separator: ":", maxSplits: 1)
|
||||||
if parts.count > 1 {
|
if parts.count > 1 {
|
||||||
return String(parts[1]).trimmingCharacters(in: .whitespaces)
|
return String(parts[1]).trimmingCharacters(in: .whitespaces)
|
||||||
@@ -165,7 +191,7 @@ struct ACPToolCallEvent: Sendable {
|
|||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
|
|
||||||
var argumentsJSON: String {
|
public var argumentsJSON: String {
|
||||||
guard let input = rawInput,
|
guard let input = rawInput,
|
||||||
let data = try? JSONSerialization.data(withJSONObject: input),
|
let data = try? JSONSerialization.data(withJSONObject: input),
|
||||||
let str = String(data: data, encoding: .utf8) else { return "{}" }
|
let str = String(data: data, encoding: .utf8) else { return "{}" }
|
||||||
@@ -173,32 +199,70 @@ struct ACPToolCallEvent: Sendable {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
struct ACPToolCallUpdateEvent: Sendable {
|
public struct ACPToolCallUpdateEvent: Sendable {
|
||||||
let toolCallId: String
|
public let toolCallId: String
|
||||||
let kind: String
|
public let kind: String
|
||||||
let status: String
|
public let status: String
|
||||||
let content: String
|
public let content: String
|
||||||
let rawOutput: String?
|
public let rawOutput: String?
|
||||||
|
|
||||||
|
public init(
|
||||||
|
toolCallId: String,
|
||||||
|
kind: String,
|
||||||
|
status: String,
|
||||||
|
content: String,
|
||||||
|
rawOutput: String?
|
||||||
|
) {
|
||||||
|
self.toolCallId = toolCallId
|
||||||
|
self.kind = kind
|
||||||
|
self.status = status
|
||||||
|
self.content = content
|
||||||
|
self.rawOutput = rawOutput
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
struct ACPPermissionRequestEvent: Sendable {
|
public struct ACPPermissionRequestEvent: Sendable {
|
||||||
let toolCallTitle: String
|
public let toolCallTitle: String
|
||||||
let toolCallKind: String
|
public let toolCallKind: String
|
||||||
let options: [(optionId: String, name: String)]
|
public let options: [(optionId: String, name: String)]
|
||||||
|
|
||||||
|
public init(
|
||||||
|
toolCallTitle: String,
|
||||||
|
toolCallKind: String,
|
||||||
|
options: [(optionId: String, name: String)]
|
||||||
|
) {
|
||||||
|
self.toolCallTitle = toolCallTitle
|
||||||
|
self.toolCallKind = toolCallKind
|
||||||
|
self.options = options
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
struct ACPPromptResult: Sendable {
|
public struct ACPPromptResult: Sendable {
|
||||||
let stopReason: String
|
public let stopReason: String
|
||||||
let inputTokens: Int
|
public let inputTokens: Int
|
||||||
let outputTokens: Int
|
public let outputTokens: Int
|
||||||
let thoughtTokens: Int
|
public let thoughtTokens: Int
|
||||||
let cachedReadTokens: Int
|
public let cachedReadTokens: Int
|
||||||
|
|
||||||
|
public init(
|
||||||
|
stopReason: String,
|
||||||
|
inputTokens: Int,
|
||||||
|
outputTokens: Int,
|
||||||
|
thoughtTokens: Int,
|
||||||
|
cachedReadTokens: Int
|
||||||
|
) {
|
||||||
|
self.stopReason = stopReason
|
||||||
|
self.inputTokens = inputTokens
|
||||||
|
self.outputTokens = outputTokens
|
||||||
|
self.thoughtTokens = thoughtTokens
|
||||||
|
self.cachedReadTokens = cachedReadTokens
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Event Parsing
|
// MARK: - Event Parsing
|
||||||
|
|
||||||
enum ACPEventParser {
|
public enum ACPEventParser {
|
||||||
nonisolated static func parse(notification: ACPRawMessage) -> ACPEvent? {
|
public nonisolated static func parse(notification: ACPRawMessage) -> ACPEvent? {
|
||||||
guard notification.method == "session/update",
|
guard notification.method == "session/update",
|
||||||
let params = notification.params?.dictValue,
|
let params = notification.params?.dictValue,
|
||||||
let sessionId = params["sessionId"] as? String,
|
let sessionId = params["sessionId"] as? String,
|
||||||
@@ -246,7 +310,7 @@ enum ACPEventParser {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
nonisolated static func parsePermissionRequest(_ message: ACPRawMessage) -> ACPEvent? {
|
public nonisolated static func parsePermissionRequest(_ message: ACPRawMessage) -> ACPEvent? {
|
||||||
guard message.method == "session/request_permission",
|
guard message.method == "session/request_permission",
|
||||||
let params = message.params?.dictValue,
|
let params = message.params?.dictValue,
|
||||||
let sessionId = params["sessionId"] as? String,
|
let sessionId = params["sessionId"] as? String,
|
||||||
@@ -0,0 +1,183 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// Top-level manifest for a `.scarfbackup` archive.
|
||||||
|
///
|
||||||
|
/// **Archive layout** (`.scarfbackup` is a plain ZIP):
|
||||||
|
/// ```
|
||||||
|
/// <name>.scarfbackup
|
||||||
|
/// ├── manifest.json — this struct, JSON-encoded
|
||||||
|
/// ├── hermes.tar.gz — gzipped tar of `~/.hermes/` (minus exclusions)
|
||||||
|
/// └── projects/
|
||||||
|
/// ├── <project-id>.tar.gz — one inner tarball per registered project
|
||||||
|
/// └── ...
|
||||||
|
/// ```
|
||||||
|
///
|
||||||
|
/// **Why two layers (outer ZIP + inner tarballs).** The inner tarballs are
|
||||||
|
/// produced by streaming `tar -czf - …` over SSH — that's the only way to
|
||||||
|
/// keep memory bounded for multi-GB hermes homes. The outer ZIP exists so
|
||||||
|
/// the manifest sits at a fixed, easy-to-inspect location and so users on
|
||||||
|
/// macOS can double-click in Finder and see the structure. ZIP also has a
|
||||||
|
/// central directory at the end, which makes "validate without extracting"
|
||||||
|
/// cheap.
|
||||||
|
///
|
||||||
|
/// **What rides along.** Hermes home (state.db + sessions + skills + cron +
|
||||||
|
/// memories + scarf sidecars + plugins/profiles), each project's full file
|
||||||
|
/// tree (the user's code), and the manifest itself. **What does NOT ride
|
||||||
|
/// along by default**: `auth.json` (provider credentials), `mcp-tokens/`
|
||||||
|
/// (per-host OAuth bearer tokens), `logs/` (size, low restore value),
|
||||||
|
/// `state.db-wal` / `state.db-shm` (in-flight WAL siblings — we checkpoint
|
||||||
|
/// before the archive). The `options` block records exactly which
|
||||||
|
/// exclusions were applied so the restore flow can warn the user.
|
||||||
|
public struct BackupManifest: Codable, Sendable, Equatable {
|
||||||
|
/// Bumped when the on-disk shape changes incompatibly. v1 is the only
|
||||||
|
/// shape today; restores refuse anything they don't recognize.
|
||||||
|
public var schemaVersion: Int
|
||||||
|
/// Magic string. Lets a future Scarf reject `.zip` files that aren't
|
||||||
|
/// our backups before unpacking them as if they were.
|
||||||
|
public var kind: String
|
||||||
|
/// ISO-8601 UTC timestamp the archive was produced.
|
||||||
|
public var createdAt: String
|
||||||
|
/// Identifies the server the backup came from. The display name is for
|
||||||
|
/// the restore preview sheet; serverID is for de-dupe and lineage.
|
||||||
|
public var source: Source
|
||||||
|
/// Hermes home tree metadata. Always present (even an empty Hermes
|
||||||
|
/// install ships an empty tarball — the restore replaces nothing
|
||||||
|
/// rather than refusing).
|
||||||
|
public var hermes: HermesTree
|
||||||
|
/// One entry per registered project at backup time. Empty array
|
||||||
|
/// when the user never registered any projects.
|
||||||
|
public var projects: [ProjectEntry]
|
||||||
|
/// What was included / excluded from the Hermes tree. Flagged so the
|
||||||
|
/// restore preview honestly reports "auth.json was not in this
|
||||||
|
/// backup — you'll re-authenticate after restore".
|
||||||
|
public var options: Options
|
||||||
|
|
||||||
|
public init(
|
||||||
|
schemaVersion: Int = BackupManifest.currentSchemaVersion,
|
||||||
|
kind: String = BackupManifest.kindMagic,
|
||||||
|
createdAt: String,
|
||||||
|
source: Source,
|
||||||
|
hermes: HermesTree,
|
||||||
|
projects: [ProjectEntry],
|
||||||
|
options: Options
|
||||||
|
) {
|
||||||
|
self.schemaVersion = schemaVersion
|
||||||
|
self.kind = kind
|
||||||
|
self.createdAt = createdAt
|
||||||
|
self.source = source
|
||||||
|
self.hermes = hermes
|
||||||
|
self.projects = projects
|
||||||
|
self.options = options
|
||||||
|
}
|
||||||
|
|
||||||
|
public static let currentSchemaVersion = 1
|
||||||
|
public static let kindMagic = "scarf-server-backup"
|
||||||
|
|
||||||
|
public struct Source: Codable, Sendable, Equatable {
|
||||||
|
public var serverID: String
|
||||||
|
public var displayName: String
|
||||||
|
public var host: String
|
||||||
|
public var user: String?
|
||||||
|
/// Output of `hermes --version` on the source host at backup
|
||||||
|
/// time. Restore warns if the target installs an older version
|
||||||
|
/// (state.db schema differences could break things silently).
|
||||||
|
public var hermesVersion: String?
|
||||||
|
|
||||||
|
public init(serverID: String, displayName: String, host: String, user: String?, hermesVersion: String?) {
|
||||||
|
self.serverID = serverID
|
||||||
|
self.displayName = displayName
|
||||||
|
self.host = host
|
||||||
|
self.user = user
|
||||||
|
self.hermesVersion = hermesVersion
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public struct HermesTree: Codable, Sendable, Equatable {
|
||||||
|
/// Absolute path of `~/.hermes/` on the source host (e.g.
|
||||||
|
/// `/root/.hermes` or `/home/alan/.hermes`). Used by restore to
|
||||||
|
/// detect path drift when targeting a different user account.
|
||||||
|
public var homePath: String
|
||||||
|
/// Path inside the outer ZIP (always `hermes.tar.gz`).
|
||||||
|
public var tarballPath: String
|
||||||
|
/// Compressed bytes — for the preview sheet's size summary.
|
||||||
|
public var tarballSize: Int64
|
||||||
|
/// Hex SHA-256 of the inner tarball. Restore verifies before
|
||||||
|
/// extracting; corruption surfaces as a single bad path
|
||||||
|
/// rather than a half-extracted home.
|
||||||
|
public var tarballSHA256: String
|
||||||
|
|
||||||
|
public init(homePath: String, tarballPath: String, tarballSize: Int64, tarballSHA256: String) {
|
||||||
|
self.homePath = homePath
|
||||||
|
self.tarballPath = tarballPath
|
||||||
|
self.tarballSize = tarballSize
|
||||||
|
self.tarballSHA256 = tarballSHA256
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public struct ProjectEntry: Codable, Sendable, Equatable {
|
||||||
|
/// Stable UUID for the project. Used to namespace the inner
|
||||||
|
/// tarball so a project with `name = "scratch"` in two
|
||||||
|
/// different directories doesn't collide.
|
||||||
|
public var id: String
|
||||||
|
public var name: String
|
||||||
|
/// Absolute path on the source host. Restore re-anchors this if
|
||||||
|
/// the target has a different home (e.g. backup from `/root`,
|
||||||
|
/// restore to `/home/ubuntu`).
|
||||||
|
public var path: String
|
||||||
|
/// Path inside the outer ZIP (e.g. `projects/<id>.tar.gz`).
|
||||||
|
public var tarballPath: String
|
||||||
|
public var tarballSize: Int64
|
||||||
|
public var tarballSHA256: String
|
||||||
|
|
||||||
|
public init(id: String, name: String, path: String, tarballPath: String, tarballSize: Int64, tarballSHA256: String) {
|
||||||
|
self.id = id
|
||||||
|
self.name = name
|
||||||
|
self.path = path
|
||||||
|
self.tarballPath = tarballPath
|
||||||
|
self.tarballSize = tarballSize
|
||||||
|
self.tarballSHA256 = tarballSHA256
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public struct Options: Codable, Sendable, Equatable {
|
||||||
|
public var includeAuth: Bool
|
||||||
|
public var includeMcpTokens: Bool
|
||||||
|
public var includeLogs: Bool
|
||||||
|
/// True if `sqlite3 PRAGMA wal_checkpoint(TRUNCATE)` was run on
|
||||||
|
/// the remote before tarballing the Hermes home. False means the
|
||||||
|
/// archive may contain a `state.db` mid-write — usually fine
|
||||||
|
/// (SQLite tolerates restarted reads from a quiesced DB) but
|
||||||
|
/// flagged for forensics.
|
||||||
|
public var checkpointedWAL: Bool
|
||||||
|
|
||||||
|
public init(includeAuth: Bool, includeMcpTokens: Bool, includeLogs: Bool, checkpointedWAL: Bool) {
|
||||||
|
self.includeAuth = includeAuth
|
||||||
|
self.includeMcpTokens = includeMcpTokens
|
||||||
|
self.includeLogs = includeLogs
|
||||||
|
self.checkpointedWAL = checkpointedWAL
|
||||||
|
}
|
||||||
|
|
||||||
|
public static let safeDefault = Options(
|
||||||
|
includeAuth: false,
|
||||||
|
includeMcpTokens: false,
|
||||||
|
includeLogs: false,
|
||||||
|
checkpointedWAL: true
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Canonical layout strings — referenced by both the producer and the
|
||||||
|
/// consumer so the on-disk paths stay in sync.
|
||||||
|
public enum BackupArchiveLayout {
|
||||||
|
public static let manifestPath = "manifest.json"
|
||||||
|
public static let hermesTarballPath = "hermes.tar.gz"
|
||||||
|
public static let projectsTarballPrefix = "projects/"
|
||||||
|
public static let archiveExtension = "scarfbackup"
|
||||||
|
|
||||||
|
/// Returns `projects/<id>.tar.gz`. The id is the `ProjectEntry.id`
|
||||||
|
/// (stable UUID), not the project name — names are renamed all the
|
||||||
|
/// time and would collide.
|
||||||
|
public static func projectTarballPath(for id: String) -> String {
|
||||||
|
projectsTarballPrefix + id + ".tar.gz"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// One image attached to an outgoing chat prompt.
|
||||||
|
///
|
||||||
|
/// Hermes v0.12 ACP advertises `prompt_capabilities.image = true` and
|
||||||
|
/// accepts content-block arrays in `session/prompt`. Scarf produces these
|
||||||
|
/// blocks from drag-dropped / pasted / picker-selected images. We
|
||||||
|
/// downsample + JPEG-encode at the producer side so the wire payload
|
||||||
|
/// stays under a few hundred kilobytes per image even when the user
|
||||||
|
/// drops a 12 MP screenshot.
|
||||||
|
///
|
||||||
|
/// Constructed via `ImageEncoder.encode(...)`. The store-the-bytes-once
|
||||||
|
/// shape means `RichChatViewModel` can keep the array between turns
|
||||||
|
/// (e.g. while the agent is responding) without holding `NSImage` /
|
||||||
|
/// `UIImage` references that would pin the originals in memory.
|
||||||
|
public struct ChatImageAttachment: Sendable, Equatable, Identifiable {
|
||||||
|
public let id: String
|
||||||
|
/// IANA MIME type — matches the `mimeType` field on ACP `ImageContentBlock`.
|
||||||
|
/// Currently always `image/jpeg` after re-encoding; PNG-only originals
|
||||||
|
/// keep their type when small enough to skip the JPEG step.
|
||||||
|
public let mimeType: String
|
||||||
|
/// Base64-encoded payload. NOT prefixed with `data:` — Hermes wraps it
|
||||||
|
/// when forwarding to OpenAI multimodal payloads (see
|
||||||
|
/// `_image_block_to_openai_part` in `acp_adapter/server.py`).
|
||||||
|
public let base64Data: String
|
||||||
|
/// Small inline thumbnail for the composer's preview strip. Same MIME
|
||||||
|
/// type as `base64Data`. Nil when the source was already small enough
|
||||||
|
/// to use directly.
|
||||||
|
public let thumbnailBase64: String?
|
||||||
|
/// Original filename, when known (drag-drop carries it; paste doesn't).
|
||||||
|
/// Surfaced as a tooltip on the preview chip.
|
||||||
|
public let filename: String?
|
||||||
|
/// Approximate decoded byte count, kept for the composer's
|
||||||
|
/// "X images, Y KB" status pill.
|
||||||
|
public let approximateByteCount: Int
|
||||||
|
|
||||||
|
public init(
|
||||||
|
id: String = UUID().uuidString,
|
||||||
|
mimeType: String,
|
||||||
|
base64Data: String,
|
||||||
|
thumbnailBase64: String?,
|
||||||
|
filename: String?,
|
||||||
|
approximateByteCount: Int
|
||||||
|
) {
|
||||||
|
self.id = id
|
||||||
|
self.mimeType = mimeType
|
||||||
|
self.base64Data = base64Data
|
||||||
|
self.thumbnailBase64 = thumbnailBase64
|
||||||
|
self.filename = filename
|
||||||
|
self.approximateByteCount = approximateByteCount
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,211 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// Human-readable rendering for `CronSchedule` values.
|
||||||
|
///
|
||||||
|
/// Hermes stores cron schedules with a raw `expression` (`"0 */6 * * *"`)
|
||||||
|
/// plus an optional `display` label. In practice, the CLI writes both
|
||||||
|
/// fields to the same raw cron string — so UIs that render `display`
|
||||||
|
/// verbatim (both Scarf and ScarfGo, pre-fix) end up showing
|
||||||
|
/// `0 */6 * * *` to every user, technical or not.
|
||||||
|
///
|
||||||
|
/// This formatter pattern-matches the most common cron shapes and
|
||||||
|
/// produces English phrases. Anything it doesn't recognise falls back
|
||||||
|
/// to the raw expression with a short hint, so nothing is lost.
|
||||||
|
///
|
||||||
|
/// Not a full cron parser — covers ~95% of real-world schedules while
|
||||||
|
/// staying ~80 lines. Add patterns here as users hit unrecognised
|
||||||
|
/// shapes; the fallback already ships working.
|
||||||
|
public enum CronScheduleFormatter {
|
||||||
|
|
||||||
|
/// Primary entry point. Returns a phrase suitable for the row
|
||||||
|
/// subtitle in Mac + ScarfGo cron lists.
|
||||||
|
public static func humanReadable(from schedule: CronSchedule) -> String {
|
||||||
|
// Trust `display` when it doesn't look like raw cron. Users
|
||||||
|
// CAN set descriptive labels via `hermes cron set-display`;
|
||||||
|
// we don't want to overwrite that.
|
||||||
|
if let display = schedule.display,
|
||||||
|
!display.isEmpty,
|
||||||
|
!looksLikeCron(display)
|
||||||
|
{
|
||||||
|
return display
|
||||||
|
}
|
||||||
|
|
||||||
|
// Use whatever raw expression we have (preferring `expression`,
|
||||||
|
// falling back to `display` since Hermes sometimes writes the
|
||||||
|
// cron into both fields).
|
||||||
|
let expr = schedule.expression ?? schedule.display ?? ""
|
||||||
|
if !expr.isEmpty, let phrase = translate(cronExpression: expr) {
|
||||||
|
return phrase
|
||||||
|
}
|
||||||
|
|
||||||
|
// Non-cron kinds (runAt, interval) get their own branches.
|
||||||
|
switch schedule.kind.lowercased() {
|
||||||
|
case "runat", "run_at":
|
||||||
|
if let runAt = schedule.runAt, !runAt.isEmpty {
|
||||||
|
return "Once on \(runAt)"
|
||||||
|
}
|
||||||
|
return "One-off"
|
||||||
|
case "interval":
|
||||||
|
return schedule.display ?? schedule.expression ?? "Interval"
|
||||||
|
default:
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
// Final fallback: show whatever raw string we have.
|
||||||
|
return expr.isEmpty ? schedule.kind : expr
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Relative next-run phrase (`"in 4 hours"`, `"tomorrow at 9 AM"`).
|
||||||
|
/// `nil` date → `"—"`. Used by both Mac + ScarfGo cron rows.
|
||||||
|
public static func formatNextRun(_ date: Date?, now: Date = Date()) -> String {
|
||||||
|
guard let date else { return "—" }
|
||||||
|
let style = Date.RelativeFormatStyle(
|
||||||
|
presentation: .numeric,
|
||||||
|
unitsStyle: .wide
|
||||||
|
)
|
||||||
|
return date.formatted(style)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Same as `formatNextRun(_:)` but accepts the ISO8601 string
|
||||||
|
/// Hermes stores in `jobs.json`. Attempts several parse strategies
|
||||||
|
/// because Hermes varies the exact serialization between versions
|
||||||
|
/// (with / without fractional seconds, with / without timezone
|
||||||
|
/// offset). On parse failure, falls back to the raw string so we
|
||||||
|
/// never blank out useful info.
|
||||||
|
public static func formatNextRun(iso: String?, now: Date = Date()) -> String {
|
||||||
|
guard let iso, !iso.isEmpty else { return "—" }
|
||||||
|
if let date = Self.isoDate(iso) {
|
||||||
|
return formatNextRun(date, now: now)
|
||||||
|
}
|
||||||
|
return iso
|
||||||
|
}
|
||||||
|
|
||||||
|
nonisolated static func isoDate(_ iso: String) -> Date? {
|
||||||
|
let formatters: [ISO8601DateFormatter] = {
|
||||||
|
let f1 = ISO8601DateFormatter()
|
||||||
|
f1.formatOptions = [.withInternetDateTime]
|
||||||
|
let f2 = ISO8601DateFormatter()
|
||||||
|
f2.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
|
||||||
|
return [f1, f2]
|
||||||
|
}()
|
||||||
|
for f in formatters {
|
||||||
|
if let d = f.date(from: iso) { return d }
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Implementation
|
||||||
|
|
||||||
|
/// True when the string starts with a typical cron token
|
||||||
|
/// (`<digit>`, `*`, `@`). Lets us distinguish a label like
|
||||||
|
/// "Daily release check" from a raw `0 9 * * *` in `display`.
|
||||||
|
nonisolated static func looksLikeCron(_ s: String) -> Bool {
|
||||||
|
let trimmed = s.trimmingCharacters(in: .whitespaces)
|
||||||
|
guard let first = trimmed.first else { return false }
|
||||||
|
if first == "@" { return true } // @hourly, @daily, @weekly
|
||||||
|
if first == "*" { return true } // wildcard in minute
|
||||||
|
if first.isNumber { // "0 ..." etc.
|
||||||
|
// Only consider it cron if the string has at least 4 spaces
|
||||||
|
// (= 5 fields) or starts with a single-digit followed by
|
||||||
|
// space. Short strings like "2:00pm" should stay as labels.
|
||||||
|
let spaces = trimmed.filter { $0 == " " }.count
|
||||||
|
return spaces >= 4
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Translate a raw cron expression into English. Returns nil when
|
||||||
|
/// no pattern matches — caller falls back to the raw string.
|
||||||
|
nonisolated static func translate(cronExpression raw: String) -> String? {
|
||||||
|
let expr = raw.trimmingCharacters(in: .whitespaces)
|
||||||
|
|
||||||
|
// Named macros Hermes / crontab accept as synonyms.
|
||||||
|
switch expr.lowercased() {
|
||||||
|
case "@hourly": return "Every hour"
|
||||||
|
case "@daily", "@midnight": return "Daily at midnight"
|
||||||
|
case "@weekly": return "Weekly (Sunday at midnight)"
|
||||||
|
case "@monthly": return "Monthly (1st at midnight)"
|
||||||
|
case "@yearly", "@annually": return "Yearly (Jan 1 at midnight)"
|
||||||
|
default: break
|
||||||
|
}
|
||||||
|
|
||||||
|
let fields = expr.split(separator: " ", omittingEmptySubsequences: true).map(String.init)
|
||||||
|
guard fields.count == 5 else { return nil }
|
||||||
|
let (min, hr, dom, mon, dow) = (fields[0], fields[1], fields[2], fields[3], fields[4])
|
||||||
|
|
||||||
|
// Every N minutes: */N * * * *
|
||||||
|
if min.hasPrefix("*/"), hr == "*", dom == "*", mon == "*", dow == "*",
|
||||||
|
let n = Int(min.dropFirst(2))
|
||||||
|
{
|
||||||
|
return n == 1 ? "Every minute" : "Every \(n) minutes"
|
||||||
|
}
|
||||||
|
|
||||||
|
// Every hour on minute M: M * * * * (M is a single number)
|
||||||
|
if let _ = Int(min), hr == "*", dom == "*", mon == "*", dow == "*" {
|
||||||
|
return min == "0" ? "Every hour" : "Every hour at :\(zeroPad(min))"
|
||||||
|
}
|
||||||
|
|
||||||
|
// Every N hours at minute M: M */N * * *
|
||||||
|
if let _ = Int(min), hr.hasPrefix("*/"), dom == "*", mon == "*", dow == "*",
|
||||||
|
let n = Int(hr.dropFirst(2))
|
||||||
|
{
|
||||||
|
let minute = min == "0" ? "" : " at :\(zeroPad(min))"
|
||||||
|
return n == 1 ? "Every hour\(minute)" : "Every \(n) hours\(minute)"
|
||||||
|
}
|
||||||
|
|
||||||
|
// Daily at H:MM: MM H * * *
|
||||||
|
if let _ = Int(min), let h = Int(hr), dom == "*", mon == "*", dow == "*" {
|
||||||
|
return "Daily at \(formatClock(hour: h, minute: min))"
|
||||||
|
}
|
||||||
|
|
||||||
|
// Weekdays at H:MM: MM H * * 1-5
|
||||||
|
if let _ = Int(min), let h = Int(hr), dom == "*", mon == "*", dow == "1-5" {
|
||||||
|
return "Weekdays at \(formatClock(hour: h, minute: min))"
|
||||||
|
}
|
||||||
|
|
||||||
|
// Weekends at H:MM: MM H * * 0,6 or 6,0
|
||||||
|
if let _ = Int(min), let h = Int(hr), dom == "*", mon == "*",
|
||||||
|
(dow == "0,6" || dow == "6,0" || dow == "6,7")
|
||||||
|
{
|
||||||
|
return "Weekends at \(formatClock(hour: h, minute: min))"
|
||||||
|
}
|
||||||
|
|
||||||
|
// Single weekday at H:MM: MM H * * <D>
|
||||||
|
if let _ = Int(min), let h = Int(hr), dom == "*", mon == "*",
|
||||||
|
let d = Int(dow), (0...7).contains(d)
|
||||||
|
{
|
||||||
|
return "Every \(weekdayName(d)) at \(formatClock(hour: h, minute: min))"
|
||||||
|
}
|
||||||
|
|
||||||
|
// Monthly on day D at H:MM: MM H D * *
|
||||||
|
if let _ = Int(min), let h = Int(hr), let d = Int(dom), mon == "*", dow == "*" {
|
||||||
|
return "Monthly on day \(d) at \(formatClock(hour: h, minute: min))"
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func zeroPad(_ s: String) -> String {
|
||||||
|
s.count == 1 ? "0" + s : s
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Return "H:MM AM/PM" — 12-hour with no leading zero on the hour,
|
||||||
|
/// to match how iOS natively displays times in most list contexts.
|
||||||
|
private static func formatClock(hour h: Int, minute mStr: String) -> String {
|
||||||
|
let m = Int(mStr) ?? 0
|
||||||
|
var h12 = h % 12
|
||||||
|
if h12 == 0 { h12 = 12 }
|
||||||
|
let suffix = (h < 12) ? "AM" : "PM"
|
||||||
|
if m == 0 {
|
||||||
|
return "\(h12) \(suffix)"
|
||||||
|
}
|
||||||
|
let mm = m < 10 ? "0\(m)" : "\(m)"
|
||||||
|
return "\(h12):\(mm) \(suffix)"
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func weekdayName(_ d: Int) -> String {
|
||||||
|
// Cron convention: 0 and 7 are both Sunday; 1..6 are Mon..Sat.
|
||||||
|
let names = ["Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday", "Sunday"]
|
||||||
|
return names[max(0, min(7, d))]
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,937 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// Settings for one of hermes's auxiliary model tasks (vision, compression, approvals, etc.).
|
||||||
|
/// Every auxiliary task follows the same provider/model/base_url/api_key/timeout pattern.
|
||||||
|
public struct AuxiliaryModel: Sendable, Equatable {
|
||||||
|
public var provider: String
|
||||||
|
public var model: String
|
||||||
|
public var baseURL: String
|
||||||
|
public var apiKey: String
|
||||||
|
public var timeout: Int
|
||||||
|
|
||||||
|
|
||||||
|
public init(
|
||||||
|
provider: String,
|
||||||
|
model: String,
|
||||||
|
baseURL: String,
|
||||||
|
apiKey: String,
|
||||||
|
timeout: Int
|
||||||
|
) {
|
||||||
|
self.provider = provider
|
||||||
|
self.model = model
|
||||||
|
self.baseURL = baseURL
|
||||||
|
self.apiKey = apiKey
|
||||||
|
self.timeout = timeout
|
||||||
|
}
|
||||||
|
public nonisolated static let empty = AuxiliaryModel(provider: "auto", model: "", baseURL: "", apiKey: "", timeout: 30)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Group of display-related settings mirroring the `display:` block in config.yaml.
|
||||||
|
public struct DisplaySettings: Sendable, Equatable {
|
||||||
|
public var skin: String
|
||||||
|
public var compact: Bool
|
||||||
|
public var resumeDisplay: String // "full" | "minimal"
|
||||||
|
public var bellOnComplete: Bool
|
||||||
|
public var inlineDiffs: Bool
|
||||||
|
public var toolProgressCommand: Bool
|
||||||
|
public var toolPreviewLength: Int
|
||||||
|
public var busyInputMode: String // e.g. "interrupt"
|
||||||
|
|
||||||
|
|
||||||
|
public init(
|
||||||
|
skin: String,
|
||||||
|
compact: Bool,
|
||||||
|
resumeDisplay: String,
|
||||||
|
bellOnComplete: Bool,
|
||||||
|
inlineDiffs: Bool,
|
||||||
|
toolProgressCommand: Bool,
|
||||||
|
toolPreviewLength: Int,
|
||||||
|
busyInputMode: String
|
||||||
|
) {
|
||||||
|
self.skin = skin
|
||||||
|
self.compact = compact
|
||||||
|
self.resumeDisplay = resumeDisplay
|
||||||
|
self.bellOnComplete = bellOnComplete
|
||||||
|
self.inlineDiffs = inlineDiffs
|
||||||
|
self.toolProgressCommand = toolProgressCommand
|
||||||
|
self.toolPreviewLength = toolPreviewLength
|
||||||
|
self.busyInputMode = busyInputMode
|
||||||
|
}
|
||||||
|
public nonisolated static let empty = DisplaySettings(
|
||||||
|
skin: "default",
|
||||||
|
compact: false,
|
||||||
|
resumeDisplay: "full",
|
||||||
|
bellOnComplete: false,
|
||||||
|
inlineDiffs: true,
|
||||||
|
toolProgressCommand: false,
|
||||||
|
toolPreviewLength: 0,
|
||||||
|
busyInputMode: "interrupt"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Container/terminal backend options. These map to `terminal.*` keys in config.yaml.
|
||||||
|
public struct TerminalSettings: Sendable, Equatable {
|
||||||
|
public var cwd: String
|
||||||
|
public var timeout: Int
|
||||||
|
public var envPassthrough: [String]
|
||||||
|
public var persistentShell: Bool
|
||||||
|
public var dockerImage: String
|
||||||
|
public var dockerMountCwdToWorkspace: Bool
|
||||||
|
public var dockerForwardEnv: [String]
|
||||||
|
public var dockerVolumes: [String]
|
||||||
|
public var containerCPU: Int // 0 = unlimited
|
||||||
|
public var containerMemory: Int // MB, 0 = unlimited
|
||||||
|
public var containerDisk: Int // MB, 0 = unlimited
|
||||||
|
public var containerPersistent: Bool
|
||||||
|
public var modalImage: String
|
||||||
|
public var modalMode: String // "auto" | other
|
||||||
|
public var daytonaImage: String
|
||||||
|
public var singularityImage: String
|
||||||
|
|
||||||
|
|
||||||
|
public init(
|
||||||
|
cwd: String,
|
||||||
|
timeout: Int,
|
||||||
|
envPassthrough: [String],
|
||||||
|
persistentShell: Bool,
|
||||||
|
dockerImage: String,
|
||||||
|
dockerMountCwdToWorkspace: Bool,
|
||||||
|
dockerForwardEnv: [String],
|
||||||
|
dockerVolumes: [String],
|
||||||
|
containerCPU: Int,
|
||||||
|
containerMemory: Int,
|
||||||
|
containerDisk: Int,
|
||||||
|
containerPersistent: Bool,
|
||||||
|
modalImage: String,
|
||||||
|
modalMode: String,
|
||||||
|
daytonaImage: String,
|
||||||
|
singularityImage: String
|
||||||
|
) {
|
||||||
|
self.cwd = cwd
|
||||||
|
self.timeout = timeout
|
||||||
|
self.envPassthrough = envPassthrough
|
||||||
|
self.persistentShell = persistentShell
|
||||||
|
self.dockerImage = dockerImage
|
||||||
|
self.dockerMountCwdToWorkspace = dockerMountCwdToWorkspace
|
||||||
|
self.dockerForwardEnv = dockerForwardEnv
|
||||||
|
self.dockerVolumes = dockerVolumes
|
||||||
|
self.containerCPU = containerCPU
|
||||||
|
self.containerMemory = containerMemory
|
||||||
|
self.containerDisk = containerDisk
|
||||||
|
self.containerPersistent = containerPersistent
|
||||||
|
self.modalImage = modalImage
|
||||||
|
self.modalMode = modalMode
|
||||||
|
self.daytonaImage = daytonaImage
|
||||||
|
self.singularityImage = singularityImage
|
||||||
|
}
|
||||||
|
public nonisolated static let empty = TerminalSettings(
|
||||||
|
cwd: ".",
|
||||||
|
timeout: 180,
|
||||||
|
envPassthrough: [],
|
||||||
|
persistentShell: true,
|
||||||
|
dockerImage: "",
|
||||||
|
dockerMountCwdToWorkspace: false,
|
||||||
|
dockerForwardEnv: [],
|
||||||
|
dockerVolumes: [],
|
||||||
|
containerCPU: 0,
|
||||||
|
containerMemory: 0,
|
||||||
|
containerDisk: 0,
|
||||||
|
containerPersistent: false,
|
||||||
|
modalImage: "",
|
||||||
|
modalMode: "auto",
|
||||||
|
daytonaImage: "",
|
||||||
|
singularityImage: ""
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Browser automation tuning (`browser.*`).
|
||||||
|
public struct BrowserSettings: Sendable, Equatable {
|
||||||
|
public var inactivityTimeout: Int
|
||||||
|
public var commandTimeout: Int
|
||||||
|
public var recordSessions: Bool
|
||||||
|
public var allowPrivateURLs: Bool
|
||||||
|
public var camofoxManagedPersistence: Bool
|
||||||
|
|
||||||
|
|
||||||
|
public init(
|
||||||
|
inactivityTimeout: Int,
|
||||||
|
commandTimeout: Int,
|
||||||
|
recordSessions: Bool,
|
||||||
|
allowPrivateURLs: Bool,
|
||||||
|
camofoxManagedPersistence: Bool
|
||||||
|
) {
|
||||||
|
self.inactivityTimeout = inactivityTimeout
|
||||||
|
self.commandTimeout = commandTimeout
|
||||||
|
self.recordSessions = recordSessions
|
||||||
|
self.allowPrivateURLs = allowPrivateURLs
|
||||||
|
self.camofoxManagedPersistence = camofoxManagedPersistence
|
||||||
|
}
|
||||||
|
public nonisolated static let empty = BrowserSettings(
|
||||||
|
inactivityTimeout: 120,
|
||||||
|
commandTimeout: 30,
|
||||||
|
recordSessions: false,
|
||||||
|
allowPrivateURLs: false,
|
||||||
|
camofoxManagedPersistence: false
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Voice push-to-talk plus TTS/STT provider settings.
|
||||||
|
public struct VoiceSettings: Sendable, Equatable {
|
||||||
|
public var recordKey: String
|
||||||
|
public var maxRecordingSeconds: Int
|
||||||
|
public var silenceDuration: Double
|
||||||
|
|
||||||
|
// TTS
|
||||||
|
public var ttsProvider: String
|
||||||
|
public var ttsEdgeVoice: String
|
||||||
|
public var ttsElevenLabsVoiceID: String
|
||||||
|
public var ttsElevenLabsModelID: String
|
||||||
|
public var ttsOpenAIModel: String
|
||||||
|
public var ttsOpenAIVoice: String
|
||||||
|
public var ttsNeuTTSModel: String
|
||||||
|
public var ttsNeuTTSDevice: String
|
||||||
|
|
||||||
|
// STT
|
||||||
|
public var sttEnabled: Bool
|
||||||
|
public var sttProvider: String
|
||||||
|
public var sttLocalModel: String
|
||||||
|
public var sttLocalLanguage: String
|
||||||
|
public var sttOpenAIModel: String
|
||||||
|
public var sttMistralModel: String
|
||||||
|
|
||||||
|
|
||||||
|
public init(
|
||||||
|
recordKey: String,
|
||||||
|
maxRecordingSeconds: Int,
|
||||||
|
silenceDuration: Double,
|
||||||
|
ttsProvider: String,
|
||||||
|
ttsEdgeVoice: String,
|
||||||
|
ttsElevenLabsVoiceID: String,
|
||||||
|
ttsElevenLabsModelID: String,
|
||||||
|
ttsOpenAIModel: String,
|
||||||
|
ttsOpenAIVoice: String,
|
||||||
|
ttsNeuTTSModel: String,
|
||||||
|
ttsNeuTTSDevice: String,
|
||||||
|
sttEnabled: Bool,
|
||||||
|
sttProvider: String,
|
||||||
|
sttLocalModel: String,
|
||||||
|
sttLocalLanguage: String,
|
||||||
|
sttOpenAIModel: String,
|
||||||
|
sttMistralModel: String
|
||||||
|
) {
|
||||||
|
self.recordKey = recordKey
|
||||||
|
self.maxRecordingSeconds = maxRecordingSeconds
|
||||||
|
self.silenceDuration = silenceDuration
|
||||||
|
self.ttsProvider = ttsProvider
|
||||||
|
self.ttsEdgeVoice = ttsEdgeVoice
|
||||||
|
self.ttsElevenLabsVoiceID = ttsElevenLabsVoiceID
|
||||||
|
self.ttsElevenLabsModelID = ttsElevenLabsModelID
|
||||||
|
self.ttsOpenAIModel = ttsOpenAIModel
|
||||||
|
self.ttsOpenAIVoice = ttsOpenAIVoice
|
||||||
|
self.ttsNeuTTSModel = ttsNeuTTSModel
|
||||||
|
self.ttsNeuTTSDevice = ttsNeuTTSDevice
|
||||||
|
self.sttEnabled = sttEnabled
|
||||||
|
self.sttProvider = sttProvider
|
||||||
|
self.sttLocalModel = sttLocalModel
|
||||||
|
self.sttLocalLanguage = sttLocalLanguage
|
||||||
|
self.sttOpenAIModel = sttOpenAIModel
|
||||||
|
self.sttMistralModel = sttMistralModel
|
||||||
|
}
|
||||||
|
public nonisolated static let empty = VoiceSettings(
|
||||||
|
recordKey: "ctrl+b",
|
||||||
|
maxRecordingSeconds: 120,
|
||||||
|
silenceDuration: 3.0,
|
||||||
|
ttsProvider: "edge",
|
||||||
|
ttsEdgeVoice: "en-US-AriaNeural",
|
||||||
|
ttsElevenLabsVoiceID: "",
|
||||||
|
ttsElevenLabsModelID: "eleven_multilingual_v2",
|
||||||
|
ttsOpenAIModel: "gpt-4o-mini-tts",
|
||||||
|
ttsOpenAIVoice: "alloy",
|
||||||
|
ttsNeuTTSModel: "neuphonic/neutts-air-q4-gguf",
|
||||||
|
ttsNeuTTSDevice: "cpu",
|
||||||
|
sttEnabled: true,
|
||||||
|
sttProvider: "local",
|
||||||
|
sttLocalModel: "base",
|
||||||
|
sttLocalLanguage: "",
|
||||||
|
sttOpenAIModel: "whisper-1",
|
||||||
|
sttMistralModel: "voxtral-mini-latest"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Per-task auxiliary model overrides.
|
||||||
|
///
|
||||||
|
/// `flush_memories` was removed in Hermes v0.12 but remains alive on
|
||||||
|
/// pre-v0.12 hosts — the field is preserved here so the YAML parser
|
||||||
|
/// can round-trip it and `AuxiliaryTab` can render the row when
|
||||||
|
/// `HermesCapabilities.hasFlushMemoriesAux` is set. On v0.12+ the
|
||||||
|
/// field stays empty and is never surfaced.
|
||||||
|
/// `curator` was added in v0.12 — Curator's review fork uses its own
|
||||||
|
/// model so users can keep main-model spend separate from background
|
||||||
|
/// maintenance.
|
||||||
|
public struct AuxiliarySettings: Sendable, Equatable {
|
||||||
|
public var vision: AuxiliaryModel
|
||||||
|
public var webExtract: AuxiliaryModel
|
||||||
|
public var compression: AuxiliaryModel
|
||||||
|
public var sessionSearch: AuxiliaryModel
|
||||||
|
public var skillsHub: AuxiliaryModel
|
||||||
|
public var approval: AuxiliaryModel
|
||||||
|
public var mcp: AuxiliaryModel
|
||||||
|
/// pre-v0.12 only; on v0.12+ this stays `.empty` and the row is hidden.
|
||||||
|
public var flushMemories: AuxiliaryModel
|
||||||
|
/// v0.12+; pre-v0.12 Hermes installs ignore this slot.
|
||||||
|
public var curator: AuxiliaryModel
|
||||||
|
|
||||||
|
|
||||||
|
public init(
|
||||||
|
vision: AuxiliaryModel,
|
||||||
|
webExtract: AuxiliaryModel,
|
||||||
|
compression: AuxiliaryModel,
|
||||||
|
sessionSearch: AuxiliaryModel,
|
||||||
|
skillsHub: AuxiliaryModel,
|
||||||
|
approval: AuxiliaryModel,
|
||||||
|
mcp: AuxiliaryModel,
|
||||||
|
flushMemories: AuxiliaryModel,
|
||||||
|
curator: AuxiliaryModel
|
||||||
|
) {
|
||||||
|
self.vision = vision
|
||||||
|
self.webExtract = webExtract
|
||||||
|
self.compression = compression
|
||||||
|
self.sessionSearch = sessionSearch
|
||||||
|
self.skillsHub = skillsHub
|
||||||
|
self.approval = approval
|
||||||
|
self.mcp = mcp
|
||||||
|
self.flushMemories = flushMemories
|
||||||
|
self.curator = curator
|
||||||
|
}
|
||||||
|
public nonisolated static let empty = AuxiliarySettings(
|
||||||
|
vision: .empty,
|
||||||
|
webExtract: .empty,
|
||||||
|
compression: .empty,
|
||||||
|
sessionSearch: .empty,
|
||||||
|
skillsHub: .empty,
|
||||||
|
approval: .empty,
|
||||||
|
mcp: .empty,
|
||||||
|
flushMemories: .empty,
|
||||||
|
curator: .empty
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Security/redaction/firewall config. Website blocklist is nested in YAML.
|
||||||
|
public struct SecuritySettings: Sendable, Equatable {
|
||||||
|
public var redactSecrets: Bool
|
||||||
|
public var redactPII: Bool // from privacy.redact_pii
|
||||||
|
public var tirithEnabled: Bool
|
||||||
|
public var tirithPath: String
|
||||||
|
public var tirithTimeout: Int
|
||||||
|
public var tirithFailOpen: Bool
|
||||||
|
public var blocklistEnabled: Bool
|
||||||
|
public var blocklistDomains: [String]
|
||||||
|
|
||||||
|
|
||||||
|
public init(
|
||||||
|
redactSecrets: Bool,
|
||||||
|
redactPII: Bool,
|
||||||
|
tirithEnabled: Bool,
|
||||||
|
tirithPath: String,
|
||||||
|
tirithTimeout: Int,
|
||||||
|
tirithFailOpen: Bool,
|
||||||
|
blocklistEnabled: Bool,
|
||||||
|
blocklistDomains: [String]
|
||||||
|
) {
|
||||||
|
self.redactSecrets = redactSecrets
|
||||||
|
self.redactPII = redactPII
|
||||||
|
self.tirithEnabled = tirithEnabled
|
||||||
|
self.tirithPath = tirithPath
|
||||||
|
self.tirithTimeout = tirithTimeout
|
||||||
|
self.tirithFailOpen = tirithFailOpen
|
||||||
|
self.blocklistEnabled = blocklistEnabled
|
||||||
|
self.blocklistDomains = blocklistDomains
|
||||||
|
}
|
||||||
|
public nonisolated static let empty = SecuritySettings(
|
||||||
|
redactSecrets: true,
|
||||||
|
redactPII: false,
|
||||||
|
tirithEnabled: true,
|
||||||
|
tirithPath: "tirith",
|
||||||
|
tirithTimeout: 5,
|
||||||
|
tirithFailOpen: true,
|
||||||
|
blocklistEnabled: false,
|
||||||
|
blocklistDomains: []
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Human-delay simulates realistic typing pace (`human_delay.*`).
|
||||||
|
public struct HumanDelaySettings: Sendable, Equatable {
|
||||||
|
public var mode: String // "off" | "natural" | "custom"
|
||||||
|
public var minMS: Int
|
||||||
|
public var maxMS: Int
|
||||||
|
|
||||||
|
|
||||||
|
public init(
|
||||||
|
mode: String,
|
||||||
|
minMS: Int,
|
||||||
|
maxMS: Int
|
||||||
|
) {
|
||||||
|
self.mode = mode
|
||||||
|
self.minMS = minMS
|
||||||
|
self.maxMS = maxMS
|
||||||
|
}
|
||||||
|
public nonisolated static let empty = HumanDelaySettings(mode: "off", minMS: 800, maxMS: 2500)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Compression / context routing.
|
||||||
|
public struct CompressionSettings: Sendable, Equatable {
|
||||||
|
public var enabled: Bool
|
||||||
|
public var threshold: Double
|
||||||
|
public var targetRatio: Double
|
||||||
|
public var protectLastN: Int
|
||||||
|
|
||||||
|
|
||||||
|
public init(
|
||||||
|
enabled: Bool,
|
||||||
|
threshold: Double,
|
||||||
|
targetRatio: Double,
|
||||||
|
protectLastN: Int
|
||||||
|
) {
|
||||||
|
self.enabled = enabled
|
||||||
|
self.threshold = threshold
|
||||||
|
self.targetRatio = targetRatio
|
||||||
|
self.protectLastN = protectLastN
|
||||||
|
}
|
||||||
|
public nonisolated static let empty = CompressionSettings(enabled: true, threshold: 0.5, targetRatio: 0.2, protectLastN: 20)
|
||||||
|
}
|
||||||
|
|
||||||
|
public struct CheckpointSettings: Sendable, Equatable {
|
||||||
|
public var enabled: Bool
|
||||||
|
public var maxSnapshots: Int
|
||||||
|
|
||||||
|
|
||||||
|
public init(
|
||||||
|
enabled: Bool,
|
||||||
|
maxSnapshots: Int
|
||||||
|
) {
|
||||||
|
self.enabled = enabled
|
||||||
|
self.maxSnapshots = maxSnapshots
|
||||||
|
}
|
||||||
|
public nonisolated static let empty = CheckpointSettings(enabled: true, maxSnapshots: 50)
|
||||||
|
}
|
||||||
|
|
||||||
|
public struct LoggingSettings: Sendable, Equatable {
|
||||||
|
public var level: String // DEBUG | INFO | WARNING | ERROR
|
||||||
|
public var maxSizeMB: Int
|
||||||
|
public var backupCount: Int
|
||||||
|
|
||||||
|
|
||||||
|
public init(
|
||||||
|
level: String,
|
||||||
|
maxSizeMB: Int,
|
||||||
|
backupCount: Int
|
||||||
|
) {
|
||||||
|
self.level = level
|
||||||
|
self.maxSizeMB = maxSizeMB
|
||||||
|
self.backupCount = backupCount
|
||||||
|
}
|
||||||
|
public nonisolated static let empty = LoggingSettings(level: "INFO", maxSizeMB: 5, backupCount: 3)
|
||||||
|
}
|
||||||
|
|
||||||
|
public struct DelegationSettings: Sendable, Equatable {
|
||||||
|
public var model: String
|
||||||
|
public var provider: String
|
||||||
|
public var baseURL: String
|
||||||
|
public var apiKey: String
|
||||||
|
public var maxIterations: Int
|
||||||
|
|
||||||
|
|
||||||
|
public init(
|
||||||
|
model: String,
|
||||||
|
provider: String,
|
||||||
|
baseURL: String,
|
||||||
|
apiKey: String,
|
||||||
|
maxIterations: Int
|
||||||
|
) {
|
||||||
|
self.model = model
|
||||||
|
self.provider = provider
|
||||||
|
self.baseURL = baseURL
|
||||||
|
self.apiKey = apiKey
|
||||||
|
self.maxIterations = maxIterations
|
||||||
|
}
|
||||||
|
public nonisolated static let empty = DelegationSettings(model: "", provider: "", baseURL: "", apiKey: "", maxIterations: 50)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Discord-specific platform settings (`discord.*`). Other platforms currently have thinner schemas.
|
||||||
|
public struct DiscordSettings: Sendable, Equatable {
|
||||||
|
public var requireMention: Bool
|
||||||
|
public var freeResponseChannels: String
|
||||||
|
public var autoThread: Bool
|
||||||
|
public var reactions: Bool
|
||||||
|
|
||||||
|
|
||||||
|
public init(
|
||||||
|
requireMention: Bool,
|
||||||
|
freeResponseChannels: String,
|
||||||
|
autoThread: Bool,
|
||||||
|
reactions: Bool
|
||||||
|
) {
|
||||||
|
self.requireMention = requireMention
|
||||||
|
self.freeResponseChannels = freeResponseChannels
|
||||||
|
self.autoThread = autoThread
|
||||||
|
self.reactions = reactions
|
||||||
|
}
|
||||||
|
public nonisolated static let empty = DiscordSettings(requireMention: true, freeResponseChannels: "", autoThread: true, reactions: true)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Telegram settings under `telegram.*` in config.yaml. Most Telegram tuning is
|
||||||
|
/// done via environment variables (`TELEGRAM_*`) — this is the subset that lives
|
||||||
|
/// in the YAML.
|
||||||
|
public struct TelegramSettings: Sendable, Equatable {
|
||||||
|
public var requireMention: Bool
|
||||||
|
public var reactions: Bool
|
||||||
|
|
||||||
|
|
||||||
|
public init(
|
||||||
|
requireMention: Bool,
|
||||||
|
reactions: Bool
|
||||||
|
) {
|
||||||
|
self.requireMention = requireMention
|
||||||
|
self.reactions = reactions
|
||||||
|
}
|
||||||
|
public nonisolated static let empty = TelegramSettings(requireMention: true, reactions: false)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Slack settings under `platforms.slack.*` (and a couple of top-level keys).
|
||||||
|
public struct SlackSettings: Sendable, Equatable {
|
||||||
|
public var replyToMode: String // "off" | "first" | "all"
|
||||||
|
public var requireMention: Bool
|
||||||
|
public var replyInThread: Bool
|
||||||
|
public var replyBroadcast: Bool
|
||||||
|
|
||||||
|
|
||||||
|
public init(
|
||||||
|
replyToMode: String,
|
||||||
|
requireMention: Bool,
|
||||||
|
replyInThread: Bool,
|
||||||
|
replyBroadcast: Bool
|
||||||
|
) {
|
||||||
|
self.replyToMode = replyToMode
|
||||||
|
self.requireMention = requireMention
|
||||||
|
self.replyInThread = replyInThread
|
||||||
|
self.replyBroadcast = replyBroadcast
|
||||||
|
}
|
||||||
|
public nonisolated static let empty = SlackSettings(replyToMode: "first", requireMention: true, replyInThread: true, replyBroadcast: false)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Matrix settings under `matrix.*`.
|
||||||
|
public struct MatrixSettings: Sendable, Equatable {
|
||||||
|
public var requireMention: Bool
|
||||||
|
public var autoThread: Bool
|
||||||
|
public var dmMentionThreads: Bool
|
||||||
|
|
||||||
|
|
||||||
|
public init(
|
||||||
|
requireMention: Bool,
|
||||||
|
autoThread: Bool,
|
||||||
|
dmMentionThreads: Bool
|
||||||
|
) {
|
||||||
|
self.requireMention = requireMention
|
||||||
|
self.autoThread = autoThread
|
||||||
|
self.dmMentionThreads = dmMentionThreads
|
||||||
|
}
|
||||||
|
public nonisolated static let empty = MatrixSettings(requireMention: true, autoThread: true, dmMentionThreads: false)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Mattermost settings. Mattermost is mostly driven by env vars; config.yaml
|
||||||
|
/// currently just exposes `group_sessions_per_user` at the top level, but we
|
||||||
|
/// reserve this struct for future expansion so the form has a stable type.
|
||||||
|
public struct MattermostSettings: Sendable, Equatable {
|
||||||
|
public var requireMention: Bool
|
||||||
|
public var replyMode: String // "thread" | "off"
|
||||||
|
|
||||||
|
|
||||||
|
public init(
|
||||||
|
requireMention: Bool,
|
||||||
|
replyMode: String
|
||||||
|
) {
|
||||||
|
self.requireMention = requireMention
|
||||||
|
self.replyMode = replyMode
|
||||||
|
}
|
||||||
|
public nonisolated static let empty = MattermostSettings(requireMention: true, replyMode: "off")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// WhatsApp settings under `whatsapp.*`.
|
||||||
|
public struct WhatsAppSettings: Sendable, Equatable {
|
||||||
|
public var unauthorizedDMBehavior: String // "pair" | "ignore"
|
||||||
|
public var replyPrefix: String
|
||||||
|
|
||||||
|
|
||||||
|
public init(
|
||||||
|
unauthorizedDMBehavior: String,
|
||||||
|
replyPrefix: String
|
||||||
|
) {
|
||||||
|
self.unauthorizedDMBehavior = unauthorizedDMBehavior
|
||||||
|
self.replyPrefix = replyPrefix
|
||||||
|
}
|
||||||
|
public nonisolated static let empty = WhatsAppSettings(unauthorizedDMBehavior: "pair", replyPrefix: "")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Home Assistant filters under `platforms.homeassistant.extra`. Hermes ignores
|
||||||
|
/// every state change by default; users must opt-in via at least one filter.
|
||||||
|
public struct HomeAssistantSettings: Sendable, Equatable {
|
||||||
|
public var watchDomains: [String]
|
||||||
|
public var watchEntities: [String]
|
||||||
|
public var watchAll: Bool
|
||||||
|
public var ignoreEntities: [String]
|
||||||
|
public var cooldownSeconds: Int
|
||||||
|
|
||||||
|
|
||||||
|
public init(
|
||||||
|
watchDomains: [String],
|
||||||
|
watchEntities: [String],
|
||||||
|
watchAll: Bool,
|
||||||
|
ignoreEntities: [String],
|
||||||
|
cooldownSeconds: Int
|
||||||
|
) {
|
||||||
|
self.watchDomains = watchDomains
|
||||||
|
self.watchEntities = watchEntities
|
||||||
|
self.watchAll = watchAll
|
||||||
|
self.ignoreEntities = ignoreEntities
|
||||||
|
self.cooldownSeconds = cooldownSeconds
|
||||||
|
}
|
||||||
|
public nonisolated static let empty = HomeAssistantSettings(watchDomains: [], watchEntities: [], watchAll: false, ignoreEntities: [], cooldownSeconds: 30)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Root Config
|
||||||
|
|
||||||
|
public struct HermesConfig: Sendable {
|
||||||
|
// Original fields — preserved for zero breakage with existing call sites.
|
||||||
|
public var model: String
|
||||||
|
public var provider: String
|
||||||
|
public var maxTurns: Int
|
||||||
|
public var personality: String
|
||||||
|
public var terminalBackend: String
|
||||||
|
public var memoryEnabled: Bool
|
||||||
|
public var memoryCharLimit: Int
|
||||||
|
public var userCharLimit: Int
|
||||||
|
public var nudgeInterval: Int
|
||||||
|
public var streaming: Bool
|
||||||
|
public var showReasoning: Bool
|
||||||
|
public var verbose: Bool
|
||||||
|
public var autoTTS: Bool
|
||||||
|
public var silenceThreshold: Int
|
||||||
|
public var reasoningEffort: String
|
||||||
|
public var showCost: Bool
|
||||||
|
public var approvalMode: String
|
||||||
|
public var browserBackend: String
|
||||||
|
public var memoryProvider: String
|
||||||
|
public var dockerEnv: [String: String]
|
||||||
|
public var commandAllowlist: [String]
|
||||||
|
public var memoryProfile: String
|
||||||
|
public var serviceTier: String
|
||||||
|
public var gatewayNotifyInterval: Int
|
||||||
|
public var forceIPv4: Bool
|
||||||
|
public var contextEngine: String
|
||||||
|
public var interimAssistantMessages: Bool
|
||||||
|
public var honchoInitOnSessionStart: Bool
|
||||||
|
|
||||||
|
// Phase 1 additions
|
||||||
|
public var timezone: String
|
||||||
|
public var userProfileEnabled: Bool
|
||||||
|
public var toolUseEnforcement: String // "auto" | "true" | "false" | comma list
|
||||||
|
public var gatewayTimeout: Int
|
||||||
|
public var approvalTimeout: Int
|
||||||
|
public var fileReadMaxChars: Int
|
||||||
|
public var cronWrapResponse: Bool
|
||||||
|
public var prefillMessagesFile: String
|
||||||
|
public var skillsExternalDirs: [String]
|
||||||
|
|
||||||
|
/// Per-platform toolset allowlists as written by `hermes setup tools`.
|
||||||
|
/// Keyed by platform (`cli`, `slack`, …) to enabled toolset identifiers
|
||||||
|
/// (`browser`, `messaging`, `nous-tools`, …). Hermes v0.10.0's Tool
|
||||||
|
/// Gateway; enabling `nous-tools` here is how subscribers opt-in per
|
||||||
|
/// platform. Scarf reads for display; edits go through Hermes CLI.
|
||||||
|
public var platformToolsets: [String: [String]]
|
||||||
|
|
||||||
|
// -- Hermes v0.12 additions ----------------------------------------
|
||||||
|
// Defaults match the Hermes v0.12 defaults so that an absent key in
|
||||||
|
// config.yaml looks identical to a freshly-installed v0.12 host.
|
||||||
|
|
||||||
|
/// `prompt_caching.cache_ttl` — `"5m"` (default) or `"1h"`. Hermes
|
||||||
|
/// v0.12 added the 1-hour ceiling for users with prompt-cache-heavy
|
||||||
|
/// workloads (long agent loops with stable system prompts).
|
||||||
|
public var cacheTTL: String
|
||||||
|
/// `redaction.enabled` — flipped from `true` to `false` as the
|
||||||
|
/// upstream default in v0.12 because the substitution corrupted
|
||||||
|
/// patches and API payloads. Surface a toggle so users with hard
|
||||||
|
/// redaction requirements can opt back in.
|
||||||
|
public var redactionEnabled: Bool
|
||||||
|
/// `agent.runtime_metadata_footer` — opt-in compact footer on each
|
||||||
|
/// final reply (provider/model/cost/turn count). Off by default;
|
||||||
|
/// useful for cost auditing and screen-recording demos.
|
||||||
|
public var runtimeMetadataFooter: Bool
|
||||||
|
|
||||||
|
// Grouped blocks
|
||||||
|
public var display: DisplaySettings
|
||||||
|
public var terminal: TerminalSettings
|
||||||
|
public var browser: BrowserSettings
|
||||||
|
public var voice: VoiceSettings
|
||||||
|
public var auxiliary: AuxiliarySettings
|
||||||
|
public var security: SecuritySettings
|
||||||
|
public var humanDelay: HumanDelaySettings
|
||||||
|
public var compression: CompressionSettings
|
||||||
|
public var checkpoints: CheckpointSettings
|
||||||
|
public var logging: LoggingSettings
|
||||||
|
public var delegation: DelegationSettings
|
||||||
|
public var discord: DiscordSettings
|
||||||
|
public var telegram: TelegramSettings
|
||||||
|
public var slack: SlackSettings
|
||||||
|
public var matrix: MatrixSettings
|
||||||
|
public var mattermost: MattermostSettings
|
||||||
|
public var whatsapp: WhatsAppSettings
|
||||||
|
public var homeAssistant: HomeAssistantSettings
|
||||||
|
|
||||||
|
|
||||||
|
public init(
|
||||||
|
model: String,
|
||||||
|
provider: String,
|
||||||
|
maxTurns: Int,
|
||||||
|
personality: String,
|
||||||
|
terminalBackend: String,
|
||||||
|
memoryEnabled: Bool,
|
||||||
|
memoryCharLimit: Int,
|
||||||
|
userCharLimit: Int,
|
||||||
|
nudgeInterval: Int,
|
||||||
|
streaming: Bool,
|
||||||
|
showReasoning: Bool,
|
||||||
|
verbose: Bool,
|
||||||
|
autoTTS: Bool,
|
||||||
|
silenceThreshold: Int,
|
||||||
|
reasoningEffort: String,
|
||||||
|
showCost: Bool,
|
||||||
|
approvalMode: String,
|
||||||
|
browserBackend: String,
|
||||||
|
memoryProvider: String,
|
||||||
|
dockerEnv: [String: String],
|
||||||
|
commandAllowlist: [String],
|
||||||
|
memoryProfile: String,
|
||||||
|
serviceTier: String,
|
||||||
|
gatewayNotifyInterval: Int,
|
||||||
|
forceIPv4: Bool,
|
||||||
|
contextEngine: String,
|
||||||
|
interimAssistantMessages: Bool,
|
||||||
|
honchoInitOnSessionStart: Bool,
|
||||||
|
timezone: String,
|
||||||
|
userProfileEnabled: Bool,
|
||||||
|
toolUseEnforcement: String,
|
||||||
|
gatewayTimeout: Int,
|
||||||
|
approvalTimeout: Int,
|
||||||
|
fileReadMaxChars: Int,
|
||||||
|
cronWrapResponse: Bool,
|
||||||
|
prefillMessagesFile: String,
|
||||||
|
skillsExternalDirs: [String],
|
||||||
|
platformToolsets: [String: [String]],
|
||||||
|
display: DisplaySettings,
|
||||||
|
terminal: TerminalSettings,
|
||||||
|
browser: BrowserSettings,
|
||||||
|
voice: VoiceSettings,
|
||||||
|
auxiliary: AuxiliarySettings,
|
||||||
|
security: SecuritySettings,
|
||||||
|
humanDelay: HumanDelaySettings,
|
||||||
|
compression: CompressionSettings,
|
||||||
|
checkpoints: CheckpointSettings,
|
||||||
|
logging: LoggingSettings,
|
||||||
|
delegation: DelegationSettings,
|
||||||
|
discord: DiscordSettings,
|
||||||
|
telegram: TelegramSettings,
|
||||||
|
slack: SlackSettings,
|
||||||
|
matrix: MatrixSettings,
|
||||||
|
mattermost: MattermostSettings,
|
||||||
|
whatsapp: WhatsAppSettings,
|
||||||
|
homeAssistant: HomeAssistantSettings,
|
||||||
|
cacheTTL: String = "5m",
|
||||||
|
redactionEnabled: Bool = false,
|
||||||
|
runtimeMetadataFooter: Bool = false
|
||||||
|
) {
|
||||||
|
self.cacheTTL = cacheTTL
|
||||||
|
self.redactionEnabled = redactionEnabled
|
||||||
|
self.runtimeMetadataFooter = runtimeMetadataFooter
|
||||||
|
self.model = model
|
||||||
|
self.provider = provider
|
||||||
|
self.maxTurns = maxTurns
|
||||||
|
self.personality = personality
|
||||||
|
self.terminalBackend = terminalBackend
|
||||||
|
self.memoryEnabled = memoryEnabled
|
||||||
|
self.memoryCharLimit = memoryCharLimit
|
||||||
|
self.userCharLimit = userCharLimit
|
||||||
|
self.nudgeInterval = nudgeInterval
|
||||||
|
self.streaming = streaming
|
||||||
|
self.showReasoning = showReasoning
|
||||||
|
self.verbose = verbose
|
||||||
|
self.autoTTS = autoTTS
|
||||||
|
self.silenceThreshold = silenceThreshold
|
||||||
|
self.reasoningEffort = reasoningEffort
|
||||||
|
self.showCost = showCost
|
||||||
|
self.approvalMode = approvalMode
|
||||||
|
self.browserBackend = browserBackend
|
||||||
|
self.memoryProvider = memoryProvider
|
||||||
|
self.dockerEnv = dockerEnv
|
||||||
|
self.commandAllowlist = commandAllowlist
|
||||||
|
self.memoryProfile = memoryProfile
|
||||||
|
self.serviceTier = serviceTier
|
||||||
|
self.gatewayNotifyInterval = gatewayNotifyInterval
|
||||||
|
self.forceIPv4 = forceIPv4
|
||||||
|
self.contextEngine = contextEngine
|
||||||
|
self.interimAssistantMessages = interimAssistantMessages
|
||||||
|
self.honchoInitOnSessionStart = honchoInitOnSessionStart
|
||||||
|
self.timezone = timezone
|
||||||
|
self.userProfileEnabled = userProfileEnabled
|
||||||
|
self.toolUseEnforcement = toolUseEnforcement
|
||||||
|
self.gatewayTimeout = gatewayTimeout
|
||||||
|
self.approvalTimeout = approvalTimeout
|
||||||
|
self.fileReadMaxChars = fileReadMaxChars
|
||||||
|
self.cronWrapResponse = cronWrapResponse
|
||||||
|
self.prefillMessagesFile = prefillMessagesFile
|
||||||
|
self.skillsExternalDirs = skillsExternalDirs
|
||||||
|
self.platformToolsets = platformToolsets
|
||||||
|
self.display = display
|
||||||
|
self.terminal = terminal
|
||||||
|
self.browser = browser
|
||||||
|
self.voice = voice
|
||||||
|
self.auxiliary = auxiliary
|
||||||
|
self.security = security
|
||||||
|
self.humanDelay = humanDelay
|
||||||
|
self.compression = compression
|
||||||
|
self.checkpoints = checkpoints
|
||||||
|
self.logging = logging
|
||||||
|
self.delegation = delegation
|
||||||
|
self.discord = discord
|
||||||
|
self.telegram = telegram
|
||||||
|
self.slack = slack
|
||||||
|
self.matrix = matrix
|
||||||
|
self.mattermost = mattermost
|
||||||
|
self.whatsapp = whatsapp
|
||||||
|
self.homeAssistant = homeAssistant
|
||||||
|
}
|
||||||
|
public nonisolated static let empty = HermesConfig(
|
||||||
|
model: "unknown",
|
||||||
|
provider: "unknown",
|
||||||
|
maxTurns: 0,
|
||||||
|
personality: "default",
|
||||||
|
terminalBackend: "local",
|
||||||
|
memoryEnabled: false,
|
||||||
|
memoryCharLimit: 0,
|
||||||
|
userCharLimit: 0,
|
||||||
|
nudgeInterval: 0,
|
||||||
|
streaming: true,
|
||||||
|
showReasoning: false,
|
||||||
|
verbose: false,
|
||||||
|
autoTTS: true,
|
||||||
|
silenceThreshold: 200,
|
||||||
|
reasoningEffort: "medium",
|
||||||
|
showCost: false,
|
||||||
|
approvalMode: "manual",
|
||||||
|
browserBackend: "",
|
||||||
|
memoryProvider: "",
|
||||||
|
dockerEnv: [:],
|
||||||
|
commandAllowlist: [],
|
||||||
|
memoryProfile: "",
|
||||||
|
serviceTier: "normal",
|
||||||
|
gatewayNotifyInterval: 600,
|
||||||
|
forceIPv4: false,
|
||||||
|
contextEngine: "compressor",
|
||||||
|
interimAssistantMessages: true,
|
||||||
|
honchoInitOnSessionStart: false,
|
||||||
|
timezone: "",
|
||||||
|
userProfileEnabled: true,
|
||||||
|
toolUseEnforcement: "auto",
|
||||||
|
gatewayTimeout: 1800,
|
||||||
|
approvalTimeout: 60,
|
||||||
|
fileReadMaxChars: 100_000,
|
||||||
|
cronWrapResponse: true,
|
||||||
|
prefillMessagesFile: "",
|
||||||
|
skillsExternalDirs: [],
|
||||||
|
platformToolsets: [:],
|
||||||
|
display: .empty,
|
||||||
|
terminal: .empty,
|
||||||
|
browser: .empty,
|
||||||
|
voice: .empty,
|
||||||
|
auxiliary: .empty,
|
||||||
|
security: .empty,
|
||||||
|
humanDelay: .empty,
|
||||||
|
compression: .empty,
|
||||||
|
checkpoints: .empty,
|
||||||
|
logging: .empty,
|
||||||
|
delegation: .empty,
|
||||||
|
discord: .empty,
|
||||||
|
telegram: .empty,
|
||||||
|
slack: .empty,
|
||||||
|
matrix: .empty,
|
||||||
|
mattermost: .empty,
|
||||||
|
whatsapp: .empty,
|
||||||
|
homeAssistant: .empty
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Hand-written `init(from:)` so Swift 6 doesn't synthesize a
|
||||||
|
// MainActor-isolated Decodable conformance (which would fail to be used from
|
||||||
|
// `HermesFileService.loadGatewayState()`, a nonisolated method).
|
||||||
|
public struct GatewayState: Sendable, Codable {
|
||||||
|
public nonisolated let pid: Int?
|
||||||
|
public nonisolated let kind: String?
|
||||||
|
public nonisolated let gatewayState: String?
|
||||||
|
public nonisolated let exitReason: String?
|
||||||
|
public nonisolated let platforms: [String: PlatformState]?
|
||||||
|
public nonisolated let updatedAt: String?
|
||||||
|
|
||||||
|
public enum CodingKeys: String, CodingKey {
|
||||||
|
case pid, kind
|
||||||
|
case gatewayState = "gateway_state"
|
||||||
|
case exitReason = "exit_reason"
|
||||||
|
case platforms
|
||||||
|
case updatedAt = "updated_at"
|
||||||
|
}
|
||||||
|
|
||||||
|
public nonisolated init(from decoder: any Decoder) throws {
|
||||||
|
let c = try decoder.container(keyedBy: CodingKeys.self)
|
||||||
|
self.pid = try c.decodeIfPresent(Int.self, forKey: .pid)
|
||||||
|
self.kind = try c.decodeIfPresent(String.self, forKey: .kind)
|
||||||
|
self.gatewayState = try c.decodeIfPresent(String.self, forKey: .gatewayState)
|
||||||
|
self.exitReason = try c.decodeIfPresent(String.self, forKey: .exitReason)
|
||||||
|
self.platforms = try c.decodeIfPresent([String: PlatformState].self, forKey: .platforms)
|
||||||
|
self.updatedAt = try c.decodeIfPresent(String.self, forKey: .updatedAt)
|
||||||
|
}
|
||||||
|
|
||||||
|
public nonisolated func encode(to encoder: any Encoder) throws {
|
||||||
|
var c = encoder.container(keyedBy: CodingKeys.self)
|
||||||
|
try c.encodeIfPresent(pid, forKey: .pid)
|
||||||
|
try c.encodeIfPresent(kind, forKey: .kind)
|
||||||
|
try c.encodeIfPresent(gatewayState, forKey: .gatewayState)
|
||||||
|
try c.encodeIfPresent(exitReason, forKey: .exitReason)
|
||||||
|
try c.encodeIfPresent(platforms, forKey: .platforms)
|
||||||
|
try c.encodeIfPresent(updatedAt, forKey: .updatedAt)
|
||||||
|
}
|
||||||
|
|
||||||
|
public nonisolated var isRunning: Bool {
|
||||||
|
gatewayState == "running"
|
||||||
|
}
|
||||||
|
|
||||||
|
public nonisolated var statusText: String {
|
||||||
|
gatewayState ?? "unknown"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public struct PlatformState: Sendable, Codable {
|
||||||
|
public nonisolated let connected: Bool?
|
||||||
|
public nonisolated let error: String?
|
||||||
|
|
||||||
|
public enum CodingKeys: String, CodingKey { case connected, error }
|
||||||
|
|
||||||
|
public nonisolated init(from decoder: any Decoder) throws {
|
||||||
|
let c = try decoder.container(keyedBy: CodingKeys.self)
|
||||||
|
self.connected = try c.decodeIfPresent(Bool.self, forKey: .connected)
|
||||||
|
self.error = try c.decodeIfPresent(String.self, forKey: .error)
|
||||||
|
}
|
||||||
|
|
||||||
|
public nonisolated func encode(to encoder: any Encoder) throws {
|
||||||
|
var c = encoder.container(keyedBy: CodingKeys.self)
|
||||||
|
try c.encodeIfPresent(connected, forKey: .connected)
|
||||||
|
try c.encodeIfPresent(error, forKey: .error)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
import Foundation
|
||||||
|
#if canImport(SQLite3)
|
||||||
|
import SQLite3
|
||||||
|
#endif
|
||||||
|
|
||||||
|
// MARK: - SQLite Constants
|
||||||
|
|
||||||
|
#if canImport(SQLite3)
|
||||||
|
/// SQLITE_TRANSIENT tells SQLite to make its own copy of bound string data.
|
||||||
|
/// The C macro is defined as ((sqlite3_destructor_type)-1) which can't be imported directly into Swift.
|
||||||
|
///
|
||||||
|
/// Gated behind `canImport(SQLite3)` so this file compiles on Linux (where
|
||||||
|
/// SPM has no built-in `SQLite3` system module). Apple platforms — the only
|
||||||
|
/// runtime targets that actually execute this code — compile it unchanged.
|
||||||
|
public nonisolated let sqliteTransient = unsafeBitCast(-1, to: sqlite3_destructor_type.self)
|
||||||
|
#endif
|
||||||
|
|
||||||
|
// MARK: - Query Defaults
|
||||||
|
|
||||||
|
public enum QueryDefaults: Sendable {
|
||||||
|
public nonisolated static let sessionLimit = 100
|
||||||
|
public nonisolated static let messageSearchLimit = 50
|
||||||
|
public nonisolated static let toolCallLimit = 50
|
||||||
|
public nonisolated static let sessionPreviewLimit = 10
|
||||||
|
public nonisolated static let previewContentLength = 100
|
||||||
|
public nonisolated static let logLineLimit = 200
|
||||||
|
public nonisolated static let defaultSilenceThreshold = 200
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Page sizes for `HermesDataService.fetchMessages(sessionId:limit:before:)`.
|
||||||
|
/// Centralized so iOS, Mac, and the polling code paths can pick a
|
||||||
|
/// consistent budget — and so we have one knob to retune if perf
|
||||||
|
/// concerns shift.
|
||||||
|
public enum HistoryPageSize: Sendable {
|
||||||
|
/// Initial chat-history load. **Sized to fit the SSH wire payload
|
||||||
|
/// inside a 30-second `RemoteSQLiteBackend.queryTimeout`.** A
|
||||||
|
/// 157-message session at 200-row page size produced enough
|
||||||
|
/// JSON (with `reasoning_content` for thinking models) to time
|
||||||
|
/// out at exactly 30 s on a 420 ms-RTT remote. Dropped to 50,
|
||||||
|
/// then to 25 in v2.7 after a 160-message session still timed
|
||||||
|
/// out at 50 — `reasoning_content` for thinking-model turns can
|
||||||
|
/// run 20+ KB per row, so 50 rows × 30 KB = 1.5 MB JSON which
|
||||||
|
/// over a slow SSH channel still trips the 30s budget. Pair
|
||||||
|
/// with `messageColumnsLight` (excludes `reasoning_content`)
|
||||||
|
/// so the on-wire payload is small even at this size; the
|
||||||
|
/// inspector pane lazy-loads via `fetchReasoningContent(for:)`
|
||||||
|
/// when the user expands a disclosure. The "Load earlier"
|
||||||
|
/// affordance pages back through older messages on demand.
|
||||||
|
public nonisolated static let initial = 25
|
||||||
|
/// Reconnection reconcile against the DB. 200 rows is plenty —
|
||||||
|
/// disconnects don't generate hundreds of unseen messages.
|
||||||
|
public nonisolated static let reconcile = 200
|
||||||
|
/// Mac sessions detail view. Larger to reduce paging UX in the
|
||||||
|
/// desktop browser-style read; the desktop has the screen real
|
||||||
|
/// estate and memory headroom for it.
|
||||||
|
public nonisolated static let macSessionDetail = 500
|
||||||
|
/// Terminal-mode polling refresh. Same 500-row budget as Mac
|
||||||
|
/// detail; covers sessions long enough that the user is actively
|
||||||
|
/// scrolling but bounded to keep each poll tick cheap.
|
||||||
|
public nonisolated static let polling = 500
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - File Size Formatting
|
||||||
|
|
||||||
|
public enum FileSizeUnit: Sendable {
|
||||||
|
public nonisolated static let kilobyte = 1_024.0
|
||||||
|
public nonisolated static let megabyte = 1_048_576.0
|
||||||
|
}
|
||||||
@@ -1,26 +1,35 @@
|
|||||||
import Foundation
|
import Foundation
|
||||||
|
|
||||||
struct HermesCronJob: Identifiable, Sendable, Codable {
|
public struct HermesCronJob: Identifiable, Sendable, Codable {
|
||||||
nonisolated let id: String
|
public nonisolated let id: String
|
||||||
nonisolated let name: String
|
public nonisolated let name: String
|
||||||
nonisolated let prompt: String
|
public nonisolated let prompt: String
|
||||||
nonisolated let skills: [String]?
|
public nonisolated let skills: [String]?
|
||||||
nonisolated let model: String?
|
public nonisolated let model: String?
|
||||||
nonisolated let schedule: CronSchedule
|
public nonisolated let schedule: CronSchedule
|
||||||
nonisolated let enabled: Bool
|
public nonisolated let enabled: Bool
|
||||||
nonisolated let state: String
|
public nonisolated let state: String
|
||||||
nonisolated let deliver: String?
|
public nonisolated let deliver: String?
|
||||||
nonisolated let nextRunAt: String?
|
public nonisolated let nextRunAt: String?
|
||||||
nonisolated let lastRunAt: String?
|
public nonisolated let lastRunAt: String?
|
||||||
nonisolated let lastError: String?
|
public nonisolated let lastError: String?
|
||||||
nonisolated let preRunScript: String?
|
public nonisolated let preRunScript: String?
|
||||||
nonisolated let deliveryFailures: Int?
|
public nonisolated let deliveryFailures: Int?
|
||||||
nonisolated let lastDeliveryError: String?
|
public nonisolated let lastDeliveryError: String?
|
||||||
nonisolated let timeoutType: String?
|
public nonisolated let timeoutType: String?
|
||||||
nonisolated let timeoutSeconds: Int?
|
public nonisolated let timeoutSeconds: Int?
|
||||||
nonisolated let silent: Bool?
|
public nonisolated let silent: Bool?
|
||||||
|
/// Hermes v0.12+ — the directory the job runs from. Hermes injects
|
||||||
|
/// AGENTS.md / CLAUDE.md / .cursorrules from this dir and uses it
|
||||||
|
/// as cwd for terminal/file/code_exec tools. `nil` preserves the
|
||||||
|
/// pre-v0.12 behaviour (no project context files).
|
||||||
|
public nonisolated let workdir: String?
|
||||||
|
/// Hermes v0.12+ — chain another cron job's last output into this
|
||||||
|
/// job's prompt. YAML-only field today (no `--context-from` CLI
|
||||||
|
/// flag yet) — Scarf displays it but doesn't write it.
|
||||||
|
public nonisolated let contextFrom: [String]?
|
||||||
|
|
||||||
enum CodingKeys: String, CodingKey {
|
public enum CodingKeys: String, CodingKey {
|
||||||
case id, name, prompt, skills, model, schedule, enabled, state, deliver, silent
|
case id, name, prompt, skills, model, schedule, enabled, state, deliver, silent
|
||||||
case nextRunAt = "next_run_at"
|
case nextRunAt = "next_run_at"
|
||||||
case lastRunAt = "last_run_at"
|
case lastRunAt = "last_run_at"
|
||||||
@@ -30,9 +39,58 @@ struct HermesCronJob: Identifiable, Sendable, Codable {
|
|||||||
case lastDeliveryError = "last_delivery_error"
|
case lastDeliveryError = "last_delivery_error"
|
||||||
case timeoutType = "timeout_type"
|
case timeoutType = "timeout_type"
|
||||||
case timeoutSeconds = "timeout_seconds"
|
case timeoutSeconds = "timeout_seconds"
|
||||||
|
case workdir
|
||||||
|
case contextFrom = "context_from"
|
||||||
}
|
}
|
||||||
|
|
||||||
nonisolated init(from decoder: any Decoder) throws {
|
/// Memberwise init. Swift doesn't synthesize one for us because
|
||||||
|
/// of the hand-written Codable conformance. The iOS Cron editor
|
||||||
|
/// uses this to rebuild jobs from user-edited fields.
|
||||||
|
public nonisolated init(
|
||||||
|
id: String,
|
||||||
|
name: String,
|
||||||
|
prompt: String,
|
||||||
|
skills: [String]? = nil,
|
||||||
|
model: String? = nil,
|
||||||
|
schedule: CronSchedule,
|
||||||
|
enabled: Bool,
|
||||||
|
state: String,
|
||||||
|
deliver: String? = nil,
|
||||||
|
nextRunAt: String? = nil,
|
||||||
|
lastRunAt: String? = nil,
|
||||||
|
lastError: String? = nil,
|
||||||
|
preRunScript: String? = nil,
|
||||||
|
deliveryFailures: Int? = nil,
|
||||||
|
lastDeliveryError: String? = nil,
|
||||||
|
timeoutType: String? = nil,
|
||||||
|
timeoutSeconds: Int? = nil,
|
||||||
|
silent: Bool? = nil,
|
||||||
|
workdir: String? = nil,
|
||||||
|
contextFrom: [String]? = nil
|
||||||
|
) {
|
||||||
|
self.id = id
|
||||||
|
self.name = name
|
||||||
|
self.prompt = prompt
|
||||||
|
self.skills = skills
|
||||||
|
self.model = model
|
||||||
|
self.schedule = schedule
|
||||||
|
self.enabled = enabled
|
||||||
|
self.state = state
|
||||||
|
self.deliver = deliver
|
||||||
|
self.nextRunAt = nextRunAt
|
||||||
|
self.lastRunAt = lastRunAt
|
||||||
|
self.lastError = lastError
|
||||||
|
self.preRunScript = preRunScript
|
||||||
|
self.deliveryFailures = deliveryFailures
|
||||||
|
self.lastDeliveryError = lastDeliveryError
|
||||||
|
self.timeoutType = timeoutType
|
||||||
|
self.timeoutSeconds = timeoutSeconds
|
||||||
|
self.silent = silent
|
||||||
|
self.workdir = workdir
|
||||||
|
self.contextFrom = contextFrom
|
||||||
|
}
|
||||||
|
|
||||||
|
public nonisolated init(from decoder: any Decoder) throws {
|
||||||
let c = try decoder.container(keyedBy: CodingKeys.self)
|
let c = try decoder.container(keyedBy: CodingKeys.self)
|
||||||
self.id = try c.decode(String.self, forKey: .id)
|
self.id = try c.decode(String.self, forKey: .id)
|
||||||
self.name = try c.decode(String.self, forKey: .name)
|
self.name = try c.decode(String.self, forKey: .name)
|
||||||
@@ -52,9 +110,11 @@ struct HermesCronJob: Identifiable, Sendable, Codable {
|
|||||||
self.timeoutType = try c.decodeIfPresent(String.self, forKey: .timeoutType)
|
self.timeoutType = try c.decodeIfPresent(String.self, forKey: .timeoutType)
|
||||||
self.timeoutSeconds = try c.decodeIfPresent(Int.self, forKey: .timeoutSeconds)
|
self.timeoutSeconds = try c.decodeIfPresent(Int.self, forKey: .timeoutSeconds)
|
||||||
self.silent = try c.decodeIfPresent(Bool.self, forKey: .silent)
|
self.silent = try c.decodeIfPresent(Bool.self, forKey: .silent)
|
||||||
|
self.workdir = try c.decodeIfPresent(String.self, forKey: .workdir)
|
||||||
|
self.contextFrom = try c.decodeIfPresent([String].self, forKey: .contextFrom)
|
||||||
}
|
}
|
||||||
|
|
||||||
nonisolated func encode(to encoder: any Encoder) throws {
|
public nonisolated func encode(to encoder: any Encoder) throws {
|
||||||
var c = encoder.container(keyedBy: CodingKeys.self)
|
var c = encoder.container(keyedBy: CodingKeys.self)
|
||||||
try c.encode(id, forKey: .id)
|
try c.encode(id, forKey: .id)
|
||||||
try c.encode(name, forKey: .name)
|
try c.encode(name, forKey: .name)
|
||||||
@@ -74,9 +134,11 @@ struct HermesCronJob: Identifiable, Sendable, Codable {
|
|||||||
try c.encodeIfPresent(timeoutType, forKey: .timeoutType)
|
try c.encodeIfPresent(timeoutType, forKey: .timeoutType)
|
||||||
try c.encodeIfPresent(timeoutSeconds, forKey: .timeoutSeconds)
|
try c.encodeIfPresent(timeoutSeconds, forKey: .timeoutSeconds)
|
||||||
try c.encodeIfPresent(silent, forKey: .silent)
|
try c.encodeIfPresent(silent, forKey: .silent)
|
||||||
|
try c.encodeIfPresent(workdir, forKey: .workdir)
|
||||||
|
try c.encodeIfPresent(contextFrom, forKey: .contextFrom)
|
||||||
}
|
}
|
||||||
|
|
||||||
nonisolated var stateIcon: String {
|
public nonisolated var stateIcon: String {
|
||||||
switch state {
|
switch state {
|
||||||
case "scheduled": return "clock"
|
case "scheduled": return "clock"
|
||||||
case "running": return "play.circle"
|
case "running": return "play.circle"
|
||||||
@@ -86,7 +148,7 @@ struct HermesCronJob: Identifiable, Sendable, Codable {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
nonisolated var deliveryDisplay: String? {
|
public nonisolated var deliveryDisplay: String? {
|
||||||
guard let deliver, !deliver.isEmpty else { return nil }
|
guard let deliver, !deliver.isEmpty else { return nil }
|
||||||
// v0.9.0 extends Discord routing to threads: `discord:<chat>:<thread>`.
|
// v0.9.0 extends Discord routing to threads: `discord:<chat>:<thread>`.
|
||||||
if deliver.hasPrefix("discord:") {
|
if deliver.hasPrefix("discord:") {
|
||||||
@@ -102,20 +164,32 @@ struct HermesCronJob: Identifiable, Sendable, Codable {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
struct CronSchedule: Sendable, Codable {
|
public struct CronSchedule: Sendable, Codable {
|
||||||
nonisolated let kind: String
|
public nonisolated let kind: String
|
||||||
nonisolated let runAt: String?
|
public nonisolated let runAt: String?
|
||||||
nonisolated let display: String?
|
public nonisolated let display: String?
|
||||||
nonisolated let expression: String?
|
public nonisolated let expression: String?
|
||||||
|
|
||||||
enum CodingKeys: String, CodingKey {
|
public enum CodingKeys: String, CodingKey {
|
||||||
case kind
|
case kind
|
||||||
case runAt = "run_at"
|
case runAt = "run_at"
|
||||||
case display
|
case display
|
||||||
case expression
|
case expression
|
||||||
}
|
}
|
||||||
|
|
||||||
nonisolated init(from decoder: any Decoder) throws {
|
public nonisolated init(
|
||||||
|
kind: String,
|
||||||
|
runAt: String? = nil,
|
||||||
|
display: String? = nil,
|
||||||
|
expression: String? = nil
|
||||||
|
) {
|
||||||
|
self.kind = kind
|
||||||
|
self.runAt = runAt
|
||||||
|
self.display = display
|
||||||
|
self.expression = expression
|
||||||
|
}
|
||||||
|
|
||||||
|
public nonisolated init(from decoder: any Decoder) throws {
|
||||||
let c = try decoder.container(keyedBy: CodingKeys.self)
|
let c = try decoder.container(keyedBy: CodingKeys.self)
|
||||||
self.kind = try c.decode(String.self, forKey: .kind)
|
self.kind = try c.decode(String.self, forKey: .kind)
|
||||||
self.runAt = try c.decodeIfPresent(String.self, forKey: .runAt)
|
self.runAt = try c.decodeIfPresent(String.self, forKey: .runAt)
|
||||||
@@ -123,7 +197,7 @@ struct CronSchedule: Sendable, Codable {
|
|||||||
self.expression = try c.decodeIfPresent(String.self, forKey: .expression)
|
self.expression = try c.decodeIfPresent(String.self, forKey: .expression)
|
||||||
}
|
}
|
||||||
|
|
||||||
nonisolated func encode(to encoder: any Encoder) throws {
|
public nonisolated func encode(to encoder: any Encoder) throws {
|
||||||
var c = encoder.container(keyedBy: CodingKeys.self)
|
var c = encoder.container(keyedBy: CodingKeys.self)
|
||||||
try c.encode(kind, forKey: .kind)
|
try c.encode(kind, forKey: .kind)
|
||||||
try c.encodeIfPresent(runAt, forKey: .runAt)
|
try c.encodeIfPresent(runAt, forKey: .runAt)
|
||||||
@@ -135,22 +209,27 @@ struct CronSchedule: Sendable, Codable {
|
|||||||
// Hand-written `init(from:)` / `encode(to:)` so Swift 6 doesn't synthesize a
|
// Hand-written `init(from:)` / `encode(to:)` so Swift 6 doesn't synthesize a
|
||||||
// MainActor-isolated Codable conformance — `HermesFileService.loadCronJobs`
|
// MainActor-isolated Codable conformance — `HermesFileService.loadCronJobs`
|
||||||
// is nonisolated and needs to decode this from a background task.
|
// is nonisolated and needs to decode this from a background task.
|
||||||
struct CronJobsFile: Sendable, Codable {
|
public struct CronJobsFile: Sendable, Codable {
|
||||||
nonisolated let jobs: [HermesCronJob]
|
public nonisolated let jobs: [HermesCronJob]
|
||||||
nonisolated let updatedAt: String?
|
public nonisolated let updatedAt: String?
|
||||||
|
|
||||||
enum CodingKeys: String, CodingKey {
|
public enum CodingKeys: String, CodingKey {
|
||||||
case jobs
|
case jobs
|
||||||
case updatedAt = "updated_at"
|
case updatedAt = "updated_at"
|
||||||
}
|
}
|
||||||
|
|
||||||
nonisolated init(from decoder: any Decoder) throws {
|
public nonisolated init(jobs: [HermesCronJob], updatedAt: String?) {
|
||||||
|
self.jobs = jobs
|
||||||
|
self.updatedAt = updatedAt
|
||||||
|
}
|
||||||
|
|
||||||
|
public nonisolated init(from decoder: any Decoder) throws {
|
||||||
let c = try decoder.container(keyedBy: CodingKeys.self)
|
let c = try decoder.container(keyedBy: CodingKeys.self)
|
||||||
self.jobs = try c.decode([HermesCronJob].self, forKey: .jobs)
|
self.jobs = try c.decode([HermesCronJob].self, forKey: .jobs)
|
||||||
self.updatedAt = try c.decodeIfPresent(String.self, forKey: .updatedAt)
|
self.updatedAt = try c.decodeIfPresent(String.self, forKey: .updatedAt)
|
||||||
}
|
}
|
||||||
|
|
||||||
nonisolated func encode(to encoder: any Encoder) throws {
|
public nonisolated func encode(to encoder: any Encoder) throws {
|
||||||
var c = encoder.container(keyedBy: CodingKeys.self)
|
var c = encoder.container(keyedBy: CodingKeys.self)
|
||||||
try c.encode(jobs, forKey: .jobs)
|
try c.encode(jobs, forKey: .jobs)
|
||||||
try c.encodeIfPresent(updatedAt, forKey: .updatedAt)
|
try c.encodeIfPresent(updatedAt, forKey: .updatedAt)
|
||||||
@@ -0,0 +1,361 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// Parsed view of `hermes curator status` text + the on-disk
|
||||||
|
/// `~/.hermes/skills/.curator_state` JSON.
|
||||||
|
///
|
||||||
|
/// Hermes v0.12 doesn't ship a `--json` flag for `curator status` — the
|
||||||
|
/// CLI writes a human-readable report. CuratorViewModel parses the text
|
||||||
|
/// output for the human-readable bits ("least recently active", "most
|
||||||
|
/// active") and reads the state file directly for last-run metadata.
|
||||||
|
public struct HermesCuratorStatus: Sendable, Equatable {
|
||||||
|
public enum RunState: String, Sendable, Equatable {
|
||||||
|
case enabled
|
||||||
|
case paused
|
||||||
|
case disabled
|
||||||
|
case unknown
|
||||||
|
}
|
||||||
|
|
||||||
|
public let state: RunState
|
||||||
|
public let runCount: Int
|
||||||
|
public let lastRunISO: String? // raw timestamp string, parsed by callers
|
||||||
|
public let lastSummary: String? // free-text summary line
|
||||||
|
public let lastReportPath: String? // absolute path to <YYYYMMDD-HHMMSS>/ dir
|
||||||
|
public let intervalLabel: String // e.g. "every 7d"
|
||||||
|
public let staleAfterLabel: String // e.g. "30d unused"
|
||||||
|
public let archiveAfterLabel: String // e.g. "90d unused"
|
||||||
|
|
||||||
|
public let totalSkills: Int
|
||||||
|
public let activeSkills: Int
|
||||||
|
public let staleSkills: Int
|
||||||
|
public let archivedSkills: Int
|
||||||
|
|
||||||
|
public let pinnedNames: [String]
|
||||||
|
|
||||||
|
/// Top-5 lists rendered in the curator output. Each row carries the
|
||||||
|
/// skill name + the four counters Hermes prints.
|
||||||
|
public let leastRecentlyActive: [HermesCuratorSkillRow]
|
||||||
|
public let mostActive: [HermesCuratorSkillRow]
|
||||||
|
public let leastActive: [HermesCuratorSkillRow]
|
||||||
|
|
||||||
|
public init(
|
||||||
|
state: RunState,
|
||||||
|
runCount: Int,
|
||||||
|
lastRunISO: String?,
|
||||||
|
lastSummary: String?,
|
||||||
|
lastReportPath: String?,
|
||||||
|
intervalLabel: String,
|
||||||
|
staleAfterLabel: String,
|
||||||
|
archiveAfterLabel: String,
|
||||||
|
totalSkills: Int,
|
||||||
|
activeSkills: Int,
|
||||||
|
staleSkills: Int,
|
||||||
|
archivedSkills: Int,
|
||||||
|
pinnedNames: [String],
|
||||||
|
leastRecentlyActive: [HermesCuratorSkillRow],
|
||||||
|
mostActive: [HermesCuratorSkillRow],
|
||||||
|
leastActive: [HermesCuratorSkillRow]
|
||||||
|
) {
|
||||||
|
self.state = state
|
||||||
|
self.runCount = runCount
|
||||||
|
self.lastRunISO = lastRunISO
|
||||||
|
self.lastSummary = lastSummary
|
||||||
|
self.lastReportPath = lastReportPath
|
||||||
|
self.intervalLabel = intervalLabel
|
||||||
|
self.staleAfterLabel = staleAfterLabel
|
||||||
|
self.archiveAfterLabel = archiveAfterLabel
|
||||||
|
self.totalSkills = totalSkills
|
||||||
|
self.activeSkills = activeSkills
|
||||||
|
self.staleSkills = staleSkills
|
||||||
|
self.archivedSkills = archivedSkills
|
||||||
|
self.pinnedNames = pinnedNames
|
||||||
|
self.leastRecentlyActive = leastRecentlyActive
|
||||||
|
self.mostActive = mostActive
|
||||||
|
self.leastActive = leastActive
|
||||||
|
}
|
||||||
|
|
||||||
|
public static let empty = HermesCuratorStatus(
|
||||||
|
state: .unknown,
|
||||||
|
runCount: 0,
|
||||||
|
lastRunISO: nil,
|
||||||
|
lastSummary: nil,
|
||||||
|
lastReportPath: nil,
|
||||||
|
intervalLabel: "—",
|
||||||
|
staleAfterLabel: "—",
|
||||||
|
archiveAfterLabel: "—",
|
||||||
|
totalSkills: 0,
|
||||||
|
activeSkills: 0,
|
||||||
|
staleSkills: 0,
|
||||||
|
archivedSkills: 0,
|
||||||
|
pinnedNames: [],
|
||||||
|
leastRecentlyActive: [],
|
||||||
|
mostActive: [],
|
||||||
|
leastActive: []
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
public struct HermesCuratorSkillRow: Sendable, Equatable, Identifiable {
|
||||||
|
public var id: String { name }
|
||||||
|
public let name: String
|
||||||
|
public let activityCount: Int
|
||||||
|
public let useCount: Int
|
||||||
|
public let viewCount: Int
|
||||||
|
public let patchCount: Int
|
||||||
|
public let lastActivityLabel: String // raw label as printed (e.g. "never", "2d ago")
|
||||||
|
|
||||||
|
public init(
|
||||||
|
name: String,
|
||||||
|
activityCount: Int,
|
||||||
|
useCount: Int,
|
||||||
|
viewCount: Int,
|
||||||
|
patchCount: Int,
|
||||||
|
lastActivityLabel: String
|
||||||
|
) {
|
||||||
|
self.name = name
|
||||||
|
self.activityCount = activityCount
|
||||||
|
self.useCount = useCount
|
||||||
|
self.viewCount = viewCount
|
||||||
|
self.patchCount = patchCount
|
||||||
|
self.lastActivityLabel = lastActivityLabel
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Pure parser for `hermes curator status` stdout. Public for tests.
|
||||||
|
///
|
||||||
|
/// Format is stable enough to text-parse; we never error on missing
|
||||||
|
/// sections — we just leave the corresponding field empty so
|
||||||
|
/// CuratorView can render "—" without crashing on a future layout
|
||||||
|
/// tweak. State file overrides text-parsed values when both are present.
|
||||||
|
public enum HermesCuratorStatusParser {
|
||||||
|
public static func parse(text: String, stateFileJSON: Data? = nil) -> HermesCuratorStatus {
|
||||||
|
let lines = text.components(separatedBy: "\n")
|
||||||
|
var status = HermesCuratorStatus.empty
|
||||||
|
|
||||||
|
// Header section: `curator: ENABLED` / `runs:` / `last run:` /
|
||||||
|
// `last summary:` / `interval:` / `stale after:` / `archive after:`
|
||||||
|
var state = HermesCuratorStatus.RunState.unknown
|
||||||
|
var runCount = 0
|
||||||
|
var lastRunISO: String?
|
||||||
|
var lastSummary: String?
|
||||||
|
var lastReportPath: String?
|
||||||
|
var interval = "—"
|
||||||
|
var stale = "—"
|
||||||
|
var archive = "—"
|
||||||
|
|
||||||
|
// Skill counts: `agent-created skills: N total` then
|
||||||
|
// ` active N` / ` stale N` / ` archived N`
|
||||||
|
var total = 0
|
||||||
|
var active = 0
|
||||||
|
var staleCount = 0
|
||||||
|
var archived = 0
|
||||||
|
|
||||||
|
var pinned: [String] = []
|
||||||
|
|
||||||
|
// Lists: `least recently active (top 5):` / `most active (top 5):` /
|
||||||
|
// `least active (top 5):` followed by indented row lines.
|
||||||
|
enum Section {
|
||||||
|
case header
|
||||||
|
case leastRecent
|
||||||
|
case mostActive
|
||||||
|
case leastActive
|
||||||
|
}
|
||||||
|
var section = Section.header
|
||||||
|
var leastRecent: [HermesCuratorSkillRow] = []
|
||||||
|
var mostActiveRows: [HermesCuratorSkillRow] = []
|
||||||
|
var leastActiveRows: [HermesCuratorSkillRow] = []
|
||||||
|
|
||||||
|
for raw in lines {
|
||||||
|
let line = raw.trimmingCharacters(in: .whitespaces)
|
||||||
|
// Section markers
|
||||||
|
if line.hasPrefix("least recently active") {
|
||||||
|
section = .leastRecent
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if line.hasPrefix("most active") {
|
||||||
|
section = .mostActive
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if line.hasPrefix("least active") {
|
||||||
|
section = .leastActive
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Header section single-line keys
|
||||||
|
if line.hasPrefix("curator:") {
|
||||||
|
let val = String(line.dropFirst("curator:".count)).trimmingCharacters(in: .whitespaces).uppercased()
|
||||||
|
switch val {
|
||||||
|
case "ENABLED": state = .enabled
|
||||||
|
case "PAUSED": state = .paused
|
||||||
|
case "DISABLED": state = .disabled
|
||||||
|
default: state = .unknown
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if line.hasPrefix("runs:") {
|
||||||
|
runCount = Int(line.dropFirst("runs:".count).trimmingCharacters(in: .whitespaces)) ?? 0
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if line.hasPrefix("last run:") {
|
||||||
|
let val = String(line.dropFirst("last run:".count)).trimmingCharacters(in: .whitespaces)
|
||||||
|
lastRunISO = val == "never" ? nil : val
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if line.hasPrefix("last summary:") {
|
||||||
|
let val = String(line.dropFirst("last summary:".count)).trimmingCharacters(in: .whitespaces)
|
||||||
|
lastSummary = (val == "(none)" || val.isEmpty) ? nil : val
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if line.hasPrefix("last report:") {
|
||||||
|
let val = String(line.dropFirst("last report:".count)).trimmingCharacters(in: .whitespaces)
|
||||||
|
lastReportPath = val.isEmpty ? nil : val
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if line.hasPrefix("interval:") {
|
||||||
|
interval = String(line.dropFirst("interval:".count)).trimmingCharacters(in: .whitespaces)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if line.hasPrefix("stale after:") {
|
||||||
|
stale = String(line.dropFirst("stale after:".count)).trimmingCharacters(in: .whitespaces)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if line.hasPrefix("archive after:") {
|
||||||
|
archive = String(line.dropFirst("archive after:".count)).trimmingCharacters(in: .whitespaces)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// `agent-created skills: 18 total`
|
||||||
|
if line.hasPrefix("agent-created skills:") {
|
||||||
|
let after = line.dropFirst("agent-created skills:".count).trimmingCharacters(in: .whitespaces)
|
||||||
|
if let n = Int(after.split(separator: " ").first ?? "") {
|
||||||
|
total = n
|
||||||
|
}
|
||||||
|
section = .header
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
// Counts: "active 18" / "stale 0" / "archived 0"
|
||||||
|
if let row = parseStateCountRow(line) {
|
||||||
|
switch row.state {
|
||||||
|
case "active": active = row.count
|
||||||
|
case "stale": staleCount = row.count
|
||||||
|
case "archived": archived = row.count
|
||||||
|
default: break
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
// pinned (3): foo, bar, baz
|
||||||
|
if line.hasPrefix("pinned (") {
|
||||||
|
if let colon = line.firstIndex(of: ":") {
|
||||||
|
let names = line[line.index(after: colon)...]
|
||||||
|
.split(separator: ",")
|
||||||
|
.map { $0.trimmingCharacters(in: .whitespaces) }
|
||||||
|
.filter { !$0.isEmpty }
|
||||||
|
pinned = names
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Skill rows like:
|
||||||
|
// <name> activity= N use= N view= N patches= N last_activity=<label>
|
||||||
|
if section != .header, let parsed = parseSkillRow(line) {
|
||||||
|
switch section {
|
||||||
|
case .leastRecent: leastRecent.append(parsed)
|
||||||
|
case .mostActive: mostActiveRows.append(parsed)
|
||||||
|
case .leastActive: leastActiveRows.append(parsed)
|
||||||
|
case .header: break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Apply state-file overrides if present. The .curator_state JSON
|
||||||
|
// is authoritative for last_run_at / last_run_summary /
|
||||||
|
// last_report_path because those carry timestamps the text
|
||||||
|
// output rounds.
|
||||||
|
if let json = stateFileJSON,
|
||||||
|
let obj = try? JSONSerialization.jsonObject(with: json) as? [String: Any] {
|
||||||
|
if obj["paused"] as? Bool == true { state = .paused }
|
||||||
|
if let count = obj["run_count"] as? Int { runCount = count }
|
||||||
|
if let lr = obj["last_run_at"] as? String { lastRunISO = lr }
|
||||||
|
if let summary = obj["last_run_summary"] as? String, !summary.isEmpty { lastSummary = summary }
|
||||||
|
if let path = obj["last_report_path"] as? String, !path.isEmpty { lastReportPath = path }
|
||||||
|
}
|
||||||
|
|
||||||
|
status = HermesCuratorStatus(
|
||||||
|
state: state,
|
||||||
|
runCount: runCount,
|
||||||
|
lastRunISO: lastRunISO,
|
||||||
|
lastSummary: lastSummary,
|
||||||
|
lastReportPath: lastReportPath,
|
||||||
|
intervalLabel: interval,
|
||||||
|
staleAfterLabel: stale,
|
||||||
|
archiveAfterLabel: archive,
|
||||||
|
totalSkills: total,
|
||||||
|
activeSkills: active,
|
||||||
|
staleSkills: staleCount,
|
||||||
|
archivedSkills: archived,
|
||||||
|
pinnedNames: pinned,
|
||||||
|
leastRecentlyActive: leastRecent,
|
||||||
|
mostActive: mostActiveRows,
|
||||||
|
leastActive: leastActiveRows
|
||||||
|
)
|
||||||
|
return status
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `active 18` style row inside the skill-count block.
|
||||||
|
private static func parseStateCountRow(_ line: String) -> (state: String, count: Int)? {
|
||||||
|
let parts = line.split(whereSeparator: { $0 == " " || $0 == "\t" }).map(String.init)
|
||||||
|
guard parts.count >= 2,
|
||||||
|
["active", "stale", "archived"].contains(parts[0]),
|
||||||
|
let count = Int(parts[1])
|
||||||
|
else { return nil }
|
||||||
|
return (parts[0], count)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Skill-list row parser. Tolerates Hermes's whitespace-padded
|
||||||
|
/// layout — `activity= 0` has two spaces between `=` and the
|
||||||
|
/// number, so we can't split-on-space-then-split-on-`=`. Instead
|
||||||
|
/// we slide a key-detection cursor across the row and grab the
|
||||||
|
/// next non-whitespace token after each known key.
|
||||||
|
private static func parseSkillRow(_ line: String) -> HermesCuratorSkillRow? {
|
||||||
|
guard let activityRange = line.range(of: "activity=") else { return nil }
|
||||||
|
let name = String(line[..<activityRange.lowerBound]).trimmingCharacters(in: .whitespaces)
|
||||||
|
guard !name.isEmpty else { return nil }
|
||||||
|
|
||||||
|
// Map each known key to its value substring. Read positionally
|
||||||
|
// by slicing between consecutive known keys — handles arbitrary
|
||||||
|
// whitespace padding without depending on column positions.
|
||||||
|
let knownKeys = ["activity=", "use=", "view=", "patches=", "last_activity="]
|
||||||
|
var positions: [(key: String, range: Range<String.Index>)] = []
|
||||||
|
for key in knownKeys {
|
||||||
|
if let r = line.range(of: key) {
|
||||||
|
positions.append((key, r))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
positions.sort { $0.range.lowerBound < $1.range.lowerBound }
|
||||||
|
|
||||||
|
var activity = 0, use = 0, view = 0, patch = 0
|
||||||
|
var lastActivity = ""
|
||||||
|
|
||||||
|
for (idx, entry) in positions.enumerated() {
|
||||||
|
let valueStart = entry.range.upperBound
|
||||||
|
let valueEnd = idx + 1 < positions.count
|
||||||
|
? positions[idx + 1].range.lowerBound
|
||||||
|
: line.endIndex
|
||||||
|
let raw = String(line[valueStart..<valueEnd]).trimmingCharacters(in: .whitespaces)
|
||||||
|
switch entry.key {
|
||||||
|
case "activity=": activity = Int(raw) ?? 0
|
||||||
|
case "use=": use = Int(raw) ?? 0
|
||||||
|
case "view=": view = Int(raw) ?? 0
|
||||||
|
case "patches=": patch = Int(raw) ?? 0
|
||||||
|
case "last_activity=": lastActivity = raw
|
||||||
|
default: break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return HermesCuratorSkillRow(
|
||||||
|
name: name,
|
||||||
|
activityCount: activity,
|
||||||
|
useCount: use,
|
||||||
|
viewCount: view,
|
||||||
|
patchCount: patch,
|
||||||
|
lastActivityLabel: lastActivity
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,90 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// One task from `hermes kanban list --json` (v0.12+).
|
||||||
|
///
|
||||||
|
/// Hermes ships a SQLite-backed task board under `~/.hermes/kanban.db`
|
||||||
|
/// — multi-profile collaboration was reverted upstream while the
|
||||||
|
/// design is reworked, so Scarf v2.6 surfaces this as a read-only
|
||||||
|
/// list. Create / claim / dispatch / dependency-link UI is deferred
|
||||||
|
/// until upstream stabilizes.
|
||||||
|
public struct HermesKanbanTask: Sendable, Equatable, Identifiable, Codable {
|
||||||
|
public let id: String
|
||||||
|
public let title: String
|
||||||
|
public let body: String?
|
||||||
|
public let assignee: String?
|
||||||
|
public let status: String // archived | blocked | done | ready | running | todo | triage
|
||||||
|
public let priority: Int?
|
||||||
|
public let tenant: String?
|
||||||
|
public let workspaceKind: String? // scratch | worktree | dir
|
||||||
|
public let workspacePath: String?
|
||||||
|
public let createdBy: String?
|
||||||
|
public let createdAt: String? // ISO timestamp
|
||||||
|
public let startedAt: String?
|
||||||
|
public let completedAt: String?
|
||||||
|
public let result: String?
|
||||||
|
public let skills: [String]
|
||||||
|
|
||||||
|
public init(
|
||||||
|
id: String,
|
||||||
|
title: String,
|
||||||
|
body: String? = nil,
|
||||||
|
assignee: String? = nil,
|
||||||
|
status: String,
|
||||||
|
priority: Int? = nil,
|
||||||
|
tenant: String? = nil,
|
||||||
|
workspaceKind: String? = nil,
|
||||||
|
workspacePath: String? = nil,
|
||||||
|
createdBy: String? = nil,
|
||||||
|
createdAt: String? = nil,
|
||||||
|
startedAt: String? = nil,
|
||||||
|
completedAt: String? = nil,
|
||||||
|
result: String? = nil,
|
||||||
|
skills: [String] = []
|
||||||
|
) {
|
||||||
|
self.id = id
|
||||||
|
self.title = title
|
||||||
|
self.body = body
|
||||||
|
self.assignee = assignee
|
||||||
|
self.status = status
|
||||||
|
self.priority = priority
|
||||||
|
self.tenant = tenant
|
||||||
|
self.workspaceKind = workspaceKind
|
||||||
|
self.workspacePath = workspacePath
|
||||||
|
self.createdBy = createdBy
|
||||||
|
self.createdAt = createdAt
|
||||||
|
self.startedAt = startedAt
|
||||||
|
self.completedAt = completedAt
|
||||||
|
self.result = result
|
||||||
|
self.skills = skills
|
||||||
|
}
|
||||||
|
|
||||||
|
enum CodingKeys: String, CodingKey {
|
||||||
|
case id, title, body, assignee, status, priority, tenant
|
||||||
|
case workspaceKind = "workspace_kind"
|
||||||
|
case workspacePath = "workspace_path"
|
||||||
|
case createdBy = "created_by"
|
||||||
|
case createdAt = "created_at"
|
||||||
|
case startedAt = "started_at"
|
||||||
|
case completedAt = "completed_at"
|
||||||
|
case result, skills
|
||||||
|
}
|
||||||
|
|
||||||
|
public init(from decoder: any Decoder) throws {
|
||||||
|
let c = try decoder.container(keyedBy: CodingKeys.self)
|
||||||
|
self.id = try c.decode(String.self, forKey: .id)
|
||||||
|
self.title = try c.decode(String.self, forKey: .title)
|
||||||
|
self.body = try c.decodeIfPresent(String.self, forKey: .body)
|
||||||
|
self.assignee = try c.decodeIfPresent(String.self, forKey: .assignee)
|
||||||
|
self.status = try c.decodeIfPresent(String.self, forKey: .status) ?? "unknown"
|
||||||
|
self.priority = try c.decodeIfPresent(Int.self, forKey: .priority)
|
||||||
|
self.tenant = try c.decodeIfPresent(String.self, forKey: .tenant)
|
||||||
|
self.workspaceKind = try c.decodeIfPresent(String.self, forKey: .workspaceKind)
|
||||||
|
self.workspacePath = try c.decodeIfPresent(String.self, forKey: .workspacePath)
|
||||||
|
self.createdBy = try c.decodeIfPresent(String.self, forKey: .createdBy)
|
||||||
|
self.createdAt = try c.decodeIfPresent(String.self, forKey: .createdAt)
|
||||||
|
self.startedAt = try c.decodeIfPresent(String.self, forKey: .startedAt)
|
||||||
|
self.completedAt = try c.decodeIfPresent(String.self, forKey: .completedAt)
|
||||||
|
self.result = try c.decodeIfPresent(String.self, forKey: .result)
|
||||||
|
self.skills = try c.decodeIfPresent([String].self, forKey: .skills) ?? []
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,106 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
public enum MCPTransport: String, Sendable, Equatable, CaseIterable, Identifiable {
|
||||||
|
case stdio
|
||||||
|
case http
|
||||||
|
|
||||||
|
public var id: String { rawValue }
|
||||||
|
|
||||||
|
#if canImport(Darwin)
|
||||||
|
public var displayName: LocalizedStringResource {
|
||||||
|
switch self {
|
||||||
|
case .stdio: return "Local (stdio)"
|
||||||
|
case .http: return "Remote (HTTP)"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
|
||||||
|
public struct HermesMCPServer: Identifiable, Sendable, Equatable {
|
||||||
|
public let name: String
|
||||||
|
public let transport: MCPTransport
|
||||||
|
public let command: String?
|
||||||
|
public let args: [String]
|
||||||
|
public let url: String?
|
||||||
|
public let auth: String?
|
||||||
|
public let env: [String: String]
|
||||||
|
public let headers: [String: String]
|
||||||
|
public let timeout: Int?
|
||||||
|
public let connectTimeout: Int?
|
||||||
|
public let enabled: Bool
|
||||||
|
public let toolsInclude: [String]
|
||||||
|
public let toolsExclude: [String]
|
||||||
|
public let resourcesEnabled: Bool
|
||||||
|
public let promptsEnabled: Bool
|
||||||
|
public let hasOAuthToken: Bool
|
||||||
|
|
||||||
|
|
||||||
|
public init(
|
||||||
|
name: String,
|
||||||
|
transport: MCPTransport,
|
||||||
|
command: String?,
|
||||||
|
args: [String],
|
||||||
|
url: String?,
|
||||||
|
auth: String?,
|
||||||
|
env: [String: String],
|
||||||
|
headers: [String: String],
|
||||||
|
timeout: Int?,
|
||||||
|
connectTimeout: Int?,
|
||||||
|
enabled: Bool,
|
||||||
|
toolsInclude: [String],
|
||||||
|
toolsExclude: [String],
|
||||||
|
resourcesEnabled: Bool,
|
||||||
|
promptsEnabled: Bool,
|
||||||
|
hasOAuthToken: Bool
|
||||||
|
) {
|
||||||
|
self.name = name
|
||||||
|
self.transport = transport
|
||||||
|
self.command = command
|
||||||
|
self.args = args
|
||||||
|
self.url = url
|
||||||
|
self.auth = auth
|
||||||
|
self.env = env
|
||||||
|
self.headers = headers
|
||||||
|
self.timeout = timeout
|
||||||
|
self.connectTimeout = connectTimeout
|
||||||
|
self.enabled = enabled
|
||||||
|
self.toolsInclude = toolsInclude
|
||||||
|
self.toolsExclude = toolsExclude
|
||||||
|
self.resourcesEnabled = resourcesEnabled
|
||||||
|
self.promptsEnabled = promptsEnabled
|
||||||
|
self.hasOAuthToken = hasOAuthToken
|
||||||
|
}
|
||||||
|
public var id: String { name }
|
||||||
|
|
||||||
|
public var summary: String {
|
||||||
|
switch transport {
|
||||||
|
case .stdio:
|
||||||
|
let argString = args.isEmpty ? "" : " " + args.joined(separator: " ")
|
||||||
|
return (command ?? "") + argString
|
||||||
|
case .http:
|
||||||
|
return url ?? ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public struct MCPTestResult: Sendable, Equatable {
|
||||||
|
public let serverName: String
|
||||||
|
public let succeeded: Bool
|
||||||
|
public let output: String
|
||||||
|
public let tools: [String]
|
||||||
|
public let elapsed: TimeInterval
|
||||||
|
|
||||||
|
public init(
|
||||||
|
serverName: String,
|
||||||
|
succeeded: Bool,
|
||||||
|
output: String,
|
||||||
|
tools: [String],
|
||||||
|
elapsed: TimeInterval
|
||||||
|
) {
|
||||||
|
self.serverName = serverName
|
||||||
|
self.succeeded = succeeded
|
||||||
|
self.output = output
|
||||||
|
self.tools = tools
|
||||||
|
self.elapsed = elapsed
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,254 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
public struct HermesMessage: Identifiable, Sendable {
|
||||||
|
public let id: Int
|
||||||
|
public let sessionId: String
|
||||||
|
public let role: String
|
||||||
|
public let content: String
|
||||||
|
public let toolCallId: String?
|
||||||
|
public let toolCalls: [HermesToolCall]
|
||||||
|
public let toolName: String?
|
||||||
|
public let timestamp: Date?
|
||||||
|
public let tokenCount: Int?
|
||||||
|
public let finishReason: String?
|
||||||
|
public let reasoning: String?
|
||||||
|
/// Hermes v2026.4.23+ richer reasoning column. Some providers
|
||||||
|
/// emit a structured "thinking" payload separate from the
|
||||||
|
/// classic `reasoning` blob; both can be present on the same
|
||||||
|
/// message during the v0.10 → v0.11 transition. UI prefers
|
||||||
|
/// `reasoningContent` when set, falls back to `reasoning`.
|
||||||
|
public let reasoningContent: String?
|
||||||
|
|
||||||
|
|
||||||
|
public init(
|
||||||
|
id: Int,
|
||||||
|
sessionId: String,
|
||||||
|
role: String,
|
||||||
|
content: String,
|
||||||
|
toolCallId: String?,
|
||||||
|
toolCalls: [HermesToolCall],
|
||||||
|
toolName: String?,
|
||||||
|
timestamp: Date?,
|
||||||
|
tokenCount: Int?,
|
||||||
|
finishReason: String?,
|
||||||
|
reasoning: String?,
|
||||||
|
reasoningContent: String? = nil
|
||||||
|
) {
|
||||||
|
self.id = id
|
||||||
|
self.sessionId = sessionId
|
||||||
|
self.role = role
|
||||||
|
self.content = content
|
||||||
|
self.toolCallId = toolCallId
|
||||||
|
self.toolCalls = toolCalls
|
||||||
|
self.toolName = toolName
|
||||||
|
self.timestamp = timestamp
|
||||||
|
self.tokenCount = tokenCount
|
||||||
|
self.finishReason = finishReason
|
||||||
|
self.reasoning = reasoning
|
||||||
|
self.reasoningContent = reasoningContent
|
||||||
|
}
|
||||||
|
public var isUser: Bool { role == "user" }
|
||||||
|
public var isAssistant: Bool { role == "assistant" }
|
||||||
|
public var isToolResult: Bool { role == "tool" }
|
||||||
|
/// True when ANY reasoning channel has content. UI uses this to
|
||||||
|
/// decide whether to render the "Thinking…" disclosure.
|
||||||
|
public var hasReasoning: Bool {
|
||||||
|
let r = reasoning ?? ""
|
||||||
|
let rc = reasoningContent ?? ""
|
||||||
|
return !r.isEmpty || !rc.isEmpty
|
||||||
|
}
|
||||||
|
/// Preferred reasoning text for rendering — `reasoningContent`
|
||||||
|
/// (newer, richer) wins over the legacy `reasoning` blob when
|
||||||
|
/// both are present.
|
||||||
|
public var preferredReasoning: String? {
|
||||||
|
if let rc = reasoningContent, !rc.isEmpty { return rc }
|
||||||
|
return reasoning
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Return a copy of this message with `toolCalls` replaced. Used
|
||||||
|
/// by the v2.8 two-phase chat loader: skeleton fetch returns
|
||||||
|
/// messages with empty `toolCalls`; the background hydrate splices
|
||||||
|
/// the parsed values in without re-fetching the conversational
|
||||||
|
/// columns.
|
||||||
|
public func withToolCalls(_ newCalls: [HermesToolCall]) -> HermesMessage {
|
||||||
|
HermesMessage(
|
||||||
|
id: id,
|
||||||
|
sessionId: sessionId,
|
||||||
|
role: role,
|
||||||
|
content: content,
|
||||||
|
toolCallId: toolCallId,
|
||||||
|
toolCalls: newCalls,
|
||||||
|
toolName: toolName,
|
||||||
|
timestamp: timestamp,
|
||||||
|
tokenCount: tokenCount,
|
||||||
|
finishReason: finishReason,
|
||||||
|
reasoning: reasoning,
|
||||||
|
reasoningContent: reasoningContent
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public struct HermesToolCall: Identifiable, Sendable, Codable {
|
||||||
|
public var id: String { callId }
|
||||||
|
public let callId: String
|
||||||
|
public let functionName: String
|
||||||
|
public let arguments: String
|
||||||
|
|
||||||
|
/// Wall-clock duration of the tool call. Set on ACP `toolCallComplete`
|
||||||
|
/// (or equivalent) by `RichChatViewModel`. Nil for sessions loaded
|
||||||
|
/// from `state.db` (no live timing) and for in-flight calls.
|
||||||
|
public var duration: TimeInterval?
|
||||||
|
|
||||||
|
/// Process exit code, when the tool kind is `.execute` and the
|
||||||
|
/// tool-result message exposes one. Best-effort parse of the result
|
||||||
|
/// content; nil when not applicable / not parseable.
|
||||||
|
public var exitCode: Int?
|
||||||
|
|
||||||
|
/// Wall-clock timestamp the call was emitted by Hermes. Set on ACP
|
||||||
|
/// `toolCallStart`. Nil for sessions loaded from `state.db`.
|
||||||
|
public var startedAt: Date?
|
||||||
|
|
||||||
|
public enum CodingKeys: String, CodingKey {
|
||||||
|
case callId = "id"
|
||||||
|
case type
|
||||||
|
case function
|
||||||
|
}
|
||||||
|
|
||||||
|
public enum FunctionKeys: String, CodingKey {
|
||||||
|
case name
|
||||||
|
case arguments
|
||||||
|
}
|
||||||
|
|
||||||
|
public init(
|
||||||
|
callId: String,
|
||||||
|
functionName: String,
|
||||||
|
arguments: String,
|
||||||
|
duration: TimeInterval? = nil,
|
||||||
|
exitCode: Int? = nil,
|
||||||
|
startedAt: Date? = nil
|
||||||
|
) {
|
||||||
|
self.callId = callId
|
||||||
|
self.functionName = functionName
|
||||||
|
self.arguments = arguments
|
||||||
|
self.duration = duration
|
||||||
|
self.exitCode = exitCode
|
||||||
|
self.startedAt = startedAt
|
||||||
|
}
|
||||||
|
|
||||||
|
public init(from decoder: Decoder) throws {
|
||||||
|
let container = try decoder.container(keyedBy: CodingKeys.self)
|
||||||
|
callId = try container.decode(String.self, forKey: .callId)
|
||||||
|
let funcContainer = try container.nestedContainer(keyedBy: FunctionKeys.self, forKey: .function)
|
||||||
|
functionName = try funcContainer.decode(String.self, forKey: .name)
|
||||||
|
arguments = try funcContainer.decode(String.self, forKey: .arguments)
|
||||||
|
// Telemetry fields are populated locally from ACP events, never
|
||||||
|
// persisted via Codable, so they decode as nil.
|
||||||
|
duration = nil
|
||||||
|
exitCode = nil
|
||||||
|
startedAt = nil
|
||||||
|
}
|
||||||
|
|
||||||
|
public func encode(to encoder: Encoder) throws {
|
||||||
|
var container = encoder.container(keyedBy: CodingKeys.self)
|
||||||
|
try container.encode(callId, forKey: .callId)
|
||||||
|
try container.encode("function", forKey: .type)
|
||||||
|
var funcContainer = container.nestedContainer(keyedBy: FunctionKeys.self, forKey: .function)
|
||||||
|
try funcContainer.encode(functionName, forKey: .name)
|
||||||
|
try funcContainer.encode(arguments, forKey: .arguments)
|
||||||
|
}
|
||||||
|
|
||||||
|
public var toolKind: ToolKind {
|
||||||
|
switch functionName {
|
||||||
|
case "read_file", "search_files", "vision_analyze": return .read
|
||||||
|
case "write_file", "patch": return .edit
|
||||||
|
case "terminal", "execute_code": return .execute
|
||||||
|
case "web_search", "web_extract": return .fetch
|
||||||
|
case "browser_navigate", "browser_click", "browser_screenshot": return .browser
|
||||||
|
default: return .other
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public var argumentsSummary: String {
|
||||||
|
guard let data = arguments.data(using: .utf8),
|
||||||
|
let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else {
|
||||||
|
return arguments
|
||||||
|
}
|
||||||
|
if let command = json["command"] as? String {
|
||||||
|
return command
|
||||||
|
}
|
||||||
|
if let path = json["path"] as? String {
|
||||||
|
return path
|
||||||
|
}
|
||||||
|
if let query = json["query"] as? String {
|
||||||
|
return query
|
||||||
|
}
|
||||||
|
if let url = json["url"] as? String {
|
||||||
|
return url
|
||||||
|
}
|
||||||
|
return arguments.prefix(120) + (arguments.count > 120 ? "..." : "")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public enum ToolKind: String, Sendable, CaseIterable {
|
||||||
|
case read
|
||||||
|
case edit
|
||||||
|
case execute
|
||||||
|
case fetch
|
||||||
|
case browser
|
||||||
|
case other
|
||||||
|
|
||||||
|
#if canImport(Darwin)
|
||||||
|
public var displayName: LocalizedStringResource {
|
||||||
|
switch self {
|
||||||
|
case .read: return "Read"
|
||||||
|
case .edit: return "Edit"
|
||||||
|
case .execute: return "Execute"
|
||||||
|
case .fetch: return "Fetch"
|
||||||
|
case .browser: return "Browser"
|
||||||
|
case .other: return "Other"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
#endif
|
||||||
|
|
||||||
|
public var icon: String {
|
||||||
|
switch self {
|
||||||
|
case .read: return "doc.text.magnifyingglass"
|
||||||
|
case .edit: return "pencil"
|
||||||
|
case .execute: return "terminal"
|
||||||
|
case .fetch: return "globe"
|
||||||
|
case .browser: return "safari"
|
||||||
|
case .other: return "gearshape"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public var color: String {
|
||||||
|
switch self {
|
||||||
|
case .read: return "green"
|
||||||
|
case .edit: return "blue"
|
||||||
|
case .execute: return "orange"
|
||||||
|
case .fetch: return "purple"
|
||||||
|
case .browser: return "indigo"
|
||||||
|
case .other: return "gray"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Outcome of a `fetchMessagesOutcome` call. `transportError` is non-nil
|
||||||
|
/// only when the underlying SSH/SQLite call hit a transport-layer
|
||||||
|
/// failure (timeout, ControlMaster drop) — distinguishes a genuine
|
||||||
|
/// empty session from a silent partial-load. The chat resume path uses
|
||||||
|
/// it to surface a "couldn't load full history" banner.
|
||||||
|
public struct MessageFetchOutcome: Sendable {
|
||||||
|
public let messages: [HermesMessage]
|
||||||
|
public let transportError: String?
|
||||||
|
|
||||||
|
public init(messages: [HermesMessage], transportError: String?) {
|
||||||
|
self.messages = messages
|
||||||
|
self.transportError = transportError
|
||||||
|
}
|
||||||
|
|
||||||
|
/// True when the fetch tripped a transport failure. Distinct from
|
||||||
|
/// `messages.isEmpty` — an empty session is a successful zero-row
|
||||||
|
/// result, while a transport error is "we don't know what's there."
|
||||||
|
public var didTimeOut: Bool { transportError != nil }
|
||||||
|
}
|
||||||
@@ -0,0 +1,141 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// The filesystem layout of a Hermes installation, parameterized by the
|
||||||
|
/// `home` directory. The same layout is used for local installations (where
|
||||||
|
/// `home` is an absolute macOS path like `/Users/alan/.hermes`) and for
|
||||||
|
/// remote installations reached over SSH (where `home` is a remote path like
|
||||||
|
/// `/home/deploy/.hermes` or an unexpanded `~/.hermes` that the remote shell
|
||||||
|
/// will resolve).
|
||||||
|
///
|
||||||
|
/// Every path that used to live as a module-level static on `HermesPaths` is
|
||||||
|
/// an instance property here. `ServerContext.paths` is the canonical way to
|
||||||
|
/// reach these values; the old `HermesPaths` statics are preserved as
|
||||||
|
/// deprecated forwarders so Phase 1 can migrate call sites incrementally.
|
||||||
|
public struct HermesPathSet: Sendable, Hashable {
|
||||||
|
public let home: String
|
||||||
|
/// `true` when this path set belongs to a remote installation. Affects
|
||||||
|
/// only `hermesBinary` resolution — every other path is identical in
|
||||||
|
/// shape between local and remote.
|
||||||
|
public let isRemote: Bool
|
||||||
|
/// Pre-resolved remote binary path (e.g. `/home/deploy/.local/bin/hermes`).
|
||||||
|
/// Populated by `SSHTransport` once `command -v hermes` has run on the
|
||||||
|
/// target host. Unused when `isRemote == false`.
|
||||||
|
public let binaryHint: String?
|
||||||
|
|
||||||
|
// MARK: - Defaults
|
||||||
|
|
||||||
|
/// Absolute path to the local user's `~/.hermes` directory.
|
||||||
|
|
||||||
|
public init(
|
||||||
|
home: String,
|
||||||
|
isRemote: Bool,
|
||||||
|
binaryHint: String?
|
||||||
|
) {
|
||||||
|
self.home = home
|
||||||
|
self.isRemote = isRemote
|
||||||
|
self.binaryHint = binaryHint
|
||||||
|
}
|
||||||
|
/// Resolved path to the active local Hermes profile (issue #50).
|
||||||
|
///
|
||||||
|
/// Hermes v0.11+ supports multiple profiles via `hermes profile use`;
|
||||||
|
/// each profile is a fully independent `HERMES_HOME` directory. We
|
||||||
|
/// delegate to `HermesProfileResolver` (which reads
|
||||||
|
/// `~/.hermes/active_profile`) so every derived path — `state.db`,
|
||||||
|
/// `sessions/`, `config.yaml`, `memories/`, etc. — automatically
|
||||||
|
/// follows the active profile. Returns the pre-profile default
|
||||||
|
/// `~/.hermes` whenever no named profile is active, so existing
|
||||||
|
/// (non-profile) installations are unaffected.
|
||||||
|
///
|
||||||
|
/// Backed by a 5-second cache inside the resolver, so frequent
|
||||||
|
/// `HermesPathSet` constructions don't hammer the filesystem.
|
||||||
|
public nonisolated static var defaultLocalHome: String {
|
||||||
|
HermesProfileResolver.resolveLocalHome()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Default remote home when the user doesn't override it in `SSHConfig`.
|
||||||
|
/// We leave `~` unexpanded on purpose — the remote shell resolves it.
|
||||||
|
public nonisolated static let defaultRemoteHome: String = "~/.hermes"
|
||||||
|
|
||||||
|
// MARK: - Paths (mirror of the old HermesPaths layout)
|
||||||
|
|
||||||
|
public nonisolated var stateDB: String { home + "/state.db" }
|
||||||
|
public nonisolated var configYAML: String { home + "/config.yaml" }
|
||||||
|
public nonisolated var envFile: String { home + "/.env" }
|
||||||
|
public nonisolated var authJSON: String { home + "/auth.json" }
|
||||||
|
public nonisolated var soulMD: String { home + "/SOUL.md" }
|
||||||
|
public nonisolated var pluginsDir: String { home + "/plugins" }
|
||||||
|
public nonisolated var memoriesDir: String { home + "/memories" }
|
||||||
|
public nonisolated var memoryMD: String { memoriesDir + "/MEMORY.md" }
|
||||||
|
public nonisolated var userMD: String { memoriesDir + "/USER.md" }
|
||||||
|
public nonisolated var sessionsDir: String { home + "/sessions" }
|
||||||
|
public nonisolated var cronJobsJSON: String { home + "/cron/jobs.json" }
|
||||||
|
public nonisolated var cronOutputDir: String { home + "/cron/output" }
|
||||||
|
public nonisolated var gatewayStateJSON: String { home + "/gateway_state.json" }
|
||||||
|
public nonisolated var skillsDir: String { home + "/skills" }
|
||||||
|
public nonisolated var errorsLog: String { home + "/logs/errors.log" }
|
||||||
|
public nonisolated var agentLog: String { home + "/logs/agent.log" }
|
||||||
|
public nonisolated var gatewayLog: String { home + "/logs/gateway.log" }
|
||||||
|
/// Curator run-reports root (v0.12+). Hermes writes per-cycle dirs
|
||||||
|
/// under here named `<YYYYMMDD-HHMMSS>/` containing `run.json` and
|
||||||
|
/// `REPORT.md`. The `last_report_path` field on `curator_state`
|
||||||
|
/// points at the most recent dir; `CuratorViewModel` resolves the
|
||||||
|
/// JSON/Markdown files relative to it.
|
||||||
|
public nonisolated var curatorLogsDir: String { home + "/logs/curator" }
|
||||||
|
/// JSON-encoded curator state (v0.12+). Filename has no extension
|
||||||
|
/// despite holding JSON — Hermes writes it via
|
||||||
|
/// `~/.hermes/skills/.curator_state`. Carries last-run metadata,
|
||||||
|
/// run count, pause flag, and the path to the most recent report.
|
||||||
|
public nonisolated var curatorStateFile: String { home + "/skills/.curator_state" }
|
||||||
|
public nonisolated var scarfDir: String { home + "/scarf" }
|
||||||
|
public nonisolated var projectsRegistry: String { scarfDir + "/projects.json" }
|
||||||
|
|
||||||
|
/// Maps Hermes session IDs to the Scarf project path a chat was
|
||||||
|
/// started for. Scarf-owned; Hermes never touches this file.
|
||||||
|
public nonisolated var sessionProjectMap: String { scarfDir + "/session_project_map.json" }
|
||||||
|
/// Cached list of available Nous Portal models. Populated by
|
||||||
|
/// `NousModelCatalogService` from `GET https://inference-api.nousresearch.com/v1/models`
|
||||||
|
/// using the bearer token in `auth.json`. Refreshed on a 24h TTL or
|
||||||
|
/// on user request from the model picker. Survives offline runs so
|
||||||
|
/// the picker still has something to render.
|
||||||
|
public nonisolated var nousModelsCache: String { scarfDir + "/nous_models_cache.json" }
|
||||||
|
/// Cached `templates/catalog.json` from awizemann.github.io. Populated
|
||||||
|
/// by `CatalogService` on first sheet-open and refreshed on a 24h TTL
|
||||||
|
/// or on explicit user click. Mirrors `nousModelsCache` exactly:
|
||||||
|
/// JSON, scarf-owned, survives offline runs so the catalog browser
|
||||||
|
/// still has something to render. Wiped by a Hermes home reset.
|
||||||
|
public nonisolated var catalogCache: String { scarfDir + "/catalog_cache.json" }
|
||||||
|
public nonisolated var mcpTokensDir: String { home + "/mcp-tokens" }
|
||||||
|
|
||||||
|
// MARK: - Binary resolution
|
||||||
|
|
||||||
|
/// Install locations we probe for the local `hermes` binary, in priority
|
||||||
|
/// order. Checked on every access so a user installing via a different
|
||||||
|
/// method doesn't need to relaunch Scarf.
|
||||||
|
public nonisolated static let hermesBinaryCandidates: [String] = {
|
||||||
|
let user = ProcessInfo.processInfo.environment["HOME"] ?? NSHomeDirectory()
|
||||||
|
return [
|
||||||
|
user + "/.local/bin/hermes", // pipx / pip --user (default)
|
||||||
|
"/opt/homebrew/bin/hermes", // Homebrew on Apple Silicon
|
||||||
|
"/usr/local/bin/hermes", // Homebrew on Intel / manual install
|
||||||
|
user + "/.hermes/bin/hermes" // Some self-install layouts
|
||||||
|
]
|
||||||
|
}()
|
||||||
|
|
||||||
|
/// Resolved path to the `hermes` executable for this installation.
|
||||||
|
///
|
||||||
|
/// Local: returns the first executable candidate, falling back to the
|
||||||
|
/// pipx default so error messages still make sense on a fresh machine.
|
||||||
|
///
|
||||||
|
/// Remote: returns `binaryHint` (populated at connect time) or bare
|
||||||
|
/// `"hermes"` as a last-resort default that relies on the remote `$PATH`.
|
||||||
|
public nonisolated var hermesBinary: String {
|
||||||
|
if isRemote {
|
||||||
|
return binaryHint ?? "hermes"
|
||||||
|
}
|
||||||
|
for path in Self.hermesBinaryCandidates
|
||||||
|
where FileManager.default.isExecutableFile(atPath: path) {
|
||||||
|
return path
|
||||||
|
}
|
||||||
|
return Self.hermesBinaryCandidates[0]
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,111 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
public struct HermesSession: Identifiable, Sendable {
|
||||||
|
public let id: String
|
||||||
|
public let source: String
|
||||||
|
public let userId: String?
|
||||||
|
public let model: String?
|
||||||
|
public let title: String?
|
||||||
|
public let parentSessionId: String?
|
||||||
|
public let startedAt: Date?
|
||||||
|
public let endedAt: Date?
|
||||||
|
public let endReason: String?
|
||||||
|
public let messageCount: Int
|
||||||
|
public let toolCallCount: Int
|
||||||
|
public let inputTokens: Int
|
||||||
|
public let outputTokens: Int
|
||||||
|
public let cacheReadTokens: Int
|
||||||
|
public let cacheWriteTokens: Int
|
||||||
|
public let estimatedCostUSD: Double?
|
||||||
|
public let reasoningTokens: Int
|
||||||
|
public let actualCostUSD: Double?
|
||||||
|
public let costStatus: String?
|
||||||
|
public let billingProvider: String?
|
||||||
|
/// Number of API calls Hermes made for this session (Hermes
|
||||||
|
/// v2026.4.23+; populated from `sessions.api_call_count`). Distinct
|
||||||
|
/// from `toolCallCount` — every tool round-trip is a tool call,
|
||||||
|
/// but each agent reasoning step also costs an API call. `0` on
|
||||||
|
/// older Hermes hosts that don't have the column.
|
||||||
|
public let apiCallCount: Int
|
||||||
|
|
||||||
|
|
||||||
|
public init(
|
||||||
|
id: String,
|
||||||
|
source: String,
|
||||||
|
userId: String?,
|
||||||
|
model: String?,
|
||||||
|
title: String?,
|
||||||
|
parentSessionId: String?,
|
||||||
|
startedAt: Date?,
|
||||||
|
endedAt: Date?,
|
||||||
|
endReason: String?,
|
||||||
|
messageCount: Int,
|
||||||
|
toolCallCount: Int,
|
||||||
|
inputTokens: Int,
|
||||||
|
outputTokens: Int,
|
||||||
|
cacheReadTokens: Int,
|
||||||
|
cacheWriteTokens: Int,
|
||||||
|
estimatedCostUSD: Double?,
|
||||||
|
reasoningTokens: Int,
|
||||||
|
actualCostUSD: Double?,
|
||||||
|
costStatus: String?,
|
||||||
|
billingProvider: String?,
|
||||||
|
apiCallCount: Int = 0
|
||||||
|
) {
|
||||||
|
self.id = id
|
||||||
|
self.source = source
|
||||||
|
self.userId = userId
|
||||||
|
self.model = model
|
||||||
|
self.title = title
|
||||||
|
self.parentSessionId = parentSessionId
|
||||||
|
self.startedAt = startedAt
|
||||||
|
self.endedAt = endedAt
|
||||||
|
self.endReason = endReason
|
||||||
|
self.messageCount = messageCount
|
||||||
|
self.toolCallCount = toolCallCount
|
||||||
|
self.inputTokens = inputTokens
|
||||||
|
self.outputTokens = outputTokens
|
||||||
|
self.cacheReadTokens = cacheReadTokens
|
||||||
|
self.cacheWriteTokens = cacheWriteTokens
|
||||||
|
self.estimatedCostUSD = estimatedCostUSD
|
||||||
|
self.reasoningTokens = reasoningTokens
|
||||||
|
self.actualCostUSD = actualCostUSD
|
||||||
|
self.costStatus = costStatus
|
||||||
|
self.billingProvider = billingProvider
|
||||||
|
self.apiCallCount = apiCallCount
|
||||||
|
}
|
||||||
|
public var isSubagent: Bool { parentSessionId != nil }
|
||||||
|
|
||||||
|
public var totalTokens: Int { inputTokens + outputTokens + reasoningTokens }
|
||||||
|
|
||||||
|
public var displayCostUSD: Double? { actualCostUSD ?? estimatedCostUSD }
|
||||||
|
|
||||||
|
public var costIsActual: Bool { actualCostUSD != nil }
|
||||||
|
|
||||||
|
public var duration: TimeInterval? {
|
||||||
|
guard let start = startedAt, let end = endedAt else { return nil }
|
||||||
|
return end.timeIntervalSince(start)
|
||||||
|
}
|
||||||
|
|
||||||
|
public var displayTitle: String {
|
||||||
|
title ?? id
|
||||||
|
}
|
||||||
|
|
||||||
|
public var sourceIcon: String {
|
||||||
|
KnownPlatforms.icon(for: source)
|
||||||
|
}
|
||||||
|
|
||||||
|
public func withTitle(_ newTitle: String) -> HermesSession {
|
||||||
|
HermesSession(
|
||||||
|
id: id, source: source, userId: userId, model: model,
|
||||||
|
title: newTitle, parentSessionId: parentSessionId,
|
||||||
|
startedAt: startedAt, endedAt: endedAt, endReason: endReason,
|
||||||
|
messageCount: messageCount, toolCallCount: toolCallCount,
|
||||||
|
inputTokens: inputTokens, outputTokens: outputTokens,
|
||||||
|
cacheReadTokens: cacheReadTokens, cacheWriteTokens: cacheWriteTokens,
|
||||||
|
estimatedCostUSD: estimatedCostUSD, reasoningTokens: reasoningTokens,
|
||||||
|
actualCostUSD: actualCostUSD, costStatus: costStatus,
|
||||||
|
billingProvider: billingProvider
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
public struct HermesSkillCategory: Identifiable, Sendable {
|
||||||
|
public let id: String
|
||||||
|
public let name: String
|
||||||
|
public let skills: [HermesSkill]
|
||||||
|
|
||||||
|
public init(
|
||||||
|
id: String,
|
||||||
|
name: String,
|
||||||
|
skills: [HermesSkill]
|
||||||
|
) {
|
||||||
|
self.id = id
|
||||||
|
self.name = name
|
||||||
|
self.skills = skills
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public struct HermesSkill: Identifiable, Sendable {
|
||||||
|
public let id: String
|
||||||
|
public let name: String
|
||||||
|
public let category: String
|
||||||
|
public let path: String
|
||||||
|
public let files: [String]
|
||||||
|
public let requiredConfig: [String]
|
||||||
|
/// Tools the skill author declared the skill is allowed to invoke
|
||||||
|
/// (Hermes v2026.4.23 SKILL.md frontmatter `allowed_tools`).
|
||||||
|
/// `nil` when the skill ships no SKILL.md or the frontmatter
|
||||||
|
/// doesn't declare the field — pre-v0.11 behaviour preserved.
|
||||||
|
public let allowedTools: [String]?
|
||||||
|
/// Skill names the author cross-references as related (`related_skills`
|
||||||
|
/// in SKILL.md frontmatter). Surfaced as chips in the skill detail
|
||||||
|
/// view so users can hop between connected skills.
|
||||||
|
public let relatedSkills: [String]?
|
||||||
|
/// External runtime dependencies the skill needs on the host
|
||||||
|
/// (`dependencies` in SKILL.md frontmatter; e.g. `npx`, `ffmpeg`,
|
||||||
|
/// Python packages). Used by `SkillPrereqService` to know what to
|
||||||
|
/// probe; nil when the field is absent.
|
||||||
|
public let dependencies: [String]?
|
||||||
|
/// `false` when the skill name appears in `skills.disabled` in
|
||||||
|
/// `~/.hermes/config.yaml`. Hermes v0.12 stores disable state in
|
||||||
|
/// the config rather than per-skill markers; this is read-only
|
||||||
|
/// from Scarf's side until the toggle UI lands. Defaults to `true`.
|
||||||
|
public let enabled: Bool
|
||||||
|
/// `true` when the skill is pinned via `hermes curator pin <name>`.
|
||||||
|
/// Pinned skills are protected from auto-archive / consolidation.
|
||||||
|
/// Read from `CuratorViewModel.status.pinnedNames`; defaults to
|
||||||
|
/// `false` when curator state is unavailable.
|
||||||
|
public let pinned: Bool
|
||||||
|
|
||||||
|
public init(
|
||||||
|
id: String,
|
||||||
|
name: String,
|
||||||
|
category: String,
|
||||||
|
path: String,
|
||||||
|
files: [String],
|
||||||
|
requiredConfig: [String],
|
||||||
|
allowedTools: [String]? = nil,
|
||||||
|
relatedSkills: [String]? = nil,
|
||||||
|
dependencies: [String]? = nil,
|
||||||
|
enabled: Bool = true,
|
||||||
|
pinned: Bool = false
|
||||||
|
) {
|
||||||
|
self.id = id
|
||||||
|
self.name = name
|
||||||
|
self.category = category
|
||||||
|
self.path = path
|
||||||
|
self.files = files
|
||||||
|
self.requiredConfig = requiredConfig
|
||||||
|
self.allowedTools = allowedTools
|
||||||
|
self.relatedSkills = relatedSkills
|
||||||
|
self.dependencies = dependencies
|
||||||
|
self.enabled = enabled
|
||||||
|
self.pinned = pinned
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// A slash command available in chat. Sourced from one of four places —
|
||||||
|
/// see `Source` for which.
|
||||||
|
public struct HermesSlashCommand: Identifiable, Sendable, Equatable {
|
||||||
|
/// Where this command came from. Drives the slash-menu badge and the
|
||||||
|
/// chat view model's invocation path (literal-send vs client-side
|
||||||
|
/// expansion vs non-interruptive flag).
|
||||||
|
public enum Source: Sendable, Equatable {
|
||||||
|
/// Advertised by the ACP server via `available_commands_update`.
|
||||||
|
/// Sent to the agent as the literal slash text.
|
||||||
|
case acp
|
||||||
|
/// User-defined `quick_commands.<name>` in `~/.hermes/config.yaml`
|
||||||
|
/// (legacy). Sent to the agent as the literal slash text.
|
||||||
|
case quickCommand
|
||||||
|
/// Project-scoped, Scarf-managed command at
|
||||||
|
/// `<project>/.scarf/slash-commands/<name>.md`. Scarf intercepts
|
||||||
|
/// the invocation, expands `{{argument}}` substitution against the
|
||||||
|
/// command's body, and sends the result as a normal user prompt
|
||||||
|
/// (the agent never sees the slash trigger). Added in v2.5.
|
||||||
|
case projectScoped
|
||||||
|
/// ACP-native commands that don't interrupt the current turn —
|
||||||
|
/// `/steer` is the flagship case. The chat UI keeps the
|
||||||
|
/// "agent working" indicator on; the guidance applies after the
|
||||||
|
/// next tool call. Added in v2.5 alongside Hermes v2026.4.23.
|
||||||
|
case acpNonInterruptive
|
||||||
|
}
|
||||||
|
|
||||||
|
public var id: String { name }
|
||||||
|
public let name: String
|
||||||
|
public let description: String
|
||||||
|
public let argumentHint: String?
|
||||||
|
public let source: Source
|
||||||
|
|
||||||
|
public init(
|
||||||
|
name: String,
|
||||||
|
description: String,
|
||||||
|
argumentHint: String?,
|
||||||
|
source: Source
|
||||||
|
) {
|
||||||
|
self.name = name
|
||||||
|
self.description = description
|
||||||
|
self.argumentHint = argumentHint
|
||||||
|
self.source = source
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,23 +1,45 @@
|
|||||||
import Foundation
|
import Foundation
|
||||||
|
|
||||||
struct HermesToolset: Identifiable, Sendable {
|
public struct HermesToolset: Identifiable, Sendable {
|
||||||
var id: String { name }
|
public var id: String { name }
|
||||||
let name: String
|
public let name: String
|
||||||
let description: String
|
public let description: String
|
||||||
let icon: String
|
public let icon: String
|
||||||
var enabled: Bool
|
public var enabled: Bool
|
||||||
|
|
||||||
|
public init(
|
||||||
|
name: String,
|
||||||
|
description: String,
|
||||||
|
icon: String,
|
||||||
|
enabled: Bool
|
||||||
|
) {
|
||||||
|
self.name = name
|
||||||
|
self.description = description
|
||||||
|
self.icon = icon
|
||||||
|
self.enabled = enabled
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
struct HermesToolPlatform: Identifiable, Sendable {
|
public struct HermesToolPlatform: Identifiable, Sendable {
|
||||||
var id: String { name }
|
public var id: String { name }
|
||||||
let name: String
|
public let name: String
|
||||||
let displayName: String
|
public let displayName: String
|
||||||
let icon: String
|
public let icon: String
|
||||||
|
|
||||||
|
public init(
|
||||||
|
name: String,
|
||||||
|
displayName: String,
|
||||||
|
icon: String
|
||||||
|
) {
|
||||||
|
self.name = name
|
||||||
|
self.displayName = displayName
|
||||||
|
self.icon = icon
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
enum KnownPlatforms {
|
public enum KnownPlatforms {
|
||||||
static let cli = HermesToolPlatform(name: "cli", displayName: "CLI", icon: "terminal")
|
public static let cli = HermesToolPlatform(name: "cli", displayName: "CLI", icon: "terminal")
|
||||||
static let all: [HermesToolPlatform] = [
|
public static let all: [HermesToolPlatform] = [
|
||||||
cli,
|
cli,
|
||||||
HermesToolPlatform(name: "telegram", displayName: "Telegram", icon: "paperplane"),
|
HermesToolPlatform(name: "telegram", displayName: "Telegram", icon: "paperplane"),
|
||||||
HermesToolPlatform(name: "discord", displayName: "Discord", icon: "bubble.left.and.bubble.right"),
|
HermesToolPlatform(name: "discord", displayName: "Discord", icon: "bubble.left.and.bubble.right"),
|
||||||
@@ -31,9 +53,16 @@ enum KnownPlatforms {
|
|||||||
HermesToolPlatform(name: "feishu", displayName: "Feishu", icon: "message.badge.circle"),
|
HermesToolPlatform(name: "feishu", displayName: "Feishu", icon: "message.badge.circle"),
|
||||||
HermesToolPlatform(name: "mattermost", displayName: "Mattermost", icon: "bubble.left.and.exclamationmark.bubble.right"),
|
HermesToolPlatform(name: "mattermost", displayName: "Mattermost", icon: "bubble.left.and.exclamationmark.bubble.right"),
|
||||||
HermesToolPlatform(name: "imessage", displayName: "iMessage", icon: "message.fill"),
|
HermesToolPlatform(name: "imessage", displayName: "iMessage", icon: "message.fill"),
|
||||||
|
// -- v0.12 additions ---------------------------------------------
|
||||||
|
// Yuanbao is a native gateway adapter (18th platform); Microsoft
|
||||||
|
// Teams ships as a plugin (19th). PlatformDetail surfaces the
|
||||||
|
// distinction in the setup copy. Names match Hermes's gateway
|
||||||
|
// platform identifiers.
|
||||||
|
HermesToolPlatform(name: "yuanbao", displayName: "Yuanbao 元宝", icon: "bubble.left.and.bubble.right.fill"),
|
||||||
|
HermesToolPlatform(name: "microsoft-teams", displayName: "Microsoft Teams", icon: "person.2.fill"),
|
||||||
]
|
]
|
||||||
|
|
||||||
static func icon(for platform: String) -> String {
|
public static func icon(for platform: String) -> String {
|
||||||
switch platform {
|
switch platform {
|
||||||
case "cli": return "terminal"
|
case "cli": return "terminal"
|
||||||
case "telegram": return "paperplane"
|
case "telegram": return "paperplane"
|
||||||
@@ -48,6 +77,8 @@ enum KnownPlatforms {
|
|||||||
case "feishu": return "message.badge.circle"
|
case "feishu": return "message.badge.circle"
|
||||||
case "mattermost": return "bubble.left.and.exclamationmark.bubble.right"
|
case "mattermost": return "bubble.left.and.exclamationmark.bubble.right"
|
||||||
case "imessage": return "message.fill"
|
case "imessage": return "message.fill"
|
||||||
|
case "yuanbao": return "bubble.left.and.bubble.right.fill"
|
||||||
|
case "microsoft-teams": return "person.2.fill"
|
||||||
default: return "bubble.left"
|
default: return "bubble.left"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||