Compare commits
210 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 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 | |||
| 5498a08b11 | |||
| a864c9af02 | |||
| ec506d4652 | |||
| 38226fea2c | |||
| 257772e2d1 | |||
| 115bc16b14 | |||
| eda5e467f9 | |||
| 9127aef682 | |||
| 5ae8db25c3 | |||
| fb833d4a0a | |||
| 7656ad8052 | |||
| 5b1481f33f | |||
| e4920538d2 | |||
| 5340e70dd3 | |||
| 7ad78a5492 | |||
| 205bb2c56e | |||
| d9688781ee | |||
| 9aad9051c4 | |||
| 4baa3d4d28 | |||
| 799cdb19e1 | |||
| 585d035fe8 | |||
| f1e8f3070f | |||
| f366057cfd | |||
| fd0d923c0b | |||
| 3c2d11470f | |||
| dcd2f8f04b | |||
| ef3ddcdd7a | |||
| 5e207f760d | |||
| d616935296 | |||
| ea4032766b | |||
| 4132cb03e2 | |||
| 44d2d6d6c6 | |||
| 6b731ddfb8 | |||
| bd6e722029 | |||
| 110611549e | |||
| 92ac102f46 | |||
| e85a7b170c | |||
| 3420abae74 | |||
| ba368d2f6d | |||
| bdf31d6781 | |||
| 920c86b4f8 | |||
| 8bd4b9282a | |||
| 27dc694aeb | |||
| 0fd2ceb9fc | |||
| f6f31cabe4 | |||
| bb5045c10f | |||
| 3e0d2db4c7 | |||
| 2b25a9da71 | |||
| 5fb9620631 | |||
| de5b278da4 | |||
| fb7a80f191 | |||
| 18640293f7 | |||
| 19750597cd | |||
| 69e9cc6c7b | |||
| 03bf5262bb | |||
| 3af99d9d9c | |||
| 3bd95de8f4 | |||
| 81e8da91d6 | |||
| bb750e237e | |||
| 68f6b98fcf | |||
| f8c086ee7a | |||
| eb34aec1f1 | |||
| 97e9beea5f | |||
| 7a99547b22 | |||
| 64b7d3beaf | |||
| 385c3a2e4d | |||
| e76fbf9937 | |||
| c9b8da9ec5 | |||
| 6175bee27d | |||
| 11732baa3c | |||
| d8a0a89db2 | |||
| 38c075d61d | |||
| c800b93804 | |||
| 7311320bfd | |||
| 4663697942 | |||
| 41635955b0 | |||
| 1989feee22 | |||
| 8773254d11 | |||
| a1aa653a33 | |||
| e256196397 | |||
| 50880efe81 | |||
| b1bc7e8494 | |||
| f47034d4ad | |||
| 1726a613a5 | |||
| de34a80807 | |||
| d9a25b3997 | |||
| b40182f2da | |||
| 6817c95681 | |||
| 89748fdfee | |||
| c8208dedb1 | |||
| a68e0c5f42 | |||
| 0384c6ef17 | |||
| f36fb55ebe | |||
| 1823160546 | |||
| d2a447fcc4 | |||
| 76bfeb34d4 | |||
| 85a4ec0e14 | |||
| 1453c7a841 | |||
| bd21a539e6 | |||
| d3055702ef | |||
| ee1d705abc | |||
| 8e3dafe4c6 | |||
| c51241dc72 | |||
| ec03627bcd | |||
| f8069a4481 | |||
| 110170d6e9 | |||
| 1293cfa23b | |||
| ba8bf14ff0 | |||
| 4212200dca | |||
| 5920923d92 | |||
| 00ca7229df | |||
| 679dedf132 | |||
| 12610faba0 | |||
| 73b44202ba | |||
| eed55cbb0f | |||
| 14c97bee62 | |||
| 8d3fe70e2c | |||
| da88c98c7a | |||
| b7ad01f9da | |||
| 868e61979e | |||
| 9bdd928469 | |||
| 75e489e39c | |||
| 41ea3aeb83 | |||
| eb39dcfa61 | |||
| 93ee194ba0 | |||
| b6d9113579 | |||
| b2a29ab68d | |||
| 117a0ee9dd | |||
| 61d59ba0e4 | |||
| 0a584f6722 | |||
| 219bca264e | |||
| c7e6a809ed | |||
| c5d6116f99 | |||
| 8672ed1e6c | |||
| 46468890d5 | |||
| cd503378e2 |
@@ -0,0 +1,42 @@
|
|||||||
|
<!--
|
||||||
|
Use this template when submitting a new Scarf project template or updating
|
||||||
|
an existing one. For regular code/docs PRs, delete this template and write
|
||||||
|
your own summary.
|
||||||
|
|
||||||
|
Switch to this template by adding `?template=template-submission.md` to the
|
||||||
|
compare URL, or let GitHub pick it up automatically when you touch files
|
||||||
|
under templates/.
|
||||||
|
-->
|
||||||
|
|
||||||
|
## What's in this PR
|
||||||
|
|
||||||
|
- [ ] New template: `templates/<your-handle>/<your-template-name>/`
|
||||||
|
- [ ] Update to existing template: `templates/<author>/<name>/` (which one and why)
|
||||||
|
|
||||||
|
## One-line pitch
|
||||||
|
|
||||||
|
_What does this template do for its installers? Two sentences max._
|
||||||
|
|
||||||
|
## Checklist
|
||||||
|
|
||||||
|
- [ ] I wrote this template, or have the author's explicit permission to submit it.
|
||||||
|
- [ ] `AGENTS.md` is present and tells any cross-agent what the project does and how to run it.
|
||||||
|
- [ ] `README.md` includes install, customize, and uninstall instructions.
|
||||||
|
- [ ] The bundle's `template.json` `contents` claim matches what's actually in the zip.
|
||||||
|
- [ ] Cron jobs (if any) ship paused and use self-contained prompts.
|
||||||
|
- [ ] No secrets in any file (API keys, tokens, hostnames, IPs, credentials).
|
||||||
|
- [ ] No writes to `config.yaml`, `auth.json`, or credential paths — v1 installer will refuse.
|
||||||
|
- [ ] `python3 tools/build-catalog.py --check` passes locally.
|
||||||
|
- [ ] I installed + uninstalled this template on my machine and verified the `AGENTS.md` contract works end-to-end.
|
||||||
|
- [ ] I did **not** edit `templates/catalog.json` — the maintainer regenerates it post-merge.
|
||||||
|
|
||||||
|
## Testing notes
|
||||||
|
|
||||||
|
_What did you run, what did you see? Paste the log output of the cron job
|
||||||
|
firing once, or the chat transcript of asking the agent to do the main
|
||||||
|
thing. Reviewers don't have your machine — show, don't tell._
|
||||||
|
|
||||||
|
## Screenshots (optional)
|
||||||
|
|
||||||
|
_Drop screenshots of the installed dashboard, or the catalog detail page
|
||||||
|
rendered locally (`./scripts/catalog.sh preview && open /tmp/scarf-catalog-preview/templates/<slug>/index.html`)._
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
# Validates `.scarftemplate` bundles on PRs that touch templates/.
|
||||||
|
#
|
||||||
|
# Mirrors the invariants `ProjectTemplateService.verifyClaims` enforces at
|
||||||
|
# install time. Runs the same Python script the maintainer uses locally
|
||||||
|
# (tools/build-catalog.py --check) so a bundle can't reach main unless the
|
||||||
|
# validator is happy.
|
||||||
|
#
|
||||||
|
# Also runs tools/test_build_catalog.py so drift between the validator and
|
||||||
|
# its own test suite is caught on the same PR.
|
||||||
|
|
||||||
|
name: Validate template submissions
|
||||||
|
|
||||||
|
on:
|
||||||
|
pull_request:
|
||||||
|
paths:
|
||||||
|
- 'templates/**'
|
||||||
|
- 'tools/build-catalog.py'
|
||||||
|
- 'tools/test_build_catalog.py'
|
||||||
|
- '.github/workflows/validate-template-pr.yml'
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
pull-requests: write
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
validate:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
# Full clone so we can diff against the PR base and scope
|
||||||
|
# --only to just the changed templates if we want to later.
|
||||||
|
fetch-depth: 0
|
||||||
|
|
||||||
|
- name: Set up Python
|
||||||
|
uses: actions/setup-python@v5
|
||||||
|
with:
|
||||||
|
# The validator is stdlib-only and tested against 3.9+ (the
|
||||||
|
# system Python on current macOS, what most maintainers run
|
||||||
|
# locally). CI uses 3.11 for faster cold-cache times on
|
||||||
|
# GitHub Actions runners — same stdlib APIs, same code paths.
|
||||||
|
python-version: '3.11'
|
||||||
|
|
||||||
|
- name: Run validator unit tests
|
||||||
|
run: python3 tools/test_build_catalog.py -v
|
||||||
|
|
||||||
|
- name: Validate every template
|
||||||
|
id: validate
|
||||||
|
run: |
|
||||||
|
set -o pipefail
|
||||||
|
python3 tools/build-catalog.py --check 2>&1 | tee /tmp/validator.log
|
||||||
|
|
||||||
|
- name: Post failure comment
|
||||||
|
if: failure() && steps.validate.outcome == 'failure'
|
||||||
|
uses: actions/github-script@v7
|
||||||
|
with:
|
||||||
|
script: |
|
||||||
|
const fs = require('fs');
|
||||||
|
let body = '## Template validation failed\n\n';
|
||||||
|
try {
|
||||||
|
const log = fs.readFileSync('/tmp/validator.log', 'utf8');
|
||||||
|
body += '```\n' + log.slice(-3000) + '\n```\n';
|
||||||
|
} catch (e) {
|
||||||
|
body += 'See the failed job log for details.\n';
|
||||||
|
}
|
||||||
|
body += '\nFix the issues above and push again — the check reruns automatically.\n';
|
||||||
|
body += '\nLocal reproduction: `python3 tools/build-catalog.py --check`\n';
|
||||||
|
await github.rest.issues.createComment({
|
||||||
|
issue_number: context.issue.number,
|
||||||
|
owner: context.repo.owner,
|
||||||
|
repo: context.repo.repo,
|
||||||
|
body,
|
||||||
|
});
|
||||||
@@ -1,5 +1,7 @@
|
|||||||
# Xcode
|
# Xcode
|
||||||
build/
|
build/
|
||||||
|
.gh-pages-worktree/
|
||||||
|
.wiki-worktree/
|
||||||
DerivedData/
|
DerivedData/
|
||||||
*.pbxuser
|
*.pbxuser
|
||||||
!default.pbxuser
|
!default.pbxuser
|
||||||
@@ -19,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/
|
||||||
@@ -46,3 +53,11 @@ scarf/standards/backups/
|
|||||||
|
|
||||||
# Scarf project dashboards (user-specific)
|
# Scarf project dashboards (user-specific)
|
||||||
.scarf/
|
.scarf/
|
||||||
|
|
||||||
|
# Release artifacts — GitHub Releases hosts the binaries; no need to bloat git
|
||||||
|
# history. RELEASE_NOTES.md stays tracked (committed with the version bump).
|
||||||
|
releases/v*/*.zip
|
||||||
|
releases/v*/appcast-entry.xml
|
||||||
|
|
||||||
|
# Wiki helper: personal patterns (hostnames, IPs) blocked from the wiki push.
|
||||||
|
scripts/wiki-blocklist.txt
|
||||||
|
|||||||
@@ -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/`
|
||||||
@@ -38,3 +67,184 @@ scarf/scarf/ Xcode project root (PBXFileSystemSynchronizedRootGroup
|
|||||||
```bash
|
```bash
|
||||||
xcodebuild -project scarf/scarf.xcodeproj -scheme scarf -configuration Debug build
|
xcodebuild -project scarf/scarf.xcodeproj -scheme scarf -configuration Debug build
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## Releases
|
||||||
|
|
||||||
|
Shipped via a single local script. **Never run manual `xcodebuild archive` / `notarytool` / `gh release create` steps — use the script so nothing is skipped or misordered.**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./scripts/release.sh <version> # full release: notarize → appcast → gh-pages → tag
|
||||||
|
./scripts/release.sh <version> --draft # draft: everything builds + notarizes, but appcast/tag are skipped
|
||||||
|
```
|
||||||
|
|
||||||
|
The script bumps version, archives Universal (arm64 + x86_64) + ARM64-only variants, signs with Developer ID, notarizes via `xcrun notarytool` (keychain profile `scarf-notary`), staples, EdDSA-signs the appcast entry with Sparkle's key, pushes the appcast to `gh-pages`, and creates a GitHub release with both zips attached. Draft mode stops after the release is uploaded so the current version stays "latest" until explicitly promoted.
|
||||||
|
|
||||||
|
**Release notes convention:** write them to `releases/v<version>/RELEASE_NOTES.md` BEFORE running the script — it's auto-included in the version-bump commit and used as the GitHub release body. If absent, a placeholder is used.
|
||||||
|
|
||||||
|
**Canonical prompts (any of these trigger the flow):**
|
||||||
|
- "Release v1.6.2" — full release
|
||||||
|
- "Release v1.6.2 as draft" — draft mode
|
||||||
|
- "Prepare v1.6.2 release notes from recent commits, then release" — generate notes first, then run
|
||||||
|
|
||||||
|
**Prerequisites (one-time, already set up on Alan's machine):** Developer ID Application cert in login Keychain (team `3Q6X2L86C4`), notarytool keychain profile `scarf-notary`, Sparkle EdDSA private key in Keychain item `https://sparkle-project.org`, `gh-pages` branch + GitHub Pages enabled. See the header of [scripts/release.sh](scripts/release.sh) and the Releases section in [README.md](README.md) for details.
|
||||||
|
|
||||||
|
## Wiki
|
||||||
|
|
||||||
|
Public documentation lives in the GitHub wiki at https://github.com/awizemann/scarf/wiki. The wiki is a separate git repo cloned to `.wiki-worktree/` in the repo root (gitignored, sibling to `.gh-pages-worktree/`). Internal dev notes stay in `scarf/docs/`; the wiki is for public-facing reference.
|
||||||
|
|
||||||
|
**Update the wiki when:**
|
||||||
|
- A new feature module is added under `scarf/scarf/scarf/Features/` → extend the relevant User Guide page.
|
||||||
|
- A new core service is added under `Core/Services/` → extend `Core-Services.md`.
|
||||||
|
- Architecture changes (AppCoordinator, transport, MVVM-F rule, sandbox) → `Architecture-Overview.md` + the specific sub-page.
|
||||||
|
- Hermes version bumps in this file → `Hermes-Version-Compatibility.md`.
|
||||||
|
- `scripts/release.sh` completes a full (non-draft) release → bump latest-version on `Home.md` + append to `Release-Notes-Index.md`.
|
||||||
|
- Keyboard shortcut or sidebar section changes → `Keyboard-Shortcuts.md` / `Sidebar-and-Navigation.md`.
|
||||||
|
|
||||||
|
**Skip for:** bug fixes with no user-observable change, pure refactors, typos, test-only changes, internal cleanups.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./scripts/wiki.sh pull # always first
|
||||||
|
# edit .wiki-worktree/*.md with normal tools
|
||||||
|
./scripts/wiki.sh commit "docs: describe X" # runs secret-scan
|
||||||
|
./scripts/wiki.sh push # runs secret-scan again, then push
|
||||||
|
```
|
||||||
|
|
||||||
|
**Never** commit API keys, tokens, `.env` files, private keys, or real hostnames/IPs to the wiki. The script's two-pass secret-scan blocks common token patterns and a user-maintained blocklist at `scripts/wiki-blocklist.txt` (gitignored). Do not bypass without explicit approval. Full workflow on the wiki itself at `.wiki-worktree/Wiki-Maintenance.md`.
|
||||||
|
|
||||||
|
## Hermes Version
|
||||||
|
|
||||||
|
Targets Hermes v2026.4.23 (v0.11.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.
|
||||||
|
|
||||||
|
**v2026.4.23 (v0.11.0)** added (Scarf-relevant subset):
|
||||||
|
|
||||||
|
- `/steer <prompt>` — non-interruptive mid-run guidance slash command. Surfaced in Scarf chat menus via `RichChatViewModel.nonInterruptiveCommands`; `ChatViewModel.sendViaACP` (Mac) and `ChatController.send` (iOS) skip the "Agent working…" status flip and show a transient toast instead.
|
||||||
|
- New CLI subcommands: `hermes plugins` / `profile` / `webhook` / `insights` / `logs` / `memory reset` / `completion` / `dashboard`. Scarf v2.5 adopts **`hermes memory reset`** (toolbar button on MemoryView with destructive confirmation). The other CLIs are documented here for v2.6 — Scarf still reads `~/.hermes/plugins/`, `~/.hermes/profiles/` etc directly today; switching those paths to the canonical CLI is a forward-compatible change to make when bandwidth permits.
|
||||||
|
- New state.db columns: `messages.reasoning_content` + `sessions.api_call_count`. `HermesDataService.detectSchema` flips `hasV011Schema` only when both are present (partial migrations stay on v0.7 path). Surfaced as the "API" chip on session rows + a network-icon counter in DashboardView. `HermesMessage.preferredReasoning` picks the newer column when both reasoning channels are populated.
|
||||||
|
- New skills: `design-md` (Google's DESIGN.md authoring; needs `npx`/Node 18+ on host — checked via `SkillPrereqService` and surfaced as a yellow banner) and `spotify` (OAuth via `hermes auth spotify` — driven by `SpotifyAuthFlow` + `SpotifySignInSheet`, mirroring v2.3 Nous Portal pattern).
|
||||||
|
- Updated skills: `research-paper-writing` 1.1.0 (+SciencePlots dep), `segment-anything-model` (expanded docs), `google-workspace` (gws CLI prefer + granular OAuth scopes), `hermes-agent` (in-tree).
|
||||||
|
- SKILL.md frontmatter gains `allowed_tools` / `related_skills` / `dependencies` lists. `HermesSkill` carries them as optional fields; `SkillsView` (Mac) + `SkillDetailView` (iOS) render them as chip rows when populated.
|
||||||
|
|
||||||
|
v0.10.0 introduced the **Tool Gateway** — paid Nous Portal subscribers route web search, image generation, TTS, and browser automation through their subscription without separate API keys. In Scarf:
|
||||||
|
|
||||||
|
- **Provider picker** ([ModelCatalogService.swift](scarf/scarf/Core/Services/ModelCatalogService.swift)) merges Hermes's `HERMES_OVERLAYS` so Nous Portal and other overlay-only providers (OpenAI Codex, Qwen OAuth, Google Gemini CLI, GitHub Copilot ACP, Arcee) appear alongside the models.dev catalog. Subscription-gated providers sort first and render a "Subscription" pill.
|
||||||
|
- **Subscription detection** ([NousSubscriptionService.swift](scarf/scarf/Core/Services/NousSubscriptionService.swift)) reads `~/.hermes/auth.json` → `providers.nous`. Read-only; Hermes owns the write path.
|
||||||
|
- **Per-task routing** (Auxiliary tab) toggles `auxiliary.<task>.provider` between `nous` and `auto`. Hermes derives gateway routing from provider selection — there is no separate `use_gateway` key.
|
||||||
|
- **Health surface** ([HealthViewModel.swift](scarf/scarf/Features/Health/ViewModels/HealthViewModel.swift)) adds a synthetic "Tool Gateway" section showing subscription state + `platform_toolsets` mappings + which aux tasks are routed through Nous.
|
||||||
|
- **Scarf's existing `Gateway` feature is renamed to "Messaging Gateway"** everywhere user-facing to disambiguate from the new Tool Gateway. The `SidebarSection.gateway` enum case and `gateway_state.json` / `gateway.log` paths are unchanged (not user-facing strings).
|
||||||
|
|
||||||
|
**Keep `ModelCatalogService.overlayOnlyProviders` in sync** with `HERMES_OVERLAYS` in `~/.hermes/hermes-agent/hermes_cli/providers.py`. When Hermes adds a new overlay-only provider, mirror the entry (display name, base URL, auth type, subscription-gated flag, doc URL) or the picker won't reach it.
|
||||||
|
|
||||||
|
## Project Templates
|
||||||
|
|
||||||
|
Scarf ships a `.scarftemplate` format (v1 as of 2.2.0) for sharing pre-packaged projects across users and machines. A bundle is a zip containing:
|
||||||
|
|
||||||
|
- `template.json` — manifest (id, name, version, `contents` claim)
|
||||||
|
- `README.md` — shown in the install preview sheet
|
||||||
|
- `AGENTS.md` — required; the [Linux Foundation cross-agent instructions standard](https://agents.md/) — every template is agent-portable out of the box
|
||||||
|
- `dashboard.json` — copied to `<project>/.scarf/dashboard.json`
|
||||||
|
- `instructions/…` — optional per-agent shims (`CLAUDE.md`, `GEMINI.md`, `.cursorrules`, `.github/copilot-instructions.md`)
|
||||||
|
- `skills/<name>/…` — optional; installed to `~/.hermes/skills/templates/<slug>/` (namespaced so uninstall is `rm -rf` on one folder)
|
||||||
|
- `cron/jobs.json` — optional; registered via `hermes cron create` with a `[tmpl:<id>] …` name prefix and immediately paused
|
||||||
|
- `memory/append.md` — optional; appended to `~/.hermes/memories/MEMORY.md` between `<!-- scarf-template:<id>:begin/end -->` markers
|
||||||
|
|
||||||
|
Key services: [ProjectTemplateService.swift](scarf/scarf/Core/Services/ProjectTemplateService.swift) (inspect + validate + plan), [ProjectTemplateInstaller.swift](scarf/scarf/Core/Services/ProjectTemplateInstaller.swift) (execute a plan), [ProjectTemplateExporter.swift](scarf/scarf/Core/Services/ProjectTemplateExporter.swift) (build a bundle from a project), [ProjectTemplateUninstaller.swift](scarf/scarf/Core/Services/ProjectTemplateUninstaller.swift) (reverse an install using the lock file). UI in [Features/Templates/](scarf/scarf/Features/Templates/). The `scarf://install?url=<https URL>` deep link + `file://` URLs for `.scarftemplate` files are handled by [TemplateURLRouter.swift](scarf/scarf/Core/Services/TemplateURLRouter.swift) and `onOpenURL` in `scarfApp.swift`. A `<project>/.scarf/template.lock.json` uninstall manifest is written after every install and drives the uninstall flow.
|
||||||
|
|
||||||
|
**Uninstall semantics:** driven by the lock file. Only files listed in `lock.projectFiles` are removed from the project dir; user-added files (e.g. a `sites.txt` created on first run) are preserved. If every file in the dir was installed by the template, the dir is removed too; otherwise the dir stays with just the user's files. Skills namespace is always removed wholesale (it's isolated). Cron jobs are removed via `hermes cron remove <id>` after resolving each lock-recorded name. Memory block is stripped between the `begin`/`end` markers, leaving the rest of MEMORY.md intact. No "undo" — uninstall is destructive; to re-install, run the install flow again. Uninstall UI lives on the project-list context menu and the dashboard header (only shown when the selected project has a lock file).
|
||||||
|
|
||||||
|
**Never** let a template write to `config.yaml`, `auth.json`, sessions, or any credential path — the v1 installer refuses. If you extend the format, treat the preview sheet as load-bearing: the user's only trust boundary is that the sheet is honest about everything that's about to be written.
|
||||||
|
|
||||||
|
### Template configuration (v2.3, schemaVersion 2)
|
||||||
|
|
||||||
|
Templates can declare a typed configuration schema in `template.json`'s new `config` block. The installer renders a **Configure** step between the parent-directory pick and the preview sheet; values land at `<project>/.scarf/config.json` (non-secret) and in the login Keychain (secret). A post-install **Configuration** button on the dashboard header (shown when `<project>/.scarf/manifest.json` exists) opens the same form pre-filled for editing.
|
||||||
|
|
||||||
|
Manifest shape:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"schemaVersion": 2,
|
||||||
|
"contents": { "dashboard": true, "agentsMd": true, "config": 2 },
|
||||||
|
"config": {
|
||||||
|
"schema": [
|
||||||
|
{"key": "site_url", "type": "string", "label": "Site URL", "required": true},
|
||||||
|
{"key": "api_token", "type": "secret", "label": "API Token", "required": true}
|
||||||
|
],
|
||||||
|
"modelRecommendation": {
|
||||||
|
"preferred": "claude-sonnet-4.5",
|
||||||
|
"rationale": "Tool-heavy workload — reasoning helps."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Supported field types: `string`, `text`, `number`, `bool`, `enum` (with `options: [{value, label}]`), `list` (itemType `"string"` only in v1), `secret`. Type-specific constraints (`pattern`, `min`/`max`, `minLength`/`maxLength`, `minItems`/`maxItems`) are optional. `secret` fields **must not** declare a `default` — the validator refuses.
|
||||||
|
|
||||||
|
Key services: [TemplateConfig.swift](scarf/scarf/Core/Models/TemplateConfig.swift) (schema + value models + Keychain ref helpers), [ProjectConfigKeychain.swift](scarf/scarf/Core/Services/ProjectConfigKeychain.swift) (thin `SecItemAdd`/`Copy`/`Delete` wrapper; the only Keychain user in Scarf today), [ProjectConfigService.swift](scarf/scarf/Core/Services/ProjectConfigService.swift) (load/save config.json, resolve secrets, cache manifest, validate schema + values). UI in [Features/Templates/ViewModels/TemplateConfigViewModel.swift](scarf/scarf/Features/Templates/ViewModels/TemplateConfigViewModel.swift) + [Features/Templates/Views/TemplateConfigSheet.swift](scarf/scarf/Features/Templates/Views/TemplateConfigSheet.swift).
|
||||||
|
|
||||||
|
**Secret storage.** Keychain service name is `com.scarf.template.<slug>`, account is `<fieldKey>:<project-path-hash-short>`. The path-hash suffix means two installs of the same template in different dirs don't collide on Keychain entries. Values in `config.json` are `"keychain://service/account"` URIs — never plaintext. The bytes hit the Keychain only on form commit, so cancelling never leaves orphan entries.
|
||||||
|
|
||||||
|
**Uninstall.** `TemplateLock` v2 gains `config_keychain_items` and `config_fields` arrays. The uninstaller iterates each URI through `SecItemDelete` before removing the lock file. Absent items (user hand-cleaned) are no-ops.
|
||||||
|
|
||||||
|
**Exporter.** Carries the *schema* from `<project>/.scarf/manifest.json` through into exported bundles, never values. Exporting never leaks anyone's secrets. `schemaVersion` bumps to 2 only when a schema is forwarded; schema-less exports stay at 1.
|
||||||
|
|
||||||
|
**Catalog site.** [tools/build-catalog.py](tools/build-catalog.py) mirrors the Swift schema validator. Each v2 template's `template.json` is copied into `.gh-pages-worktree/templates/<slug>/manifest.json` and the site's `widgets.js` calls `ScarfWidgets.renderConfigSchema` to display the schema on the detail page (display-only — the form lives in-app).
|
||||||
|
|
||||||
|
**Schema is Swift-primary.** If `TemplateConfigField.FieldType` gains a new case, update in order: `TemplateConfig.swift` (model + validation), `tools/build-catalog.py` (`SUPPORTED_CONFIG_FIELD_TYPES` + type-specific rules), `widgets.js` (`summariseConstraint`), `TemplateConfigSheet.swift` (new control subview), tests on both sides. Schema drift between validator + installer is the kind of bug users only notice after shipping.
|
||||||
|
|
||||||
|
### Project-scoped chat + Scarf-managed AGENTS.md context (v2.3)
|
||||||
|
|
||||||
|
v2.3 adds a per-project Sessions tab and a "New Chat" button that spawns `hermes acp` with `cwd = project.path`. Session-to-project attribution is persisted in a Scarf-owned sidecar at `~/.hermes/scarf/session_project_map.json` — the ACP wire protocol has no project-metadata hook (extra params are silently dropped), and `state.db` has no cwd column, so the sidecar is Scarf's source of truth for "which project does this session belong to?" Managed by [SessionAttributionService.swift](scarf/scarf/Core/Services/SessionAttributionService.swift); read by the per-project [ProjectSessionsView.swift](scarf/scarf/Features/Projects/Views/ProjectSessionsView.swift).
|
||||||
|
|
||||||
|
**Giving the agent project awareness.** Hermes auto-reads a context file from the session's cwd at startup — priority order `.hermes.md` → `HERMES.md` → `AGENTS.md` → `CLAUDE.md` → `.cursorrules`, first match wins, 20KB cap. We lean on that by writing a Scarf-managed block into `<project>/AGENTS.md` before opening the session. Service: [ProjectAgentContextService.swift](scarf/scarf/Core/Services/ProjectAgentContextService.swift). Block shape:
|
||||||
|
|
||||||
|
```
|
||||||
|
<!-- scarf-project:begin -->
|
||||||
|
## Scarf project context
|
||||||
|
_Auto-generated by Scarf — do not edit between the begin/end markers._
|
||||||
|
|
||||||
|
You are operating inside a Scarf project named **"<Project Name>"**. …
|
||||||
|
|
||||||
|
- **Project directory:** `<absolute path>`
|
||||||
|
- **Dashboard:** `<path>/.scarf/dashboard.json`
|
||||||
|
- **Template:** `<author/id>` v<version> <!-- template-installed only -->
|
||||||
|
- **Configuration fields:** `field_a`, `field_b (secret — name only, value stored in Keychain)`
|
||||||
|
- **Registered cron jobs:** `[tmpl:<id>] <name>` — schedule …, currently paused|enabled
|
||||||
|
- **Uninstall manifest:** `<path>/.scarf/template.lock.json` <!-- when present -->
|
||||||
|
|
||||||
|
Any content below this block is template- or user-authored; preserve and defer to it.
|
||||||
|
<!-- scarf-project:end -->
|
||||||
|
```
|
||||||
|
|
||||||
|
**Invariants.**
|
||||||
|
|
||||||
|
- **Secret-safe.** Block surfaces field NAMES, never VALUES. A project with a Keychain-stored secret shows `api_token (secret — name only, …)`; the Keychain ref URI and any plaintext value never appear. Auditable by `refreshListsFieldNamesNotValues` in `ProjectAgentContextServiceTests`.
|
||||||
|
- **Idempotent.** Two refreshes with unchanged state produce byte-identical output. The write is skipped entirely when no delta, avoiding file-watcher churn.
|
||||||
|
- **Bounded.** Everything outside the markers is preserved on every refresh. Template-author AGENTS.md content lives safely below the block.
|
||||||
|
- **Non-fatal.** `ChatViewModel.startACPSession` calls refresh with `try?` + log — a failed write doesn't block the chat from starting; worst case is the session loses project awareness.
|
||||||
|
- **Refresh timing.** Called BEFORE `client.start()` so the block lands before Hermes's session-boot context scan. Skipping this ordering = the agent sees stale context from the previous refresh (or nothing, on fresh projects).
|
||||||
|
|
||||||
|
**Template-author contract.** A template shipped via the catalog should include an `AGENTS.md` with the template's operational instructions. Authors leave the `<!-- scarf-project -->` region alone — Scarf populates it at chat-start time. Everything below is template-owned and preserved.
|
||||||
|
|
||||||
|
**Known caveat.** If any parent directory of the project contains `.hermes.md` or `HERMES.md`, those shadow the project's `AGENTS.md` (higher in Hermes's priority order). No fix in v2.3 — deferred to v2.4 pending user input on how to handle authored `.hermes.md` files.
|
||||||
|
|
||||||
|
## Template Catalog
|
||||||
|
|
||||||
|
Shipped community templates live at `templates/<author>/<name>/` (one level down — `templates/CONTRIBUTING.md` explains the submission flow for authors). The catalog site is generated from this directory and served at `awizemann.github.io/scarf/templates/` alongside the Sparkle appcast — the two coexist on the `gh-pages` branch but touch completely disjoint paths.
|
||||||
|
|
||||||
|
Pipeline:
|
||||||
|
|
||||||
|
- **Validator + regenerator:** [tools/build-catalog.py](tools/build-catalog.py) is stdlib-only Python (3.9+). It walks `templates/*/*/`, validates every `.scarftemplate` against its manifest claim (mirrors the Swift `ProjectTemplateService.verifyClaims` invariants), enforces a 5 MB bundle-size cap, scans for high-confidence secret patterns, checks `staging/` matches the built bundle byte-for-byte, and emits `templates/catalog.json`. Tested by [tools/test_build_catalog.py](tools/test_build_catalog.py) — 16 tests covering every validation path.
|
||||||
|
- **Wrapper:** [scripts/catalog.sh](scripts/catalog.sh) mirrors the `scripts/wiki.sh` shape with `check / build / preview / serve / publish` subcommands. `publish` runs a second-pass secret-scan against the rendered site before committing + pushing `gh-pages`.
|
||||||
|
- **Site source:** `site/index.html.tmpl` + `site/template.html.tmpl` are `{{TOKEN}}`-substitution templates. `site/widgets.js` (~300 lines of vanilla JS) is the dogfood — renders a `ProjectDashboard` JSON into HTML using the same widget vocabulary the Swift app uses, so each template's detail page shows a live preview of its post-install dashboard.
|
||||||
|
- **Install-URL hosting:** raw-served from `main` at `https://raw.githubusercontent.com/awizemann/scarf/main/templates/<author>/<name>/<name>.scarftemplate`. No per-template Releases ceremony.
|
||||||
|
- **CI gate:** [.github/workflows/validate-template-pr.yml](.github/workflows/validate-template-pr.yml) runs the Python validator + its own test suite on every PR that touches `templates/`, the validator, or its tests. Failures post a comment on the PR with the last 3 KB of the validator log.
|
||||||
|
|
||||||
|
Maintainer workflow on merge to main:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./scripts/catalog.sh build # regenerate templates/catalog.json + .gh-pages-worktree/templates/
|
||||||
|
./scripts/catalog.sh publish # secret-scan rendered output + commit + push gh-pages
|
||||||
|
```
|
||||||
|
|
||||||
|
Same cadence as `scripts/release.sh` (manual, auditable, no auto-deploy). Runs stay isolated: release.sh only touches `appcast.xml` on gh-pages; catalog.sh only touches `templates/` on gh-pages. Never push catalog output on a release cadence or vice versa.
|
||||||
|
|
||||||
|
**Schema is Swift-primary.** When `ProjectDashboardWidget.type` gains a new case or `ProjectTemplateManifest` adds a field, update Swift first, then mirror into `tools/build-catalog.py` (`SUPPORTED_WIDGET_TYPES`, `_validate_manifest`, `_validate_contents_claim`) so the web validator stays honest. The Python test suite's real-bundle test catches drift on the example template but not on the full widget vocabulary — add a synthetic fixture to `test_build_catalog.py` for any new widget type.
|
||||||
|
|||||||
@@ -33,6 +33,27 @@ Rules:
|
|||||||
- The app only reads from `~/.hermes/state.db` (never writes). Memory files are the exception.
|
- The app only reads from `~/.hermes/state.db` (never writes). Memory files are the exception.
|
||||||
- Swift 6 strict concurrency: `@MainActor` default isolation, `nonisolated` for service methods.
|
- Swift 6 strict concurrency: `@MainActor` default isolation, `nonisolated` for service methods.
|
||||||
|
|
||||||
|
## Documentation
|
||||||
|
|
||||||
|
Public docs live in the [GitHub wiki](https://github.com/awizemann/scarf/wiki). Small fixes (typos, clarifications) can be made via the "Edit" button on any wiki page — you need push access to the main repo. For larger changes, clone the wiki locally (`git clone git@github.com:awizemann/scarf.wiki.git`) or open an issue describing the proposed change.
|
||||||
|
|
||||||
|
## Adding a Language
|
||||||
|
|
||||||
|
Scarf ships with English + Simplified Chinese, German, French, Spanish, Japanese, and Brazilian Portuguese. To add another locale (or improve an existing one):
|
||||||
|
|
||||||
|
1. **Fork** the repo and create a branch.
|
||||||
|
2. **Add the locale to `knownRegions`** in `scarf/scarf.xcodeproj/project.pbxproj` — follow the existing list (e.g. add `it` after `"pt-BR"`).
|
||||||
|
3. **Drop a new JSON file at `tools/translations/<locale>.json`** — copy an existing one (say `tools/translations/es.json`) as a starting point. Each entry maps the English source string to your translation. Keys you omit fall back to English at runtime — do that for proper nouns (Scarf, Hermes, Anthropic, OAuth, SSH, …) and for anything technical that shouldn't translate.
|
||||||
|
4. **Preserve format specifiers exactly**: `%@`, `%lld`, `%d`, positional `%1$@` / `%2$lld`, etc. If word order needs to change in your language, use positional forms (`%1$@ … %2$@`).
|
||||||
|
5. **Add your locale to `tools/merge-translations.py`'s `LOCALES` list** and run `python3 tools/merge-translations.py` — this writes your translations into `scarf/scarf/Localizable.xcstrings`.
|
||||||
|
6. **Translate `scarf/scarf/InfoPlist.xcstrings`** (the macOS microphone-permission prompt) for your locale. Add a new `stringUnit` under `localizations`.
|
||||||
|
7. **Build** (`xcodebuild -project scarf/scarf.xcodeproj -scheme scarf build`) and **sanity-check in Xcode**: Scheme → Run → App Language → your locale. Walk the main views (Dashboard, Chat, Settings) and look for clipping or obvious leaks.
|
||||||
|
8. **Open a PR** including the new JSON file, the updated catalog, and the pbxproj / script changes. Mention which routes you spot-checked.
|
||||||
|
|
||||||
|
AI translation is fine for the first pass — it's how the initial six locales landed. Native-speaker review improves quality and is always welcome, either as a follow-up PR or as review comments on the initial one.
|
||||||
|
|
||||||
|
See [scarf/docs/I18N.md](scarf/docs/I18N.md) for deeper context on the String Catalog setup and which strings are intentionally kept verbatim.
|
||||||
|
|
||||||
## Reporting Issues
|
## Reporting Issues
|
||||||
|
|
||||||
Open an issue with:
|
Open an issue with:
|
||||||
|
|||||||
@@ -13,33 +13,130 @@
|
|||||||
<img src="https://img.shields.io/badge/macOS-14.6+%20Sonoma-blue" alt="macOS">
|
<img src="https://img.shields.io/badge/macOS-14.6+%20Sonoma-blue" alt="macOS">
|
||||||
<img src="https://img.shields.io/badge/Swift-6-orange" alt="Swift">
|
<img src="https://img.shields.io/badge/Swift-6-orange" alt="Swift">
|
||||||
<img src="https://img.shields.io/badge/license-MIT-green" alt="License">
|
<img src="https://img.shields.io/badge/license-MIT-green" alt="License">
|
||||||
|
<br>
|
||||||
|
<em>Available in English, 简体中文, Deutsch, Français, Español, 日本語, and Português (Brasil).</em>
|
||||||
<br><br>
|
<br><br>
|
||||||
<a href="https://www.buymeacoffee.com/awizemann"><img src="https://cdn.buymeacoffee.com/buttons/v2/default-yellow.png" alt="Buy Me a Coffee" height="28"></a>
|
<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.5
|
||||||
|
|
||||||
|
### ScarfGo — the iPhone companion ships in public TestFlight
|
||||||
|
|
||||||
|
Same Hermes server you've been running on your Mac — now reachable from your phone over SSH. Multi-server, project-scoped chat, session resume, memory editor, cron list, skills tree, settings (read), all native iOS. Pure-Swift SSH (Citadel under the hood — no `ssh` binary needed on iOS). Per-project chat writes the same Scarf-managed `AGENTS.md` block the Mac app does, so the agent boots with the same project context regardless of which client opened the session.
|
||||||
|
|
||||||
|
**[Join the public TestFlight](https://testflight.apple.com/join/qCrRpcTz)** — the link is live now but only accepts new beta testers once Apple's Beta Review approves the first build. If you hit a "not accepting testers" splash, bookmark it and try again in 24–48h.
|
||||||
|
|
||||||
|
See the [ScarfGo wiki page](https://github.com/awizemann/scarf/wiki/ScarfGo) for the full feature tour, [ScarfGo Onboarding](https://github.com/awizemann/scarf/wiki/ScarfGo-Onboarding) for the SSH-key setup walkthrough, and [Platform Differences](https://github.com/awizemann/scarf/wiki/Platform-Differences) for what is and isn't shared between Mac and iOS.
|
||||||
|
|
||||||
|
### Everything else in 2.5
|
||||||
|
|
||||||
|
- **Portable project-scoped slash commands.** Author reusable prompt templates as Markdown files at `<project>/.scarf/slash-commands/<name>.md` with YAML frontmatter (name, description, argumentHint, optional model override). Invoke as `/<name> [args]` from chat — Scarf substitutes `{{argument}}` (with optional `default:` fallback) in the body and sends the expanded prompt to Hermes. Mac authoring tab + iOS read-only browser. Templates carry them via the new `slash-commands/` block in `.scarftemplate` bundles (schemaVersion 3). See [Slash Commands](https://github.com/awizemann/scarf/wiki/Slash-Commands) for the full schema.
|
||||||
|
- **Hermes v2026.4.23 chat parity.** `/steer` non-interruptive guidance command, per-turn stopwatch on assistant bubbles, numbered keyboard shortcuts (1–9) on the permission sheet, git branch chip in the chat header. The new `messages.reasoning_content` and `sessions.api_call_count` columns surface as a richer reasoning disclosure + an "API" chip on session rows.
|
||||||
|
- **Spotify + design-md skills.** Mac ships an in-app Spotify OAuth sheet (mirrors the v2.3 Nous Portal pattern); design-md gets a host-side `npx` prereq check on both platforms. SKILL.md frontmatter (`allowed_tools`, `related_skills`, `dependencies`) renders as chip rows. A "What's New" pill on the Skills tab tells you when remote skills changed since you last looked.
|
||||||
|
- **Mac global Sessions: project filter + project badges** — parity with ScarfGo's Sessions tab. The list grows a filter Menu (All projects / Unattributed / each registered project) and each row carries a tinted folder chip with the project name when attributed.
|
||||||
|
- **Human-readable cron schedules everywhere.** New `CronScheduleFormatter` in ScarfCore translates the common cron shapes into English phrases and falls back to the raw expression on anything custom. Mac and iOS render the same.
|
||||||
|
- **Mac design-system overhaul.** Rust palette, typed token bundle (`ScarfColor`, `ScarfFont`, `ScarfSpace`, `ScarfRadius`), reusable components (`ScarfPageHeader`, `ScarfCard`, `ScarfBadge`, `ScarfTextField`, four button styles), redesigned 3-pane chat. iOS adopts the same tokens with a hybrid Dynamic Type policy so accessibility scaling on body text is preserved. See [Design System](https://github.com/awizemann/scarf/wiki/Design-System) for the full reference.
|
||||||
|
- **Under the hood** — `SessionAttributionService`, `ProjectContextBlock`, `CronScheduleFormatter`, `GitBranchService`, `SkillPrereqService`, `SkillSnapshotService`, `ProjectSlashCommandService`, and the ACP error triplet (`acpError` / `acpErrorHint` / `acpErrorDetails`) consolidated into ScarfCore so Mac and iOS consume one source of truth. 179 tests across 13 suites, three consecutive green runs. Several `try?` swallows in iOS lifecycle code now surface real failures (Keychain unlock errors no longer drop people into onboarding; partial Forget operations report what failed).
|
||||||
|
- **iOS push notifications skeleton** — `NotificationRouter` ships with foreground presentation + a lock-screen "Approve / Deny" action category gated by `apnsEnabled = false`. Lights up when Hermes ships a server-side push sender + an APNs cert.
|
||||||
|
|
||||||
|
See the full [v2.5.0 release notes](https://github.com/awizemann/scarf/releases/tag/v2.5.0).
|
||||||
|
|
||||||
|
**Previous releases:** see the [Release Notes Index](https://github.com/awizemann/scarf/wiki/Release-Notes-Index) on the wiki for v2.3, v2.2, v2.0, v1.6, and earlier.
|
||||||
|
|
||||||
|
## Connect ScarfGo to your Hermes server
|
||||||
|
|
||||||
|
ScarfGo speaks SSH directly — no companion service, no developer-controlled server in between. Onboarding takes about a minute:
|
||||||
|
|
||||||
|
1. **Install via TestFlight.** Open the [public TestFlight link](https://testflight.apple.com/join/qCrRpcTz) on your phone, accept the invite, install ScarfGo from TestFlight (just like any other beta).
|
||||||
|
2. **Tap Add Server.** Enter the host (IP or DNS), SSH user, port (default 22), and an optional nickname. Same details you'd type into `ssh user@host`.
|
||||||
|
3. **Generate Key.** ScarfGo creates a fresh Ed25519 keypair on the device. The private half lives in the iOS Keychain (`kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly`) and is excluded from iCloud sync — it never leaves the phone.
|
||||||
|
4. **Add the public key to your Hermes host.** Tap **Copy public key**, then on the host run:
|
||||||
|
```bash
|
||||||
|
cat >> ~/.ssh/authorized_keys <<'EOF'
|
||||||
|
<paste the line ScarfGo showed you>
|
||||||
|
EOF
|
||||||
|
chmod 600 ~/.ssh/authorized_keys
|
||||||
|
```
|
||||||
|
This is its own line per device — the convention any second SSH client uses. Mac (Scarf) keeps using your existing ssh-agent / `~/.ssh/config` and is unaffected.
|
||||||
|
5. **Tap Test connection.** ScarfGo opens an SSH session, probes for the `hermes` binary, and saves the server on success. If it can't find `hermes`, see the [troubleshooting section](https://github.com/awizemann/scarf/wiki/ScarfGo-Onboarding#troubleshooting) — it's almost always a `PATH` quirk on non-interactive SSH.
|
||||||
|
|
||||||
|
Done. Open the Dashboard tab and tap any session to resume it; tap the **+** in Chat to start a new project-scoped session.
|
||||||
|
|
||||||
|
## Multi-server, one window per server
|
||||||
|
|
||||||
|
Scarf 2.0 is a multi-window app. Each window is bound to exactly one Hermes server — your local `~/.hermes/` is synthesized automatically, and you can add remotes via **File → Open Server…** → **Add Server** (host, user, port, optional identity file). Open a second window for a different server and the two run side-by-side with independent state.
|
||||||
|
|
||||||
|
Remote Hermes is reached over system SSH — the same `~/.ssh/config`, ssh-agent, ProxyJump, and ControlMaster pooling your terminal uses. File I/O flows through `scp`/`sftp`; SQLite is served from atomic `sqlite3 .backup` snapshots cached under `~/Library/Caches/scarf/snapshots/<server-id>/`; chat (ACP) tunnels as `ssh -T host -- hermes acp` with JSON-RPC over stdio end-to-end. Everything in the feature list below works against remote identically to local.
|
||||||
|
|
||||||
|
### Remote setup requirements
|
||||||
|
|
||||||
|
The remote host must have:
|
||||||
|
|
||||||
|
1. **SSH access** — key-based auth via your local ssh-agent. Scarf never prompts for passphrases; run `ssh-add` once in Terminal before connecting.
|
||||||
|
2. **`sqlite3`** on the remote `$PATH` — needed for the atomic DB snapshots. Install on the remote with `apt install sqlite3` (Ubuntu/Debian), `yum install sqlite` (RHEL/Fedora), or `apk add sqlite` (Alpine).
|
||||||
|
3. **`pgrep`** on the remote `$PATH` — used by the Dashboard "is Hermes running" check. Standard on every distro; install `procps` if missing.
|
||||||
|
4. **`~/.hermes/` readable by the SSH user**. When Hermes runs as a separate user (systemd service, Docker container), the SSH user needs read access to `config.yaml` and `state.db`. Either (a) SSH as the Hermes user, (b) `chmod` Hermes's home to be group-readable and add your SSH user to that group, or (c) set the **Hermes data directory** field when adding the server to point at the right location (e.g. `/var/lib/hermes/.hermes`).
|
||||||
|
|
||||||
|
### Troubleshooting remote connections
|
||||||
|
|
||||||
|
If the connection pill is green but the Dashboard shows "Stopped", "unknown", or empty values, the SSH user can't read the Hermes state files. Open **Manage Servers → 🩺 Run Diagnostics** (or click the yellow "Can't read Hermes state" pill in the toolbar). The diagnostics sheet runs fourteen checks in one SSH session — connectivity, `sqlite3` presence, read access to `config.yaml` and `state.db`, the effective non-login `$PATH` — and tells you exactly which one fails and why, with remediation hints for each. Use the **Copy Full Report** button to paste the full output into a bug report.
|
||||||
|
|
||||||
|
For the common "Hermes isn't at the default path" case (systemd services, Docker), **Test Connection** in the Add Server sheet now probes `/var/lib/hermes/.hermes`, `/opt/hermes/.hermes`, `/home/hermes/.hermes`, and `/root/.hermes` when it can't find `state.db` at `~/.hermes/`, and offers a one-click fill if it finds any of them.
|
||||||
|
|
||||||
## Features
|
## Features
|
||||||
|
|
||||||
|
Scarf mirrors Hermes's surface area through a sidebar-based UI. Sections below map 1:1 to the app's sidebar.
|
||||||
|
|
||||||
|
### Monitor
|
||||||
|
|
||||||
- **Dashboard** — System health, token usage, cost tracking, recent sessions with live refresh
|
- **Dashboard** — System health, token usage, cost tracking, recent sessions with live refresh
|
||||||
- **Insights** — Usage analytics with token breakdown (including reasoning tokens), cost tracking, model/platform stats, top tools bar chart, activity heatmaps, notable sessions, and time period filtering (7/30/90 days or all time)
|
- **Insights** — Usage analytics with token breakdown (including reasoning tokens), cost tracking, model/platform stats, top tools bar chart, activity heatmaps, notable sessions, and time period filtering (7/30/90 days or all time)
|
||||||
- **Sessions Browser** — Full conversation history with message rendering, model reasoning/thinking display, tool call inspection, full-text search, rename, delete, and JSONL export. Subagent sessions are filtered from the main list and accessible via parent session drill-down
|
- **Sessions Browser** — Full conversation history with message rendering, model reasoning/thinking display, tool call inspection, full-text search, rename, delete, and JSONL export. Subagent sessions are filtered from the main list and accessible via parent session drill-down
|
||||||
- **Activity Feed** — Recent tool execution log with filtering by kind and session, detail inspector with pretty-printed arguments and tool output display
|
- **Activity Feed** — Recent tool execution log with filtering by kind and session, detail inspector with pretty-printed arguments and tool output display
|
||||||
- **Live Chat** — Two modes: **Rich Chat** streams responses in real-time via the Agent Client Protocol (ACP) with iMessage-style bubbles, markdown rendering, tool call visualization, thinking/reasoning display, and permission request dialogs; **Terminal** runs `hermes chat` with full ANSI color and Rich formatting via [SwiftTerm](https://github.com/migueldeicaza/SwiftTerm). Both modes support session persistence, resume/continue previous sessions, auto-reconnection with session recovery, and voice mode controls
|
|
||||||
|
### Interact
|
||||||
|
|
||||||
|
- **Live Chat** — Two modes: **Rich Chat** streams responses in real-time via the Agent Client Protocol (ACP) with iMessage-style bubbles, markdown rendering, tool call visualization, thinking/reasoning display, permission request dialogs, and a one-click `/compress` focus sheet (when Hermes advertises the command); **Terminal** runs `hermes chat` with full ANSI color and Rich formatting via [SwiftTerm](https://github.com/migueldeicaza/SwiftTerm). Both modes support session persistence, resume/continue previous sessions, auto-reconnection with session recovery, and voice mode controls
|
||||||
- **Memory Viewer/Editor** — View and edit Hermes's MEMORY.md and USER.md with live file-watcher refresh, external memory provider awareness (Honcho, Supermemory, etc.), and profile-scoped memory support with profile picker
|
- **Memory Viewer/Editor** — View and edit Hermes's MEMORY.md and USER.md with live file-watcher refresh, external memory provider awareness (Honcho, Supermemory, etc.), and profile-scoped memory support with profile picker
|
||||||
- **Skills Browser** — Browse and edit installed skills by category with file content viewer, file switcher, and required config warnings for skills that need specific settings
|
- **Skills Browser** — Browse installed skills by category with file content viewer and required config warnings. **New in 1.6:** Browse the Skills Hub, search by registry (official, skills.sh, well-known, GitHub, ClawHub, LobeHub), install, check for updates, and uninstall — all from the app
|
||||||
- **Tools Manager** — Enable/disable toolsets per platform (CLI, Telegram, Discord, Slack, WhatsApp, Signal, Email, Home Assistant, Webhook, Matrix, Feishu, Mattermost) with toggle switches and segmented platform picker, MCP server status
|
|
||||||
|
### Configure *(new in 1.6)*
|
||||||
|
|
||||||
|
- **Platforms** — Native GUI setup for all 13 messaging platforms (Telegram, Discord, Slack, WhatsApp, Signal, Email, Matrix, Mattermost, Feishu, iMessage, Home Assistant, Webhook, CLI). Per-platform forms write credentials to `~/.hermes/.env` and behavior toggles to `~/.hermes/config.yaml`. WhatsApp and Signal pairing use an inline SwiftTerm terminal for QR scan and signal-cli daemon management
|
||||||
|
- **Personalities** — List defined personalities, pick the active one, and edit `SOUL.md` inline with markdown preview
|
||||||
|
- **Quick Commands** — Editor for custom `/command_name` shell shortcuts with dangerous-pattern detection (`rm -rf`, `mkfs`, etc.)
|
||||||
|
- **Credential Pools** — Per-provider credential rotation with a fixed OAuth flow (URL extraction + browser open + code paste) and proper `--type api-key` handling. API keys never stored in UI state — only last-4 preview. Strategy picker (fill_first / round_robin / least_used / random)
|
||||||
|
- **Plugins** — Install via Git URL or `owner/repo`, update, remove, enable/disable. Reads `~/.hermes/plugins/` directly for reliable state
|
||||||
|
- **Webhooks** — Create, list, test-fire, and remove webhook subscriptions. Detects the "platform not enabled" state and links to gateway setup
|
||||||
|
- **Profiles** — Switch between multiple isolated Hermes instances. Create, rename, delete, export (zip), import. Safe-switch warning reminds users to restart Scarf after activating a different profile
|
||||||
|
|
||||||
|
### Manage
|
||||||
|
|
||||||
|
- **Tools** — Enable/disable toolsets per platform with a connectivity-aware platform menu (green/orange/grey/red dots for connected/configured/offline/error). **Fixed in 1.6:** all 13 platforms now appear (was previously stuck on CLI)
|
||||||
|
- **MCP Servers** — Manage Model Context Protocol servers Hermes connects to. Add via curated presets (GitHub, Linear, Notion, Sentry, Stripe, and more) or fully custom (stdio command + args, or HTTP URL with optional bearer auth). Per-server detail view with enable/disable toggle, environment variable + header editor, tool-include/exclude filters, resources/prompts toggles, request and connect timeouts, OAuth token detection + clearing, and one-click "Test Connection" that runs `hermes mcp test` and surfaces the discovered tool list. Gateway-restart banner appears after config changes that require a reload
|
||||||
- **Gateway Control** — Start/stop/restart the messaging gateway, view platform connection status, manage user pairing (approve/revoke)
|
- **Gateway Control** — Start/stop/restart the messaging gateway, view platform connection status, manage user pairing (approve/revoke)
|
||||||
- **Cron Manager** — View scheduled jobs with pre-run scripts, delivery failure tracking, timeout info, and `[SILENT]` job indicators
|
- **Cron Manager** — View scheduled jobs with pre-run scripts, delivery failure tracking, timeout info, and `[SILENT]` job indicators. **New in 1.6:** full write support — create, edit, pause, resume, run-now, and delete jobs from the app
|
||||||
- **Log Viewer** — Real-time log tailing for agent.log, errors.log, and gateway.log with level filtering and text search
|
- **Health** — Component-level status and diagnostics. **New in 1.6:** inline "Run Dump" and "Share Debug Report" buttons (the latter with an upload-confirmation dialog before sending to Nous support)
|
||||||
- **Project Dashboards** — Custom, agent-generated dashboards for any project. Define stat boxes, charts, tables, progress bars, checklists, rich text, and embedded web views in a simple JSON file — Scarf renders them with live refresh. Let your Hermes agent build and maintain project-specific visualizations automatically
|
- **Log Viewer** — Real-time log tailing for agent.log, errors.log, and gateway.log with level filtering, component filter (Gateway / Agent / Tools / CLI / Cron), clickable session-ID pills that filter to a single session, and text search
|
||||||
- **Settings** — Structured config editor for all Hermes settings including model/provider selection, browser backend, reasoning effort, approval mode, cost display, Docker environment, command allowlist, credential management, and more
|
- **Settings** — **Restructured in 1.6** into a 10-tab layout: General, Display, Agent, Terminal, Browser, Voice, Memory, Aux Models, Security, Advanced. Exposes ~60 previously hidden config fields including all 8 auxiliary model tasks, container limits, full TTS/STT provider settings, human-delay simulation, compression thresholds, logging rotation, checkpoints, website blocklist, Tirith sandbox, and delegation. One-click **Backup & Restore** via `hermes backup` / `hermes import`. Model picker replaces the old free-text model field, backed by the models.dev cache (111 providers, all major models) with a "Custom…" escape hatch
|
||||||
|
|
||||||
|
### Project Dashboards
|
||||||
|
|
||||||
|
Custom, agent-generated dashboards for any project. Define stat boxes, charts, tables, progress bars, checklists, rich text, and embedded web views in a simple JSON file — Scarf renders them with live refresh. Let your Hermes agent build and maintain project-specific visualizations automatically. See [Project Dashboards](#project-dashboards-1) below for the full schema.
|
||||||
|
|
||||||
|
### System
|
||||||
|
|
||||||
- **Hermes Process Control** — Start, stop, and restart the Hermes agent directly from Scarf
|
- **Hermes Process Control** — Start, stop, and restart the Hermes agent directly from Scarf
|
||||||
- **Menu Bar** — Status icon showing Hermes running state with quick actions
|
- **Menu Bar** — Status icon showing Hermes running state with quick actions
|
||||||
|
|
||||||
## 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/` (v0.8.0 recommended for full feature support)
|
- Xcode 16.0+ to build from source
|
||||||
|
- [Hermes agent](https://github.com/hermes-ai/hermes-agent) v0.6.0+ installed at `~/.hermes/` on each target host (v0.11.0+ recommended for full v2.5 feature support — `/steer`, new state.db columns, design-md/spotify skills, SKILL.md frontmatter chips)
|
||||||
|
- For remote servers: SSH access (key-based), `sqlite3` on the remote (for atomic DB snapshots), and the `hermes` CLI resolvable from the remote user's `PATH` or at a path you specify per server. ScarfGo requires the same on every Hermes host it connects to.
|
||||||
|
|
||||||
### Compatibility
|
### Compatibility
|
||||||
|
|
||||||
@@ -49,7 +146,12 @@ Scarf reads Hermes's SQLite database and parses CLI output from `hermes status`,
|
|||||||
|----------------|--------|
|
|----------------|--------|
|
||||||
| v0.6.0 (2026-03-30) | Verified |
|
| v0.6.0 (2026-03-30) | Verified |
|
||||||
| v0.7.0 (2026-04-03) | Verified |
|
| v0.7.0 (2026-04-03) | Verified |
|
||||||
| v0.8.0 (2026-04-08, latest) | Verified |
|
| v0.8.0 (2026-04-08) | Verified |
|
||||||
|
| v0.9.0 (2026-04-13) | Verified |
|
||||||
|
| v0.10.0 (2026-04-16) | Verified (Tool Gateway introduced) |
|
||||||
|
| v0.11.0 (2026-04-23) | **Verified — current target (recommended for full v2.5 feature support)** |
|
||||||
|
|
||||||
|
Scarf 2.5 targets Hermes v0.11.0 for `/steer`, the new state.db columns (`messages.reasoning_content`, `sessions.api_call_count`), the new skills (design-md, spotify), the SKILL.md frontmatter chip surfaces, and the `hermes memory reset` toolbar action. Earlier Hermes versions remain supported for monitoring, sessions, file-based features, and ACP chat; v0.11-specific behavior degrades gracefully on older agents (`/steer` is harmless, new columns silently nil out).
|
||||||
|
|
||||||
If a Hermes update changes the database schema or CLI output format, Scarf may need to be updated. Check the [Health](#features) view for compatibility warnings.
|
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.
|
||||||
|
|
||||||
@@ -57,11 +159,15 @@ If a Hermes update changes the database schema or CLI output format, Scarf may n
|
|||||||
|
|
||||||
### Pre-built Binary (no Xcode required)
|
### Pre-built Binary (no Xcode required)
|
||||||
|
|
||||||
Download the latest universal binary (Apple Silicon + Intel) from [Releases](https://github.com/awizemann/scarf/releases):
|
Download the latest build from [Releases](https://github.com/awizemann/scarf/releases):
|
||||||
|
|
||||||
1. Download `Scarf-vX.X.X-Universal.zip`
|
- `Scarf-vX.X.X-Universal.zip` — Apple Silicon + Intel (recommended)
|
||||||
2. Unzip and drag **Scarf.app** to Applications
|
- `Scarf-vX.X.X-ARM64.zip` — Apple Silicon only (smaller download)
|
||||||
3. On first launch, right-click and choose **Open** (or go to System Settings → Privacy & Security → Open Anyway)
|
|
||||||
|
1. Unzip and drag **Scarf.app** to Applications
|
||||||
|
2. Launch normally — builds are Developer ID signed and notarized, so Gatekeeper accepts them on first launch
|
||||||
|
|
||||||
|
Scarf checks for updates automatically on launch via [Sparkle](https://sparkle-project.org) and daily thereafter. You can disable automatic checks or trigger a manual check from **Settings → General → Updates** or the menu bar icon.
|
||||||
|
|
||||||
### Build from Source
|
### Build from Source
|
||||||
|
|
||||||
@@ -96,6 +202,7 @@ scarf/
|
|||||||
Memory/ Memory viewer and editor
|
Memory/ Memory viewer and editor
|
||||||
Skills/ Skill browser by category
|
Skills/ Skill browser by category
|
||||||
Tools/ Toolset management per platform
|
Tools/ Toolset management per platform
|
||||||
|
MCPServers/ MCP server registry, presets, OAuth, tool filters, test runner
|
||||||
Gateway/ Messaging gateway control and pairing
|
Gateway/ Messaging gateway control and pairing
|
||||||
Cron/ Scheduled job viewer
|
Cron/ Scheduled job viewer
|
||||||
Logs/ Real-time log viewer
|
Logs/ Real-time log viewer
|
||||||
@@ -122,6 +229,8 @@ Scarf reads Hermes data directly from `~/.hermes/`:
|
|||||||
| `hermes sessions` | CLI commands | Rename/Delete/Export |
|
| `hermes sessions` | CLI commands | Rename/Delete/Export |
|
||||||
| `hermes gateway` | CLI commands | Start/Stop/Restart |
|
| `hermes gateway` | CLI commands | Start/Stop/Restart |
|
||||||
| `hermes pairing` | CLI commands | Approve/Revoke |
|
| `hermes pairing` | CLI commands | Approve/Revoke |
|
||||||
|
| `hermes mcp` | CLI commands | Add/Remove/Test MCP servers |
|
||||||
|
| `mcp-tokens/*.json` | JSON (per-server OAuth) | Detect/Delete |
|
||||||
| `.scarf/dashboard.json` | JSON (per-project) | Read-only |
|
| `.scarf/dashboard.json` | JSON (per-project) | Read-only |
|
||||||
| `scarf/projects.json` | JSON (registry) | Read/Write |
|
| `scarf/projects.json` | JSON (registry) | Read/Write |
|
||||||
|
|
||||||
@@ -132,6 +241,7 @@ The app opens `state.db` in read-only mode to avoid WAL contention with Hermes.
|
|||||||
| Package | Purpose |
|
| Package | Purpose |
|
||||||
|---------|---------|
|
|---------|---------|
|
||||||
| [SwiftTerm](https://github.com/migueldeicaza/SwiftTerm) | Terminal emulator for the Chat feature |
|
| [SwiftTerm](https://github.com/migueldeicaza/SwiftTerm) | Terminal emulator for the Chat feature |
|
||||||
|
| [Sparkle](https://github.com/sparkle-project/Sparkle) | Auto-updates from the GitHub-hosted appcast |
|
||||||
|
|
||||||
Everything else uses system frameworks: SQLite3 C API, Foundation JSON, AttributedString markdown, SwiftUI Charts, GCD file watching.
|
Everything else uses system frameworks: SQLite3 C API, Foundation JSON, AttributedString markdown, SwiftUI Charts, GCD file watching.
|
||||||
|
|
||||||
@@ -281,6 +391,30 @@ Your agent can update the dashboard as part of cron jobs, after builds, or whene
|
|||||||
|
|
||||||
Each section defines a grid with 1–4 columns. Widgets flow left-to-right, wrapping to new rows. See [DASHBOARD_SCHEMA.md](scarf/docs/DASHBOARD_SCHEMA.md) for the full schema reference with examples of every widget type.
|
Each section defines a grid with 1–4 columns. Widgets flow left-to-right, wrapping to new rows. See [DASHBOARD_SCHEMA.md](scarf/docs/DASHBOARD_SCHEMA.md) for the full schema reference with examples of every widget type.
|
||||||
|
|
||||||
|
## Releases
|
||||||
|
|
||||||
|
Scarf ships through GitHub releases — the App Store is not supported because Scarf spawns the user-installed `hermes` binary and reads `~/.hermes/` directly, both of which App Sandbox forbids.
|
||||||
|
|
||||||
|
Each release goes through a single local script: [scripts/release.sh](scripts/release.sh). The script archives a universal binary, signs it with the Developer ID Application cert, submits to `notarytool`, staples the ticket, produces the distribution zip, signs an appcast entry with Sparkle's EdDSA key, pushes an updated `appcast.xml` to the `gh-pages` branch, creates the GitHub release, and tags `main`.
|
||||||
|
|
||||||
|
The Sparkle appcast is served from [awizemann.github.io/scarf/appcast.xml](https://awizemann.github.io/scarf/appcast.xml).
|
||||||
|
|
||||||
|
Signing prerequisites (one-time):
|
||||||
|
|
||||||
|
- `Developer ID Application` certificate in the login Keychain
|
||||||
|
- `scarf-notary` keychain profile registered via `xcrun notarytool store-credentials`
|
||||||
|
- Sparkle EdDSA private key in Keychain item `https://sparkle-project.org` (back this up — without it, shipped apps can never receive updates)
|
||||||
|
|
||||||
|
## Template Catalog
|
||||||
|
|
||||||
|
Community-contributed Scarf project templates live under [`templates/`](templates/) in this repo and are browsable at **[awizemann.github.io/scarf/templates/](https://awizemann.github.io/scarf/templates/)** with live dashboard previews and one-click `scarf://install?url=…` links.
|
||||||
|
|
||||||
|
- **Install from the web** — click "Install with Scarf" on any template's detail page; the app takes over from there.
|
||||||
|
- **Install from a local file** — Scarf → Projects → Templates → Install from File…, or double-click any `.scarftemplate` in Finder.
|
||||||
|
- **Author a template** — see [`templates/CONTRIBUTING.md`](templates/CONTRIBUTING.md) for the full walkthrough. Fork, drop a template under `templates/<your-github-handle>/<your-name>/`, open a PR; CI validates the bundle automatically.
|
||||||
|
|
||||||
|
The catalog's site is a static HTML + vanilla JS build generated by [`tools/build-catalog.py`](tools/build-catalog.py) and driven by [`scripts/catalog.sh`](scripts/catalog.sh) (check / build / preview / publish). Appcast and main landing page are independent — updating the catalog never disturbs Sparkle.
|
||||||
|
|
||||||
## Contributing
|
## Contributing
|
||||||
|
|
||||||
Contributions are welcome. Please open an issue to discuss what you'd like to change before submitting a PR.
|
Contributions are welcome. Please open an issue to discuss what you'd like to change before submitting a PR.
|
||||||
@@ -291,6 +425,8 @@ Contributions are welcome. Please open an issue to discuss what you'd like to ch
|
|||||||
4. Push to the branch (`git push origin feature/my-feature`)
|
4. Push to the branch (`git push origin feature/my-feature`)
|
||||||
5. Open a Pull Request
|
5. Open a Pull Request
|
||||||
|
|
||||||
|
For template submissions, see [`templates/CONTRIBUTING.md`](templates/CONTRIBUTING.md) — same flow, with a catalog-specific checklist + automated CI validation.
|
||||||
|
|
||||||
## Support
|
## Support
|
||||||
|
|
||||||
If you find Scarf useful, consider buying me a coffee.
|
If you find Scarf useful, consider buying me a coffee.
|
||||||
|
|||||||
@@ -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,
|
||||||
|
});
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
## What's New in 1.6.1
|
||||||
|
|
||||||
|
### Auto-updates
|
||||||
|
|
||||||
|
Scarf now ships with [Sparkle](https://sparkle-project.org). On launch (and daily thereafter) it checks an EdDSA-signed appcast at [awizemann.github.io/scarf/appcast.xml](https://awizemann.github.io/scarf/appcast.xml). When a new version is available you'll get an in-app update prompt — no more manually downloading zips and dragging into Applications.
|
||||||
|
|
||||||
|
You can disable automatic checks or trigger a manual one from **Settings → General → Updates**, the menu bar icon, or the **Scarf → Check for Updates…** menu item.
|
||||||
|
|
||||||
|
### Notarized & Developer ID signed
|
||||||
|
|
||||||
|
This is the first release that's properly Developer ID signed and notarized by Apple. Gatekeeper accepts it on first launch — no more right-click → Open dance, no more "Scarf cannot be opened because the developer cannot be verified" warnings.
|
||||||
|
|
||||||
|
### Fixes
|
||||||
|
|
||||||
|
- Chat works correctly when no terminal hermes session is running, and surfaces the real error when it can't reach the agent (b6df…)
|
||||||
|
|
||||||
|
### Under the hood
|
||||||
|
|
||||||
|
- Tracked `Info.plist` (replacing auto-generation) so signing-relevant keys live in version control
|
||||||
|
- New `UpdaterService` wraps Sparkle and is injected via SwiftUI `.environment()`
|
||||||
|
- One-command release pipeline at [scripts/release.sh](https://github.com/awizemann/scarf/blob/main/scripts/release.sh) handles archive → sign → notarize → staple → appcast → GitHub release → tag
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**Migrating from 1.6.0:** unzip and replace your existing `Scarf.app` in `/Applications`. After this release, future updates install in-place via Sparkle.
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
## What's New in 1.6.2
|
||||||
|
|
||||||
|
### Fixes
|
||||||
|
|
||||||
|
- **No more bogus "missing credentials" banner on Chat.** The orange "No AI provider credentials detected" warning was firing on the Chat tab whenever no session was selected, even for users whose credentials were configured and working. Root cause: the preflight check only inspected `~/.hermes/.env` and shell environment variables, missing the Credential Pools file at `~/.hermes/auth.json` (the in-app flow introduced in 1.6.0) and `api_key:` fields in `config.yaml`. The check now covers all four locations Hermes itself reads at runtime, so if you've added credentials via **Configure → Credential Pools**, the warning stays hidden.
|
||||||
|
|
||||||
|
### Polish
|
||||||
|
|
||||||
|
- Banner subtitle updated to point users at the in-app Credential Pools flow first, rather than prescribing `.env` edits.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**Upgrading from 1.6.1:** Sparkle will offer the update automatically. You can also trigger a check via **Scarf → Check for Updates…** or the menu bar icon.
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
## What's New in 2.0
|
||||||
|
|
||||||
|
Scarf now manages **multiple Hermes installations** — your local `~/.hermes/` plus any number of remote Hermes instances reached over SSH. Every feature that worked on your Mac now works against a Linux server, a Mac mini on the network, or whatever other host has Hermes installed.
|
||||||
|
|
||||||
|
This is a major version bump because the entire service layer was rewritten around a `ServerContext` + `ServerTransport` abstraction, and because the window model changed from single-window-single-server to multi-window-one-server-per-window.
|
||||||
|
|
||||||
|
### Multi-server
|
||||||
|
|
||||||
|
- **Manage Servers** sheet lets you add, rename, and remove remote servers. Each entry is an SSH target (`user@host`, port, optional identity file, optional `remoteHome` override if your install isn't at `~/.hermes/`).
|
||||||
|
- Each window is bound to exactly one server. Open a second window via **File → Open Server** → pick a different server, and the two run side-by-side with independent state — chat, dashboards, activity, sessions, the lot.
|
||||||
|
- The menu bar status icon shows a summary across all registered servers (green hare = any Hermes running anywhere).
|
||||||
|
- Window-state restoration: quit + relaunch re-opens every window you had open, each reconnected to its bound server.
|
||||||
|
|
||||||
|
### Remote over SSH
|
||||||
|
|
||||||
|
- **ControlMaster connection pooling** — after the first auth, each remote primitive is a ~5ms tunnel call. Uses the system `ssh`, `scp`, `sftp` so your `~/.ssh/config`, ssh-agent, 1Password/Secretive SSH agents, and ProxyJump all work unchanged.
|
||||||
|
- **DB access via atomic snapshots** — Scarf runs `sqlite3 .backup` on the remote (WAL-safe, won't corrupt), flips the snapshot out of WAL mode, and pulls it down with `scp`. Snapshots are cached under `~/Library/Caches/scarf/snapshots/<server-id>/` and re-pulled when the file watcher sees a change on the remote's `state.db`.
|
||||||
|
- **ACP chat over SSH** — the Agent Client Protocol tunnel runs `ssh -T host -- hermes acp`. JSON-RPC over stdio travels end-to-end unmodified, so Rich Chat, streaming, tool calls, permission dialogs, and compression all work against the remote agent identically to local.
|
||||||
|
- **File watcher** — local uses FSEvents (instant); remote polls `stat` mtime every 3s with ControlMaster keeping the cost bounded. Views auto-refresh on any tick.
|
||||||
|
- **Cleanup on server-remove** — deleting a remote closes its ControlMaster socket (`ssh -O exit`), prunes its snapshot cache, and invalidates any process-wide caches keyed to its ID. App launch also sweeps orphaned snapshot dirs whose UUIDs are no longer in the registry.
|
||||||
|
|
||||||
|
### Chat UX overhaul
|
||||||
|
|
||||||
|
All of these were visible bugs during remote dogfooding and are now fixed on both local and remote:
|
||||||
|
|
||||||
|
- **No more white-screen flash** on the first message of a session. `RichChatView` used to swap `ContentUnavailableView` out for the message list, which tore down and recreated the entire ScrollView hierarchy. The empty state now lives inside the ScrollView itself.
|
||||||
|
- **No more scroll-jumping to whitespace** at stream start/finish. Replaced six racing `onChange`-driven scroll calls with SwiftUI's built-in `.defaultScrollAnchor(.bottom)`, which is implemented inside the layout pass and doesn't overshoot LazyVStack content.
|
||||||
|
- **Resuming a session on a remote now shows its full history.** The DB snapshot is refreshed on session-load — previously it was pulled once on first open and never again, so any messages the remote wrote since launch were invisible.
|
||||||
|
- **"Continue from last session" surfaces errors** instead of silently doing nothing when SSH is down.
|
||||||
|
- **Typing into a blank Chat always creates a new session.** Previously it auto-resumed the most recently active session in the DB, which often picked up a cron-spawned session that Hermes had already garbage-collected — producing a silent prompt failure.
|
||||||
|
- **Failed prompts now explain themselves.** When the agent returns `stopReason: "refusal"`, `"error"`, or `"max_tokens"` with no assistant output, a system message appears under your prompt explaining what happened. No more spinning "Agent working…" forever.
|
||||||
|
|
||||||
|
### Correctness — remote SQLite
|
||||||
|
|
||||||
|
- The WAL-error spam (`cannot open file at line 51044 of [f0ca7bba1c] — os_unix.c:51044: (2) open(/Users/…/state.db-wal) - No such file or directory`) is gone. `sqlite3 .backup` preserves the source DB's journal mode; the scp'd copy used to try to open a WAL sidecar that doesn't exist. The snapshot script now runs `PRAGMA journal_mode=DELETE` after `.backup` on the remote, and Scarf opens remote snapshots with `file:…?immutable=1` as defense-in-depth.
|
||||||
|
- **Concurrent snapshot dedupe** — a new `SnapshotCoordinator` actor makes sure that when Dashboard + Sessions + Activity all ask for a fresh snapshot at the same moment (e.g. on a file-watcher tick), only one SSH backup runs; the other callers await the in-flight pull and share the result.
|
||||||
|
|
||||||
|
### Under the hood
|
||||||
|
|
||||||
|
- New `ServerContext` value type flows through `.environment()` to every view and ViewModel. Every file and process operation routes through `context.makeTransport()` — `LocalTransport` (`FileManager`, `Process`, FSEvents) or `SSHTransport` (ssh, scp, sftp, mtime polling). The protocol is small enough that each transport is ~400 lines.
|
||||||
|
- Swift 6 complete-concurrency sweep: ~230 warnings reduced to 1. `ServerContext`, `HermesPathSet`, `ServerTransport`, all service inits, and every value-type accessor are explicitly `nonisolated`. Hand-written `Codable` conformances for the nine types whose synthesized conformances were inferred `@MainActor` by Swift 6's default-isolation rule (`ACPRequest`, `ACPRawMessage`, `GatewayState`, `PlatformState`, `HermesCronJob`, `CronSchedule`, `CronJobsFile`, `AuthFile`, `AuthEntry`).
|
||||||
|
- ACP cwd now comes from the *remote* `$HOME`, probed once on first connect and cached per server. Previously it passed your local Mac's home path to the ACP adapter, which only worked by coincidence when the remote username matched.
|
||||||
|
|
||||||
|
### Compatibility
|
||||||
|
|
||||||
|
Hermes v0.10.0 is now verified alongside v0.6–v0.9. Scarf builds its session/message `SELECT` columns based on an additive schema detection (`hasV07Schema`), so newer Hermes versions with extra columns don't break queries.
|
||||||
|
|
||||||
|
### Migration from 1.6.x
|
||||||
|
|
||||||
|
- Sparkle will offer the update automatically. Trigger manually via **Scarf → Check for Updates…** or the menu bar.
|
||||||
|
- Your local server is synthesized automatically — existing 1.6.x users see "Local" in the server list with no setup needed.
|
||||||
|
- `servers.json` is created on first add-remote. Location: `~/Library/Application Support/scarf/servers.json`.
|
||||||
|
- Nothing you configured in 1.6.x (OAuth tokens, credential pools, cron jobs, MCP servers, platform setup) is touched. Those live in `~/.hermes/` and remain the source of truth.
|
||||||
|
|
||||||
|
### Known limitations
|
||||||
|
|
||||||
|
- Remote file watching is 3s mtime polling (vs. FSEvents on local). If you need sub-second updates on a remote, that's a followup.
|
||||||
|
- The `session/load` ACP call against an already-deleted session returns success-with-no-body from the Hermes adapter — Scarf now detects the resulting `stopReason: "refusal"` and surfaces it, but the underlying Hermes behavior is an upstream-adapter bug that should also get a proper error response.
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
## What's New in 2.0.1
|
||||||
|
|
||||||
|
Hotfix for [#19](https://github.com/awizemann/scarf/issues/19) and the related reports from the first day of v2.0: users' remote SSH connections would show a green "Connected" pill but every view (Dashboard, Sessions, Activity, Chat) read as empty / "not running" / "not configured". Three distinct environments reported it — Docker Hermes on a LAN, homelab VM over Tailscale, Ubuntu VPS — and every one was a silent file-access failure on the remote that Scarf wasn't surfacing.
|
||||||
|
|
||||||
|
### Errors no longer disappear
|
||||||
|
|
||||||
|
Every remote read (`config.yaml`, `gateway_state.json`, `state.db`, `pgrep`) used to silently substitute an empty value on *any* failure — permission denied, missing file, `sqlite3` not installed, connection drop — they all looked identical to the UI. Now:
|
||||||
|
|
||||||
|
- Each failure logs a specific warning via `os.Logger` (visible in Console.app under subsystem `com.scarf`).
|
||||||
|
- The Dashboard shows an orange banner above the stats with the exact error (e.g. "Permission denied reading `~/.hermes/state.db`") and a **Run Diagnostics…** button.
|
||||||
|
- `HermesDataService` exposes a `lastOpenError` so views can explain *why* state.db couldn't be opened, rather than just rendering zeros.
|
||||||
|
- Routine "file doesn't exist" cases (optional `skill.yaml` metadata, `gateway_state.json` before Hermes starts, `memories/USER.md` on fresh installs) are detected and **not** logged as warnings — only real errors (permission denied, connection drops, `sqlite3` missing) hit the log. Prevents Console from filling with false-positive noise when directory walks encounter optional files.
|
||||||
|
|
||||||
|
### New Remote Diagnostics sheet
|
||||||
|
|
||||||
|
Accessible from **Manage Servers → 🩺** per-server button, or by clicking the orange connection pill when Scarf can see the server but can't read Hermes state. Runs fourteen checks in a single SSH session and shows pass/fail for each, plus a targeted hint per failure:
|
||||||
|
|
||||||
|
- SSH connectivity and auth
|
||||||
|
- Remote user identity and `$HOME` resolution
|
||||||
|
- `~/.hermes` directory existence and readability
|
||||||
|
- `config.yaml` readable (existence *and* actual read access — the old probe only checked existence)
|
||||||
|
- `state.db` readable
|
||||||
|
- `sqlite3` installed on the remote (required for the atomic snapshot Scarf pulls)
|
||||||
|
- `sqlite3` can actually open `state.db`
|
||||||
|
- `hermes` binary on the non-login `$PATH` (what runtime uses)
|
||||||
|
- `hermes` binary on the login `$PATH` (what the Test Connection probe uses)
|
||||||
|
- `pgrep` available (for the "is Hermes running" check)
|
||||||
|
|
||||||
|
One **Copy Full Report** button dumps every check as plain text for bug reports, and a raw-output disclosure panel shows the exact stdout/stderr the remote returned whenever any probe fails — so transport-level problems are self-diagnosing.
|
||||||
|
|
||||||
|
The diagnostics script is piped to `/bin/sh -s` on stdin rather than passed as `sh -c <script>` argv. The latter was getting split line-by-line by the remote's login shell (newlines parsed as command separators), which stranded variables set on line 1 in an ephemeral `sh` subprocess that exited before line 2 could use them. Stdin-piping runs the whole script in one `sh` process with variable scope preserved.
|
||||||
|
|
||||||
|
### Connection pill gains a "degraded" state
|
||||||
|
|
||||||
|
The pill used to be green as long as SSH connected; now after connectivity passes it runs a second-tier check (`test -r $HOME/.hermes/config.yaml`). If that fails, the pill turns **orange** with "Connected — can't read Hermes state" and clicking it opens Remote Diagnostics directly. This is the exact symptom mode in #19, and it's now one click away from a specific answer.
|
||||||
|
|
||||||
|
The pill's visual also got a pass: the colored dot is replaced with a state-specific SF Symbol (`checkmark.circle.fill` / `stethoscope` / `arrow.triangle.2.circlepath` / `exclamationmark.triangle.fill`), which reads more like a clickable toolbar tool and doubles as the status signal. No custom pill background anymore — the toolbar's native `.principal` bezel is the frame.
|
||||||
|
|
||||||
|
### Auto-suggest the correct `remoteHome` during Add Server
|
||||||
|
|
||||||
|
When Test Connection can't find `state.db` at the configured (or default) path, it now also probes the common alternate locations — `/var/lib/hermes/.hermes`, `/opt/hermes/.hermes`, `/home/hermes/.hermes`, `/root/.hermes` — and offers a one-click "Use this" fill if it finds one. Removes the need to know that systemd-installed Hermes lives at `/var/lib/hermes/.hermes` by convention.
|
||||||
|
|
||||||
|
### Clearer copy for the `remoteHome` field
|
||||||
|
|
||||||
|
The Add Server sheet field is now labeled "Hermes data directory" with a description explaining when you'd override it (systemd service installs, Docker sidecars) and noting that Test Connection auto-suggests.
|
||||||
|
|
||||||
|
### README has a new "Remote setup requirements" section
|
||||||
|
|
||||||
|
Four concrete prerequisites (SSH, `sqlite3`, `pgrep`, read access to `~/.hermes`) and a troubleshooting paragraph pointing at Remote Diagnostics.
|
||||||
|
|
||||||
|
### Migrating from 2.0.0
|
||||||
|
|
||||||
|
Sparkle will offer the update automatically. Settings and server list are preserved verbatim — this is purely additive (new diagnostics surface, new error banners, auto-suggest in Test Connection). If you were affected by #19, run Remote Diagnostics after updating; the sheet should pinpoint the specific file access issue and suggest a fix.
|
||||||
|
|
||||||
|
### Under the hood
|
||||||
|
|
||||||
|
- New types: `RemoteDiagnosticsViewModel`, `RemoteDiagnosticsView`. Both are local to Scarf; no new transport protocol.
|
||||||
|
- `HermesFileService` gains `loadConfigResult()`, `loadGatewayStateResult()`, `hermesPIDResult()`, `readFileResult()`, `readFileDataResult()` — Result-returning variants that preserve the error. Legacy `loadConfig()` etc. still exist as thin forwarders for callers that don't need diagnostics.
|
||||||
|
- `HermesDataService.open()` records `lastOpenError` with humanized hints for "sqlite3 not installed", "permission denied", and "file not found" — the three failure modes that produce 90% of issue #19 symptoms.
|
||||||
|
- `ConnectionStatusViewModel` status enum gains `.degraded(reason:)` between `.connected` and `.error`.
|
||||||
|
- `TestConnectionProbe` result enum gains `suggestedRemoteHome: String?` carrying any alternate-location hit.
|
||||||
|
|
||||||
|
### Known follow-ups (not in 2.0.1)
|
||||||
|
|
||||||
|
- `TestConnectionProbe` uses a direct-argv ssh invocation that's functionally correct but fragile (works by accident when split across the login shell). Should be ported to the stdin-pipe pattern the diagnostics sheet now uses.
|
||||||
|
- Remaining `try?`-swallowed read paths beyond the four Dashboard-surfacing ones — Cron, Memory, Skills, MCP Servers, Platforms still silently render empty on read errors. Same fix pattern applies, low priority.
|
||||||
|
- `hermesBinaryHint` is only populated when the user clicks Test Connection; if they skip it, ACP chat and CLI calls fall back to bare `hermes` which requires it on the non-interactive PATH (rarely true for `~/.local/bin` installs). The connection-pill's second-tier probe could auto-populate this.
|
||||||
|
- Docker-host support: when users SSH to a Docker host, `pgrep` and `~/.hermes/` on the host don't see what's inside the container. Needs a `docker exec` wrapping option per server.
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
## What's New in 2.0.2
|
||||||
|
|
||||||
|
The actual root cause of [#19](https://github.com/awizemann/scarf/issues/19), found and patched by Scarf's first external contributor. v2.0.1 added the diagnostics UI assuming file-perm root cause; v2.0.2 fixes the underlying bug for everyone, regardless of perms.
|
||||||
|
|
||||||
|
### macOS Unix domain socket path limit (the real #19)
|
||||||
|
|
||||||
|
OpenSSH's ControlMaster multiplexes our bursty stat/cat/cp traffic over one TCP session per host. The socket path is bound by `bind(2)` to a Unix domain socket — and macOS' `sun_path` is **104 bytes including the NUL terminator**.
|
||||||
|
|
||||||
|
Scarf's old socket path was `~/Library/Caches/scarf/ssh/<%C>` where `%C` is OpenSSH's 64-char SHA1 hash of `(local user, host, port, remote user)`. For a username like `alex.maksimchuk`, the full path landed at **105 bytes** — one byte over the limit. ssh exited 255 with `unix_listener: path "..." too long for Unix domain socket`. Our `LogLevel=QUIET` flag (set so ACP's line-delimited JSON stays binary-clean) suppressed the diagnostic, and the user just saw "Remote command exited 255" — which the UI rendered as the silent empty-data state every reporter in #19 described.
|
||||||
|
|
||||||
|
The fix is to use a much shorter path:
|
||||||
|
|
||||||
|
```swift
|
||||||
|
"/tmp/scarf-ssh-\(getuid())" // ~17 bytes + 64 hash + sep + NUL = ~83 bytes
|
||||||
|
```
|
||||||
|
|
||||||
|
Per-user uid suffix keeps two local users' sockets from colliding in the shared `/tmp`, and 0700 perms on the dir keep them inaccessible to other users.
|
||||||
|
|
||||||
|
**Massive thanks to Alex Maksimchuk ([@aliatx2017](https://github.com/aliatx2017)) — Scarf's first external PR contributor — for diagnosing and patching this in [#20](https://github.com/awizemann/scarf/pull/20).** That diagnosis only happened because Alex bothered to read the codebase, reproduce against multiple usernames including a Termux/Android instance, and walk back from the cryptic exit code to the actual `bind()` failure. This release wouldn't exist without that work.
|
||||||
|
|
||||||
|
### Hardening on top of the fix
|
||||||
|
|
||||||
|
Three additions on top of Alex's patch, layered in via separate commits to keep the original change reviewable:
|
||||||
|
|
||||||
|
- **Defensive ownership check on the socket dir.** `/tmp` is world-writable, so a malicious local user could pre-create `/tmp/scarf-ssh-<uid>` and trick Scarf into using a hostile directory (we'd silently fail to chmod it back to 0700, since we wouldn't own it). `ensureControlDir` now uses POSIX `mkdir(0700)` (atomic, sets perms at create time) and on `EEXIST` runs `lstat` to verify the entry is a directory we own with mode 0700 — symlink → refuse, wrong owner → refuse + log to `os.Logger`, wrong mode → repair. Closes the `/tmp` pre-creation hole that's the standard concern for any per-user `/tmp` path.
|
||||||
|
- **Launch-time sweep of stale sockets.** `ServerRegistry.sweepOrphanCaches` already prunes orphaned snapshot directories on launch; it now also removes ControlMaster socket files older than 30 minutes. Socket basenames are `%C` hashes (not ServerIDs), so we can't keep "still registered" sockets the way the snapshot sweep does — but `ControlPersist` is 600s, so anything older than 30 minutes is guaranteed to be a dead orphan from a crashed master, an unclean app exit, or a server removed while another Scarf instance was holding the dir. Keeps `/tmp/scarf-ssh-<uid>/` from accumulating indefinitely until reboot, while leaving any concurrent Scarf instance's live sockets untouched.
|
||||||
|
- **Regression test for the path-length invariant.** `scarfTests` was a stub — it now has two tests: one asserting `controlDirPath().utf8.count + 1 + 64 + 1 ≤ 104` (would have caught the original #19 bug in CI), one asserting the path includes the current uid (pins the per-user-isolation invariant against a future "simplification" that drops it).
|
||||||
|
|
||||||
|
### v2.0.1 diagnostics work is still useful
|
||||||
|
|
||||||
|
The diagnostics sheet, orange "degraded" pill, dashboard error banner, and `remoteHome` auto-suggest from v2.0.1 all still ship — they just turn out not to have been the right diagnosis for the original three reporters. They remain valuable for the *other* connection-failure modes they were designed to surface (missing `sqlite3` on the remote, real permission errors, container/host visibility gaps, custom Hermes data directories). If you upgrade to v2.0.2 and *still* see incomplete data, run Remote Diagnostics from **Manage Servers → 🩺** and the sheet will tell you why.
|
||||||
|
|
||||||
|
### Migrating from 2.0.0 / 2.0.1 / draft 2.0.1
|
||||||
|
|
||||||
|
Sparkle will offer the update automatically. Settings and server list are preserved verbatim. The first time v2.0.2 connects to a remote, it'll create `/tmp/scarf-ssh-<uid>/` with mode 0700; the old `~/Library/Caches/scarf/ssh/` directory becomes unused (you can delete it manually, or leave it — macOS will sweep it eventually).
|
||||||
|
|
||||||
|
The previous v2.0.1 draft download remains available for anyone who already grabbed it — it's still a valid build with the diagnostics work. v2.0.2 is the recommended upgrade path.
|
||||||
|
|
||||||
|
### Reporters of #19
|
||||||
|
|
||||||
|
@cmalpass, @flyespresso, @maikokan — please grab v2.0.2 and confirm the dashboard populates without needing to run Remote Diagnostics first. If it still doesn't, the diagnostics sheet should now have a much better chance of pinpointing what's left.
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
## What's New in 2.1.0
|
||||||
|
|
||||||
|
Scarf now speaks seven languages and has a proper slash-command menu in the chat. The language work closes [#13](https://github.com/awizemann/scarf/issues/13) and opens the door for community contributions of additional locales.
|
||||||
|
|
||||||
|
### Multi-language support
|
||||||
|
|
||||||
|
The UI is now fully translated to **Simplified Chinese, German, French, Spanish, Japanese, and Brazilian Portuguese** on top of the existing English. Scarf respects the system language by default; override per-app from **System Settings → Language & Region → Apps → Scarf**.
|
||||||
|
|
||||||
|
- **644 source strings** catalogued. **583 translated per locale** — the remaining ~60 are deliberate fall-throughs to English: proper nouns (Scarf, Hermes, OAuth, MCP, SSH), brand names (Docker, Daytona, Singularity, BlueBubbles), format-only tokens (`%lld`, `·`, `•`), and config-literal placeholders (`my_server`, `npx`, `sk-…`).
|
||||||
|
- **Locale-aware number and date formatting.** Previous builds hardcoded POSIX-style decimal separators (`$12.34`) and English unit names (`"MB"`, `"K"`, `"M"`). Currency now routes through `.formatted(.currency(code: "USD"))`, byte sizes through `.byteCount(style: .file)`, token counts through `.notation(.compactName)`, and the day-of-week chart through `Calendar.current.shortWeekdaySymbols` — so German users see `15,2 MB`, Japanese users see `15.5万 tokens`, and the activity heatmap starts on the locale's first weekday.
|
||||||
|
- **Microphone permission prompt localized** — the system dialog that appears the first time you enable voice chat now reads in the user's language.
|
||||||
|
|
||||||
|
#### How the translation work shipped
|
||||||
|
|
||||||
|
Three stacked PRs to keep each piece independently reviewable, all AI-translated with the bar explicitly set low so native speakers can iterate:
|
||||||
|
|
||||||
|
1. **[#22](https://github.com/awizemann/scarf/pull/22) — String Catalog infrastructure.** Added `Localizable.xcstrings` + `InfoPlist.xcstrings`, expanded `knownRegions` with the six new locales, and fixed the locale-aware number formatters mentioned above. No user-visible English-locale change; the groundwork only.
|
||||||
|
2. **[#24](https://github.com/awizemann/scarf/pull/24) — Audit burn-down.** Swept the codebase for "silently un-localizable" patterns that look fine in Xcode's catalog but leak English at runtime: `Text(cond ? "A" : "B")` routes through the String overload instead of `LocalizedStringKey`, as do `Label(stringVar, systemImage:)`, `.help(stringVar)`, and composite format strings with translatable text suffixes. ~40 sites refactored, covering Chat voice/TTS toggles, Logs pickers, Insights period + day names, MCPServer test result, Profiles, SignalSetup, QuickCommands, ConnectionStatusPill. Without this PR the translations would have landed but ~40 visible strings would still have rendered in English.
|
||||||
|
3. **[#25](https://github.com/awizemann/scarf/pull/25) — Translations + contributor path.** The six locale JSONs + a 90-line merge script + a "Adding a Language" section in `CONTRIBUTING.md`. The sidebar and Settings tab bar fix also shipped here after smoke-testing revealed they were still missed — `Label(section.rawValue, …)` goes to the String overload just like the audit cases.
|
||||||
|
|
||||||
|
#### Contributing a new language
|
||||||
|
|
||||||
|
Per-locale source of truth lives in [`tools/translations/<locale>.json`](https://github.com/awizemann/scarf/tree/main/tools/translations). Each entry is a plain `{ "English": "Translation" }` map — keys you omit fall through to English at runtime. Workflow is: fork, drop a JSON, run `python3 tools/merge-translations.py`, open a PR. The full bar is documented in [CONTRIBUTING.md → Adding a Language](https://github.com/awizemann/scarf/blob/main/CONTRIBUTING.md#adding-a-language).
|
||||||
|
|
||||||
|
Native-speaker review of the initial six locales is welcome — AI translation gets us most of the way, but idiom and tone are better with someone who actually uses the language. Post a PR against the relevant `<locale>.json` and it'll land as a follow-up.
|
||||||
|
|
||||||
|
### Chat slash-command menu
|
||||||
|
|
||||||
|
Type `/` in Rich Chat and a floating menu appears above the input with every command the connected agent has advertised via ACP's `available_commands_update`, plus any user-defined `quick_commands:` from `~/.hermes/config.yaml`. ↑/↓ to navigate, Tab or Enter to complete, Esc to dismiss. Commands with argument hints (e.g. `/compress <topic>`) insert a trailing space so you can start typing the argument immediately.
|
||||||
|
|
||||||
|
The filter uses pure-prefix match and re-renders on every query — the old menu had a description-fallback filter and a cached child view that together pinned `/help` on-screen regardless of what you typed. The dedicated `/compress` button is hidden once the menu has more than one command; it only surfaces when `/compress` is the single advertised slash command, preserving the v2.0 one-click compression flow for that case.
|
||||||
|
|
||||||
|
### Chat UX polish
|
||||||
|
|
||||||
|
- **Auto-scroll on send and on completion.** `.defaultScrollAnchor(.bottom)` handles slow streaming fine, but rapid slash-command responses (common once the menu lands) outran the anchor and left the reply off-screen. Now the list explicitly scrolls to the latest message when you submit and again when the prompt finishes.
|
||||||
|
- **Loading state.** `ChatViewModel.isPreparingSession` is true during Starting / Creating / Loading / Reconnecting. While true, the message list swaps its empty-state placeholder for a spinner — non-blocking, just a view inside the ScrollView.
|
||||||
|
- **Empty-state centering.** The "Start a new session or resume an existing one" placeholder was positioned with a fixed `.padding(.vertical, 80)` that looked wrong at extreme window sizes. Replaced with Spacers inside `.containerRelativeFrame(.vertical)` so it sits in the true vertical center of the chat pane.
|
||||||
|
- **Session-load whitespace bug.** Opening a session used to render a blank viewport you'd have to scroll up from — the fix was `LazyVStack` → `VStack` in `RichChatMessageList`. LazyVStack's estimated row heights were fooling `.defaultScrollAnchor(.bottom)` into overshooting real content; VStack measures every row upfront so the anchor has real heights to work with.
|
||||||
|
|
||||||
|
### Under the hood
|
||||||
|
|
||||||
|
- **String Catalog build pipeline.** `SWIFT_EMIT_LOC_STRINGS` + `STRING_CATALOG_GENERATE_SYMBOLS` are enabled; keys extract automatically on IDE build. Headless builds use `xcrun xcstringstool sync` to merge the per-source `.stringsdata` files into the catalog (wrapped by [`tools/merge-translations.py`](https://github.com/awizemann/scarf/blob/main/tools/merge-translations.py) when applying JSON translations).
|
||||||
|
- **New docs.** [`scarf/docs/I18N.md`](https://github.com/awizemann/scarf/blob/main/scarf/docs/I18N.md) covers the catalog setup, the patterns that silently bypass localization (and their fixes), and which strings are intentionally kept verbatim. Anyone adding UI copy should read the "Guardrails when writing new UI code" section to avoid re-introducing the leaks #24 cleaned up.
|
||||||
|
|
||||||
|
### Migrating from 2.0.x
|
||||||
|
|
||||||
|
Sparkle will offer the update automatically. No config migration needed. The first launch after update picks up the system locale — if you want English even on a non-English macOS, set **System Settings → Language & Region → Apps → Scarf → English**.
|
||||||
|
|
||||||
|
### Thanks
|
||||||
|
|
||||||
|
- [Onion3](https://github.com/Onion3) for filing [#13](https://github.com/awizemann/scarf/issues/13) back in April. The single-locale ask turned into a six-locale rollout.
|
||||||
|
- Future translators: if you spot a weird AI translation in your language, open a PR against `tools/translations/<locale>.json`. The bar is explicitly low — we'd rather have a 95%-correct translation shipped and iterated on than hold everything for perfection.
|
||||||
@@ -0,0 +1,94 @@
|
|||||||
|
## What's New in 2.2.0
|
||||||
|
|
||||||
|
Scarf projects can now travel. This release introduces **Project Templates** — a shareable `.scarftemplate` bundle format that packages a project's dashboard, agent instructions, skills, cron jobs, and a typed configuration schema into a single file anyone can install with one click. Bundles are agent-portable by design: every template ships with a cross-agent [`AGENTS.md`](https://agents.md/) so the instructions work natively in Claude Code, Cursor, Codex, Aider, Jules, Copilot, Zed, and every other agent that reads the Linux Foundation standard.
|
||||||
|
|
||||||
|
This is also the first release to ship a public **template catalog website** — a static site generated from `templates/<author>/<name>/` in this repo, previewed at [awizemann.github.io/scarf/templates/](https://awizemann.github.io/scarf/templates/), with a CI-enforced validator for community submissions.
|
||||||
|
|
||||||
|
### Project Templates
|
||||||
|
|
||||||
|
- **Bundle format: `.scarftemplate`.** A zip carrying a `template.json` manifest, the project's dashboard, a required `AGENTS.md` (the [Linux Foundation cross-agent instructions standard](https://agents.md/) — reads natively in Claude Code, Cursor, Codex, Aider, Jules, Copilot, Zed, and more), a README shown in the installer, optional per-agent instruction shims (`CLAUDE.md`, `GEMINI.md`, `.cursorrules`, `.github/copilot-instructions.md`), optional namespaced skills, optional cron job definitions, and an optional memory appendix.
|
||||||
|
- **Install preview sheet.** Before anything touches disk, Scarf shows you the exact project directory that will be created, every file inside it, every skill that will be namespaced under `~/.hermes/skills/templates/<slug>/`, every cron job that will be registered (always paused — you enable each one manually), and a live diff of the memory appendix against your existing `MEMORY.md`. Markdown fields — the README, field descriptions, cron prompts — render inline. The manifest's content claim is cross-checked against the actual zip entries so a bundle can't hide files from the preview.
|
||||||
|
- **`scarf://install?url=…` deep links.** Register Scarf as the handler for the `scarf` URL scheme so a future catalog site can link one-click installs straight into the app. Only `https://` payloads are accepted; `file://`, `javascript:`, and `http://` are refused on principle. A 50 MB size cap keeps a malicious link from exhausting disk. The URL never auto-installs — the preview sheet is always user-confirmed.
|
||||||
|
- **Install-time token substitution.** Template authors use `{{PROJECT_DIR}}`, `{{TEMPLATE_ID}}`, and `{{TEMPLATE_SLUG}}` placeholders in cron prompts; the installer resolves them to absolute paths at install time so the registered cron job works regardless of where Hermes sets CWD.
|
||||||
|
- **Export any project as a template.** Select a project, open the new Templates menu in the Projects toolbar, fill in a handful of fields (id, name, version, description, optional author + category + tags), tick the skills and cron jobs you want to include, optionally drop in a memory snippet, and save. The exporter carries the authored configuration schema forward but **never** the user's values — exports are safe on projects with live config.
|
||||||
|
- **No-overwrite, reversible by design.** Installed templates drop a `<project>/.scarf/template.lock.json` recording exactly what they wrote — every project file, skill path, cron job name, memory block id, and Keychain reference. Installing the same template id twice is refused at the preview step so you don't accidentally double-append to `MEMORY.md`.
|
||||||
|
- **Safe globals.** Skills install to `~/.hermes/skills/templates/<slug>/<skill-name>/` so they never collide with your own skills. Cron jobs are prefixed with `[tmpl:<id>]` and start paused. The installer **never** touches `~/.hermes/config.yaml`, `auth.json`, sessions, or any credential-bearing path.
|
||||||
|
|
||||||
|
### Template Configuration (schemaVersion 2)
|
||||||
|
|
||||||
|
Templates can now declare a typed configuration schema that drives a form step during install — no more "edit a `sites.txt` file to get started."
|
||||||
|
|
||||||
|
- **Typed field vocabulary.** Seven field types: `string`, `text` (multiline), `number` (with `min`/`max`), `bool`, `enum` (with `{value, label}` options), `list` (of strings, with `minItems`/`maxItems`), and `secret` (routed to the macOS Keychain). Constraints per type — `pattern` for regex, `minLength`/`maxLength` for text, etc. — are enforced at install and at edit time.
|
||||||
|
- **Configure step in the install flow.** If the template declares a schema, a **Configure** screen is inserted between "pick parent directory" and the preview sheet. Non-secret values land in `<project>/.scarf/config.json`; secrets land in the macOS Keychain with a service name of `com.scarf.template.<slug>` and an account keyed to the project-directory hash (so two installs of the same template in different dirs don't collide on Keychain entries).
|
||||||
|
- **Post-install Configuration editor.** A slider icon in the dashboard header opens the same form pre-filled with the current values. Change a site, rotate a token, toggle a feature — the cron job picks up the new values on its next run. Secrets are never echoed back ("Saved in Keychain — leave empty to keep the stored value").
|
||||||
|
- **Model recommendations.** Templates can suggest a preferred model (`claude-sonnet-4.5`, `claude-haiku-4`, `gpt-4.1`, etc.) with a rationale. Scarf surfaces the recommendation in the configure sheet without auto-switching your active model — always your call.
|
||||||
|
- **Secrets are tracked in the lock file.** Uninstalling a template runs `SecItemDelete` on every Keychain ref recorded at install, so a full clean-up leaves nothing behind. Absent entries (user already cleaned them) are no-ops.
|
||||||
|
|
||||||
|
### Template Catalog
|
||||||
|
|
||||||
|
A Sparkle-style pipeline for community-contributed templates, living on the same `gh-pages` branch as the auto-update feed.
|
||||||
|
|
||||||
|
- **Static site.** [awizemann.github.io/scarf/templates/](https://awizemann.github.io/scarf/templates/) — generated from every `templates/<author>/<name>/` directory. Each template gets a detail page showing the README, a live preview of the post-install dashboard, and the configuration schema rendered with human-readable constraint summaries. One-click install via the `scarf://install?url=…` button.
|
||||||
|
- **Stdlib-only Python validator.** `tools/build-catalog.py` is a no-external-dependencies Python script that mirrors the Swift-side schema and validation invariants (supported widget types, supported field types, `contents` claim verification, secret-with-default rejection, bundle-size cap, high-confidence secret patterns). Run it locally with `./scripts/catalog.sh check` before submitting a PR.
|
||||||
|
- **CI gate on PRs.** [`.github/workflows/validate-template-pr.yml`](https://github.com/awizemann/scarf/blob/main/.github/workflows/validate-template-pr.yml) runs the validator + its 24-test suite on every PR touching `templates/`, the validator itself, or its tests. Failing PRs get an inline comment with the last 3 KB of the validator output; passing PRs get a tailored checklist naming the specific template directory being changed.
|
||||||
|
- **Install-URL hosting.** Bundles are raw-served from `main` at `https://raw.githubusercontent.com/awizemann/scarf/main/templates/<author>/<name>/<name>.scarftemplate`. No per-template GitHub Releases ceremony.
|
||||||
|
- **Dogfood: the site uses Scarf's dashboard format.** `site/widgets.js` is ~300 lines of vanilla JS that renders a `ProjectDashboard` JSON using the same widget vocabulary the app uses, so each detail page's "live preview" is the actual dashboard the user will get.
|
||||||
|
|
||||||
|
### Example template: `awizemann/site-status-checker`
|
||||||
|
|
||||||
|
Ships as the first catalog entry and exercises every v2.2 surface. [See it in the catalog →](https://awizemann.github.io/scarf/templates/awizemann-site-status-checker/)
|
||||||
|
|
||||||
|
- Configure step asks for a list of URLs and a per-URL timeout.
|
||||||
|
- A paused cron job runs daily at 09:00 (editable from the Cron sidebar), does HTTP GETs with 3-redirect follow, writes a timestamped results table to `status-log.md`, updates the dashboard's Sites Up / Sites Down / Last Checked stat widgets plus the Watched Sites list, and rewrites the Site tab's webview URL to the first configured site.
|
||||||
|
- Works in any agent — the `AGENTS.md` is the single source of truth; no per-agent shim needed.
|
||||||
|
|
||||||
|
### Site tab
|
||||||
|
|
||||||
|
A dashboard with at least one `webview` widget now exposes a **Site** tab next to Dashboard. Useful for templates that watch something renderable (a site, a preview endpoint, a Grafana panel). The `site-status-checker` example rewrites the webview URL to the first configured site on every cron run, so the tab stays in sync with live config.
|
||||||
|
|
||||||
|
### Using templates
|
||||||
|
|
||||||
|
- **Install from file:** Projects → Templates → *Install from File…*, pick a `.scarftemplate` from disk.
|
||||||
|
- **Install from URL:** Projects → Templates → *Install from URL…*, paste an https URL.
|
||||||
|
- **Install from the web:** click any `scarf://install?url=…` link in a browser.
|
||||||
|
- **Export:** select a project → Projects → Templates → *Export "<name>" as Template…*, fill the form, save.
|
||||||
|
- **Edit config post-install:** slider icon in the dashboard header.
|
||||||
|
- **Uninstall:** right-click the project in the sidebar → *Uninstall Template (remove installed files)…*, or click the uninstall icon in the dashboard header. The preview sheet lists every file, cron job, Keychain secret, and memory block that will be removed, plus every user-created file that will be preserved.
|
||||||
|
|
||||||
|
### UX clarifications
|
||||||
|
|
||||||
|
- **Remove from List vs. Uninstall Template.** Sidebar context-menu labels clarified so you can see at a glance whether a click is destructive. *Remove from List (keep files)…* is registry-only — nothing on disk is touched, cron jobs stay, Keychain secrets stay. A confirmation dialog spells this out before the click lands. *Uninstall Template (remove installed files)…* is the full, lock-driven cleanup.
|
||||||
|
- **Post-uninstall "folder kept" banner.** When the uninstaller preserves the project directory because the cron wrote a `status-log.md` (or the user dropped files in there), the success view now explicitly lists the preserved paths with a pointer to delete the folder from Finder if desired.
|
||||||
|
- **Run Now no longer blocks on agent runs.** The Cron sidebar's Run Now button used to show a "Run failed" toast whenever an agent job ran longer than 60 s — even when the job was finishing correctly in the background. Run Now now shows "Agent started — dashboard will update when it finishes" immediately and the dashboard watcher picks up the completed state when it lands (timeout bumped to 300 s for the catch-stuck-process case).
|
||||||
|
|
||||||
|
### Uninstall
|
||||||
|
|
||||||
|
- **One-click uninstall** driven by `template.lock.json`. The preview sheet lists every file, cron job, Keychain ref, and memory block that will be removed, and every user-created file that will be preserved.
|
||||||
|
- **User content is never removed.** Files you (or the agent) added to the project dir after install — like a `sites.txt` or `status-log.md` — are detected and listed as "keep" in the preview. The project directory itself is removed only if nothing user-owned is left inside.
|
||||||
|
- **Clean global state.** The isolated `~/.hermes/skills/templates/<slug>/` namespace is removed wholesale. Tagged cron jobs are removed via `hermes cron remove`. Every recorded Keychain ref is cleared via `SecItemDelete`. The memory block between the `<!-- scarf-template:<id>:begin/end -->` markers is stripped, leaving the rest of MEMORY.md intact. The project registry entry is removed last.
|
||||||
|
- **No undo.** Uninstall is destructive — to reinstall, run the install flow again.
|
||||||
|
|
||||||
|
### Under the hood
|
||||||
|
|
||||||
|
- New models in `Core/Models/ProjectTemplate.swift` (manifest, inspection, install plan, lock file v2) and `Core/Models/TemplateConfig.swift` (schema + typed values + Keychain ref model).
|
||||||
|
- `Core/Services/ProjectTemplateService.swift` unzips, parses, and validates; `ProjectTemplateInstaller.swift` executes the plan with preflight + fail-fast semantics; `ProjectTemplateUninstaller.swift` reverses an install driven by the lock file; `ProjectTemplateExporter.swift` builds bundles from a live project + selections.
|
||||||
|
- `Core/Services/ProjectConfigService.swift` owns load/save/validation of `<project>/.scarf/config.json` + secret resolution; `Core/Services/ProjectConfigKeychain.swift` is the thin `SecItemAdd`/`Copy`/`Delete` wrapper (the only Keychain consumer in Scarf today).
|
||||||
|
- `Core/Services/TemplateURLRouter.swift` is the process-wide landing pad for `scarf://` URLs so a cold-launch browser click still reaches the install sheet.
|
||||||
|
- New Swift Testing suites covering 57 tests across the service / installer / uninstaller / exporter / config / Keychain / URL-router paths.
|
||||||
|
- New Python validator (`tools/build-catalog.py`) + test suite (`tools/test_build_catalog.py`, 24 tests) mirrors the Swift invariants for the CI gate and the site generator. Schema is Swift-primary — additions go to Swift first, Python mirrors.
|
||||||
|
- `scripts/catalog.sh` wraps the validator with `check / build / preview / serve / publish` subcommands that parallel the `scripts/release.sh` shape.
|
||||||
|
|
||||||
|
### Migrating from 2.1.x
|
||||||
|
|
||||||
|
Sparkle will offer the update automatically. No config migration needed. Existing projects are untouched — templates are additive. If you had a v2.2.0-dev install of the earlier `project-templates` branch, uninstall and reinstall any previously-installed templates to pick up the schema-version-2 lock file.
|
||||||
|
|
||||||
|
### Documentation
|
||||||
|
|
||||||
|
- [Project Templates wiki page](https://github.com/awizemann/scarf/wiki/Project-Templates) — installing, exporting, configuring, authoring, uninstalling.
|
||||||
|
- [Catalog site](https://awizemann.github.io/scarf/templates/) — the public catalog with live dashboard previews.
|
||||||
|
- [`templates/CONTRIBUTING.md`](https://github.com/awizemann/scarf/blob/main/templates/CONTRIBUTING.md) — how to submit a template via PR.
|
||||||
|
- [Architecture notes in root `CLAUDE.md`](https://github.com/awizemann/scarf/blob/main/CLAUDE.md#project-templates) — service-layer map, Keychain scheme, schema-drift discipline.
|
||||||
|
|
||||||
|
### Thanks
|
||||||
|
|
||||||
|
Thanks to everyone who tested drafts of the install flow, caught the "Run Now blocks on agent" bug, and pushed on the Remove-vs-Uninstall UX until it was clear. A 2.3 follow-up will extend the catalog validator to enforce per-field-type constraints at PR-time (currently enforced on install but not at submission).
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
## What's New in 2.2.1
|
||||||
|
|
||||||
|
A patch release covering Template Configuration rendering fixes reported against v2.2.0, plus a new catalog template that packages a Hermes skill for scaffolding new Scarf projects.
|
||||||
|
|
||||||
|
### Configuration sheet — no more clipping
|
||||||
|
|
||||||
|
Two independent rendering fixes to the post-install Configuration editor and the install-flow Configure step:
|
||||||
|
|
||||||
|
- **Enum fields with long option labels.** An enum with three or four options whose labels exceeded ~20 characters — e.g. a Claude-model picker with labels like *"Claude Opus 4 (Recommended - Most Capable)"* — rendered as a segmented picker that sized to the intrinsic width of all labels concatenated. On macOS, `.pickerStyle(.segmented)` refuses to respect offered width, refuses to wrap, refuses to truncate. The result was a ~650pt picker that overflowed the sheet's 560pt viewport and clipped the entire form on both sides. Enum fields now always render as a dropdown Menu picker, which surfaces long labels in the popup list and respects the parent's offered width regardless of option count or label length.
|
||||||
|
- **Descriptions with unbreakable content.** Field descriptions rendered via inline AttributedString markdown can contain tokens SwiftUI's `Text` refuses to break mid-token (raw URLs, long paths). Added `.frame(maxWidth: .infinity, alignment: .leading)` on the sheet's inner VStack and on each field row as a secondary constraint, so description text wraps at whitespace boundaries instead of expanding the sheet width. Applied the same modifier to `TemplateInstallSheet`'s main preview VStack for symmetry — installs with README blocks or cron prompts containing long URLs now wrap cleanly too.
|
||||||
|
|
||||||
|
### New catalog entry — `awizemann/template-author`
|
||||||
|
|
||||||
|
A `.scarftemplate` whose only content is a Hermes skill (`scarf-template-author`) plus a minimal dashboard that points users at it. Installing the template drops the skill at `~/.hermes/skills/templates/awizemann-template-author/scarf-template-author/SKILL.md`, discoverable by Claude Code, Cursor, Codex, Aider, and every other agent that reads the standard `~/.hermes/skills/` directory.
|
||||||
|
|
||||||
|
The skill teaches agents how to scaffold a new Scarf-compatible project through a short interview — purpose, data source, cadence, widgets, config, secrets — then write `<project>/.scarf/dashboard.json`, `<project>/.scarf/manifest.json`, `<project>/AGENTS.md`, and `<project>/README.md`. Scaffolded projects are usable locally and cleanly exportable as `.scarftemplate` bundles via Scarf's Export flow later. [Catalog detail page →](https://awizemann.github.io/scarf/templates/awizemann-template-author/)
|
||||||
|
|
||||||
|
v1 is fully conversational / blank-slate. Pre-baked archetypes (monitor, dev-dashboard, personal-log) are deferred to a future release pending real usage data.
|
||||||
|
|
||||||
|
### Authoring guidance — SKILL.md
|
||||||
|
|
||||||
|
The `scarf-template-author` skill now tells scaffolding agents to prefer markdown link syntax (`[label](https://…)`) over raw URLs in schema field descriptions. Raw URLs work now (v2.2.1's description wrap fix above handles them gracefully), but `[Anthropic console](https://console.anthropic.com)` reads cleaner in the form than a dumped URL. Same rule extended to long paths or other unbreakable strings — wrap in inline code if they have to appear verbatim, prefer markdown links otherwise.
|
||||||
|
|
||||||
|
### Under the hood
|
||||||
|
|
||||||
|
- **`scripts/catalog.sh publish` fix.** The pre-flight `need_ghpages` check tested `[[ -d "$GHPAGES_DIR/.git" ]]` — "is `.git` a directory?" — which is true for a regular clone but false for a `git worktree add` worktree (where `.git` is a pointer file). `release.sh` creates and leaves the gh-pages worktree around, so after any release the subsequent catalog-publish call was rejected with a misleading "run `git worktree add`" error on a worktree that was already there and valid. Switched to `-e` (exists, either file or directory). Unblocks publishing the catalog immediately after a release.
|
||||||
|
|
||||||
|
### Migrating from 2.2.0
|
||||||
|
|
||||||
|
Sparkle will offer the update automatically. No config migration needed. Existing template installs are untouched.
|
||||||
|
|
||||||
|
If you've already installed `awizemann/template-author` from a pre-release build, no action needed — the catalog and bundle content are forward-compatible.
|
||||||
|
|
||||||
|
### Documentation
|
||||||
|
|
||||||
|
- [Project Templates wiki page](https://github.com/awizemann/scarf/wiki/Project-Templates) — installing, exporting, configuring, authoring, uninstalling.
|
||||||
|
- [Catalog site](https://awizemann.github.io/scarf/templates/) — two templates live: `awizemann/site-status-checker` and `awizemann/template-author`.
|
||||||
|
- [`templates/CONTRIBUTING.md`](https://github.com/awizemann/scarf/blob/main/templates/CONTRIBUTING.md) — how to submit a template via PR.
|
||||||
@@ -0,0 +1,124 @@
|
|||||||
|
## What's New in 2.3.0
|
||||||
|
|
||||||
|
Two themes land together in this release. The projects sidebar stops being a flat list and becomes a workspace — folders, rename + archive + search + keyboard jumps, a per-project Sessions tab, and every project-scoped chat now automatically carries Scarf-managed context into the agent itself. And Scarf catches up to **Hermes v0.10.0's Tool Gateway**: paid Nous Portal subscribers can now route web search, image generation, TTS, and browser automation through their subscription without separate API keys — and they can sign in entirely from Scarf, no terminal needed.
|
||||||
|
|
||||||
|
### Projects sidebar grows up
|
||||||
|
|
||||||
|
- **Folders.** Group related projects with folders. Right-click any project → *Move to Folder…* — pick an existing folder or create a new one on the fly. Folders are soft: any folder name that isn't referenced by at least one project just disappears, so there's no "empty folder" state to clean up.
|
||||||
|
- **Rename** a project from the context menu. Preserves everything else — the path, folder assignment, archive flag, and any running cron attribution stay intact. Rejects duplicate names + empty input with an inline warning.
|
||||||
|
- **Archive / Unarchive.** Hide projects you don't actively use without deleting anything. The sidebar's bottom bar gains a Show Archived toggle so they're one click away when you need them.
|
||||||
|
- **Search.** ⌘F focuses a filter field at the top of the sidebar. Fuzzy-matches on name, path, and folder label, live as you type.
|
||||||
|
- **Keyboard jumps.** ⌘1 through ⌘9 jump to the first nine top-level projects. Pairs cleanly with Scarf's existing window-level shortcuts.
|
||||||
|
|
||||||
|
Registry migration is non-destructive — `~/.hermes/scarf/projects.json` gains two optional fields (`folder`, `archived`), and a file written by v2.3 is still parseable by v2.2.1 (unknown-keys are ignored), so downgrade works if you ever need it.
|
||||||
|
|
||||||
|
### Per-project Sessions tab
|
||||||
|
|
||||||
|
Every project now has a **Sessions** tab alongside Dashboard and Site. It shows chats attributed to this specific project — the sidecar at `~/.hermes/scarf/session_project_map.json` maintains the session-to-project mapping (Hermes's `state.db` has no column for this, so Scarf owns the record).
|
||||||
|
|
||||||
|
- **New Chat** — spawns `hermes acp` with the project's directory as the session's working directory, attributes the resulting session to the project, and takes you straight into the chat view.
|
||||||
|
- **Click any listed session to resume it** in the Chat tab; the project indicator comes along automatically.
|
||||||
|
- Forward-only attribution: sessions you've already started via the CLI or via the global Chat sidebar section continue to live in the global Sessions view unchanged; they simply aren't attributed to any project.
|
||||||
|
|
||||||
|
File descriptors are released cleanly on tab-disappear, matching Scarf's other Hermes-DB-reading VMs.
|
||||||
|
|
||||||
|
### Agent context injection via AGENTS.md
|
||||||
|
|
||||||
|
The architectural headline of this release. Hermes has no native "project" concept and ACP's wire protocol drops extra session params. But Hermes DOES auto-read `AGENTS.md` from the session's cwd at startup (priority: `.hermes.md` → `HERMES.md` → `AGENTS.md` → `CLAUDE.md` → `.cursorrules`, first match wins, 20KB cap). So Scarf leans on that.
|
||||||
|
|
||||||
|
Every time you start a project-scoped chat, Scarf writes a managed block into `<project>/AGENTS.md`:
|
||||||
|
|
||||||
|
```
|
||||||
|
<!-- scarf-project:begin -->
|
||||||
|
## Scarf project context
|
||||||
|
|
||||||
|
You are operating inside a Scarf project named "<Project Name>". …
|
||||||
|
|
||||||
|
- Project directory: …
|
||||||
|
- Dashboard: …
|
||||||
|
- Template: <id> v<version>
|
||||||
|
- Configuration fields: field_a, api_token (secret — name only, value stored in Keychain)
|
||||||
|
- Registered cron jobs: [tmpl:<id>] <name> — schedule …
|
||||||
|
…
|
||||||
|
<!-- scarf-project:end -->
|
||||||
|
```
|
||||||
|
|
||||||
|
Ask a fresh chat *"what project am I in?"* and the agent answers with the project name, dashboard path, template id, and current cron schedule — pulled from the block Hermes injected into its system prompt automatically.
|
||||||
|
|
||||||
|
**Invariants the block guarantees:**
|
||||||
|
|
||||||
|
- **Secret-safe.** Surfaces config field *names* with type hints; never values. A project whose config.json has Keychain-ref URIs renders the fields as `api_token (secret — name only, value stored in Keychain)`. Keychain URIs and plaintext values never appear in the block. Locked in by an explicit test (`refreshListsFieldNamesNotValues`).
|
||||||
|
- **Idempotent.** Two consecutive refreshes with unchanged state produce byte-identical output. The write is skipped entirely when no delta — no unnecessary file-watcher churn.
|
||||||
|
- **Bounded.** Everything outside the `<!-- scarf-project -->` markers is preserved across every refresh. Template-author AGENTS.md content lives safely below the block; hand-edits are never clobbered.
|
||||||
|
- **Non-fatal.** A failed block refresh doesn't block the chat from starting — logged + the session proceeds without the extra context.
|
||||||
|
- **Bare-project friendly.** Projects without an AGENTS.md (plain directories added via the + button) get one created with just the block. Agent awareness works even without template scaffolding.
|
||||||
|
|
||||||
|
**Template-author contract:** leave the `<!-- scarf-project -->` region alone in your bundled `AGENTS.md`. Put template-specific instructions below it so they're preserved across refreshes. The `scarf-template-author` scaffolding skill already teaches this pattern to future agents doing project scaffolding.
|
||||||
|
|
||||||
|
**Known caveat:** if any parent directory of your project contains a `.hermes.md` or `HERMES.md`, that file takes priority over the project's AGENTS.md in Hermes's discovery order — the Scarf block gets shadowed. No fix in 2.3 — planned for 2.4 pending design input on handling authored `.hermes.md` files.
|
||||||
|
|
||||||
|
### Chat UI — project awareness everywhere
|
||||||
|
|
||||||
|
Once the cwd, attribution, and AGENTS.md pieces land, the UI follows:
|
||||||
|
|
||||||
|
- **Folder chip in `SessionInfoBar`** at the start of the bar (before the working dot + title) shows the active project name with a folder icon.
|
||||||
|
- **Navigation title** reads `Chat · <ProjectName>` when scoped, plain `Chat` otherwise — macOS `Subject — Detail` convention.
|
||||||
|
- **Resumed sessions keep the indicator.** Whether you click a session in the project's Sessions tab or come in from a future deep-link, the attribution is looked up at resume time and the chip renders from the same state.
|
||||||
|
|
||||||
|
### Window-layout fixes
|
||||||
|
|
||||||
|
A pre-existing issue — untracked until v2.3's heavier Chat/Sessions content exposed it — where the window grew past the screen when you switched to content-heavy sections. Fixed by:
|
||||||
|
|
||||||
|
- Setting `WindowGroup.windowResizability(.contentMinSize)` so the window's floor (not ceiling) is derived from content.
|
||||||
|
- Capping `idealHeight` on `RichChatView` and `ProjectSessionsView` so their plain-VStack children (deliberate choice to dodge a LazyVStack whitespace bug) don't report screen-exceeding ideals upward through `NavigationSplitView.detail`.
|
||||||
|
|
||||||
|
Window now stays at a user-draggable size and persists across section switches.
|
||||||
|
|
||||||
|
### Under the hood
|
||||||
|
|
||||||
|
- New models: `SessionProjectMap` — `~/.hermes/scarf/session_project_map.json` serialization (`SessionAttributionService` manages it).
|
||||||
|
- New services: `SessionAttributionService` (reads + writes the sidecar), `ProjectAgentContextService` (writes the AGENTS.md marker block, tests cover prepend/replace/idempotency/secret-redaction).
|
||||||
|
- New view models: `ProjectSessionsViewModel` (per-project session list with attribution filter), `ChatViewModel` gains `currentProjectPath` + `currentProjectName`.
|
||||||
|
- `HermesFileWatcher` now watches the attribution sidecar — file-system events propagate through the VMs as they do for every other Scarf-written file.
|
||||||
|
- `ProjectsViewModel` gains `moveProject / renameProject / archiveProject / unarchiveProject / folders` — rename preserves selection; archive clears it; reorders driven by `localizedCaseInsensitiveCompare` for locale-aware ordering.
|
||||||
|
- **Tool Gateway services.** `NousSubscriptionService` reads `~/.hermes/auth.json` to detect the subscription state. `NousAuthFlow` spawns `hermes auth add nous --no-browser` (with `PYTHONUNBUFFERED=1` so the device-code block surfaces immediately — Python block-buffers otherwise), parses the verification URL + user code with two line-anchored regexes, auto-opens the approval page via `NSWorkspace`, and confirms success by re-reading `auth.json`. `NousSignInSheet` drives the four-state UI (starting / waiting-for-approval / success / failure-with-billing-link). `CredentialPoolsOAuthGate` is the testable helper that routes providers to the right OAuth flow based on their overlay auth-type.
|
||||||
|
- **Catalog overlay merge.** `ModelCatalogService` gains a static `overlayOnlyProviders` table mirroring the 6 entries from `HERMES_OVERLAYS` in `hermes-agent/hermes_cli/providers.py`. `HermesProviderInfo` carries `isOverlay` and `subscriptionGated` flags so the picker can render them distinctly.
|
||||||
|
- **Config parsing.** `HermesConfig` gains `platformToolsets: [String: [String]]`; `HermesFileService` parses the `platform_toolsets.<platform>` block from `config.yaml` as written by `hermes setup tools`.
|
||||||
|
- **36 new Swift tests** across `ProjectRegistryMigrationTests`, `ProjectsViewModelTests`, `SessionAttributionServiceTests`, `ProjectAgentContextServiceTests` (22 for v2.3 projects work) + `ToolGatewayTests`, `NousAuthFlowParserTests`, `CredentialPoolsGatingTests` (14 for Tool Gateway). Total: 120 tests, all green against v2.3-projects + Tool Gateway combined.
|
||||||
|
|
||||||
|
### Icon tweak
|
||||||
|
|
||||||
|
App icon files renamed from iOS-template suffixes to macOS-native filenames + paired `Contents.json` update. Pure naming; no visual change at any rendered size.
|
||||||
|
|
||||||
|
### Tool Gateway — Nous Portal support
|
||||||
|
|
||||||
|
Hermes v0.10.0 introduced a **Tool Gateway**: paid [Nous Portal](https://portal.nousresearch.com) subscribers route web search (Firecrawl), image generation (FAL / FLUX 2 Pro), text-to-speech (OpenAI TTS), and browser automation (Browser Use) through their subscription. No separate API keys, no credential pool juggling. Scarf 2.3 surfaces the whole flow natively.
|
||||||
|
|
||||||
|
- **Nous Portal appears in the model picker.** Our picker used to read only the models.dev cache, which doesn't list Nous — so it was invisible. Scarf now merges Hermes's `HERMES_OVERLAYS` table on top of the cache, surfacing **six previously-hidden providers**: Nous Portal, OpenAI Codex, Qwen OAuth, Google Gemini CLI, GitHub Copilot ACP, and Arcee. Subscription-gated providers sort first, with a **Subscription** pill so they're visually distinct from BYO-key providers.
|
||||||
|
- **In-app sign-in.** Click *Sign in to Nous Portal* in the picker (or in the Auxiliary tab's fallback, or Credential Pools for the `nous` provider) and Scarf runs the device-code flow: opens the approval page in your browser, shows the device code in a large monospaced badge you can copy, and auto-detects success by re-reading `~/.hermes/auth.json`. No six-step terminal dance. Subscription-required failures surface a **Subscribe** button that opens the portal's billing page directly.
|
||||||
|
- **Per-task gateway routing.** The Auxiliary tab's 8 sub-model tasks (vision, web_extract, compression, session_search, skills_hub, approval, mcp, flush_memories) each gain a "Nous Portal" toggle. Enabling it flips `auxiliary.<task>.provider` to `nous` — Hermes derives gateway routing from that, no separate `use_gateway` key needed.
|
||||||
|
- **Health surface.** A new **Tool Gateway** card in Health shows subscription state, `platform_toolsets` wiring, and which aux tasks are currently routed through Nous.
|
||||||
|
- **Credential Pools dead-end fixed.** Before: selecting `nous` in the Add Credential sheet and clicking *Start OAuth* silently stalled (the PKCE URL regex never matched the device-code output). Now the sheet detects Nous and routes to the dedicated sign-in flow. For the other non-PKCE providers (OpenAI Codex, Qwen OAuth, Google Gemini CLI, GitHub Copilot ACP), the button disables with an inline hint pointing to `hermes auth add <provider>` — no more silent failures. PKCE providers (Anthropic, etc.) behave exactly as before.
|
||||||
|
- **Messaging Gateway rename.** Scarf's pre-existing "Gateway" section (Slack / Discord / inbound messaging) is renamed throughout to **Messaging Gateway** to disambiguate from the new Tool Gateway. Same feature, clearer name. Sidebar, dashboard card, menu-bar status, log-source filter, and Settings → Agent section header all updated. Internal enum cases and file paths (`gateway_state.json`, `gateway.log`) are unchanged.
|
||||||
|
|
||||||
|
If you don't use Hermes v0.10.0 or don't have a Nous subscription, nothing in your flow changes — the Tool Gateway surface only activates when it's relevant. Sign-in state reads `~/.hermes/auth.json` in read-only mode; Scarf never writes to the credential file.
|
||||||
|
|
||||||
|
### Migrating from 2.2.x
|
||||||
|
|
||||||
|
Sparkle will offer the update automatically. No config migration needed. Existing template installs are untouched — the v2.3 additions (folders, archive, sidecar) are purely additive; a v2.2.1 projects.json loads cleanly.
|
||||||
|
|
||||||
|
If you had any chat sessions attributed to projects in a pre-release v2.3 build, the forward-only attribution model means those sidecar entries surface correctly in the new Sessions tab on first launch.
|
||||||
|
|
||||||
|
**Hermes version.** The Tool Gateway features target [Hermes v0.10.0](https://github.com/NousResearch/hermes-agent/releases/tag/v2026.4.16) or newer. If you're on v0.9.0 the rest of Scarf 2.3 works, but Nous Portal won't appear in the picker (it's sourced from `HERMES_OVERLAYS` in v0.10.0+) and the Tool Gateway card won't have subscription data to show. Updating Hermes is `pipx upgrade hermes-agent` or the equivalent for your install method.
|
||||||
|
|
||||||
|
### Documentation
|
||||||
|
|
||||||
|
- **[Project Templates wiki page](https://github.com/awizemann/scarf/wiki/Project-Templates)** — gained a "How the agent sees the project" section covering the AGENTS.md injection pattern.
|
||||||
|
- **[Hermes Version Compatibility](https://github.com/awizemann/scarf/wiki/Hermes-Version-Compatibility)** — bumped recommended minimum to v0.10.0, new subsection covering Tool Gateway feature gating.
|
||||||
|
- **[Core Services](https://github.com/awizemann/scarf/wiki/Core-Services)** — new rows for `NousSubscriptionService` and `NousAuthFlow`, updated `ModelCatalogService` entry noting overlay merge.
|
||||||
|
- **Root `CLAUDE.md`** — new subsection "Project-scoped chat + Scarf-managed AGENTS.md context (v2.3)" under Project Templates, plus the Tool Gateway subsection under Hermes Version covering the overlay table and per-task gateway contract.
|
||||||
|
- **`scarf-template-author` skill** — pitfall bullet added so future scaffolding agents preserve the marker region when authoring new templates.
|
||||||
|
|
||||||
|
### Thanks
|
||||||
|
|
||||||
|
Thanks to the users who exercised this release through several layout iterations, caught the `fetchSessions` short-circuit on a fresh VM, and pushed on the "agent doesn't know what project it's in" question until the AGENTS.md mechanism clicked. Several of these fixes are small on their own but add up to a much tighter per-project workflow.
|
||||||
@@ -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,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,82 @@
|
|||||||
|
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 }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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,555 @@
|
|||||||
|
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 {
|
||||||
|
statusMessage = "Sending prompt..."
|
||||||
|
let messageId = UUID().uuidString
|
||||||
|
let params: [String: AnyCodable] = [
|
||||||
|
"sessionId": AnyCodable(sessionId),
|
||||||
|
"messageId": AnyCodable(messageId),
|
||||||
|
"prompt": AnyCodable([
|
||||||
|
["type": "text", "text": text] as [String: Any],
|
||||||
|
] as [Any]),
|
||||||
|
]
|
||||||
|
let result = try await sendRequest(method: "session/prompt", params: params)
|
||||||
|
let dict = result?.dictValue ?? [:]
|
||||||
|
let usage = dict["usage"] as? [String: Any] ?? [:]
|
||||||
|
|
||||||
|
statusMessage = "Ready"
|
||||||
|
return ACPPromptResult(
|
||||||
|
stopReason: dict["stopReason"] as? String ?? "end_turn",
|
||||||
|
inputTokens: usage["inputTokens"] as? Int ?? 0,
|
||||||
|
outputTokens: usage["outputTokens"] as? Int ?? 0,
|
||||||
|
thoughtTokens: usage["thoughtTokens"] as? Int ?? 0,
|
||||||
|
cachedReadTokens: usage["cachedReadTokens"] as? Int ?? 0
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
public func cancel(sessionId: String) async throws {
|
||||||
|
let params: [String: AnyCodable] = [
|
||||||
|
"sessionId": AnyCodable(sessionId),
|
||||||
|
]
|
||||||
|
_ = try await sendRequest(method: "session/cancel", params: params)
|
||||||
|
statusMessage = "Cancelled"
|
||||||
|
}
|
||||||
|
|
||||||
|
public func respondToPermission(requestId: Int, optionId: String) async {
|
||||||
|
let response: [String: Any] = [
|
||||||
|
"jsonrpc": "2.0",
|
||||||
|
"id": requestId,
|
||||||
|
"result": [
|
||||||
|
"outcome": [
|
||||||
|
"kind": optionId == "deny" ? "rejected" : "allowed",
|
||||||
|
"optionId": optionId,
|
||||||
|
] as [String: Any],
|
||||||
|
] as [String: Any],
|
||||||
|
]
|
||||||
|
await writeJSON(response)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - JSON-RPC Transport
|
||||||
|
|
||||||
|
private func sendRequest(method: String, params: [String: AnyCodable]) async throws -> AnyCodable? {
|
||||||
|
let requestId = nextRequestId
|
||||||
|
nextRequestId += 1
|
||||||
|
|
||||||
|
let request = ACPRequest(id: requestId, method: method, params: params)
|
||||||
|
guard let data = try? JSONEncoder().encode(request),
|
||||||
|
let line = String(data: data, encoding: .utf8)
|
||||||
|
else {
|
||||||
|
throw ACPClientError.encodingFailed
|
||||||
|
}
|
||||||
|
|
||||||
|
#if canImport(os)
|
||||||
|
logger.debug("Sending: \(method) (id: \(requestId))")
|
||||||
|
#endif
|
||||||
|
|
||||||
|
// session/prompt streams events and can run for minutes — no hard
|
||||||
|
// timeout. Control messages get a 30s watchdog.
|
||||||
|
let timeoutTask: Task<Void, Error>? = if method != "session/prompt" {
|
||||||
|
Task { [weak self] in
|
||||||
|
try await Task.sleep(nanoseconds: 30 * 1_000_000_000)
|
||||||
|
await self?.timeoutRequest(id: requestId, method: method)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
nil
|
||||||
|
}
|
||||||
|
defer { timeoutTask?.cancel() }
|
||||||
|
|
||||||
|
guard let ch = channel else {
|
||||||
|
throw ACPClientError.notConnected
|
||||||
|
}
|
||||||
|
|
||||||
|
return try await withCheckedThrowingContinuation { (continuation: CheckedContinuation<AnyCodable?, Error>) in
|
||||||
|
pendingRequests[requestId] = continuation
|
||||||
|
|
||||||
|
// Write in a detached task so the actor can process incoming
|
||||||
|
// response messages while we're awaiting the send. The
|
||||||
|
// continuation is already stored; the response arrives via
|
||||||
|
// the read loop.
|
||||||
|
Task.detached { [weak self] in
|
||||||
|
do {
|
||||||
|
try await ch.send(line)
|
||||||
|
} catch {
|
||||||
|
await self?.handleWriteFailedForRequest(id: requestId)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func timeoutRequest(id: Int, method: String) {
|
||||||
|
guard let continuation = pendingRequests.removeValue(forKey: id) else { return }
|
||||||
|
#if canImport(os)
|
||||||
|
logger.error("Request timed out: \(method) (id: \(id))")
|
||||||
|
#endif
|
||||||
|
statusMessage = "Request timed out"
|
||||||
|
continuation.resume(throwing: ACPClientError.requestTimeout(method: method))
|
||||||
|
}
|
||||||
|
|
||||||
|
private func writeJSON(_ dict: [String: Any]) async {
|
||||||
|
guard let ch = channel,
|
||||||
|
let data = try? JSONSerialization.data(withJSONObject: dict),
|
||||||
|
let line = String(data: data, encoding: .utf8)
|
||||||
|
else { return }
|
||||||
|
do {
|
||||||
|
try await ch.send(line)
|
||||||
|
} catch {
|
||||||
|
await handleWriteFailed()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Read Loops
|
||||||
|
|
||||||
|
private func startReadLoops(channel ch: any ACPChannel) {
|
||||||
|
// Consume incoming JSON-RPC lines from the channel.
|
||||||
|
readTask = Task { [weak self] in
|
||||||
|
do {
|
||||||
|
for try await line in ch.incoming {
|
||||||
|
guard let data = line.data(using: .utf8) else { continue }
|
||||||
|
do {
|
||||||
|
let message = try JSONDecoder().decode(ACPRawMessage.self, from: data)
|
||||||
|
await self?.handleMessage(message)
|
||||||
|
} catch {
|
||||||
|
#if canImport(os)
|
||||||
|
await self?.logParseFailure(error, line: line)
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await self?.handleReadLoopEnded(cleanly: true)
|
||||||
|
} catch {
|
||||||
|
await self?.handleReadLoopEnded(cleanly: false, error: error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mirror stderr into the diagnostic ring buffer.
|
||||||
|
stderrTask = Task { [weak self] in
|
||||||
|
do {
|
||||||
|
for try await text in ch.stderr {
|
||||||
|
await self?.appendStderr(text)
|
||||||
|
#if canImport(os)
|
||||||
|
await self?.logStderrLine(text)
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Stderr errors don't matter — we already handle EOF on
|
||||||
|
// the incoming stream.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#if canImport(os)
|
||||||
|
private func logParseFailure(_ error: Error, line: String) {
|
||||||
|
logger.warning("Failed to decode ACP message: \(error.localizedDescription)")
|
||||||
|
}
|
||||||
|
|
||||||
|
private func logStderrLine(_ text: String) {
|
||||||
|
logger.info("ACP stderr: \(text.prefix(500))")
|
||||||
|
}
|
||||||
|
#endif
|
||||||
|
|
||||||
|
private func handleMessage(_ message: ACPRawMessage) {
|
||||||
|
if message.isResponse {
|
||||||
|
if let requestId = message.id,
|
||||||
|
let continuation = pendingRequests.removeValue(forKey: requestId) {
|
||||||
|
if let error = message.error {
|
||||||
|
#if canImport(os)
|
||||||
|
logger.error("ACP RPC error (id: \(requestId)): \(error.message)")
|
||||||
|
#endif
|
||||||
|
statusMessage = "Error: \(error.message)"
|
||||||
|
continuation.resume(throwing: ACPClientError.rpcError(code: error.code, message: error.message))
|
||||||
|
} else {
|
||||||
|
#if canImport(os)
|
||||||
|
logger.debug("ACP response (id: \(requestId))")
|
||||||
|
#endif
|
||||||
|
continuation.resume(returning: message.result)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
#if canImport(os)
|
||||||
|
logger.warning("ACP response for unknown request id: \(message.id ?? -1)")
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
} else if message.isNotification {
|
||||||
|
if let event = ACPEventParser.parse(notification: message) {
|
||||||
|
eventContinuation?.yield(event)
|
||||||
|
}
|
||||||
|
} else if message.isRequest {
|
||||||
|
if message.method == "session/request_permission",
|
||||||
|
let event = ACPEventParser.parsePermissionRequest(message) {
|
||||||
|
statusMessage = "Permission required"
|
||||||
|
eventContinuation?.yield(event)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Disconnect Cleanup
|
||||||
|
|
||||||
|
/// Single idempotent cleanup path for all disconnect scenarios.
|
||||||
|
private func performDisconnectCleanup(reason: String) {
|
||||||
|
guard isConnected else { return }
|
||||||
|
#if canImport(os)
|
||||||
|
logger.warning("ACP disconnecting: \(reason)")
|
||||||
|
#endif
|
||||||
|
isConnected = false
|
||||||
|
statusMessage = "Connection lost"
|
||||||
|
for (_, continuation) in pendingRequests {
|
||||||
|
continuation.resume(throwing: ACPClientError.processTerminated)
|
||||||
|
}
|
||||||
|
pendingRequests.removeAll()
|
||||||
|
eventContinuation?.finish()
|
||||||
|
eventContinuation = nil
|
||||||
|
}
|
||||||
|
|
||||||
|
private func handleReadLoopEnded(cleanly: Bool, error: Error? = nil) {
|
||||||
|
let reason = cleanly ? "read loop ended (EOF)" : "read loop failed: \(error?.localizedDescription ?? "unknown")"
|
||||||
|
performDisconnectCleanup(reason: reason)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func handleWriteFailed() {
|
||||||
|
performDisconnectCleanup(reason: "write failed (broken pipe)")
|
||||||
|
}
|
||||||
|
|
||||||
|
private func handleWriteFailedForRequest(id: Int) {
|
||||||
|
if let continuation = pendingRequests.removeValue(forKey: id) {
|
||||||
|
continuation.resume(throwing: ACPClientError.processTerminated)
|
||||||
|
}
|
||||||
|
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
|
||||||
|
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: return "ACP process terminated unexpectedly"
|
||||||
|
case .requestTimeout(let method): return "ACP request '\(method)' timed out"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Maps a raw error message (RPC message or captured stderr) to a short
|
||||||
|
/// human-readable hint for the chat UI. Pattern-matches the most common
|
||||||
|
/// fresh-install failure modes. Returns nil when no known pattern matches.
|
||||||
|
public enum ACPErrorHint {
|
||||||
|
public static func classify(errorMessage: String, stderrTail: String) -> String? {
|
||||||
|
let haystack = errorMessage + "\n" + stderrTail
|
||||||
|
if haystack.range(of: #"No\s+(Anthropic|OpenAI|OpenRouter|Gemini|Google|Groq|Mistral|XAI)?\s*credentials\s+found"#,
|
||||||
|
options: .regularExpression) != nil
|
||||||
|
|| haystack.contains("ANTHROPIC_API_KEY")
|
||||||
|
|| haystack.contains("ANTHROPIC_TOKEN")
|
||||||
|
|| haystack.contains("claude setup-token")
|
||||||
|
|| haystack.contains("claude /login") {
|
||||||
|
return "Hermes can't find your AI provider credentials. Set `ANTHROPIC_API_KEY` (or similar) in `~/.hermes/.env` or your shell profile, then restart Scarf."
|
||||||
|
}
|
||||||
|
if let match = haystack.range(of: #"No such file or directory:\s*'([^']+)'"#,
|
||||||
|
options: .regularExpression) {
|
||||||
|
let matched = String(haystack[match])
|
||||||
|
if let nameStart = matched.range(of: "'"),
|
||||||
|
let nameEnd = matched.range(of: "'", range: nameStart.upperBound..<matched.endIndex) {
|
||||||
|
let name = String(matched[nameStart.upperBound..<nameEnd.lowerBound])
|
||||||
|
return "Hermes couldn't find `\(name)` on PATH. If you use nvm/asdf/mise, make sure it's exported in `~/.zprofile` (not only `~/.zshrc`), then restart Scarf."
|
||||||
|
}
|
||||||
|
return "Hermes couldn't find a required binary on PATH. Check that your shell's PATH is exported in `~/.zprofile`, then restart Scarf."
|
||||||
|
}
|
||||||
|
if haystack.localizedCaseInsensitiveContains("rate limit")
|
||||||
|
|| haystack.localizedCaseInsensitiveContains("429") {
|
||||||
|
return "Your AI provider returned a rate-limit error. Try again in a moment."
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,255 @@
|
|||||||
|
// 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>?
|
||||||
|
|
||||||
|
/// 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, self_: nil)
|
||||||
|
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
|
||||||
|
|
||||||
|
await startReaders()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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, self_: nil)
|
||||||
|
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
|
||||||
|
|
||||||
|
await startReaders()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Wire fresh stdin/stdout/stderr pipes (overwriting any the caller
|
||||||
|
/// set) and start the subprocess. `self_` is unused today — the
|
||||||
|
/// placeholder keeps the signature ready for a future hook that
|
||||||
|
/// captures termination in `proc.terminationHandler` and routes it
|
||||||
|
/// into the channel's actor state.
|
||||||
|
private static func launch(process: Process, self_: Any?) async throws {
|
||||||
|
process.standardInput = Pipe()
|
||||||
|
process.standardOutput = Pipe()
|
||||||
|
process.standardError = Pipe()
|
||||||
|
do {
|
||||||
|
try process.run()
|
||||||
|
} catch {
|
||||||
|
throw ACPChannelError.launchFailed(error.localizedDescription)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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,354 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
// MARK: - JSON-RPC Transport
|
||||||
|
|
||||||
|
// Hand-written `encode(to:)` / `init(from:)` with explicit `nonisolated` so
|
||||||
|
// Swift 6's default-isolation doesn't synthesize a MainActor-isolated
|
||||||
|
// conformance — which would prevent these payloads from being encoded or
|
||||||
|
// decoded inside `ACPClient`'s actor context (the JSON-RPC read/write loop).
|
||||||
|
// The member list must stay in sync with the stored properties above.
|
||||||
|
|
||||||
|
public struct ACPRequest: Encodable, Sendable {
|
||||||
|
public nonisolated let jsonrpc = "2.0"
|
||||||
|
public nonisolated let id: Int
|
||||||
|
public nonisolated let method: String
|
||||||
|
public nonisolated let params: [String: AnyCodable]
|
||||||
|
|
||||||
|
|
||||||
|
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)
|
||||||
|
try c.encode(jsonrpc, forKey: .jsonrpc)
|
||||||
|
try c.encode(id, forKey: .id)
|
||||||
|
try c.encode(method, forKey: .method)
|
||||||
|
try c.encode(params, forKey: .params)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public struct ACPRawMessage: Decodable, Sendable {
|
||||||
|
public nonisolated let jsonrpc: String?
|
||||||
|
public nonisolated let id: Int?
|
||||||
|
public nonisolated let method: String?
|
||||||
|
public nonisolated let result: AnyCodable?
|
||||||
|
public nonisolated let error: ACPError?
|
||||||
|
public nonisolated let params: AnyCodable?
|
||||||
|
|
||||||
|
public nonisolated var isResponse: Bool { id != nil && method == nil }
|
||||||
|
public nonisolated var isNotification: Bool { method != nil && id == nil }
|
||||||
|
public nonisolated var isRequest: Bool { method != nil && id != nil }
|
||||||
|
|
||||||
|
public enum CodingKeys: String, CodingKey { case jsonrpc, id, method, result, error, params }
|
||||||
|
|
||||||
|
public nonisolated init(from decoder: any Decoder) throws {
|
||||||
|
let c = try decoder.container(keyedBy: CodingKeys.self)
|
||||||
|
self.jsonrpc = try c.decodeIfPresent(String.self, forKey: .jsonrpc)
|
||||||
|
self.id = try c.decodeIfPresent(Int.self, forKey: .id)
|
||||||
|
self.method = try c.decodeIfPresent(String.self, forKey: .method)
|
||||||
|
self.result = try c.decodeIfPresent(AnyCodable.self, forKey: .result)
|
||||||
|
self.error = try c.decodeIfPresent(ACPError.self, forKey: .error)
|
||||||
|
self.params = try c.decodeIfPresent(AnyCodable.self, forKey: .params)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public struct ACPError: Decodable, Sendable {
|
||||||
|
public nonisolated let code: Int
|
||||||
|
public nonisolated let message: String
|
||||||
|
|
||||||
|
public enum CodingKeys: String, CodingKey { case code, message }
|
||||||
|
|
||||||
|
public nonisolated init(from decoder: any Decoder) throws {
|
||||||
|
let c = try decoder.container(keyedBy: CodingKeys.self)
|
||||||
|
self.code = try c.decode(Int.self, forKey: .code)
|
||||||
|
self.message = try c.decode(String.self, forKey: .message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - AnyCodable (for dynamic JSON)
|
||||||
|
|
||||||
|
public struct AnyCodable: Codable, @unchecked Sendable {
|
||||||
|
public nonisolated let value: Any
|
||||||
|
|
||||||
|
public nonisolated init(_ value: Any) { self.value = value }
|
||||||
|
|
||||||
|
// NOT marked `nonisolated`: Swift's default-isolation treats writes to a
|
||||||
|
// `let value: Any` stored property as MainActor-isolated even when the
|
||||||
|
// property is declared nonisolated (Any can't be strictly Sendable, so
|
||||||
|
// the compiler can't prove the write is safe off-main). Leaving the
|
||||||
|
// init as default-isolated silences the mutation warnings; the Decodable
|
||||||
|
// conformance is still usable from ACPClient's nonisolated read loop
|
||||||
|
// because all callers are already @preconcurrency with respect to
|
||||||
|
// `AnyCodable` (it's @unchecked Sendable).
|
||||||
|
public init(from decoder: any Decoder) throws {
|
||||||
|
let container = try decoder.singleValueContainer()
|
||||||
|
if container.decodeNil() {
|
||||||
|
value = NSNull()
|
||||||
|
} else if let bool = try? container.decode(Bool.self) {
|
||||||
|
value = bool
|
||||||
|
} else if let int = try? container.decode(Int.self) {
|
||||||
|
value = int
|
||||||
|
} else if let double = try? container.decode(Double.self) {
|
||||||
|
value = double
|
||||||
|
} else if let string = try? container.decode(String.self) {
|
||||||
|
value = string
|
||||||
|
} else if let array = try? container.decode([AnyCodable].self) {
|
||||||
|
value = array.map(\.value)
|
||||||
|
} else if let dict = try? container.decode([String: AnyCodable].self) {
|
||||||
|
value = dict.mapValues(\.value)
|
||||||
|
} else {
|
||||||
|
value = NSNull()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public func encode(to encoder: any Encoder) throws {
|
||||||
|
var container = encoder.singleValueContainer()
|
||||||
|
switch value {
|
||||||
|
case is NSNull:
|
||||||
|
try container.encodeNil()
|
||||||
|
case let bool as Bool:
|
||||||
|
try container.encode(bool)
|
||||||
|
case let int as Int:
|
||||||
|
try container.encode(int)
|
||||||
|
case let double as Double:
|
||||||
|
try container.encode(double)
|
||||||
|
case let string as String:
|
||||||
|
try container.encode(string)
|
||||||
|
case let array as [Any]:
|
||||||
|
try container.encode(array.map { AnyCodable($0) })
|
||||||
|
case let dict as [String: Any]:
|
||||||
|
try container.encode(dict.mapValues { AnyCodable($0) })
|
||||||
|
default:
|
||||||
|
try container.encodeNil()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Accessors
|
||||||
|
|
||||||
|
public nonisolated var stringValue: String? { value as? String }
|
||||||
|
public nonisolated var intValue: Int? { value as? Int }
|
||||||
|
public nonisolated var dictValue: [String: Any]? { value as? [String: Any] }
|
||||||
|
public nonisolated var arrayValue: [Any]? { value as? [Any] }
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - ACP Events (parsed from session/update notifications)
|
||||||
|
|
||||||
|
public enum ACPEvent: Sendable {
|
||||||
|
case messageChunk(sessionId: String, text: String)
|
||||||
|
case thoughtChunk(sessionId: String, text: String)
|
||||||
|
case toolCallStart(sessionId: String, call: ACPToolCallEvent)
|
||||||
|
case toolCallUpdate(sessionId: String, update: ACPToolCallUpdateEvent)
|
||||||
|
case permissionRequest(sessionId: String, requestId: Int, request: ACPPermissionRequestEvent)
|
||||||
|
case promptComplete(sessionId: String, response: ACPPromptResult)
|
||||||
|
case availableCommands(sessionId: String, commands: [[String: Any]])
|
||||||
|
case connectionLost(reason: String)
|
||||||
|
case unknown(sessionId: String, type: String)
|
||||||
|
}
|
||||||
|
|
||||||
|
public struct ACPToolCallEvent: Sendable {
|
||||||
|
public let toolCallId: String
|
||||||
|
public let title: String
|
||||||
|
public let kind: String
|
||||||
|
public let status: String
|
||||||
|
public let content: String
|
||||||
|
public let rawInput: [String: Any]?
|
||||||
|
|
||||||
|
|
||||||
|
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"
|
||||||
|
let parts = title.split(separator: ":", maxSplits: 1)
|
||||||
|
return String(parts.first ?? Substring(title)).trimmingCharacters(in: .whitespaces)
|
||||||
|
}
|
||||||
|
|
||||||
|
public var argumentsSummary: String {
|
||||||
|
let parts = title.split(separator: ":", maxSplits: 1)
|
||||||
|
if parts.count > 1 {
|
||||||
|
return String(parts[1]).trimmingCharacters(in: .whitespaces)
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
public var argumentsJSON: String {
|
||||||
|
guard let input = rawInput,
|
||||||
|
let data = try? JSONSerialization.data(withJSONObject: input),
|
||||||
|
let str = String(data: data, encoding: .utf8) else { return "{}" }
|
||||||
|
return str
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public struct ACPToolCallUpdateEvent: Sendable {
|
||||||
|
public let toolCallId: String
|
||||||
|
public let kind: String
|
||||||
|
public let status: String
|
||||||
|
public let content: 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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public struct ACPPermissionRequestEvent: Sendable {
|
||||||
|
public let toolCallTitle: String
|
||||||
|
public let toolCallKind: 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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public struct ACPPromptResult: Sendable {
|
||||||
|
public let stopReason: String
|
||||||
|
public let inputTokens: Int
|
||||||
|
public let outputTokens: Int
|
||||||
|
public let thoughtTokens: 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
|
||||||
|
|
||||||
|
public enum ACPEventParser {
|
||||||
|
public nonisolated static func parse(notification: ACPRawMessage) -> ACPEvent? {
|
||||||
|
guard notification.method == "session/update",
|
||||||
|
let params = notification.params?.dictValue,
|
||||||
|
let sessionId = params["sessionId"] as? String,
|
||||||
|
let update = params["update"] as? [String: Any],
|
||||||
|
let updateType = update["sessionUpdate"] as? String else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
switch updateType {
|
||||||
|
case "agent_message_chunk":
|
||||||
|
let text = extractContentText(from: update)
|
||||||
|
return .messageChunk(sessionId: sessionId, text: text)
|
||||||
|
|
||||||
|
case "agent_thought_chunk":
|
||||||
|
let text = extractContentText(from: update)
|
||||||
|
return .thoughtChunk(sessionId: sessionId, text: text)
|
||||||
|
|
||||||
|
case "tool_call":
|
||||||
|
let event = ACPToolCallEvent(
|
||||||
|
toolCallId: update["toolCallId"] as? String ?? "",
|
||||||
|
title: update["title"] as? String ?? "",
|
||||||
|
kind: update["kind"] as? String ?? "other",
|
||||||
|
status: update["status"] as? String ?? "pending",
|
||||||
|
content: extractContentArrayText(from: update),
|
||||||
|
rawInput: update["rawInput"] as? [String: Any]
|
||||||
|
)
|
||||||
|
return .toolCallStart(sessionId: sessionId, call: event)
|
||||||
|
|
||||||
|
case "tool_call_update":
|
||||||
|
let event = ACPToolCallUpdateEvent(
|
||||||
|
toolCallId: update["toolCallId"] as? String ?? "",
|
||||||
|
kind: update["kind"] as? String ?? "other",
|
||||||
|
status: update["status"] as? String ?? "completed",
|
||||||
|
content: extractContentArrayText(from: update),
|
||||||
|
rawOutput: update["rawOutput"] as? String
|
||||||
|
)
|
||||||
|
return .toolCallUpdate(sessionId: sessionId, update: event)
|
||||||
|
|
||||||
|
case "available_commands_update":
|
||||||
|
let commands = update["availableCommands"] as? [[String: Any]] ?? []
|
||||||
|
return .availableCommands(sessionId: sessionId, commands: commands)
|
||||||
|
|
||||||
|
default:
|
||||||
|
return .unknown(sessionId: sessionId, type: updateType)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public nonisolated static func parsePermissionRequest(_ message: ACPRawMessage) -> ACPEvent? {
|
||||||
|
guard message.method == "session/request_permission",
|
||||||
|
let params = message.params?.dictValue,
|
||||||
|
let sessionId = params["sessionId"] as? String,
|
||||||
|
let requestId = message.id else { return nil }
|
||||||
|
|
||||||
|
let toolCall = params["toolCall"] as? [String: Any] ?? [:]
|
||||||
|
let optionsRaw = params["options"] as? [[String: Any]] ?? []
|
||||||
|
let options = optionsRaw.compactMap { opt -> (optionId: String, name: String)? in
|
||||||
|
guard let id = opt["optionId"] as? String,
|
||||||
|
let name = opt["name"] as? String else { return nil }
|
||||||
|
return (optionId: id, name: name)
|
||||||
|
}
|
||||||
|
|
||||||
|
let event = ACPPermissionRequestEvent(
|
||||||
|
toolCallTitle: toolCall["title"] as? String ?? "",
|
||||||
|
toolCallKind: toolCall["kind"] as? String ?? "other",
|
||||||
|
options: options
|
||||||
|
)
|
||||||
|
return .permissionRequest(sessionId: sessionId, requestId: requestId, request: event)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Content Extraction
|
||||||
|
|
||||||
|
nonisolated private static func extractContentText(from update: [String: Any]) -> String {
|
||||||
|
if let content = update["content"] as? [String: Any],
|
||||||
|
let text = content["text"] as? String {
|
||||||
|
return text
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
nonisolated private static func extractContentArrayText(from update: [String: Any]) -> String {
|
||||||
|
if let contentArray = update["content"] as? [[String: Any]] {
|
||||||
|
return contentArray.compactMap { item -> String? in
|
||||||
|
guard let inner = item["content"] as? [String: Any] else { return nil }
|
||||||
|
return inner["text"] as? String
|
||||||
|
}.joined(separator: "\n")
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,898 @@
|
|||||||
|
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"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Eight sub-models that share the same provider/model/base_url/api_key/timeout shape.
|
||||||
|
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
|
||||||
|
public var flushMemories: AuxiliaryModel
|
||||||
|
|
||||||
|
|
||||||
|
public init(
|
||||||
|
vision: AuxiliaryModel,
|
||||||
|
webExtract: AuxiliaryModel,
|
||||||
|
compression: AuxiliaryModel,
|
||||||
|
sessionSearch: AuxiliaryModel,
|
||||||
|
skillsHub: AuxiliaryModel,
|
||||||
|
approval: AuxiliaryModel,
|
||||||
|
mcp: AuxiliaryModel,
|
||||||
|
flushMemories: 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
|
||||||
|
}
|
||||||
|
public nonisolated static let empty = AuxiliarySettings(
|
||||||
|
vision: .empty,
|
||||||
|
webExtract: .empty,
|
||||||
|
compression: .empty,
|
||||||
|
sessionSearch: .empty,
|
||||||
|
skillsHub: .empty,
|
||||||
|
approval: .empty,
|
||||||
|
mcp: .empty,
|
||||||
|
flushMemories: .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]]
|
||||||
|
|
||||||
|
// 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
|
||||||
|
) {
|
||||||
|
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,35 @@
|
|||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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
|
||||||
|
}
|
||||||
@@ -0,0 +1,218 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
public struct HermesCronJob: Identifiable, Sendable, Codable {
|
||||||
|
public nonisolated let id: String
|
||||||
|
public nonisolated let name: String
|
||||||
|
public nonisolated let prompt: String
|
||||||
|
public nonisolated let skills: [String]?
|
||||||
|
public nonisolated let model: String?
|
||||||
|
public nonisolated let schedule: CronSchedule
|
||||||
|
public nonisolated let enabled: Bool
|
||||||
|
public nonisolated let state: String
|
||||||
|
public nonisolated let deliver: String?
|
||||||
|
public nonisolated let nextRunAt: String?
|
||||||
|
public nonisolated let lastRunAt: String?
|
||||||
|
public nonisolated let lastError: String?
|
||||||
|
public nonisolated let preRunScript: String?
|
||||||
|
public nonisolated let deliveryFailures: Int?
|
||||||
|
public nonisolated let lastDeliveryError: String?
|
||||||
|
public nonisolated let timeoutType: String?
|
||||||
|
public nonisolated let timeoutSeconds: Int?
|
||||||
|
public nonisolated let silent: Bool?
|
||||||
|
|
||||||
|
public enum CodingKeys: String, CodingKey {
|
||||||
|
case id, name, prompt, skills, model, schedule, enabled, state, deliver, silent
|
||||||
|
case nextRunAt = "next_run_at"
|
||||||
|
case lastRunAt = "last_run_at"
|
||||||
|
case lastError = "last_error"
|
||||||
|
case preRunScript = "pre_run_script"
|
||||||
|
case deliveryFailures = "delivery_failures"
|
||||||
|
case lastDeliveryError = "last_delivery_error"
|
||||||
|
case timeoutType = "timeout_type"
|
||||||
|
case timeoutSeconds = "timeout_seconds"
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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
|
||||||
|
) {
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
public nonisolated init(from decoder: any Decoder) throws {
|
||||||
|
let c = try decoder.container(keyedBy: CodingKeys.self)
|
||||||
|
self.id = try c.decode(String.self, forKey: .id)
|
||||||
|
self.name = try c.decode(String.self, forKey: .name)
|
||||||
|
self.prompt = try c.decode(String.self, forKey: .prompt)
|
||||||
|
self.skills = try c.decodeIfPresent([String].self, forKey: .skills)
|
||||||
|
self.model = try c.decodeIfPresent(String.self, forKey: .model)
|
||||||
|
self.schedule = try c.decode(CronSchedule.self, forKey: .schedule)
|
||||||
|
self.enabled = try c.decode(Bool.self, forKey: .enabled)
|
||||||
|
self.state = try c.decode(String.self, forKey: .state)
|
||||||
|
self.deliver = try c.decodeIfPresent(String.self, forKey: .deliver)
|
||||||
|
self.nextRunAt = try c.decodeIfPresent(String.self, forKey: .nextRunAt)
|
||||||
|
self.lastRunAt = try c.decodeIfPresent(String.self, forKey: .lastRunAt)
|
||||||
|
self.lastError = try c.decodeIfPresent(String.self, forKey: .lastError)
|
||||||
|
self.preRunScript = try c.decodeIfPresent(String.self, forKey: .preRunScript)
|
||||||
|
self.deliveryFailures = try c.decodeIfPresent(Int.self, forKey: .deliveryFailures)
|
||||||
|
self.lastDeliveryError = try c.decodeIfPresent(String.self, forKey: .lastDeliveryError)
|
||||||
|
self.timeoutType = try c.decodeIfPresent(String.self, forKey: .timeoutType)
|
||||||
|
self.timeoutSeconds = try c.decodeIfPresent(Int.self, forKey: .timeoutSeconds)
|
||||||
|
self.silent = try c.decodeIfPresent(Bool.self, forKey: .silent)
|
||||||
|
}
|
||||||
|
|
||||||
|
public nonisolated func encode(to encoder: any Encoder) throws {
|
||||||
|
var c = encoder.container(keyedBy: CodingKeys.self)
|
||||||
|
try c.encode(id, forKey: .id)
|
||||||
|
try c.encode(name, forKey: .name)
|
||||||
|
try c.encode(prompt, forKey: .prompt)
|
||||||
|
try c.encodeIfPresent(skills, forKey: .skills)
|
||||||
|
try c.encodeIfPresent(model, forKey: .model)
|
||||||
|
try c.encode(schedule, forKey: .schedule)
|
||||||
|
try c.encode(enabled, forKey: .enabled)
|
||||||
|
try c.encode(state, forKey: .state)
|
||||||
|
try c.encodeIfPresent(deliver, forKey: .deliver)
|
||||||
|
try c.encodeIfPresent(nextRunAt, forKey: .nextRunAt)
|
||||||
|
try c.encodeIfPresent(lastRunAt, forKey: .lastRunAt)
|
||||||
|
try c.encodeIfPresent(lastError, forKey: .lastError)
|
||||||
|
try c.encodeIfPresent(preRunScript, forKey: .preRunScript)
|
||||||
|
try c.encodeIfPresent(deliveryFailures, forKey: .deliveryFailures)
|
||||||
|
try c.encodeIfPresent(lastDeliveryError, forKey: .lastDeliveryError)
|
||||||
|
try c.encodeIfPresent(timeoutType, forKey: .timeoutType)
|
||||||
|
try c.encodeIfPresent(timeoutSeconds, forKey: .timeoutSeconds)
|
||||||
|
try c.encodeIfPresent(silent, forKey: .silent)
|
||||||
|
}
|
||||||
|
|
||||||
|
public nonisolated var stateIcon: String {
|
||||||
|
switch state {
|
||||||
|
case "scheduled": return "clock"
|
||||||
|
case "running": return "play.circle"
|
||||||
|
case "completed": return "checkmark.circle"
|
||||||
|
case "failed": return "xmark.circle"
|
||||||
|
default: return "questionmark.circle"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public nonisolated var deliveryDisplay: String? {
|
||||||
|
guard let deliver, !deliver.isEmpty else { return nil }
|
||||||
|
// v0.9.0 extends Discord routing to threads: `discord:<chat>:<thread>`.
|
||||||
|
if deliver.hasPrefix("discord:") {
|
||||||
|
let parts = deliver.dropFirst("discord:".count).split(separator: ":", maxSplits: 1, omittingEmptySubsequences: false)
|
||||||
|
if parts.count == 2 {
|
||||||
|
return "Discord thread \(parts[1]) in \(parts[0])"
|
||||||
|
}
|
||||||
|
if parts.count == 1 {
|
||||||
|
return "Discord \(parts[0])"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return deliver
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public struct CronSchedule: Sendable, Codable {
|
||||||
|
public nonisolated let kind: String
|
||||||
|
public nonisolated let runAt: String?
|
||||||
|
public nonisolated let display: String?
|
||||||
|
public nonisolated let expression: String?
|
||||||
|
|
||||||
|
public enum CodingKeys: String, CodingKey {
|
||||||
|
case kind
|
||||||
|
case runAt = "run_at"
|
||||||
|
case display
|
||||||
|
case expression
|
||||||
|
}
|
||||||
|
|
||||||
|
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)
|
||||||
|
self.kind = try c.decode(String.self, forKey: .kind)
|
||||||
|
self.runAt = try c.decodeIfPresent(String.self, forKey: .runAt)
|
||||||
|
self.display = try c.decodeIfPresent(String.self, forKey: .display)
|
||||||
|
self.expression = try c.decodeIfPresent(String.self, forKey: .expression)
|
||||||
|
}
|
||||||
|
|
||||||
|
public nonisolated func encode(to encoder: any Encoder) throws {
|
||||||
|
var c = encoder.container(keyedBy: CodingKeys.self)
|
||||||
|
try c.encode(kind, forKey: .kind)
|
||||||
|
try c.encodeIfPresent(runAt, forKey: .runAt)
|
||||||
|
try c.encodeIfPresent(display, forKey: .display)
|
||||||
|
try c.encodeIfPresent(expression, forKey: .expression)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Hand-written `init(from:)` / `encode(to:)` so Swift 6 doesn't synthesize a
|
||||||
|
// MainActor-isolated Codable conformance — `HermesFileService.loadCronJobs`
|
||||||
|
// is nonisolated and needs to decode this from a background task.
|
||||||
|
public struct CronJobsFile: Sendable, Codable {
|
||||||
|
public nonisolated let jobs: [HermesCronJob]
|
||||||
|
public nonisolated let updatedAt: String?
|
||||||
|
|
||||||
|
public enum CodingKeys: String, CodingKey {
|
||||||
|
case jobs
|
||||||
|
case updatedAt = "updated_at"
|
||||||
|
}
|
||||||
|
|
||||||
|
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)
|
||||||
|
self.jobs = try c.decode([HermesCronJob].self, forKey: .jobs)
|
||||||
|
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.encode(jobs, forKey: .jobs)
|
||||||
|
try c.encodeIfPresent(updatedAt, forKey: .updatedAt)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,212 @@
|
|||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,106 @@
|
|||||||
|
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
|
||||||
|
}
|
||||||
|
public nonisolated static let defaultLocalHome: String = {
|
||||||
|
let user = ProcessInfo.processInfo.environment["HOME"] ?? NSHomeDirectory()
|
||||||
|
return user + "/.hermes"
|
||||||
|
}()
|
||||||
|
|
||||||
|
/// 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" }
|
||||||
|
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" }
|
||||||
|
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
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||