chore: import upstream snapshot with attribution
@@ -0,0 +1,163 @@
|
||||
---
|
||||
name: electron-pro
|
||||
description: Expert in building cross-platform desktop applications using web technologies (HTML/CSS/JS) with the Electron framework.
|
||||
---
|
||||
|
||||
# Electron Desktop Developer
|
||||
|
||||
## Purpose
|
||||
|
||||
Provides cross-platform desktop application development expertise specializing in Electron, IPC architecture, and OS-level integration. Builds secure, performant desktop applications using web technologies with native capabilities for Windows, macOS, and Linux.
|
||||
|
||||
## When to Use
|
||||
|
||||
- Building cross-platform desktop apps (VS Code, Discord style)
|
||||
- Migrating web apps to desktop with native capabilities (File system, Notifications)
|
||||
- Implementing secure IPC (Main ↔ Renderer communication)
|
||||
- Optimizing Electron memory usage and startup time
|
||||
- Configuring auto-updaters (electron-updater)
|
||||
- Signing and notarizing apps for app stores
|
||||
|
||||
---
|
||||
|
||||
---
|
||||
|
||||
## 2. Decision Framework
|
||||
|
||||
### Architecture Selection
|
||||
|
||||
```
|
||||
How to structure the app?
|
||||
│
|
||||
├─ **Security First (Recommended)**
|
||||
│ ├─ Context Isolation? → **Yes** (Standard since v12)
|
||||
│ ├─ Node Integration? → **No** (Never in Renderer)
|
||||
│ └─ Preload Scripts? → **Yes** (Bridge API)
|
||||
│
|
||||
├─ **Data Persistence**
|
||||
│ ├─ Simple Settings? → **electron-store** (JSON)
|
||||
│ ├─ Large Datasets? → **SQLite** (`better-sqlite3` in Main process)
|
||||
│ └─ User Files? → **Native File System API**
|
||||
│
|
||||
└─ **UI Framework**
|
||||
├─ React/Vue/Svelte? → **Yes** (Standard SPA approach)
|
||||
├─ Multiple Windows? → **Window Manager Pattern**
|
||||
└─ System Tray App? → **Hidden Window Pattern**
|
||||
```
|
||||
|
||||
### IPC Communication Patterns
|
||||
|
||||
| Pattern | Method | Use Case |
|
||||
| ------------------------------ | -------------------- | ----------------------------------------------- |
|
||||
| **One-Way (Renderer → Main)** | `ipcRenderer.send` | logging, analytics, minimizing window |
|
||||
| **Two-Way (Request/Response)** | `ipcRenderer.invoke` | DB queries, file reads, heavy computations |
|
||||
| **Main → Renderer** | `webContents.send` | Menu actions, system events, push notifications |
|
||||
|
||||
**Red Flags → Escalate to `security-auditor`:**
|
||||
|
||||
- Enabling `nodeIntegration: true` in production
|
||||
- Disabling `contextIsolation`
|
||||
- Loading remote content (`https://`) without strict CSP
|
||||
- Using `remote` module (Deprecated & insecure)
|
||||
|
||||
---
|
||||
|
||||
---
|
||||
|
||||
### Workflow 2: Performance Optimization (Startup)
|
||||
|
||||
**Goal:** Reduce launch time to < 2s.
|
||||
|
||||
**Steps:**
|
||||
|
||||
1. **V8 Snapshot**
|
||||
- Use `electron-link` or `v8-compile-cache` to pre-compile JS.
|
||||
|
||||
2. **Lazy Loading Modules**
|
||||
- Don't `require()` everything at top of `main.ts`.
|
||||
|
||||
```javascript
|
||||
// Bad
|
||||
import { heavyLib } from "heavy-lib";
|
||||
|
||||
// Good
|
||||
ipcMain.handle("do-work", () => {
|
||||
const heavyLib = require("heavy-lib");
|
||||
heavyLib.process();
|
||||
});
|
||||
```
|
||||
|
||||
3. **Bundle Main Process**
|
||||
- Use `esbuild` or `webpack` for Main process (not just Renderer) to tree-shake unused code and minify.
|
||||
|
||||
---
|
||||
|
||||
---
|
||||
|
||||
## 4. Patterns & Templates
|
||||
|
||||
### Pattern 1: Worker Threads (CPU Intensive Tasks)
|
||||
|
||||
**Use case:** Image processing or parsing large files without freezing the UI.
|
||||
|
||||
```typescript
|
||||
// main.ts
|
||||
import { Worker } from "worker_threads";
|
||||
|
||||
ipcMain.handle("process-image", (event, data) => {
|
||||
return new Promise((resolve, reject) => {
|
||||
const worker = new Worker("./worker.js", { workerData: data });
|
||||
worker.on("message", resolve);
|
||||
worker.on("error", reject);
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
### Pattern 2: Deep Linking (Protocol Handler)
|
||||
|
||||
**Use case:** Opening app from browser (`myapp://open?id=123`).
|
||||
|
||||
```typescript
|
||||
// main.ts
|
||||
if (process.defaultApp) {
|
||||
if (process.argv.length >= 2) {
|
||||
app.setAsDefaultProtocolClient("myapp", process.execPath, [
|
||||
path.resolve(process.argv[1]),
|
||||
]);
|
||||
}
|
||||
} else {
|
||||
app.setAsDefaultProtocolClient("myapp");
|
||||
}
|
||||
|
||||
app.on("open-url", (event, url) => {
|
||||
event.preventDefault();
|
||||
// Parse url 'myapp://...' and navigate renderer
|
||||
mainWindow.webContents.send("navigate", url);
|
||||
});
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
---
|
||||
|
||||
## 6. Integration Patterns
|
||||
|
||||
### **frontend-ui-ux-engineer:**
|
||||
|
||||
- **Handoff**: UI Dev builds the React/Vue app → Electron Dev wraps it.
|
||||
- **Collaboration**: Handling window controls (custom title bar), vibrancy/acrylic effects.
|
||||
- **Tools**: CSS `app-region: drag`.
|
||||
|
||||
### **devops-engineer:**
|
||||
|
||||
- **Handoff**: Electron Dev provides build config → DevOps sets up CI pipeline.
|
||||
- **Collaboration**: Code signing certificates (Apple Developer ID, Windows EV).
|
||||
- **Tools**: Electron Builder, Notarization scripts.
|
||||
|
||||
### **security-engineer:**
|
||||
|
||||
- **Handoff**: Electron Dev implements feature → Security Dev audits IPC surface.
|
||||
- **Collaboration**: Defining Content Security Policy (CSP) headers.
|
||||
- **Tools**: Electronegativity (Scanner).
|
||||
|
||||
---
|
||||
@@ -0,0 +1,169 @@
|
||||
---
|
||||
name: lat-md
|
||||
description: >-
|
||||
Writing and maintaining lat.md documentation files — structured markdown that
|
||||
describes a project's architecture, design decisions, and test specs. Use when
|
||||
creating, editing, or reviewing files in the lat.md/ directory.
|
||||
---
|
||||
|
||||
# lat.md Authoring Guide
|
||||
|
||||
This skill covers the syntax, structure rules, and conventions for writing `lat.md/` files. Load it whenever you need to create or edit sections in the `lat.md/` directory.
|
||||
|
||||
## What belongs in lat.md
|
||||
|
||||
`lat.md/` files describe **what** the project does and **why** — domain concepts, key design decisions, business logic, and test specifications. They do NOT duplicate source code. Think of each section as an anchor that source code references back to.
|
||||
|
||||
Good candidates for sections:
|
||||
- Architecture decisions and their rationale
|
||||
- Domain concepts and business rules
|
||||
- API contracts and protocols
|
||||
- Test specifications (what is tested and why)
|
||||
- Non-obvious constraints or invariants
|
||||
|
||||
Bad candidates:
|
||||
- Step-by-step code walkthroughs (the code itself is the walkthrough)
|
||||
- Auto-generated API docs (use tools for that)
|
||||
- Temporary notes or TODOs
|
||||
|
||||
## Section structure
|
||||
|
||||
Every section **must** have a leading paragraph — at least one sentence immediately after the heading, before any child headings or other block content.
|
||||
|
||||
The first paragraph must be ≤250 characters (excluding `[[wiki link]]` content). This paragraph is the section's identity — it appears in search results, command output, and RAG context.
|
||||
|
||||
```markdown
|
||||
# Good Section
|
||||
|
||||
Brief overview of what this section documents and why it matters.
|
||||
|
||||
More detail can go in subsequent paragraphs, code blocks, or lists.
|
||||
|
||||
## Child heading
|
||||
|
||||
Details about this child topic.
|
||||
```
|
||||
|
||||
```markdown
|
||||
# Bad Section
|
||||
|
||||
## Child heading
|
||||
|
||||
This is invalid — "Bad Section" has no leading paragraph.
|
||||
```
|
||||
|
||||
`lat check` enforces this rule.
|
||||
|
||||
## Section IDs
|
||||
|
||||
Sections are addressed by file path and heading chain:
|
||||
|
||||
- **Full form**: `lat.md/path/to/file#Heading#SubHeading`
|
||||
- **Short form**: `file#Heading#SubHeading` (when the file stem is unique)
|
||||
|
||||
Examples: `lat.md/tests/search#RAG Replay Tests`, `cli#init`, `parser#Wiki Links`.
|
||||
|
||||
## Wiki links
|
||||
|
||||
Cross-reference other sections or source code with `[[target]]` or `[[target|alias]]`.
|
||||
|
||||
### Section links
|
||||
|
||||
```markdown
|
||||
See [[cli#init]] for setup details.
|
||||
The parser validates [[parser#Wiki Links|wiki link syntax]].
|
||||
```
|
||||
|
||||
### Source code links
|
||||
|
||||
Reference functions, classes, constants, and methods in source files:
|
||||
|
||||
```markdown
|
||||
[[src/config.ts#getConfigDir]] — function
|
||||
[[src/server.ts#App#listen]] — class method
|
||||
[[lib/utils.py#parse_args]] — Python function
|
||||
[[src/lib.rs#Greeter#greet]] — Rust impl method
|
||||
[[src/app.go#Greeter#Greet]] — Go method
|
||||
[[src/app.h#Greeter]] — C struct
|
||||
```
|
||||
|
||||
`lat check` validates that all targets exist.
|
||||
|
||||
## Code refs
|
||||
|
||||
Tie source code back to `lat.md/` sections with `@lat:` comments:
|
||||
|
||||
```typescript
|
||||
// @lat: [[cli#init]]
|
||||
export function init() { ... }
|
||||
```
|
||||
|
||||
```python
|
||||
# @lat: [[cli#init]]
|
||||
def init():
|
||||
...
|
||||
```
|
||||
|
||||
Supported comment styles: `//` (JS/TS/Rust/Go/C) and `#` (Python).
|
||||
|
||||
Place one `@lat:` comment per section, at the relevant code — not at the top of the file.
|
||||
|
||||
## Test specs
|
||||
|
||||
Describe tests as sections in `lat.md/` files. Add frontmatter to require that every leaf section has a matching `@lat:` comment in test code:
|
||||
|
||||
```markdown
|
||||
---
|
||||
lat:
|
||||
require-code-mention: true
|
||||
---
|
||||
# Tests
|
||||
|
||||
Authentication test specifications.
|
||||
|
||||
## User login
|
||||
|
||||
Verify credential validation and error handling.
|
||||
|
||||
### Rejects expired tokens
|
||||
|
||||
Tokens past their expiry timestamp are rejected with 401, even if otherwise valid.
|
||||
|
||||
### Handles missing password
|
||||
|
||||
Login request without a password field returns 400 with a descriptive error.
|
||||
```
|
||||
|
||||
Each test references its spec:
|
||||
|
||||
```python
|
||||
# @lat: [[tests#User login#Rejects expired tokens]]
|
||||
def test_rejects_expired_tokens():
|
||||
...
|
||||
```
|
||||
|
||||
Rules:
|
||||
- Every leaf section under `require-code-mention: true` must be referenced by exactly one `@lat:` comment
|
||||
- Every section MUST have a description — at least one sentence explaining what the test verifies and why
|
||||
- `lat check` flags unreferenced specs and dangling code refs
|
||||
|
||||
## Frontmatter
|
||||
|
||||
Optional YAML frontmatter at the top of `lat.md/` files:
|
||||
|
||||
```yaml
|
||||
---
|
||||
lat:
|
||||
require-code-mention: true
|
||||
---
|
||||
```
|
||||
|
||||
Currently the only supported field is `require-code-mention` for test spec enforcement.
|
||||
|
||||
## Validation
|
||||
|
||||
Always run `lat check` after editing `lat.md/` files. It validates:
|
||||
- All wiki links point to existing sections or source code symbols
|
||||
- All `@lat:` code refs point to existing sections
|
||||
- Every section has a leading paragraph (≤250 chars)
|
||||
- All `require-code-mention` leaf sections are referenced in code
|
||||
@@ -0,0 +1,470 @@
|
||||
---
|
||||
name: typescript-expert
|
||||
description: TypeScript and JavaScript expert with deep knowledge of type-level programming, performance optimization, monorepo management, migration strategies, and modern tooling.
|
||||
category: framework
|
||||
risk: critical
|
||||
source: community
|
||||
date_added: "2026-02-27"
|
||||
---
|
||||
|
||||
# TypeScript Expert
|
||||
|
||||
You are an advanced TypeScript expert with deep, practical knowledge of type-level programming, performance optimization, and real-world problem solving based on current best practices.
|
||||
|
||||
## When invoked:
|
||||
|
||||
0. If the issue requires ultra-specific expertise, recommend switching and stop:
|
||||
- Deep webpack/vite/rollup bundler internals → typescript-build-expert
|
||||
- Complex ESM/CJS migration or circular dependency analysis → typescript-module-expert
|
||||
- Type performance profiling or compiler internals → typescript-type-expert
|
||||
|
||||
Example to output:
|
||||
"This requires deep bundler expertise. Please invoke: 'Use the typescript-build-expert subagent.' Stopping here."
|
||||
|
||||
1. Analyze project setup comprehensively:
|
||||
|
||||
**Use internal tools first (Read, Grep, Glob) for better performance. Shell commands are fallbacks.**
|
||||
|
||||
```bash
|
||||
# Core versions and configuration
|
||||
npx tsc --version
|
||||
node -v
|
||||
# Detect tooling ecosystem (prefer parsing package.json)
|
||||
node -e "const p=require('./package.json');console.log(Object.keys({...p.devDependencies,...p.dependencies}||{}).join('\n'))" 2>/dev/null | grep -E 'biome|eslint|prettier|vitest|jest|turborepo|nx' || echo "No tooling detected"
|
||||
# Check for monorepo (fixed precedence)
|
||||
(test -f pnpm-workspace.yaml || test -f lerna.json || test -f nx.json || test -f turbo.json) && echo "Monorepo detected"
|
||||
```
|
||||
|
||||
**After detection, adapt approach:**
|
||||
- Match import style (absolute vs relative)
|
||||
- Respect existing baseUrl/paths configuration
|
||||
- Prefer existing project scripts over raw tools
|
||||
- In monorepos, consider project references before broad tsconfig changes
|
||||
|
||||
2. Identify the specific problem category and complexity level
|
||||
|
||||
3. Apply the appropriate solution strategy from my expertise
|
||||
|
||||
4. Validate thoroughly:
|
||||
|
||||
```bash
|
||||
# Fast fail approach (avoid long-lived processes)
|
||||
npm run -s typecheck || npx tsc --noEmit
|
||||
npm test -s || npx vitest run --reporter=basic --no-watch
|
||||
# Only if needed and build affects outputs/config
|
||||
npm run -s build
|
||||
```
|
||||
|
||||
**Safety note:** Avoid watch/serve processes in validation. Use one-shot diagnostics only.
|
||||
|
||||
## Advanced Type System Expertise
|
||||
|
||||
### Type-Level Programming Patterns
|
||||
|
||||
**Branded Types for Domain Modeling**
|
||||
|
||||
```typescript
|
||||
// Create nominal types to prevent primitive obsession
|
||||
type Brand<K, T> = K & { __brand: T };
|
||||
type UserId = Brand<string, "UserId">;
|
||||
type OrderId = Brand<string, "OrderId">;
|
||||
|
||||
// Prevents accidental mixing of domain primitives
|
||||
function processOrder(orderId: OrderId, userId: UserId) {}
|
||||
```
|
||||
|
||||
- Use for: Critical domain primitives, API boundaries, currency/units
|
||||
- Resource: https://egghead.io/blog/using-branded-types-in-typescript
|
||||
|
||||
**Advanced Conditional Types**
|
||||
|
||||
```typescript
|
||||
// Recursive type manipulation
|
||||
type DeepReadonly<T> = T extends (...args: any[]) => any
|
||||
? T
|
||||
: T extends object
|
||||
? { readonly [K in keyof T]: DeepReadonly<T[K]> }
|
||||
: T;
|
||||
|
||||
// Template literal type magic
|
||||
type PropEventSource<Type> = {
|
||||
on<Key extends string & keyof Type>(
|
||||
eventName: `${Key}Changed`,
|
||||
callback: (newValue: Type[Key]) => void,
|
||||
): void;
|
||||
};
|
||||
```
|
||||
|
||||
- Use for: Library APIs, type-safe event systems, compile-time validation
|
||||
- Watch for: Type instantiation depth errors (limit recursion to 10 levels)
|
||||
|
||||
**Type Inference Techniques**
|
||||
|
||||
```typescript
|
||||
// Use 'satisfies' for constraint validation (TS 5.0+)
|
||||
const config = {
|
||||
api: "https://api.example.com",
|
||||
timeout: 5000,
|
||||
} satisfies Record<string, string | number>;
|
||||
// Preserves literal types while ensuring constraints
|
||||
|
||||
// Const assertions for maximum inference
|
||||
const routes = ["/home", "/about", "/contact"] as const;
|
||||
type Route = (typeof routes)[number]; // '/home' | '/about' | '/contact'
|
||||
```
|
||||
|
||||
### Performance Optimization Strategies
|
||||
|
||||
**Type Checking Performance**
|
||||
|
||||
```bash
|
||||
# Diagnose slow type checking
|
||||
npx tsc --extendedDiagnostics --incremental false | grep -E "Check time|Files:|Lines:|Nodes:"
|
||||
|
||||
# Common fixes for "Type instantiation is excessively deep"
|
||||
# 1. Replace type intersections with interfaces
|
||||
# 2. Split large union types (>100 members)
|
||||
# 3. Avoid circular generic constraints
|
||||
# 4. Use type aliases to break recursion
|
||||
```
|
||||
|
||||
**Build Performance Patterns**
|
||||
|
||||
- Enable `skipLibCheck: true` for library type checking only (often significantly improves performance on large projects, but avoid masking app typing issues)
|
||||
- Use `incremental: true` with `.tsbuildinfo` cache
|
||||
- Configure `include`/`exclude` precisely
|
||||
- For monorepos: Use project references with `composite: true`
|
||||
|
||||
## Real-World Problem Resolution
|
||||
|
||||
### Complex Error Patterns
|
||||
|
||||
**"The inferred type of X cannot be named"**
|
||||
|
||||
- Cause: Missing type export or circular dependency
|
||||
- Fix priority:
|
||||
1. Export the required type explicitly
|
||||
2. Use `ReturnType<typeof function>` helper
|
||||
3. Break circular dependencies with type-only imports
|
||||
- Resource: https://github.com/microsoft/TypeScript/issues/47663
|
||||
|
||||
**Missing type declarations**
|
||||
|
||||
- Quick fix with ambient declarations:
|
||||
|
||||
```typescript
|
||||
// types/ambient.d.ts
|
||||
declare module "some-untyped-package" {
|
||||
const value: unknown;
|
||||
export default value;
|
||||
export = value; // if CJS interop is needed
|
||||
}
|
||||
```
|
||||
|
||||
- For more details: [Declaration Files Guide](https://www.typescriptlang.org/docs/handbook/declaration-files/introduction.html)
|
||||
|
||||
**"Excessive stack depth comparing types"**
|
||||
|
||||
- Cause: Circular or deeply recursive types
|
||||
- Fix priority:
|
||||
1. Limit recursion depth with conditional types
|
||||
2. Use `interface` extends instead of type intersection
|
||||
3. Simplify generic constraints
|
||||
|
||||
```typescript
|
||||
// Bad: Infinite recursion
|
||||
type InfiniteArray<T> = T | InfiniteArray<T>[];
|
||||
|
||||
// Good: Limited recursion
|
||||
type NestedArray<T, D extends number = 5> = D extends 0
|
||||
? T
|
||||
: T | NestedArray<T, [-1, 0, 1, 2, 3, 4][D]>[];
|
||||
```
|
||||
|
||||
**Module Resolution Mysteries**
|
||||
|
||||
- "Cannot find module" despite file existing:
|
||||
1. Check `moduleResolution` matches your bundler
|
||||
2. Verify `baseUrl` and `paths` alignment
|
||||
3. For monorepos: Ensure workspace protocol (workspace:\*)
|
||||
4. Try clearing cache: `rm -rf node_modules/.cache .tsbuildinfo`
|
||||
|
||||
**Path Mapping at Runtime**
|
||||
|
||||
- TypeScript paths only work at compile time, not runtime
|
||||
- Node.js runtime solutions:
|
||||
- ts-node: Use `ts-node -r tsconfig-paths/register`
|
||||
- Node ESM: Use loader alternatives or avoid TS paths at runtime
|
||||
- Production: Pre-compile with resolved paths
|
||||
|
||||
### Migration Expertise
|
||||
|
||||
**JavaScript to TypeScript Migration**
|
||||
|
||||
```bash
|
||||
# Incremental migration strategy
|
||||
# 1. Enable allowJs and checkJs (merge into existing tsconfig.json):
|
||||
# Add to existing tsconfig.json:
|
||||
# {
|
||||
# "compilerOptions": {
|
||||
# "allowJs": true,
|
||||
# "checkJs": true
|
||||
# }
|
||||
# }
|
||||
|
||||
# 2. Rename files gradually (.js → .ts)
|
||||
# 3. Add types file by file using AI assistance
|
||||
# 4. Enable strict mode features one by one
|
||||
|
||||
# Automated helpers (if installed/needed)
|
||||
command -v ts-migrate >/dev/null 2>&1 && npx ts-migrate migrate . --sources 'src/**/*.js'
|
||||
command -v typesync >/dev/null 2>&1 && npx typesync # Install missing @types packages
|
||||
```
|
||||
|
||||
**Tool Migration Decisions**
|
||||
|
||||
| From | To | When | Migration Effort |
|
||||
| ----------------- | --------------- | --------------------------------------------- | ----------------- |
|
||||
| ESLint + Prettier | Biome | Need much faster speed, okay with fewer rules | Low (1 day) |
|
||||
| TSC for linting | Type-check only | Have 100+ files, need faster feedback | Medium (2-3 days) |
|
||||
| Lerna | Nx/Turborepo | Need caching, parallel builds | High (1 week) |
|
||||
| CJS | ESM | Node 18+, modern tooling | High (varies) |
|
||||
|
||||
### Monorepo Management
|
||||
|
||||
**Nx vs Turborepo Decision Matrix**
|
||||
|
||||
- Choose **Turborepo** if: Simple structure, need speed, <20 packages
|
||||
- Choose **Nx** if: Complex dependencies, need visualization, plugins required
|
||||
- Performance: Nx often performs better on large monorepos (>50 packages)
|
||||
|
||||
**TypeScript Monorepo Configuration**
|
||||
|
||||
```json
|
||||
// Root tsconfig.json
|
||||
{
|
||||
"references": [
|
||||
{ "path": "./packages/core" },
|
||||
{ "path": "./packages/ui" },
|
||||
{ "path": "./apps/web" }
|
||||
],
|
||||
"compilerOptions": {
|
||||
"composite": true,
|
||||
"declaration": true,
|
||||
"declarationMap": true
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Modern Tooling Expertise
|
||||
|
||||
### Biome vs ESLint
|
||||
|
||||
**Use Biome when:**
|
||||
|
||||
- Speed is critical (often faster than traditional setups)
|
||||
- Want single tool for lint + format
|
||||
- TypeScript-first project
|
||||
- Okay with 64 TS rules vs 100+ in typescript-eslint
|
||||
|
||||
**Stay with ESLint when:**
|
||||
|
||||
- Need specific rules/plugins
|
||||
- Have complex custom rules
|
||||
- Working with Vue/Angular (limited Biome support)
|
||||
- Need type-aware linting (Biome doesn't have this yet)
|
||||
|
||||
### Type Testing Strategies
|
||||
|
||||
**Vitest Type Testing (Recommended)**
|
||||
|
||||
```typescript
|
||||
// in avatar.test-d.ts
|
||||
import { expectTypeOf } from "vitest";
|
||||
import type { Avatar } from "./avatar";
|
||||
|
||||
test("Avatar props are correctly typed", () => {
|
||||
expectTypeOf<Avatar>().toHaveProperty("size");
|
||||
expectTypeOf<Avatar["size"]>().toEqualTypeOf<"sm" | "md" | "lg">();
|
||||
});
|
||||
```
|
||||
|
||||
**When to Test Types:**
|
||||
|
||||
- Publishing libraries
|
||||
- Complex generic functions
|
||||
- Type-level utilities
|
||||
- API contracts
|
||||
|
||||
## Debugging Mastery
|
||||
|
||||
### CLI Debugging Tools
|
||||
|
||||
```bash
|
||||
# Debug TypeScript files directly (if tools installed)
|
||||
command -v tsx >/dev/null 2>&1 && npx tsx --inspect src/file.ts
|
||||
command -v ts-node >/dev/null 2>&1 && npx ts-node --inspect-brk src/file.ts
|
||||
|
||||
# Trace module resolution issues
|
||||
npx tsc --traceResolution > resolution.log 2>&1
|
||||
grep "Module resolution" resolution.log
|
||||
|
||||
# Debug type checking performance (use --incremental false for clean trace)
|
||||
npx tsc --generateTrace trace --incremental false
|
||||
# Analyze trace (if installed)
|
||||
command -v @typescript/analyze-trace >/dev/null 2>&1 && npx @typescript/analyze-trace trace
|
||||
|
||||
# Memory usage analysis
|
||||
node --max-old-space-size=8192 node_modules/typescript/lib/tsc.js
|
||||
```
|
||||
|
||||
### Custom Error Classes
|
||||
|
||||
```typescript
|
||||
// Proper error class with stack preservation
|
||||
class DomainError extends Error {
|
||||
constructor(
|
||||
message: string,
|
||||
public code: string,
|
||||
public statusCode: number,
|
||||
) {
|
||||
super(message);
|
||||
this.name = "DomainError";
|
||||
Error.captureStackTrace(this, this.constructor);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Current Best Practices
|
||||
|
||||
### Strict by Default
|
||||
|
||||
```json
|
||||
{
|
||||
"compilerOptions": {
|
||||
"strict": true,
|
||||
"noUncheckedIndexedAccess": true,
|
||||
"noImplicitOverride": true,
|
||||
"exactOptionalPropertyTypes": true,
|
||||
"noPropertyAccessFromIndexSignature": true
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### ESM-First Approach
|
||||
|
||||
- Set `"type": "module"` in package.json
|
||||
- Use `.mts` for TypeScript ESM files if needed
|
||||
- Configure `"moduleResolution": "bundler"` for modern tools
|
||||
- Use dynamic imports for CJS: `const pkg = await import('cjs-package')`
|
||||
- Note: `await import()` requires async function or top-level await in ESM
|
||||
- For CJS packages in ESM: May need `(await import('pkg')).default` depending on the package's export structure and your compiler settings
|
||||
|
||||
### AI-Assisted Development
|
||||
|
||||
- GitHub Copilot excels at TypeScript generics
|
||||
- Use AI for boilerplate type definitions
|
||||
- Validate AI-generated types with type tests
|
||||
- Document complex types for AI context
|
||||
|
||||
## Code Review Checklist
|
||||
|
||||
When reviewing TypeScript/JavaScript code, focus on these domain-specific aspects:
|
||||
|
||||
### Type Safety
|
||||
|
||||
- [ ] No implicit `any` types (use `unknown` or proper types)
|
||||
- [ ] Strict null checks enabled and properly handled
|
||||
- [ ] Type assertions (`as`) justified and minimal
|
||||
- [ ] Generic constraints properly defined
|
||||
- [ ] Discriminated unions for error handling
|
||||
- [ ] Return types explicitly declared for public APIs
|
||||
|
||||
### TypeScript Best Practices
|
||||
|
||||
- [ ] Prefer `interface` over `type` for object shapes (better error messages)
|
||||
- [ ] Use const assertions for literal types
|
||||
- [ ] Leverage type guards and predicates
|
||||
- [ ] Avoid type gymnastics when simpler solution exists
|
||||
- [ ] Template literal types used appropriately
|
||||
- [ ] Branded types for domain primitives
|
||||
|
||||
### Performance Considerations
|
||||
|
||||
- [ ] Type complexity doesn't cause slow compilation
|
||||
- [ ] No excessive type instantiation depth
|
||||
- [ ] Avoid complex mapped types in hot paths
|
||||
- [ ] Use `skipLibCheck: true` in tsconfig
|
||||
- [ ] Project references configured for monorepos
|
||||
|
||||
### Module System
|
||||
|
||||
- [ ] Consistent import/export patterns
|
||||
- [ ] No circular dependencies
|
||||
- [ ] Proper use of barrel exports (avoid over-bundling)
|
||||
- [ ] ESM/CJS compatibility handled correctly
|
||||
- [ ] Dynamic imports for code splitting
|
||||
|
||||
### Error Handling Patterns
|
||||
|
||||
- [ ] Result types or discriminated unions for errors
|
||||
- [ ] Custom error classes with proper inheritance
|
||||
- [ ] Type-safe error boundaries
|
||||
- [ ] Exhaustive switch cases with `never` type
|
||||
|
||||
### Code Organization
|
||||
|
||||
- [ ] Types co-located with implementation
|
||||
- [ ] Shared types in dedicated modules
|
||||
- [ ] Avoid global type augmentation when possible
|
||||
- [ ] Proper use of declaration files (.d.ts)
|
||||
|
||||
## Quick Decision Trees
|
||||
|
||||
### "Which tool should I use?"
|
||||
|
||||
```
|
||||
Type checking only? → tsc
|
||||
Type checking + linting speed critical? → Biome
|
||||
Type checking + comprehensive linting? → ESLint + typescript-eslint
|
||||
Type testing? → Vitest expectTypeOf
|
||||
Build tool? → Project size <10 packages? Turborepo. Else? Nx
|
||||
```
|
||||
|
||||
### "How do I fix this performance issue?"
|
||||
|
||||
```
|
||||
Slow type checking? → skipLibCheck, incremental, project references
|
||||
Slow builds? → Check bundler config, enable caching
|
||||
Slow tests? → Vitest with threads, avoid type checking in tests
|
||||
Slow language server? → Exclude node_modules, limit files in tsconfig
|
||||
```
|
||||
|
||||
## Expert Resources
|
||||
|
||||
### Performance
|
||||
|
||||
- [TypeScript Wiki Performance](https://github.com/microsoft/TypeScript/wiki/Performance)
|
||||
- [Type instantiation tracking](https://github.com/microsoft/TypeScript/pull/48077)
|
||||
|
||||
### Advanced Patterns
|
||||
|
||||
- [Type Challenges](https://github.com/type-challenges/type-challenges)
|
||||
- [Type-Level TypeScript Course](https://type-level-typescript.com)
|
||||
|
||||
### Tools
|
||||
|
||||
- [Biome](https://biomejs.dev) - Fast linter/formatter
|
||||
- [TypeStat](https://github.com/JoshuaKGoldberg/TypeStat) - Auto-fix TypeScript types
|
||||
- [ts-migrate](https://github.com/airbnb/ts-migrate) - Migration toolkit
|
||||
|
||||
### Testing
|
||||
|
||||
- [Vitest Type Testing](https://vitest.dev/guide/testing-types)
|
||||
- [tsd](https://github.com/tsdjs/tsd) - Standalone type testing
|
||||
|
||||
Always validate changes don't break existing functionality before considering the issue resolved.
|
||||
|
||||
## When to Use
|
||||
|
||||
This skill is applicable to execute the workflow or actions described in the overview.
|
||||
@@ -0,0 +1,74 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/tsconfig",
|
||||
"display": "Strict TypeScript 5.x",
|
||||
"compilerOptions": {
|
||||
// =========================================================================
|
||||
// STRICTNESS (Maximum Type Safety)
|
||||
// =========================================================================
|
||||
"strict": true,
|
||||
"noUncheckedIndexedAccess": true,
|
||||
"noImplicitOverride": true,
|
||||
"noPropertyAccessFromIndexSignature": true,
|
||||
"exactOptionalPropertyTypes": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
// =========================================================================
|
||||
// MODULE SYSTEM (Modern ESM)
|
||||
// =========================================================================
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"resolveJsonModule": true,
|
||||
"esModuleInterop": true,
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"isolatedModules": true,
|
||||
"verbatimModuleSyntax": true,
|
||||
// =========================================================================
|
||||
// OUTPUT
|
||||
// =========================================================================
|
||||
"target": "ES2022",
|
||||
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
||||
"declaration": true,
|
||||
"declarationMap": true,
|
||||
"sourceMap": true,
|
||||
// =========================================================================
|
||||
// PERFORMANCE
|
||||
// =========================================================================
|
||||
"skipLibCheck": true,
|
||||
"incremental": true,
|
||||
// =========================================================================
|
||||
// PATH ALIASES
|
||||
// =========================================================================
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@/*": ["./src/*"],
|
||||
"@/components/*": ["./src/components/*"],
|
||||
"@/lib/*": ["./src/lib/*"],
|
||||
"@/types/*": ["./src/types/*"],
|
||||
"@/utils/*": ["./src/utils/*"]
|
||||
},
|
||||
// =========================================================================
|
||||
// JSX (for React projects)
|
||||
// =========================================================================
|
||||
// "jsx": "react-jsx",
|
||||
// =========================================================================
|
||||
// EMIT
|
||||
// =========================================================================
|
||||
"noEmit": true // Let bundler handle emit
|
||||
// "outDir": "./dist",
|
||||
// "rootDir": "./src",
|
||||
// =========================================================================
|
||||
// DECORATORS (if needed)
|
||||
// =========================================================================
|
||||
// "experimentalDecorators": true,
|
||||
// "emitDecoratorMetadata": true
|
||||
},
|
||||
"include": ["src/**/*.ts", "src/**/*.tsx", "src/**/*.d.ts"],
|
||||
"exclude": [
|
||||
"node_modules",
|
||||
"dist",
|
||||
"build",
|
||||
"coverage",
|
||||
"**/*.test.ts",
|
||||
"**/*.spec.ts"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,393 @@
|
||||
# TypeScript Cheatsheet
|
||||
|
||||
## Type Basics
|
||||
|
||||
```typescript
|
||||
// Primitives
|
||||
const name: string = "John";
|
||||
const age: number = 30;
|
||||
const isActive: boolean = true;
|
||||
const nothing: null = null;
|
||||
const notDefined: undefined = undefined;
|
||||
|
||||
// Arrays
|
||||
const numbers: number[] = [1, 2, 3];
|
||||
const strings: Array<string> = ["a", "b", "c"];
|
||||
|
||||
// Tuple
|
||||
const tuple: [string, number] = ["hello", 42];
|
||||
|
||||
// Object
|
||||
const user: { name: string; age: number } = { name: "John", age: 30 };
|
||||
|
||||
// Union
|
||||
const value: string | number = "hello";
|
||||
|
||||
// Literal
|
||||
const direction: "up" | "down" | "left" | "right" = "up";
|
||||
|
||||
// Any vs Unknown
|
||||
const anyValue: any = "anything"; // ❌ Avoid
|
||||
const unknownValue: unknown = "safe"; // ✅ Prefer, requires narrowing
|
||||
```
|
||||
|
||||
## Type Aliases & Interfaces
|
||||
|
||||
```typescript
|
||||
// Type Alias
|
||||
type Point = {
|
||||
x: number;
|
||||
y: number;
|
||||
};
|
||||
|
||||
// Interface (preferred for objects)
|
||||
interface User {
|
||||
id: string;
|
||||
name: string;
|
||||
email?: string; // Optional
|
||||
readonly createdAt: Date; // Readonly
|
||||
}
|
||||
|
||||
// Extending
|
||||
interface Admin extends User {
|
||||
permissions: string[];
|
||||
}
|
||||
|
||||
// Intersection
|
||||
type AdminUser = User & { permissions: string[] };
|
||||
```
|
||||
|
||||
## Generics
|
||||
|
||||
```typescript
|
||||
// Generic function
|
||||
function identity<T>(value: T): T {
|
||||
return value;
|
||||
}
|
||||
|
||||
// Generic with constraint
|
||||
function getLength<T extends { length: number }>(item: T): number {
|
||||
return item.length;
|
||||
}
|
||||
|
||||
// Generic interface
|
||||
interface ApiResponse<T> {
|
||||
data: T;
|
||||
status: number;
|
||||
message: string;
|
||||
}
|
||||
|
||||
// Generic with default
|
||||
type Container<T = string> = {
|
||||
value: T;
|
||||
};
|
||||
|
||||
// Multiple generics
|
||||
function merge<T, U>(obj1: T, obj2: U): T & U {
|
||||
return { ...obj1, ...obj2 };
|
||||
}
|
||||
```
|
||||
|
||||
## Utility Types
|
||||
|
||||
```typescript
|
||||
interface User {
|
||||
id: string;
|
||||
name: string;
|
||||
email: string;
|
||||
age: number;
|
||||
}
|
||||
|
||||
// Partial - all optional
|
||||
type PartialUser = Partial<User>;
|
||||
|
||||
// Required - all required
|
||||
type RequiredUser = Required<User>;
|
||||
|
||||
// Readonly - all readonly
|
||||
type ReadonlyUser = Readonly<User>;
|
||||
|
||||
// Pick - select properties
|
||||
type UserName = Pick<User, "id" | "name">;
|
||||
|
||||
// Omit - exclude properties
|
||||
type UserWithoutEmail = Omit<User, "email">;
|
||||
|
||||
// Record - key-value map
|
||||
type UserMap = Record<string, User>;
|
||||
|
||||
// Extract - extract from union
|
||||
type StringOrNumber = string | number | boolean;
|
||||
type OnlyStrings = Extract<StringOrNumber, string>;
|
||||
|
||||
// Exclude - exclude from union
|
||||
type NotString = Exclude<StringOrNumber, string>;
|
||||
|
||||
// NonNullable - remove null/undefined
|
||||
type MaybeString = string | null | undefined;
|
||||
type DefinitelyString = NonNullable<MaybeString>;
|
||||
|
||||
// ReturnType - get function return type
|
||||
function getUser() {
|
||||
return { name: "John" };
|
||||
}
|
||||
type UserReturn = ReturnType<typeof getUser>;
|
||||
|
||||
// Parameters - get function parameters
|
||||
type GetUserParams = Parameters<typeof getUser>;
|
||||
|
||||
// Awaited - unwrap Promise
|
||||
type ResolvedUser = Awaited<Promise<User>>;
|
||||
```
|
||||
|
||||
## Conditional Types
|
||||
|
||||
```typescript
|
||||
// Basic conditional
|
||||
type IsString<T> = T extends string ? true : false;
|
||||
|
||||
// Infer keyword
|
||||
type UnwrapPromise<T> = T extends Promise<infer U> ? U : T;
|
||||
|
||||
// Distributive conditional
|
||||
type ToArray<T> = T extends any ? T[] : never;
|
||||
type Result = ToArray<string | number>; // string[] | number[]
|
||||
|
||||
// NonDistributive
|
||||
type ToArrayNonDist<T> = [T] extends [any] ? T[] : never;
|
||||
```
|
||||
|
||||
## Template Literal Types
|
||||
|
||||
```typescript
|
||||
type Color = "red" | "green" | "blue";
|
||||
type Size = "small" | "medium" | "large";
|
||||
|
||||
// Combine
|
||||
type ColorSize = `${Color}-${Size}`;
|
||||
// 'red-small' | 'red-medium' | 'red-large' | ...
|
||||
|
||||
// Event handlers
|
||||
type EventName = "click" | "focus" | "blur";
|
||||
type EventHandler = `on${Capitalize<EventName>}`;
|
||||
// 'onClick' | 'onFocus' | 'onBlur'
|
||||
```
|
||||
|
||||
## Mapped Types
|
||||
|
||||
```typescript
|
||||
// Basic mapped type
|
||||
type Optional<T> = {
|
||||
[K in keyof T]?: T[K];
|
||||
};
|
||||
|
||||
// With key remapping
|
||||
type Getters<T> = {
|
||||
[K in keyof T as `get${Capitalize<string & K>}`]: () => T[K];
|
||||
};
|
||||
|
||||
// Filter keys
|
||||
type OnlyStrings<T> = {
|
||||
[K in keyof T as T[K] extends string ? K : never]: T[K];
|
||||
};
|
||||
```
|
||||
|
||||
## Type Guards
|
||||
|
||||
```typescript
|
||||
// typeof guard
|
||||
function process(value: string | number) {
|
||||
if (typeof value === "string") {
|
||||
return value.toUpperCase(); // string
|
||||
}
|
||||
return value.toFixed(2); // number
|
||||
}
|
||||
|
||||
// instanceof guard
|
||||
class Dog {
|
||||
bark() {}
|
||||
}
|
||||
class Cat {
|
||||
meow() {}
|
||||
}
|
||||
|
||||
function makeSound(animal: Dog | Cat) {
|
||||
if (animal instanceof Dog) {
|
||||
animal.bark();
|
||||
} else {
|
||||
animal.meow();
|
||||
}
|
||||
}
|
||||
|
||||
// in guard
|
||||
interface Bird {
|
||||
fly(): void;
|
||||
}
|
||||
interface Fish {
|
||||
swim(): void;
|
||||
}
|
||||
|
||||
function move(animal: Bird | Fish) {
|
||||
if ("fly" in animal) {
|
||||
animal.fly();
|
||||
} else {
|
||||
animal.swim();
|
||||
}
|
||||
}
|
||||
|
||||
// Custom type guard
|
||||
function isString(value: unknown): value is string {
|
||||
return typeof value === "string";
|
||||
}
|
||||
|
||||
// Assertion function
|
||||
function assertIsString(value: unknown): asserts value is string {
|
||||
if (typeof value !== "string") {
|
||||
throw new Error("Not a string");
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Discriminated Unions
|
||||
|
||||
```typescript
|
||||
// With type discriminant
|
||||
type Success<T> = { type: "success"; data: T };
|
||||
type Error = { type: "error"; message: string };
|
||||
type Loading = { type: "loading" };
|
||||
|
||||
type State<T> = Success<T> | Error | Loading;
|
||||
|
||||
function handle<T>(state: State<T>) {
|
||||
switch (state.type) {
|
||||
case "success":
|
||||
return state.data; // T
|
||||
case "error":
|
||||
return state.message; // string
|
||||
case "loading":
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// Exhaustive check
|
||||
function assertNever(value: never): never {
|
||||
throw new Error(`Unexpected value: ${value}`);
|
||||
}
|
||||
```
|
||||
|
||||
## Branded Types
|
||||
|
||||
```typescript
|
||||
// Create branded type
|
||||
type Brand<K, T> = K & { __brand: T };
|
||||
|
||||
type UserId = Brand<string, "UserId">;
|
||||
type OrderId = Brand<string, "OrderId">;
|
||||
|
||||
// Constructor functions
|
||||
function createUserId(id: string): UserId {
|
||||
return id as UserId;
|
||||
}
|
||||
|
||||
function createOrderId(id: string): OrderId {
|
||||
return id as OrderId;
|
||||
}
|
||||
|
||||
// Usage - prevents mixing
|
||||
function getOrder(orderId: OrderId, userId: UserId) {}
|
||||
|
||||
const userId = createUserId("user-123");
|
||||
const orderId = createOrderId("order-456");
|
||||
|
||||
getOrder(orderId, userId); // ✅ OK
|
||||
// getOrder(userId, orderId) // ❌ Error - types don't match
|
||||
```
|
||||
|
||||
## Module Declarations
|
||||
|
||||
```typescript
|
||||
// Declare module for untyped package
|
||||
declare module "untyped-package" {
|
||||
export function doSomething(): void;
|
||||
export const value: string;
|
||||
}
|
||||
|
||||
// Augment existing module
|
||||
declare module "express" {
|
||||
interface Request {
|
||||
user?: { id: string };
|
||||
}
|
||||
}
|
||||
|
||||
// Declare global
|
||||
declare global {
|
||||
interface Window {
|
||||
myGlobal: string;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## TSConfig Essentials
|
||||
|
||||
```json
|
||||
{
|
||||
"compilerOptions": {
|
||||
// Strictness
|
||||
"strict": true,
|
||||
"noUncheckedIndexedAccess": true,
|
||||
"noImplicitOverride": true,
|
||||
|
||||
// Modules
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"esModuleInterop": true,
|
||||
|
||||
// Output
|
||||
"target": "ES2022",
|
||||
"lib": ["ES2022", "DOM"],
|
||||
|
||||
// Performance
|
||||
"skipLibCheck": true,
|
||||
"incremental": true,
|
||||
|
||||
// Paths
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@/*": ["./src/*"]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Best Practices
|
||||
|
||||
```typescript
|
||||
// ✅ Prefer interface for objects
|
||||
interface User {
|
||||
name: string;
|
||||
}
|
||||
|
||||
// ✅ Use const assertions
|
||||
const routes = ["home", "about"] as const;
|
||||
|
||||
// ✅ Use satisfies for validation
|
||||
const config = {
|
||||
api: "https://api.example.com",
|
||||
} satisfies Record<string, string>;
|
||||
|
||||
// ✅ Use unknown over any
|
||||
function parse(input: unknown) {
|
||||
if (typeof input === "string") {
|
||||
return JSON.parse(input);
|
||||
}
|
||||
}
|
||||
|
||||
// ✅ Explicit return types for public APIs
|
||||
export function getUser(id: string): User | null {
|
||||
// ...
|
||||
}
|
||||
|
||||
// ❌ Avoid
|
||||
const data: any = fetchData();
|
||||
data.anything.goes.wrong; // No type safety
|
||||
```
|
||||
@@ -0,0 +1,332 @@
|
||||
/**
|
||||
* TypeScript Utility Types Library
|
||||
*
|
||||
* A collection of commonly used utility types for TypeScript projects.
|
||||
* Copy and use as needed in your projects.
|
||||
*/
|
||||
|
||||
// =============================================================================
|
||||
// BRANDED TYPES
|
||||
// =============================================================================
|
||||
|
||||
/**
|
||||
* Create nominal/branded types to prevent primitive obsession.
|
||||
*
|
||||
* @example
|
||||
* type UserId = Brand<string, 'UserId'>
|
||||
* type OrderId = Brand<string, 'OrderId'>
|
||||
*/
|
||||
export type Brand<K, T> = K & { readonly __brand: T };
|
||||
|
||||
// Branded type constructors
|
||||
export type UserId = Brand<string, "UserId">;
|
||||
export type Email = Brand<string, "Email">;
|
||||
export type UUID = Brand<string, "UUID">;
|
||||
export type Timestamp = Brand<number, "Timestamp">;
|
||||
export type PositiveNumber = Brand<number, "PositiveNumber">;
|
||||
|
||||
// =============================================================================
|
||||
// RESULT TYPE (Error Handling)
|
||||
// =============================================================================
|
||||
|
||||
/**
|
||||
* Type-safe error handling without exceptions.
|
||||
*/
|
||||
export type Result<T, E = Error> =
|
||||
| { success: true; data: T }
|
||||
| { success: false; error: E };
|
||||
|
||||
export const ok = <T>(data: T): Result<T, never> => ({
|
||||
success: true,
|
||||
data,
|
||||
});
|
||||
|
||||
export const err = <E>(error: E): Result<never, E> => ({
|
||||
success: false,
|
||||
error,
|
||||
});
|
||||
|
||||
// =============================================================================
|
||||
// OPTION TYPE (Nullable Handling)
|
||||
// =============================================================================
|
||||
|
||||
/**
|
||||
* Explicit optional value handling.
|
||||
*/
|
||||
export type Option<T> = Some<T> | None;
|
||||
|
||||
export type Some<T> = { type: "some"; value: T };
|
||||
export type None = { type: "none" };
|
||||
|
||||
export const some = <T>(value: T): Some<T> => ({ type: "some", value });
|
||||
export const none: None = { type: "none" };
|
||||
|
||||
// =============================================================================
|
||||
// DEEP UTILITIES
|
||||
// =============================================================================
|
||||
|
||||
/**
|
||||
* Make all properties deeply readonly.
|
||||
*/
|
||||
export type DeepReadonly<T> = T extends (...args: any[]) => any
|
||||
? T
|
||||
: T extends object
|
||||
? { readonly [K in keyof T]: DeepReadonly<T[K]> }
|
||||
: T;
|
||||
|
||||
/**
|
||||
* Make all properties deeply optional.
|
||||
*/
|
||||
export type DeepPartial<T> = T extends object
|
||||
? { [K in keyof T]?: DeepPartial<T[K]> }
|
||||
: T;
|
||||
|
||||
/**
|
||||
* Make all properties deeply required.
|
||||
*/
|
||||
export type DeepRequired<T> = T extends object
|
||||
? { [K in keyof T]-?: DeepRequired<T[K]> }
|
||||
: T;
|
||||
|
||||
/**
|
||||
* Make all properties deeply mutable (remove readonly).
|
||||
*/
|
||||
export type DeepMutable<T> = T extends object
|
||||
? { -readonly [K in keyof T]: DeepMutable<T[K]> }
|
||||
: T;
|
||||
|
||||
// =============================================================================
|
||||
// OBJECT UTILITIES
|
||||
// =============================================================================
|
||||
|
||||
/**
|
||||
* Get keys of object where value matches type.
|
||||
*/
|
||||
export type KeysOfType<T, V> = {
|
||||
[K in keyof T]: T[K] extends V ? K : never;
|
||||
}[keyof T];
|
||||
|
||||
/**
|
||||
* Pick properties by value type.
|
||||
*/
|
||||
export type PickByType<T, V> = Pick<T, KeysOfType<T, V>>;
|
||||
|
||||
/**
|
||||
* Omit properties by value type.
|
||||
*/
|
||||
export type OmitByType<T, V> = Omit<T, KeysOfType<T, V>>;
|
||||
|
||||
/**
|
||||
* Make specific keys optional.
|
||||
*/
|
||||
export type PartialBy<T, K extends keyof T> = Omit<T, K> & Partial<Pick<T, K>>;
|
||||
|
||||
/**
|
||||
* Make specific keys required.
|
||||
*/
|
||||
export type RequiredBy<T, K extends keyof T> = Omit<T, K> &
|
||||
Required<Pick<T, K>>;
|
||||
|
||||
/**
|
||||
* Make specific keys readonly.
|
||||
*/
|
||||
export type ReadonlyBy<T, K extends keyof T> = Omit<T, K> &
|
||||
Readonly<Pick<T, K>>;
|
||||
|
||||
/**
|
||||
* Merge two types (second overrides first).
|
||||
*/
|
||||
export type Merge<T, U> = Omit<T, keyof U> & U;
|
||||
|
||||
// =============================================================================
|
||||
// ARRAY UTILITIES
|
||||
// =============================================================================
|
||||
|
||||
/**
|
||||
* Get element type from array.
|
||||
*/
|
||||
export type ElementOf<T> = T extends (infer E)[] ? E : never;
|
||||
|
||||
/**
|
||||
* Tuple of specific length.
|
||||
*/
|
||||
export type Tuple<T, N extends number> = N extends N
|
||||
? number extends N
|
||||
? T[]
|
||||
: _TupleOf<T, N, []>
|
||||
: never;
|
||||
|
||||
type _TupleOf<T, N extends number, R extends unknown[]> = R["length"] extends N
|
||||
? R
|
||||
: _TupleOf<T, N, [T, ...R]>;
|
||||
|
||||
/**
|
||||
* Non-empty array.
|
||||
*/
|
||||
export type NonEmptyArray<T> = [T, ...T[]];
|
||||
|
||||
/**
|
||||
* At least N elements.
|
||||
*/
|
||||
export type AtLeast<T, N extends number> = [...Tuple<T, N>, ...T[]];
|
||||
|
||||
// =============================================================================
|
||||
// FUNCTION UTILITIES
|
||||
// =============================================================================
|
||||
|
||||
/**
|
||||
* Get function arguments as tuple.
|
||||
*/
|
||||
export type Arguments<T> = T extends (...args: infer A) => any ? A : never;
|
||||
|
||||
/**
|
||||
* Get first argument of function.
|
||||
*/
|
||||
export type FirstArgument<T> = T extends (first: infer F, ...args: any[]) => any
|
||||
? F
|
||||
: never;
|
||||
|
||||
/**
|
||||
* Async version of function.
|
||||
*/
|
||||
export type AsyncFunction<T extends (...args: any[]) => any> = (
|
||||
...args: Parameters<T>
|
||||
) => Promise<Awaited<ReturnType<T>>>;
|
||||
|
||||
/**
|
||||
* Promisify return type.
|
||||
*/
|
||||
export type Promisify<T> = T extends (...args: infer A) => infer R
|
||||
? (...args: A) => Promise<Awaited<R>>
|
||||
: never;
|
||||
|
||||
// =============================================================================
|
||||
// STRING UTILITIES
|
||||
// =============================================================================
|
||||
|
||||
/**
|
||||
* Split string by delimiter.
|
||||
*/
|
||||
export type Split<
|
||||
S extends string,
|
||||
D extends string,
|
||||
> = S extends `${infer T}${D}${infer U}` ? [T, ...Split<U, D>] : [S];
|
||||
|
||||
/**
|
||||
* Join tuple to string.
|
||||
*/
|
||||
export type Join<T extends string[], D extends string> = T extends []
|
||||
? ""
|
||||
: T extends [infer F extends string]
|
||||
? F
|
||||
: T extends [infer F extends string, ...infer R extends string[]]
|
||||
? `${F}${D}${Join<R, D>}`
|
||||
: never;
|
||||
|
||||
/**
|
||||
* Path to nested object.
|
||||
*/
|
||||
export type PathOf<T, K extends keyof T = keyof T> = K extends string
|
||||
? T[K] extends object
|
||||
? K | `${K}.${PathOf<T[K]>}`
|
||||
: K
|
||||
: never;
|
||||
|
||||
// =============================================================================
|
||||
// UNION UTILITIES
|
||||
// =============================================================================
|
||||
|
||||
/**
|
||||
* Last element of union.
|
||||
*/
|
||||
export type UnionLast<T> =
|
||||
UnionToIntersection<T extends any ? () => T : never> extends () => infer R
|
||||
? R
|
||||
: never;
|
||||
|
||||
/**
|
||||
* Union to intersection.
|
||||
*/
|
||||
export type UnionToIntersection<U> = (
|
||||
U extends any ? (k: U) => void : never
|
||||
) extends (k: infer I) => void
|
||||
? I
|
||||
: never;
|
||||
|
||||
/**
|
||||
* Union to tuple.
|
||||
*/
|
||||
export type UnionToTuple<T, L = UnionLast<T>> = [T] extends [never]
|
||||
? []
|
||||
: [...UnionToTuple<Exclude<T, L>>, L];
|
||||
|
||||
// =============================================================================
|
||||
// VALIDATION UTILITIES
|
||||
// =============================================================================
|
||||
|
||||
/**
|
||||
* Assert type at compile time.
|
||||
*/
|
||||
export type AssertEqual<T, U> =
|
||||
(<V>() => V extends T ? 1 : 2) extends <V>() => V extends U ? 1 : 2
|
||||
? true
|
||||
: false;
|
||||
|
||||
/**
|
||||
* Ensure type is not never.
|
||||
*/
|
||||
export type IsNever<T> = [T] extends [never] ? true : false;
|
||||
|
||||
/**
|
||||
* Ensure type is any.
|
||||
*/
|
||||
export type IsAny<T> = 0 extends 1 & T ? true : false;
|
||||
|
||||
/**
|
||||
* Ensure type is unknown.
|
||||
*/
|
||||
export type IsUnknown<T> =
|
||||
IsAny<T> extends true ? false : unknown extends T ? true : false;
|
||||
|
||||
// =============================================================================
|
||||
// JSON UTILITIES
|
||||
// =============================================================================
|
||||
|
||||
/**
|
||||
* JSON-safe types.
|
||||
*/
|
||||
export type JsonPrimitive = string | number | boolean | null;
|
||||
export type JsonArray = JsonValue[];
|
||||
export type JsonObject = { [key: string]: JsonValue };
|
||||
export type JsonValue = JsonPrimitive | JsonArray | JsonObject;
|
||||
|
||||
/**
|
||||
* Make type JSON-serializable.
|
||||
*/
|
||||
export type Jsonify<T> = T extends JsonPrimitive
|
||||
? T
|
||||
: T extends undefined | ((...args: any[]) => any) | symbol
|
||||
? never
|
||||
: T extends { toJSON(): infer R }
|
||||
? R
|
||||
: T extends object
|
||||
? { [K in keyof T]: Jsonify<T[K]> }
|
||||
: never;
|
||||
|
||||
// =============================================================================
|
||||
// EXHAUSTIVE CHECK
|
||||
// =============================================================================
|
||||
|
||||
/**
|
||||
* Ensure all cases are handled in switch/if.
|
||||
*/
|
||||
export function assertNever(value: never, message?: string): never {
|
||||
throw new Error(message ?? `Unexpected value: ${value}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Exhaustive check without throwing.
|
||||
*/
|
||||
export function exhaustiveCheck(_value: never): void {
|
||||
// This function should never be called
|
||||
}
|
||||
@@ -0,0 +1,203 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
TypeScript Project Diagnostic Script
|
||||
Analyzes TypeScript projects for configuration, performance, and common issues.
|
||||
"""
|
||||
|
||||
import subprocess
|
||||
import sys
|
||||
import os
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
def run_cmd(cmd: str) -> str:
|
||||
"""Run shell command and return output."""
|
||||
try:
|
||||
result = subprocess.run(cmd, shell=True, capture_output=True, text=True)
|
||||
return result.stdout + result.stderr
|
||||
except Exception as e:
|
||||
return str(e)
|
||||
|
||||
def check_versions():
|
||||
"""Check TypeScript and Node versions."""
|
||||
print("\n📦 Versions:")
|
||||
print("-" * 40)
|
||||
|
||||
ts_version = run_cmd("npx tsc --version 2>/dev/null").strip()
|
||||
node_version = run_cmd("node -v 2>/dev/null").strip()
|
||||
|
||||
print(f" TypeScript: {ts_version or 'Not found'}")
|
||||
print(f" Node.js: {node_version or 'Not found'}")
|
||||
|
||||
def check_tsconfig():
|
||||
"""Analyze tsconfig.json settings."""
|
||||
print("\n⚙️ TSConfig Analysis:")
|
||||
print("-" * 40)
|
||||
|
||||
tsconfig_path = Path("tsconfig.json")
|
||||
if not tsconfig_path.exists():
|
||||
print("⚠️ tsconfig.json not found")
|
||||
return
|
||||
|
||||
try:
|
||||
with open(tsconfig_path) as f:
|
||||
config = json.load(f)
|
||||
|
||||
compiler_opts = config.get("compilerOptions", {})
|
||||
|
||||
# Check strict mode
|
||||
if compiler_opts.get("strict"):
|
||||
print("✅ Strict mode enabled")
|
||||
else:
|
||||
print("⚠️ Strict mode NOT enabled")
|
||||
|
||||
# Check important flags
|
||||
flags = {
|
||||
"noUncheckedIndexedAccess": "Unchecked index access protection",
|
||||
"noImplicitOverride": "Implicit override protection",
|
||||
"skipLibCheck": "Skip lib check (performance)",
|
||||
"incremental": "Incremental compilation"
|
||||
}
|
||||
|
||||
for flag, desc in flags.items():
|
||||
status = "✅" if compiler_opts.get(flag) else "⚪"
|
||||
print(f" {status} {desc}: {compiler_opts.get(flag, 'not set')}")
|
||||
|
||||
# Check module settings
|
||||
print(f"\n Module: {compiler_opts.get('module', 'not set')}")
|
||||
print(f" Module Resolution: {compiler_opts.get('moduleResolution', 'not set')}")
|
||||
print(f" Target: {compiler_opts.get('target', 'not set')}")
|
||||
|
||||
except json.JSONDecodeError:
|
||||
print("❌ Invalid JSON in tsconfig.json")
|
||||
|
||||
def check_tooling():
|
||||
"""Detect TypeScript tooling ecosystem."""
|
||||
print("\n🛠️ Tooling Detection:")
|
||||
print("-" * 40)
|
||||
|
||||
pkg_path = Path("package.json")
|
||||
if not pkg_path.exists():
|
||||
print("⚠️ package.json not found")
|
||||
return
|
||||
|
||||
try:
|
||||
with open(pkg_path) as f:
|
||||
pkg = json.load(f)
|
||||
|
||||
all_deps = {**pkg.get("dependencies", {}), **pkg.get("devDependencies", {})}
|
||||
|
||||
tools = {
|
||||
"biome": "Biome (linter/formatter)",
|
||||
"eslint": "ESLint",
|
||||
"prettier": "Prettier",
|
||||
"vitest": "Vitest (testing)",
|
||||
"jest": "Jest (testing)",
|
||||
"turborepo": "Turborepo (monorepo)",
|
||||
"turbo": "Turbo (monorepo)",
|
||||
"nx": "Nx (monorepo)",
|
||||
"lerna": "Lerna (monorepo)"
|
||||
}
|
||||
|
||||
for tool, desc in tools.items():
|
||||
for dep in all_deps:
|
||||
if tool in dep.lower():
|
||||
print(f" ✅ {desc}")
|
||||
break
|
||||
|
||||
except json.JSONDecodeError:
|
||||
print("❌ Invalid JSON in package.json")
|
||||
|
||||
def check_monorepo():
|
||||
"""Check for monorepo configuration."""
|
||||
print("\n📦 Monorepo Check:")
|
||||
print("-" * 40)
|
||||
|
||||
indicators = [
|
||||
("pnpm-workspace.yaml", "PNPM Workspace"),
|
||||
("lerna.json", "Lerna"),
|
||||
("nx.json", "Nx"),
|
||||
("turbo.json", "Turborepo")
|
||||
]
|
||||
|
||||
found = False
|
||||
for file, name in indicators:
|
||||
if Path(file).exists():
|
||||
print(f" ✅ {name} detected")
|
||||
found = True
|
||||
|
||||
if not found:
|
||||
print(" ⚪ No monorepo configuration detected")
|
||||
|
||||
def check_type_errors():
|
||||
"""Run quick type check."""
|
||||
print("\n🔍 Type Check:")
|
||||
print("-" * 40)
|
||||
|
||||
result = run_cmd("npx tsc --noEmit 2>&1 | head -20")
|
||||
if "error TS" in result:
|
||||
errors = result.count("error TS")
|
||||
print(f" ❌ {errors}+ type errors found")
|
||||
print(result[:500])
|
||||
else:
|
||||
print(" ✅ No type errors")
|
||||
|
||||
def check_any_usage():
|
||||
"""Check for any type usage."""
|
||||
print("\n⚠️ 'any' Type Usage:")
|
||||
print("-" * 40)
|
||||
|
||||
result = run_cmd("grep -r ': any' --include='*.ts' --include='*.tsx' src/ 2>/dev/null | wc -l")
|
||||
count = result.strip()
|
||||
if count and count != "0":
|
||||
print(f" ⚠️ Found {count} occurrences of ': any'")
|
||||
sample = run_cmd("grep -rn ': any' --include='*.ts' --include='*.tsx' src/ 2>/dev/null | head -5")
|
||||
if sample:
|
||||
print(sample)
|
||||
else:
|
||||
print(" ✅ No explicit 'any' types found")
|
||||
|
||||
def check_type_assertions():
|
||||
"""Check for type assertions."""
|
||||
print("\n⚠️ Type Assertions (as):")
|
||||
print("-" * 40)
|
||||
|
||||
result = run_cmd("grep -r ' as ' --include='*.ts' --include='*.tsx' src/ 2>/dev/null | grep -v 'import' | wc -l")
|
||||
count = result.strip()
|
||||
if count and count != "0":
|
||||
print(f" ⚠️ Found {count} type assertions")
|
||||
else:
|
||||
print(" ✅ No type assertions found")
|
||||
|
||||
def check_performance():
|
||||
"""Check type checking performance."""
|
||||
print("\n⏱️ Type Check Performance:")
|
||||
print("-" * 40)
|
||||
|
||||
result = run_cmd("npx tsc --extendedDiagnostics --noEmit 2>&1 | grep -E 'Check time|Files:|Lines:|Nodes:'")
|
||||
if result.strip():
|
||||
for line in result.strip().split('\n'):
|
||||
print(f" {line}")
|
||||
else:
|
||||
print(" ⚠️ Could not measure performance")
|
||||
|
||||
def main():
|
||||
print("=" * 50)
|
||||
print("🔍 TypeScript Project Diagnostic Report")
|
||||
print("=" * 50)
|
||||
|
||||
check_versions()
|
||||
check_tsconfig()
|
||||
check_tooling()
|
||||
check_monorepo()
|
||||
check_any_usage()
|
||||
check_type_assertions()
|
||||
check_type_errors()
|
||||
check_performance()
|
||||
|
||||
print("\n" + "=" * 50)
|
||||
print("✅ Diagnostic Complete")
|
||||
print("=" * 50)
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,7 @@
|
||||
# Target the Chromium version that the bundled Electron ships. Tooling that
|
||||
# reads browserslist (PostCSS / Microsoft Edge Tools / linters) will stop
|
||||
# warning about features that are only "missing" on engines we never ship to.
|
||||
#
|
||||
# Electron 39.x bundles Chromium 130+. Bumping this when we upgrade Electron
|
||||
# keeps the lint signal honest without silencing legitimate compat issues.
|
||||
last 2 Chrome versions
|
||||
@@ -0,0 +1,24 @@
|
||||
{
|
||||
"hooks": {
|
||||
"UserPromptSubmit": [
|
||||
{
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "lat hook claude UserPromptSubmit"
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"Stop": [
|
||||
{
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "lat hook claude Stop"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
../../.agents/skills/electron-pro
|
||||
@@ -0,0 +1,169 @@
|
||||
---
|
||||
name: lat-md
|
||||
description: >-
|
||||
Writing and maintaining lat.md documentation files — structured markdown that
|
||||
describes a project's architecture, design decisions, and test specs. Use when
|
||||
creating, editing, or reviewing files in the lat.md/ directory.
|
||||
---
|
||||
|
||||
# lat.md Authoring Guide
|
||||
|
||||
This skill covers the syntax, structure rules, and conventions for writing `lat.md/` files. Load it whenever you need to create or edit sections in the `lat.md/` directory.
|
||||
|
||||
## What belongs in lat.md
|
||||
|
||||
`lat.md/` files describe **what** the project does and **why** — domain concepts, key design decisions, business logic, and test specifications. They do NOT duplicate source code. Think of each section as an anchor that source code references back to.
|
||||
|
||||
Good candidates for sections:
|
||||
- Architecture decisions and their rationale
|
||||
- Domain concepts and business rules
|
||||
- API contracts and protocols
|
||||
- Test specifications (what is tested and why)
|
||||
- Non-obvious constraints or invariants
|
||||
|
||||
Bad candidates:
|
||||
- Step-by-step code walkthroughs (the code itself is the walkthrough)
|
||||
- Auto-generated API docs (use tools for that)
|
||||
- Temporary notes or TODOs
|
||||
|
||||
## Section structure
|
||||
|
||||
Every section **must** have a leading paragraph — at least one sentence immediately after the heading, before any child headings or other block content.
|
||||
|
||||
The first paragraph must be ≤250 characters (excluding `[[wiki link]]` content). This paragraph is the section's identity — it appears in search results, command output, and RAG context.
|
||||
|
||||
```markdown
|
||||
# Good Section
|
||||
|
||||
Brief overview of what this section documents and why it matters.
|
||||
|
||||
More detail can go in subsequent paragraphs, code blocks, or lists.
|
||||
|
||||
## Child heading
|
||||
|
||||
Details about this child topic.
|
||||
```
|
||||
|
||||
```markdown
|
||||
# Bad Section
|
||||
|
||||
## Child heading
|
||||
|
||||
This is invalid — "Bad Section" has no leading paragraph.
|
||||
```
|
||||
|
||||
`lat check` enforces this rule.
|
||||
|
||||
## Section IDs
|
||||
|
||||
Sections are addressed by file path and heading chain:
|
||||
|
||||
- **Full form**: `lat.md/path/to/file#Heading#SubHeading`
|
||||
- **Short form**: `file#Heading#SubHeading` (when the file stem is unique)
|
||||
|
||||
Examples: `lat.md/tests/search#RAG Replay Tests`, `cli#init`, `parser#Wiki Links`.
|
||||
|
||||
## Wiki links
|
||||
|
||||
Cross-reference other sections or source code with `[[target]]` or `[[target|alias]]`.
|
||||
|
||||
### Section links
|
||||
|
||||
```markdown
|
||||
See [[cli#init]] for setup details.
|
||||
The parser validates [[parser#Wiki Links|wiki link syntax]].
|
||||
```
|
||||
|
||||
### Source code links
|
||||
|
||||
Reference functions, classes, constants, and methods in source files:
|
||||
|
||||
```markdown
|
||||
[[src/config.ts#getConfigDir]] — function
|
||||
[[src/server.ts#App#listen]] — class method
|
||||
[[lib/utils.py#parse_args]] — Python function
|
||||
[[src/lib.rs#Greeter#greet]] — Rust impl method
|
||||
[[src/app.go#Greeter#Greet]] — Go method
|
||||
[[src/app.h#Greeter]] — C struct
|
||||
```
|
||||
|
||||
`lat check` validates that all targets exist.
|
||||
|
||||
## Code refs
|
||||
|
||||
Tie source code back to `lat.md/` sections with `@lat:` comments:
|
||||
|
||||
```typescript
|
||||
// @lat: [[cli#init]]
|
||||
export function init() { ... }
|
||||
```
|
||||
|
||||
```python
|
||||
# @lat: [[cli#init]]
|
||||
def init():
|
||||
...
|
||||
```
|
||||
|
||||
Supported comment styles: `//` (JS/TS/Rust/Go/C) and `#` (Python).
|
||||
|
||||
Place one `@lat:` comment per section, at the relevant code — not at the top of the file.
|
||||
|
||||
## Test specs
|
||||
|
||||
Describe tests as sections in `lat.md/` files. Add frontmatter to require that every leaf section has a matching `@lat:` comment in test code:
|
||||
|
||||
```markdown
|
||||
---
|
||||
lat:
|
||||
require-code-mention: true
|
||||
---
|
||||
# Tests
|
||||
|
||||
Authentication test specifications.
|
||||
|
||||
## User login
|
||||
|
||||
Verify credential validation and error handling.
|
||||
|
||||
### Rejects expired tokens
|
||||
|
||||
Tokens past their expiry timestamp are rejected with 401, even if otherwise valid.
|
||||
|
||||
### Handles missing password
|
||||
|
||||
Login request without a password field returns 400 with a descriptive error.
|
||||
```
|
||||
|
||||
Each test references its spec:
|
||||
|
||||
```python
|
||||
# @lat: [[tests#User login#Rejects expired tokens]]
|
||||
def test_rejects_expired_tokens():
|
||||
...
|
||||
```
|
||||
|
||||
Rules:
|
||||
- Every leaf section under `require-code-mention: true` must be referenced by exactly one `@lat:` comment
|
||||
- Every section MUST have a description — at least one sentence explaining what the test verifies and why
|
||||
- `lat check` flags unreferenced specs and dangling code refs
|
||||
|
||||
## Frontmatter
|
||||
|
||||
Optional YAML frontmatter at the top of `lat.md/` files:
|
||||
|
||||
```yaml
|
||||
---
|
||||
lat:
|
||||
require-code-mention: true
|
||||
---
|
||||
```
|
||||
|
||||
Currently the only supported field is `require-code-mention` for test spec enforcement.
|
||||
|
||||
## Validation
|
||||
|
||||
Always run `lat check` after editing `lat.md/` files. It validates:
|
||||
- All wiki links point to existing sections or source code symbols
|
||||
- All `@lat:` code refs point to existing sections
|
||||
- Every section has a leading paragraph (≤250 chars)
|
||||
- All `require-code-mention` leaf sections are referenced in code
|
||||
@@ -0,0 +1 @@
|
||||
../../.agents/skills/typescript-expert
|
||||
@@ -0,0 +1,24 @@
|
||||
{
|
||||
"hooks": {
|
||||
"UserPromptSubmit": [
|
||||
{
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "lat hook claude UserPromptSubmit"
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"Stop": [
|
||||
{
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "lat hook claude Stop"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
# Hermes Analytics Configuration (optional)
|
||||
# Set these in your build environment or CI to enable analytics.
|
||||
# These values are NOT exposed to users — analytics only works in official builds.
|
||||
#
|
||||
# GITHUB SECRETS SETUP:
|
||||
# 1. Go to Settings → Secrets and variables → Actions
|
||||
# 2. Add repository secrets:
|
||||
# - VITE_ANALYTICS_BASE_URL - Base URL of the analytics service
|
||||
# (e.g. https://analytics.hermesone.org)
|
||||
# - VITE_ANALYTICS_API_KEY - API key sent as the x-api-key header
|
||||
# 3. These are automatically injected during GitHub Actions builds.
|
||||
# 4. Forks without these secrets will have analytics disabled.
|
||||
|
||||
VITE_ANALYTICS_BASE_URL=https://analytics.hermesone.org
|
||||
|
||||
VITE_ANALYTICS_API_KEY=your_analytics_api_key_here
|
||||
|
||||
# Hermes One Backend Configuration (optional)
|
||||
# Base URL + client key for device login and desktop↔backend agent sync.
|
||||
# The MAIN_VITE_ prefix scopes these to the main process (where the API calls
|
||||
# run) so the key isn't also copied into the renderer bundle.
|
||||
#
|
||||
# GITHUB SETUP (injected during GitHub Actions builds):
|
||||
# - Repository VARIABLE HERMES_API_URL → MAIN_VITE_HERMES_API_URL
|
||||
# - Repository SECRET HERMES_API_KEY → MAIN_VITE_HERMES_API_KEY
|
||||
#
|
||||
# Local dev: leave the URL at the Nitro dev server and the key empty (the
|
||||
# backend doesn't enforce the key yet). Runtime HERMES_API_URL / HERMES_API_KEY
|
||||
# override these baked values.
|
||||
|
||||
MAIN_VITE_HERMES_API_URL=http://localhost:3002
|
||||
|
||||
MAIN_VITE_HERMES_API_KEY=your_hermes_api_key_here
|
||||
@@ -0,0 +1,7 @@
|
||||
# Auto detect text files and perform LF normalization
|
||||
* text=auto
|
||||
|
||||
# Shell scripts must always be LF — CRLF breaks the shebang line on
|
||||
# Linux ("bad interpreter: /bin/bash\r: No such file or directory"),
|
||||
# which silently fails the .deb / .rpm postinst hook (#395).
|
||||
*.sh text eol=lf
|
||||
@@ -0,0 +1,44 @@
|
||||
name: CI
|
||||
|
||||
# Runs the type checker and test suite on every PR and on pushes to main,
|
||||
# so broken code is caught before it merges. Mirrors the Node version and
|
||||
# install step already used by release.yml.
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
push:
|
||||
branches: [main]
|
||||
|
||||
# A newer commit on the same ref supersedes an in-flight run.
|
||||
concurrency:
|
||||
group: ci-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
check:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Node
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 22
|
||||
cache: npm
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm ci
|
||||
|
||||
- name: Typecheck
|
||||
run: npm run typecheck
|
||||
|
||||
- name: Test
|
||||
run: npm test
|
||||
|
||||
# Lint is informational for now — the codebase has a large backlog of
|
||||
# prettier/line-ending warnings (no errors). Surfaced but not gating,
|
||||
# so a warning backlog doesn't block merges.
|
||||
- name: Lint
|
||||
run: npm run lint
|
||||
continue-on-error: true
|
||||
@@ -0,0 +1,403 @@
|
||||
name: Release
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- release
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
dry_run:
|
||||
description: "Run all build jobs but skip publish (no tag, no GitHub Release)"
|
||||
type: boolean
|
||||
default: true
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
concurrency:
|
||||
group: release
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
prepare:
|
||||
name: Prepare Release
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
version: ${{ steps.version.outputs.version }}
|
||||
tag: ${{ steps.version.outputs.tag }}
|
||||
tag_exists: ${{ steps.check.outputs.exists }}
|
||||
is_dry_run: ${{ steps.mode.outputs.is_dry_run }}
|
||||
steps:
|
||||
- name: Check out repository
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Read version from package.json
|
||||
id: version
|
||||
run: |
|
||||
VERSION=$(node -p "require('./package.json').version")
|
||||
echo "version=$VERSION" >> "$GITHUB_OUTPUT"
|
||||
echo "tag=v$VERSION" >> "$GITHUB_OUTPUT"
|
||||
echo "Release version: v$VERSION"
|
||||
|
||||
- name: Check if tag already exists
|
||||
id: check
|
||||
run: |
|
||||
if git ls-remote --tags origin "refs/tags/v${{ steps.version.outputs.version }}" | grep -q .; then
|
||||
echo "exists=true" >> "$GITHUB_OUTPUT"
|
||||
echo "Tag v${{ steps.version.outputs.version }} already exists — skipping."
|
||||
else
|
||||
echo "exists=false" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
|
||||
- name: Compute dry-run flag
|
||||
id: mode
|
||||
run: |
|
||||
if [ "${{ github.event_name }}" = "workflow_dispatch" ] && [ "${{ inputs.dry_run }}" = "true" ]; then
|
||||
echo "is_dry_run=true" >> "$GITHUB_OUTPUT"
|
||||
echo "Dry run: builds will run, publish will be skipped."
|
||||
else
|
||||
echo "is_dry_run=false" >> "$GITHUB_OUTPUT"
|
||||
echo "Real release: publish will run if tag does not exist."
|
||||
fi
|
||||
|
||||
release_mac:
|
||||
name: Build macOS (${{ matrix.arch }})
|
||||
needs: prepare
|
||||
if: needs.prepare.outputs.tag_exists == 'false'
|
||||
runs-on: macos-latest
|
||||
strategy:
|
||||
matrix:
|
||||
arch: [x64, arm64]
|
||||
steps:
|
||||
- name: Check out repository
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 22
|
||||
cache: npm
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm ci
|
||||
|
||||
- name: Rebuild native dependencies for target arch
|
||||
run: npx electron-builder install-app-deps --arch=${{ matrix.arch }}
|
||||
|
||||
- name: Build app
|
||||
env:
|
||||
VITE_ANALYTICS_BASE_URL: ${{ vars.VITE_ANALYTICS_BASE_URL }}
|
||||
VITE_ANALYTICS_API_KEY: ${{ secrets.VITE_ANALYTICS_API_KEY }}
|
||||
MAIN_VITE_HERMES_API_URL: ${{ vars.HERMES_API_URL }}
|
||||
MAIN_VITE_HERMES_API_KEY: ${{ secrets.HERMES_API_KEY }}
|
||||
run: npm run build
|
||||
|
||||
- name: Stage App Store Connect API key
|
||||
env:
|
||||
ASC_API_KEY_BASE64: ${{ secrets.ASC_API_KEY }}
|
||||
ASC_KEY_ID: ${{ secrets.ASC_KEY_ID }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if [ -z "${ASC_API_KEY_BASE64:-}" ]; then
|
||||
echo "ASC_API_KEY secret is missing — notarization will fail." >&2
|
||||
exit 1
|
||||
fi
|
||||
KEY_DIR="$RUNNER_TEMP/appstore"
|
||||
mkdir -p "$KEY_DIR"
|
||||
KEY_PATH="$KEY_DIR/AuthKey_${ASC_KEY_ID}.p8"
|
||||
printf '%s' "$ASC_API_KEY_BASE64" | base64 --decode > "$KEY_PATH"
|
||||
chmod 600 "$KEY_PATH"
|
||||
echo "APPLE_API_KEY=$KEY_PATH" >> "$GITHUB_ENV"
|
||||
|
||||
- name: Package macOS artifacts
|
||||
env:
|
||||
CSC_LINK: ${{ secrets.CSC_LINK }}
|
||||
CSC_KEY_PASSWORD: ${{ secrets.CSC_KEY_PASSWORD }}
|
||||
APPLE_API_KEY_ID: ${{ secrets.ASC_KEY_ID }}
|
||||
APPLE_API_ISSUER: ${{ secrets.ASC_ISSUER_ID }}
|
||||
run: npx electron-builder --mac dmg zip --${{ matrix.arch }} --publish never
|
||||
|
||||
- name: Verify native module architecture
|
||||
run: |
|
||||
set -euo pipefail
|
||||
NODE_FILE="dist/mac-${{ matrix.arch }}/Hermes One.app/Contents/Resources/app.asar.unpacked/node_modules/better-sqlite3/build/Release/better_sqlite3.node"
|
||||
if [ ! -f "$NODE_FILE" ]; then
|
||||
NODE_FILE="dist/mac/Hermes One.app/Contents/Resources/app.asar.unpacked/node_modules/better-sqlite3/build/Release/better_sqlite3.node"
|
||||
fi
|
||||
file "$NODE_FILE"
|
||||
case "${{ matrix.arch }}" in
|
||||
x64) file "$NODE_FILE" | grep -q "x86_64" ;;
|
||||
arm64) file "$NODE_FILE" | grep -q "arm64" ;;
|
||||
esac
|
||||
|
||||
- name: Upload artifacts
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: mac-${{ matrix.arch }}-artifacts
|
||||
path: |
|
||||
dist/*.dmg
|
||||
dist/*.zip
|
||||
dist/*.blockmap
|
||||
|
||||
release_linux:
|
||||
name: Build Linux (${{ matrix.arch }})
|
||||
needs: prepare
|
||||
if: needs.prepare.outputs.tag_exists == 'false'
|
||||
runs-on: ${{ matrix.runs-on }}
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- arch: x64
|
||||
runs-on: ubuntu-latest
|
||||
targets: AppImage deb rpm
|
||||
- arch: arm64
|
||||
runs-on: ubuntu-24.04-arm
|
||||
targets: AppImage
|
||||
steps:
|
||||
- name: Check out repository
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 22
|
||||
cache: npm
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm ci
|
||||
|
||||
- name: Rebuild native dependencies for target arch
|
||||
run: npx electron-builder install-app-deps --arch=${{ matrix.arch }}
|
||||
|
||||
- name: Build app
|
||||
env:
|
||||
VITE_ANALYTICS_BASE_URL: ${{ vars.VITE_ANALYTICS_BASE_URL }}
|
||||
VITE_ANALYTICS_API_KEY: ${{ secrets.VITE_ANALYTICS_API_KEY }}
|
||||
MAIN_VITE_HERMES_API_URL: ${{ vars.HERMES_API_URL }}
|
||||
MAIN_VITE_HERMES_API_KEY: ${{ secrets.HERMES_API_KEY }}
|
||||
run: npm run build
|
||||
|
||||
- name: Install rpmbuild
|
||||
if: contains(matrix.targets, 'rpm')
|
||||
run: sudo apt-get update && sudo apt-get install -y rpm
|
||||
|
||||
- name: Package Linux artifacts
|
||||
run: npx electron-builder --linux ${{ matrix.targets }} --${{ matrix.arch }} --publish never
|
||||
|
||||
- name: Upload x64 artifacts
|
||||
if: matrix.arch == 'x64'
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: linux-x64-artifacts
|
||||
path: |
|
||||
dist/*.AppImage
|
||||
dist/*.deb
|
||||
dist/*.rpm
|
||||
dist/latest-linux.yml
|
||||
|
||||
- name: Upload arm64 artifacts
|
||||
if: matrix.arch == 'arm64'
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: linux-arm64-artifacts
|
||||
path: |
|
||||
dist/*.AppImage
|
||||
|
||||
release_windows:
|
||||
name: Build Windows
|
||||
needs: prepare
|
||||
if: needs.prepare.outputs.tag_exists == 'false'
|
||||
runs-on: windows-latest
|
||||
steps:
|
||||
- name: Check out repository
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 22
|
||||
cache: npm
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm ci
|
||||
|
||||
- name: Build app
|
||||
env:
|
||||
VITE_ANALYTICS_BASE_URL: ${{ vars.VITE_ANALYTICS_BASE_URL }}
|
||||
VITE_ANALYTICS_API_KEY: ${{ secrets.VITE_ANALYTICS_API_KEY }}
|
||||
MAIN_VITE_HERMES_API_URL: ${{ vars.HERMES_API_URL }}
|
||||
MAIN_VITE_HERMES_API_KEY: ${{ secrets.HERMES_API_KEY }}
|
||||
run: npm run build
|
||||
|
||||
- name: Package Windows artifacts
|
||||
run: npx electron-builder --win nsis portable --x64 --publish never
|
||||
|
||||
- name: Upload artifacts
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: windows-artifacts
|
||||
path: |
|
||||
dist/*.exe
|
||||
dist/*.exe.blockmap
|
||||
dist/latest.yml
|
||||
|
||||
generate_winget:
|
||||
name: Generate winget manifests
|
||||
needs: [prepare, release_windows]
|
||||
if: needs.prepare.outputs.tag_exists == 'false'
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Check out repository
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 22
|
||||
|
||||
- name: Download Windows installer artifact
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: windows-artifacts
|
||||
path: dist/
|
||||
|
||||
- name: Generate winget manifests
|
||||
env:
|
||||
VERSION: ${{ needs.prepare.outputs.version }}
|
||||
PUBLISH_OWNER: fathah
|
||||
run: node scripts/generate-winget-manifests.mjs
|
||||
|
||||
- name: Upload winget manifests artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: winget-manifests-${{ needs.prepare.outputs.version }}
|
||||
path: dist/winget/
|
||||
|
||||
publish:
|
||||
name: Publish Release
|
||||
needs:
|
||||
[prepare, release_mac, release_linux, release_windows, generate_winget]
|
||||
if: needs.prepare.outputs.is_dry_run == 'false' && needs.prepare.outputs.tag_exists == 'false'
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Check out repository
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Create tag
|
||||
run: |
|
||||
git config user.name "github-actions[bot]"
|
||||
git config user.email "github-actions[bot]@users.noreply.github.com"
|
||||
git tag ${{ needs.prepare.outputs.tag }}
|
||||
git push origin ${{ needs.prepare.outputs.tag }}
|
||||
|
||||
- name: Download all artifacts
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
path: artifacts
|
||||
merge-multiple: true
|
||||
|
||||
- name: Generate macOS update metadata
|
||||
env:
|
||||
VERSION: ${{ needs.prepare.outputs.version }}
|
||||
run: |
|
||||
node <<'NODE'
|
||||
const crypto = require("crypto");
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
|
||||
const artifactsDir = path.join(process.cwd(), "artifacts");
|
||||
const version = process.env.VERSION;
|
||||
const zipNames = fs
|
||||
.readdirSync(artifactsDir)
|
||||
.filter(
|
||||
(name) =>
|
||||
name.startsWith(`hermes-desktop-${version}-`) &&
|
||||
name.endsWith("-mac.zip"),
|
||||
)
|
||||
.sort((a, b) => {
|
||||
if (a.includes("-x64-")) return -1;
|
||||
if (b.includes("-x64-")) return 1;
|
||||
return a.localeCompare(b);
|
||||
});
|
||||
|
||||
if (
|
||||
zipNames.length < 2 ||
|
||||
!zipNames.some((name) => name.includes("-x64-")) ||
|
||||
!zipNames.some((name) => name.includes("-arm64-"))
|
||||
) {
|
||||
throw new Error(
|
||||
`Expected x64 and arm64 macOS zips, found: ${zipNames.join(", ")}`,
|
||||
);
|
||||
}
|
||||
|
||||
const files = zipNames.map((name) => {
|
||||
const filePath = path.join(artifactsDir, name);
|
||||
return {
|
||||
url: name,
|
||||
sha512: crypto
|
||||
.createHash("sha512")
|
||||
.update(fs.readFileSync(filePath))
|
||||
.digest("base64"),
|
||||
size: fs.statSync(filePath).size,
|
||||
};
|
||||
});
|
||||
const primary = files.find((file) => file.url.includes("-x64-")) || files[0];
|
||||
const releaseDate = new Date().toISOString();
|
||||
const lines = [
|
||||
`version: ${version}`,
|
||||
"files:",
|
||||
...files.flatMap((file) => [
|
||||
` - url: ${file.url}`,
|
||||
` sha512: ${file.sha512}`,
|
||||
` size: ${file.size}`,
|
||||
]),
|
||||
`path: ${primary.url}`,
|
||||
`sha512: ${primary.sha512}`,
|
||||
`releaseDate: '${releaseDate}'`,
|
||||
"",
|
||||
];
|
||||
|
||||
fs.writeFileSync(path.join(artifactsDir, "latest-mac.yml"), lines.join("\n"));
|
||||
NODE
|
||||
|
||||
- name: Publish GitHub release
|
||||
uses: softprops/action-gh-release@v2
|
||||
with:
|
||||
tag_name: ${{ needs.prepare.outputs.tag }}
|
||||
name: Hermes Desktop ${{ needs.prepare.outputs.tag }}
|
||||
generate_release_notes: true
|
||||
files: |
|
||||
artifacts/*.dmg
|
||||
artifacts/*.zip
|
||||
artifacts/*.AppImage
|
||||
artifacts/*.deb
|
||||
artifacts/*.rpm
|
||||
artifacts/*.exe
|
||||
artifacts/*.blockmap
|
||||
artifacts/latest.yml
|
||||
artifacts/latest-linux.yml
|
||||
artifacts/latest-mac.yml
|
||||
|
||||
rebuild_landing_page:
|
||||
name: Rebuild landing page
|
||||
needs: [prepare, publish]
|
||||
# Only after a real release that actually published a new tag.
|
||||
if: needs.prepare.outputs.is_dry_run == 'false' && needs.prepare.outputs.tag_exists == 'false'
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Wait for release to propagate
|
||||
# Give GitHub's releases/latest API time to settle before the landing
|
||||
# page rebuilds and re-fetches the latest version.
|
||||
run: sleep 300
|
||||
|
||||
- name: Trigger landing page deploy
|
||||
# SITE_DEPLOY_TOKEN is a fine-grained PAT with "Actions: read and write"
|
||||
# on fathah/hermes-landing-page. The default GITHUB_TOKEN cannot dispatch
|
||||
# workflows in another repository.
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.SITE_DEPLOY_TOKEN }}
|
||||
run: |
|
||||
gh workflow run deploy.yml \
|
||||
--repo fathah/hermes-landing-page \
|
||||
--ref main
|
||||
@@ -0,0 +1,43 @@
|
||||
.DS_Store
|
||||
.idea/
|
||||
|
||||
# Dependencies
|
||||
node_modules/
|
||||
|
||||
# Build output
|
||||
dist/
|
||||
out/
|
||||
|
||||
# TypeScript incremental build info
|
||||
*.tsbuildinfo
|
||||
|
||||
# Logs
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
|
||||
# Environment files
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
|
||||
# ESLint cache
|
||||
.eslintcache
|
||||
|
||||
# Local isolated Hermes homes for development/tests
|
||||
.sandbox/
|
||||
|
||||
# Electron packaging artifacts
|
||||
release/
|
||||
.claude/worktrees
|
||||
|
||||
# Tauri
|
||||
**/src-tauri/target/
|
||||
**/src-tauri/gen/
|
||||
**/src-tauri/WixTools/
|
||||
|
||||
# Local launcher scripts (user-specific, do not commit)
|
||||
/*.bat
|
||||
/*.ps1
|
||||
.mcp.json
|
||||
@@ -0,0 +1,25 @@
|
||||
BRANCH=$(git symbolic-ref --short HEAD 2>/dev/null)
|
||||
if [[ "$BRANCH" != "release" && "$BRANCH" != release/* ]]; then
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Git hooks run with a minimal PATH that omits user-local tool dirs, so bun
|
||||
# (installed under ~/.bun/bin) isn't found. Add common locations, then prefer
|
||||
# bun and fall back to npm.
|
||||
export PATH="$HOME/.bun/bin:$HOME/.local/bin:/opt/homebrew/bin:/usr/local/bin:$PATH"
|
||||
|
||||
if command -v bun >/dev/null 2>&1; then
|
||||
RUN="bun run"
|
||||
elif command -v npm >/dev/null 2>&1; then
|
||||
RUN="npm run"
|
||||
else
|
||||
echo "✗ Neither bun nor npm found on PATH. Commit aborted."; exit 1
|
||||
fi
|
||||
|
||||
echo "→ [pre-commit] Release branch — running lint..."
|
||||
$RUN lint || { echo "✗ Lint failed. Commit aborted."; exit 1; }
|
||||
|
||||
echo "→ [pre-commit] Running tests..."
|
||||
$RUN test || { echo "✗ Tests failed. Commit aborted."; exit 1; }
|
||||
|
||||
echo "✓ [pre-commit] Lint and tests passed."
|
||||
@@ -0,0 +1,22 @@
|
||||
BRANCH=$(git symbolic-ref --short HEAD 2>/dev/null)
|
||||
if [[ "$BRANCH" != "release" && "$BRANCH" != release/* ]]; then
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Git hooks run with a minimal PATH that omits user-local tool dirs, so bun
|
||||
# (installed under ~/.bun/bin) isn't found. Add common locations, then prefer
|
||||
# bun and fall back to npm.
|
||||
export PATH="$HOME/.bun/bin:$HOME/.local/bin:/opt/homebrew/bin:/usr/local/bin:$PATH"
|
||||
|
||||
if command -v bun >/dev/null 2>&1; then
|
||||
RUN="bun run"
|
||||
elif command -v npm >/dev/null 2>&1; then
|
||||
RUN="npm run"
|
||||
else
|
||||
echo "✗ Neither bun nor npm found on PATH. Push aborted."; exit 1
|
||||
fi
|
||||
|
||||
echo "→ [pre-push] Release branch — running build..."
|
||||
$RUN build || { echo "✗ Build failed. Push aborted."; exit 1; }
|
||||
|
||||
echo "✓ [pre-push] Build passed."
|
||||
@@ -0,0 +1,105 @@
|
||||
# Before starting work
|
||||
|
||||
- Run `lat search` to find sections relevant to your task. Read them to understand the design intent before writing code.
|
||||
- Run `lat expand` on user prompts to expand any `[[refs]]` — this resolves section names to file locations and provides context.
|
||||
|
||||
# Post-task checklist (REQUIRED — do not skip)
|
||||
|
||||
After EVERY task, before responding to the user:
|
||||
|
||||
- [ ] Update `lat.md/` if you added or changed any functionality, architecture, tests, or behavior
|
||||
- [ ] Run `lat check` — all wiki links and code refs must pass
|
||||
- [ ] Do not skip these steps. Do not consider your task done until both are complete.
|
||||
|
||||
---
|
||||
|
||||
# What is lat.md?
|
||||
|
||||
This project uses [lat.md](https://www.npmjs.com/package/lat.md) to maintain a structured knowledge graph of its architecture, design decisions, and test specs in the `lat.md/` directory. It is a set of cross-linked markdown files that describe **what** this project does and **why** — the domain concepts, key design decisions, business logic, and test specifications. Use it to ground your work in the actual architecture rather than guessing.
|
||||
|
||||
# Commands
|
||||
|
||||
```bash
|
||||
lat locate "Section Name" # find a section by name (exact, fuzzy)
|
||||
lat refs "file#Section" # find what references a section
|
||||
lat search "natural language" # semantic search across all sections
|
||||
lat expand "user prompt text" # expand [[refs]] to resolved locations
|
||||
lat check # validate all links and code refs
|
||||
```
|
||||
|
||||
Run `lat --help` when in doubt about available commands or options.
|
||||
|
||||
If `lat search` fails because no API key is configured, explain to the user that semantic search requires a key provided via `LAT_LLM_KEY` (direct value), `LAT_LLM_KEY_FILE` (path to key file), or `LAT_LLM_KEY_HELPER` (command that prints the key). Supported key prefixes: `sk-...` (OpenAI) or `vck_...` (Vercel). If the user doesn't want to set it up, use `lat locate` for direct lookups instead.
|
||||
|
||||
# Syntax primer
|
||||
|
||||
- **Section ids**: `lat.md/path/to/file#Heading#SubHeading` — full form uses project-root-relative path (e.g. `lat.md/tests/search#RAG Replay Tests`). Short form uses bare file name when unique (e.g. `search#RAG Replay Tests`, `cli#search#Indexing`).
|
||||
- **Wiki links**: `[[target]]` or `[[target|alias]]` — cross-references between sections. Can also reference source code: `[[src/foo.ts#myFunction]]`.
|
||||
- **Source code links**: Wiki links in `lat.md/` files can reference functions, classes, constants, and methods in TypeScript/JavaScript/Python/Rust/Go/C files. Use the full path: `[[src/config.ts#getConfigDir]]`, `[[src/server.ts#App#listen]]` (class method), `[[lib/utils.py#parse_args]]`, `[[src/lib.rs#Greeter#greet]]` (Rust impl method), `[[src/app.go#Greeter#Greet]]` (Go method), `[[src/app.h#Greeter]]` (C struct). `lat check` validates these exist.
|
||||
- **Code refs**: `// @lat: [[section-id]]` (JS/TS/Rust/Go/C) or `# @lat: [[section-id]]` (Python) — ties source code to concepts
|
||||
|
||||
# Test specs
|
||||
|
||||
Key tests can be described as sections in `lat.md/` files (e.g. `tests.md`). Add frontmatter to require that every leaf section is referenced by a `// @lat:` or `# @lat:` comment in test code:
|
||||
|
||||
```markdown
|
||||
---
|
||||
lat:
|
||||
require-code-mention: true
|
||||
---
|
||||
# Tests
|
||||
|
||||
Authentication and authorization test specifications.
|
||||
|
||||
## User login
|
||||
|
||||
Verify credential validation and error handling for the login endpoint.
|
||||
|
||||
### Rejects expired tokens
|
||||
Tokens past their expiry timestamp are rejected with 401, even if otherwise valid.
|
||||
|
||||
### Handles missing password
|
||||
Login request without a password field returns 400 with a descriptive error.
|
||||
```
|
||||
|
||||
Every section MUST have a description — at least one sentence explaining what the test verifies and why. Empty sections with just a heading are not acceptable. (This is a specific case of the general leading paragraph rule below.)
|
||||
|
||||
Each test in code should reference its spec with exactly one comment placed next to the relevant test — not at the top of the file:
|
||||
|
||||
```python
|
||||
# @lat: [[tests#User login#Rejects expired tokens]]
|
||||
def test_rejects_expired_tokens():
|
||||
...
|
||||
|
||||
# @lat: [[tests#User login#Handles missing password]]
|
||||
def test_handles_missing_password():
|
||||
...
|
||||
```
|
||||
|
||||
Do not duplicate refs. One `@lat:` comment per spec section, placed at the test that covers it. `lat check` will flag any spec section not covered by a code reference, and any code reference pointing to a nonexistent section.
|
||||
|
||||
# Section structure
|
||||
|
||||
Every section in `lat.md/` **must** have a leading paragraph — at least one sentence immediately after the heading, before any child headings or other block content. The first paragraph must be ≤250 characters (excluding `[[wiki link]]` content). This paragraph serves as the section's overview and is used in search results, command output, and RAG context — keeping it concise guarantees the section's essence is always captured.
|
||||
|
||||
```markdown
|
||||
# Good Section
|
||||
|
||||
Brief overview of what this section documents and why it matters.
|
||||
|
||||
More detail can go in subsequent paragraphs, code blocks, or lists.
|
||||
|
||||
## Child heading
|
||||
|
||||
Details about this child topic.
|
||||
```
|
||||
|
||||
```markdown
|
||||
# Bad Section
|
||||
|
||||
## Child heading
|
||||
|
||||
Details about this child topic.
|
||||
```
|
||||
|
||||
The second example is invalid because `Bad Section` has no leading paragraph. `lat check` validates this rule and reports errors for missing or overly long leading paragraphs.
|
||||
@@ -0,0 +1,105 @@
|
||||
# Before starting work
|
||||
|
||||
- Run `lat search` to find sections relevant to your task. Read them to understand the design intent before writing code.
|
||||
- Run `lat expand` on user prompts to expand any `[[refs]]` — this resolves section names to file locations and provides context.
|
||||
|
||||
# Post-task checklist (REQUIRED — do not skip)
|
||||
|
||||
After EVERY task, before responding to the user:
|
||||
|
||||
- [ ] Update `lat.md/` if you added or changed any functionality, architecture, tests, or behavior
|
||||
- [ ] Run `lat check` — all wiki links and code refs must pass
|
||||
- [ ] Do not skip these steps. Do not consider your task done until both are complete.
|
||||
|
||||
---
|
||||
|
||||
# What is lat.md?
|
||||
|
||||
This project uses [lat.md](https://www.npmjs.com/package/lat.md) to maintain a structured knowledge graph of its architecture, design decisions, and test specs in the `lat.md/` directory. It is a set of cross-linked markdown files that describe **what** this project does and **why** — the domain concepts, key design decisions, business logic, and test specifications. Use it to ground your work in the actual architecture rather than guessing.
|
||||
|
||||
# Commands
|
||||
|
||||
```bash
|
||||
lat locate "Section Name" # find a section by name (exact, fuzzy)
|
||||
lat refs "file#Section" # find what references a section
|
||||
lat search "natural language" # semantic search across all sections
|
||||
lat expand "user prompt text" # expand [[refs]] to resolved locations
|
||||
lat check # validate all links and code refs
|
||||
```
|
||||
|
||||
Run `lat --help` when in doubt about available commands or options.
|
||||
|
||||
If `lat search` fails because no API key is configured, explain to the user that semantic search requires a key provided via `LAT_LLM_KEY` (direct value), `LAT_LLM_KEY_FILE` (path to key file), or `LAT_LLM_KEY_HELPER` (command that prints the key). Supported key prefixes: `sk-...` (OpenAI) or `vck_...` (Vercel). If the user doesn't want to set it up, use `lat locate` for direct lookups instead.
|
||||
|
||||
# Syntax primer
|
||||
|
||||
- **Section ids**: `lat.md/path/to/file#Heading#SubHeading` — full form uses project-root-relative path (e.g. `lat.md/tests/search#RAG Replay Tests`). Short form uses bare file name when unique (e.g. `search#RAG Replay Tests`, `cli#search#Indexing`).
|
||||
- **Wiki links**: `[[target]]` or `[[target|alias]]` — cross-references between sections. Can also reference source code: `[[src/foo.ts#myFunction]]`.
|
||||
- **Source code links**: Wiki links in `lat.md/` files can reference functions, classes, constants, and methods in TypeScript/JavaScript/Python/Rust/Go/C files. Use the full path: `[[src/config.ts#getConfigDir]]`, `[[src/server.ts#App#listen]]` (class method), `[[lib/utils.py#parse_args]]`, `[[src/lib.rs#Greeter#greet]]` (Rust impl method), `[[src/app.go#Greeter#Greet]]` (Go method), `[[src/app.h#Greeter]]` (C struct). `lat check` validates these exist.
|
||||
- **Code refs**: `// @lat: [[section-id]]` (JS/TS/Rust/Go/C) or `# @lat: [[section-id]]` (Python) — ties source code to concepts
|
||||
|
||||
# Test specs
|
||||
|
||||
Key tests can be described as sections in `lat.md/` files (e.g. `tests.md`). Add frontmatter to require that every leaf section is referenced by a `// @lat:` or `# @lat:` comment in test code:
|
||||
|
||||
```markdown
|
||||
---
|
||||
lat:
|
||||
require-code-mention: true
|
||||
---
|
||||
# Tests
|
||||
|
||||
Authentication and authorization test specifications.
|
||||
|
||||
## User login
|
||||
|
||||
Verify credential validation and error handling for the login endpoint.
|
||||
|
||||
### Rejects expired tokens
|
||||
Tokens past their expiry timestamp are rejected with 401, even if otherwise valid.
|
||||
|
||||
### Handles missing password
|
||||
Login request without a password field returns 400 with a descriptive error.
|
||||
```
|
||||
|
||||
Every section MUST have a description — at least one sentence explaining what the test verifies and why. Empty sections with just a heading are not acceptable. (This is a specific case of the general leading paragraph rule below.)
|
||||
|
||||
Each test in code should reference its spec with exactly one comment placed next to the relevant test — not at the top of the file:
|
||||
|
||||
```python
|
||||
# @lat: [[tests#User login#Rejects expired tokens]]
|
||||
def test_rejects_expired_tokens():
|
||||
...
|
||||
|
||||
# @lat: [[tests#User login#Handles missing password]]
|
||||
def test_handles_missing_password():
|
||||
...
|
||||
```
|
||||
|
||||
Do not duplicate refs. One `@lat:` comment per spec section, placed at the test that covers it. `lat check` will flag any spec section not covered by a code reference, and any code reference pointing to a nonexistent section.
|
||||
|
||||
# Section structure
|
||||
|
||||
Every section in `lat.md/` **must** have a leading paragraph — at least one sentence immediately after the heading, before any child headings or other block content. The first paragraph must be ≤250 characters (excluding `[[wiki link]]` content). This paragraph serves as the section's overview and is used in search results, command output, and RAG context — keeping it concise guarantees the section's essence is always captured.
|
||||
|
||||
```markdown
|
||||
# Good Section
|
||||
|
||||
Brief overview of what this section documents and why it matters.
|
||||
|
||||
More detail can go in subsequent paragraphs, code blocks, or lists.
|
||||
|
||||
## Child heading
|
||||
|
||||
Details about this child topic.
|
||||
```
|
||||
|
||||
```markdown
|
||||
# Bad Section
|
||||
|
||||
## Child heading
|
||||
|
||||
Details about this child topic.
|
||||
```
|
||||
|
||||
The second example is invalid because `Bad Section` has no leading paragraph. `lat check` validates this rule and reports errors for missing or overly long leading paragraphs.
|
||||
@@ -0,0 +1,104 @@
|
||||
# Hermes Desktop へのコントリビューション
|
||||
|
||||
Hermes Desktop へのコントリビューションに興味を持っていただきありがとうございます!バグ修正、新機能、ドキュメント改善、ちょっとしたタイポ修正まで、どんな貢献も歓迎します。
|
||||
|
||||
## 言語
|
||||
|
||||
- English: `CONTRIBUTING.md`
|
||||
- 简体中文: `CONTRIBUTING.zh-CN.md`
|
||||
- 日本語: `CONTRIBUTING.ja-JP.md`
|
||||
|
||||
## はじめに
|
||||
|
||||
1. リポジトリを **Fork** し、ローカルにクローンします。
|
||||
2. **依存関係をインストール:**
|
||||
|
||||
```bash
|
||||
npm install
|
||||
```
|
||||
|
||||
3. **開発モードでアプリを起動:**
|
||||
|
||||
```bash
|
||||
npm run dev
|
||||
```
|
||||
|
||||
## 変更を加える
|
||||
|
||||
1. `main` から新しいブランチを作成します。
|
||||
|
||||
```bash
|
||||
git checkout -b your-branch-name
|
||||
```
|
||||
|
||||
2. 変更を加えます。コミットは目的を絞って — 1 つの論理的な変更につき 1 つのコミットを心掛けてください。
|
||||
|
||||
3. 提出前にチェックを実行します。
|
||||
|
||||
```bash
|
||||
npm run lint
|
||||
npm run typecheck
|
||||
```
|
||||
|
||||
4. `npm run dev` でローカル動作確認を行い、期待通りに動くことを確認してください。
|
||||
|
||||
## プルリクエストの提出
|
||||
|
||||
1. ブランチを自分の fork に push します。
|
||||
2. 上流リポジトリの `main` に対してプルリクエストを開きます。
|
||||
3. 変更内容とその理由を明確に記述してください。
|
||||
4. PR が Open Issue に対応する場合は、参照を記述してください(例: `Fixes #42`)。
|
||||
|
||||
### プルリクエストは小さく保つ
|
||||
|
||||
PR は小さく目的を絞ってください — その方がレビューもマージもずっと容易になります。多くのファイルに触れる PR や、無関係な変更をまとめた PR は、分割を依頼されたり、受け入れられない場合があります。
|
||||
|
||||
- 1 つの PR につき 1 つの論理的な変更(1 つの修正、1 つの機能、1 つのリファクタリング)に絞ってください。
|
||||
- 無関係なファイルを多数触っていることに気づいたら、作業を複数の PR に分割してください。
|
||||
- フォーマット / スタイルの一括変更を機能変更と混在させないでください。
|
||||
- 小さな PR ほど、レビューとマージが速くなります。
|
||||
|
||||
メンテナが PR をレビューし、変更を依頼する場合があります。承認されるとマージされます。
|
||||
|
||||
## バグ報告
|
||||
|
||||
バグを見つけた場合は、以下の情報を添えて [Issue を作成してください](https://github.com/fathah/hermes-desktop/issues/new)。
|
||||
|
||||
- 明確なタイトルと説明
|
||||
- 再現手順
|
||||
- 期待される動作と実際の動作
|
||||
- 必要に応じて OS とアプリのバージョン
|
||||
|
||||
## 機能リクエスト
|
||||
|
||||
アイデアがある場合は、[Issue を作成して](https://github.com/fathah/hermes-desktop/issues/new)以下を記述してください。
|
||||
|
||||
- 解決したい問題
|
||||
- どのように動作してほしいか
|
||||
- 検討した代替案
|
||||
|
||||
## プロジェクト構成
|
||||
|
||||
```text
|
||||
src/main/ Electron メインプロセス、IPC ハンドラ、Hermes 統合
|
||||
src/preload/ セキュアな renderer ブリッジ
|
||||
src/renderer/src/ React アプリと UI コンポーネント
|
||||
resources/ アプリアイコンとパッケージ用アセット
|
||||
build/ パッケージング用リソース
|
||||
```
|
||||
|
||||
## コードスタイル
|
||||
|
||||
- 本プロジェクトでは TypeScript、React、Electron を使用しています。
|
||||
- Lint エラーを確認するには `npm run lint` を実行してください。
|
||||
- 型の安全性を検証するには `npm run typecheck` を実行してください。
|
||||
- コードベース内の既存のパターンや慣習に従ってください。
|
||||
|
||||
## コミュニティ
|
||||
|
||||
- 他のコントリビューターと話すには [Nous Research Discord](https://discord.gg/NousResearch) に参加してください。
|
||||
- Hermes の動作についての詳細は[ドキュメント](https://hermes-agent.nousresearch.com/docs/)を参照してください。
|
||||
|
||||
## ライセンス
|
||||
|
||||
コントリビューションをいただいた時点で、その内容が [MIT License](LICENSE) の下でライセンスされることに同意したものとみなします。
|
||||
@@ -0,0 +1,104 @@
|
||||
# Contributing to Hermes Desktop
|
||||
|
||||
Thanks for your interest in contributing to Hermes Desktop! Whether it's a bug fix, a new feature, improved docs, or just a typo — every contribution helps.
|
||||
|
||||
## Languages
|
||||
|
||||
- English: `CONTRIBUTING.md`
|
||||
- 简体中文: `CONTRIBUTING.zh-CN.md`
|
||||
- 日本語: `CONTRIBUTING.ja-JP.md`
|
||||
|
||||
## Getting Started
|
||||
|
||||
1. **Fork** the repository and clone your fork locally.
|
||||
2. **Install dependencies:**
|
||||
|
||||
```bash
|
||||
npm install
|
||||
```
|
||||
|
||||
3. **Start the app in development mode:**
|
||||
|
||||
```bash
|
||||
npm run dev
|
||||
```
|
||||
|
||||
## Making Changes
|
||||
|
||||
1. Create a new branch from `main`:
|
||||
|
||||
```bash
|
||||
git checkout -b your-branch-name
|
||||
```
|
||||
|
||||
2. Make your changes. Keep commits focused — one logical change per commit.
|
||||
|
||||
3. Run checks before submitting:
|
||||
|
||||
```bash
|
||||
npm run lint
|
||||
npm run typecheck
|
||||
```
|
||||
|
||||
4. Test your changes locally with `npm run dev` to make sure everything works as expected.
|
||||
|
||||
## Submitting a Pull Request
|
||||
|
||||
1. Push your branch to your fork.
|
||||
2. Open a pull request against `main` on the upstream repo.
|
||||
3. Write a clear description of what you changed and why.
|
||||
4. If your PR addresses an open issue, reference it (e.g., `Fixes #42`).
|
||||
|
||||
### Keep Pull Requests Small
|
||||
|
||||
Please keep PRs small and focused — they are much easier to review and merge. PRs that touch too many files or bundle unrelated changes will likely be asked for splitting up or may not be accepted.
|
||||
|
||||
- Stick to one logical change per PR (one fix, one feature, one refactor).
|
||||
- If you find yourself touching many unrelated files, split the work into multiple PRs.
|
||||
- Avoid bundling formatting/style sweeps with functional changes.
|
||||
- Smaller PRs get reviewed and merged faster.
|
||||
|
||||
A maintainer will review your PR and may request changes. Once approved, it will be merged.
|
||||
|
||||
## Reporting Bugs
|
||||
|
||||
Found a bug? [Open an issue](https://github.com/fathah/hermes-desktop/issues/new) with:
|
||||
|
||||
- A clear title and description.
|
||||
- Steps to reproduce the issue.
|
||||
- What you expected to happen vs. what actually happened.
|
||||
- Your OS and app version, if relevant.
|
||||
|
||||
## Requesting Features
|
||||
|
||||
Have an idea? [Open an issue](https://github.com/fathah/hermes-desktop/issues/new) and describe:
|
||||
|
||||
- The problem you're trying to solve.
|
||||
- How you'd like it to work.
|
||||
- Any alternatives you've considered.
|
||||
|
||||
## Project Structure
|
||||
|
||||
```text
|
||||
src/main/ Electron main process, IPC handlers, Hermes integration
|
||||
src/preload/ Secure renderer bridge
|
||||
src/renderer/src/ React app and UI components
|
||||
resources/ App icons and packaged assets
|
||||
build/ Packaging resources
|
||||
```
|
||||
|
||||
## Code Style
|
||||
|
||||
- The project uses TypeScript, React, and Electron.
|
||||
- Run `npm run lint` to check for lint errors.
|
||||
- Run `npm run typecheck` to verify type safety.
|
||||
- Follow existing patterns and conventions in the codebase.
|
||||
|
||||
## Community
|
||||
|
||||
- Join the [Nous Research Discord](https://discord.gg/NousResearch) to chat with other contributors.
|
||||
- Check the [documentation](https://hermes-agent.nousresearch.com/docs/) for more context on how Hermes works.
|
||||
|
||||
## License
|
||||
|
||||
By contributing, you agree that your contributions will be licensed under the [MIT License](LICENSE).
|
||||
@@ -0,0 +1,104 @@
|
||||
# 为 Hermes Desktop 做贡献
|
||||
|
||||
感谢你愿意为 Hermes Desktop 做出贡献。无论是修复 bug、添加新功能、完善文档,还是修正一个拼写错误,每一份贡献都很有价值。
|
||||
|
||||
## 语言
|
||||
|
||||
- 英文:`CONTRIBUTING.md`
|
||||
- 简体中文:`CONTRIBUTING.zh-CN.md`
|
||||
- 日本語:`CONTRIBUTING.ja-JP.md`
|
||||
|
||||
## 快速开始
|
||||
|
||||
1. **Fork** 本仓库,并将你的 fork 克隆到本地。
|
||||
2. **安装依赖:**
|
||||
|
||||
```bash
|
||||
npm install
|
||||
```
|
||||
|
||||
3. **以开发模式启动应用:**
|
||||
|
||||
```bash
|
||||
npm run dev
|
||||
```
|
||||
|
||||
## 修改代码
|
||||
|
||||
1. 从 `main` 创建新分支:
|
||||
|
||||
```bash
|
||||
git checkout -b your-branch-name
|
||||
```
|
||||
|
||||
2. 完成你的改动。请保持提交聚焦,每个 commit 只做一类逻辑改动。
|
||||
|
||||
3. 提交前先运行检查:
|
||||
|
||||
```bash
|
||||
npm run lint
|
||||
npm run typecheck
|
||||
```
|
||||
|
||||
4. 使用 `npm run dev` 在本地测试改动,确保行为符合预期。
|
||||
|
||||
## 提交 Pull Request
|
||||
|
||||
1. 将分支推送到你的 fork。
|
||||
2. 在上游仓库中向 `main` 发起 Pull Request。
|
||||
3. 清楚描述你改了什么,以及为什么这样改。
|
||||
4. 如果你的 PR 解决了某个已有 issue,请在描述中引用它(例如:`Fixes #42`)。
|
||||
|
||||
### 保持 Pull Request 精简
|
||||
|
||||
请保持 PR 小而聚焦——这样更容易审核和合并。触及过多文件或捆绑了不相关改动的 PR 可能会被要求拆分,甚至可能不被接受。
|
||||
|
||||
- 每个 PR 只做一类逻辑改动(一个修复、一个功能、一次重构)。
|
||||
- 如果你发现自己改了很多不相关的文件,请将工作拆分成多个 PR。
|
||||
- 避免将格式化/样式改动与功能改动混在一起提交。
|
||||
- 更小的 PR 能更快得到审核和合并。
|
||||
|
||||
维护者会审核你的 PR,并可能提出修改建议。审核通过后,PR 会被合并。
|
||||
|
||||
## 报告 Bug
|
||||
|
||||
如果你发现了 bug,请在 GitHub 上 [提交 issue](https://github.com/fathah/hermes-desktop/issues/new),并尽量包含:
|
||||
|
||||
- 清晰的标题和描述
|
||||
- 复现步骤
|
||||
- 预期行为与实际行为
|
||||
- 你的操作系统和应用版本(如果相关)
|
||||
|
||||
## 功能请求
|
||||
|
||||
如果你有新想法,也欢迎 [提交 issue](https://github.com/fathah/hermes-desktop/issues/new),并描述:
|
||||
|
||||
- 你想解决的问题
|
||||
- 你希望它如何工作
|
||||
- 你考虑过的替代方案
|
||||
|
||||
## 项目结构
|
||||
|
||||
```text
|
||||
src/main/ Electron 主进程、IPC 处理器、Hermes 集成
|
||||
src/preload/ 安全的 renderer bridge
|
||||
src/renderer/src/ React 应用和 UI 组件
|
||||
resources/ 应用图标和打包资源
|
||||
build/ 打包配置资源
|
||||
```
|
||||
|
||||
## 代码风格
|
||||
|
||||
- 项目使用 TypeScript、React 和 Electron。
|
||||
- 运行 `npm run lint` 检查 lint 错误。
|
||||
- 运行 `npm run typecheck` 验证类型安全。
|
||||
- 尽量遵循当前仓库现有模式和约定。
|
||||
|
||||
## 社区
|
||||
|
||||
- 欢迎加入 [Nous Research Discord](https://discord.gg/NousResearch),与其他贡献者交流。
|
||||
- 也可以查看 [文档](https://hermes-agent.nousresearch.com/docs/) 了解 Hermes 的整体工作方式。
|
||||
|
||||
## 许可证
|
||||
|
||||
通过提交贡献,即表示你同意你的贡献将按照 [MIT License](LICENSE) 授权。
|
||||
@@ -0,0 +1,48 @@
|
||||
## Development
|
||||
|
||||
### Prerequisites
|
||||
|
||||
- Node.js and npm
|
||||
- A Unix-like shell environment for the Hermes installer
|
||||
- Network access for downloading Hermes during first-run install
|
||||
|
||||
### Install dependencies
|
||||
|
||||
```bash
|
||||
npm install
|
||||
```
|
||||
|
||||
### Start the app in development
|
||||
|
||||
```bash
|
||||
npm run dev
|
||||
```
|
||||
|
||||
### Run checks
|
||||
|
||||
```bash
|
||||
npm run lint
|
||||
npm run typecheck
|
||||
```
|
||||
|
||||
### Run tests
|
||||
|
||||
```bash
|
||||
npm run test
|
||||
npm run test:watch
|
||||
```
|
||||
|
||||
### Build the desktop app
|
||||
|
||||
```bash
|
||||
npm run build
|
||||
```
|
||||
|
||||
Platform packaging:
|
||||
|
||||
```bash
|
||||
npm run build:mac
|
||||
npm run build:win
|
||||
npm run build:linux
|
||||
npm run build:rpm # Fedora/RHEL .rpm only
|
||||
```
|
||||
@@ -0,0 +1,113 @@
|
||||
# Kanban — Reference vs. hermes-desktop: Report & Plan
|
||||
|
||||
_Generated 2026-06-21. Sources: `hermes-agent` docs + `kanban_db.py` + `plugins/kanban/dashboard/plugin_api.py`; our `src/main/kanban.ts` + `src/renderer/src/screens/Kanban/Kanban.tsx`._
|
||||
|
||||
## 1. What Kanban is (in hermes-agent)
|
||||
|
||||
Hermes Kanban is a **durable, SQLite-backed task board** (`~/.hermes/kanban.db`, WAL mode) for coordinating multiple named agent profiles. It is the heavier sibling of `delegate_task`:
|
||||
|
||||
| | `delegate_task` | Kanban |
|
||||
|---|---|---|
|
||||
| Shape | RPC (fork→join) | Durable queue + state machine |
|
||||
| Parent | Blocks | Fire-and-forget |
|
||||
| Child | Anonymous subagent | Named profile w/ persistent memory |
|
||||
| Resumable | No | Block→unblock→re-run; crash→reclaim |
|
||||
| Human-in-loop | No | Comment / unblock anytime |
|
||||
| Audit | Lost on compression | Durable SQLite rows |
|
||||
|
||||
**Three surfaces, one `kanban_db` core (cannot drift):**
|
||||
1. **Agents** drive it via `kanban_*` tools (`kanban_show/list/complete/block/heartbeat/comment/create/link/unblock`).
|
||||
2. **Humans/scripts/cron** drive it via `hermes kanban …` CLI and `/kanban` slash command.
|
||||
3. **Dashboard plugin** (FastAPI + React SPA) at `plugins/kanban/` — REST under `/api/plugins/kanban/`, live `task_events` WebSocket.
|
||||
|
||||
**Key concepts:** Board (isolated queue, multi-project) · Task · Link (parent→child dependency, promotes `todo→ready` when all parents `done`) · Comment (inter-agent protocol) · Workspace (`scratch` / `dir:<abs>` / `worktree`) · Dispatcher (gateway-embedded loop, 60s tick: reclaim stale/crashed, promote, claim, spawn) · Tenant (soft namespace within a board).
|
||||
|
||||
**Notable mechanics:** auto-decompose of triage tasks, goal-mode cards (Ralph loop + judge), circuit-breaker (`failure_limit`, auto-block after N spawn failures), respawn guard (`blocker_auth`/`recent_success`/`active_pr`), `scheduled_at` delayed dispatch, diagnostics rule-engine, attachments (25 MB cap), Kanban Swarm topology helper.
|
||||
|
||||
## 2. Canonical statuses (the "kanban words")
|
||||
|
||||
From `kanban_db.VALID_STATUSES`:
|
||||
|
||||
```
|
||||
triage · todo · scheduled · ready · running · blocked · review · done · archived
|
||||
```
|
||||
|
||||
Dashboard `BOARD_COLUMNS` (plugin_api.py): `triage, todo, scheduled, ready, running, blocked, review, done` (+ `archived` via toggle).
|
||||
|
||||
`VALID_INITIAL_STATUSES = {running, blocked}`.
|
||||
|
||||
### Allowed status transitions (from plugin PATCH `/tasks/:id`)
|
||||
- `done` → `complete_task(result, summary, metadata)`
|
||||
- `blocked` → `block_task(reason)`
|
||||
- `scheduled` → `schedule_task(reason)`
|
||||
- `ready` → if currently `blocked`/`scheduled` → `unblock_task`; else direct set (drag-drop `todo→ready`); **refused if any parent not `done`** (409 names the blocking parent)
|
||||
- `archived` → `archive_task`
|
||||
- `running` → **rejected** ("use the dispatcher/claim path")
|
||||
- `todo` / `triage` / `scheduled` → direct set
|
||||
- Reopening a `done`/`archived` parent demotes stale-`ready` children back to `todo`.
|
||||
|
||||
## 3. Actions / API surface
|
||||
|
||||
### Canonical CLI verbs (`hermes_cli/kanban.py`)
|
||||
`init, create, list, show, assign, reassign, edit, promote, schedule, diagnostics, link, unlink, claim, comment, complete, block, unblock, archive, tail, watch, heartbeat, runs, assignees, dispatch, daemon, stats, log, notify-subscribe, notify-list, notify-unsubscribe, context, specify, decompose, swarm, gc`, plus `boards {list,create,rm,switch,show,rename,set-workdir}`.
|
||||
|
||||
### Dashboard REST (representative)
|
||||
`GET /board` · `GET/POST/PATCH/DELETE /tasks[/:id]` · `POST /tasks/bulk` · attachments CRUD · `POST /tasks/:id/comments` · `POST /tasks/:id/{specify,decompose}` · `GET/PATCH /profiles[/:name]` + `/describe-auto` · `GET/PUT /orchestration` · `POST/DELETE /links` · `POST /dispatch` · `GET /diagnostics` · `GET /workers/active` · `GET /runs/:id[/inspect]` · `POST /runs/:id/terminate` · `GET /config` · `WS /events`.
|
||||
|
||||
> Note: the **reference desktop app** (`hermes-agent/apps/desktop`) has **no kanban board UI** — only `/kanban` as a slash-command string and a notifications comment. The rich UI reference is the **dashboard plugin**, not that app.
|
||||
|
||||
## 4. What our hermes-desktop has today
|
||||
|
||||
**Main (`src/main/kanban.ts`)** — execs `hermes kanban` (local) or SSH-tunnels (`sshRunKanban`); blocks plain-remote mode. Exposes:
|
||||
`listBoards, currentBoard, switchBoard, createBoard, removeBoard, listTasks, getTask, createTask, assignTask, completeTask, blockTask, unblockTask, archiveTask, specifyTask, reclaimTask, commentTask, listClaw3dHqTasks, dispatchOnce`.
|
||||
|
||||
**Renderer (`Kanban.tsx`)** — 6 columns `triage, todo, ready, running, blocked, done`; 6s poll; board switcher (+ read-only Claw3D HQ virtual board); create-task modal; create-board modal; read-only detail modal (body/summary/result/comments/events). Card actions: specify, mark-done, reclaim, unblock, block, archive. Drag-drop transitions: `→done`, `→blocked` (from todo/ready/running), `blocked→ready`.
|
||||
|
||||
## 5. Gap analysis (reference → ours)
|
||||
|
||||
### A. Status coverage
|
||||
| Status | Canonical | Our columns | Gap |
|
||||
|---|---|---|---|
|
||||
| triage,todo,ready,running,blocked,done | ✓ | ✓ | — |
|
||||
| **scheduled** | ✓ | ✗ | **Missing column** — `scheduled` tasks fall through to `todo` bucket (`Kanban.tsx:322`). |
|
||||
| **review** | ✓ | ✗ | **Missing column** — `review` tasks mis-bucket to `todo`. |
|
||||
| archived | ✓ (toggle) | ✗ | No "show archived" toggle in UI (main supports `includeArchived`). |
|
||||
|
||||
### B. Actions defined in main but NOT surfaced in UI
|
||||
- `assignTask` / reassign — no reassign control in detail modal.
|
||||
- `commentTask` — detail modal shows comments **read-only**; no compose box.
|
||||
- `removeBoard` — no delete-board affordance.
|
||||
|
||||
### C. Canonical actions NOT wired at all (no main fn, no UI)
|
||||
- **`decompose`** — the headline triage flow (fan-out to child graph). We only have `specify`.
|
||||
- **`promote`** (todo/blocked→ready recovery), **`schedule`** (`scheduled_at`), **`edit`** (title/body/priority in place — we can create but not edit), **`link`/`unlink`** (dependency editing), **`diagnostics`**, **`runs`** (attempt history — `KanbanRun` typed but not fetched standalone), **`assignees`/`stats`**, **`notify-*`**, **`swarm`**, **`gc`**, **`tail`/`watch`**, **`heartbeat`**, **boards `rename`/`set-workdir`**, **attachments** (upload/list/download/delete).
|
||||
|
||||
### D. UX / behavior gaps vs dashboard plugin
|
||||
- **Polling vs live** — we poll every 6s; plugin tails `task_events` over WebSocket (instant, debounced). No WS bridge in our main.
|
||||
- **No dependency / progress UI** — no parent/child chips, no `N/M` progress pill, no link editor.
|
||||
- **No diagnostics surfacing** — no distress badges (hallucination/crash/stuck-blocked) the plugin renders.
|
||||
- **No bulk multi-select** actions.
|
||||
- **No orchestration controls** — Auto/Manual decompose pill, profile-description editor, orchestrator settings.
|
||||
- **Transition rules are re-implemented client-side** (`isValidDragTransition`) and narrower than the backend — risk of drift; e.g. no `→scheduled`, no `→review`, no `→ready` from `todo` via drag.
|
||||
- **Detail modal is read-only** — can't edit title/body/priority/assignee or add comments/links inline.
|
||||
|
||||
## 6. Recommended plan (phased)
|
||||
|
||||
### Phase 1 — Correctness (low effort, high value)
|
||||
1. Add `scheduled` + `review` to `COLUMNS` and `en/kanban.ts` `status.*` (and other locales). Fixes silent mis-bucketing.
|
||||
2. Add a **"show archived"** toggle (main already supports `includeArchived`).
|
||||
3. Wire **comment compose** in the detail modal (`commentTask` already in main).
|
||||
4. Wire **reassign** in the detail modal (`assignTask` already in main).
|
||||
|
||||
### Phase 2 — Parity actions
|
||||
5. Add main fns + UI for **`decompose`**, **`edit`** (title/body/priority), **`promote`**, **`schedule`** (`--at`), **`link`/`unlink`** (dependency editor with parent/child chips + progress pill).
|
||||
6. Add **`runs`/diagnostics`** read views in the detail drawer (attempt history, distress badges).
|
||||
|
||||
### Phase 3 — Live + richer UX
|
||||
7. Replace 6s poll with a **`task_events` tail** (SSH `kanban watch --json` stream, or local tail) for instant updates.
|
||||
8. **Bulk multi-select** actions; **orchestration** Auto/Manual + profile descriptions; **attachments**.
|
||||
|
||||
### Cross-cutting
|
||||
- Prefer routing transitions through backend verbs rather than widening the client-side `isValidDragTransition`, to avoid drift from `kanban_db` rules.
|
||||
- Keep SSH-tunnel + remote-unsupported guards on every new verb (existing pattern in `kanban.ts`).
|
||||
- Each new IPC verb needs: `kanban.ts` fn → `ipc/register.ts` → `preload/index.ts` (+ `.d.ts`) → renderer.
|
||||
@@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2026 github.com/fathah
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
@@ -0,0 +1,102 @@
|
||||
# Profile Modal — Handoff
|
||||
|
||||
A global, reusable profile detail/settings modal for Hermes Desktop (Electron + React renderer). Built on branch `pr/746`. This doc is a self-contained handoff so another agent can continue the work.
|
||||
|
||||
## What it is
|
||||
|
||||
A single **80vw × 80vh** modal, mounted once at the app root, opened from anywhere via a context hook. It has a **left section-nav** (Profile / Wallet / Advanced) and a scrollable right content pane. It replaces the old inline "appearance" modal that used to live inside the Agents screen.
|
||||
|
||||
It was built to grow — the user has "more plans with profile" (e.g. a real Wallet screen).
|
||||
|
||||
## Files
|
||||
|
||||
| File | Role |
|
||||
| --- | --- |
|
||||
| `src/renderer/src/components/profile/ProfileModal.tsx` | The modal UI (header, left nav, panes, mutations). |
|
||||
| `src/renderer/src/components/profile/ProfileModalProvider.tsx` | Mounts the modal at app root; holds open state. |
|
||||
| `src/renderer/src/components/profile/ProfileModalContext.ts` | Context + `useProfileModal()` hook + `OpenProfileOptions` type. |
|
||||
| `src/renderer/src/App.tsx` | `<ProfileModalProvider>` wraps the app (inside `FontProvider`). |
|
||||
| `src/renderer/src/screens/Layout/ProfileSwitcher.tsx` | Sidebar popover; active profile button calls `openProfile`. |
|
||||
| `src/renderer/src/screens/Agents/Agents.tsx` | Profiles screen; pencil/card edit calls `openProfile` (inline modal removed). |
|
||||
| `src/renderer/src/assets/main.css` | All `.profile-modal-*` styles (search that prefix). |
|
||||
| `src/renderer/src/assets/icons/index.tsx` | Re-exports lucide icons; added `User`, `Wallet`. |
|
||||
| `src/shared/i18n/locales/en/agents.ts` | i18n keys (English source; see i18n note). |
|
||||
| `lat.md/sidebar-navigation.md` | Architecture doc, section "Profile detail modal" (keep in sync). |
|
||||
|
||||
## How to open it
|
||||
|
||||
```ts
|
||||
import { useProfileModal } from "../../components/profile/ProfileModalContext";
|
||||
|
||||
const { openProfile } = useProfileModal();
|
||||
|
||||
openProfile("fatha", {
|
||||
onChanged: reloadMyList, // called after every successful mutation
|
||||
onDeleted: (name) => { /* e.g. fall back to "default" if it was active */ },
|
||||
});
|
||||
```
|
||||
|
||||
The modal **self-loads** its data via `window.hermesAPI.listProfiles()` (there is no single-profile IPC) and re-reads after every mutation, so callers only need `onChanged`/`onDeleted` to refresh their own lists.
|
||||
|
||||
## Current structure
|
||||
|
||||
**Header:** small `ProfileAvatar` (28px) + the profile **name only** (no "Edit {name}") + close (X).
|
||||
|
||||
**Left nav** (`PROFILE_SECTIONS` in `ProfileModal.tsx`), each item = icon + label:
|
||||
- **Profile** (`User` icon) — large avatar + gateway dot, name + `default` tag, Upload/Remove image, the provider/model/skills/gateway chips, and the color swatches.
|
||||
- **Wallet** (`Wallet` icon) — **placeholder only**: centered "Coming soon" (`.profile-modal-coming-soon`). Not yet implemented.
|
||||
- **Advanced** (`Settings` icon) — the Delete Profile danger zone.
|
||||
|
||||
**Behaviors:**
|
||||
- Every profile is editable (avatar + color), **including `default`**.
|
||||
- Only `default` **cannot be deleted** — its Advanced pane shows `agents.defaultNotDeletable` instead of the delete button.
|
||||
- Dismiss: overlay click, Escape, close (X), or Done button.
|
||||
|
||||
## Profile data shape (`listProfiles()`)
|
||||
|
||||
```ts
|
||||
{ name, path, isDefault, isActive, model, provider, hasEnv, hasSoul,
|
||||
skillCount, gatewayRunning, color?, avatar? }
|
||||
```
|
||||
|
||||
## IPC available on `window.hermesAPI` (no new IPC was added)
|
||||
|
||||
- `listProfiles()` → `ProfileInfo[]`
|
||||
- `setProfileColor(name, color)` → `{ success, error? }`
|
||||
- `setProfileAvatar(name, dataUrl)` → `{ success, error? }`
|
||||
- `removeProfileAvatar(name)` → `{ success, error? }`
|
||||
- `deleteProfile(name)` → `{ success, error? }`
|
||||
- `createProfile(name, clone)`, `setActiveProfile(name)` (used elsewhere)
|
||||
|
||||
Avatar files are converted with `fileToAvatarDataUrl` (`src/renderer/src/utils/imageResize.ts`). Colors come from `PROFILE_COLORS` (`src/shared/profileColors.ts`).
|
||||
|
||||
## How to add a new section (e.g. build out Wallet)
|
||||
|
||||
1. Add the id to the `ProfileSection` union and an entry to `PROFILE_SECTIONS` (`{ id, labelKey, Icon }`) in `ProfileModal.tsx`.
|
||||
2. Add a `{section === "<id>" && (<div className="profile-modal-pane"> … </div>)}` block in the content area.
|
||||
3. Add the nav label key to `src/shared/i18n/locales/en/agents.ts` (e.g. `sectionWallet`).
|
||||
4. Style with existing `.profile-modal-*` classes or add new ones in `main.css`.
|
||||
|
||||
## Project conventions (important)
|
||||
|
||||
- **i18n**: source/fallback locale is `en`. New UI strings go in `src/shared/i18n/locales/en/agents.ts`; the other 10 locales (`es, he, id, ja, pl, pt-BR, pt-PT, tr, zh-CN, zh-TW`) **fall back to en automatically** (`FALLBACK_LOCALE` in `src/shared/i18n/index.ts`), so you don't have to edit them to ship — translate later.
|
||||
- **lat.md sync (required)**: this repo uses [lat.md]. After any code change, update the relevant section in `lat.md/` and run `lat check` (a Stop hook enforces this). The profile modal is documented in `lat.md/sidebar-navigation.md` → "Profile detail modal". Wiki links like `[[src/.../ProfileModal.tsx#ProfileModal]]` must resolve.
|
||||
- **Node for tooling**: the repo's `.nvmrc` pins Node 21, but `vitest`/typecheck need **Node ≥22** (`nvm use 22.19.0`) — Node 21 fails to load `vitest.config.ts` (`ERR_REQUIRE_ESM`).
|
||||
- **react-refresh**: keep hooks/context in `.ts` files separate from the provider component (`.tsx`) — that's why context/hook and provider are split.
|
||||
|
||||
## Verify
|
||||
|
||||
```bash
|
||||
nvm use 22.19.0
|
||||
npm run typecheck:web
|
||||
npx eslint src/renderer/src/components/profile/*.ts src/renderer/src/components/profile/*.tsx
|
||||
lat check
|
||||
```
|
||||
|
||||
Manual (`npm run dev`, Node ≥22): sidebar profile popover → click the active profile → modal opens; switch nav sections; edit color/avatar (reflects live in sidebar + Agents); Wallet shows "Coming soon"; Advanced deletes non-default profiles (default shows the not-deletable note).
|
||||
|
||||
## Known open items / ideas
|
||||
|
||||
- **Wallet** is a stub — needs the real screen + likely new IPC for wallet/balance.
|
||||
- Advanced currently holds only Delete; the user mentioned "wallet etc. in advanced … add later," so more settings can live there.
|
||||
- No automated tests yet for `ProfileModal` (the dashboard adapter has tests as a pattern to follow under `src/renderer/src/screens/Chat/dashboardEventAdapter.test.ts`).
|
||||
@@ -0,0 +1,350 @@
|
||||
<img width="100%" alt="HERMES DESKTOP" src="previews/header.webp" />
|
||||
|
||||
<br/>
|
||||
<p align="center">
|
||||
<a href="https://x.com/HermesOneApp"><img src="https://img.shields.io/badge/Síguenos-000000?style=for-the-badge&logo=x" alt="Twitter/X"></a>
|
||||
<a href="https://discord.gg/Fqu72h8z"><img src="https://img.shields.io/badge/Discord-5865F2?style=for-the-badge&logo=discord&logoColor=white" alt="Discord"></a>
|
||||
<a href="https://github.com/fathah/hermes-desktop/blob/main/LICENSE"><img src="https://img.shields.io/badge/Licencia-MIT-green?style=for-the-badge" alt="Licencia: MIT"></a>
|
||||
<a href="https://hermesone.org/"><img src="https://img.shields.io/badge/Descargar-Releases-FF6600?style=for-the-badge" alt="Releases"></a>
|
||||
<a href="https://github.com/fathah/hermes-desktop/stargazers">
|
||||
<img src="https://img.shields.io/github/stars/fathah/hermes-desktop?style=for-the-badge&color=FFD700&label=Estrellas" alt="Estrellas">
|
||||
</a>
|
||||
<a href="https://github.com/fathah/hermes-desktop/releases/">
|
||||
<img src="https://img.shields.io/github/downloads/fathah/hermes-desktop/total?style=for-the-badge&color=00B496&label=Descargas%20Totales" alt="Descargas">
|
||||
</a>
|
||||
<a href="https://bankr.bot/launches/0xfda75f77a22b4f4b783bbbb21915ef64d149bba3">
|
||||
<img src="https://img.shields.io/badge/Token-$HD-purple?style=for-the-badge&logo=ethereum" alt="Token $HD">
|
||||
</a>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<a href="README.md">English</a> ·
|
||||
<a href="README.zh-CN.md">简体中文</a> ·
|
||||
<a href="README.ja-JP.md">日本語</a> ·
|
||||
<a href="README.es-LATAM.md">Español (LATAM)</a>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<a href="https://www.star-history.com/fathah/hermes-desktop">
|
||||
<picture>
|
||||
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/badge?repo=fathah/hermes-desktop&theme=dark" />
|
||||
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/badge?repo=fathah/hermes-desktop" />
|
||||
<img alt="Star History Rank" src="https://api.star-history.com/badge?repo=fathah/hermes-desktop" />
|
||||
</picture>
|
||||
</a>
|
||||
</p>
|
||||
|
||||
> **Este proyecto está en desarrollo activo.** Las funciones pueden cambiar y algunas cosas podrían no funcionar perfectamente. Si encuentras un problema o tienes una idea, [abre un issue](https://github.com/fathah/hermes-desktop/issues). ¡Las contribuciones son bienvenidas!
|
||||
|
||||
Hermes Desktop es una aplicación nativa de escritorio para instalar, configurar y chatear con [Hermes Agent](https://github.com/NousResearch/hermes-agent) — un asistente de IA con autoaprendizaje, uso de herramientas, mensajería multiplataforma y un ciclo de aprendizaje cerrado.
|
||||
|
||||
En lugar de manejar el CLI a mano, la app guía todo el proceso de instalación, configuración de proveedores y uso diario en un solo lugar. Usa el script oficial de instalación de Hermes, guarda los archivos en `~/.hermes` y te da una GUI para chat, sesiones, perfiles, memoria, habilidades, herramientas, tareas programadas, gateways de mensajería y más.
|
||||
|
||||
## Patrocinadores
|
||||
|
||||
<a href="https://www.atlascloud.ai/?utm_source=github&utm_medium=link&utm_campaign=hermes-desktop" target="_blank" rel="noopener noreferrer">
|
||||
<img src="src/renderer/src/assets/logos/atlascloud.svg" alt="Atlas Cloud" height="100" style="display: block;">
|
||||
</a>
|
||||
|
||||
> **[Atlas Cloud](https://www.atlascloud.ai/?utm_source=github&utm_medium=link&utm_campaign=hermes-desktop)** es una plataforma de inferencia de IA full-modal compatible con OpenAI (DeepSeek, Qwen, GLM, Kimi, MiniMax y más). Úsala en Hermes Desktop seleccionando **Atlas Cloud** como tu proveedor — la URL base se configura automáticamente.
|
||||
|
||||
## Instalación
|
||||
|
||||
<a href="https://hermesone.org/"><img width="380" alt="Descargar ahora" src="previews/download.webp" /></a>
|
||||
|
||||
### Windows
|
||||
|
||||
> **Usuarios de Windows:** El instalador no tiene firma de código. Windows SmartScreen mostrará una advertencia al primer lanzamiento — haz clic en "Más información" → "Ejecutar de todas formas".
|
||||
|
||||
> **Usuarios de WSL:** Si el instalador se queda colgado en `Switching to root user to install dependencies...`, Playwright está esperando una contraseña de sudo que no tiene TTY para leerla. Otorga sudo sin contraseña para la instalación y reviértelo al terminar:
|
||||
>
|
||||
> ```bash
|
||||
> echo "$USER ALL=(ALL) NOPASSWD: ALL" | sudo tee /etc/sudoers.d/hermes-install
|
||||
> # …vuelve a ejecutar el instalador; cuando termine:
|
||||
> sudo rm /etc/sudoers.d/hermes-install
|
||||
> ```
|
||||
>
|
||||
> Seguimiento en [#109](https://github.com/fathah/hermes-desktop/issues/109).
|
||||
|
||||
### Fedora (RPM)
|
||||
|
||||
```bash
|
||||
sudo dnf install ./hermes-desktop-<version>.rpm
|
||||
```
|
||||
|
||||
> **Usuarios de Fedora:** El `.rpm` no tiene firma GPG. Si tu sistema exige verificación de firma, agrega `--nogpgcheck` al comando de instalación. La actualización automática no está disponible para builds `.rpm` (limitación de `electron-updater`); reinstala el nuevo `.rpm` para actualizar.
|
||||
|
||||
## Vista previa
|
||||
|
||||
<table>
|
||||
<tr>
|
||||
<td width="50%" align="center"><b>Chat</b><br/><img width="100%" alt="Chat" src="previews/chat.png" /></td>
|
||||
<td width="50%" align="center"><b>Perfiles</b><br/><img width="100%" alt="Profiles" src="previews/profiles.png" /></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td width="50%" align="center"><b>Modelos</b><br/><img width="100%" alt="Models" src="previews/models.png" /></td>
|
||||
<td width="50%" align="center"><b>Proveedores</b><br/><img width="100%" alt="Providers" src="previews/providers.png" /></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td width="50%" align="center"><b>Herramientas</b><br/><img width="100%" alt="Tools" src="previews/tools.png" /></td>
|
||||
<td width="50%" align="center"><b>Habilidades</b><br/><img width="100%" alt="Skills" src="previews/skills.png" /></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td width="50%" align="center"><b>Tareas programadas</b><br/><img width="100%" alt="Schedules" src="previews/schedules.png" /></td>
|
||||
<td width="50%" align="center"><b>Gateway</b><br/><img width="100%" alt="Gateway" src="previews/gateway.png" /></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td width="50%" align="center"><b>Persona</b><br/><img width="100%" alt="Persona" src="previews/persona.png" /></td>
|
||||
<td width="50%" align="center"><b>Kanban</b><br/><img width="100%" alt="Kanban" src="previews/kanban.png" /></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td width="50%" align="center"><b>Oficina</b><br/><img width="100%" alt="Office" src="previews/office.png" /></td>
|
||||
<td width="50%" align="center"><b>Configuración</b><br/><img width="100%" alt="Settings" src="previews/settings.png" /></td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
## Funcionalidades
|
||||
|
||||
- **Instalación guiada en el primer uso** de Hermes Agent con seguimiento de progreso y resolución de dependencias
|
||||
- **Backend local o remoto** — ejecuta Hermes localmente en `127.0.0.1:8642`, o conecta la app a un servidor remoto con URL + clave API
|
||||
- **Soporte multi-proveedor** — OpenRouter, Anthropic, OpenAI, Google (Gemini), xAI (Grok), Nous Portal, Qwen, MiniMax, Hugging Face, Groq y endpoints compatibles con OpenAI (LM Studio, Atomic Chat, Ollama, vLLM, llama.cpp)
|
||||
- **UI de chat con streaming** con SSE, indicadores de progreso de herramientas, renderizado de Markdown y resaltado de sintaxis
|
||||
- **Seguimiento de tokens** — conteo en tiempo real de tokens de entrada/salida y costo en el pie del chat, más el comando `/usage`
|
||||
- **22 comandos slash** — `/new`, `/clear`, `/fast`, `/web`, `/image`, `/browse`, `/code`, `/shell`, `/usage`, `/help`, `/tools`, `/skills`, `/model`, `/memory`, `/persona`, `/version`, `/compact`, `/compress`, `/undo`, `/retry`, `/debug`, `/status` y más
|
||||
- **Gestión de sesiones** — búsqueda de texto completo (SQLite FTS5), historial agrupado por fecha, reanudar y buscar conversaciones
|
||||
- **Cambio de perfiles** — crea, elimina y cambia entre entornos Hermes con configuración aislada
|
||||
- **14 conjuntos de herramientas** — web, navegador, terminal, archivos, ejecución de código, visión, generación de imágenes, TTS, habilidades, memoria, búsqueda de sesiones, clarificación, delegación, MoA y planificación de tareas
|
||||
- **Sistema de memoria** — ver/editar entradas de memoria, perfil de usuario, seguimiento de capacidad y proveedores de memoria (Honcho, Hindsight, Mem0, RetainDB, Supermemory, ByteRover)
|
||||
- **Editor de Persona** — edita y restablece el archivo SOUL.md de personalidad de tu agente
|
||||
- **Modelos guardados** — gestión CRUD de configuraciones de modelos por proveedor
|
||||
- **Tareas programadas** — constructor de cron jobs (minutos, cada hora, diario, semanal, cron personalizado) con 15 destinos de entrega
|
||||
- **16 gateways de mensajería** — Telegram, Discord, Slack, WhatsApp, Signal, Matrix, Mattermost, Email (IMAP/SMTP), SMS (Twilio/Vonage), iMessage (BlueBubbles), DingTalk, Feishu/Lark, WeCom, WeChat (iLink Bot), Webhooks, Home Assistant
|
||||
- **Hermes Office (Claw3d)** — interfaz visual 3D con servidor de desarrollo y gestión de adaptadores
|
||||
- **Backup, importar y diagnóstico** — respaldo/restauración completa y diagnóstico del sistema desde Configuración
|
||||
- **Visor de logs** — visualiza logs de gateway y agente directamente desde la pantalla de Configuración
|
||||
- **Actualizador automático** — verifica e instala actualizaciones vía electron-updater
|
||||
- **Listo para i18n** — framework de internacionalización con localización en inglés para todas las pantallas, listo para traducciones de la comunidad
|
||||
- **Suite de pruebas** — parser SSE, handlers IPC, superficie de API preload, utilidades del instalador y validación de constantes con Vitest
|
||||
|
||||
## Cómo funciona
|
||||
|
||||
Al primer lanzamiento, la app:
|
||||
|
||||
1. Pregunta si deseas ejecutar Hermes **localmente** o conectarte a un **servidor remoto**.
|
||||
2. **Modo local:** verifica si Hermes ya está instalado en `~/.hermes`; si no, ejecuta el instalador oficial con resolución de dependencias (Git, uv, Python 3.11+).
|
||||
3. **Modo remoto:** solicita la URL de la API remota y la clave API, valida la conexión y omite la instalación local.
|
||||
4. Solicita un proveedor de API o endpoint de modelo local.
|
||||
5. Guarda la configuración del proveedor y las claves API en los archivos de configuración de Hermes.
|
||||
6. Lanza el workspace principal una vez completada la configuración.
|
||||
|
||||
En modo local, las solicitudes de chat van por `http://127.0.0.1:8642` con streaming SSE. En modo remoto, la app se comunica con tu URL remota configurada con el mismo protocolo de streaming. La app parsea el stream en tiempo real, renderizando progreso de herramientas, contenido Markdown y uso de tokens a medida que llegan.
|
||||
|
||||
## Pantallas
|
||||
|
||||
| Pantalla | Descripción |
|
||||
| ----------------- | -------------------------------------------------------------------------------------------------- |
|
||||
| **Chat** | UI de conversación con streaming, comandos slash, progreso de herramientas y seguimiento de tokens |
|
||||
| **Sesiones** | Navega, busca y reanuda conversaciones pasadas |
|
||||
| **Agentes** | Crea, elimina y cambia entre perfiles de Hermes |
|
||||
| **Habilidades** | Navega, instala y gestiona habilidades incluidas e instaladas |
|
||||
| **Modelos** | Gestiona configuraciones de modelos guardadas por proveedor |
|
||||
| **Memoria** | Ver/editar entradas de memoria, perfil de usuario y configurar proveedores de memoria |
|
||||
| **Soul** | Edita la persona del perfil activo (SOUL.md) |
|
||||
| **Herramientas** | Activa o desactiva conjuntos de herramientas individuales |
|
||||
| **Programadas** | Crea y gestiona cron jobs con destinos de entrega |
|
||||
| **Gateway** | Configura y controla integraciones de plataformas de mensajería |
|
||||
| **Oficina** | Configuración y gestión de la interfaz visual Claw3d |
|
||||
| **Configuración** | Config de proveedor, pools de credenciales, backup/importar, visor de logs, red, tema |
|
||||
|
||||
## Proveedores soportados
|
||||
|
||||
### Patrocinadores
|
||||
|
||||
| Proveedor | Notas |
|
||||
| --------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
|
||||
| **Atlas Cloud** | Gateway compatible con OpenAI — DeepSeek, Qwen, GLM, Kimi, MiniMax y más ([atlascloud.ai](https://www.atlascloud.ai/?utm_source=github&utm_medium=link&utm_campaign=hermes-desktop)) |
|
||||
|
||||
### Proveedores de LLM
|
||||
|
||||
| Proveedor | Notas |
|
||||
| ------------------- | ---------------------------------------- |
|
||||
| **OpenRouter** | 200+ modelos vía API única (recomendado) |
|
||||
| **Anthropic** | Acceso directo a Claude |
|
||||
| **OpenAI** | Acceso directo a GPT |
|
||||
| **Google (Gemini)** | Google AI Studio |
|
||||
| **xAI (Grok)** | Modelos Grok |
|
||||
| **Nous Portal** | Capa gratuita disponible |
|
||||
| **Qwen** | Modelos QwenAI |
|
||||
| **MiniMax** | Endpoints globales y de China |
|
||||
| **Hugging Face** | 20+ modelos abiertos vía HF Inference |
|
||||
| **Groq** | Inferencia rápida (voz/STT) |
|
||||
| **Local/Custom** | Cualquier endpoint compatible con OpenAI |
|
||||
|
||||
Los presets locales incluyen LM Studio, Atomic Chat, Ollama, vLLM y llama.cpp.
|
||||
|
||||
### Plataformas de mensajería
|
||||
|
||||
Telegram, Discord, Slack, WhatsApp, Signal, Matrix/Element, Mattermost, Email (IMAP/SMTP), SMS (Twilio y Vonage), iMessage (BlueBubbles), DingTalk, Feishu/Lark, WeCom, WeChat (iLink Bot), Webhooks y Home Assistant.
|
||||
|
||||
### Integraciones de herramientas
|
||||
|
||||
Exa Search, Parallel API, Tavily, Firecrawl, FAL.ai (generación de imágenes), Honcho, Browserbase, Weights & Biases y Tinker.
|
||||
|
||||
## Desarrollo
|
||||
|
||||
### Requisitos previos
|
||||
|
||||
- Node.js y npm
|
||||
- Un entorno tipo Unix para el instalador de Hermes
|
||||
- Acceso a internet para descargar Hermes en la primera instalación
|
||||
|
||||
### Instalar dependencias
|
||||
|
||||
```bash
|
||||
npm install
|
||||
```
|
||||
|
||||
### Iniciar la app en desarrollo
|
||||
|
||||
```bash
|
||||
npm run dev
|
||||
```
|
||||
|
||||
### Ejecutar verificaciones
|
||||
|
||||
```bash
|
||||
npm run lint
|
||||
npm run typecheck
|
||||
```
|
||||
|
||||
### Ejecutar pruebas
|
||||
|
||||
```bash
|
||||
npm run test
|
||||
npm run test:watch
|
||||
```
|
||||
|
||||
### Construir la app de escritorio
|
||||
|
||||
```bash
|
||||
npm run build
|
||||
```
|
||||
|
||||
Empaquetado por plataforma:
|
||||
|
||||
```bash
|
||||
npm run build:mac
|
||||
npm run build:win
|
||||
npm run build:linux
|
||||
npm run build:rpm # Solo .rpm para Fedora/RHEL
|
||||
```
|
||||
|
||||
## Configuración inicial
|
||||
|
||||
Cuando la app se abre por primera vez, detectará una instalación existente de Hermes o se ofrecerá a instalarla.
|
||||
|
||||
Rutas de configuración soportadas en la UI:
|
||||
|
||||
- `OpenRouter`
|
||||
- `Anthropic`
|
||||
- `OpenAI`
|
||||
- `Local LLM` vía URL base compatible con OpenAI
|
||||
|
||||
Los presets locales incluyen:
|
||||
|
||||
- LM Studio
|
||||
- Atomic Chat
|
||||
- Ollama
|
||||
- vLLM
|
||||
- llama.cpp
|
||||
|
||||
Los archivos de Hermes se gestionan en:
|
||||
|
||||
- `~/.hermes`
|
||||
- `~/.hermes/.env`
|
||||
- `~/.hermes/config.yaml`
|
||||
- `~/.hermes/hermes-agent`
|
||||
- `~/.hermes/profiles/` — directorios de perfiles con nombre
|
||||
- `~/.hermes/state.db` — base de datos del historial de sesiones
|
||||
- `~/.hermes/cron/jobs.json` — tareas programadas
|
||||
|
||||
## Proveedor de secretos
|
||||
|
||||
Por defecto, las claves API viven en `~/.hermes/.env` (el proveedor **env**). No se necesita configuración — este es el comportamiento histórico y nada cambia para ti.
|
||||
|
||||
Si prefieres no guardar claves en un `.env` en texto plano, el proveedor **command** (de activación opcional) las resuelve ejecutando un comando auxiliar que tú configuras. El orden de resolución en todos lados es: `process.env` → `.env` → proveedor → no definida.
|
||||
|
||||
Helper por clave (el nombre de la clave solicitada llega como `$HERMES_SECRET_KEY`):
|
||||
|
||||
```yaml
|
||||
# ~/.hermes/config.yaml
|
||||
secrets:
|
||||
provider: command
|
||||
command: secret-tool lookup hermes "$HERMES_SECRET_KEY"
|
||||
```
|
||||
|
||||
O un helper que vuelca un bloque dotenv (por ejemplo, un vault que se descifra en tmpfs):
|
||||
|
||||
```yaml
|
||||
secrets:
|
||||
provider: command
|
||||
command: "cat /run/user/1000/hermes-secrets.env"
|
||||
```
|
||||
|
||||
La salida del helper puede ser un valor único (helpers por clave) o líneas `KEY=VALUE` (volcados dotenv); ambos formatos se detectan automáticamente.
|
||||
|
||||
### Integración con vault / gestor de secretos (sin TPM requerido)
|
||||
|
||||
El proveedor `command` es **agnóstico al vault** — ejecuta el helper que configures y lee su stdout. El helper es lo único que necesita comunicarse con tu almacén de secretos. Si no tienes un keyfile sellado con TPM, cualquiera de estas opciones funciona sin cambios en el código de Hermes:
|
||||
|
||||
- **KeePassXC (BD solo con contraseña, sin keyfile):** apunta `secrets.command` a un pequeño script `kpxc-export.sh` que hace `keepassxc-cli ls ~/secrets/hermes.kdbx <<<"$KPXC_PASSWORD"` y vuelca el grupo relevante como dotenv. Pide la contraseña maestra una vez por sesión.
|
||||
- **GnuPG con clave solo de contraseña:** `gpg --batch --passphrase-fd 0 --decrypt ~/.keys/api-keys.gpg` funciona directamente como valor de `command`. Pasa la contraseña vía descriptor de archivo o variable de entorno, nunca como argumento.
|
||||
- **`pass` (el gestor de contraseñas unix estándar):** `command: "pass show hermes/$HERMES_SECRET_KEY"` para helper por clave, o un script wrapper para volcado dotenv.
|
||||
- **`secret-tool` (libsecret/Gnome Keyring):** `command: "secret-tool lookup hermes $HERMES_SECRET_KEY"` (ya mostrado arriba como ejemplo canónico por clave).
|
||||
- **Bitwarden CLI:** `bw get item "$HERMES_SECRET_KEY" | jq -r .notes` (después de `bw unlock` en la sesión).
|
||||
- **1Password CLI:** `op read "op://vault/$HERMES_SECRET_KEY/credential"`.
|
||||
- **Archivo env plano con permisos gestionados:** `command: "cat ~/.config/hermes/secrets.env"` con `chmod 600` y el archivo propiedad de tu usuario. No tan seguro como un vault, pero mejor que un `.env` legible por todos.
|
||||
|
||||
El punto: **cualquier helper que imprima un valor (por clave) o un bloque dotenv (modo lista) en stdout funcionará**, y Hermes impone un timeout de 3 segundos y un límite de 1 MiB de salida al helper. El proveedor no hace suposiciones sobre TPM, FIDO2, tarjetas inteligentes ni keychains de plataforma.
|
||||
|
||||
Modelo de seguridad:
|
||||
|
||||
- El string de comando es tu propia configuración — mismo nivel de confianza que `.env`. Se ejecuta vía `/bin/sh -c`, por lo que el proveedor command es solo POSIX (Linux/macOS); Windows se mantiene en el proveedor env.
|
||||
- El helper hereda el entorno del proceso más `HERMES_SECRET_KEY`; el nombre de la clave se pasa como dato, nunca interpolado en el string del shell.
|
||||
- Timeout fijo de 3 segundos, límite de 1 MiB de salida, y stderr se descarta.
|
||||
- Los valores resueltos nunca se registran ni se escriben en disco; los fallos degradan a "clave no definida", registrando solo el código de salida/señal.
|
||||
- El broadcast de inicio de gateway usa una única llamada `list()`, nunca un loop de helper por clave.
|
||||
|
||||
Fuente de verdad: [`src/main/secrets/`](src/main/secrets/).
|
||||
|
||||
## Stack tecnológico
|
||||
|
||||
- **Electron** 39 — shell de escritorio multiplataforma
|
||||
- **React** 19 — framework de UI
|
||||
- **TypeScript** 5.9 — tipado seguro en procesos main y renderer
|
||||
- **Tailwind CSS** 4 — estilos utility-first
|
||||
- **Vite** 7 + electron-vite — servidor de desarrollo rápido y herramientas de build
|
||||
- **better-sqlite3** — almacenamiento local de sesiones con búsqueda FTS5
|
||||
- **i18next** — framework de internacionalización
|
||||
- **Vitest** — test runner
|
||||
|
||||
## Notas
|
||||
|
||||
- La app de escritorio depende del proyecto upstream Hermes Agent para el comportamiento del agente y la ejecución de herramientas.
|
||||
- El instalador integrado ejecuta el script oficial de instalación de Hermes con `--skip-setup`, luego completa la configuración del proveedor en la GUI.
|
||||
- Los proveedores de modelos locales no requieren clave API, pero el servidor compatible debe estar ejecutándose.
|
||||
- Las rutas alternativas de registro npm son compatibles para entornos con acceso restringido a internet.
|
||||
|
||||
## Contribuir
|
||||
|
||||
¡Las contribuciones son bienvenidas! Consulta la [Guía de contribución](CONTRIBUTING.md) para comenzar. Si no sabes por dónde empezar, mira los [issues abiertos](https://github.com/fathah/hermes-desktop/issues). ¿Encontraste un bug o tienes una solicitud de funcionalidad? [Abre un issue](https://github.com/fathah/hermes-desktop/issues/new).
|
||||
|
||||
## Proyecto relacionado
|
||||
|
||||
Para el agente central, documentación y flujos de trabajo CLI, consulta el repositorio principal de Hermes Agent:
|
||||
|
||||
- https://github.com/NousResearch/hermes-agent
|
||||
|
||||
---
|
||||
|
||||
_Traducción al español LATAM por [Nanoboy](https://github.com/365diascollaboration-prog)._
|
||||
@@ -0,0 +1,268 @@
|
||||
<img width="100%" alt="HERMES DESKTOP" src="previews/header.webp" />
|
||||
|
||||
<br/>
|
||||
<p align="center">
|
||||
<a href="https://hermes-agent.nousresearch.com/docs/"><img src="https://img.shields.io/badge/Docs-hermes--agent.nousresearch.com-FFD700?style=for-the-badge" alt="Documentation"></a>
|
||||
<a href="https://t.me/hermes_agent_desktop"><img src="https://img.shields.io/badge/Telegram-2CA5E0?style=for-the-badge&logo=telegram&logoColor=white" alt="Telegram"></a>
|
||||
<a href="https://github.com/fathah/hermes-desktop/blob/main/LICENSE"><img src="https://img.shields.io/badge/License-MIT-green?style=for-the-badge" alt="License: MIT"></a>
|
||||
<a href="https://hermesone.org/"><img src="https://img.shields.io/badge/Download-Releases-FF6600?style=for-the-badge" alt="Releases"></a>
|
||||
<a href="https://github.com/fathah/hermes-desktop/stargazers">
|
||||
<img src="https://img.shields.io/github/stars/fathah/hermes-desktop?style=for-the-badge&color=FFD700&label=Stars" alt="Stars">
|
||||
</a>
|
||||
<a href="https://github.com/fathah/hermes-desktop/releases/">
|
||||
<img src="https://img.shields.io/github/downloads/fathah/hermes-desktop/total?style=for-the-badge&color=00B496&label=Total%20Downloads" alt="Downloads">
|
||||
</a>
|
||||
</p>
|
||||
|
||||
> **本プロジェクトは現在も活発に開発中です。** 機能は変更される可能性があり、一部が動作しなくなることもあります。問題に遭遇した場合や、アイデアがある場合は[Issue を作成してください](https://github.com/fathah/hermes-desktop/issues)。コントリビューションも歓迎しています!
|
||||
|
||||
## 言語
|
||||
|
||||
- English: `README.md`
|
||||
- 简体中文: `README.zh-CN.md`
|
||||
- 日本語: `README.ja-JP.md`
|
||||
|
||||
Hermes Desktop は、[Hermes Agent](https://github.com/NousResearch/hermes-agent)(ツール使用、マルチプラットフォームメッセージング、クローズドな学習ループを備えた、自己改善型 AI アシスタント)のインストール・設定・チャットを行うためのネイティブデスクトップアプリです。
|
||||
|
||||
CLI を手作業で管理する代わりに、本アプリではインストール、プロバイダのセットアップ、日常的な利用までを一箇所でガイドします。公式の Hermes インストールスクリプトを使用し、Hermes を `~/.hermes` に保存し、チャット、セッション、プロファイル、メモリ、スキル、ツール、スケジューリング、メッセージングゲートウェイなどを GUI で操作できます。
|
||||
|
||||
## インストール
|
||||
|
||||
<a href="https://hermesone.org/"><img width="380" alt="Download Now" src="previews/download.webp" /></a>
|
||||
|
||||
### Windows
|
||||
|
||||
> **Windows ユーザーへ:** インストーラはコード署名されていません。初回起動時に Windows SmartScreen の警告が表示されます。「詳細情報」→「実行」をクリックしてください。
|
||||
|
||||
> **WSL ユーザーへ:** インストーラが `Switching to root user to install dependencies...` で停止する場合、Playwright が sudo パスワードを待っていますが、読み取るための TTY がありません。インストール中だけパスワードなしの sudo を許可し、完了後に元へ戻してください。
|
||||
>
|
||||
> ```bash
|
||||
> echo "$USER ALL=(ALL) NOPASSWD: ALL" | sudo tee /etc/sudoers.d/hermes-install
|
||||
> # …インストーラを再実行し、完了したら:
|
||||
> sudo rm /etc/sudoers.d/hermes-install
|
||||
> ```
|
||||
>
|
||||
> [#109](https://github.com/fathah/hermes-desktop/issues/109) で追跡しています。
|
||||
|
||||
### Fedora (RPM)
|
||||
|
||||
```bash
|
||||
sudo dnf install ./hermes-desktop-<version>.rpm
|
||||
```
|
||||
|
||||
> **Fedora ユーザーへ:** `.rpm` は GPG 署名されていません。署名検証を強制する設定の場合は、インストールコマンドに `--nogpgcheck` を追加してください。`.rpm` ビルドは自動アップデートに対応していません(`electron-updater` の制約)。アップデートする場合は新しい `.rpm` を再インストールしてください。
|
||||
|
||||
## プレビュー
|
||||
|
||||
<table>
|
||||
<tr>
|
||||
<td width="50%" align="center"><b>Chat</b><br/><img width="100%" alt="Chat" src="previews/chat.png" /></td>
|
||||
<td width="50%" align="center"><b>Profiles</b><br/><img width="100%" alt="Profiles" src="previews/profiles.png" /></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td width="50%" align="center"><b>Models</b><br/><img width="100%" alt="Models" src="previews/models.png" /></td>
|
||||
<td width="50%" align="center"><b>Providers</b><br/><img width="100%" alt="Providers" src="previews/providers.png" /></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td width="50%" align="center"><b>Tools</b><br/><img width="100%" alt="Tools" src="previews/tools.png" /></td>
|
||||
<td width="50%" align="center"><b>Skills</b><br/><img width="100%" alt="Skills" src="previews/skills.png" /></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td width="50%" align="center"><b>Schedules</b><br/><img width="100%" alt="Schedules" src="previews/schedules.png" /></td>
|
||||
<td width="50%" align="center"><b>Gateway</b><br/><img width="100%" alt="Gateway" src="previews/gateway.png" /></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td width="50%" align="center"><b>Persona</b><br/><img width="100%" alt="Persona" src="previews/persona.png" /></td>
|
||||
<td width="50%" align="center"><b>Kanban</b><br/><img width="100%" alt="Kanban" src="previews/kanban.png" /></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td width="50%" align="center"><b>Office</b><br/><img width="100%" alt="Office" src="previews/office.png" /></td>
|
||||
<td width="50%" align="center"><b>Settings</b><br/><img width="100%" alt="Settings" src="previews/settings.png" /></td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
## 機能
|
||||
|
||||
- **初回起動時のガイド付きインストール** — Hermes Agent のインストールを進捗表示と依存関係解決付きで案内します
|
||||
- **ローカル / リモートバックエンド** — Hermes をローカル (`127.0.0.1:8642`) で実行するか、URL + API キーを使ってリモートの Hermes API サーバーに接続できます
|
||||
- **マルチプロバイダ対応** — OpenRouter, Anthropic, OpenAI, Google (Gemini), xAI (Grok), Nous Portal, Qwen, MiniMax, Hugging Face, Groq、そしてローカルの OpenAI 互換エンドポイント (LM Studio, Ollama, vLLM, llama.cpp)
|
||||
- **ストリーミングチャット UI** — SSE ストリーミング、ツール進捗インジケータ、Markdown レンダリング、シンタックスハイライト対応
|
||||
- **トークン使用量のトラッキング** — プロンプト / 出力トークン数とコストをチャットフッターにリアルタイム表示。`/usage` スラッシュコマンドも利用可能
|
||||
- **22 種類のスラッシュコマンド** — `/new`, `/clear`, `/fast`, `/web`, `/image`, `/browse`, `/code`, `/shell`, `/usage`, `/help`, `/tools`, `/skills`, `/model`, `/memory`, `/persona`, `/version`, `/compact`, `/compress`, `/undo`, `/retry`, `/debug`, `/status` など
|
||||
- **セッション管理** — 全文検索 (SQLite FTS5)、日付別の履歴表示、会話の再開と横断検索
|
||||
- **プロファイル切り替え** — Hermes 環境を分離した状態で作成・削除・切り替え可能
|
||||
- **14 のツールセット** — Web、ブラウザ、ターミナル、ファイル、コード実行、ビジョン、画像生成、TTS、スキル、メモリ、セッション検索、Clarify、Delegation、MoA、タスクプランニング
|
||||
- **メモリシステム** — メモリエントリの閲覧 / 編集、ユーザープロファイルメモリ、容量トラッキング、検出可能なメモリプロバイダ (Honcho, Hindsight, Mem0, RetainDB, Supermemory, ByteRover) に対応
|
||||
- **ペルソナエディタ** — エージェントの SOUL.md パーソナリティを編集・リセット可能
|
||||
- **保存済みモデル** — プロバイダごとのモデル設定を CRUD で管理
|
||||
- **スケジュールタスク** — 分単位 / 時間単位 / 日単位 / 週単位 / カスタム cron に対応する cron ジョブビルダー(15 種類の配信先)
|
||||
- **16 種類のメッセージングゲートウェイ** — Telegram, Discord, Slack, WhatsApp, Signal, Matrix, Mattermost, Email (IMAP/SMTP), SMS (Twilio/Vonage), iMessage (BlueBubbles), DingTalk, Feishu/Lark, WeCom, WeChat (iLink Bot), Webhooks, Home Assistant
|
||||
- **Hermes Office (Claw3d)** — ビジュアルな 3D インターフェース。開発サーバーとアダプタの管理機能を備える
|
||||
- **バックアップ、インポート、デバッグダンプ** — 設定画面からデータの完全なバックアップ / リストアとシステム診断が可能
|
||||
- **ログビューア** — ゲートウェイとエージェントのログを設定画面から直接閲覧
|
||||
- **自動アップデーター** — electron-updater を使ったアップデートチェックとインストール
|
||||
- **i18n 対応** — 全画面に対応する英語ロケールを含む国際化フレームワーク。コミュニティ翻訳の受け入れ準備済み
|
||||
- **テストスイート** — SSE パーサ、IPC ハンドラ、preload API サーフェス、インストーラユーティリティ、定数バリデーションを Vitest で検証
|
||||
|
||||
## 動作の仕組み
|
||||
|
||||
初回起動時、アプリは次の手順で動作します。
|
||||
|
||||
1. Hermes を**ローカル**で動かすか、**リモート**の Hermes API サーバーに接続するかを尋ねます。
|
||||
2. **ローカルモード:** `~/.hermes` に Hermes が既にインストールされているかを確認します。なければ、依存関係 (Git, uv, Python 3.11+) を解決しつつ公式インストーラを実行します。
|
||||
3. **リモートモード:** リモート API の URL と API キーを入力させ、接続を検証し、ローカルインストールをスキップします。
|
||||
4. API プロバイダまたはローカルモデルのエンドポイントを尋ねます。
|
||||
5. プロバイダ設定と API キーを Hermes の設定ファイルに保存します。
|
||||
6. セットアップが完了するとメインのワークスペースを起動します。
|
||||
|
||||
ローカルモードでは、チャットリクエストは `http://127.0.0.1:8642` 経由で SSE ストリーミングされます。リモートモードでは、設定したリモート URL に対して同じストリーミングプロトコルで通信します。デスクトップアプリはストリームをリアルタイムで解析し、ツール進捗、Markdown コンテンツ、トークン使用量を順次レンダリングします。
|
||||
|
||||
## 画面構成
|
||||
|
||||
| 画面 | 説明 |
|
||||
| ------------- | --------------------------------------------------------------------------------------------- |
|
||||
| **Chat** | ストリーミング会話 UI。スラッシュコマンド、ツール進捗、トークントラッキングに対応 |
|
||||
| **Sessions** | 過去の会話の閲覧、検索、再開 |
|
||||
| **Agents** | Hermes プロファイルの作成、削除、切り替え |
|
||||
| **Skills** | バンドル済み / インストール済みスキルの閲覧、インストール、管理 |
|
||||
| **Models** | プロバイダごとに保存されたモデル設定の管理 |
|
||||
| **Memory** | メモリエントリとユーザープロファイルの閲覧 / 編集、メモリプロバイダの設定 |
|
||||
| **Soul** | アクティブなプロファイルのペルソナ (SOUL.md) を編集 |
|
||||
| **Tools** | 個別のツールセットを有効化 / 無効化 |
|
||||
| **Schedules** | 配信先付きの cron ジョブを作成・管理 |
|
||||
| **Gateway** | メッセージングプラットフォーム統合の設定と制御 |
|
||||
| **Office** | Claw3d ビジュアルインターフェースのセットアップと管理 |
|
||||
| **Settings** | プロバイダ設定、認証情報プール、バックアップ / インポート、ログビューア、ネットワーク、テーマ |
|
||||
|
||||
## 対応プロバイダ
|
||||
|
||||
### LLM プロバイダ
|
||||
|
||||
| プロバイダ | 備考 |
|
||||
| ------------------- | ---------------------------------------------- |
|
||||
| **OpenRouter** | 単一 API で 200 以上のモデルを利用可能(推奨) |
|
||||
| **Anthropic** | Claude に直接アクセス |
|
||||
| **OpenAI** | GPT に直接アクセス |
|
||||
| **Google (Gemini)** | Google AI Studio |
|
||||
| **xAI (Grok)** | Grok モデル |
|
||||
| **Nous Portal** | 無料枠あり |
|
||||
| **Qwen** | QwenAI モデル |
|
||||
| **MiniMax** | グローバル / 中国向けエンドポイント |
|
||||
| **Hugging Face** | HF Inference 経由で 20 以上のオープンモデル |
|
||||
| **Groq** | 高速推論 (Voice/STT) |
|
||||
| **Local/Custom** | 任意の OpenAI 互換エンドポイント |
|
||||
|
||||
LM Studio、Ollama、vLLM、llama.cpp 用のローカルプリセットが付属しています。
|
||||
|
||||
### メッセージングプラットフォーム
|
||||
|
||||
Telegram、Discord、Slack、WhatsApp、Signal、Matrix/Element、Mattermost、Email (IMAP/SMTP)、SMS (Twilio & Vonage)、iMessage (BlueBubbles)、DingTalk、Feishu/Lark、WeCom、WeChat (iLink Bot)、Webhooks、Home Assistant。
|
||||
|
||||
### ツール統合
|
||||
|
||||
Exa Search、Parallel API、Tavily、Firecrawl、FAL.ai (画像生成)、Honcho、Browserbase、Weights & Biases、Tinker。
|
||||
|
||||
## 開発
|
||||
|
||||
### 前提条件
|
||||
|
||||
- Node.js と npm
|
||||
- Hermes インストーラ用の Unix 系シェル環境
|
||||
- 初回起動時に Hermes をダウンロードするためのネットワークアクセス
|
||||
|
||||
### 依存関係のインストール
|
||||
|
||||
```bash
|
||||
npm install
|
||||
```
|
||||
|
||||
### 開発モードでアプリを起動
|
||||
|
||||
```bash
|
||||
npm run dev
|
||||
```
|
||||
|
||||
### チェック実行
|
||||
|
||||
```bash
|
||||
npm run lint
|
||||
npm run typecheck
|
||||
```
|
||||
|
||||
### テスト実行
|
||||
|
||||
```bash
|
||||
npm run test
|
||||
npm run test:watch
|
||||
```
|
||||
|
||||
### デスクトップアプリのビルド
|
||||
|
||||
```bash
|
||||
npm run build
|
||||
```
|
||||
|
||||
プラットフォーム別パッケージング:
|
||||
|
||||
```bash
|
||||
npm run build:mac
|
||||
npm run build:win
|
||||
npm run build:linux
|
||||
npm run build:rpm # Fedora/RHEL .rpm のみ
|
||||
```
|
||||
|
||||
## 初回セットアップ
|
||||
|
||||
アプリを初めて開くと、既存の Hermes インストールを検出するか、インストールを提案します。
|
||||
|
||||
UI でサポートされているセットアップパス:
|
||||
|
||||
- `OpenRouter`
|
||||
- `Anthropic`
|
||||
- `OpenAI`
|
||||
- OpenAI 互換 base URL を使った `Local LLM`
|
||||
|
||||
以下のローカルプリセットが付属しています。
|
||||
|
||||
- LM Studio
|
||||
- Ollama
|
||||
- vLLM
|
||||
- llama.cpp
|
||||
|
||||
Hermes のファイルは以下の場所で管理されます。
|
||||
|
||||
- `~/.hermes`
|
||||
- `~/.hermes/.env`
|
||||
- `~/.hermes/config.yaml`
|
||||
- `~/.hermes/hermes-agent`
|
||||
- `~/.hermes/profiles/` — 名前付きプロファイルディレクトリ
|
||||
- `~/.hermes/state.db` — セッション履歴データベース
|
||||
- `~/.hermes/cron/jobs.json` — スケジュールタスク
|
||||
|
||||
## 技術スタック
|
||||
|
||||
- **Electron** 39 — クロスプラットフォームのデスクトップシェル
|
||||
- **React** 19 — UI フレームワーク
|
||||
- **TypeScript** 5.9 — main / renderer プロセス間で型安全性を確保
|
||||
- **Tailwind CSS** 4 — ユーティリティファーストのスタイリング
|
||||
- **Vite** 7 + electron-vite — 高速な開発サーバーとビルドツール
|
||||
- **better-sqlite3** — FTS5 全文検索付きのローカルセッションストレージ
|
||||
- **i18next** — 国際化フレームワーク
|
||||
- **Vitest** — テストランナー
|
||||
|
||||
## 補足
|
||||
|
||||
- 本デスクトップアプリはエージェントの動作やツール実行を上流の Hermes Agent プロジェクトに依存しています。
|
||||
- 内蔵インストーラは公式の Hermes インストールスクリプトを `--skip-setup` 付きで実行し、その後 GUI でプロバイダ設定を完了します。
|
||||
- ローカルモデルプロバイダには API キーは不要ですが、互換サーバーが事前に起動している必要があります。
|
||||
- ネットワーク制限のある環境向けに、代替の npm レジストリ経路をサポートしています。
|
||||
|
||||
## コントリビューション
|
||||
|
||||
コントリビューションを歓迎します!始め方は[コントリビューションガイド](CONTRIBUTING.md)をご覧ください。どこから手を付ければよいか分からない場合は、[Open Issues](https://github.com/fathah/hermes-desktop/issues) を確認してください。バグを見つけた、または機能要望がある場合は [Issue を作成してください](https://github.com/fathah/hermes-desktop/issues/new)。
|
||||
|
||||
## 関連プロジェクト
|
||||
|
||||
コアエージェント、ドキュメント、CLI ワークフローについては、Hermes Agent 本体のリポジトリを参照してください。
|
||||
|
||||
- https://github.com/NousResearch/hermes-agent
|
||||
@@ -0,0 +1,350 @@
|
||||
<img width="100%" alt="HERMES DESKTOP" src="assets/header.webp" />
|
||||
|
||||
<br/>
|
||||
<p align="center">
|
||||
<a href="https://x.com/HermesOneApp"><img src="https://img.shields.io/badge/Follow Us-000000?style=for-the-badge&logo=x" alt="Twitter"></a>
|
||||
<a href="https://discord.gg/Fqu72h8z"><img src="https://img.shields.io/badge/Discord-5865F2?style=for-the-badge&logo=discord&logoColor=white" alt="Discord"></a>
|
||||
<a href="https://github.com/fathah/hermes-desktop/blob/main/LICENSE"><img src="https://img.shields.io/badge/License-MIT-green?style=for-the-badge" alt="License: MIT"></a>
|
||||
<a href="https://hermesone.org"><img src="https://img.shields.io/badge/Download-Releases-FF6600?style=for-the-badge" alt="Releases"></a>
|
||||
<a href="https://github.com/fathah/hermes-desktop/stargazers">
|
||||
<img src="https://img.shields.io/github/stars/fathah/hermes-desktop?style=for-the-badge&color=FFD700&label=Stars" alt="Stars">
|
||||
</a>
|
||||
<a href="https://github.com/fathah/hermes-desktop/releases/">
|
||||
<img src="https://img.shields.io/github/downloads/fathah/hermes-desktop/total?style=for-the-badge&color=00B496&label=Total%20Downloads" alt="Downloads">
|
||||
</a>
|
||||
<a href="https://bankr.bot/launches/0xfda75f77a22b4f4b783bbbb21915ef64d149bba3">
|
||||
<img src="https://img.shields.io/badge/Token-$HD-purple?style=for-the-badge&logo=ethereum" alt="Downloads">
|
||||
</a>
|
||||
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<a href="README.md">English</a> ·
|
||||
<a href="README.zh-CN.md">简体中文</a> ·
|
||||
<a href="README.ja-JP.md">日本語</a> ·
|
||||
<a href="README.es-LATAM.md">Español (LATAM)</a>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
|
||||
|
||||
|
||||
<a href="https://www.star-history.com/fathah/hermes-desktop">
|
||||
<picture>
|
||||
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/badge?repo=fathah/hermes-desktop&theme=dark" />
|
||||
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/badge?repo=fathah/hermes-desktop" />
|
||||
<img alt="Star History Rank" src="https://api.star-history.com/badge?repo=fathah/hermes-desktop" />
|
||||
</picture>
|
||||
</a>
|
||||
</p>
|
||||
|
||||
|
||||
> **This project is in active development.** Features may change, and some things might break. If you run into a problem or have an idea, [open an issue](https://github.com/fathah/hermes-desktop/issues). Contributions are welcome!
|
||||
|
||||
Hermes One is a community maintained native desktop app for installing, configuring, and chatting with [Hermes Agent](https://github.com/NousResearch/hermes-agent) — a self-improving AI assistant with tool use, multi-platform messaging, and a closed learning loop.
|
||||
|
||||
Instead of managing the CLI by hand, the app walks through install, provider setup, and day-to-day usage in one place. It uses the official Hermes install script, stores Hermes in `~/.hermes`, and gives you a GUI for chat, sessions, profiles, memory, skills, tools, scheduling, messaging gateways, and more.
|
||||
|
||||
[](https://ko-fi.com/D1D41ZEKFO)
|
||||
|
||||
## Sponsors
|
||||
|
||||
> [Want to appear here?](mailto:fathah@hermesone.org)
|
||||
|
||||
<details open>
|
||||
<summary>Click to collapse</summary>
|
||||
<br/>
|
||||
<table>
|
||||
|
||||
<tr>
|
||||
<td width="180"><a href="ttps://www.atlascloud.ai/?utm_source=github&utm_medium=link&utm_campaign=hermes-desktop"><img src="assets/partners/atlascloud.webp" alt="Atlas Cloud" width=""></a></td>
|
||||
<td> <a href="https://www.atlascloud.ai/?utm_source=github&utm_medium=link&utm_campaign=hermes-desktop">Atlas Cloud</a> is a full-modal, OpenAI-compatible AI inference platform. Use it in Hermes One by selecting <b>Atlas Cloud</b> as your provider. The base URL is pre-configured automatically. </td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td width="180"><a href="https://www.greptile.com/?utm_source=hermes-desktop"><img src="assets/partners/greptile.webp" alt="Greptile" width=""></a></td>
|
||||
<td> <a href="https://www.greptile.com/?utm_source=hermes-desktop">Greptile</a> is an AI code reviewer. It reviews and tests pull requests with full context of the codebase. It catches bugs, flags regressions, and leaves inline review comments on every PR automatically. </td>
|
||||
</tr>
|
||||
|
||||
</table>
|
||||
|
||||
</details>
|
||||
|
||||
## Install
|
||||
|
||||
<a href="https://hermesone.org"><img width="380" alt="Download Now" src="previews/download.webp" /></a>
|
||||
|
||||
<details>
|
||||
<summary>Windows</summary>
|
||||
<br/>
|
||||
|
||||
> **Windows users:** The installer is not code-signed. Windows SmartScreen will warn on first launch — click "More info" → "Run anyway".
|
||||
|
||||
> **WSL users:** If the installer stalls at `Switching to root user to install dependencies...`, Playwright is waiting for a sudo password that has no TTY to read from. Grant passwordless sudo for the install, then revert when finished:
|
||||
>
|
||||
> ```bash
|
||||
> echo "$USER ALL=(ALL) NOPASSWD: ALL" | sudo tee /etc/sudoers.d/hermes-install
|
||||
> # …re-run the installer; once it finishes:
|
||||
> sudo rm /etc/sudoers.d/hermes-install
|
||||
> ```
|
||||
>
|
||||
> Tracked in [#109](https://github.com/fathah/hermes-desktop/issues/109).
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary>Fedora (RPM)</summary>
|
||||
<br/>
|
||||
|
||||
```bash
|
||||
sudo dnf install ./hermes-desktop-<version>.rpm
|
||||
```
|
||||
|
||||
> **Fedora users:** The `.rpm` is not GPG-signed. If your system enforces signature checking, append `--nogpgcheck` to the install command. Auto-update is not supported for `.rpm` builds (limitation of `electron-updater`); reinstall the new `.rpm` to update.
|
||||
|
||||
</details>
|
||||
|
||||
## Preview
|
||||
|
||||
<table>
|
||||
<tr>
|
||||
<td width="50%" align="center"><b>Chat</b><br/><img width="100%" alt="Chat" src="previews/chat.png" /></td>
|
||||
<td width="50%" align="center"><b>Profiles</b><br/><img width="100%" alt="Profiles" src="previews/profiles.png" /></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td width="50%" align="center"><b>Models</b><br/><img width="100%" alt="Models" src="previews/models.png" /></td>
|
||||
<td width="50%" align="center"><b>Providers</b><br/><img width="100%" alt="Providers" src="previews/providers.png" /></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td width="50%" align="center"><b>Tools</b><br/><img width="100%" alt="Tools" src="previews/tools.png" /></td>
|
||||
<td width="50%" align="center"><b>Discover</b><br/><img width="100%" alt="Skills" src="previews/discover.png" /></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td width="50%" align="center"><b>Schedules</b><br/><img width="100%" alt="Schedules" src="previews/schedules.png" /></td>
|
||||
<td width="50%" align="center"><b>Gateway</b><br/><img width="100%" alt="Gateway" src="previews/gateway.png" /></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td width="50%" align="center"><b>Persona</b><br/><img width="100%" alt="Persona" src="previews/persona.png" /></td>
|
||||
<td width="50%" align="center"><b>Kanban</b><br/><img width="100%" alt="Kanban" src="previews/kanban.png" /></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td width="50%" align="center"><b>Office</b><br/><img width="100%" alt="Office" src="previews/office.png" /></td>
|
||||
<td width="50%" align="center"><b>Settings</b><br/><img width="100%" alt="Settings" src="previews/settings.png" /></td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
## Features
|
||||
|
||||
- **Guided first-run install** for Hermes Agent with progress tracking and dependency resolution
|
||||
- **Local or remote backend** — run Hermes locally on `127.0.0.1:8642`, or connect the desktop app to a remote Hermes API server with URL + API key
|
||||
- **Multi-provider support** — OpenRouter, Anthropic, OpenAI, Google (Gemini), xAI (Grok), Nous Portal, Qwen, MiniMax, Hugging Face, Groq, and local OpenAI-compatible endpoints (LM Studio, Atomic Chat, Ollama, vLLM, llama.cpp)
|
||||
- **Streaming chat UI** with SSE streaming, tool progress indicators, markdown rendering, and syntax highlighting
|
||||
- **Token usage tracking** — live prompt/completion token counts and cost display in the chat footer, plus a `/usage` slash command
|
||||
- **22 slash commands** — `/new`, `/clear`, `/fast`, `/web`, `/image`, `/browse`, `/code`, `/shell`, `/usage`, `/help`, `/tools`, `/skills`, `/model`, `/memory`, `/persona`, `/version`, `/compact`, `/compress`, `/undo`, `/retry`, `/debug`, `/status`, and more
|
||||
- **Session management** — full-text search (SQLite FTS5), date-grouped history, resume and search across conversations
|
||||
- **Profile switching** — create, delete, and switch between separate Hermes environments with isolated config
|
||||
- **14 toolsets** — web, browser, terminal, file, code execution, vision, image gen, TTS, skills, memory, session search, clarify, delegation, MoA, and task planning
|
||||
- **Memory system** — view/edit memory entries, user profile memory, capacity tracking, and discoverable memory providers (Honcho, Hindsight, Mem0, RetainDB, Supermemory, ByteRover)
|
||||
- **Persona editor** — edit and reset your agent's SOUL.md personality
|
||||
- **Saved models** — CRUD management for model configurations across providers
|
||||
- **Scheduled tasks** — cron job builder (minutes, hourly, daily, weekly, custom cron) with 15 delivery targets
|
||||
- **16 messaging gateways** — Telegram, Discord, Slack, WhatsApp, Signal, Matrix, Mattermost, Email (IMAP/SMTP), SMS (Twilio/Vonage), iMessage (BlueBubbles), DingTalk, Feishu/Lark, WeCom, WeChat (iLink Bot), Webhooks, Home Assistant
|
||||
- **Hermes Office (Claw3d)** — visual 3D interface with dev server and adapter management
|
||||
- **Backup, import & debug dump** — full data backup/restore and system diagnostics from Settings
|
||||
- **Log viewer** — view gateway and agent logs directly from the Settings screen
|
||||
- **Auto-updater** — check for and install updates via electron-updater
|
||||
- **i18n ready** — internationalization framework with English locale covering all screens, ready for community translations
|
||||
- **Test suite** — SSE parser, IPC handlers, preload API surface, installer utilities, and constants validation with Vitest
|
||||
|
||||
## How It Works
|
||||
|
||||
On first launch, the app:
|
||||
|
||||
1. Asks whether you want to run Hermes **locally** or connect to a **remote** Hermes API server.
|
||||
2. **Local mode:** checks whether Hermes is already installed in `~/.hermes`; if not, runs the official Hermes installer with dependency resolution (Git, uv, Python 3.11+).
|
||||
3. **Remote mode:** prompts for the remote API URL and API key, validates the connection, and skips local install.
|
||||
4. Prompts for an API provider or local model endpoint.
|
||||
5. Saves provider config and API keys through Hermes config files.
|
||||
6. Launches the main workspace once setup is complete.
|
||||
|
||||
In local mode, chat requests go through `http://127.0.0.1:8642` with SSE streaming. In remote mode, the app talks to your configured remote URL with the same streaming protocol. The desktop app parses the stream in real time, rendering tool progress, markdown content, and token usage as it arrives.
|
||||
|
||||
## Screens
|
||||
|
||||
| Screen | Description |
|
||||
| ------------- | ------------------------------------------------------------------------------------- |
|
||||
| **Chat** | Streaming conversation UI with slash commands, tool progress, and token tracking |
|
||||
| **Sessions** | Browse, search, and resume past conversations |
|
||||
| **Agents** | Create, delete, and switch between Hermes profiles |
|
||||
| **Skills** | Browse, install, and manage bundled and installed skills |
|
||||
| **Models** | Manage saved model configurations per provider |
|
||||
| **Memory** | View/edit memory entries, user profile, and configure memory providers |
|
||||
| **Soul** | Edit the active profile's persona (SOUL.md) |
|
||||
| **Tools** | Enable or disable individual toolsets |
|
||||
| **Schedules** | Create and manage cron jobs with delivery targets |
|
||||
| **Gateway** | Configure and control messaging platform integrations |
|
||||
| **Office** | Claw3d visual interface setup and management |
|
||||
| **Settings** | Provider config, credential pools, backup/import, log viewer, network settings, theme |
|
||||
|
||||
## Supported Providers
|
||||
|
||||
### Sponsors
|
||||
|
||||
| Provider | Notes |
|
||||
| --------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| **Atlas Cloud** | OpenAI-compatible gateway — DeepSeek, Qwen, GLM, Kimi, MiniMax and more ([atlascloud.ai](https://www.atlascloud.ai/?utm_source=github&utm_medium=link&utm_campaign=hermes-desktop)) |
|
||||
|
||||
### LLM Providers
|
||||
|
||||
| Provider | Notes |
|
||||
| ------------------- | ---------------------------------------- |
|
||||
| **OpenRouter** | 200+ models via single API (recommended) |
|
||||
| **Anthropic** | Direct Claude access |
|
||||
| **OpenAI** | Direct GPT access |
|
||||
| **Google (Gemini)** | Google AI Studio |
|
||||
| **xAI (Grok)** | Grok models |
|
||||
| **Nous Portal** | Free tier available |
|
||||
| **Qwen** | QwenAI models |
|
||||
| **MiniMax** | Global and China endpoints |
|
||||
| **Hugging Face** | 20+ open models via HF Inference |
|
||||
| **Groq** | Fast inference (voice/STT) |
|
||||
| **Local/Custom** | Any OpenAI-compatible endpoint |
|
||||
|
||||
Local presets are included for LM Studio, Atomic Chat, Ollama, vLLM, and llama.cpp.
|
||||
|
||||
### Messaging Platforms
|
||||
|
||||
Telegram, Discord, Slack, WhatsApp, Signal, Matrix/Element, Mattermost, Email (IMAP/SMTP), SMS (Twilio & Vonage), iMessage (BlueBubbles), DingTalk, Feishu/Lark, WeCom, WeChat (iLink Bot), Webhooks, and Home Assistant.
|
||||
|
||||
### Tool Integrations
|
||||
|
||||
Exa Search, Parallel API, Tavily, Firecrawl, FAL.ai (image generation), Honcho, Browserbase, Weights & Biases, and Tinker.
|
||||
|
||||
## First-Time Setup
|
||||
|
||||
When the app opens for the first time, it will either detect an existing Hermes installation or offer to install it for you.
|
||||
|
||||
Supported setup paths in the UI:
|
||||
|
||||
- `OpenRouter`
|
||||
- `Anthropic`
|
||||
- `OpenAI`
|
||||
- `Local LLM` via an OpenAI-compatible base URL
|
||||
|
||||
Local presets are included for:
|
||||
|
||||
- LM Studio
|
||||
- Atomic Chat
|
||||
- Ollama
|
||||
- vLLM
|
||||
- llama.cpp
|
||||
|
||||
Hermes files are managed in:
|
||||
|
||||
- `~/.hermes`
|
||||
- `~/.hermes/.env`
|
||||
- `~/.hermes/config.yaml`
|
||||
- `~/.hermes/hermes-agent`
|
||||
- `~/.hermes/profiles/` — named profile directories
|
||||
- `~/.hermes/state.db` — session history database
|
||||
- `~/.hermes/cron/jobs.json` — scheduled tasks
|
||||
|
||||
## Secrets provider
|
||||
|
||||
By default, API keys live in `~/.hermes/.env` (the **env** provider). No
|
||||
configuration is needed — this is byte-for-byte the historical behavior, and
|
||||
nothing changes for you.
|
||||
|
||||
If you'd rather not keep keys in a plaintext `.env`, the opt-in **command**
|
||||
provider resolves them by running a helper command you configure. Resolution
|
||||
order everywhere is: `process.env` → `.env` → provider → unset.
|
||||
|
||||
Per-key helper (the requested key name arrives as `$HERMES_SECRET_KEY`):
|
||||
|
||||
```yaml
|
||||
# ~/.hermes/config.yaml
|
||||
secrets:
|
||||
provider: command
|
||||
command: secret-tool lookup hermes "$HERMES_SECRET_KEY"
|
||||
```
|
||||
|
||||
Or a helper that dumps a dotenv blob (e.g. a vault that unseals into tmpfs):
|
||||
|
||||
```yaml
|
||||
secrets:
|
||||
provider: command
|
||||
command: "cat /run/user/1000/hermes-secrets.env"
|
||||
```
|
||||
|
||||
The helper's stdout may be either a single bare value (per-key helpers) or
|
||||
`KEY=VALUE` lines (dotenv dumps); both shapes are auto-detected.
|
||||
|
||||
### Vault / secret manager integration (no TPM required)
|
||||
|
||||
The `command` provider is **vault-agnostic** — it runs whatever helper you
|
||||
configure and reads its stdout. The helper is the only thing that needs to
|
||||
talk to your secret store. If you don't have a TPM-sealed keyfile, any of
|
||||
these work without code changes to Hermes:
|
||||
|
||||
- **KeePassXC (password-only DB, no keyfile):** point `secrets.command` at a
|
||||
small `kpxc-export.sh` script that does
|
||||
`keepassxc-cli ls ~/secrets/hermes.kdbx <<<"$KPXC_PASSWORD"` and dumps
|
||||
the relevant group as dotenv. Prompt the user for the master password
|
||||
once per session.
|
||||
- **GnuPG with a passphrase-only key:** `gpg --batch --passphrase-fd 0
|
||||
--decrypt ~/.keys/api-keys.gpg` works directly as the `command` value.
|
||||
Pass the passphrase via a file descriptor or env var, never argv.
|
||||
- **`pass` (the standard unix password manager):**
|
||||
`command: "pass show hermes/$HERMES_SECRET_KEY"` for a per-key helper,
|
||||
or a small wrapper script for a dotenv dump.
|
||||
- **`secret-tool` (libsecret/Gnome Keyring):**
|
||||
`command: "secret-tool lookup hermes $HERMES_SECRET_KEY"` (already
|
||||
shown above as the canonical per-key example).
|
||||
- **Bitwarden CLI:** `bw get item "$HERMES_SECRET_KEY" | jq -r .notes`
|
||||
(after `bw unlock` in the session).
|
||||
- **1Password CLI:** `op read "op://vault/$HERMES_SECRET_KEY/credential"`.
|
||||
- **Plain env file with user-managed permissions:**
|
||||
`command: "cat ~/.config/hermes/secrets.env"` with `chmod 600` and
|
||||
the file owned by your user. Not as secure as a vault, but better than
|
||||
a world-readable `.env`.
|
||||
|
||||
The point: **any helper that prints a value (per-key) or a dotenv blob
|
||||
(list-mode) on stdout will work**, and Hermes imposes a 3-second timeout
|
||||
and 1 MiB output cap on the helper so a misbehaving one can't wedge the
|
||||
app. The provider makes no assumptions about TPM, FIDO2, smart cards,
|
||||
or platform keychains.
|
||||
|
||||
Security model:
|
||||
|
||||
- The command string is your own configuration — same trust level as `.env`.
|
||||
It runs via `/bin/sh -c`, so the command provider is POSIX-only
|
||||
(Linux/macOS); Windows stays on the env provider.
|
||||
- The helper inherits the process environment plus `HERMES_SECRET_KEY`; the
|
||||
key name is passed as data, never interpolated into the shell string.
|
||||
- Hard 3-second timeout (resolution is synchronous on the main process — keep
|
||||
helpers fast and non-interactive), 1 MiB output cap, and stderr is discarded.
|
||||
- Resolved values are never logged or written to disk; failures degrade to
|
||||
"key unset", logging only exit code/signal.
|
||||
- The gateway-spawn broadcast uses a single `list()` call, never a per-key
|
||||
helper loop.
|
||||
|
||||
Source of truth: [`src/main/secrets/`](src/main/secrets/).
|
||||
|
||||
## Notes
|
||||
|
||||
- The desktop app depends on the upstream Hermes Agent project for agent behavior and tool execution.
|
||||
- The built-in installer runs the official Hermes install script with `--skip-setup`, then completes provider configuration in the GUI.
|
||||
- Local model providers do not require an API key, but the compatible server must already be running.
|
||||
- Alternative npm registry routes are supported for environments with restricted network access.
|
||||
|
||||
## Contributing
|
||||
|
||||
Contributions are welcome! Check out the [Contributing Guide](CONTRIBUTING.md) to get started. If you're not sure where to begin, take a look at the [open issues](https://github.com/fathah/hermes-desktop/issues). Found a bug or have a feature request? [File an issue](https://github.com/fathah/hermes-desktop/issues/new).
|
||||
|
||||
## Related Project
|
||||
|
||||
This repo is not affiliated to **Nous Research**. This is a community maintained project.
|
||||
|
||||
For the core agent, docs, and CLI workflows, see the main Hermes Agent repository:
|
||||
|
||||
- https://github.com/NousResearch/hermes-agent
|
||||
@@ -0,0 +1,7 @@
|
||||
# WeHub 来源说明
|
||||
|
||||
- 原始项目:`fathah/hermes-desktop`
|
||||
- 原始仓库:https://github.com/fathah/hermes-desktop
|
||||
- 导入方式:上游默认分支的最新快照
|
||||
- 原作者、版权和许可证信息以原始仓库及本仓库 LICENSE 为准
|
||||
- 本文件仅用于记录来源,不代表 WeHub 是原项目作者
|
||||
@@ -0,0 +1,268 @@
|
||||
<img width="100%" alt="HERMES DESKTOP" src="previews/header.webp" />
|
||||
|
||||
<br/>
|
||||
<p align="center">
|
||||
<a href="https://hermes-agent.nousresearch.com/docs/"><img src="https://img.shields.io/badge/Docs-hermes--agent.nousresearch.com-FFD700?style=for-the-badge" alt="文档"></a>
|
||||
<a href="https://t.me/hermes_agent_desktop"><img src="https://img.shields.io/badge/Telegram-2CA5E0?style=for-the-badge&logo=telegram&logoColor=white" alt="Telegram"></a>
|
||||
<a href="https://github.com/fathah/hermes-desktop/blob/main/LICENSE"><img src="https://img.shields.io/badge/License-MIT-green?style=for-the-badge" alt="License: MIT"></a>
|
||||
<a href="https://hermesone.org/"><img src="https://img.shields.io/badge/Download-Releases-FF6600?style=for-the-badge" alt="下载"></a>
|
||||
<a href="https://github.com/fathah/hermes-desktop/stargazers">
|
||||
<img src="https://img.shields.io/github/stars/fathah/hermes-desktop?style=for-the-badge&color=FFD700&label=Stars" alt="Stars">
|
||||
</a>
|
||||
<a href="https://github.com/fathah/hermes-desktop/releases/">
|
||||
<img src="https://img.shields.io/github/downloads/fathah/hermes-desktop/total?style=for-the-badge&color=00B496&label=Total%20Downloads" alt="下载量">
|
||||
</a>
|
||||
</p>
|
||||
|
||||
> **本项目处于活跃开发阶段。** 功能可能会发生变化,某些功能也可能会失效。如果您遇到问题或有好的想法,请 [提交 Issue](https://github.com/fathah/hermes-desktop/issues)。欢迎贡献代码!
|
||||
|
||||
## 语言
|
||||
|
||||
- English: `README.md`
|
||||
- 简体中文: `README.zh-CN.md`
|
||||
- 日本語: `README.ja-JP.md`
|
||||
|
||||
Hermes Desktop 是一款原生的桌面应用程序,用于安装、配置并与 [Hermes Agent](https://github.com/NousResearch/hermes-agent) 进行聊天——这是一款具备工具调用、多平台消息传递和闭环学习能力的自我进化的 AI 助手。
|
||||
|
||||
无需手动管理命令行界面 (CLI),该应用可在一个统一界面中引导您完成安装、提供商设置以及日常使用。它使用官方的 Hermes 安装脚本,将 Hermes 存储在 `~/.hermes` 目录下,并为您提供涵盖聊天、会话、配置、记忆、技能、工具、计划任务、消息网关等功能的图形界面。
|
||||
|
||||
## 安装
|
||||
|
||||
<a href="https://hermesone.org/"><img width="380" alt="Download Now" src="previews/download.webp" /></a>
|
||||
|
||||
### Windows
|
||||
|
||||
> **Windows 用户注意:** 安装程序未进行代码签名。首次启动时 Windows SmartScreen 会弹出警告——请点击“更多信息” → “仍要运行”。
|
||||
|
||||
> **WSL 用户注意:** 如果安装程序停滞在 `Switching to root user to install dependencies...`,这说明 Playwright 正在等待输入 sudo 密码,但在没有 TTY 的情况下无法读取。请在安装期间授予无密码的 sudo 权限,完成后再恢复:
|
||||
>
|
||||
> ```bash
|
||||
> echo "$USER ALL=(ALL) NOPASSWD: ALL" | sudo tee /etc/sudoers.d/hermes-install
|
||||
> # …重新运行安装程序;完成后执行:
|
||||
> sudo rm /etc/sudoers.d/hermes-install
|
||||
> ```
|
||||
>
|
||||
> 详情请见 [#109](https://github.com/fathah/hermes-desktop/issues/109)。
|
||||
|
||||
### Fedora (RPM)
|
||||
|
||||
```bash
|
||||
sudo dnf install ./hermes-desktop-<version>.rpm
|
||||
```
|
||||
|
||||
> **Fedora 用户注意:** `.rpm` 包没有 GPG 签名。如果您的系统强制检查签名,请在安装命令后添加 `--nogpgcheck`。`.rpm` 构建不支持自动更新(这是 `electron-updater` 的限制);若要更新,请重新安装新的 `.rpm` 包。
|
||||
|
||||
## 预览
|
||||
|
||||
<table>
|
||||
<tr>
|
||||
<td width="50%" align="center"><b>聊天 (Chat)</b><br/><img width="100%" alt="Chat" src="previews/chat.png" /></td>
|
||||
<td width="50%" align="center"><b>配置 (Profiles)</b><br/><img width="100%" alt="Profiles" src="previews/profiles.png" /></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td width="50%" align="center"><b>模型 (Models)</b><br/><img width="100%" alt="Models" src="previews/models.png" /></td>
|
||||
<td width="50%" align="center"><b>提供商 (Providers)</b><br/><img width="100%" alt="Providers" src="previews/providers.png" /></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td width="50%" align="center"><b>工具 (Tools)</b><br/><img width="100%" alt="Tools" src="previews/tools.png" /></td>
|
||||
<td width="50%" align="center"><b>技能 (Skills)</b><br/><img width="100%" alt="Skills" src="previews/skills.png" /></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td width="50%" align="center"><b>计划任务 (Schedules)</b><br/><img width="100%" alt="Schedules" src="previews/schedules.png" /></td>
|
||||
<td width="50%" align="center"><b>网关 (Gateway)</b><br/><img width="100%" alt="Gateway" src="previews/gateway.png" /></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td width="50%" align="center"><b>人格 (Persona)</b><br/><img width="100%" alt="Persona" src="previews/persona.png" /></td>
|
||||
<td width="50%" align="center"><b>看板 (Kanban)</b><br/><img width="100%" alt="Kanban" src="previews/kanban.png" /></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td width="50%" align="center"><b>办公室 (Office)</b><br/><img width="100%" alt="Office" src="previews/office.png" /></td>
|
||||
<td width="50%" align="center"><b>设置 (Settings)</b><br/><img width="100%" alt="Settings" src="previews/settings.png" /></td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
## 功能特性
|
||||
|
||||
- **向导式初次安装**:带有进度跟踪和依赖解析的 Hermes Agent 引导安装。
|
||||
- **本地或远程后端**:可在本地 `127.0.0.1:8642` 运行 Hermes,或通过 URL 和 API 密钥将桌面应用连接到远程的 Hermes API 服务器。
|
||||
- **多提供商支持**:OpenRouter, Anthropic, OpenAI, Google (Gemini), xAI (Grok), Nous Portal, Qwen, MiniMax, Hugging Face, Groq, 以及本地兼容 OpenAI 格式的端点 (LM Studio, Ollama, vLLM, llama.cpp)。
|
||||
- **流式聊天界面**:具有 SSE 流式传输、工具进度指示、Markdown 渲染和语法高亮。
|
||||
- **Token 使用情况追踪**:在聊天底部显示实时的 Prompt/补全 Token 计数及预估费用,并可通过 `/usage` 斜杠命令查看。
|
||||
- **22 个斜杠命令**:`/new`, `/clear`, `/fast`, `/web`, `/image`, `/browse`, `/code`, `/shell`, `/usage`, `/help`, `/tools`, `/skills`, `/model`, `/memory`, `/persona`, `/version`, `/compact`, `/compress`, `/undo`, `/retry`, `/debug`, `/status` 等等。
|
||||
- **会话管理**:全文检索 (SQLite FTS5)、按日期分组的历史记录、在会话之间继续聊天或搜索。
|
||||
- **配置切换 (Profile)**:创建、删除并切换不同的 Hermes 环境,配置完全隔离。
|
||||
- **14 种工具集**:网络、浏览器、终端、文件、代码执行、视觉识别、图像生成、语音合成 (TTS)、技能、记忆、会话搜索、澄清询问、委托调度、混合专家模型 (MoA) 和任务规划。
|
||||
- **记忆系统**:查看/编辑记忆条目和用户资料记忆,追踪容量,并发现不同的记忆提供商(如 Honcho, Hindsight, Mem0, RetainDB, Supermemory, ByteRover)。
|
||||
- **人格编辑器**:编辑并重置您的代理 (Agent) 的 `SOUL.md` 人格设定。
|
||||
- **已保存模型**:跨不同提供商对模型配置进行增删改查。
|
||||
- **计划任务**:支持 15 种推送目标的 Cron 任务构建器(分钟、小时、每日、每周、自定义 Cron)。
|
||||
- **16 个消息网关**:Telegram, Discord, Slack, WhatsApp, Signal, Matrix/Element, Mattermost, Email (IMAP/SMTP), SMS (Twilio & Vonage), iMessage (BlueBubbles), 钉钉 (DingTalk), 飞书 (Feishu/Lark), 企业微信 (WeCom), 微信 (WeChat iLink Bot), Webhooks 和 Home Assistant。
|
||||
- **Hermes 办公室 (Claw3d)**:具有开发服务器和适配器管理功能的可视化 3D 界面。
|
||||
- **备份、导入与诊断导出**:可在设置面板中完成完整的数据备份/恢复,并进行系统诊断。
|
||||
- **日志查看器**:直接在设置界面中查看网关和代理的运行日志。
|
||||
- **自动更新**:通过 `electron-updater` 检查并安装更新。
|
||||
- **国际化 (i18n)**:预置英文语言环境,涵盖所有界面,并已为社区翻译做好框架准备。
|
||||
- **测试套件**:涵盖 SSE 解析器、IPC 处理程序、预加载 API、安装程序工具以及常数验证的 Vitest 测试用例。
|
||||
|
||||
## 运行原理
|
||||
|
||||
在首次启动时,应用会:
|
||||
|
||||
1. 询问您是希望在**本地**运行 Hermes,还是连接到**远程**的 Hermes API 服务器。
|
||||
2. **本地模式:** 检查 `~/.hermes` 目录下是否已安装 Hermes;如果未安装,则运行官方 Hermes 安装脚本并解决依赖关系 (Git, uv, Python 3.11+)。
|
||||
3. **远程模式:** 提示输入远程 API URL 和 API 密钥,验证连接,并跳过本地安装。
|
||||
4. 提示输入 API 提供商或本地模型端点。
|
||||
5. 将提供商配置和 API 密钥保存至 Hermes 配置文件。
|
||||
6. 设置完成后启动主工作区。
|
||||
|
||||
在本地模式下,聊天请求会通过带有 SSE 流的 `http://127.0.0.1:8642` 发送。在远程模式下,应用程序通过相同的流协议与您配置的远程 URL 进行通信。桌面应用会实时解析数据流,并在接收时渲染工具进度、Markdown 内容以及 token 消耗。
|
||||
|
||||
## 界面说明
|
||||
|
||||
| 界面 (Screen) | 描述 (Description) |
|
||||
| -------------------- | --------------------------------------------------------- |
|
||||
| **聊天 (Chat)** | 支持斜杠命令、工具进度展示和 token 跟踪的流式对话界面 |
|
||||
| **会话 (Sessions)** | 浏览、搜索并恢复过去的对话 |
|
||||
| **代理 (Agents)** | 创建、删除和在不同的 Hermes 配置 (Profile) 之间切换 |
|
||||
| **技能 (Skills)** | 浏览、安装并管理内置及已安装的技能 |
|
||||
| **模型 (Models)** | 管理并保存各个提供商的模型配置 |
|
||||
| **记忆 (Memory)** | 查看/编辑记忆条目、用户配置,并配置记忆提供商 |
|
||||
| **灵魂 (Soul)** | 编辑当前活动配置的代理人格设定 (`SOUL.md`) |
|
||||
| **工具 (Tools)** | 启用或禁用特定的工具集 |
|
||||
| **计划 (Schedules)** | 创建并管理定时任务及推送目标 |
|
||||
| **网关 (Gateway)** | 配置和控制各类消息平台集成 |
|
||||
| **办公室 (Office)** | Claw3d 可视化界面设置及管理 |
|
||||
| **设置 (Settings)** | 提供商配置、凭证池、备份/导入、日志查看器、网络设置、主题 |
|
||||
|
||||
## 支持的提供商
|
||||
|
||||
### 大语言模型 (LLM) 提供商
|
||||
|
||||
| 提供商 (Provider) | 备注说明 (Notes) |
|
||||
| ------------------------------ | ---------------------------------------- |
|
||||
| **OpenRouter** | 通过单一 API 访问 200+ 种模型 (推荐使用) |
|
||||
| **Anthropic** | 直接访问 Claude 模型 |
|
||||
| **OpenAI** | 直接访问 GPT 模型 |
|
||||
| **Google (Gemini)** | Google AI Studio |
|
||||
| **xAI (Grok)** | Grok 模型 |
|
||||
| **Nous Portal** | 提供免费额度 |
|
||||
| **Qwen (通义千问)** | QwenAI 模型 |
|
||||
| **MiniMax** | 包含全球与中国区端点 |
|
||||
| **Hugging Face** | 通过 HF Inference 访问 20+ 开源模型 |
|
||||
| **Groq** | 快速推理 (支持语音/STT) |
|
||||
| **本地/自定义 (Local/Custom)** | 任何兼容 OpenAI 格式的端点 |
|
||||
|
||||
内置以下本地模型预设:LM Studio, Ollama, vLLM, llama.cpp。
|
||||
|
||||
### 消息平台
|
||||
|
||||
Telegram, Discord, Slack, WhatsApp, Signal, Matrix/Element, Mattermost, 电子邮件 (IMAP/SMTP), 短信 (Twilio & Vonage), iMessage (BlueBubbles), 钉钉 (DingTalk), 飞书 (Feishu/Lark), 企业微信 (WeCom), 微信 (WeChat iLink Bot), Webhooks 和 Home Assistant。
|
||||
|
||||
### 工具集成
|
||||
|
||||
Exa Search, Parallel API, Tavily, Firecrawl, FAL.ai (图像生成), Honcho, Browserbase, Weights & Biases 和 Tinker。
|
||||
|
||||
## 开发
|
||||
|
||||
### 前置要求
|
||||
|
||||
- Node.js 和 npm
|
||||
- 能够运行 Hermes 安装程序的类 Unix Shell 环境
|
||||
- 首次运行安装 Hermes 时需要网络连接
|
||||
|
||||
### 安装依赖
|
||||
|
||||
```bash
|
||||
npm install
|
||||
```
|
||||
|
||||
### 在开发模式下启动应用
|
||||
|
||||
```bash
|
||||
npm run dev
|
||||
```
|
||||
|
||||
### 运行检查
|
||||
|
||||
```bash
|
||||
npm run lint
|
||||
npm run typecheck
|
||||
```
|
||||
|
||||
### 运行测试
|
||||
|
||||
```bash
|
||||
npm run test
|
||||
npm run test:watch
|
||||
```
|
||||
|
||||
### 构建桌面应用
|
||||
|
||||
```bash
|
||||
npm run build
|
||||
```
|
||||
|
||||
各平台打包命令:
|
||||
|
||||
```bash
|
||||
npm run build:mac
|
||||
npm run build:win
|
||||
npm run build:linux
|
||||
npm run build:rpm # 仅适用于 Fedora/RHEL 的 .rpm 格式
|
||||
```
|
||||
|
||||
## 首次运行设置
|
||||
|
||||
当应用首次打开时,它会自动检测是否存在已安装的 Hermes 实例,或者提供帮您进行自动安装的选项。
|
||||
|
||||
UI 中支持的设置路径:
|
||||
|
||||
- `OpenRouter`
|
||||
- `Anthropic`
|
||||
- `OpenAI`
|
||||
- 通过兼容 OpenAI API 基础 URL 接入的 `本地大语言模型 (Local LLM)`
|
||||
|
||||
内置预设包含:
|
||||
|
||||
- LM Studio
|
||||
- Ollama
|
||||
- vLLM
|
||||
- llama.cpp
|
||||
|
||||
Hermes 的相关文件统一管理于以下目录:
|
||||
|
||||
- `~/.hermes`
|
||||
- `~/.hermes/.env`
|
||||
- `~/.hermes/config.yaml`
|
||||
- `~/.hermes/hermes-agent`
|
||||
- `~/.hermes/profiles/` — 命名配置文件目录
|
||||
- `~/.hermes/state.db` — 会话历史数据库
|
||||
- `~/.hermes/cron/jobs.json` — 计划任务
|
||||
|
||||
## 技术栈
|
||||
|
||||
- **Electron 39** — 跨平台桌面外壳
|
||||
- **React 19** — UI 框架
|
||||
- **TypeScript 5.9** — 跨主进程和渲染进程的类型安全
|
||||
- **Tailwind CSS 4** — 实用优先的样式库
|
||||
- **Vite 7 + electron-vite** — 快速开发服务器及构建工具
|
||||
- **better-sqlite3** — 带有 FTS5 全文搜索功能的本地会话存储
|
||||
- **i18next** — 国际化框架
|
||||
- **Vitest** — 测试运行器
|
||||
|
||||
## 注意事项
|
||||
|
||||
- 此桌面应用依赖上游的 Hermes Agent 项目来处理代理行为和工具执行。
|
||||
- 内置安装程序会通过带有 `--skip-setup` 参数的方式运行官方 Hermes 安装脚本,然后在 GUI 界面中完成提供商相关的配置。
|
||||
- 本地模型提供商不需要 API 密钥,但您必须确保兼容的服务器已经在运行中。
|
||||
- 在网络受限的环境下,支持配置备用的 npm 镜像源路由。
|
||||
|
||||
## 参与贡献
|
||||
|
||||
欢迎大家参与贡献!查看 [参与贡献指南 (Contributing Guide)](CONTRIBUTING.md) 以开始。如果您不知从何入手,可以看一看 [开启的 Issues](https://github.com/fathah/hermes-desktop/issues)。发现了 Bug 或是对功能有新的需求? [提交一个 Issue](https://github.com/fathah/hermes-desktop/issues/new)。
|
||||
|
||||
## 相关项目
|
||||
|
||||
如果想了解核心代理功能、详细文档及命令行 (CLI) 的工作流程,请查阅主仓库 Hermes Agent:
|
||||
|
||||
- https://github.com/NousResearch/hermes-agent
|
||||
|
After Width: | Height: | Size: 52 KiB |
|
After Width: | Height: | Size: 9.1 KiB |
|
After Width: | Height: | Size: 12 KiB |
@@ -0,0 +1,52 @@
|
||||
const { execSync } = require("child_process");
|
||||
const path = require("path");
|
||||
|
||||
// Sign a single path, ignoring "not an Mach-O" errors for non-binary files.
|
||||
function sign(target) {
|
||||
try {
|
||||
execSync(`codesign --force --sign - "${target}"`, { stdio: "pipe" });
|
||||
} catch (e) {
|
||||
// Ignore files that aren't signable (scripts, plists, etc.)
|
||||
const msg = (e.stderr || e.stdout || "").toString();
|
||||
if (
|
||||
!msg.includes("is not an Mach-O file") &&
|
||||
!msg.includes("bundle format unrecognized")
|
||||
) {
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
exports.default = async function afterPack(context) {
|
||||
if (context.electronPlatformName !== "darwin") return;
|
||||
|
||||
const appPath = path.join(
|
||||
context.appOutDir,
|
||||
`${context.packager.appInfo.productFilename}.app`,
|
||||
);
|
||||
|
||||
console.log(`Ad-hoc re-signing (inside-out): ${appPath}`);
|
||||
|
||||
// Step 1: sign .dylib files (deepest leaves first)
|
||||
execSync(
|
||||
`find "${appPath}" -name "*.dylib" | while IFS= read -r f; do codesign --force --sign - "$f" 2>/dev/null || true; done`,
|
||||
{ stdio: "inherit", shell: "/bin/bash" },
|
||||
);
|
||||
|
||||
// Step 2: sign XPC services and nested .app bundles inside Frameworks
|
||||
execSync(
|
||||
`find "${appPath}/Contents/Frameworks" -mindepth 1 -maxdepth 4 \\( -name "*.xpc" -o -name "*.app" \\) -prune | while IFS= read -r f; do codesign --force --sign - "$f" 2>/dev/null || true; done`,
|
||||
{ stdio: "inherit", shell: "/bin/bash" },
|
||||
);
|
||||
|
||||
// Step 3: sign each .framework (the versioned bundle, not through symlinks)
|
||||
execSync(
|
||||
`find "${appPath}/Contents/Frameworks" -mindepth 1 -maxdepth 1 -name "*.framework" | while IFS= read -r f; do codesign --force --sign - "$f" 2>/dev/null || true; done`,
|
||||
{ stdio: "inherit", shell: "/bin/bash" },
|
||||
);
|
||||
|
||||
// Step 4: sign the outer .app
|
||||
execSync(`codesign --force --sign - "${appPath}"`, { stdio: "inherit" });
|
||||
|
||||
console.log("Ad-hoc re-signing complete.");
|
||||
};
|
||||
@@ -0,0 +1,14 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>com.apple.security.cs.allow-jit</key>
|
||||
<true/>
|
||||
<key>com.apple.security.cs.allow-unsigned-executable-memory</key>
|
||||
<true/>
|
||||
<key>com.apple.security.cs.allow-dyld-environment-variables</key>
|
||||
<true/>
|
||||
<key>com.apple.security.cs.disable-library-validation</key>
|
||||
<true/>
|
||||
</dict>
|
||||
</plist>
|
||||
@@ -0,0 +1,14 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>com.apple.security.cs.allow-jit</key>
|
||||
<true/>
|
||||
<key>com.apple.security.cs.allow-unsigned-executable-memory</key>
|
||||
<true/>
|
||||
<key>com.apple.security.cs.allow-dyld-environment-variables</key>
|
||||
<true/>
|
||||
<key>com.apple.security.cs.disable-library-validation</key>
|
||||
<true/>
|
||||
</dict>
|
||||
</plist>
|
||||
|
After Width: | Height: | Size: 8.2 KiB |
|
After Width: | Height: | Size: 25 KiB |
@@ -0,0 +1,29 @@
|
||||
#!/bin/bash
|
||||
# After-install hook for the .deb and .rpm packages.
|
||||
#
|
||||
# Sets the SUID root bit on chrome-sandbox so Electron's sandbox helper
|
||||
# can elevate as required. Without this, Electron crashes on close (and
|
||||
# on first sandboxed renderer launch on some configs) — issue #395.
|
||||
#
|
||||
# Why it's needed: Electron's setuid sandbox is the supported sandboxing
|
||||
# path on Linux when unprivileged user namespaces aren't available. Newer
|
||||
# Ubuntu (24.04+, definitely 26.04 LTS) ships AppArmor + kernel hardening
|
||||
# that disable unprivileged user namespaces by default, so the SUID path
|
||||
# becomes mandatory.
|
||||
#
|
||||
# This matches the postinst behaviour of other Electron apps (VS Code,
|
||||
# Slack, Discord, Signal, etc.). electron-builder doesn't ship this by
|
||||
# default — it has to be wired up via {deb,rpm}.afterInstall.
|
||||
|
||||
set -e
|
||||
|
||||
SANDBOX="/opt/Hermes One/chrome-sandbox"
|
||||
|
||||
if [ -f "$SANDBOX" ]; then
|
||||
# 4755 = SUID + rwxr-xr-x. Root-owned by package install; SUID is what
|
||||
# lets the sandbox briefly elevate for the chroot/setresuid step before
|
||||
# dropping back to the calling user.
|
||||
chmod 4755 "$SANDBOX"
|
||||
fi
|
||||
|
||||
exit 0
|
||||
@@ -0,0 +1,16 @@
|
||||
# Generated from this template by scripts/generate-winget-manifests.mjs.
|
||||
# Placeholders ({{...}}) are replaced at build time. Do not edit the generated copy in dist/.
|
||||
PackageIdentifier: NousResearch.HermesDesktop
|
||||
PackageVersion: { { VERSION } }
|
||||
InstallerLocale: en-US
|
||||
InstallerType: nullsoft
|
||||
Scope: user
|
||||
MinimumOSVersion: 10.0.17763.0
|
||||
ReleaseDate: { { RELEASE_DATE } }
|
||||
Installers:
|
||||
- Architecture: x64
|
||||
InstallerUrl: { { INSTALLER_URL } }
|
||||
InstallerSha256: { { INSTALLER_SHA256 } }
|
||||
UpgradeBehavior: install
|
||||
ManifestType: installer
|
||||
ManifestVersion: 1.12.0
|
||||
@@ -0,0 +1,25 @@
|
||||
# Generated from this template by scripts/generate-winget-manifests.mjs.
|
||||
PackageIdentifier: NousResearch.HermesDesktop
|
||||
PackageVersion: { { VERSION } }
|
||||
PackageLocale: en-US
|
||||
Publisher: Nous Research
|
||||
PublisherUrl: https://github.com/fathah/hermes-desktop
|
||||
PublisherSupportUrl: https://github.com/fathah/hermes-desktop/issues
|
||||
PackageName: Hermes One
|
||||
PackageUrl: https://github.com/fathah/hermes-desktop
|
||||
License: MIT
|
||||
LicenseUrl: https://github.com/fathah/hermes-desktop/blob/main/LICENSE
|
||||
ShortDescription: Self-improving AI assistant desktop app
|
||||
Description: |-
|
||||
Hermes One is a native desktop app for installing, configuring, and chatting
|
||||
with Hermes Agent — a self-improving AI assistant with tool use, multi-platform
|
||||
messaging, and a closed learning loop.
|
||||
Tags:
|
||||
- ai
|
||||
- agent
|
||||
- desktop
|
||||
- electron
|
||||
- llm
|
||||
ReleaseNotesUrl: { { RELEASE_NOTES_URL } }
|
||||
ManifestType: defaultLocale
|
||||
ManifestVersion: 1.12.0
|
||||
@@ -0,0 +1,6 @@
|
||||
# Generated from this template by scripts/generate-winget-manifests.mjs.
|
||||
PackageIdentifier: NousResearch.HermesDesktop
|
||||
PackageVersion: { { VERSION } }
|
||||
DefaultLocale: en-US
|
||||
ManifestType: version
|
||||
ManifestVersion: 1.12.0
|
||||
@@ -0,0 +1,69 @@
|
||||
# Changelog — v0.4.3 → v0.4.5
|
||||
|
||||
## Features
|
||||
|
||||
- **Chat attachments** — image and text-file attachments via click, drag-and-drop, and paste; arbitrary file attachments via path references _([@pmos69](https://github.com/pmos69))_
|
||||
- **Session deletion** — delete sessions from the Sessions screen _([@leedusty91-prog](https://github.com/leedusty91-prog), [@fathah](https://github.com/fathah))_
|
||||
- **OpenAI Codex provider** — setup card for CLI/OAuth-based Codex plan (no API key required) _([@leejamesss](https://github.com/leejamesss))_
|
||||
- **Models: provider auto-detect** — auto-detects provider from Base URL in Add/Edit dialog _([@andreab67](https://github.com/andreab67))_
|
||||
- **Kanban in SSH tunnel mode** — Kanban board now works when connected via SSH tunnel _([@andreab67](https://github.com/andreab67))_
|
||||
- **Office: remote Claw3D auto-detect** — detects and connects to remote Claw3D instance in SSH tunnel mode _([@andreab67](https://github.com/andreab67))_
|
||||
- **Providers expanded** — added NVIDIA NIM, Gemini CLI, MiniMax OAuth, and more OpenAI-compatible endpoints _([@pmos69](https://github.com/pmos69))_
|
||||
- **Settings: API key mask** — key mask sized to match the actual stored key length _([@pmos69](https://github.com/pmos69))_
|
||||
- **i18n: European Portuguese (pt-PT)** — new locale with native-speaker review _([@pmos69](https://github.com/pmos69))_
|
||||
- **i18n: Traditional Chinese (zh-TW)** — new locale _([@hansai-art](https://github.com/hansai-art))_
|
||||
- **Persist interface locale** — language selection now survives app restarts _([@ASTairov](https://github.com/ASTairov))_
|
||||
|
||||
## Security
|
||||
|
||||
- **Remote API keys kept in main process** — keys no longer exposed to the renderer process _([@ASTairov](https://github.com/ASTairov))_
|
||||
|
||||
## Bug Fixes
|
||||
|
||||
### Chat & Models
|
||||
|
||||
- Remote mode URL doubling + local-spawn process leak _([@pmos69](https://github.com/pmos69))_
|
||||
- Remote API key wiped when only the URL is edited _([@pmos69](https://github.com/pmos69))_
|
||||
- URL normalization in health probe and API key lookup _([@pmos69](https://github.com/pmos69))_
|
||||
- Stale `base_url` not cleared when switching named providers _([@pmos69](https://github.com/pmos69))_
|
||||
- Stale `baseUrl` when selecting a named-provider model from the model picker _([@pmos69](https://github.com/pmos69))_
|
||||
- Models: entries not reloading when tab becomes visible _([@pmos69](https://github.com/pmos69))_
|
||||
- Models: intermediate/debounced writes to model library _([@pmos69](https://github.com/pmos69))_
|
||||
- Models: Add/Remove/Update not routed through SSH in tunnel mode _([@andreab67](https://github.com/andreab67))_
|
||||
|
||||
### Install Gate & Providers
|
||||
|
||||
- OAuth-backed active profiles not recognized by install gate _([@pmos69](https://github.com/pmos69))_
|
||||
- Install gate not recognizing all known providers in `.env` _([@pmos69](https://github.com/pmos69))_
|
||||
- Providers: API keys not persisted on change (only on blur) _([@pmos69](https://github.com/pmos69))_
|
||||
|
||||
### Config
|
||||
|
||||
- Nested YAML paths not supported in `getConfigValue` _([@andreab67](https://github.com/andreab67))_
|
||||
- Model fields reading/writing outside the `model:` block scope _([@pmos69](https://github.com/pmos69))_
|
||||
- Dotted YAML paths in `getConfig` / `setConfig` _([@pmos69](https://github.com/pmos69))_
|
||||
- Empty env var values rejected in `readEnv` _([@ytfh44](https://github.com/ytfh44))_
|
||||
- SSH config helpers not supporting dotted YAML paths _([@pmos69](https://github.com/pmos69))_
|
||||
|
||||
### SSH & Gateway
|
||||
|
||||
- Tunnel state reset when ControlMaster takes over SSH process _([@arronLuo](https://github.com/arronLuo))_
|
||||
- `sudo-wrapper` bypass missing for non-mutating commands (doctor/update/dump/version) _([@andreab67](https://github.com/andreab67))_
|
||||
- Gateway: platform enabled state not detected from env vars on Windows _([@pmos69](https://github.com/pmos69))_
|
||||
- Gateway: `EPERM` not treated as alive + Python image verification on Windows _([@pmos69](https://github.com/pmos69))_
|
||||
- SSH tunnel multiplexing disabled for tunnel processes _([@andreab67](https://github.com/andreab67))_
|
||||
|
||||
### Installer & Windows
|
||||
|
||||
- `%LOCALAPPDATA%` not matched correctly on Windows _([@pmos69](https://github.com/pmos69))_
|
||||
- Office stack start reliability on Windows _([@ASTairov](https://github.com/ASTairov))_
|
||||
|
||||
### Session & Database
|
||||
|
||||
- Session cache: `messageCount` stale for old sessions on sync _([@pmos69](https://github.com/pmos69))_
|
||||
- `deleteSession` using a read-only DB connection (fixed to use writable) _([@ytfh44](https://github.com/ytfh44))_
|
||||
|
||||
### Other
|
||||
|
||||
- API auth not sent correctly for desktop chat in server mode _([@pmos69](https://github.com/pmos69))_
|
||||
- Interface locale not persisted across restarts _([@ASTairov](https://github.com/ASTairov))_
|
||||
@@ -0,0 +1,64 @@
|
||||
# Changelog — v0.4.5 → v0.5.0
|
||||
|
||||
## Features
|
||||
|
||||
### Chat
|
||||
- **Per-conversation context folder** — pin a local folder to a conversation; its files are available as context for that session _([@pmos69](https://github.com/pmos69))_
|
||||
- **Tool calls, tool output & reasoning in history** — collapsible sections in chat history show tool invocations, their results, and model reasoning steps _([@jason-edstrom](https://github.com/jason-edstrom))_
|
||||
- **Right-click context menu** — copy / paste / select-all and copy-entire-chat from a native context menu in the chat view _([@pmos69](https://github.com/pmos69))_
|
||||
|
||||
### Providers
|
||||
- **In-app OAuth sign-in** — "Subscription / OAuth Plans" section on the Providers screen; sign in to ChatGPT (Codex), xAI Grok, Qwen, Gemini CLI, and MiniMax without leaving the app. The Codex device-code flow auto-opens the browser and copies the code to the clipboard _([@leejamesss](https://github.com/leejamesss))_
|
||||
- **Model autocomplete for OAuth / subscription providers** — live `/v1/models` discovery now works for OAuth-backed providers _([@pmos69](https://github.com/pmos69))_
|
||||
- **Live model-discovery autocomplete** — model name field autocompletes from the provider's live model list for all OpenAI-compatible endpoints _([@pmos69](https://github.com/pmos69))_
|
||||
|
||||
### Kanban
|
||||
- **Claw3D HQ read-only board** — surfaces the Claw3D HQ board as a second read-only Kanban view alongside the local board _([@andreab67](https://github.com/andreab67))_
|
||||
|
||||
### Build & CI
|
||||
- **Portable Windows build target** — new `win-portable` artifact in the release matrix _([@pmos69](https://github.com/pmos69))_
|
||||
- **GitHub Actions CI** — typecheck + full test suite now run on every push and PR _([@pmos69](https://github.com/pmos69))_
|
||||
|
||||
## Bug Fixes
|
||||
|
||||
### Chat & Sessions
|
||||
- Sessions screen reads the active profile's `state.db`, not the root one _([@pmos69](https://github.com/pmos69))_
|
||||
- Sessions tab now auto-refreshes while it stays open _([@pmos69](https://github.com/pmos69))_
|
||||
- Gateway session ID synced on session switch so context is not lost _([@pmos69](https://github.com/pmos69))_
|
||||
- Gateway sessions resumed via `X-Hermes-Session-Id` header _([@pmos69](https://github.com/pmos69))_
|
||||
- IPC chat-send callbacks guarded against destroyed renderer sender _([@Ricardo-M-L](https://github.com/Ricardo-M-L))_
|
||||
|
||||
### Providers & Config
|
||||
- `model.api_key` auto-populated for known-host custom providers _([@pmos69](https://github.com/pmos69))_
|
||||
- OpenAI provider correctly routed through custom + explicit `base_url` in setup _([@pmos69](https://github.com/pmos69))_
|
||||
|
||||
### Tools
|
||||
- `setToolsetEnabled` no longer drops the `cli` section when a trailing platform block is present _([@ytfh44](https://github.com/ytfh44))_
|
||||
|
||||
### Skills
|
||||
- Install failures surfaced when the CLI exits 0 silently instead of disappearing _([@pmos69](https://github.com/pmos69))_
|
||||
|
||||
### Installer & Updater
|
||||
- OpenClaw migration detector no longer false-positives on empty directory stubs _([@andreab67](https://github.com/andreab67))_
|
||||
- `getHermesVersion` wait capped so a stuck version flag can't leak polling intervals _([@Ricardo-M-L](https://github.com/Ricardo-M-L))_
|
||||
- Install confirmation prompt before writing + option to adopt an existing install _([@pmos69](https://github.com/pmos69))_
|
||||
- Auto-updater lifecycle logged to a file for post-mortem debugging _([@pmos69](https://github.com/pmos69))_
|
||||
|
||||
### SSH & Office
|
||||
- SSH-mode gateway operations routed through `systemd` when `hermes.service` exists _([@pmos69](https://github.com/pmos69))_
|
||||
- Kanban SSH errors surface real messages instead of a misleading "unsupported mode" screen _([@pmos69](https://github.com/pmos69))_
|
||||
- Office (Claw3D) services authenticated to the gateway _([@pmos69](https://github.com/pmos69))_
|
||||
|
||||
### Tests
|
||||
- Three failing tests on `main` repaired _([@pmos69](https://github.com/pmos69))_
|
||||
|
||||
## New Contributors
|
||||
|
||||
- **[@leejamesss](https://github.com/leejamesss)** — in-app OAuth sign-in for subscription providers ([#102](https://github.com/fathah/hermes-desktop/pull/102))
|
||||
- **[@jason-edstrom](https://github.com/jason-edstrom)** — tool calls, tool output & reasoning in chat history ([#327](https://github.com/fathah/hermes-desktop/pull/327))
|
||||
|
||||
## Contributors
|
||||
|
||||
[@pmos69](https://github.com/pmos69) · [@andreab67](https://github.com/andreab67) · [@Ricardo-M-L](https://github.com/Ricardo-M-L) · [@ytfh44](https://github.com/ytfh44) · [@leejamesss](https://github.com/leejamesss) · [@jason-edstrom](https://github.com/jason-edstrom)
|
||||
|
||||
**Full diff:** [v0.4.5...v0.5.0](https://github.com/fathah/hermes-desktop/compare/v0.4.5...v0.5.0)
|
||||
@@ -0,0 +1,133 @@
|
||||
# Changelog — v0.5.0 → v0.6.0
|
||||
|
||||
## Features
|
||||
|
||||
### Hermes One (3D Office)
|
||||
- **3D Office environment** — immersive CEO office with rigged characters, seating arrangements, and idle animations powered by Three.js / Troika
|
||||
- **Per-agent chat modal** — open a floating chat window directly from an agent's desk in the Office with full session persistence
|
||||
- **Bank feature** — Bank room added to the Office world with expandable road-line and city backdrop
|
||||
- **Office pantry & row items** — Pantry room and increased row-item density in the office layout
|
||||
- **Office revamp** — major CEO office redesign, control ease improvements, and efficient rendering with drag-building support
|
||||
- **Performance improvements** — render pipeline optimised for the 3D scene; GPU crash fix applied
|
||||
|
||||
### Gateway & Messaging Platforms
|
||||
- **Multi-profile gateways** — each profile now runs its own independent gateway instance with dynamic port allocation
|
||||
- **Gateway start-failure banner** — gateway boot errors surface in the UI instead of silently failing
|
||||
- **Gateway mismatch error fix** — detects and reports gateway version mismatches
|
||||
- **Collapsible sidebar** — sidebar can be collapsed to icon-only mode for more chat space
|
||||
- **Improved messaging platform management** — gateway page redesigned; platform cards show richer state
|
||||
|
||||
### Chat
|
||||
- **Live structured tool-event stream** — tool calls, results, and reasoning render live as the agent works, with collapsible activity groups and multi-tool summaries
|
||||
- **Reasoning effort picker** — choose the reasoning budget (low / medium / high) per message for supported models
|
||||
- **Speech-to-text input** — microphone button in the chat bar for voice input
|
||||
- **Clarify-request cards** — gateway `clarify.request` events render as inline cards in the conversation
|
||||
- **Message queue** — messages typed while the agent is processing are queued and sent automatically when it's free
|
||||
- **Bulk session deletion** — select and delete multiple sessions at once from the Sessions tab
|
||||
- **Per-row session delete button** — individual delete button on each session row in the Sessions tab
|
||||
- **Rename session** — rename any session directly from the Sessions tab
|
||||
- **Chat context folder** — pin a local folder to a conversation for persistent file context (UI upgrade)
|
||||
- **Pre-send validation** — Send button is disabled with an inline reason when the message cannot be sent
|
||||
- **Chat layout upgrade** — refreshed chat header and profile layout
|
||||
|
||||
### Providers & Models
|
||||
- **Nous Portal** — model discovery with `(free)` flag in the model picker; API key + OAuth cards; auto-detected missing credentials
|
||||
- **Ollama Cloud** — live model list in the picker; API key settings exposed in Providers
|
||||
- **Atlas Cloud** — new OpenAI-compatible provider preset
|
||||
- **AIML API** — new provider preset with full i18n labels
|
||||
- **Xiaomi MiMo** — new provider preset
|
||||
- **Auxiliary tasks model** — dedicated model selector for background / auxiliary tasks
|
||||
- **Atomic Chat local preset** — one-click OpenAI-compatible local setup preset
|
||||
- **Agnes context window** — correct context-length mapping for Agnes and DeepSeek models
|
||||
- **Reasoning effort** — reasoning effort exposed per-message for models that support it
|
||||
|
||||
### Discover & Skills
|
||||
- **Discover page** — new Discover screen for browsing and installing skills and MCP servers
|
||||
- **MCP server management UI** — add, remove, and configure MCP servers from a dedicated UI
|
||||
- **Skill discovery** — browse the skill registry directly from the Discover page
|
||||
- **Registry link** — direct link to the skill/MCP registry from the Discover page
|
||||
|
||||
### Tools & Toolsets
|
||||
- **Worktree terminal launcher** — launch a terminal scoped to any Git worktree
|
||||
- **Worktree file panel** — browse worktree files with image preview, code preview, and "open in editor" action; toggle between worktrees
|
||||
- **File icons** — VS Code–style material file icons in the worktree panel
|
||||
|
||||
### Internationalisation
|
||||
- **Turkish (tr)** locale — full translation across all screens
|
||||
- **Polish (pl)** locale — full translation across all screens
|
||||
- **Spanish LATAM (es-LATAM)** — README + 100 % UI translation
|
||||
- **Japanese** README and CONTRIBUTING
|
||||
- **Chinese (zh-TW / zh-CN)** Kanban translations
|
||||
- **Portuguese (pt-PT)** config-health and chat-validation strings
|
||||
|
||||
### Themes & Appearance
|
||||
- **Theme support** — additional built-in colour themes selectable from Settings
|
||||
- **Font modifications** — refined system font stack and heading weights
|
||||
|
||||
## Bug Fixes
|
||||
|
||||
### Chat & Sessions
|
||||
- Fixed chat session split on cold gateway start
|
||||
- Fixed duplicate split turns during stream reconciliation
|
||||
- Fixed image prompt replay after DB refresh
|
||||
- Fixed compressed image filename extension not syncing
|
||||
- Fixed IME composition Enter sending truncated CJK text
|
||||
- Fixed `Content-Length` header missing on image-containing chat POSTs
|
||||
- Fixed clarify card ordering after reconcile + surfaced delivery failures
|
||||
- Fixed partial stream failures after gateway resume
|
||||
- Fixed chat errors from stale renderer state
|
||||
|
||||
### Config & Credentials
|
||||
- Fixed cross-profile API key migration bleeding keys between profiles
|
||||
- Fixed `API_SERVER_KEY` resolution from `api_server.token` in `config.yaml`
|
||||
- Fixed host-derived `<VENDOR>_API_KEY` not being written for custom-provider chat
|
||||
- Fixed network proxy settings not persisting across restarts
|
||||
- Fixed local provider base URLs not persisting
|
||||
- Fixed import backup file path resolution
|
||||
|
||||
### SSH & Cron
|
||||
- Fixed SSH API port fallback validation
|
||||
- Fixed cron schedule prompts not passed positionally
|
||||
- Fixed SSH API key not being cached before the first message (#212)
|
||||
|
||||
### Gateway
|
||||
- Fixed gateway restart after resume
|
||||
- Fixed `API_SERVER_KEY` not injected into gateway spawn environment
|
||||
- Fixed OAuth login output buffering
|
||||
|
||||
### Skills & Tools
|
||||
- Fixed skill uninstall edge cases
|
||||
- Fixed `.trim()` guard on undefined stdout during skill install/uninstall (#145)
|
||||
- Fixed staged attachments not cleaned up on session delete
|
||||
|
||||
### Office
|
||||
- Fixed Office gateway startup handoff
|
||||
- Fixed `executeJavaScript` throwing before DOM is ready
|
||||
- Fixed Office starting with wrong model (now uses desktop-configured model)
|
||||
- Fixed Office gateway settings going stale on reconnect
|
||||
- Quoted Office startup command script safely
|
||||
|
||||
### Diagnose / Config Health
|
||||
- Fixed config-health banner not refreshing after auto-fix
|
||||
- Fixed "Show details" button not wiring to Settings nav
|
||||
- Fixed Hermes profile create errors not surfacing in UI
|
||||
- Hardened skill content and file writes against partial failures
|
||||
- Recovered sessions from empty stale cache
|
||||
|
||||
### i18n
|
||||
- Removed extra Polish locale keys
|
||||
- Added missing Turkish keys for agents, chat, and sessions screens
|
||||
- Fixed Turkish locale consistency (`gözat` as single word, `Araçlar` for tools)
|
||||
- Addressed Spanish i18n review feedback
|
||||
|
||||
## New Contributors
|
||||
|
||||
- **[@914e18c](https://github.com/fathah)** — Turkish locale
|
||||
- **[@5abea69](https://github.com/fathah)** — Polish locale
|
||||
- **[@2cba1e1](https://github.com/fathah)** — Spanish LATAM translation
|
||||
|
||||
## Contributors
|
||||
|
||||
[@pmos69](https://github.com/pmos69) · [@andreab67](https://github.com/andreab67) · [@Ricardo-M-L](https://github.com/Ricardo-M-L) · [@ytfh44](https://github.com/ytfh44) · [@leejamesss](https://github.com/leejamesss)
|
||||
|
||||
**Full diff:** [v0.5.0...v0.6.0](https://github.com/fathah/hermes-desktop/compare/v0.5.0...v0.6.0)
|
||||
@@ -0,0 +1,106 @@
|
||||
# Changelog — v0.6.0 → v0.7.0
|
||||
|
||||
## Features
|
||||
|
||||
### Wallets & Token Economy
|
||||
- **Profile wallets** — each profile can create or import Ethereum wallets on Base mainnet; recovery phrases are encrypted with Electron `safeStorage` and never leave the main process (10 wallets per profile)
|
||||
- **On-chain token balances** — live $H1 / $HD and native balance reads surfaced in the profile Wallet pane
|
||||
- **Token economy groundwork** — wallet store with create / import / rename / delete / list, public-metadata stripping across the IPC boundary, and balance fetch + refresh
|
||||
|
||||
### Secrets Provider
|
||||
- **Pluggable secrets backend** — resolve provider keys and credentials through an env-var or external-command backend instead of plaintext config
|
||||
- **Vault-aware key resolution** — secrets provider wired into every key-resolution path with a vault-first lookup and audit
|
||||
- **Hardened sudo / secret request modal** — gateway `sudo.request` and `secret.request` events render in a guarded confirmation modal with a finished-guard and vault-first lookup
|
||||
|
||||
### Chat
|
||||
- **Slash commands** — type `/` in the chat bar to run commands; context-aware command palette
|
||||
- **Collapsible code blocks** — long code blocks in chat collapse with copy support
|
||||
- **Copy message** — copy button on each chat message bubble (via the hardened `hermesAPI.copyToClipboard` path)
|
||||
- **Cancel queued messages** — remove individual messages from the send queue while the agent is busy
|
||||
- **Notification sound** — optional sound when the agent finishes responding
|
||||
- **Reconciliation rewrite** — chat history reconciliation aligned with the Hermes Agent dashboard using an interleave algorithm, preserving text streamed before tool calls and fixing duplicate / reordered turns
|
||||
- **Visible text selection** — selection highlight is now visible inside chat bubbles
|
||||
|
||||
### In-App Web Preview
|
||||
- **Web preview panel** — open and browse web pages inside the app from the chat workspace
|
||||
- **Navigation security gating** — preview navigation is gated and auto-navigation is restricted to vetted destinations
|
||||
|
||||
### Sidebar & Navigation
|
||||
- **Recent sessions list** — ChatGPT-style paged conversation list in the sidebar with infinite scroll, lazy cache reads, and project grouping by linked folder
|
||||
- **Pinned chats & row context menu** — per-row options menu (Pin, Rename, Move to project, Delete) opened from a hover `…` button or right-click, with motion-driven open/close and page transitions
|
||||
- **Collapsible, redesigned sidebar** — new layout, reordered navigation, brand-mark collapse toggle, nav logo, and footer action row
|
||||
- **Full-list sessions modal** — Cmd/Ctrl+K opens the full session list reusing the Sessions screen
|
||||
|
||||
### Profiles
|
||||
- **Multi-profile sessions** — sessions are scoped per profile; the footer profile switcher keeps the active chat run aligned with the selected profile
|
||||
- **Profile modal** — a single global modal to view and edit a profile's appearance, persona, agent memory, and wallet, opened from anywhere
|
||||
- **Profile colours & appearance** — per-profile colour selection and avatar upload/remove
|
||||
- **Profile enhancements** — basic profile management and mismatch handling improvements
|
||||
|
||||
### Models
|
||||
- **Chat model search** — searchable model picker in the chat bar
|
||||
- **Session-based model override** — pick a model per session without changing the global default
|
||||
- **Auxiliary tasks model** — dedicated model for background / auxiliary tasks
|
||||
|
||||
### Desktop Updates
|
||||
- **Desktop update controls** — in-app update checking and install controls with status surfaced in the UI
|
||||
- **Auto-update** — desktop auto-update flow
|
||||
|
||||
### Context Folder
|
||||
- **Remote context folder** — bind a remote/SSH working folder to a conversation via an in-app picker
|
||||
- **Persisted per session** — the linked folder is restored when re-opening a conversation and survives session deletion cleanup
|
||||
|
||||
### Internationalisation
|
||||
- **Hebrew (he)** — complete translation with full RTL support
|
||||
- **Spanish LATAM (es-LATAM)** — synced with the latest README (Discord, star history, Secrets provider, sponsors)
|
||||
- **Language updates** — additional locale syncs across screens
|
||||
|
||||
### Branding
|
||||
- **Hermes One** — rebranded default titles and onboarding copy
|
||||
- **Updated links & badges** — download link moved to `hermesone.org`; Telegram badge replaced with Discord; refreshed README social and star-history badges
|
||||
|
||||
### Performance
|
||||
- **SQLite connection caching** — database connections are cached for faster repeated reads
|
||||
- **GPU fallback** — automatic fallback to avoid GPU-related crashes during 3D / heavy rendering
|
||||
|
||||
## Bug Fixes
|
||||
|
||||
### Chat & Reconciliation
|
||||
- Fixed previously-sent messages jumping to the bottom of the chat after an agent reply
|
||||
- Fixed near-duplicate bubbles by pre-seeding seen result bubbles
|
||||
- Fixed streamed text being clobbered across tool-call boundaries
|
||||
- Fixed dashboard completion text replacement for re-streamed (e.g. CJK) deltas
|
||||
- Suppressed duplicate remote dashboard assistant deltas
|
||||
- Fixed synthetic tool ordering after the interleave reorder
|
||||
|
||||
### Models & Providers
|
||||
- Fixed the chat model picker overwriting the global default model
|
||||
- Switched session model selection to a dedicated `sessionModelOverride` state
|
||||
- Forwarded the model override through the runs-transport fallback
|
||||
- Provider preset and endpoint updates
|
||||
|
||||
### Secrets
|
||||
- `getSecretSafe()` now honours the never-throws contract of `getSecret()`
|
||||
- Closed two `list()` / `get()` consistency gaps and added a get-path spawn floor
|
||||
- Fixed the S2 misroute guard to test the extracted line rather than full output
|
||||
|
||||
### Profiles & Memory
|
||||
- Fixed profile mismatch when switching the active agent
|
||||
- Surfaced profile-appearance errors and de-duplicated resume handling
|
||||
- Memory limits now read from the profile config
|
||||
|
||||
### SSH & Security
|
||||
- Reliable legacy fallback when the dashboard `/api/ws` endpoint is unavailable (#667)
|
||||
- Fixed SSH profile tunnel routing to the selected profile's API port
|
||||
- Restored the web-preview navigation gate dropped during the `app/` refactor
|
||||
- Fixed SSH dashboard CSP loopback access
|
||||
|
||||
### Platform
|
||||
- Fixed a macOS click-blocking drag region in the custom window chrome
|
||||
- Replaced `navigator.clipboard.writeText` with the hardened `hermesAPI.copyToClipboard`
|
||||
|
||||
## Contributors
|
||||
|
||||
[@hankkyy](https://github.com/hankkyy) · [@pmos69](https://github.com/pmos69) · [@emachado88](https://github.com/emachado88) · [@saxster](https://github.com/saxster) · Michael Valentin · Jesús Alberto Cornelio · Imre Eilertsen · Nazmus Samir · sint · Yosef Hai Yechezkel
|
||||
|
||||
**Full diff:** [v0.6.0...v0.7.0](https://github.com/fathah/hermes-desktop/compare/v0.6.0...v0.7.0)
|
||||
@@ -0,0 +1,4 @@
|
||||
provider: github
|
||||
owner: fathah
|
||||
repo: hermes-desktop
|
||||
updaterCacheDirName: hermes-desktop-updater
|
||||
@@ -0,0 +1,306 @@
|
||||
# Connecting Hermes Desktop to a Remote Hermes VPS over SSH
|
||||
|
||||
This guide walks through configuring Hermes Desktop to use a Hermes Agent
|
||||
running on a remote server (a VPS, a HyperV/KVM VM, a Raspberry Pi on your
|
||||
LAN, etc.) so that **every screen — Chat, Sessions, Skills, Memory, Soul,
|
||||
Tools, Schedules, Gateway, Profiles, Models, Logs — works as if Hermes
|
||||
were installed locally**.
|
||||
|
||||
If you only need to chat against a remote Hermes and you don't care about
|
||||
the management screens, the simpler **"Remote" mode** (HTTP URL + API key)
|
||||
is enough. If you want full functionality parity, you need **"SSH Tunnel"
|
||||
mode**, which is what this document covers.
|
||||
|
||||
## Why SSH Tunnel mode (not plain Remote mode)
|
||||
|
||||
The desktop app has two remote modes, and they cover very different
|
||||
surface areas:
|
||||
|
||||
| Screen / feature | Remote (HTTP + API key) | SSH Tunnel |
|
||||
| ---------------------------------------------- | :------------------------: | :--------------: |
|
||||
| Chat (`/v1/chat/completions`) | ✅ | ✅ |
|
||||
| Sessions list & search | ❌ reads local `~/.hermes` | ✅ via SSH proxy |
|
||||
| Skills (browse, install, uninstall) | ❌ reads local `~/.hermes` | ✅ via SSH proxy |
|
||||
| Memory (view/edit entries, user profile) | ❌ reads local `~/.hermes` | ✅ via SSH proxy |
|
||||
| Soul (persona editor) | ❌ reads local `~/.hermes` | ✅ via SSH proxy |
|
||||
| Tools (toolset enable/disable) | ❌ reads local `~/.hermes` | ✅ via SSH proxy |
|
||||
| Schedules (cron jobs) | ❌ reads local `~/.hermes` | ✅ via SSH proxy |
|
||||
| Gateway (status, start/stop, platform toggles) | ❌ reads local | ✅ via SSH proxy |
|
||||
| Profile switching | ❌ reads local | ✅ via SSH proxy |
|
||||
| Models (saved per-provider configs) | ❌ reads local | ✅ via SSH proxy |
|
||||
| Logs (gateway, agent) | ❌ reads local | ✅ via SSH proxy |
|
||||
|
||||
Plain Remote mode only proxies the chat path. **All other screens read
|
||||
the local `~/.hermes` directory**, so if you have no Hermes install on the
|
||||
desktop's host, those screens look empty even though your remote Hermes
|
||||
has data. SSH Tunnel mode proxies every screen via `sshExec` against the
|
||||
remote host's `~/.hermes`, which is what you almost certainly want.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
On the **desktop machine** (where Hermes Desktop runs):
|
||||
|
||||
- An SSH key pair (e.g. `~/.ssh/id_ed25519` / `~/.ssh/id_ed25519.pub`).
|
||||
Generate one with `ssh-keygen -t ed25519` if you don't have it.
|
||||
- The OpenSSH client on `PATH`. macOS and Linux have it by default;
|
||||
Windows 10/11 ship it as an optional feature ("OpenSSH Client").
|
||||
|
||||
On the **remote machine** (where Hermes Agent runs):
|
||||
|
||||
- OpenSSH server reachable from the desktop host (port 22 by default).
|
||||
- A user account whose `~/.hermes` directory contains your Hermes data
|
||||
(more on this below).
|
||||
- Your desktop's public key authorized for that user
|
||||
(`~/.ssh/authorized_keys`).
|
||||
- The Hermes API listening on `127.0.0.1:8642` (the default — it does
|
||||
**not** need to be exposed publicly; the SSH tunnel forwards it).
|
||||
|
||||
## Which user account should the desktop SSH in as?
|
||||
|
||||
This is the most important decision and the most common source of "the
|
||||
screens are empty" reports.
|
||||
|
||||
The desktop app's SSH proxy uses paths like `~/.hermes/...` (which
|
||||
resolves to `$HOME/.hermes/` of the SSH user). It must log in as the
|
||||
**same user that runs Hermes Agent** so that `~` points at the directory
|
||||
containing your real data.
|
||||
|
||||
### Case A — You installed Hermes manually as your own user
|
||||
|
||||
If you ran the Hermes installer interactively as e.g. `andrea` and your
|
||||
data lives in `/home/andrea/.hermes`, SSH in as `andrea`. Nothing extra
|
||||
to do.
|
||||
|
||||
### Case B — Hermes runs as a dedicated service user (systemd)
|
||||
|
||||
This is common on production VPSes. Hermes is installed under
|
||||
`/opt/hermes` (or similar) and runs via a systemd unit like:
|
||||
|
||||
```ini
|
||||
[Service]
|
||||
User=hermes
|
||||
Group=hermes
|
||||
Environment=HOME=/opt/hermes
|
||||
ExecStart=/opt/hermes/hermes-agent/.venv/bin/hermes gateway
|
||||
```
|
||||
|
||||
In this case the data lives at `/opt/hermes/.hermes/` and you need to
|
||||
SSH in as the `hermes` user. Two things to set up:
|
||||
|
||||
1. **Make sure the `hermes` user has a real login shell.** Hardened
|
||||
installs sometimes set `/usr/sbin/nologin`. Switch it to bash:
|
||||
|
||||
```bash
|
||||
sudo chsh -s /bin/bash hermes
|
||||
```
|
||||
|
||||
2. **Authorize your desktop's public key for the `hermes` user.** Run
|
||||
this from an account with sudo (e.g. your normal login user):
|
||||
|
||||
```bash
|
||||
PUBKEY="ssh-ed25519 AAAA... your-desktop-host" # paste yours
|
||||
|
||||
sudo install -d -o hermes -g hermes -m 700 /opt/hermes/.ssh
|
||||
sudo touch /opt/hermes/.ssh/authorized_keys
|
||||
sudo chown hermes:hermes /opt/hermes/.ssh/authorized_keys
|
||||
sudo chmod 600 /opt/hermes/.ssh/authorized_keys
|
||||
echo "$PUBKEY" | sudo tee -a /opt/hermes/.ssh/authorized_keys
|
||||
```
|
||||
|
||||
**Note:** systemd's `ProtectHome=read-only` on the Hermes service unit
|
||||
only restricts the Hermes process itself. Interactive SSH sessions
|
||||
into the `hermes` user are unaffected, so the desktop can still
|
||||
write skills, memory edits, soul updates, etc.
|
||||
|
||||
### Case C — Hermes runs as root
|
||||
|
||||
Don't. If it currently does, migrate it to a dedicated user before
|
||||
exposing SSH to it.
|
||||
|
||||
## Step-by-step setup
|
||||
|
||||
### 1. Verify SSH works exactly as the desktop will call it
|
||||
|
||||
The desktop spawns `ssh` with these flags (see `src/main/ssh-tunnel.ts`):
|
||||
`-N -L <localPort>:127.0.0.1:8642 -i <keyPath> -o BatchMode=yes
|
||||
-o StrictHostKeyChecking=accept-new`. The critical one is
|
||||
`BatchMode=yes` — **any password or passphrase prompt will fail closed
|
||||
with no useful error message**. From your desktop, run:
|
||||
|
||||
```bash
|
||||
ssh -o BatchMode=yes -o StrictHostKeyChecking=accept-new \
|
||||
-i ~/.ssh/id_ed25519 -p 22 hermes@your.vps.example.com \
|
||||
'curl -s -o /dev/null -w "%{http_code}\n" http://127.0.0.1:8642/health'
|
||||
```
|
||||
|
||||
You should see `200`. If you see `Permission denied (publickey)`, the
|
||||
key isn't authorized for that user — double-check
|
||||
`/opt/hermes/.ssh/authorized_keys` and its permissions (700 on the dir,
|
||||
600 on the file, owned by the target user). If you see a passphrase
|
||||
prompt, your key has a passphrase and SSH agent isn't loaded — either
|
||||
remove the passphrase, or load it into the agent before launching the
|
||||
desktop app.
|
||||
|
||||
### 2. Configure the desktop app
|
||||
|
||||
Open **Settings → Connection** and select **SSH Tunnel**. Fill in:
|
||||
|
||||
| Field | Value |
|
||||
| ------------------ | --------------------------------------------------------------------------------------------------- |
|
||||
| SSH Host | hostname or IP of the remote (e.g. `your.vps.example.com`) |
|
||||
| SSH Port | `22` (or your sshd port) |
|
||||
| Username | the user whose `~/.hermes` is the real one (`hermes` in Case B) |
|
||||
| Private Key Path | absolute path, e.g. `~/.ssh/id_ed25519` on macOS/Linux or `C:\Users\you\.ssh\id_ed25519` on Windows |
|
||||
| Remote Hermes Port | `8642` (default) |
|
||||
|
||||
Click **Test SSH Connection**. Expected result: "SSH tunnel connected!".
|
||||
Then **Save** and restart the app.
|
||||
|
||||
### 3. (Alternative) Edit `~/.hermes/desktop.json` directly
|
||||
|
||||
If you prefer to skip the UI, the same config is stored at
|
||||
`~/.hermes/desktop.json` (the desktop app's _local_ config, on the
|
||||
desktop machine — not on the VPS):
|
||||
|
||||
```json
|
||||
{
|
||||
"connectionMode": "ssh",
|
||||
"remoteUrl": "http://your.vps.example.com:8642",
|
||||
"remoteApiKey": "",
|
||||
"sshConfig": {
|
||||
"host": "your.vps.example.com",
|
||||
"port": 22,
|
||||
"username": "hermes",
|
||||
"keyPath": "/Users/you/.ssh/id_ed25519",
|
||||
"remotePort": 8642,
|
||||
"localPort": 18642
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
`remoteUrl` / `remoteApiKey` are retained so you can switch back to
|
||||
plain Remote mode by changing only `connectionMode`.
|
||||
|
||||
## Verifying every screen works
|
||||
|
||||
After restart, walk through these screens — each should reflect data
|
||||
from the _remote_ `~/.hermes`, not your local one:
|
||||
|
||||
- **Chat** — send a message. Tokens should stream.
|
||||
- **Sessions** — should list past conversations from the VPS.
|
||||
- **Skills** — should show installed skills from the VPS.
|
||||
- **Memory** — should show memory entries from the VPS.
|
||||
- **Soul** — should show your remote `SOUL.md`.
|
||||
- **Tools** — should show toolset enable/disable state.
|
||||
- **Profiles** — should list profiles defined on the VPS.
|
||||
- **Schedules** — should show cron jobs from `~/.hermes/cron/jobs.json`.
|
||||
- **Gateway** — should reflect the running gateway's state.
|
||||
|
||||
If any screen still looks empty, see Troubleshooting below.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### "SSH tunnel is not active" or chat hangs
|
||||
|
||||
On **Linux/macOS** versions ≤ 0.4.3 there is a known
|
||||
`ControlPersist` lifecycle bug — the SSH process exits immediately,
|
||||
making the desktop think the tunnel died even though port-forwarding is
|
||||
alive. See [#195][#195] and [#159][#159]. Upgrade to a build that
|
||||
includes [PR #204][#204] or apply the fix from those issues.
|
||||
|
||||
### "Permission denied (publickey)" from the desktop, but my key works in the terminal
|
||||
|
||||
Most common causes:
|
||||
|
||||
- You use a different key from your terminal (via `~/.ssh/config` host
|
||||
alias or `ssh-agent`) than the path you configured in the desktop. The
|
||||
desktop only uses the explicit key file you give it (`BatchMode=yes`
|
||||
disables agent fallback negotiation in some configurations).
|
||||
- The key has a passphrase and is unlocked only in the agent. Either
|
||||
remove the passphrase or ensure the agent is loaded before launching
|
||||
Hermes Desktop.
|
||||
|
||||
### Screens are empty even after switching to SSH Tunnel mode
|
||||
|
||||
You're almost certainly SSH'ing in as the wrong user — `~/.hermes`
|
||||
resolves to that user's home, not where Hermes actually keeps its data.
|
||||
Verify with:
|
||||
|
||||
```bash
|
||||
ssh -i <key> <user>@<host> 'ls -la ~/.hermes && pwd'
|
||||
```
|
||||
|
||||
The directory should contain `SOUL.md`, `config.yaml`, `auth.json`,
|
||||
`memories/`, `profiles/`, etc. If you see `No such file or directory`,
|
||||
you're in the wrong account — re-read the **"Which user account"**
|
||||
section above.
|
||||
|
||||
### Settings → Hermes Agent shows blank Engine / Released / Python / OpenAI SDK
|
||||
|
||||
Production installs commonly ship `/usr/local/bin/hermes` as a
|
||||
`sudo -u hermes …` wrapper, and the sudoers policy refuses to run the
|
||||
wrapper as the `hermes` user itself ("Sorry, user hermes is not allowed
|
||||
to execute …"). The result: `sshGetHermesVersion` returns empty and the
|
||||
Settings card renders four blanks while everything else works.
|
||||
|
||||
Fixed in [PR #205][#205] by probing the venv binary directly. If your
|
||||
build pre-dates that fix, you can verify locally with:
|
||||
|
||||
```bash
|
||||
ssh <user>@<host> '/opt/hermes/hermes-agent/.venv/bin/hermes --version'
|
||||
```
|
||||
|
||||
A working version string means the fix will populate the card once your
|
||||
build includes #205.
|
||||
|
||||
### Kanban shows "Kanban requires a local Hermes install"
|
||||
|
||||
This screen is not yet wired for remote/SSH mode (the UI explicitly
|
||||
says "Remote/SSH support is coming in a follow-up"). All other
|
||||
management screens work in SSH tunnel mode; Kanban is the one
|
||||
exception. Track upstream for the follow-up PR.
|
||||
|
||||
### Office (Claw3D) offers to install Claw3D locally
|
||||
|
||||
The Office screen detects Claw3D on the desktop host, not on the VPS.
|
||||
If you're already running `hermes-office.service` on the VPS, that
|
||||
service is independent of this screen — visit it directly at
|
||||
`http://<vps>:3000`. Tighter integration is tracked in
|
||||
[#196](https://github.com/fathah/hermes-desktop/issues/196).
|
||||
|
||||
### `Test SSH Connection` succeeds but chat fails with 401 or auth errors
|
||||
|
||||
Hermes API may require an API key locally even when bound to
|
||||
`127.0.0.1`. Configure it in the desktop app's Settings → API key (or
|
||||
leave blank if the gateway is configured for no-auth on localhost). The
|
||||
key, if used, is the one stored in your remote Hermes `.env`/`auth.json`,
|
||||
not a value you generate on the desktop.
|
||||
|
||||
### Windows-specific: keys not persisting across restarts
|
||||
|
||||
Tracked in [#182][#182]. If you hit this, store the desktop's API key
|
||||
and SSH key path in a password manager and re-paste after a Windows
|
||||
restart until the upstream fix lands.
|
||||
|
||||
## Security notes
|
||||
|
||||
- The SSH tunnel binds **only** to `127.0.0.1` on the desktop side. The
|
||||
remote Hermes port is **not** exposed to the public internet at any
|
||||
point in this flow.
|
||||
- `BatchMode=yes` means a stolen desktop without an unlocked SSH key
|
||||
cannot impersonate you to the remote Hermes — there's no password to
|
||||
steal and no key-loading prompt to manipulate.
|
||||
- `StrictHostKeyChecking=accept-new` trusts the host key on first
|
||||
connection and pins it in `~/.ssh/known_hosts` after that. If the
|
||||
remote host key ever changes (e.g. server reinstall), SSH will fail
|
||||
closed and you'll need to manually re-trust it. This is the desired
|
||||
behavior — don't change it.
|
||||
- Authorize the desktop's pubkey only on the dedicated Hermes user, not
|
||||
on root. The Hermes user is already what runs the agent; giving it
|
||||
inbound SSH does not expand the blast radius.
|
||||
|
||||
[#159]: https://github.com/fathah/hermes-desktop/issues/159
|
||||
[#182]: https://github.com/fathah/hermes-desktop/issues/182
|
||||
[#195]: https://github.com/fathah/hermes-desktop/issues/195
|
||||
[#204]: https://github.com/fathah/hermes-desktop/pull/204
|
||||
[#205]: https://github.com/fathah/hermes-desktop/pull/205
|
||||
@@ -0,0 +1,270 @@
|
||||
# Chat Reconciliation Regression Playbook
|
||||
|
||||
This playbook is the repeatable gate for the sandboxed Hermes One reconciliation work.
|
||||
Run it after Hermes One changes, Hermes Agent engine updates, compatibility
|
||||
addon changes, or remote/SSH lab changes.
|
||||
|
||||
## Scope
|
||||
|
||||
The goal is to verify that Hermes One uses the dashboard event stream as the
|
||||
active-turn source of truth without losing behavior that existed in the legacy
|
||||
desktop app:
|
||||
|
||||
- ordered assistant output, reasoning, tool calls, tool results, errors, and
|
||||
artifacts;
|
||||
- restored sessions that match live sessions;
|
||||
- successful continuation after restoring a session;
|
||||
- recovery from failed provider turns;
|
||||
- local, Remote HTTP, and SSH dashboard parity;
|
||||
- legacy fallback still available where configured.
|
||||
|
||||
## Automated Gate
|
||||
|
||||
Run from the separate development worktree:
|
||||
|
||||
```powershell
|
||||
cd C:\Users\pmos6\Documents\Claude\Projects\Hermes-Desktop-reconcile
|
||||
|
||||
npm run typecheck
|
||||
npm test -- --run tests/remote-sessions.test.ts tests/remote-metadata.test.ts tests/remote-models.test.ts tests/dashboard-chat-transport.test.ts tests/dashboard-event-adapter.test.ts tests/live-tool-events.test.ts tests/live-reasoning-events.test.ts tests/tool-activity-group-title.test.ts tests/reconcile-streamed-with-db.test.ts tests/session-history-mapping.test.ts tests/sessions-history-items.test.ts tests/sessions-decode-content.test.ts src/renderer/src/screens/Chat/mediaUtils.test.ts src/renderer/src/screens/Chat/hooks/useChatIPC.test.tsx tests/chat-messages.test.ts tests/session-continuation-store.test.ts tests/dashboard-remote.test.ts tests/dashboard-launch.test.ts tests/dashboard-gateway-client.test.ts tests/hermes-agent-compat.test.ts tests/run-stream.test.ts src/renderer/src/screens/Sessions/Sessions.test.tsx
|
||||
```
|
||||
|
||||
Expected result:
|
||||
|
||||
- TypeScript node and web checks pass.
|
||||
- All listed test files pass.
|
||||
|
||||
When time permits, also run the entire suite:
|
||||
|
||||
```powershell
|
||||
npm test -- --run
|
||||
```
|
||||
|
||||
## Lab Setup
|
||||
|
||||
Start the disposable Remote HTTP target:
|
||||
|
||||
```powershell
|
||||
powershell -NoProfile -ExecutionPolicy Bypass -File scripts\remote-lab.ps1 up
|
||||
powershell -NoProfile -ExecutionPolicy Bypass -File scripts\remote-lab.ps1 status
|
||||
```
|
||||
|
||||
Start the SSH tunnel lab:
|
||||
|
||||
```powershell
|
||||
powershell -NoProfile -ExecutionPolicy Bypass -File scripts\ssh-lab.ps1 up
|
||||
powershell -NoProfile -ExecutionPolicy Bypass -File scripts\ssh-lab.ps1 status
|
||||
```
|
||||
|
||||
Start the sandboxed Hermes One instance:
|
||||
|
||||
```powershell
|
||||
npm run dev:sandbox
|
||||
```
|
||||
|
||||
Pass criteria:
|
||||
|
||||
- Remote status shows dashboard status, dashboard session auth, and legacy
|
||||
OpenAI models auth as OK.
|
||||
- SSH status reaches the remote dashboard through the tunnel.
|
||||
- Hermes One title bar says `Hermes One`.
|
||||
- Settings shows the active connection mode and active chat transport.
|
||||
|
||||
## Connection Matrix
|
||||
|
||||
Run the manual cases below in these modes:
|
||||
|
||||
- Local, dashboard auto.
|
||||
- Local, dashboard forced.
|
||||
- Local, legacy fallback.
|
||||
- Remote HTTP, dashboard auto.
|
||||
- Remote HTTP, dashboard forced.
|
||||
- Remote HTTP, legacy fallback.
|
||||
- SSH, dashboard auto.
|
||||
- SSH, dashboard forced.
|
||||
- SSH, legacy fallback.
|
||||
|
||||
For auto modes, Settings should show the resolved active path. Switching between
|
||||
Local, Remote HTTP, and SSH should refresh:
|
||||
|
||||
- model selector rows;
|
||||
- Models page rows;
|
||||
- Sessions page rows;
|
||||
- Settings Hermes Agent metadata.
|
||||
|
||||
Remote HTTP and SSH should show models configured on the remote Hermes Agent
|
||||
side, not the local desktop's model library.
|
||||
|
||||
## Manual Cases
|
||||
|
||||
### 1. Clean Text Turn
|
||||
|
||||
Prompt:
|
||||
|
||||
```text
|
||||
Reply with exactly TEXT_OK and no tools.
|
||||
```
|
||||
|
||||
Pass criteria:
|
||||
|
||||
- Streaming starts promptly.
|
||||
- Final assistant message is present once.
|
||||
- Restoring the session shows the user prompt and assistant answer once.
|
||||
|
||||
### 2. Bad Provider Then Recovery
|
||||
|
||||
Switch to a known-bad model/provider.
|
||||
|
||||
Prompt:
|
||||
|
||||
```text
|
||||
Live regression bad provider turn. Reply with BAD_KEY_SHOULD_FAIL.
|
||||
```
|
||||
|
||||
Then switch to a known-good model/provider in the same visible session.
|
||||
|
||||
Prompt:
|
||||
|
||||
```text
|
||||
Live regression recovery after bad provider. Reply with exactly RECOVERY_AFTER_BAD_OK and do not mention BAD_KEY_SHOULD_FAIL. Do not use tools.
|
||||
```
|
||||
|
||||
Pass criteria:
|
||||
|
||||
- Failed turn appears as an error in sequence.
|
||||
- Good-provider recovery does not repeat the bad-provider error.
|
||||
- Restored session keeps both user prompts, the error, and the recovery answer
|
||||
in order.
|
||||
- Repeating failed/non-failed/failed/non-failed turns keeps every semi-session
|
||||
boundary in order.
|
||||
|
||||
### 3. Reasoning And Interleaved Output
|
||||
|
||||
Use a reasoning-heavy model such as DeepSeek V4 Pro.
|
||||
|
||||
Prompt:
|
||||
|
||||
```text
|
||||
Think briefly, then answer in two short sentences. Mention the words FIRST and SECOND in separate sentences.
|
||||
```
|
||||
|
||||
Pass criteria:
|
||||
|
||||
- Reasoning/thought blocks appear when the backend emits them.
|
||||
- Intermediate assistant output stays in sequence with reasoning and tools.
|
||||
- Restored session preserves reasoning blocks and assistant text order.
|
||||
|
||||
### 4. Tool-Heavy Image Generation
|
||||
|
||||
Use the AI Playground / ComfyUI skill.
|
||||
|
||||
Prompt:
|
||||
|
||||
```text
|
||||
Generate an image of a toy duck in a bathtub using AI Playground / ComfyUI. Save it to the media folder if the workflow supports that, and show me the resulting file path.
|
||||
```
|
||||
|
||||
Pass criteria:
|
||||
|
||||
- Tool calls and tool results stream in order when emitted.
|
||||
- Sequential tool calls are grouped as `N tools called`.
|
||||
- Inside each group, each tool call is paired with its matching result.
|
||||
- Final answer does not duplicate the same image because of markdown/path
|
||||
parsing.
|
||||
- Existing local Windows paths render as media when they exist.
|
||||
- Remote/SSH paths under `/opt/data/images` render through the dashboard media
|
||||
endpoint.
|
||||
- Restored session shows the same grouped calls/results and the same media.
|
||||
|
||||
### 5. Pasted Image Prompt
|
||||
|
||||
Paste an image into the prompt box.
|
||||
|
||||
Prompt:
|
||||
|
||||
```text
|
||||
What is this?
|
||||
```
|
||||
|
||||
Pass criteria:
|
||||
|
||||
- The user bubble appears once.
|
||||
- The pasted image thumbnail appears in the user bubble.
|
||||
- Hermes Agent fallback text such as `[The user attached an image but analysis failed.]`
|
||||
is not shown as user-visible prompt content.
|
||||
- The same session restored from Sessions still shows the image thumbnail, not
|
||||
raw fallback text.
|
||||
- Continuing the restored session does not duplicate the prior pasted-image
|
||||
prompt.
|
||||
|
||||
### 6. Remote Vision Tool
|
||||
|
||||
In Remote HTTP and SSH dashboard modes, ask about a known image using a text-only
|
||||
chat model plus configured auxiliary vision.
|
||||
|
||||
Prompt:
|
||||
|
||||
```text
|
||||
Use vision_analyze to describe the attached image in one short sentence.
|
||||
```
|
||||
|
||||
Pass criteria:
|
||||
|
||||
- `vision_analyze` returns semantic visual content, not just pixel statistics.
|
||||
- The remote lab resolves auxiliary vision to OpenRouter
|
||||
`google/gemini-3-flash-preview` when an OpenRouter key is available.
|
||||
|
||||
Optional container smoke test:
|
||||
|
||||
```powershell
|
||||
docker exec hermes-two-remote-lab-agent sh -lc 'cd /opt/hermes && HERMES_HOME=/opt/data /opt/hermes/.venv/bin/python3 - <<\"PY\"
|
||||
import asyncio
|
||||
from tools.vision_tools import vision_analyze_tool
|
||||
async def main():
|
||||
out = await vision_analyze_tool("/opt/data/images/duck_bathtub.png", "Describe this image in one short sentence.")
|
||||
print(out[:1200])
|
||||
asyncio.run(main())
|
||||
PY'
|
||||
```
|
||||
|
||||
### 7. Session Search, Restore, Continue
|
||||
|
||||
For each connection mode:
|
||||
|
||||
1. Open Sessions.
|
||||
2. Restore the newest session from that mode.
|
||||
3. Verify the visible transcript matches the original live transcript.
|
||||
4. Send:
|
||||
|
||||
```text
|
||||
Continue this session with exactly CONTINUE_OK and no tools.
|
||||
```
|
||||
|
||||
Pass criteria:
|
||||
|
||||
- Sessions list belongs to the active connection mode.
|
||||
- Session restore does not mix local, Remote HTTP, and SSH sessions.
|
||||
- Continuing a restored dashboard session does not produce `session not found`.
|
||||
- Continuing does not write the remote/SSH session into local-only history.
|
||||
|
||||
## Known Upstream Limitations
|
||||
|
||||
- Gemini failures in the current lab have been traced to Hermes Agent upstream
|
||||
behavior, not Hermes One dashboard reconciliation.
|
||||
- Plain Remote HTTP cannot be patched by Hermes One unless the target exposes a
|
||||
future deploy endpoint or is also reachable over SSH.
|
||||
- The remote lab intentionally bridges to this Windows host's AI Playground
|
||||
ComfyUI for testing. That is not normal remote deployment behavior.
|
||||
|
||||
## Exit Criteria
|
||||
|
||||
Before asking for review or preparing a PR:
|
||||
|
||||
- Automated gate passes.
|
||||
- Remote and SSH lab health checks pass.
|
||||
- At least one dashboard-mode live pass succeeds for Local, Remote HTTP, and
|
||||
SSH.
|
||||
- Legacy fallback is checked at least once after any change that touches legacy
|
||||
IPC or `/v1` paths.
|
||||
- Any known failure is classified as Hermes One, Hermes Agent upstream, lab
|
||||
setup, or provider/service behavior.
|
||||
@@ -0,0 +1,90 @@
|
||||
# Remote Access Lab
|
||||
|
||||
This lab creates a disposable remote Hermes target for the sandboxed Hermes One instance without
|
||||
touching the normal Hermes One worktree, config, or database.
|
||||
|
||||
## Shape
|
||||
|
||||
- Lab state lives in `.sandbox/remote-lab/hermes-home`.
|
||||
- Containers are named `hermes-two-remote-lab-*`.
|
||||
- The desktop connects to one URL: `http://127.0.0.1:19080`.
|
||||
- A small nginx proxy routes:
|
||||
- `/api/*` and `/api/ws` to the Hermes dashboard service in the agent container.
|
||||
- `/v1/*` to the OpenAI-compatible API server in the same agent container.
|
||||
- One disposable token is used for both:
|
||||
- `X-Hermes-Session-Token` dashboard auth.
|
||||
- `Authorization: Bearer ...` legacy API auth.
|
||||
- The Hermes Agent image is built from the bundled checkout at
|
||||
`.sandbox/hermes-home/hermes-agent` into `hermes-two-remote-lab-agent:local`,
|
||||
because the public `nousresearch/hermes-agent:latest` image can lag the
|
||||
bundled dashboard auth contract.
|
||||
- The lab copies working model configuration, then strips messaging/webhook
|
||||
platform credentials and opts out of bundled skill sync to avoid side effects.
|
||||
- For regression testing only, the lab exports `COMFYUI_HOST` as
|
||||
`http://host.docker.internal:49000` so Remote HTTP and SSH-dashboard tests can
|
||||
use this Windows machine's AI Playground ComfyUI. Normal remote deployments
|
||||
should not rely on access to connecting-host resources.
|
||||
- Remote-generated images should be copied into `/opt/data/images` and surfaced
|
||||
as `MEDIA:/opt/data/images/<file>.png` when possible. That path is under the
|
||||
Hermes home media roots exposed by the upstream dashboard `/api/media`
|
||||
endpoint.
|
||||
- If the copied config has an `OPENROUTER_API_KEY`, the lab pins
|
||||
`auxiliary.vision` to OpenRouter with `google/gemini-3-flash-preview`. This
|
||||
keeps `vision_analyze` on a vision-capable auxiliary model even when the
|
||||
active chat model is a text-only custom provider such as DeepSeek.
|
||||
|
||||
## Commands
|
||||
|
||||
Run from the separate development worktree:
|
||||
|
||||
```powershell
|
||||
cd C:\Users\pmos6\Documents\Claude\Projects\Hermes-Desktop-reconcile
|
||||
|
||||
powershell -NoProfile -ExecutionPolicy Bypass -File scripts\remote-lab.ps1 init
|
||||
powershell -NoProfile -ExecutionPolicy Bypass -File scripts\remote-lab.ps1 up
|
||||
powershell -NoProfile -ExecutionPolicy Bypass -File scripts\remote-lab.ps1 status
|
||||
powershell -NoProfile -ExecutionPolicy Bypass -File scripts\remote-lab.ps1 configure-desktop
|
||||
```
|
||||
|
||||
Stop the lab:
|
||||
|
||||
```powershell
|
||||
powershell -NoProfile -ExecutionPolicy Bypass -File scripts\remote-lab.ps1 down
|
||||
```
|
||||
|
||||
Remove all lab state:
|
||||
|
||||
```powershell
|
||||
powershell -NoProfile -ExecutionPolicy Bypass -File scripts\remote-lab.ps1 clean
|
||||
```
|
||||
|
||||
## Expected Probes
|
||||
|
||||
`status` should show three OK checks:
|
||||
|
||||
- `dashboard status`
|
||||
- `dashboard sessions auth`
|
||||
- `legacy OpenAI models auth`
|
||||
|
||||
If Docker Desktop returns pipe/API 500 errors or hangs on `docker ps`, restart
|
||||
Docker Desktop or run:
|
||||
|
||||
```powershell
|
||||
wsl --shutdown
|
||||
```
|
||||
|
||||
Then reopen Docker Desktop and retry `scripts\remote-lab.ps1 up`.
|
||||
|
||||
The first `up` may take several minutes because Docker builds the local Hermes
|
||||
Agent image. Later starts reuse the image cache.
|
||||
|
||||
## Hermes One Sandbox Settings
|
||||
|
||||
After `configure-desktop`, the sandboxed Hermes One instance is set to:
|
||||
|
||||
- Connection mode: Remote
|
||||
- Remote URL: `http://127.0.0.1:19080`
|
||||
- Chat transport: Auto
|
||||
|
||||
That is the intended regression-test mode: dashboard first, legacy fallback
|
||||
available through the same remote URL.
|
||||
@@ -0,0 +1,97 @@
|
||||
# SSH dashboard transport — design for full support (issue #667)
|
||||
|
||||
Status: **design / not yet implemented.** A reliable legacy-HTTP fallback shipped first (see
|
||||
"Shipped now" below). This document captures the design for making the _dashboard_ chat transport
|
||||
(profile switching, session history, slash commands, background prompts) work over an SSH tunnel.
|
||||
|
||||
## Background: why SSH dashboard chat is broken
|
||||
|
||||
The desktop's dashboard chat transport speaks WebSocket JSON-RPC at **`/api/ws`**
|
||||
(`src/renderer/src/screens/Chat/dashboardGatewayClient.ts`, URL built in `src/main/dashboard.ts`).
|
||||
|
||||
In `hermes-agent`, `/api/ws` is served **only** by `hermes dashboard`
|
||||
(`hermes_cli/web_server.py` → `start_server`, gated by `_DASHBOARD_EMBEDDED_CHAT_ENABLED = True`). It is
|
||||
**never** served by `hermes gateway` (`gateway/...`, the api_server, which serves `/v1/chat/completions`,
|
||||
`/health`, etc.).
|
||||
|
||||
But SSH mode today:
|
||||
|
||||
1. Starts `hermes gateway start` on the remote — `buildGatewayStartCommand` in `src/main/ssh-remote.ts`.
|
||||
2. Tunnels the **gateway** port (`config.remotePort`, default 8642) — `ensureSshTunnel` in
|
||||
`src/main/ssh-tunnel.ts`.
|
||||
3. Connects `ws://127.0.0.1:{tunnelPort}/api/ws` — which 404s on the gateway.
|
||||
|
||||
A second, independent blocker: `/api/ws` authenticates with `HERMES_DASHBOARD_SESSION_TOKEN`
|
||||
(`web_server.py` `_SESSION_TOKEN`; `?token=<…>` on loopback), but the SSH path passes the remote
|
||||
`API_SERVER_KEY` (`sshReadRemoteApiKey` in `src/main/ssh-remote.ts`). Even a correctly-tunneled
|
||||
dashboard would reject the WS upgrade.
|
||||
|
||||
## Shipped now: reliable legacy fallback
|
||||
|
||||
`src/renderer/src/screens/Chat/hooks/useDashboardChatTransport.ts` now latches a sticky
|
||||
`dashboardUnavailableRef` on the first failed `ensureClient` for a remote/SSH connection, so subsequent
|
||||
messages fall back to the working legacy HTTP transport (`/v1/chat/completions` through the tunnel)
|
||||
_immediately_ instead of re-running the multi-second status+probe each time. It also fires
|
||||
`onDashboardUnavailable` once, which `Chat.tsx` surfaces as a one-time toast. The flag resets on any
|
||||
connection change. This makes SSH chat **work** (degraded: no profile switching / session history /
|
||||
dashboard slash commands).
|
||||
|
||||
## Full design: run a remote `hermes dashboard` and tunnel it
|
||||
|
||||
Goal: restore the dashboard transport over SSH by talking to a real remote `hermes dashboard`.
|
||||
|
||||
### 1. Start the remote dashboard (not the gateway)
|
||||
|
||||
Add `sshStartDashboard(config, sessionToken, port)` in `src/main/ssh-remote.ts`, mirroring
|
||||
`buildGatewayStartCommand`. It should run, detached:
|
||||
|
||||
```
|
||||
HERMES_DASHBOARD_SESSION_TOKEN=<sessionToken> \
|
||||
nohup hermes dashboard --no-open --host 127.0.0.1 --port <port> \
|
||||
> $HOME/.hermes/dashboard.log 2>&1 &
|
||||
```
|
||||
|
||||
This mirrors the **local** spawn in `src/main/hermes.ts:559` (`dashboard --no-open --host 127.0.0.1
|
||||
--port <port>`, gated by `HERMES_DASHBOARD_SESSION_TOKEN`) — reuse that flag/arg shape. Add a matching
|
||||
status/stop command pair (`buildDashboardStatusCommand` / `buildDashboardStopCommand`).
|
||||
|
||||
### 2. Tunnel the dashboard port (in addition to / instead of the gateway port)
|
||||
|
||||
The legacy fallback still needs the gateway tunnel for `/v1/chat/completions`, while the dashboard
|
||||
transport needs the dashboard's `/api/ws` + `/api/sessions` + `/api/status`. Options:
|
||||
|
||||
- **Second forward**: generalize `ensureSshTunnel` / `getSshTunnelUrl` in `src/main/ssh-tunnel.ts` to
|
||||
manage a named set of forwards (gateway + dashboard), each `localPort → 127.0.0.1:remotePort`.
|
||||
- Remote dashboard port: pick a free remote port over SSH (run a tiny Python one-liner like the
|
||||
existing helpers in `ssh-remote.ts`) or document a fixed default; surface it in the SSH config UI.
|
||||
|
||||
### 3. Authenticate `/api/ws` with the session token
|
||||
|
||||
The desktop generates a `sessionToken` (e.g. `randomUUID()`), exports it to the remote dashboard's env
|
||||
(step 1), and builds the WS URL as `ws://127.0.0.1:{dashTunnelPort}/api/ws?token=<sessionToken>` —
|
||||
replacing `API_SERVER_KEY`. Rewrite `sshDashboardConnectionFromConfig` in `src/main/dashboard.ts` to
|
||||
this flow (it currently calls `sshStartGateway` + `sshReadRemoteApiKey`). Note `web_server.py`'s
|
||||
`_ws_auth_ok` accepts the `?token=` query only on loopback / `--insecure`; over the SSH tunnel the
|
||||
endpoint is loopback on the remote, so this should hold — **verify on a real host**.
|
||||
|
||||
### 4. Compatibility
|
||||
|
||||
`ensureSshDashboardCompatibility` (`src/main/hermes-agent-compat.ts`) already patches `web_server.py`'s
|
||||
embedded-chat default and `/api/model/set`; keep it. Confirm the remote `hermes` build accepts
|
||||
`dashboard --no-open --host --port` and the `HERMES_DASHBOARD_SESSION_TOKEN` env (v0.16.0 has
|
||||
`_DASHBOARD_EMBEDDED_CHAT_ENABLED = True`, so embedded chat is available).
|
||||
|
||||
### 5. Lifecycle
|
||||
|
||||
- Stop the remote `hermes dashboard` on disconnect / app quit (best-effort, like `sshStopGateway`).
|
||||
- Health-check the dashboard (`/api/status` through the tunnel) and restart on failure.
|
||||
- Keep the gateway running too if other features depend on it.
|
||||
|
||||
## Testing
|
||||
|
||||
- **Unit**: command builders (`sshStartDashboard` / status / stop) and the multi-forward tunnel wiring,
|
||||
following the existing `ssh-remote` / `ssh-tunnel` test patterns.
|
||||
- **Manual E2E (required before merge)**: an SSH host running `hermes-agent` (see
|
||||
`docs/SSH-TUNNEL-VPS.md`). Verify: tunnel up → dashboard starts remotely → `/api/ws` connects with the
|
||||
session token → a sent message streams back → profile switching and session history work. This step
|
||||
cannot be exercised without a real remote host.
|
||||
@@ -0,0 +1,235 @@
|
||||
# Windows (winget) and Fedora (RPM) release automation
|
||||
|
||||
**Status:** Approved (brainstorming) — pending implementation plan
|
||||
**Date:** 2026-04-30
|
||||
**Branch target:** `Aiacos/hermes-desktop:feat/winget-rpm-release` → PR upstream `fathah/hermes-desktop:main`
|
||||
|
||||
## Goal
|
||||
|
||||
Extend the existing release pipeline so it produces:
|
||||
|
||||
1. **Windows artifacts** — an NSIS installer (`.exe`) published in each GitHub Release, plus winget manifests (`Installer.yaml`, `Locale.en-US.yaml`, `Version.yaml`) generated as a CI artifact for manual submission to `microsoft/winget-pkgs`.
|
||||
2. **Fedora artifacts** — an unsigned `.rpm` published in each GitHub Release, alongside the existing `.AppImage` and `.deb`.
|
||||
|
||||
No changes to macOS. The existing `.AppImage` / `.deb` artifacts are rebuilt by the same job that now also produces `.rpm`; the only adjacent change touching them is shared `linux.*` metadata (`synopsis`, `description`, `vendor`) which improves their packaging metadata too. No code-signing introduced (none available). No COPR repository, no auto-submission to `microsoft/winget-pkgs`.
|
||||
|
||||
## Non-goals
|
||||
|
||||
- Code signing for Windows (no certificate available; SmartScreen warning persists).
|
||||
- macOS notarization changes.
|
||||
- GPG-signing the RPM (no signing key available; users install with `sudo dnf install ./file.rpm` or `--nogpgcheck`).
|
||||
- A Fedora COPR repository.
|
||||
- Automated PR submission to `microsoft/winget-pkgs` (would require a GitHub PAT in upstream secrets, which the PR author does not control).
|
||||
- Auto-update support for `.rpm` (limitation of `electron-updater`).
|
||||
- Windows ARM64 build (requires extra toolchain; nobody is testing it).
|
||||
|
||||
## Architecture
|
||||
|
||||
The existing `release.yml` workflow is extended in place. Two new jobs are added; one existing job is extended; a `dry_run` input is added to the manual-dispatch trigger.
|
||||
|
||||
```
|
||||
release.yml (extended)
|
||||
├─ prepare [ubuntu] unchanged + new is_dry_run output
|
||||
├─ release_mac [macos] unchanged
|
||||
├─ release_linux [ubuntu] ADD: rpm to electron-builder targets, install rpm apt package
|
||||
├─ release_windows [windows] NEW: NSIS .exe + .blockmap + latest.yml
|
||||
├─ generate_winget [ubuntu] NEW: SHA256 the .exe, fill manifest templates, upload as artifact
|
||||
└─ publish [ubuntu] gated: skip if is_dry_run == 'true'
|
||||
```
|
||||
|
||||
### Triggers
|
||||
|
||||
- `push` to branch `release` — unchanged. Behaves as a real release: builds run, publish runs.
|
||||
- `workflow_dispatch` — adds `inputs.dry_run` (boolean, default `true`). When `dry_run=true`, all build jobs run; `publish` is skipped.
|
||||
|
||||
The default-true on dispatch is a safety net: a stray click in the Actions UI cannot trigger a real release.
|
||||
|
||||
### Identifiers and naming
|
||||
|
||||
- **Winget `PackageIdentifier`:** `NousResearch.HermesDesktop` (stable, never renamed)
|
||||
- **Winget `Publisher`:** `Nous Research` (free-text; binaries hosted under `fathah/hermes-desktop`, which the moderation review will verify against the `InstallerUrl`)
|
||||
- **Winget `PackageName`:** `Hermes Agent` (matches `productName` in `electron-builder.yml`)
|
||||
- **NSIS scope:** `oneClick: true`, `perMachine: false` — installs into `%LOCALAPPDATA%`, no UAC prompt, aligns with `winget install` default user scope and with the app's existing `~/.hermes` user-state model.
|
||||
- **RPM artifact name:** `hermes-desktop-<version>.rpm` (no spaces, no arch suffix — consistent with the existing `.deb` and `.AppImage` naming. The default `${productName}` would produce `Hermes Agent-...rpm` which breaks `dnf install ./file.rpm`. We explicitly only build `x86_64`, so the missing arch suffix is unambiguous in practice.).
|
||||
|
||||
## File changes
|
||||
|
||||
### Modified
|
||||
|
||||
#### `.github/workflows/release.yml` (~+120 / -10 lines)
|
||||
|
||||
- Add `workflow_dispatch.inputs.dry_run` (boolean, default `true`).
|
||||
- Add `prepare.outputs.is_dry_run` computed as `${{ github.event_name == 'workflow_dispatch' && inputs.dry_run }}` (string `'true'`/`'false'`).
|
||||
- Add new job `release_windows`:
|
||||
- `runs-on: windows-latest`
|
||||
- `needs: prepare`
|
||||
- `if: needs.prepare.outputs.tag_exists == 'false'`
|
||||
- Steps: checkout, setup-node 22 with `cache: npm`, `npm ci`, `npm run build`, `npx electron-builder --win nsis --x64 --publish never`, upload `dist/*.exe`, `dist/*.exe.blockmap`, `dist/latest.yml` as artifact `windows-artifacts`.
|
||||
- Modify existing `release_linux`:
|
||||
- Add `sudo apt-get update && sudo apt-get install -y rpm` step before packaging.
|
||||
- Change packaging command to `npx electron-builder --linux AppImage deb rpm --publish never`.
|
||||
- Extend artifact upload paths to include `dist/*.rpm`.
|
||||
- Add new job `generate_winget`:
|
||||
- `runs-on: ubuntu-latest`
|
||||
- `needs: [prepare, release_windows]`
|
||||
- `if: needs.prepare.outputs.tag_exists == 'false'`
|
||||
- Steps: checkout, download `windows-artifacts` to `dist/`, run `node scripts/generate-winget-manifests.mjs` with env `VERSION` and `PUBLISH_OWNER=fathah`, upload `dist/winget/` as artifact `winget-manifests-${{ needs.prepare.outputs.version }}`.
|
||||
- Modify `publish`:
|
||||
- `needs: [prepare, release_mac, release_linux, release_windows, generate_winget]`
|
||||
- `if: needs.prepare.outputs.is_dry_run == 'false' && needs.prepare.outputs.tag_exists == 'false'`
|
||||
- `download-artifact` step keeps `merge-multiple: true` and downloads into `artifacts/`. The winget manifests artifact ends up under `artifacts/winget/manifests/...` and is **deliberately excluded** from the GitHub Release files (manifests are operator-facing, not user-facing).
|
||||
- The `softprops/action-gh-release` `files` input is changed from the existing `artifacts/*` glob to an **explicit list of patterns** that names each user-facing artifact: `artifacts/*.dmg`, `artifacts/*.zip`, `artifacts/*.AppImage`, `artifacts/*.deb`, `artifacts/*.rpm`, `artifacts/*.exe`, `artifacts/*.blockmap`, `artifacts/latest*.yml`. This is deterministic regardless of how `merge-multiple` lays out subdirectories, and prevents future artifacts from leaking into releases by accident.
|
||||
|
||||
Concurrency block (`group: release`, `cancel-in-progress: true`) remains unchanged. **Caveat (pre-existing, not introduced by this change):** with `cancel-in-progress: true`, a _new_ run cancels the _currently running_ one in the same group. So a stray `workflow_dispatch` triggered during a real release would cancel the release. This risk is low (dispatches are manual; the dispatch defaults to `dry_run=true` which would not produce a tag anyway, but the cancellation of the in-flight real release is the actual hazard). Not addressed here to keep scope focused; flagged for a follow-up that scopes the concurrency group by `github.event_name`.
|
||||
|
||||
#### `electron-builder.yml` (~+15 lines)
|
||||
|
||||
- `linux.target`: add `rpm` (existing `AppImage`, `snap`, `deb` retained).
|
||||
- `linux.synopsis`: short one-line description (required by fpm/rpmbuild for valid RPM metadata).
|
||||
- `linux.description`: longer description.
|
||||
- `linux.vendor`: `Nous Research` (or repo owner of upstream; finalized during implementation).
|
||||
- New `rpm:` block with `artifactName: ${name}-${version}.${ext}`.
|
||||
- `nsis:` block extended with explicit `oneClick: true` and `perMachine: false` (currently relies on electron-builder defaults; making them explicit prevents silent behavior change across electron-builder versions).
|
||||
|
||||
### New
|
||||
|
||||
#### `build/winget/Installer.template.yaml`
|
||||
|
||||
YAML manifest with placeholders: `{{VERSION}}`, `{{INSTALLER_URL}}`, `{{INSTALLER_SHA256}}`, `{{RELEASE_DATE}}`. Format follows winget v1.6 schema, `InstallerType: nullsoft`, `Scope: user`, `MinimumOSVersion: 10.0.17763.0` (Windows 10 1809 — minimum supported by Electron 39).
|
||||
|
||||
#### `build/winget/Locale.en-US.template.yaml`
|
||||
|
||||
Locale manifest with placeholders: `{{VERSION}}`, `{{RELEASE_NOTES_URL}}`. Includes `Publisher: Nous Research`, `PublisherUrl: https://github.com/fathah/hermes-desktop`, `PackageName: Hermes Agent`, `License: MIT`, `LicenseUrl: https://github.com/fathah/hermes-desktop/blob/main/LICENSE`, `ShortDescription`, `Tags: [ai, agent, desktop, electron, llm]`.
|
||||
|
||||
#### `build/winget/Version.template.yaml`
|
||||
|
||||
Root version manifest with placeholders: `{{VERSION}}`. Trivial: `PackageIdentifier`, `PackageVersion`, `DefaultLocale: en-US`, `ManifestType: version`, `ManifestVersion: 1.6.0`.
|
||||
|
||||
#### `scripts/generate-winget-manifests.mjs`
|
||||
|
||||
Node ESM script (~50 lines, zero external deps). Reads `package.json` to get `version` and `name`. Locates `dist/<name>-<version>-setup.exe`. Computes SHA256 with `node:crypto` `createHash('sha256')` over the file. Reads each `*.template.yaml` from `build/winget/`. Replaces all `{{KEY}}` placeholders by string `replaceAll`. Writes output to `dist/winget/manifests/n/NousResearch/HermesDesktop/<version>/`:
|
||||
|
||||
- `NousResearch.HermesDesktop.installer.yaml`
|
||||
- `NousResearch.HermesDesktop.locale.en-US.yaml`
|
||||
- `NousResearch.HermesDesktop.yaml`
|
||||
|
||||
The path mirrors the directory layout in `microsoft/winget-pkgs`, so the operator submitting the PR can `cp -r` directly.
|
||||
|
||||
Exit code 1 with explicit error if the `.exe` is not found.
|
||||
|
||||
#### `package.json`
|
||||
|
||||
Add script `"build:rpm": "npm run build && electron-builder --linux rpm"` for local Fedora developers. CI does not use this script (it calls `npx electron-builder` directly).
|
||||
|
||||
#### `README.md`
|
||||
|
||||
Update the Install section's platform table to add Windows (`.exe` and, once accepted into winget-pkgs, `winget install NousResearch.HermesDesktop`) and Fedora (`.rpm`). Add a note that:
|
||||
|
||||
- The Windows build is unsigned; Windows SmartScreen will warn on first launch.
|
||||
- The `.rpm` is unsigned; install with `sudo dnf install ./hermes-desktop-<version>.x86_64.rpm` (or use `--nogpgcheck` if a system policy enforces signature checking).
|
||||
- Auto-update on Linux is supported only for `.AppImage` builds; `.rpm` and `.deb` users must download new releases manually.
|
||||
|
||||
## Data flow
|
||||
|
||||
### CI build (Windows)
|
||||
|
||||
```
|
||||
checkout
|
||||
→ npm ci → node_modules + electron-builder install-app-deps
|
||||
→ npm run build → out/main + out/preload + out/renderer
|
||||
→ electron-builder --win nsis --x64
|
||||
→ dist/hermes-desktop-<version>-setup.exe
|
||||
→ dist/hermes-desktop-<version>-setup.exe.blockmap
|
||||
→ dist/latest.yml
|
||||
→ upload-artifact "windows-artifacts"
|
||||
```
|
||||
|
||||
### CI generate winget manifests
|
||||
|
||||
```
|
||||
download-artifact "windows-artifacts" → dist/
|
||||
node scripts/generate-winget-manifests.mjs
|
||||
→ SHA256(dist/hermes-desktop-<version>-setup.exe)
|
||||
→ fill 3 templates with VERSION, INSTALLER_URL, INSTALLER_SHA256, RELEASE_DATE
|
||||
→ write dist/winget/manifests/n/NousResearch/HermesDesktop/<version>/*.yaml
|
||||
upload-artifact "winget-manifests-<version>"
|
||||
```
|
||||
|
||||
### CI build (Linux)
|
||||
|
||||
```
|
||||
checkout
|
||||
→ npm ci
|
||||
→ npm run build
|
||||
→ apt install rpm
|
||||
→ electron-builder --linux AppImage deb rpm
|
||||
→ dist/hermes-desktop-<version>.AppImage
|
||||
→ dist/hermes-desktop-<version>.deb
|
||||
→ dist/hermes-desktop-<version>.x86_64.rpm
|
||||
→ dist/latest-linux.yml
|
||||
→ upload-artifact "linux-artifacts"
|
||||
```
|
||||
|
||||
### Publish (only on real release)
|
||||
|
||||
```
|
||||
download-artifact merge-multiple=true → artifacts/
|
||||
*.dmg, *.zip, *.AppImage, *.deb, *.rpm, *.exe, *.blockmap, latest-*.yml, latest.yml
|
||||
+ artifacts/winget/manifests/... (excluded from release files glob)
|
||||
git tag v<version> + push
|
||||
softprops/action-gh-release files=artifacts/* (excludes winget/ subdir)
|
||||
```
|
||||
|
||||
## Error handling and edge cases
|
||||
|
||||
1. **Missing NSIS installer in `generate_winget` job:** The script exits with code 1 and a clear message. Failure is loud, not silent.
|
||||
2. **`rpm` package missing on Linux runner:** electron-builder fails with a clear "Cannot find rpmbuild" error. We pre-install via apt, so this should not surface.
|
||||
3. **Tag already exists:** All build jobs gate on `tag_exists == 'false'`; new jobs replicate this guard.
|
||||
4. **Concurrency cancellation (pre-existing behavior):** With `cancel-in-progress: true`, a new run in the same group cancels the currently running one. A stray dispatch during a real release would cancel the release. Mitigation: dispatches are manual and rare; the safer fix (per-event-type concurrency group) is intentionally out of scope here.
|
||||
5. **`workflow_dispatch` on a branch other than `release`:** Allowed and intended — that is how E2 verification works. The `prepare` step still reads version from `package.json`, so it builds whatever version is on the branch. Real releases only happen on `release` branch (no `is_dry_run` short-circuit there because `is_dry_run` is `false` for push events by definition).
|
||||
6. **Operator submits stale manifests:** The CI artifact name includes the version (`winget-manifests-<version>`), so it cannot be confused with a different release's manifests. The operator copies the directory wholesale into their `microsoft/winget-pkgs` clone.
|
||||
7. **Manifest schema validation:** Validated by the `microsoft/winget-pkgs` moderation bot at PR-submission time. Common failure modes (missing `MinimumOSVersion`, invalid `License`, mismatched `InstallerType`) are addressed up front in the templates.
|
||||
|
||||
## Verification plan
|
||||
|
||||
### Local
|
||||
|
||||
1. Create branch `feat/winget-rpm-release`.
|
||||
2. Apply all file changes.
|
||||
3. `npm run lint`
|
||||
4. `npm run typecheck`
|
||||
5. `npm run test`
|
||||
6. `npm run build:rpm` — produces a real `.rpm` on this Fedora host (sanity-checks the new electron-builder rpm target).
|
||||
7. `node scripts/generate-winget-manifests.mjs` against a placeholder `dist/<name>-<version>-setup.exe` (created via `dd if=/dev/urandom of=dist/... bs=1M count=1` to provide arbitrary content) — verifies that the generator produces three valid YAML files with consistent placeholders replaced.
|
||||
8. (Optional) `actionlint .github/workflows/release.yml` if installed.
|
||||
|
||||
### CI on fork
|
||||
|
||||
9. Push `feat/winget-rpm-release` to `Aiacos/hermes-desktop`.
|
||||
10. Trigger `workflow_dispatch` from the Actions UI on `feat/winget-rpm-release` with `dry_run=true`.
|
||||
11. Verify all build jobs succeed:
|
||||
- `prepare` ✓
|
||||
- `release_mac` (x64 + arm64) ✓
|
||||
- `release_linux` (with `.rpm`) ✓
|
||||
- `release_windows` ✓
|
||||
- `generate_winget` ✓
|
||||
- `publish` SKIPPED (status: skipped, not failed)
|
||||
12. Download the `winget-manifests-<version>` artifact and inspect the three YAML files for correctness (URL, SHA256, version, no leftover placeholders).
|
||||
|
||||
### PR
|
||||
|
||||
13. Open PR `Aiacos:feat/winget-rpm-release` → `fathah:main`. PR description summarizes what was added, the verification done, and the manual steps the maintainer has to take post-merge to actually publish to winget (download manifest artifact from the first real release run, submit to `microsoft/winget-pkgs`).
|
||||
|
||||
## Open questions deferred to implementation
|
||||
|
||||
- Exact wording of `linux.synopsis` and `linux.description` (will follow `package.json.description` style).
|
||||
- Final value of `linux.vendor` (`Nous Research` vs `fathah`).
|
||||
- Whether to include `manifestVersion: 1.10.0` (current latest) or stick with `1.6.0` (more compatible with older `winget` clients). Default to `1.6.0` unless implementation reveals required fields.
|
||||
|
||||
## Out-of-scope follow-ups (future PRs, if desired)
|
||||
|
||||
- Auto-submit winget PR via `vedantmgoyal2009/winget-releaser` action (requires GitHub PAT in upstream secrets).
|
||||
- Fedora COPR repository for `dnf install` integration.
|
||||
- GPG-signing the RPM.
|
||||
- Windows code signing.
|
||||
- Windows ARM64 build.
|
||||
@@ -0,0 +1,73 @@
|
||||
appId: com.nousresearch.hermes
|
||||
productName: Hermes One
|
||||
directories:
|
||||
buildResources: build
|
||||
files:
|
||||
- "!**/.vscode/*"
|
||||
- "!src/*"
|
||||
- "!electron.vite.config.{js,ts,mjs,cjs}"
|
||||
- "!{.eslintcache,eslint.config.mjs,.prettierignore,.prettierrc.yaml,dev-app-update.yml,CHANGELOG.md,README.md}"
|
||||
- "!{.env,.env.*,.npmrc,pnpm-lock.yaml}"
|
||||
- "!{tsconfig.json,tsconfig.node.json,tsconfig.web.json}"
|
||||
asarUnpack:
|
||||
- resources/**
|
||||
- node_modules/better-sqlite3/build/Release/*.node
|
||||
win:
|
||||
executableName: hermes-agent
|
||||
target:
|
||||
- nsis
|
||||
- portable
|
||||
portable:
|
||||
artifactName: ${name}-${version}-portable.${ext}
|
||||
nsis:
|
||||
artifactName: ${name}-${version}-setup.${ext}
|
||||
shortcutName: ${productName}
|
||||
uninstallDisplayName: ${productName}
|
||||
createDesktopShortcut: always
|
||||
oneClick: true
|
||||
perMachine: false
|
||||
mac:
|
||||
artifactName: ${name}-${version}-${arch}-${os}.${ext}
|
||||
icon: build/icon.icns
|
||||
entitlements: build/entitlements.mac.plist
|
||||
entitlementsInherit: build/entitlements.mac.inherit.plist
|
||||
extendInfo:
|
||||
- NSCameraUsageDescription: Application requests access to the device's camera.
|
||||
- NSMicrophoneUsageDescription: Application requests access to the device's microphone.
|
||||
- NSDocumentsFolderUsageDescription: Application requests access to the user's Documents folder.
|
||||
- NSDownloadsFolderUsageDescription: Application requests access to the user's Downloads folder.
|
||||
hardenedRuntime: true
|
||||
gatekeeperAssess: false
|
||||
notarize: true
|
||||
dmg:
|
||||
artifactName: ${name}-${version}-${arch}.${ext}
|
||||
linux:
|
||||
target:
|
||||
- AppImage
|
||||
- snap
|
||||
- deb
|
||||
- rpm
|
||||
maintainer: electronjs.org
|
||||
vendor: Nous Research
|
||||
category: Utility
|
||||
synopsis: Self-improving AI assistant desktop app
|
||||
description: >-
|
||||
Hermes One is a native desktop app for installing, configuring, and chatting
|
||||
with Hermes Agent — a self-improving AI assistant with tool use, multi-platform
|
||||
messaging, and a closed learning loop.
|
||||
appImage:
|
||||
artifactName: ${name}-${version}.${ext}
|
||||
deb:
|
||||
# Run chmod 4755 on chrome-sandbox so Electron's setuid sandbox helper
|
||||
# works on modern Linux distros that disable unprivileged user
|
||||
# namespaces (Ubuntu 24.04+, etc.). Closes #395.
|
||||
afterInstall: build/linux-after-install.sh
|
||||
rpm:
|
||||
artifactName: ${name}-${version}.${ext}
|
||||
# Same SUID fix for .rpm consumers (Fedora 40+ also restricts userns).
|
||||
afterInstall: build/linux-after-install.sh
|
||||
npmRebuild: false
|
||||
publish:
|
||||
provider: github
|
||||
owner: fathah
|
||||
repo: hermes-desktop
|
||||
@@ -0,0 +1,46 @@
|
||||
import { resolve } from "path";
|
||||
import { defineConfig } from "electron-vite";
|
||||
import react from "@vitejs/plugin-react";
|
||||
import tailwindcss from "@tailwindcss/vite";
|
||||
|
||||
const rendererPort = Number(process.env.HERMES_DESKTOP_RENDERER_PORT || 0);
|
||||
|
||||
export default defineConfig({
|
||||
main: {
|
||||
build: {
|
||||
rollupOptions: {
|
||||
external: ["better-sqlite3"],
|
||||
},
|
||||
},
|
||||
},
|
||||
preload: {
|
||||
build: {
|
||||
rollupOptions: {
|
||||
input: {
|
||||
index: resolve("src/preload/index.ts"),
|
||||
askpass: resolve("src/preload/askpass.ts"),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
renderer: {
|
||||
...(rendererPort > 0
|
||||
? {
|
||||
server: {
|
||||
port: rendererPort,
|
||||
strictPort: false,
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
resolve: {
|
||||
alias: {
|
||||
"@renderer": resolve("src/renderer/src"),
|
||||
},
|
||||
// Ensure a single Three.js instance across our code, @react-three/fiber,
|
||||
// drei and troika — multiple copies break `instanceof THREE.*` checks in
|
||||
// the ported office agent renderer.
|
||||
dedupe: ["three"],
|
||||
},
|
||||
plugins: [tailwindcss(), react()],
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,77 @@
|
||||
import { defineConfig } from "eslint/config";
|
||||
import tseslint from "@electron-toolkit/eslint-config-ts";
|
||||
import eslintConfigPrettier from "@electron-toolkit/eslint-config-prettier";
|
||||
import eslintPluginReact from "eslint-plugin-react";
|
||||
import eslintPluginReactHooks from "eslint-plugin-react-hooks";
|
||||
import eslintPluginReactRefresh from "eslint-plugin-react-refresh";
|
||||
|
||||
export default defineConfig(
|
||||
{
|
||||
ignores: [
|
||||
"**/node_modules",
|
||||
"**/dist",
|
||||
"**/out",
|
||||
".claude/**",
|
||||
".agents/**",
|
||||
"build/**",
|
||||
// CDP E2E harness — plain Node CommonJS scripts driving the
|
||||
// dev electron via Chrome DevTools Protocol for live testing.
|
||||
// They intentionally use require() because they run as one-off
|
||||
// `node scripts/*.js` invocations outside the TS build, and
|
||||
// they're not part of the shipped app. See scripts/README.md.
|
||||
"scripts/e2e-attach.js",
|
||||
"scripts/repro-*.js",
|
||||
"scripts/probe-*.js",
|
||||
"scripts/drive-*.js",
|
||||
"scripts/verify-*.js",
|
||||
],
|
||||
},
|
||||
tseslint.configs.recommended,
|
||||
eslintPluginReact.configs.flat.recommended,
|
||||
eslintPluginReact.configs.flat["jsx-runtime"],
|
||||
{
|
||||
settings: {
|
||||
react: {
|
||||
version: "detect",
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
files: ["**/*.{ts,tsx}"],
|
||||
plugins: {
|
||||
"react-hooks": eslintPluginReactHooks,
|
||||
"react-refresh": eslintPluginReactRefresh,
|
||||
},
|
||||
rules: {
|
||||
...eslintPluginReactHooks.configs.recommended.rules,
|
||||
...eslintPluginReactRefresh.configs.vite.rules,
|
||||
"react-hooks/set-state-in-effect": "off",
|
||||
"react-hooks/refs": "off",
|
||||
"react-refresh/only-export-components": "off",
|
||||
// Honour the `_`-prefix convention used across the codebase for
|
||||
// deliberately-unused parameters/variables (e.g. unused args kept for
|
||||
// signature symmetry, or ignored caught errors).
|
||||
"@typescript-eslint/no-unused-vars": [
|
||||
"error",
|
||||
{
|
||||
argsIgnorePattern: "^_",
|
||||
varsIgnorePattern: "^_",
|
||||
caughtErrorsIgnorePattern: "^_",
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
// The 3D office (react-three-fiber) uses Three.js intrinsic elements whose
|
||||
// props (position, args, rotation, intensity, ...) are flagged by the
|
||||
// DOM-oriented `react/no-unknown-property` rule. Disable it here only.
|
||||
files: ["src/renderer/src/screens/Office/office3d/**/*.{ts,tsx}"],
|
||||
rules: {
|
||||
"react/no-unknown-property": "off",
|
||||
// Ported 3D art modules use many small internal helpers without explicit
|
||||
// return annotations; the renderer doesn't require them here.
|
||||
"@typescript-eslint/explicit-function-return-type": "off",
|
||||
},
|
||||
},
|
||||
eslintConfigPrettier,
|
||||
);
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"init_version": 1,
|
||||
"file_hashes": {
|
||||
"CLAUDE.md": "5fd49383be7fd33deec07b4e7a6cbac441568611e392864c7474e5495c8ab0d3",
|
||||
".claude/skills/lat-md/SKILL.md": "39c77385d424367ce6f9290973e2abef5660d288e4efda3609e4c2c50e0b574a"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
# Cloud agent sync
|
||||
|
||||
Syncs desktop profiles (the app's agents) with the signed-in [[hermes-account-login|Hermes One account]]'s cloud agents, bidirectionally, via the backend's `/api/agents` CRUD.
|
||||
|
||||
Phase 1 covers the free parts from the backend's `docs/agent-sync.md`: color, persona (`SOUL.md` ↔ `systemPrompt`), memory (`memories/MEMORY.md` ↔ `memory`), and config basics (`model`/`provider` only — never the whole `config.yaml`, so no secrets leave the device). Skills, automations, and sessions are deferred. Deletions never propagate in either direction.
|
||||
|
||||
## Sync engine
|
||||
|
||||
[[src/main/agent-sync.ts#syncAgents]] runs one single-flight pass: link local profiles to cloud agents, reconcile each part, create missing counterparts on both sides, and unlink mappings whose cloud agent disappeared.
|
||||
|
||||
The stored link (a profile's cloud `agentId`) is also read by [[wallet-token-balances#Wallet Sync]] via [[src/main/agent-sync.ts#getLinkedAgentId]], so backend-provisioned wallets can be fetched for the same agent.
|
||||
|
||||
Requests are bearer-authenticated with the device-login token — the account is located app-wide by [[src/main/account-store.ts#findAccountProfile]] (the token is saved under whichever profile was active at sign-in). Linking keys on the cloud agent's stable `id`; names only match never-synced profiles to their cloud namesakes and are never used to rename.
|
||||
|
||||
Links are **account-scoped**: every state write records the owning backend user id, and a pass skips (never unlinks, never pushes) profiles whose link belongs to a different account — signing out and back in as someone else must not re-upload the first account's agents to the second. A missing cloud agent only unlinks when the state provably belongs to the current account; legacy states without an owner are adopted when their agent exists in the account's list and skipped with a warning otherwise. Wallet flows apply the same rule through [[src/main/agent-sync.ts#getLinkedAgentAccountId]] — see [[wallet-token-balances#Wallet Sync]].
|
||||
|
||||
Per part, the pure [[src/main/agent-sync.ts#decidePartAction]] compares the last-sync base hash with both sides' current hashes: only one side moved → that side wins; both moved (or first sync) → last-writer-wins by timestamp (local file mtime vs the agent's `updatedAt`). Equal content is always a no-op.
|
||||
|
||||
Pushes are built by [[src/main/agent-sync.ts#buildPushBody]], which enforces the backend's field limits by *skipping* oversize parts with a warning — truncating and later pulling back would destroy local content. An unset local model is also skipped so a PATCH can't clobber the cloud value with an empty string. Pulls write through the existing per-part helpers: [[src/main/soul.ts#writeSoul]], [[src/main/memory.ts#writeMemoryRaw]], [[src/main/profile-meta.ts#setProfileColor]], and [[src/main/config.ts#setModelConfig]] (preserving the local base URL).
|
||||
|
||||
A profile's stable **id** (its directory slug), not its editable display **name**, keys every on-disk operation — `getModelConfig`/`readSoul`/`readMemoryRaw`, the `cloud-sync.json` state file, and all pull writes — so a renamed profile keeps syncing against the same directory. The display `name` is used only as the cloud agent's human label (create/name-match/warnings).
|
||||
|
||||
Cloud-only agents are materialized locally by [[src/main/profiles.ts#createProfile]], which derives a valid, collision-free id from the cloud agent's display name and returns it; the pulled parts are then written under that id.
|
||||
|
||||
## State file
|
||||
|
||||
Each linked profile stores its mapping in `cloud-sync.json` under the profile home: the cloud `agentId`, the owning account's user id, the cloud-side name, and a per-part content hash from the last successful sync (the conflict-detection base).
|
||||
|
||||
Parts that failed to push (or were skipped as oversize) keep their old base, so they stay pending rather than being silently marked clean. Deleting the file unlinks the profile — which is exactly what a pass does when the cloud agent was deleted in the console, leaving the local profile untouched.
|
||||
|
||||
## IPC and UI
|
||||
|
||||
The main process exposes `agent-sync-run`, `agent-sync-status`, and `agent-sync-linked-id` in [[src/main/ipc/register.ts#registerIpcHandlers]], next to the account handlers; a completed run also emits `agent-sync-updated` so the renderer can refresh.
|
||||
|
||||
`agent-sync-linked-id` returns the cloud agent id a profile is linked to (or null) — for the per-profile Sync tab below.
|
||||
|
||||
The preload bridge surfaces these as `syncAgents`, `getAgentSyncStatus`, `getLinkedAgentId`, and `onAgentSyncUpdated` on `window.hermesAPI`, typed by the shared shapes in [[src/shared/agent-sync.ts]].
|
||||
|
||||
[[src/renderer/src/screens/Agents/Agents.tsx]] (local mode only — Layout shows a remote notice otherwise) renders the sync affordance in the header: a signed-out hint pointing at the Providers account card, or a Sync button with the last pass's summary (warnings in the tooltip). It auto-runs one pass per visit when signed in, and reloads the profile list when a pass pull-created profiles. [[src/renderer/src/components/HermesAccountModal.tsx]] kicks off the first sync right after a successful sign-in.
|
||||
|
||||
The profile modal's **Sync** tab, [[src/renderer/src/components/profile/ProfileSyncPane.tsx]], is a per-profile manual path for when the auto-sync hasn't run: it shows the signed-in account, whether this profile is linked to a cloud agent (`getLinkedAgentId`), and this profile's outcome from the last pass, with a **Sync now** button that runs the same app-wide `syncAgents()`. Sign-in/unauthorized/error states are surfaced inline.
|
||||
|
||||
## Tests
|
||||
|
||||
[[src/main/agent-sync.test.ts]] fakes the profile/config/fs surface and stubs `fetch`, so the tests exercise the reconciliation logic itself; one account-lookup test lives in [[src/main/account-store.test.ts]].
|
||||
|
||||
### Locates the account app-wide
|
||||
|
||||
`findAccountProfile` finds a session saved under a named profile and prefers the default home when both have one, so sync works no matter which profile was active at sign-in.
|
||||
|
||||
### Part decision matrix
|
||||
|
||||
`decidePartAction` across the base/local/remote hash matrix: equal content is a no-op, a single moved side pushes or pulls, and both-moved / never-synced parts fall back to last-writer-wins by timestamp.
|
||||
|
||||
### Push bodies stay within limits
|
||||
|
||||
`buildPushBody` maps parts to exactly the backend's fields and nothing else, and skips oversize persona/memory and unset models instead of truncating or sending empty strings.
|
||||
|
||||
### Keys on-disk work off the stable id
|
||||
|
||||
A renamed profile (id `hello-agent`, display name `Hello Agent`) drives all on-disk sync — state file, part reads — off the id, while the cloud agent it creates carries the display name as its label.
|
||||
|
||||
### Backs up new local profiles
|
||||
|
||||
A never-synced local profile is POSTed to the backend with its four parts and the returned agent id is persisted to `cloud-sync.json`.
|
||||
|
||||
### Links by name and pulls the newer side
|
||||
|
||||
An unmapped profile links to its cloud namesake without creating a duplicate, and on first sync the newer side (the cloud, when local files are older) wins part by part.
|
||||
|
||||
### Pull-creates cloud-only agents
|
||||
|
||||
A cloud agent with no local counterpart becomes a local profile (via `createProfile`, which derives the id from the agent's display name), and its persona/color/memory/config are written locally under that id.
|
||||
|
||||
### Unlinks deleted cloud agents
|
||||
|
||||
When a mapped cloud agent disappears **and the link's recorded owner is the current account**, the pass removes `cloud-sync.json` and keeps the local profile — no DELETE is ever sent in either direction.
|
||||
|
||||
### Skips foreign-linked profiles
|
||||
|
||||
A profile whose state names a different `accountId` is left completely untouched — state file intact, nothing pushed or pulled — and wallet sync refuses it client-side with `status: "foreign"` before any backend call.
|
||||
|
||||
### Leaves ambiguous legacy links alone
|
||||
|
||||
A legacy state (no `accountId`) whose agent isn't in the current account's list is skipped with a warning, not unlinked.
|
||||
|
||||
It could be a console deletion or another account's agent — and a wrong unlink would re-upload someone else's agent on the next pass, so skipping is the safe read.
|
||||
|
||||
### Records the owning account
|
||||
|
||||
Every successful pass stamps the current account's user id into the state file, adopting legacy links whose agent exists in this account's list.
|
||||
@@ -0,0 +1,33 @@
|
||||
# Analytics
|
||||
|
||||
Privacy-first, opt-out usage analytics that report anonymous events to the in-house Hermes analytics service. Replaces the former PostHog integration; no third-party analytics SDK is bundled.
|
||||
|
||||
Events are sent directly over `fetch` from the renderer — there is no client library. Each event POSTs to `{VITE_ANALYTICS_BASE_URL}/v1/events` with an `x-api-key: {VITE_ANALYTICS_API_KEY}` header and a JSON body of `{ anonymous_id, event, source: "desktop", properties }`. The base URL and API key are injected at build time from GitHub Actions secrets, so analytics is silently disabled in local and unofficial builds where neither is configured. It is also disabled on the Vite dev server — [[src/renderer/src/utils/analytics.ts#isConfigured]] short-circuits when `import.meta.env.DEV` is true, since the `http://localhost:5173` dev origin isn't allowed by the service's CORS (every request would just fail preflight and spam the console). Packaged builds run `vite build`, so they are unaffected.
|
||||
|
||||
## Per-install identity
|
||||
|
||||
A random UUID created on first launch and persisted in `localStorage` under `hermes-anonymous-id` is the analytics user id — created if absent, reused if present.
|
||||
|
||||
It contains no PII and never leaves the device except as the `anonymous_id` field on events.
|
||||
|
||||
[[src/renderer/src/utils/analytics.ts]] owns this logic. `getOrCreateAnonymousId` reads or mints the id; `resetAnalytics` clears it so a fresh id is minted on the next event.
|
||||
|
||||
## Consent
|
||||
|
||||
Analytics is opt-out: enabled by default when the endpoint is configured, and the user can disable it from Settings. The choice is stored in `localStorage` under `hermes-analytics-enabled`.
|
||||
|
||||
[[src/renderer/src/components/settings/PrivacyPane.tsx#PrivacyPane]] (the Privacy pane of the settings modal) renders the toggle and a short one-line privacy note, calling `setAnalyticsConsent`; the initial state comes from `getAnalyticsConsent` in [[src/renderer/src/components/settings/useSettingsData.ts#useSettingsData]]. When consent is off, `capture` short-circuits and no requests are made.
|
||||
|
||||
## Capture surface
|
||||
|
||||
`initAnalytics` runs once at renderer startup from [[src/renderer/src/main.tsx]] and emits an `app_opened` event.
|
||||
|
||||
Its properties are `app_version` (the Hermes version from `package.json`, fetched over the `get-app-version` IPC — not the runtime version), `electron_version`, `node_version`, and `platform`.
|
||||
|
||||
Screen navigation is tracked via `captureScreenView` from [[src/renderer/src/App.tsx#App]], and `captureFeatureUsage` records feature-level events. No chat content, prompts, model responses, file paths, or credentials are ever collected.
|
||||
|
||||
## Build & CSP
|
||||
|
||||
The `VITE_ANALYTICS_BASE_URL` and `VITE_ANALYTICS_API_KEY` secrets are injected into every `npm run build` step of the release workflow (`.github/workflows/release.yml`).
|
||||
|
||||
The Content-Security-Policy in [[src/main/app/start.ts]] and `src/renderer/index.html` allows `connect-src` to reach the analytics host (`https://*.hermesone.org`); the former PostHog `script-src`/`connect-src` allowances were removed.
|
||||
@@ -0,0 +1,122 @@
|
||||
# Slash command execution
|
||||
|
||||
Typed slash commands (`/compact`, `/compress`, `/reset`, `/web`, …) are run through the gateway's command pipeline, not submitted to the model as plain prompt text. This is what makes them _do_ something instead of being echoed back as prose.
|
||||
|
||||
The desktop talks to the hermes-agent gateway over JSON-RPC. A normal message goes via `prompt.submit`, which the gateway treats as a user turn — so a literal `/compact` reaches the model and comes back as text. Real commands must instead go through `slash.exec` (registry-backed worker) with a `command.dispatch` fallback for commands that resolve to an alias, plugin, skill, or an agent prompt.
|
||||
|
||||
**Profile scoping over the unified SSH dashboard.** In SSH mode one machine dashboard serves every profile (see [[main-process#SSH dashboard transport]]), so chat calls must carry the active `profile` or the gateway runs them under its launch profile (`default`) — the agent would then answer as `default` even when a named profile is selected. [[src/main/remote-sessions.ts#RemoteSessionConfig]]`.profile` scopes the `/api/*` HTTP ops, and the `/api/ws` chat client passes `profile` on `session.create`/`session.resume` **and** `prompt.submit`/`prompt.background` ([[src/renderer/src/screens/Chat/hooks/useDashboardChatTransport.ts#submitDashboardPromptWithRecovery]]); `session.create` builds the agent and persists against that profile's `HERMES_HOME`/`state.db`, and each turn re-binds it. Omitted/`default` → the launch profile (unchanged for local and per-profile-remote setups).
|
||||
|
||||
## Routing pipeline
|
||||
|
||||
The pure routing logic lives in [[src/renderer/src/screens/Chat/slashExec.ts#executeSlash]]: try `slash.exec`, accept either rendered output or a structured dispatch result, and on rejection fall back to `command.dispatch`, returning `done`, `send`, or `error`.
|
||||
|
||||
The name/argument split is done by [[src/renderer/src/screens/Chat/slashExec.ts#parseSlash]], which matches with the dotAll flag so a command's argument may span multiple lines (e.g. a multi-line `/remember` note) — an empty name is what `executeSlash` rejects as an empty command, so a multi-line body must not collapse the match.
|
||||
|
||||
It mirrors hermes-agent's reference client (`web/src/lib/slashExec.ts`) so every front-end implements the same contract. Pending-input commands such as `/learn` can return `{type: "send"}` directly from `slash.exec`; that prompt still passes through the central model-submission path.
|
||||
|
||||
## Local vs gateway commands
|
||||
|
||||
Every typed slash command is resolved through the merged catalog before execution. Ownership is explicit (`target: "desktop" | "agent" | "model"`); display categories such as `info` do not determine routing.
|
||||
|
||||
Desktop-only commands delegate to local renderer handlers, Agent commands use the gateway command pipeline, and model commands build a prompt through the shared model-submission formatter. The legacy transport reports Agent commands as unavailable instead of sending raw `/…` text to the model.
|
||||
|
||||
## Commands never queue
|
||||
|
||||
Slash commands run on the gateway's **persistent slash-worker subprocess**, concurrent with any in-flight turn — so they respond instantly and must NOT sit in the busy queue behind a running turn (only plain prompts queue).
|
||||
|
||||
`handleSubmitOrQueue` in [[src/renderer/src/screens/Chat/Chat.tsx]] dispatches every `/…` input immediately to the central router. Desktop and slash-worker commands can complete concurrently; model commands and Agent `send`/skill directives are formatted once and queued when the main model turn is busy.
|
||||
|
||||
Because no global loading state is set, the slash branch shows its own feedback: it inserts an in-place `⏳ Running …` agent bubble, buffers the pipeline output, and replaces that bubble with the result (or `error: …`) when the command resolves — otherwise a slow or unreachable gateway would leave the user staring at nothing. Handled UI actions without output silently remove the pending bubble without leaving conversation artifacts.
|
||||
|
||||
## Transport connection lifecycle
|
||||
|
||||
|
||||
Every dashboard turn first connects a JSON-RPC WebSocket to the gateway; that handshake must be time-bounded or a stalled socket wedges the whole transport with no error and no fallback (issue #718).
|
||||
|
||||
[[src/renderer/src/screens/Chat/dashboardGatewayClient.ts#DashboardGatewayClient#connect]] resolves on `open`, rejects on `error` or an early `close`, **and** rejects on a connect-timeout (default 10s). A WebSocket stuck in `CONNECTING` — TCP accepted but the upgrade never completing, e.g. when a busy renderer starves the handshake — fires none of those events on its own, so without the timer the connect promise never settles. When it never settles, `ensureClient` in [[src/renderer/src/screens/Chat/hooks/useDashboardChatTransport.ts#useDashboardChatTransport]] never resolves, its cached `connectingRef` promise poisons every later send, `setIsLoading(false)` never runs, and the user sees a permanent loading spinner. The timeout makes the promise reject so auto mode falls back to the legacy HTTP transport (and explicit-dashboard mode surfaces a real error) instead of hanging. Per-request calls are separately bounded by their own 30s timeout.
|
||||
|
||||
## Dashboard up ⇒ /api/ws only (never /v1 fallback)
|
||||
|
||||
When a dashboard is available, chat goes through `/api/ws` **only** — never the `/v1` fallback, which 405s over the dashboard tunnel.
|
||||
|
||||
This matches the reference `apps/desktop`, which has no `/v1` chat path at all (its `use-prompt-actions.ts` submits via `requestGateway('prompt.submit', …)` with a busy-retry). The fork's main-process `/v1` path (`sendMessageViaApi`/`sendMessageViaRuns`) exists solely for genuine gateway-only remotes; falling to it while a dashboard is up POSTs `/v1` to the dashboard tunnel — which has no `/v1` → **405**.
|
||||
|
||||
So `ensureClient` distinguishes two failures: a **genuinely absent** dashboard (`startDashboard` → `running:false`) latches the negative flag and (auto mode) drops to legacy gateway `/v1`; a **transient** WS drop while the dashboard is up (a "socket hang up" from a tunnel blip) instead **retries the connect** (up to 3×, re-running `startDashboard` each time to re-establish the SSH tunnel). If it still can't connect, it throws a `dashboardWasReachable`-tagged error so `sendMessage` **fails the turn for the user to retry** rather than 405-ing on `/v1`.
|
||||
|
||||
## Completion text reconciliation
|
||||
|
||||
On `message.complete` the desktop reconciles the text streamed via `message.delta` with the turn's `final_response`, because a last-turn-only final would otherwise clobber text streamed before a tool call (#746).
|
||||
|
||||
[[src/renderer/src/screens/Chat/dashboardEventAdapter.ts#completeAssistantWithFinalText]] rewrites the last assistant bubble through [[src/renderer/src/screens/Chat/dashboardEventAdapter.ts#mergeStreamedWithFinal]], which compares whitespace-insensitively and: uses the final text when it already contains the streamed text; keeps the streamed text when it contains the final (preserving pre-tool-call content); stitches a re-streamed boundary by dropping the duplicated word-aligned seam (rejecting coincidental mid-word overlaps); replaces a garbled re-stream with the final text when the two converge on a substantial common suffix (a corrupted-prefix delta — e.g. a mangled CJK stream — that ends the same sentence as the clean final, rather than the disjoint pre-tool-call + answer pair); and otherwise concatenates the two with a blank-line separator so segments never run together. On the remote/SSH path deltas are not rendered (`renderAssistantDeltas: false`), so the bubble starts empty and the final text is used verbatim.
|
||||
|
||||
## Streaming source-of-truth ref
|
||||
|
||||
`handleGatewayEvent` in [[src/renderer/src/screens/Chat/hooks/useDashboardChatTransport.ts#useDashboardChatTransport]] applies stream events against a synchronous `messagesRef`, not React state, because state lags a render behind and each successive delta must build on the previous one.
|
||||
|
||||
The handler reads the ref, applies a delta, writes the ref back, then calls `setMessages`. An effect mirrors `messages` back into `messagesRef`, and its guard is a correctness invariant. Every `setMessages` in the hook stores the exact same array in the ref, so when React commits the hook's own push, `messages === messagesRef.current` and the effect must skip: re-adopting that snapshot let a second `message.delta` land on a pre-delta array and silently drop a chunk (#757). The effect therefore syncs only when the identity differs (`messages !== messagesRef.current`), which happens only when Chat state changes underneath the hook — a new user turn, `handleClear` emptying the list, or a clarify card resolving in place. A length comparison is wrong here: it misses the shrink and the same-length replacement.
|
||||
|
||||
## Reasoning & tool activity rows
|
||||
|
||||
Streamed reasoning and tool calls are folded into compact, collapsible transcript rows rather than stacked bubbles, so a turn with heavy thinking or many tool calls stays scannable.
|
||||
|
||||
[[src/renderer/src/screens/Chat/HistoryRow.tsx#ReasoningRow]] renders the `Thought` / `Thinking…` row and [[src/renderer/src/screens/Chat/HistoryRow.tsx#ToolActivityGroup]] folds a contiguous run of tool calls/results into one row titled by [[src/renderer/src/screens/Chat/HistoryRow.tsx#toolActivityGroupTitle]]. Each row is collapsed by default and borderless (Codex-style): dim at rest, it brightens and reveals an expand chevron beside the title on hover/focus, and clicking toggles the body open. While the turn is still streaming the leading icon is a `Grid` loader (purple for reasoning, blue for tools); once finished it shows the brain/tool glyph.
|
||||
|
||||
## Bubble hover timestamp
|
||||
|
||||
Each user/assistant bubble reveals a relative "time ago" label on row hover, so the transcript stays uncluttered at rest but is still scrutable when a user wants to know _when_ something was said.
|
||||
|
||||
The canonical time comes from state.db: [[src/renderer/src/screens/Chat/sessionHistory.ts#dbItemsToChatMessages]] copies each row's `timestamp` onto the `ChatBubbleMessage`, and [[src/renderer/src/screens/Chat/sessionHistory.ts#reconcileAfterDbRefresh|the end-of-stream reconcile]] adopts it onto the matching streamed bubble (via `mergeDbMetadataIntoStreamed`) so a live turn picks up its real time after refresh without remounting. state.db stores times in **seconds**, so `toEpochMs` in MessageRow scales any sub-`1e12` value up to milliseconds before use (otherwise it renders as ~Jan 1970). [[src/renderer/src/screens/Chat/MessageRow.tsx#formatBubbleTime]] builds the label with date-fns `formatDistanceToNowStrict` (e.g. "5 minutes ago", "just now" under 10s), with `formatBubbleTimeAbsolute` supplying the exact date/time as the `<time>` element's `title`/`dateTime`. The `.chat-message:hover .chat-bubble-time` CSS fades it in below the bubble, anchored to `.chat-message` because `.chat-bubble`'s own `overflow` would clip it.
|
||||
|
||||
## Renderer-native commands
|
||||
|
||||
A few non-local commands have dedicated desktop handling and must NOT be diverted to the gateway slash pipeline, or they'd lose their behaviour.
|
||||
|
||||
The approval responses `/approve` and `/deny` (the `RENDERER_NATIVE_SLASH` set) are excluded from the pipeline and sent as prompt-level input, matching their dedicated button handlers — `slash.exec` rejects pending-input commands anyway.
|
||||
|
||||
## Side questions (`/btw`)
|
||||
|
||||
`/btw` (with aliases `/bg` and `/background`) is a side question that runs on a **concurrent background agent**, so it must never block or queue behind the main turn — that is the point of "ask without affecting context".
|
||||
|
||||
It maps to the gateway's `prompt.background` RPC, which spawns a separate agent and reports back later via a `background.complete` event (a normal `prompt.submit` mid-turn is rejected with "session busy"). [[src/renderer/src/screens/Chat/hooks/useChatActions.ts#parseBackgroundCommand|parseBackgroundCommand]] detects these commands; `handleSubmitOrQueue` in [[src/renderer/src/screens/Chat/Chat.tsx]] fires them immediately — bypassing the busy queue — via the shared background flow (also used by the 💭 quick-ask button). The transport's `runBackground` ([[src/renderer/src/screens/Chat/hooks/useDashboardChatTransport.ts#useDashboardChatTransport]]) calls the RPC, and its gateway-event handler renders the `background.complete` answer as a standalone `[bg …]` message. The legacy (non-dashboard) transport has no background RPC and falls back to the blocking quick-ask.
|
||||
|
||||
## Central command router
|
||||
|
||||
The central slash command architecture in [[src/renderer/src/screens/Chat/slash/handleSlashCommand.ts#handleSlashCommand]] classifies every slash command into a discriminated union (`target: "desktop" | "agent" | "model"`). Unrecognized commands return an error instead of reaching the model as prose.
|
||||
|
||||
The router's attachment guard rejects a command run with staged attachments unless it declares `supportsAttachments`, but `target: "desktop"` commands are exempt — they are local UI actions / info displays that never consume attachments (the files stay in the composer for the next message), matching the pre-router behavior where local commands ran unconditionally. Only `agent`/`model` commands, which route content upstream, are gated.
|
||||
|
||||
The command palette and executor share a catalog built by [[src/renderer/src/screens/Chat/slash/commandCatalog.ts#createSlashCatalog]]. Hermes Agent metadata comes from `commands.catalog`; Desktop commands are merged after collision validation, and upstream names/aliases are normalized from `/name` to the router's canonical `name`.
|
||||
|
||||
[[src/renderer/src/screens/Chat/slash/commandCatalog.ts#reconcileSlashCatalog]] merges the backend catalog with the in-repo desktop commands into a conflict-free catalog before it reaches `createSlashCatalog`. Desktop commands are authored in-repo and win deterministically; the backend catalog is untrusted runtime data, so a collision there must never crash the app. Any backend command whose name equals a desktop command **name or alias** is dropped (missing the alias check let a backend `/commands` command squat `help`'s `commands` alias and crash startup — #813), and a `canon` alias that targets a desktop command becomes an agent-visible alias entry instead.
|
||||
|
||||
[[src/renderer/src/screens/Chat/slash/commandCatalog.ts#agentCommandsFromCatalog]] reconciles the gateway's two-part catalog — the flat `pairs` command list and the `canon` alias map — into a self-consistent shape first. Because `createSlashCatalog` deliberately throws on a name registered twice (to catch genuine desktop-authoring conflicts), the reconciler drops any `canon` alias whose name is already a first-class `pairs` command: the backend can legitimately expose the same name as both (e.g. `/compact` is a standalone TUI command _and_ an alias of `/compress`), and without this guard the merge would throw and crash the app on agent connect.
|
||||
|
||||
### Desktop commands
|
||||
|
||||
Desktop commands in [[src/renderer/src/screens/Chat/slash/desktopCommands.ts#DESKTOP_SLASH_COMMANDS]] handle local Electron/renderer UI operations such as opening settings, triggering the active chat's model picker, and switching navigation views without sending prompts.
|
||||
|
||||
Pure UI desktop actions are flagged `uiAction: true` (settings, model picker, navigation, `/new`, `/clear`, `/fast`). [[src/renderer/src/screens/Chat/hooks/useChatActions.ts#useChatActions]] reads that flag to suppress the echoed `/command` user bubble for them — their effect is the UI change itself, so a bubble would be a dangling artifact. Output-producing desktop commands (`/help`, `/memory`, `/usage`, …) are not flagged and still echo, so their output reads as a reply.
|
||||
|
||||
`/settings <section>` forwards the section name through `openSettings` to [[src/renderer/src/screens/Layout/Layout.tsx]], which opens the global settings modal on the matching nav item (see [[sidebar-navigation#Settings modal]]). [[src/renderer/src/components/settings/SettingsModal.tsx#resolveSection]] maps the argument to a nav id (`appearance`, `privacy`, `connection`, …, plus the legacy alias `hermesagent` → About); an unknown or omitted name lands on the first item.
|
||||
|
||||
Asynchronous Agent commands render a temporary slash-loader bubble without transcript actions such as Copy; the bubble is replaced by the command output or error when execution finishes.
|
||||
|
||||
### Agent commands
|
||||
|
||||
Agent commands forward upstream via [[src/renderer/src/screens/Chat/slash/executeAgentCommand.ts#executeAgentCommand]] using gateway JSON-RPC.
|
||||
|
||||
### Model commands
|
||||
|
||||
Model commands and Agent `send`/skill directives pass through [[src/renderer/src/screens/Chat/slash/prepareModelSubmission.ts#prepareModelSubmission]] before entering the standard chat transport. This is the only slash route allowed to submit model content.
|
||||
|
||||
### Command icons
|
||||
|
||||
Visual presentation in the autocomplete popup is handled by [[src/renderer/src/screens/Chat/slash/SlashCommandIcon.tsx#SlashCommandIcon]], mapping command names to Lucide icons with fallback defaults and a custom SVG registry. Every slash command including desktop settings and navigation shortcuts is assigned an icon.
|
||||
|
||||
Custom icons render via `dangerouslySetInnerHTML`, so string SVGs passed to [[src/renderer/src/screens/Chat/slash/SlashCommandIcon.tsx#registerCustomSlashSvg]] are stripped of `<script>`/`<foreignObject>`, inline `on*` handlers, and `javascript:` URIs before storage — a defensive guard (real icons never need them), not a full sanitizer. Only register trusted markup; route remote/plugin-sourced SVG through a proper sanitizer first.
|
||||
|
||||
Typing `/` opens a centered command palette in [[src/renderer/src/screens/Chat/ChatInput.tsx#ChatInput]] while the composer retains keyboard focus. Results filter by name or description, stay grouped by category, and support arrows, Enter or Tab, and Escape.
|
||||
|
||||
Escape is captured at the document level while the palette is open, so it closes even if focus has moved from the composer into a command row. Dismissal preserves the slash draft and returns focus to the composer.
|
||||
|
||||
The palette pre-normalizes searchable command metadata and virtualizes its grouped rows through [[src/renderer/src/screens/Chat/slash/virtualSlashCommands.ts#createSlashCommandVirtualLayout]]. Only visible rows plus a small overscan are mounted, while keyboard selection uses calculated offsets.
|
||||
@@ -0,0 +1,53 @@
|
||||
# Chat message-list rendering performance
|
||||
|
||||
Typing in the composer must stay fast no matter how long the conversation is. The transcript is not virtualized in JS, so the layout cost is bounded with CSS containment plus a single batched textarea measurement (issue #748).
|
||||
|
||||
The symptom this guards against: in conversations with many messages, each keystroke took up to ~2.6s with an empty JS profile — the cost was entirely in Chromium's layout engine, recalculating the whole transcript on every keystroke. CPU and memory were normal; new sessions were instant.
|
||||
|
||||
## Off-screen rows are skipped with content-visibility
|
||||
|
||||
Every transcript row (`.chat-message`) sets `content-visibility: auto` with `contain-intrinsic-size: auto 120px`, so the browser skips layout and paint for off-screen rows. That turns a forced reflow from O(all messages) into O(visible rows).
|
||||
|
||||
The rule lives on `.chat-message` in the renderer stylesheet (`src/renderer/src/assets/main.css`). That class is shared by user/agent bubbles, the reasoning and tool-activity rows, and the typing indicator (see [[src/renderer/src/screens/Chat/MessageList.tsx]] and [[src/renderer/src/screens/Chat/MessageRow.tsx]]), so one rule covers every heavy row.
|
||||
|
||||
The `auto` keyword in `contain-intrinsic-size` makes the browser remember each row's real measured height after it renders once, so the scrollbar and scroll position stay accurate; the `120px` is only the first-paint estimate for never-yet-rendered rows.
|
||||
|
||||
### Paint containment and the hover timestamp
|
||||
|
||||
`content-visibility` implies paint containment, which clips anything drawn outside the row's box — including the hover timestamp that sits below the bubble.
|
||||
|
||||
The timestamp (`.chat-bubble-time`) used to overflow ~15px below the bubble and would be clipped. It now sits at `bottom: 1px` inside the row's `padding-bottom: 16px`, so it stays visible while still appearing just under the bubble.
|
||||
|
||||
### Fullscreen overlays inside rows must portal to body
|
||||
|
||||
Paint containment also makes each row a containing block for `position: fixed` descendants — a fullscreen overlay rendered inline inside a row gets trapped and clipped to the row's box instead of covering the viewport.
|
||||
|
||||
The image zoom lightboxes in [[src/renderer/src/components/MediaImage.tsx]] and [[src/renderer/src/components/AttachmentChip.tsx]] hit exactly this: `.chat-image-preview-backdrop` is `position: fixed; inset: 0`, and rendered inline it appeared as a clipped strip inside the message row. Both now render through `createPortal(…, document.body)`. Any future overlay spawned from within a transcript row must do the same.
|
||||
|
||||
Both lightboxes share [[src/renderer/src/hooks/useLightboxClose.ts#useLightboxClose]] for Escape handling. It listens in the capture phase and stops propagation because the lightbox is the topmost modal: other overlays (e.g. the FileViewer panel) bind document-level bubble-phase Escape listeners, and without the capture+stop one keypress would close both the lightbox and the panel behind it.
|
||||
|
||||
## Block flow, not a flex column
|
||||
|
||||
The scroll container `.chat-messages` is block flow, not a flex column. A flex column measures each child to lay itself out, which defeats `content-visibility` and reports a wrong `scrollHeight`.
|
||||
|
||||
A correct `scrollHeight` matters because [[src/renderer/src/screens/Chat/hooks/useChatScroll.ts#useChatScroll]] uses `scrollHeight - scrollTop - clientHeight` to decide whether the view is pinned to the bottom; a wrong value would break auto-scroll.
|
||||
|
||||
The flex `gap` that previously spaced rows is replaced by per-row spacing: `.chat-message` carries `padding-bottom: 16px` (which also provides the timestamp's room), and non-message children that lack it (`.chat-clarify`) carry an equivalent `margin-bottom`. Block flow also moves alignment from `align-self` to `margin-left: auto` for user rows, and the empty state fills height with `min-height: 100%` instead of `flex: 1`.
|
||||
|
||||
## Textarea auto-resize avoids per-keystroke reflow
|
||||
|
||||
The composer textarea auto-grows to its content. Reading `scrollHeight` to size it forces a layout flush, so it runs once per committed value in a `useLayoutEffect` keyed on the input string, not on every keystroke.
|
||||
|
||||
In [[src/renderer/src/screens/Chat/ChatInput.tsx]] every path that changes the value (typing, history recall, voice transcription, and the imperative `setText`/`appendText`) goes through `setInput`, so the layout effect is the single owner of resizing — the other paths only set the caret and focus. Combined with the row-level `content-visibility`, the one measurement per keystroke stays O(visible rows).
|
||||
|
||||
## Slash command palette uses fixed-row virtualization
|
||||
|
||||
Large Agent command catalogs must not make opening, filtering, scrolling, or keyboard navigation proportional to the number of mounted command elements.
|
||||
|
||||
[[src/renderer/src/screens/Chat/slash/virtualSlashCommands.ts#createSlashCommandVirtualLayout]] converts the filtered catalog into fixed-height category and command rows. The scroll viewport mounts only intersecting rows plus four command-row heights of overscan, found from the ordered layout with a binary search.
|
||||
|
||||
The fixed heights are an invariant shared with the `.slash-menu-item` and `.slash-menu-group-label` styles. Changing either visual height requires updating the corresponding layout constant so calculated scroll positions and the virtual canvas remain accurate.
|
||||
|
||||
Arrow-key selection does not query or measure command DOM nodes. [[src/renderer/src/screens/Chat/ChatInput.tsx]] computes the selected row's offset and adjusts the list scroll position only when that row leaves the viewport, including wraparound from the first command to the last.
|
||||
|
||||
The searchable name and description are normalized once when the command catalog changes rather than once per command on every keystroke. The virtual canvas uses layout and paint containment, and the modal overlay avoids backdrop blur so opening the palette does not trigger a full-window blur pass.
|
||||
@@ -0,0 +1,21 @@
|
||||
# Collapsible code blocks
|
||||
|
||||
Long fenced code blocks in agent messages render collapsed behind a "Show more" / "Show less" toggle, so a big file dump doesn't bury the rest of the conversation. [[src/renderer/src/components/AgentMarkdown.tsx]]'s `CodeBlock` treats a block as long when it exceeds 15 lines or 800 characters.
|
||||
|
||||
## Expansion must survive streaming remounts
|
||||
|
||||
The expand/collapse choice is stored in a module-level `Set` keyed by the block's source position, not in plain component state — otherwise it resets to collapsed mid-stream.
|
||||
|
||||
While a message is still streaming, react-markdown re-parses the growing markdown on every token. Its index-based child keys shift as the AST grows, so a `CodeBlock` is frequently unmounted and remounted; a per-component `useState(true)` would re-initialize to collapsed on each remount, undoing the user's click.
|
||||
|
||||
The fix keys expansion on the opening fence's source offset (`node.position.start.offset`), which is stable as content appends. The `code` component mapper passes it as `blockId`; `CodeBlock` seeds its initial state from `expandedCodeBlocks.has(blockId)` and updates that set on toggle, so an expanded block stays expanded across remounts.
|
||||
|
||||
## Box diagrams render plain, not highlighted
|
||||
|
||||
Fenced blocks dominated by Unicode box-drawing characters (tree output like `├── src`, table borders, `█░` progress bars) bypass Prism and render as a single plain `<pre><code>` flow via `PlainCodeView`.
|
||||
|
||||
Prism fragments each glyph into nested token spans; in Electron renderers with imperfect Unicode metrics that fragmentation visually truncates or misaligns the diagram. Plain rendering also skips the lazy highlighter import and keeps the DOM to one text node. `fontVariantLigatures: "none"` and `unicodeBidi: "isolate"` guard glyph fidelity.
|
||||
|
||||
The gate is [[src/renderer/src/components/AgentMarkdown.tsx#isBoxDiagram]]: at least half of the block's non-empty lines must contain a character in U+2500–U+259F (Box Drawing + Block Elements). Density — not mere presence — is the discriminator, so one `│` in a string literal or comment does not demote a whole source file to plain text.
|
||||
|
||||
Two precedence rules: `diff` blocks always keep the colored `DiffView` (it never uses Prism, so it has no fragmentation risk), and the header label keeps the fence's declared language — only an unlabeled box diagram is labeled `text`.
|
||||
@@ -0,0 +1,39 @@
|
||||
# Linked working folder
|
||||
|
||||
A conversation can be bound to a working folder (issue #27) — a desktop-only binding that scopes the agent's work. It is sent to the agent per message as a system message, and persisted per session so re-opening a conversation restores its folder.
|
||||
|
||||
## Desktop-only persistence
|
||||
|
||||
The folder isn't part of hermes-agent's session schema, so it lives in a desktop-owned table in the active profile's `state.db`, keyed by `session_id`.
|
||||
|
||||
[[src/main/session-context-folder-store.ts]] holds `desktop_session_context_folders` (mirroring [[src/main/session-continuation-store.ts]]): [[src/main/session-context-folder-store.ts#setSessionContextFolder]] upserts or, for a null folder, deletes the row; [[src/main/session-context-folder-store.ts#getSessionContextFolder]] reads it. The row is dropped with the rest of a session's data in [[src/main/sessions.ts#deleteSessionRows]] so a deleted session leaves no orphan binding.
|
||||
|
||||
## Restore and save in the chat
|
||||
|
||||
The chat loads the stored folder when resuming a session and saves it whenever it changes, once the conversation has a gateway session id.
|
||||
|
||||
In [[src/renderer/src/screens/Chat/Chat.tsx#Chat]] a load effect fetches the folder for `initialSessionId` on mount; a save effect writes `contextFolder` via `setSessionContextFolder` on every change. The save is gated on a "loaded" ref so the initial null can't overwrite a resumed session's stored folder before the load resolves. A brand-new chat saves once its session id resolves after the first message, binding the pre-selected folder to the new session.
|
||||
|
||||
## Recent folders dropdown
|
||||
|
||||
The context folder picker displays recently used project folders first, allowing quick selection across sessions without opening the OS folder dialog.
|
||||
|
||||
[[src/renderer/src/screens/Chat/ContextFolderChip.tsx#ContextFolderChip]] presents a dropdown menu populated by [[src/main/session-context-folder-store.ts#getRecentSessionContextFolders]] via the `list-recent-session-context-folders` IPC channel, combining distinct database folder bindings with cached session paths.
|
||||
|
||||
## Resizable tree panel
|
||||
|
||||
The context-folder tree panel uses a compact header and can be resized from its left edge, mirroring the in-app browser panel.
|
||||
|
||||
[[src/renderer/src/screens/Chat/WorktreePanel.tsx#WorktreePanel]] stores its width in `localStorage` under `hermes:worktreePanelWidth`, clamps it between a usable minimum and the available chat width, and updates it through a pointer-drag handle styled by `.worktree-resize-handle`.
|
||||
|
||||
## Remote folder picker
|
||||
|
||||
Remote and SSH chats use an in-app picker so users do not accidentally select a local macOS folder for a remote session.
|
||||
|
||||
[[src/renderer/src/screens/Chat/RemoteFolderPicker.tsx#RemoteFolderPicker]] provides a scrollable folder list, horizontally scrollable breadcrumbs, manual path entry, Escape-to-close, and arrow/Enter keyboard navigation. [[src/main/ipc/register.ts#registerIpcHandlers]] routes `read-directory` to [[src/main/ssh-remote.ts#sshReadDirectory]] for SSH connections and returns no listing for pure Remote Gateway mode until the backend exposes a directory-list endpoint, so the picker still allows typed remote paths.
|
||||
|
||||
## Muted tree icons
|
||||
|
||||
The tree keeps file-type icon shapes but normalizes their colors so the explorer reads quietly in the chat sidebar.
|
||||
|
||||
The `@wesbos/code-icons` SVGs render inside `.worktree-file-icon-wrapper`; CSS overrides inline fills/strokes to `currentColor` while preserving `fill:none` outlines, and folder icons use the same low-opacity white tone.
|
||||
@@ -0,0 +1,9 @@
|
||||
# Desktop Updates
|
||||
|
||||
Desktop updates use GitHub releases and expose both a startup upgrade action and a Settings auto-upgrade preference.
|
||||
|
||||
The Electron main process configures `electron-updater` against the repository publisher metadata from `electron-builder.yml`, which points at `fathah/hermes-desktop`. [[src/main/app/updater.ts#setupUpdater]] registers update IPC handlers, persists the auto-upgrade preference under Electron `userData`, and applies that preference to `autoUpdater.autoDownload`.
|
||||
|
||||
When GitHub reports a newer release, [[src/renderer/src/screens/Layout/Layout.tsx#Layout]] shows an upgrade button in the sidebar footer as soon as the app reaches the main layout. The button downloads the update when needed, shows download progress, and changes into a restart action after the update is ready.
|
||||
|
||||
[[src/renderer/src/components/settings/AboutPane.tsx#AboutPane]] (the About & Updates pane of the settings modal) presents the desktop app as its own card, separate from the Hermes Agent engine card — the two update on independent channels. The card shows the app version, the auto-upgrade toggle, and an explicit update action: [[src/renderer/src/components/settings/useSettingsData.ts#useSettingsData]] subscribes to the same `onUpdateAvailable`/`onUpdateDownloadProgress`/`onUpdateDownloaded`/`onUpdateError` events as the footer button and adds a manual `checkDesktopUpdate` (via `checkForUpdates`) plus a `handleDesktopUpdate` that downloads, then restarts via `installUpdate`. When auto-upgrade is enabled the startup release check downloads automatically; when disabled, downloading waits for the user's click (footer button or this card's action).
|
||||
@@ -0,0 +1,110 @@
|
||||
# Hermes One account login
|
||||
|
||||
Signs the desktop app into a Hermes One account (on `hermes-one-backend`),
|
||||
distinct from the per-provider model OAuth in [[provider-setup]].
|
||||
|
||||
It uses the OAuth 2.0 Device Authorization Grant (RFC 8628): the app shows a
|
||||
code, opens the browser to approve it, and polls for a token — so it can then act
|
||||
on the user's behalf.
|
||||
|
||||
The backend serves the grant (see the backend's `lat.md/device-login.md`); this
|
||||
document covers the desktop half — the client, secure storage, IPC, and UI.
|
||||
The stored token's first consumer is [[agent-sync|cloud agent sync]].
|
||||
|
||||
## Device login client
|
||||
|
||||
[[src/main/hermes-account.ts#startDeviceLogin]] runs the whole flow for a profile.
|
||||
|
||||
It POSTs `/api/device/code` (sending the machine hostname as `device_name`, which
|
||||
the approval page shows), hands the code to the caller (`onCode`) so the browser
|
||||
can open and the modal can show it, then polls `/api/device/token` until the grant
|
||||
resolves. [[src/main/hermes-account.ts#cancelDeviceLogin]] stops an abandoned flow
|
||||
(single-flight, mirroring [[src/main/hermes-auth.ts#runHermesAuthLogin]]).
|
||||
|
||||
The backend base URL is resolved fresh on every call by
|
||||
[[src/main/hermes-account.ts#getApiUrl]], **runtime env first** so switching
|
||||
backends is an env edit + relaunch (no rebuild): `HERMES_API_URL` (explicit
|
||||
override) → `MAIN_VITE_HERMES_API_URL` from `process.env` → the build-time
|
||||
baked `import.meta.env.MAIN_VITE_HERMES_API_URL` → `http://localhost:3002`.
|
||||
Because Vite inlines `import.meta.env` at *build* time, the `process.env` reads
|
||||
are what make it truly env-driven in dev — [[src/main/load-env.ts#loadDotEnvForDev]]
|
||||
copies the project `.env` into `process.env` at startup (dev only; called from
|
||||
[[src/main/index.ts]]), and packaged/CI builds carry the value baked in by the
|
||||
release workflow. The resolved value is normalized by
|
||||
[[src/main/api-url.ts#normalizeApiUrl]] — a remote `http://` origin is upgraded
|
||||
to `https://` (localhost stays http), because remote backends 301-redirect
|
||||
http→https and Node's fetch drops the `Authorization` header across that
|
||||
scheme-change redirect, so authenticated sync calls would 401 while anonymous
|
||||
device login still succeeds. [[src/main/account-store.ts#getAccount]] applies
|
||||
the same normalization when reading the `apiUrl` persisted in `account.json`, so
|
||||
a URL stored as http by an earlier login is corrected on read (the sync path
|
||||
uses that stored value) without a re-login. An optional client key
|
||||
([[src/main/hermes-account.ts#getApiKey]], same order with
|
||||
`MAIN_VITE_HERMES_API_KEY` / `HERMES_API_KEY`) is sent as `x-api-key` via
|
||||
[[src/main/hermes-account.ts#apiHeaders]] on all backend calls (device login
|
||||
and [[agent-sync|agent sync]]); the backend doesn't require it yet, and a key
|
||||
shipped in a desktop binary is extractable — abuse-limiting, not a secret.
|
||||
|
||||
Each poll response is turned into the next action by the pure
|
||||
[[src/main/hermes-account.ts#interpretTokenResponse]]: `pending` keeps polling,
|
||||
`slow_down` backs off, `access_denied`/`expired_token` are terminal, and a token
|
||||
ends the loop. Keeping it pure makes the RFC branch logic unit-testable without a
|
||||
live server.
|
||||
|
||||
## Account store
|
||||
|
||||
[[src/main/account-store.ts#saveAccount]] persists the redeemed session to
|
||||
`account.json` under the profile home, encrypting the bearer token at rest with
|
||||
the OS keychain via Electron `safeStorage` — the same approach as
|
||||
[[wallet-token-balances#Wallet Store]].
|
||||
|
||||
The token never leaves the main process: [[src/main/account-store.ts#getAccount]]
|
||||
returns only the public profile (`apiUrl` + user), while
|
||||
[[src/main/account-store.ts#getAccessToken]] decrypts the token for authenticated
|
||||
backend calls, and [[src/main/account-store.ts#clearAccount]] signs out.
|
||||
|
||||
Because the file lives under whichever profile was active at sign-in, app-wide
|
||||
consumers locate it with [[src/main/account-store.ts#findAccountProfile]]
|
||||
(default home first, then named profiles) — see [[agent-sync#Sync engine]].
|
||||
|
||||
## IPC and UI
|
||||
|
||||
The main process exposes `hermes-account-login` (+ `-cancel`, `-get`, `-logout`)
|
||||
in [[src/main/ipc/register.ts#registerIpcHandlers]].
|
||||
|
||||
The login handler opens the browser to the approval page and streams
|
||||
progress/code events to the renderer. The preload bridge surfaces these as
|
||||
`accountLogin`, `getAccount`, etc. on `window.hermesAPI` ([[src/preload/index.ts]]),
|
||||
typed with the shared shapes in [[src/shared/account.ts]].
|
||||
|
||||
The account is device-wide, so the `-get` handler resolves it through
|
||||
[[src/main/account-store.ts#findAccountProfile]] rather than the active
|
||||
profile — switching agents must not read as signed out just because
|
||||
`account.json` lives under the profile that was active at sign-in. Likewise
|
||||
`-logout` calls [[src/main/account-store.ts#clearAllAccounts]], which sweeps
|
||||
every profile home holding an account file (two sign-ins on different
|
||||
profiles leave two), so signing out signs the whole device out.
|
||||
|
||||
In the renderer, [[src/renderer/src/components/HermesAccountModal.tsx]] shows the
|
||||
`user_code` to confirm and reports the result, and
|
||||
[[src/renderer/src/screens/Providers/Providers.tsx]] hosts the "Hermes One
|
||||
account" card that opens it; once signed in it renders an identity card —
|
||||
avatar (or letter fallback), name/email, a "Connected" status line — with a
|
||||
Sign out action.
|
||||
|
||||
## Tests
|
||||
|
||||
Unit tests cover the two pieces that can break silently.
|
||||
|
||||
[[src/main/account-store.test.ts]] round-trips the encrypted token, asserts the
|
||||
public shape never leaks it, and checks logout and the "secure storage
|
||||
unavailable" guard. [[src/main/hermes-account.test.ts]] exercises
|
||||
[[src/main/hermes-account.ts#interpretTokenResponse]] across every RFC branch,
|
||||
the base-URL resolution order (runtime env → baked build-time value →
|
||||
localhost default), and the conditional `x-api-key` header.
|
||||
|
||||
### Signs out everywhere
|
||||
|
||||
With accounts saved under both the default home and a named profile,
|
||||
`clearAllAccounts` removes every one — afterwards `findAccountProfile` is null
|
||||
and both profiles read as signed out.
|
||||
@@ -0,0 +1,39 @@
|
||||
# Kanban board tab
|
||||
|
||||
The Kanban tab ([[src/renderer/src/screens/Kanban/Kanban.tsx]]) is a JIRA-style board for the hermes-agent multi-agent task queue, presented as "JIRA for AI agents": named agent profiles pick up cards, run them, and hand off through the durable `~/.hermes/kanban.db`.
|
||||
|
||||
It is a **thin client over the `hermes kanban` CLI** — every read and mutation shells out through [[src/main/kanban.ts]] (local exec, or SSH-tunnelled via `sshRunKanban` when in tunnel mode). Plain remote HTTP mode is unsupported and shows a "switch modes" notice. The renderer holds no domain logic; it renders board state and routes actions to the CLI.
|
||||
|
||||
## Statuses and columns
|
||||
|
||||
The board renders the agent's canonical statuses, kept in sync with the agent's `kanban_db.VALID_STATUSES` and the dashboard plugin's `BOARD_COLUMNS`. Mis-syncing here silently mis-buckets cards into To-do.
|
||||
|
||||
The `COLUMNS` constant lists eight always-visible lanes in canonical order — `triage, todo, scheduled, ready, running, blocked, review, done` — each with a fixed status `tone` that drives a colored header dot and lane accent. A ninth `archived` lane is appended only when the "show archived" toggle is on; `STATUS_TONE` maps any status to its tone for surfaces outside the column loop (the detail drawer). A task whose status is none of the rendered columns falls back to the To-do lane.
|
||||
|
||||
## Actions
|
||||
|
||||
Cards expose status-appropriate actions, each calling a `hermes kanban` verb via the preload bridge. Header actions are dispatch, new task, new board, and the board switcher.
|
||||
|
||||
Card actions are specify (triage), mark-done (ready), reclaim (running), unblock (blocked), block (todo/ready), and archive (any).
|
||||
|
||||
Drag-drop moves route through `dragAction(from, to)`, which maps a target column to the single `hermes kanban` verb that effects it: `done`→complete, `blocked`→block, `ready`→unblock|reclaim|promote (by source), `scheduled`→schedule, `archived`→archive ([[src/main/kanban.ts#promoteTask]], [[src/main/kanban.ts#scheduleTask]]). The web dashboard can move a card to *any* column because it writes the status field directly in `kanban.db`; the desktop only has CLI verbs, so `todo`, `triage`, and `review` have no verb to set them and are not drop targets. `dragAction` returning a verb is also the drag-validity gate (`isValidDragTransition`).
|
||||
|
||||
In-place editing of a live card's title/body/priority is unavailable — the CLI `edit` verb only backfills a result on already-`done` tasks.
|
||||
|
||||
## Refresh model
|
||||
|
||||
The board stays current without a live event stream, using three refresh triggers instead.
|
||||
|
||||
A 6-second poll (`POLL_INTERVAL_MS`) runs while the tab is visible, a `focus` / `visibilitychange` listener refetches whenever the user returns to the app, and every mutation handler calls `loadAll(true)` so a UI action reflects immediately rather than waiting for the next tick.
|
||||
|
||||
## Detail drawer
|
||||
|
||||
Clicking a card opens a right-docked issue drawer (`kanban-detail-drawer`) fed by `kanbanGetTask` ([[src/main/kanban.ts#getTask]]).
|
||||
|
||||
It shows status, assignee, body, latest run summary, result, the read-only comment thread, and the recent event timeline. It is presentation-only; mutations stay on the card actions.
|
||||
|
||||
## Claw3D HQ virtual board
|
||||
|
||||
A read-only "HQ (Claw3D)" board appears in the switcher when SSH tunnel mode can read the remote task-store JSON ([[src/main/kanban.ts#listClaw3dHqTasks]]).
|
||||
|
||||
It is a renderer-only mirror — selecting it routes reads to the remote store, hides all mutation affordances, and never calls the backend board-switch RPC.
|
||||
@@ -0,0 +1,24 @@
|
||||
This directory defines the high-level concepts, business logic, and architecture of this project using markdown. It is managed by [lat.md](https://www.npmjs.com/package/lat.md) — a tool that anchors source code to these definitions. Install the `lat` command with `npm i -g lat.md` and run `lat --help`.
|
||||
|
||||
> **Hermes One** is a community-maintained project. This desktop app is a wrapper around **Hermes Agent** — it is **not affiliated with, endorsed by, or supported by Nous Research**. "Hermes One" is the name of this community project; "Hermes"/"Hermes Agent" refer to the upstream agent it builds on.
|
||||
|
||||
- [[chat-commands]] — how typed slash commands are routed through the gateway's `slash.exec`/`command.dispatch` pipeline instead of being sent as prompt text.
|
||||
- [[chat-performance]] — how chat rendering stays responsive through contained transcript rows, batched textarea resizing, and fixed-row slash-command virtualization.
|
||||
- [[model-context]] — the per-model context-window override that drives the context gauge and the agent's auto-compaction.
|
||||
- [[model-selection]] — the session-scoped in-chat model override that switches the model (and provider) for one conversation without touching the global default.
|
||||
- [[web-preview]] — the in-app split-screen webview and the `partition`-based gate that lets only it load remote HTTPS while staying sandboxed.
|
||||
- [[code-blocks]] — collapsible long code blocks, and why expansion state is keyed on source position to survive react-markdown's streaming remounts.
|
||||
- [[window-chrome]] — the browser-style title bar where open-conversation tabs sit on top of the window drag region, clickable while empty space still drags.
|
||||
- [[desktop-updates]] — GitHub release checks, startup upgrade button behavior, and the Settings auto-upgrade preference.
|
||||
- [[sidebar-navigation]] — the recent-sessions list under the Chat nav item, capped at five with a "Show more" button that opens the full session list in a modal.
|
||||
- [[context-folder]] — the per-session linked working folder, persisted in a desktop-owned state.db table so a re-opened conversation restores its folder.
|
||||
- [[main-process]] — the Electron main-process entrypoint, app lifecycle modules, and centralized IPC registry.
|
||||
- [[provider-setup]] — the first-run provider picker; its top grid mirrors the agent's native `CANONICAL_PROVIDERS` while OpenAI-compatible endpoints route through the Local presets.
|
||||
- [[hermes-account-login]] — desktop sign-in to a Hermes account via the RFC 8628 device grant; secure token storage, IPC, and the Providers-screen entry point.
|
||||
- [[agent-sync]] — bidirectional sync of desktop profiles with the signed-in account's cloud agents (persona, memory, color, model/provider) via the backend's `/api/agents`; hash-based per-part conflict handling, no deletion propagation.
|
||||
- [[kanban]] — the JIRA-style multi-agent board tab; a thin client over the `hermes kanban` CLI with canonical status columns, an archived toggle, and focus/poll refresh.
|
||||
- [[analytics]] — privacy-first, opt-out usage analytics that POST anonymous events to the in-house Hermes analytics service, keyed by a per-install localStorage UUID; replaces the former PostHog integration.
|
||||
- [[wallet-token-balances]] — profile-scoped Base mainnet wallets with encrypted recovery phrases, and on-chain ERC-20 token balance reads via ethers v6.
|
||||
- [[office-3d-traffic]] — the Office tab's backdrop traffic: car-following and junction-yielding simulation, per-model nose orientation, and instanced fleet rendering in a dozen draw calls.
|
||||
- [[office-3d-interiors]] — enterable office/bank/showroom interiors: per-location conditional mounting (city unmounts while indoors), camera fly-in rig, interactable objects (ATM → wallet, desk → agent, car → spec card), and idle-agent walking trips between buildings.
|
||||
- [[office-interactions]] — space representatives: interactive bank tellers whose menu runs account status, balances, and account creation against the hermes-one backend for a chosen agent; the extensible pattern for future spaces (showroom sales, building space).
|
||||
@@ -0,0 +1,100 @@
|
||||
# Main Process
|
||||
|
||||
The Electron main process keeps the entrypoint small and separates app lifecycle from IPC registration.
|
||||
|
||||
## Entrypoint
|
||||
|
||||
`src/main/index.ts` performs only pre-ready setup and delegates startup.
|
||||
|
||||
[[src/main/index.ts]] applies GPU crash preferences, enables the optional CDP testing port, and calls [[src/main/app/start.ts#startMainProcess]]. This keeps one-off process boot concerns separate from windows, menus, updater wiring, and IPC.
|
||||
|
||||
## GPU Fallback
|
||||
|
||||
Hardware acceleration is disabled and persisted after a GPU-process crash so machines without a usable GPU avoid an infinite crash → relaunch loop — but only temporarily, so a transient crash can't strand a working GPU on SwiftShader.
|
||||
|
||||
[[src/main/gpu-fallback.ts#applyGpuPreferences]] disables hardware acceleration when a crash flag, relaunch sentinel, or `HERMES_DISABLE_GPU` says so, while keeping SwiftShader WebGL available. Persistent GPU-off fallback is honored by default on Windows/Linux, but macOS clears stale flags unless `HERMES_GPU_FALLBACK=1` forces it, protecting the Office tab from permanent software-rendering lag. [[src/main/gpu-fallback.ts#installGpuCrashGuard]] watches fatal GPU-process exits and relaunches with software rendering where the persistent fallback is enabled.
|
||||
|
||||
### Flag expiry
|
||||
|
||||
The persisted `disable-gpu.flag` is only honored for 24 hours after the crash that wrote it; a stale or unparseable flag is cleared at launch and hardware acceleration is retried.
|
||||
|
||||
GPU crashes are often transient (driver update mid-session, a since-removed virtual display adapter, a Chromium blocklist gap for a brand-new GPU), and before the TTL a single crash silently pinned Windows/Linux machines to software rendering forever — a user with an RTX 5060 Ti ran the Office 3D tab at 1 fps on 10+ CPU cores for over a week. If the GPU genuinely still crashes, the re-armed crash guard re-persists a fresh flag, so a broken machine pays at most one crash+relaunch per 24-hour window.
|
||||
|
||||
### User preference
|
||||
|
||||
Settings → Appearance offers a tri-state hardware-acceleration preference — Auto (crash-guard driven, the default), Always on, Always off — persisted in `gpu-preference.json` beside the crash flag.
|
||||
|
||||
The preference lives in `userData`, not renderer settings storage, because [[src/main/gpu-fallback.ts#getGpuPreference]] must read it synchronously before app-ready — the only point where hardware acceleration can still be disabled. Precedence is `HERMES_DISABLE_GPU` env (support escape hatch) > relaunch sentinel (a crash still rescues the current session even under "Always on") > preference > crash flag. Under "Always on" the crash guard relaunches with the sentinel but skips persisting the flag, so every subsequent launch retries hardware acceleration; "Always off" suppresses the crash guard and the Office banner's re-enable button (the banner points at Settings instead). [[src/main/gpu-fallback.ts#setGpuPreference]] writes the file (IPC `set-gpu-preference`, validated in the main process); changes apply after a relaunch via [[src/main/gpu-fallback.ts#relaunchApp]] (IPC `relaunch-app`). The Appearance pane (`src/renderer/src/components/settings/AppearancePane.tsx`) compares the saved preference against the `bootPreference` captured by [[src/main/gpu-fallback.ts#applyGpuPreferences]] so its "restart to apply" prompt survives closing and reopening Settings.
|
||||
|
||||
### Renderer visibility and recovery
|
||||
|
||||
Software rendering is no longer silent: the Office tab shows a warning banner with a one-click recovery when hardware acceleration is off.
|
||||
|
||||
[[src/main/gpu-fallback.ts#getGpuStatus]] reports whether the GPU is disabled, why (`env` / `preference` / `sentinel` / `flag`), and whether the app can recover; [[src/main/gpu-fallback.ts#reenableGpuAndRelaunch]] deletes the flag and relaunches without the GPU-off sentinel (refused when `HERMES_DISABLE_GPU=1` forces software rendering, since a relaunch would inherit it). Both are exposed over IPC (`get-gpu-status`, `reenable-gpu`) via the preload bridge, and the Office screen (`src/renderer/src/screens/Office/Office.tsx`) renders the banner over the 3D view — the one surface where SwiftShader is painfully visible. The one-click re-enable applies only to crash fallbacks: env- and preference-forced software rendering render an informational banner without the button.
|
||||
|
||||
## App Lifecycle
|
||||
|
||||
Lifecycle code owns Electron windows, global app events, and shutdown cleanup.
|
||||
|
||||
[[src/main/app/start.ts#startMainProcess]] registers crash logging, IPC handlers, updater handlers, Electron ready/activate/window-all-closed/before-quit events, CSP headers, security hardening, and the main BrowserWindow.
|
||||
|
||||
[[src/main/app/start.ts]] also supports the `HERMES_OPEN_DEVTOOLS=1` diagnostic launch path so packaged builds can expose renderer console errors when startup fails before the UI paints.
|
||||
|
||||
The packaged renderer keeps its meta CSP aligned with the production response CSP so file-backed startup assets load consistently from `file://` before the main-process header can help.
|
||||
|
||||
Because electron-vite emits a bundled main file at `out/main/index.js`, packaged renderer loading resolves `../renderer/index.html` from `__dirname` to reach `out/renderer/index.html`.
|
||||
|
||||
## App Chrome Helpers
|
||||
|
||||
Menu, updater, and context-menu behavior live in focused modules.
|
||||
|
||||
[[src/main/app/menu.ts#buildMenu]] owns the application menu, [[src/main/app/updater.ts#setupUpdater]] owns update IPC and electron-updater events, and [[src/main/app/context-menu.ts#showChatContextMenu]] owns the chat right-click menu.
|
||||
|
||||
Release builds keep a Help-menu Developer Tools toggle as a production diagnostics escape hatch without changing renderer sandbox or Node isolation.
|
||||
|
||||
## IPC Registry
|
||||
|
||||
Renderer IPC handlers are isolated from app bootstrap so the registry can be split by domain.
|
||||
|
||||
[[src/main/ipc/register.ts#registerIpcHandlers]] currently preserves the existing handler behavior behind one registration function. It receives app-level callbacks for the main window, model-library notifications, connection-config notifications, external URL opening, and active chat abort handles.
|
||||
|
||||
Wallet and token-balance handlers sit in the same registry: `list-wallets`, `create-wallet`, `import-wallet`, `rename-wallet`, `delete-wallet` (backed by [[wallet-token-balances#Wallet Store]]) and `get-token-balances` (backed by [[wallet-token-balances#Token Balances]]).
|
||||
|
||||
## Voice transcription IPC
|
||||
|
||||
Speech-to-text IPC sends recorded desktop audio through the Hermes API server, not through the active chat model endpoint.
|
||||
|
||||
[[src/main/ipc/register.ts#registerIpcHandlers]] exposes `transcribe-audio` for the preload bridge, and [[src/main/hermes.ts#transcribeAudio]] posts a base64 data URL to `/api/audio/transcribe`. If the local gateway lacks that desktop route, it falls back to the Python `tools.transcription_tools.transcribe_audio` dispatcher, so local Whisper, Groq, OpenAI, ElevenLabs, and command/plugin STT providers remain independent from the selected chat model.
|
||||
|
||||
## SSH dashboard transport
|
||||
|
||||
SSH mode has two chat transports because the remote serves chat from **two different servers**, and the desktop must reach the right one.
|
||||
|
||||
The dashboard is **not** a `/v1` superset (a long-standing misconception in earlier comments): `hermes_cli/web_server.py` has no `/v1/chat`, `/v1/responses`, or `/v1/runs` routes and does not proxy `/v1` to the gateway.
|
||||
|
||||
- **Gateway api_server** (port 8642, `API_SERVER_KEY` auth) serves `/v1` chat (`/v1/chat/completions`, `/v1/responses`, `/v1/runs`) + `/health`. This is the **no-build** transport — no Node, no web dist — used by `remote` mode and the SSH gateway fallback. See [[main-process#SSH api_server provisioning]].
|
||||
- **Dashboard** (`hermes dashboard`, port 9119, session-token auth) serves the model library, session list (`/api/*`), and the chat **WebSocket** (`/api/ws`) — surfaces the gateway api_server does not. Local chat uses `/api/ws`; over SSH the renderer's dashboard transport uses it too, when a dashboard is available.
|
||||
|
||||
[[src/main/ssh-remote.ts#sshEnsureDashboard]] ensures the gateway is up, builds the web dist if missing ([[src/main/ssh-remote.ts#sshEnsureDashboardDist]] resolves the real install root via [[src/main/ssh-remote.ts#sshResolveDashboardRoot]] — a system-wide install lives at `/usr/local/lib/hermes-agent`, NOT under `$HOME`, so a hardcoded `~/.hermes/hermes-agent` path wrongly reported "no web dist" and forced every connection into basic chat; it now detects an already-built dist wherever hermes lives, or builds it with the vendored Node at `~/.hermes/node`, single shared in-flight build), then starts the **unified machine** `hermes dashboard --host 127.0.0.1 --port <port> --no-open --skip-build` ([[src/main/ssh-remote.ts#sshStartDashboard]]) with the session token in its env. **One dashboard serves every profile** (no `--profile`, no `--isolated`): `ensureDashboardInner` is machine-scoped (profile=undefined → default port + default token), and per-profile data is selected per-request via `?profile=` ([[src/main/remote-sessions.ts#RemoteSessionConfig]]`.profile`, applied in `dashboardApiUrl`). This is REQUIRED because the desktop has a single global SSH tunnel that can only point at one remote port: the desktop queries multiple profiles at once (e.g. `default` for the machine view + the active named profile), so per-profile dashboard ports (an earlier `--isolated` attempt) made those concurrent queries resolve different ports and thrash the one tunnel ("SSH tunnel is not active"). Readiness requires both the public `/api/status` probe ([[src/main/ssh-remote.ts#sshWaitDashboardReady]], [[src/main/ssh-remote.ts#sshDashboardRunning]]) and an authenticated `/api/sessions` probe ([[src/main/ssh-remote.ts#sshDashboardAuthenticated]]). If the preferred port belongs to a stale dashboard with another token or an unrelated HTTP service, the desktop leaves that process alone, allocates a free loopback port, and persists it as `HERMES_DESKTOP_DASHBOARD_PORT` (one canonical line, deduped) in the **default** `.env`. [[src/main/dashboard.ts#sshDashboardConnectionFromConfig]] and [[src/main/ipc/register.ts#getSshDashboardSessionConfig]] then `ensureSshTunnel` to that single dashboard port and build the connection (model library, sessions, and the `/api/ws` chat WS), carrying the requested `profile`.
|
||||
|
||||
Because the dashboard is machine-unified, an **unscoped** request silently answers with the **default** profile's data — a named-profile user would get the default session list and open the wrong transcript. Session and metadata IPC handlers (`list-sessions`, `get-session-messages`, delete/title/search/cache ops, hermes version/home, model config) therefore default the dashboard profile to the locally persisted active profile via [[src/main/ipc/register.ts#activeSshProfile]] (explicit renderer-passed profiles win; `"default"` and already-explicit params like the session list's `profile=all` are handled in `dashboardApiUrl`). [[src/main/remote-metadata.ts]]'s `/api/status` probe shares [[src/main/remote-sessions.ts#dashboardApiUrl]] rather than building its own URL, so status-derived surfaces (Hermes home/version) are scoped the same way.
|
||||
|
||||
**Every** SSH tunnel entry point that prepares chat — the `send-message` preamble and the `start-ssh-tunnel` IPC handler — routes through [[src/main/ipc/register.ts#prepareSshTunnel]]. When an authenticated dashboard is available it tunnels to the dashboard port and caches the dashboard token; otherwise (gateway-only installs with no web dist, or `legacy` transport) it provisions and tunnels to the gateway `/v1` port. This single funnel matters because the tunnel is one global resource: a path tunnelling to 8642 while another used 9119 would thrash it (each `startSshTunnel` first `stopSshTunnel`s), surfacing as "SSH tunnel is not active". The `before-quit` handler in [[src/main/app/start.ts#startMainProcess]] calls `stopSshTunnel()` on exit — without it the `ssh -N -L` child is orphaned (reparented to PID 1) and keeps holding its local port, so each relaunch leaks another tunnel and the port drifts (18642 → 61799 → …). When the dashboard can't run, `sshEnsureDashboard` returns `null`: `auto` degrades quietly to the gateway `/v1` path for chat and legacy CLI/SSH-exec ops for `withSshDashboardModelLibrary`/`withSshDashboardSessions`, while a forced `dashboard` transport surfaces the error.
|
||||
|
||||
The dashboard is "ensured" on every chat/model-library/session op, so `sshEnsureDashboard` is guarded against a spawn spiral: an in-flight promise collapses a connect storm into one probe, and a ~60s negative cache (`dashboardUnavailableUntil`, cleared by [[src/main/ssh-remote.ts#resetSshDashboardAvailability]] on connection-config change) short-circuits to the gateway path. The negative cache latches **only for the permanent case** — the remote has no buildable web dist — never for a transient (dashboard still starting, a readiness/auth blip): caching a transient would force chat's `prepareSshTunnel` onto the gateway `/v1` tunnel (8642) while model-library still targets the dashboard port, thrashing the single global tunnel ("SSH tunnel is not active" / 405). [[src/main/ssh-remote.ts#sshStartGateway]] carries the same in-flight dedup (and re-checks status inside the guard). Without these, concurrent ops each found "no gateway/dashboard", launched their own, and on a small remote the duplicates OOM-killed each other — the desktop then saw "not running" and respawned, wedging the box. The dashboard cache/in-flight keys are **machine-scoped** (host:port:user, not per-profile, since one dashboard serves all profiles), so the whole connect storm collapses to a single probe and a single tunnel.
|
||||
|
||||
Because the launch-time SSH connect (the splash "Starting SSH tunnel…" step in [[src/renderer/src/App.tsx#App]]'s `runInstallCheck`) can be slow on first connect or stall on an unreachable host, [[src/renderer/src/screens/SplashScreen/SplashScreen.tsx]] shows a "Switch to local mode" escape hatch after a delay so the user is never trapped. It reuses `handleSwitchToLocal`, which stops any in-flight tunnel, persists `local` mode, and re-runs the check; `runInstallCheck` carries a generation guard (`runIdRef`) so the abandoned SSH run can't clobber the local run's screen transition.
|
||||
|
||||
## SSH api_server provisioning
|
||||
|
||||
The gateway `/v1` chat path is the no-build SSH transport (and the only one on gateway-only installs that lack the dashboard web dist), but it requires the remote api_server to be configured — which SSH mode, unlike local mode, never did.
|
||||
|
||||
The gateway only loads the api_server platform when `API_SERVER_ENABLED` is truthy (`gateway/config.py`), and the api_server refuses to bind without `API_SERVER_KEY`. Local mode writes both via `startGateway`; SSH mode previously only **read** the key, so a fresh server had no `/v1` endpoint at all and every chat failed. [[src/main/ssh-remote.ts#sshEnsureApiServerKey]] now ensures both on the remote `.env` (per profile): it generates + writes `API_SERVER_KEY` when missing/invalid ([[src/main/ssh-remote.ts#isUsableApiServerKey]] rejects empty, <16-char, and placeholder keys) and sets `API_SERVER_ENABLED=true`, returning whether anything was written. [[src/main/ipc/register.ts#prepareSshTunnel]]'s gateway branch calls it, then starts the gateway if down — or stops+starts it when the env was just written so the running gateway picks up the new api_server config — and waits for the api_server `/health` ([[src/main/ssh-remote.ts#sshWaitGatewayApiReady]]) before opening the tunnel, so the first chat doesn't race "tunnel health check failed". A `false` readiness result (health never bound within the timeout — fresh or slow remotes) makes `prepareSshTunnel` **throw** instead of opening the tunnel and caching the key: reporting success with an unbound `/v1` just deferred the failure to the first chat with a less actionable connection error. Chat then POSTs `/v1` over the tunnel with that key cached via `setSshRemoteApiKey`.
|
||||
|
||||
These `.env` writes go through [[src/main/ssh-remote.ts#upsertEnvLine]], which rewrites the first matching line and **drops any later duplicates**. Both `sshReadEnv` and the remote gateway's dotenv are last-wins, and pre-dedup desktops left `.env` files with several `API_SERVER_KEY` lines — replacing only the first while a stale later line survived meant the gateway kept the old key while the desktop cached the new one, a permanent 401. Writes self-heal that corruption, matching the canonical-line writers used for the dashboard token and port.
|
||||
|
||||
## SSH credential resolution
|
||||
|
||||
The credential depends on which transport is active. Over the **dashboard** the **session token** is used; over the **gateway `/v1`** path the remote **`API_SERVER_KEY`** is used.
|
||||
|
||||
The dashboard's `/api/*` routes (and its `/api/ws` chat WS) reject the api_server key (401) and accept only `HERMES_DASHBOARD_SESSION_TOKEN`. [[src/main/ssh-remote.ts#sshEnsureDashboardToken]] reads the token from the remote `.env` (per profile), generating + persisting one when absent so it stays stable across reconnects and is shared by the remote dashboard process and the desktop. It writes exactly one canonical line (stripping any duplicates) under an in-flight guard — the dashboard is ensured on every chat/model-library/session op, and the old unguarded `printf >>` let concurrent first-connect callers append divergent tokens (observed as 9 conflicting lines in one `.env`, where dotenv's last-wins value drifted from a caller's cached token → 401). [[src/main/ssh-remote.ts#sshEnsureApiServerKey]] carries the same guard for the gateway `/v1` key. The desktop caches it via `setSshRemoteApiKey`. The SSH form has no API-key field (only **remote** mode does, [[src/renderer/src/components/settings/ConnectionPane.tsx]]), so the shared `conn.apiKey` is never used for SSH — avoiding the stale-key 401s the old `conn.apiKey || …` precedence caused. On the gateway `/v1` path the credential is the remote `API_SERVER_KEY`, provisioned by [[src/main/ssh-remote.ts#sshEnsureApiServerKey]] and read via [[src/main/ssh-remote.ts#sshReadRemoteApiKey]].
|
||||
@@ -0,0 +1,25 @@
|
||||
# Model context window
|
||||
|
||||
A model can carry an optional manual context-window override (tokens), for providers that don't advertise `context_length` over `/models` — without it the desktop can't size the context gauge or the agent's auto-compaction.
|
||||
|
||||
The same value fixes two symptoms at once: the context gauge showing a wrong heuristic size (e.g. 32k for a 64k model), and the agent never auto-compacting. hermes-agent auto-compacts at `context_length × compression.threshold` (default 0.50, enabled by default), so a correct `context_length` re-enables compaction without any extra UI.
|
||||
|
||||
## Storage and propagation
|
||||
|
||||
The override is stored per-model in `models.json` as `contextLength` and mirrored into `config.yaml`'s `model.context_length` whenever a model is activated — the single value both the gauge and the agent read.
|
||||
|
||||
Per-model storage (set in the Models add/edit dialog) survives switching between models. On activation, [[src/main/config.ts#setModelConfig]] writes or clears `model.context_length` from the activated model's library entry; an absent override clears any stale value left by a previously-active model. Remote/SSH activation does not propagate the override yet (local-mode only).
|
||||
|
||||
## Gauge resolution order
|
||||
|
||||
The context gauge resolves its window size as: config override (active model) → provider `/models` `context_length` → static heuristic.
|
||||
|
||||
[[src/main/model-discovery.ts#getModelContextWindow]] consults [[src/main/config.ts#getModelContextLengthOverride]] first, returning it only when it targets the model being asked about (so a stale value can't leak onto a different model id), before falling through to the authoritative `/models` lookup and finally the renderer's substring heuristic.
|
||||
|
||||
## Occupancy estimate when the provider omits usage
|
||||
|
||||
The gauge's numerator resolves as: exact payload counts (`context_used`, else prompt tokens) → a chars/4 transcript estimate → the previous turn's value. Without the estimate the gauge went blank on providers that return no usage at all (#789).
|
||||
|
||||
The gauge only renders when `contextTokens` is set (see `contextUsage` in [[src/renderer/src/screens/Chat/Chat.tsx]]), so on `message.complete` the transport fills it in even when `usageFromPayload` returns null. [[src/renderer/src/screens/Chat/hooks/useDashboardChatTransport.ts#estimateContextTokens]] sums the transcript's characters — bubbles, reasoning text, tool call args, and tool results all occupied the prompt loop — and excludes the just-completed assistant reply bubble, because `contextTokens` means prompt-side occupancy and the reply was generated output. The estimate is a floor: system prompt, tool schemas, and attachments aren't visible to the renderer.
|
||||
|
||||
A failed turn with no usage record does not fabricate an estimate — nothing new entered the context, and the previous gauge value stays.
|
||||
@@ -0,0 +1,29 @@
|
||||
# Session model override
|
||||
|
||||
The in-chat (bottom) model picker selects a model for the **current conversation only** — it never rewrites `config.yaml`, so the Settings global default is preserved (#688), and carries the full model identity so cross-provider switches route correctly.
|
||||
|
||||
The override is held in renderer state on each `<Chat>` run ([[src/renderer/src/screens/Chat/Chat.tsx]]), persisted by session id, and sent with every message; it is cleared when the conversation is cleared/reset and is absent on a fresh chat, so new conversations start on the global default. This is distinct from the persisted [[model-context]] default that non-chat surfaces read.
|
||||
|
||||
## Full identity, not just the model name
|
||||
|
||||
The override is a `SessionModelOverride` (`{provider, model, baseUrl}`), not a bare model string — because switching across providers must change routing, not only the `model` field.
|
||||
|
||||
The picker builds it via [[src/renderer/src/screens/Chat/hooks/useModelConfig.ts#effectiveOverrideBaseUrl]], the same baseUrl rule `selectModel` applies (keep the URL only for `custom`/`ollama-cloud`; clear it for named providers that have a canonical base URL), so the session pick and a persisted save can't drift. It is threaded renderer → preload IPC → main `sendMessage` as `modelOverride`.
|
||||
|
||||
## Desktop-only persistence
|
||||
|
||||
The selected model/provider is saved in a desktop-owned table keyed by session id, without storing API keys.
|
||||
|
||||
[[src/main/session-model-override-store.ts]] holds `desktop_session_model_overrides` with `provider`, `model`, and `base_url` only. [[src/renderer/src/screens/Chat/Chat.tsx#Chat]] restores the saved value for a resumed session, applies it to the local picker with `persist:false`, and saves later changes once a gateway session id exists. Deleting a session removes the row through [[src/main/sessions.ts#deleteSessionRows]].
|
||||
|
||||
## Text-only legacy fallback routes via CLI
|
||||
|
||||
Text-only legacy turns can use the CLI fallback when a session override changes provider or base URL away from `config.yaml`.
|
||||
|
||||
The upstream desktop model applies the session switch on the active gateway session with `/model <model> --provider <provider>`, then attaches media and submits on that same session. Hermes Desktop's dashboard transport follows that path; [[src/main/hermes.ts#shouldForceCliForSessionOverride]] keeps the CLI escape hatch only for text-only legacy fallback, where it can pass `-m <model>` and `--provider` without dropping attachments. Same-provider model swaps stay on the gateway/API path, where the new `model` string is sufficient. Remote (SSH) mode has no local CLI transport, so it remains limited to the model string.
|
||||
|
||||
## Attachment turns stay on session transport
|
||||
|
||||
Attachment turns must not be forced through the CLI override fallback because the CLI path cannot carry multimodal input.
|
||||
|
||||
[[src/main/hermes.ts#sendMessageViaCli]] can inline text-file attachments but ignores images, while the gateway/API path preserves image parts and path refs through [[src/main/hermes.ts#buildUserContent]]. When a session override is active and the user sends attachments, [[src/main/hermes.ts#shouldForceCliForSessionOverride]] leaves the turn eligible for the dashboard/gateway or API transport instead of silently dropping media.
|
||||
@@ -0,0 +1,65 @@
|
||||
# Office 3D Interiors
|
||||
|
||||
Enterable building interiors on the Office tab: click the office, bank, or car showroom in the city view, press Enter, and the whole screen becomes that interior while the rest of the city stops rendering. Exiting restores the full city.
|
||||
|
||||
The feature spans the screen shell ([[src/renderer/src/screens/Office/Office.tsx]] owns the location state and DOM overlays) and the scene ([[src/renderer/src/screens/Office/office3d/Office3D.tsx]] mounts layers per location).
|
||||
|
||||
## Locations & conditional rendering
|
||||
|
||||
`OfficeLocation` ("city" | "office" | "bank" | "showroom") lives in `office3d/core/locations.ts` with per-location camera presets, orbit clamps, and shadow centres. Buildings never move — entering only flies the camera and changes what's mounted.
|
||||
|
||||
Interior modes mount ONLY the active building plus [[src/renderer/src/screens/Office/office3d/objects/AgentsLayer.tsx#AgentsLayer]]; the city backdrop, distant skyline, connecting street, traffic, and the other buildings unmount entirely. React unmounting also stops their `useFrame` work, so the [[office-3d-traffic|traffic simulation]] pauses while indoors and resumes where it left off on exit. This is the efficiency contract: the GPU renders what the current context shows, never the whole world.
|
||||
|
||||
Entry flow: in city mode, clicking a building calls `onFocusBuilding` (wrapping `<group onClick>`s in Office3D); Office.tsx shows an "Enter …" button; clicking it sets the location. Exit via the top-left button or Escape. The dev building-mover keeps exclusive click ownership when active — no focus/enter in devMode.
|
||||
|
||||
## Camera rig
|
||||
|
||||
`CameraRig` (in Office3D.tsx) lerps the camera position and OrbitControls target to the new location's preset over ~0.8 s with cubic easing; afterwards the user orbits freely within the location's clamp box.
|
||||
|
||||
Controls are disabled during flight so damping doesn't fight the animation, and target clamping is skipped while disabled (mid-flight the target legitimately crosses out-of-bounds space).
|
||||
|
||||
[[src/renderer/src/screens/Office/office3d/objects/SceneEnvironment.tsx#SceneEnvironment]] takes a `center`/`shadowHalfExtent` so the key light's shadow camera follows the location — the bank (world x≈68) sits outside the default ±36 frustum and would get no shadows otherwise. The light remounts on frustum change (three reads shadow camera bounds only at creation).
|
||||
|
||||
## Interactables
|
||||
|
||||
`office3d/objects/Interactable.tsx` wraps interior objects: hover shows a billboard label (troika text, CSP-safe local font) and a ground ring, click fires the action. Disabled outside the matching interior so city-view click semantics are untouched.
|
||||
|
||||
Wired actions: bank ATMs open the profile modal on its wallet section (`OpenProfileOptions.initialSection`, threaded through [[src/renderer/src/components/profile/ProfileModalProvider.tsx#ProfileModalProvider]]); bank tellers open the space-representative menu (see [[office-interactions#Office Space Interactions#Teller Interactable]]); showroom cars open a spec card overlay in Office.tsx; office desks select their owner agent (details sidebar).
|
||||
|
||||
## People & staff
|
||||
|
||||
Every human in the world stands the same height: `PERSON_WORLD_HEIGHT` in `office3d/core/constants.ts` (≈1.65 world units). Ambient NPCs normalise to it, so a visiting agent never towers over the locals.
|
||||
|
||||
The value is derived, not chosen: profile agents render at their 0.65 normalised height × RiggedCharacter's 1.45 multiplier × `AGENT_SCALE` (1.75); the constant bakes that product so NPC scaling can't drift from the agent pipeline.
|
||||
|
||||
[[src/renderer/src/screens/Office/office3d/objects/StaffPerson.tsx#StaffPerson]] is a stationary tinted man.glb rig playing its idle clip, used for building staff: three bank tellers behind the counter stations ([[src/renderer/src/screens/Office/office3d/objects/Bank.tsx#BankTellers]]) and a showroom salesperson + manager. The tellers are interactive — clicking one opens the bank's representative menu ([[office-interactions#Office Space Interactions]]); the showroom staff are set dressing until car sales attach to them the same way.
|
||||
|
||||
All ambient people are coloured through [[src/renderer/src/screens/Office/office3d/core/glb.ts#tintCharacterClone]], which mirrors the agents' RiggedCharacter rule: only the rig's shirt materials take the tint (skin/hair/trousers keep their own colours), with an all-materials fallback — so NPCs never look like full-body colour casts next to an agent.
|
||||
|
||||
NPCs draw from a character pool in [[src/renderer/src/screens/Office/office3d/core/characters.ts#CHARACTER_MODELS]]: man.glb plus the Casual-family rigs (person.glb, person2.glb, women.glb). Shirt material names differ per model (man = "Shirt"; the Casual rigs use colour names like "LightBrown"/"Purple"/"White", identified from each rig's torso mesh), so each pool entry carries its own `shirtMaterials` list. Pedestrians pick a seeded random rig, tellers cycle through the pool, and the showroom pairs a female salesperson with a male manager.
|
||||
|
||||
### City pedestrians
|
||||
|
||||
Ambient people live on the streets, not in one building: [[src/renderer/src/screens/Office/office3d/objects/Pedestrians.tsx#PedestriansLayer]] runs seeded pedestrians on cyclic sidewalk loops that pop into the bank and the car showroom, with dwell stops inside. The office is agents-only — no pedestrian route enters it.
|
||||
|
||||
Each pedestrian tracks a `place` ("outside" | "bank" | "showroom") from its current waypoint, so interior views show exactly the people actually in that building and the city view shows everyone on the streets. Walk/idle clips crossfade at dwell stops. The layer is mounted in every location (like AgentsLayer) so interiors are already populated when entered; it replaced the old `BankFakePeople`, whose eight walkers were confined to the bank floor.
|
||||
|
||||
## Collision
|
||||
|
||||
People never pass through walls, furniture, or each other: a crowd registry separates overlapping people, and per-place static colliders (wall boxes with door gaps, furniture circles) push walkers out. Buildings are entered through doorways only.
|
||||
|
||||
Everything lives in [[src/renderer/src/screens/Office/office3d/core/collision.ts]] and works in world coordinates; the office simulation converts its canvas positions at the boundary. Wall colliders mirror the visible geometry including every door gap — the office's south wall gained a real doorway (`OFFICE_DOOR_X` in cityPlan.ts, east of the HQ logo) that trips now walk through instead of phasing the wall. Seats (chairs, beanbags) are deliberately not colliders so agents can reach them, and desk boxes cover only the desk body away from the seat side.
|
||||
|
||||
Blocked walkers wall-follow: when the push-out cancels a step, the walker commits to a short slide along the blocking face's tangent — signed toward its goal, re-derived at corners — with goal steering suspended for the burst. (A per-frame perpendicular nudge is not enough: the goal pull re-pins the walker against the face each frame, walking in place forever — the original stuck-at-desk bug.) The static resolver exposes its push normal for this via `resolveStaticColliders`' `pushOut`.
|
||||
|
||||
Desk seats add a structural rule: agents approach the chair from the open side — up the desk-free aisle between desk columns, then across at seat height (`deskApproachByAgent` in AgentsLayer) — so the everyday sit-down never depends on obstacle avoidance at all. Trip/NPC waypoints sit clear of all colliders with looser arrival radii so a crowded waypoint can't strand anyone. Pedestrians register in the same crowd as visiting agents, so the two populations avoid each other too; standing staff participate as static circles.
|
||||
|
||||
## Agent trips
|
||||
|
||||
Idle agents occasionally walk out of the office to the bank or showroom, wander inside, and walk back. Routes live in `office3d/trips.ts` as canvas-space waypoint chains.
|
||||
|
||||
The canvas↔world mapping ([[src/renderer/src/screens/Office/office3d/core/geometry.ts#worldToCanvas]]) is linear, so waypoints far outside the office's 0..1800 rectangle work unchanged — no second coordinate system.
|
||||
|
||||
The controller in [[src/renderer/src/screens/Office/office3d/objects/AgentsLayer.tsx#AgentsLayer]] adds a "trip" mode (phases out → wander → back) beside toSeat/seated. Only idle (non-working) seated agents start trips, capped at `TRIP_MAX_TRAVELLERS`; if an agent's gateway starts mid-trip it walks the route home in reverse rather than teleporting. Each agent's `place` ("office" | "bank" | "showroom" | "outside") is derived from route progress.
|
||||
|
||||
The simulation always runs for every agent; the `visiblePlace` prop only toggles per-agent wrapper-group visibility each frame, so each interior view shows exactly the agents actually in that building and the city view shows everyone, including walkers on the street.
|
||||
@@ -0,0 +1,47 @@
|
||||
# Office 3D Traffic
|
||||
|
||||
Backdrop cars and trucks looping on the Office tab's city roads. Vehicles follow the car ahead in their lane, yield at junctions, and the whole fleet renders with GPU instancing driven by one per-frame update in [[src/renderer/src/screens/Office/office3d/objects/Traffic.tsx#TrafficLayer]].
|
||||
|
||||
TrafficLayer is mounted only in the city view: entering a building interior (see [[office-3d-interiors]]) unmounts it, which pauses the simulation and its draw calls entirely; on exit it resumes from where it stopped.
|
||||
|
||||
Roads stay physically clear of scenery: the detailed backdrop grid excludes road corridors when placing buildings, and the [[src/renderer/src/screens/Office/office3d/objects/CityBackdrop.tsx#DistantSkyline]] ring rejection-resamples each silhouette tower's polar position (using its half-diagonal as clearance) until it misses every corridor — the roads run the full ROAD_LEN out into the skyline band, so without this, cars drove straight through distant towers.
|
||||
|
||||
The road network itself (8 roads, two-way lanes, loop length) comes from the city master plan in `src/renderer/src/screens/Office/office3d/core/cityPlan.ts`; traffic reads `ROADS`, `ROAD_WIDTH` and `TRAFFIC_LEN` from there.
|
||||
|
||||
## Fleet generation
|
||||
|
||||
[[src/renderer/src/screens/Office/office3d/objects/Traffic.tsx#makeTraffic]] builds 7 vehicles per road (56 total) from fixed seeds — like the rest of world-gen, traffic is deterministic and every load produces the same fleet, tints, and starting positions.
|
||||
|
||||
Each vehicle carries static config (model URL, tint, lane, cruise speed, precomputed heading index) plus live simulation state (`s` position along the road, current `speed`). Directions alternate per slot so each road has traffic in both lanes.
|
||||
|
||||
## Driving simulation
|
||||
|
||||
[[src/renderer/src/screens/Office/office3d/objects/Traffic.tsx#stepTraffic]] advances all vehicles once per frame in three passes: junction occupancy, target-speed selection, then integration. Cars never drive through each other.
|
||||
|
||||
Car-following: within a lane each vehicle finds the nearest vehicle ahead (wrapped over the traffic loop). Inside `SLOW_GAP` it matches the leader's speed; inside `MIN_GAP` it targets zero — so cars brake, queue behind a stopped leader, and pull away again once it moves. Speeds ease toward the target with separate acceleration/braking rates so stops look like braking rather than snapping.
|
||||
|
||||
### Junction yielding
|
||||
|
||||
Every E-W/N-S road crossing is a "junction box" (crossing road width plus clearance). A vehicle approaching a box stops before it while cross-axis traffic occupies it, and proceeds once clear.
|
||||
|
||||
A vehicle already inside a box is committed and never told to stop there.
|
||||
|
||||
Deadlock avoidance is by axis priority: N-S traffic also yields while E-W traffic is merely *approaching* the box (`YIELD_DIST`), whereas E-W traffic only stops for N-S vehicles actually inside it — so the two axes can't wait on each other symmetrically.
|
||||
|
||||
## Instanced rendering
|
||||
|
||||
The fleet renders as one `THREE.InstancedMesh` per model sub-mesh — about a dozen draw calls for all 56 vehicles — with a single `useFrame` loop that simulates and writes instance matrices.
|
||||
|
||||
The previous approach cloned the GLB per vehicle: hundreds of draw calls (each clone has ~5-10 meshes with unique materials) plus 56 separate `useFrame` subscriptions.
|
||||
|
||||
[[src/renderer/src/screens/Office/office3d/objects/Traffic.tsx#buildPartTemplates]] flattens each vehicle GLB into parts, baking the same recentre/ground/scale/align transform as [[src/renderer/src/screens/Office/office3d/core/glb.ts#normalizeFootprint]] into a per-part matrix. Per-vehicle paint uses `instanceColor`: tintable (light) materials get a white base colour so the instance colour is the final paint, while dark trim (tyres, glass) keeps its source colour — matching what [[src/renderer/src/screens/Office/office3d/core/glb.ts#vehicleClone]] does for the showroom's individually-cloned cars.
|
||||
|
||||
Per-frame matrix work is allocation-free: each part precomputes its four possible heading matrices (`ROT_YAWS`), so placing an instance is a matrix copy plus a translation add.
|
||||
|
||||
## Model nose orientation
|
||||
|
||||
`normalizeFootprint` aligns a vehicle's long axis to +Z but cannot know which end is the front, so `MODEL_NOSE_YAW` in Traffic.tsx adds a 180° yaw for models whose nose points -Z in the GLB — without it those cars drive tail-first.
|
||||
|
||||
Currently only car1 needs the flip: its GLB has front wheels at -Z, while car2 and truck1 already face +Z.
|
||||
|
||||
The correction is applied in both pipelines — the instanced traffic templates and [[src/renderer/src/screens/Office/office3d/objects/Traffic.tsx#VehicleModel]] (used by the car showroom) — so a model faces the same way everywhere.
|
||||
@@ -0,0 +1,45 @@
|
||||
# Office Space Interactions
|
||||
|
||||
Space representatives: staff NPCs in the Office tab's buildings that agents do business with. Clicking a bank teller opens an action menu — account status, balances, account creation — executed against the hermes-one backend for the chosen agent.
|
||||
|
||||
The bank is the first transaction space; future spaces (car showroom sales, building space) reuse the same pieces: a registry entry, an [[src/renderer/src/screens/Office/office3d/objects/Interactable.tsx#Interactable]] hookup in the interior, and action wiring in the panel. "Send money to an agent" is registered but disabled (coming soon) — the backend transfer endpoint exists, but the desktop confirmation flow for moving real funds is a later phase.
|
||||
|
||||
## Representative Registry
|
||||
|
||||
Identity and menu contents of every representative, decoupled from the 3D scene: id, space, i18n label keys, and the ordered action list.
|
||||
|
||||
[[src/renderer/src/screens/Office/office3d/interactions/registry.ts#REPRESENTATIVES]] holds the entries ("bank-teller" today) and [[src/renderer/src/screens/Office/office3d/interactions/registry.ts#getRepresentative]] resolves one by id. Actions carry a `disabled` flag so not-yet-executable options (send money) still render with a "coming soon" badge. The 3D side of a representative is the interior's [[src/renderer/src/screens/Office/office3d/objects/StaffPerson.tsx#StaffPerson]] wrapped in an Interactable — see [[office-3d-interiors#Office 3D Interiors#Interactables]].
|
||||
|
||||
## Teller Interactable
|
||||
|
||||
Inside the bank interior each teller is hover/click interactive; clicking any of the three opens the representative menu up in the Office screen.
|
||||
|
||||
[[src/renderer/src/screens/Office/office3d/objects/Bank.tsx#BankTellers]] wraps each StaffPerson in an Interactable (enabled only in interior mode, like the ATMs). The hover label is pre-translated in Office.tsx and threaded down as `tellerLabel` because the i18n context doesn't cross the r3f Canvas boundary. `onTellerActivate` bubbles Bank → Office3D → Office.tsx, which sets the active rep id; entering/exiting a building clears it.
|
||||
|
||||
## Interaction Panel
|
||||
|
||||
A right-side overlay (same styling as the agent details sidebar, which hides while it is open) listing the representative's actions for a chosen agent.
|
||||
|
||||
[[src/renderer/src/screens/Office/RepInteractionPanel.tsx#RepInteractionPanel]] takes the rep, the agent list, and an initial agent (the selected agent, else a picker). Bank wiring: **account status** lists the linked cloud agent's wallets via the existing `syncWallets` IPC with transactable/receive-only badges; **check balance** finds the first transactable cloud wallet and renders its backend portfolio (token rows + USD total); **create account** provisions a backend wallet, mapping the 409 "already provisioned" reply to a friendly notice; **send money** is disabled with a coming-soon badge. Signed-out, unlinked, and foreign states (the agent's cloud link belongs to a different Hermes account) render hints instead of errors. Results are guarded by a per-request token: switching the agent picker invalidates any in-flight action, so a late response can never render one agent's wallets under another.
|
||||
|
||||
## Backend Wallet Actions
|
||||
|
||||
Main-process calls to the hermes-one backend for the panel's actions — the desktop holds no keys and reads no chain state locally for these flows.
|
||||
|
||||
[[src/main/wallet-actions.ts#getWalletPortfolio]] wraps `GET /api/wallets/:id/portfolio` (requires a transactable wallet — the backend authenticates reads with the wallet's stored key) and [[src/main/wallet-actions.ts#provisionAgentWallet]] wraps `POST /api/wallets` with `kind: "bankr"`, surfacing the backend's idempotency 409 as `status: "exists"`. Both reuse [[src/main/wallet-sync.ts#resolveLinkedAgent]] — the account/token/linked-agent-id preamble extracted from the wallet sync flow (see [[wallet-token-balances#Wallet Sync]]). Exposed to the renderer as `getWalletPortfolio`/`provisionCloudWallet` via the `wallet-portfolio` and `wallet-provision` IPC channels; result shapes (`WalletPortfolioResult`, `ProvisionWalletResult`) live in [[src/shared/wallets.ts]].
|
||||
|
||||
## Tests
|
||||
|
||||
Vitest suites covering the registry's shape and the backend wallet calls.
|
||||
|
||||
- [[src/renderer/src/screens/Office/office3d/interactions/registry.test.ts]] — every rep has labels and ≥1 executable action, ids unique, bank teller registered with its bank actions
|
||||
- [[src/main/wallet-actions.test.ts]] — portfolio: signed-out short-circuit, token mapping, malformed-row defaults, backend error strings, network failure; provisioning: request body, 409 → exists, unlinked after failed auto-sync, HTTP error
|
||||
- [[src/renderer/src/screens/Office/RepInteractionPanel.test.tsx]] — the panel's agent-context guarantees, specced below
|
||||
|
||||
### Panel follows the Office selection
|
||||
|
||||
The panel stays mounted while the Office selection changes; its agent picker follows a new non-null selection and keeps its own choice when the selection clears, so actions never silently run for an agent the rest of the UI left.
|
||||
|
||||
### Drops stale action results
|
||||
|
||||
An action started for agent A whose response lands after the picker moved to agent B is discarded — B's context never shows A's wallets — while re-running the action for B renders B's data.
|
||||
@@ -0,0 +1,97 @@
|
||||
# Provider setup
|
||||
|
||||
The first-run screen where the user picks an AI provider and enters credentials before the app is usable. Rendered by [[src/renderer/src/screens/Setup/Setup.tsx]], it writes the chosen provider/base-URL via `setModelConfig` and any key via `setEnv`.
|
||||
|
||||
The provider list is data-driven from `PROVIDERS.setup` in [[src/renderer/src/constants.ts]]. Each entry carries an `envKey`, `configProvider`, `baseUrl`, and `needsKey`; selecting a card drives which form fields show (API key, or the Local server/base-URL flow).
|
||||
|
||||
## Hermes One is the first-priority provider
|
||||
|
||||
**Hermes One Inference** (`https://inference.hermesone.org/v1`) is Hermes One's own OpenAI-compatible gateway, listed **first** in `PROVIDERS.setup`, `PROVIDER_CARDS`, and the `SETTINGS_SECTIONS` "LLM Providers" items — so it leads the Add-provider picker.
|
||||
|
||||
It is not a canonical agent provider, so it routes through `custom` + `base_url` exactly like the `openai` card, with its key stored/host-derived as `HERMESONE_API_KEY` (`inference.hermesone.org` → `HERMESONE_API_KEY` in [[src/shared/url-key-map.ts]], and `hermesone` in `OPENAI_COMPAT_PROVIDERS`). It appears in `OPENAI_COMPATIBLE_BASE_URLS` so `displayProviderFromConfig` reverse-maps it back to the Hermes One card on reload, and its logo (`detectBrand` → `hermesone`, the `hermes-icon.svg` mark) shows in the grids. Users get a key from the console's Credits → API keys.
|
||||
|
||||
## Top grid mirrors the agent's native providers
|
||||
|
||||
The top provider grid shows only providers the upstream agent supports natively; generic OpenAI-compatible endpoints live in the Local presets instead.
|
||||
|
||||
The source of truth is `CANONICAL_PROVIDERS` in the bundled agent (`hermes-agent/hermes_cli/models.py`) — the registry of providers with first-class auth/base-URL handling (nous, openrouter, anthropic, openai-codex, openai-api, gemini, xai, xiaomi, ollama-cloud, deepseek, …). A card belongs in the top grid only if it maps to a canonical slug. `aimlapi` was removed from the grid because it has no canonical entry; it remains reachable as a **Local → Remote OpenAI-Compatible APIs** preset.
|
||||
|
||||
DashScope API-key traffic uses the agent's native `alibaba` provider. The agent itself aliases `qwen` (and `dashscope`, `aliyun`, `alibaba-cloud`) to `alibaba`; only `qwen-oauth` is the Qwen Portal OAuth provider. DashScope hosts resolve to `alibaba` and `DASHSCOPE_API_KEY`, and legacy configs that still say `provider: qwen` keep working: the install-gate env map covers every alias, and `displayProviderFromConfig` lands them on the DashScope card.
|
||||
|
||||
DashScope users choose between the mainland China and international endpoints during first-run setup ([[src/renderer/src/screens/Setup/Setup.tsx]]). Both choices keep `provider: alibaba`; only `base_url` changes. The **Setup picker** defaults to mainland China (`DEFAULT_DASHSCOPE_BASE_URL`) and always writes `base_url` explicitly, but the **canonical registry** ([[src/main/provider-registry.ts]] `PROVIDER_BASE_URLS`) stays on the international endpoint because it mirrors the agent's own default and is what `setModelConfig` fills into an empty `base_url` — a CN value there would silently repoint existing international users. The Providers tab has no endpoint field anymore (the active model is picked from configured providers), so `confirmModelPick` preserves the current `base_url` when re-picking an `alibaba` model — dropping it to empty would let the canonical fill flip a mainland user to the intl endpoint.
|
||||
|
||||
## OpenAI-compatible endpoints route through Local
|
||||
|
||||
Endpoints the agent does not natively support (Groq, DeepSeek, Together, Fireworks, Cerebras, AtlasCloud, Mistral, AIML, …) are offered as `LOCAL_PRESETS` chips under the `local` card, not as top-level cards.
|
||||
|
||||
Selecting a preset sets the base URL; the API-key env var is resolved by `resolveCustomEnvKey` — first an exact `LOCAL_PRESETS.envKey` match, then [[src/shared/url-key-map.ts]] by host. So a compatible provider configures correctly without a dedicated card (e.g. `api.aimlapi.com` → `AIMLAPI_API_KEY`).
|
||||
|
||||
## Active model is picked from configured providers
|
||||
|
||||
The Providers tab ([[src/renderer/src/screens/Providers/Providers.tsx]]) sets the default (active) model by choosing from what's already configured, not by free-form entry — there's no more provider chip grid, manual model/base-URL fields, or inline API-key input.
|
||||
|
||||
The screen is organized as three tabs: Providers, Models, and Auxiliary Tasks. Providers owns the active provider/model credentials, while Models and Auxiliary Tasks embed [[src/renderer/src/screens/Models/Models.tsx]] so the saved model library and per-task model overrides live beside the provider configuration instead of as a separate sidebar destination.
|
||||
|
||||
The **MODEL** section shows a read-only summary (logo + provider label + model). A **Change** button opens a picker modal with a **provider** picker (a custom `LogoSelect` — the brand logo renders inside the control and each option, which a native `<select>` can't do) and a native **model** dropdown. Confirming sets `modelProvider`/`modelName`/`modelBaseUrl`, which the existing debounced auto-save persists to `config.yaml` via `setModelConfig` (compat providers as `custom` + base_url). The **API key is resolved automatically** at runtime — the picker never asks for it.
|
||||
|
||||
The provider list (`pickerProviders`) is sourced from the **configured providers** — the same set shown as LLM cards — NOT from which providers happen to have saved models: keyed FieldDef providers (`env[f.key]` set, in FieldDef order so Hermes One leads) plus named custom providers whose `customProviderEnvKey(label)` is set. So a freshly-keyed provider with no models yet still appears.
|
||||
|
||||
The **model** dropdown merges that provider's saved models with live discovery ([[src/renderer/src/hooks/useDiscoveredModels.ts#useDiscoveredModels]]) so a just-configured provider is immediately usable. On confirm, a discovered-only model is persisted via `addModel` first (so its key resolves and it reappears), and compat providers store `custom` + their `OPENAI_COMPATIBLE_BASE_URLS` base URL.
|
||||
|
||||
The debounced auto-save keeps a guard from the grid era that still applies: `saveModelConfig` skips persisting a `custom` selection whose `base_url` is empty (writing it would clobber config.yaml with a dead endpoint) — **unless** config.yaml already holds a custom endpoint, tracked by the `persistedCustomUrl` ref (refreshed on load and after each save). In that case the empty value IS persisted, so deliberately clearing a configured custom endpoint doesn't leave the UI (empty) and config.yaml (old URL) disagreeing after navigation/relaunch.
|
||||
|
||||
## LLM-provider keys are configured-only, via modals
|
||||
|
||||
The `SETTINGS_SECTIONS` "LLM Providers" section no longer renders a static key card for every known provider (an overwhelming wall of empty inputs). It shows only providers with a key set, plus an **Add provider** action.
|
||||
|
||||
[[src/renderer/src/components/ProviderKeysSection.tsx#ProviderKeysSection]] renders the configured cards + an Add tile; Add opens a searchable picker modal (logo per provider) → a per-provider config modal (key input with show/hide, **Remove provider**). It's a presentation layer over the same `env` state + `handleChange`/`handleBlur`/`handleRemove` handlers in [[src/renderer/src/screens/Providers/Providers.tsx]], so persistence is unchanged (`setEnv`); removing clears the env var.
|
||||
|
||||
The section is rendered **standalone, above the credential pool** rather than in the `SETTINGS_SECTIONS.map` position — it's the primary surface for configuring providers and the models the top active-model selector picks from, so it sits before the advanced multi-key pool. The map skips the `constants.sectionLlmProviders` entry (an inline title check returning null); other `SETTINGS_SECTIONS` (non-LLM) still render inline in place, after the pool.
|
||||
|
||||
### Named custom providers
|
||||
|
||||
The picker offers a **Custom provider** tile (last) for any OpenAI-compatible endpoint not covered by a built-in card. You can add **multiple**, each with a distinct name, base URL, and its own key.
|
||||
|
||||
There is no separate store — a custom provider *is* its name + base URL + the models routed to it in `models.json`.
|
||||
|
||||
The config modal collects **Name**, **Base URL**, and an API key. The key is stored under the provider's dedicated env var, [[src/shared/url-key-map.ts#customProviderEnvKey]]`(name)` → `CUSTOM_PROVIDER_<SANITISED_NAME>_KEY` — so two custom providers never share a key. Models are added through the same [[src/renderer/src/components/ProviderKeysSection.tsx#ProviderModelsManager]] with an explicit `{ provider: "custom", baseUrl }` route plus `providerLabel = name`; that label is persisted on each [[src/main/models.ts#SavedModel]] (`providerLabel`) via [[src/main/models.ts#addModel]] (whose dedup now includes base URL, so the same model id can exist under two endpoints).
|
||||
|
||||
Configured custom providers are re-derived from `models.json` on load: `provider: "custom"` models whose host resolves to `CUSTOM_API_KEY` (known compat hosts like groq/hermesone are excluded — they own dedicated key cards), grouped by `providerLabel` (falling back to host for legacy unlabeled models). Each shows as a card titled by its name; **Remove provider** deletes its models and clears its `CUSTOM_PROVIDER_*` key. The runtime resolves the same key: [[src/main/hermes.ts]] looks up the base-URL-matched model and derives `customProviderEnvKey(providerLabel ?? name)`, so every model under a provider shares that provider's key.
|
||||
|
||||
### Adding a curated partner provider
|
||||
|
||||
Sponsor/partner providers (and Hermes One itself) are OpenAI-compatible custom endpoints under the hood but are presented **first-class** — curated in-app, exactly like `hermesone`, with their own host-derived key and branding.
|
||||
|
||||
To add one, mirror the Hermes One entries: a card in `PROVIDERS.setup` + `PROVIDER_CARDS` + a base URL in `OPENAI_COMPATIBLE_BASE_URLS` ([[src/renderer/src/constants.ts]]), a `URL_KEY_MAP` entry giving it a dedicated `<PARTNER>_API_KEY` in [[src/shared/url-key-map.ts]], and a `detectBrand` rule + logo in [[src/renderer/src/components/common/BrandLogo.tsx]].
|
||||
|
||||
## Models live under each provider (OpenCode-style)
|
||||
|
||||
A provider's config modal also manages the models it serves, so the provider→models hierarchy lives in one place instead of a separate flat list.
|
||||
|
||||
[[src/renderer/src/components/ProviderKeysSection.tsx#ProviderModelsManager]] renders below the key field in the config modal: a key-status line, the model pills, and an add-input. It reads/writes the same `models.json` library the Models screen uses (`listModels`/`addModel`/`removeModel`, and re-syncs on `onModelLibraryChanged`), so added models immediately appear in the chat model picker. Models show as removable chips; the add-input autocompletes off live discovery and strips whitespace as typed/pasted (model IDs never contain spaces, so `"hello there"` can't be saved).
|
||||
|
||||
The single [[src/renderer/src/hooks/useDiscoveredModels.ts#useDiscoveredModels]] call does double duty: it feeds the add-input's `<datalist>` **and** drives the "Connected · key verified" status line — a `status: "ok"` means the endpoint accepted the key and returned a model list, so the "verified" claim is truthful. `unsupported`/`unknown-host` degrade to a plain "Connected" (key set, list not exposed), `error` to "Couldn't verify key", and an empty key to "Add a key to connect".
|
||||
|
||||
The env key is the only anchor the modal has, so persistence routing is derived from it by [[src/renderer/src/constants.ts#providerRouteForEnvKey]]: it scans `PROVIDERS.setup` (returning `{provider: configProvider ?? id, baseUrl}`) then `LOCAL_PRESETS` (always `custom` + `baseUrl`), falling back to a bare `custom` route. Native providers keep their agent slug (the gateway hardcodes the base URL); OpenAI-compatible providers save as `provider: "custom"` + explicit `baseUrl` — the same routing the Models screen / Providers tab apply, so entries stay consistent regardless of where they were added.
|
||||
|
||||
DashScope is a native provider rather than a compatible/custom endpoint, but it follows the same inline editing pattern: the endpoint selector writes either `dashscope.aliyuncs.com` or `dashscope-intl.aliyuncs.com` to `base_url`, and the key field writes `DASHSCOPE_API_KEY`.
|
||||
|
||||
Ids the agent can't resolve by id are listed in `OPENAI_COMPATIBLE_BASE_URLS` ([[src/renderer/src/constants.ts]]) — openai, perplexity, and every `LOCAL_PRESETS` chip (local servers + remote endpoints like groq, deepseek, atlascloud, mistral, …). This map MUST contain every preset id, or selecting that chip mis-routes; a test in `tests/constants.test.ts` enforces it. Selecting one autofills its base URL and shows the base-URL field; on save it is persisted as `provider: custom` + `base_url`, which the gateway accepts and uses to host-derive the API key (`runtime_provider._host_derived_api_key`, e.g. `api.groq.com` → `GROQ_API_KEY`). `displayProviderFromConfig` reverse-maps a stored `custom` + known base URL back to the brand id so the dropdown re-selects it on load. Native providers (the gateway hardcodes their base URL) clear the field instead.
|
||||
|
||||
## Switching providers rewrites the transport (`api_mode`)
|
||||
|
||||
Activating a model must rewrite or clear `model.api_mode`, or a stale protocol from the previous model routes the new endpoint over the wrong transport — dropping connections when switching OpenAI- and Anthropic-compatible custom endpoints.
|
||||
|
||||
The gateway's runtime-provider resolver honors a persisted `model.api_mode` (`anthropic_messages` vs `chat_completions`, …) for `custom`/compatible providers, and only auto-detects from the base URL (`/anthropic` suffix, `api.openai.com`, …) when the key is absent. So a leftover `anthropic_messages` would keep an OpenAI-compatible endpoint pointed at `/v1/messages` (404 / lost connection).
|
||||
|
||||
[[src/main/config.ts#setModelConfig]] takes an optional `apiMode` argument, handled exactly like `context_length`: a non-empty string sets `model.api_mode`, `null`/empty removes it (so auto-detection resumes), `undefined` leaves it untouched. The `set-model-config` IPC handler ([[src/main/ipc/register.ts]]) resolves it from the activated model's `apiMode` library field ([[src/main/models.ts#SavedModel]]) — `null` when the entry has none — alongside the `contextLength` mirror, on both the pure-local and remote-fallback local writes. Custom-provider library entries carry `apiMode` because `loadCustomProviders` reads `api_mode` from each `custom_providers:` block.
|
||||
|
||||
The library lookup runs through [[src/main/ipc/register.ts#resolveLibraryModelEntry]], which disambiguates by base URL when several entries share the same provider+model — e.g. two `custom` endpoints exposing the same model id over different transports. A bare provider+model match would return the first entry and persist its `api_mode` for the other endpoint, routing it over the wrong protocol; matching the base URL too keeps each endpoint's transport correct. Single-entry activations are unaffected.
|
||||
|
||||
## Provider icons
|
||||
|
||||
Each card's logo is resolved by [[src/renderer/src/components/common/BrandLogo.tsx]] from the provider id, falling back to a generic robot for unknown ids.
|
||||
|
||||
`detectBrand` matches the provider/model string to a `BrandKey`, and `matchTheme` flattens every logo to a single white/black tint so colored and `currentColor` SVGs render uniformly in the grid's logo tiles.
|
||||
|
||||
The Local/Remote preset chips are also branded: each renders the same `BrandLogo` (by preset id) to the left of its name in a row. `llama.cpp` is mapped off the Meta logo to the generic API mark (the `/llama/` substring would otherwise tag it, and Ollama, as Meta); any preset without a bundled logo falls back to the generic mark.
|
||||
@@ -0,0 +1,139 @@
|
||||
# Sidebar recent sessions
|
||||
|
||||
The sidebar starts with New Chat, keeps app destinations pinned, then gives conversations and projects their own scroll area.
|
||||
|
||||
[[src/renderer/src/screens/Layout/Layout.tsx#Layout]] renders a New Chat action before Discover, Office, Kanban, and Schedules from `PINNED_NAV_ITEMS`, then renders [[src/renderer/src/screens/Layout/SidebarRecentSessions.tsx]] inside a flexible `.sidebar-chat-section`. New Chat is active when the visible Chat view has no session id yet. The standalone `sessions` view is still absent from the `View` union; the full list opens from the Cmd/Ctrl+K menu action.
|
||||
|
||||
## Collapse toggle brand mark
|
||||
|
||||
The sidebar header's collapse control doubles as the brand mark: collapsed it shows a circular dot that swaps to the expand icon on hover; expanded it shows the full wordmark beside the collapse icon.
|
||||
|
||||
[[src/renderer/src/screens/Layout/Layout.tsx#Layout]] renders `.sidebar-collapse-toggle`. Collapsed, it holds a fixed-size `.sidebar-collapse-swap` box stacking a `.sidebar-collapse-mark` circle (filled with `--text-primary`, so white on dark themes and dark on light) over the `PanelLeftOpen` icon; only opacity toggles on hover/focus, so the button never reflows. Expanded, the maskable `.sidebar-logo` wordmark shows next to the `PanelLeftClose` icon.
|
||||
|
||||
## Infinite sidebar list
|
||||
|
||||
The inline list lazily loads cached sessions in pages as the user scrolls, so the sidebar can expose the full chat history without a fixed inline cap.
|
||||
|
||||
[[src/renderer/src/screens/Layout/SidebarRecentSessions.tsx]] fetches `RECENT_SESSIONS_PAGE_SIZE + 1` rows from the `sessions.json` cache to detect whether another page exists. [[src/renderer/src/screens/Layout/Layout.tsx#Layout]] passes the chat scroll container ref down, and the sidebar loads the next page when that container nears the bottom. The initial sync still refreshes `state.db`, then paints the first page.
|
||||
|
||||
Session titles in the inline list are constrained to the sidebar width and truncate with ellipses, while the chat section only scrolls vertically. This keeps long generated titles from creating a horizontal scrollbar.
|
||||
|
||||
The native sidebar scrollbar is hidden to avoid layout shifts. [[src/renderer/src/screens/Layout/Layout.tsx#Layout]] measures the chat scroll container and renders an absolutely positioned overlay thumb only while the user is scrolling, so showing or hiding the scrollbar never changes row width.
|
||||
|
||||
## Project grouping
|
||||
|
||||
Workspace-linked conversations are grouped under project rows so repository chats stay together without hiding ordinary chats.
|
||||
|
||||
[[src/main/session-cache.ts#syncSessionCache]] attaches each row's context folder in one batched [[src/main/session-context-folder-store.ts#getSessionContextFolders]] read and persists `contextFolder` into the `sessions.json` cache. [[src/main/session-cache.ts#listCachedSessions]] stays a DB-free cache read — it returns the persisted `contextFolder` without re-querying the store. The sidebar groups rows with a `contextFolder` under a Projects section by folder basename, while rows without one remain under Chats.
|
||||
|
||||
When [[src/renderer/src/screens/Chat/Chat.tsx#Chat]] saves a session context folder, it emits a renderer event that [[src/renderer/src/screens/Layout/SidebarRecentSessions.tsx]] uses to force-refresh the cache. This keeps project grouping visible immediately after a workspace is linked.
|
||||
|
||||
Projects and Chats are top-level collapsible sections, and each project folder can also be expanded or collapsed. [[src/renderer/src/screens/Layout/SidebarRecentSessions.tsx]] persists those disclosure states in `localStorage`; the sidebar CSS keeps section and folder rows on the same left rail, keeps disclosure arrows right-aligned, animates each disclosure with grid-row transitions, and removes hidden rows from keyboard tab order.
|
||||
|
||||
## Row context menu
|
||||
|
||||
Each sidebar session row exposes a ChatGPT-style options menu — Pin, Rename, Move to project, and Delete — opened from a hover-revealed `…` button or by right-clicking the row.
|
||||
|
||||
[[src/renderer/src/screens/Layout/SidebarRecentSessions.tsx]] renders each row as a `div role="button"` (so the trailing `.sidebar-recent-session-options` button is valid nested markup) and tracks the open row in `menuTarget`. [[src/renderer/src/screens/Layout/SidebarSessionMenu.tsx#SidebarSessionMenu]] renders the menu in a `document.body` portal at clamped viewport coordinates so it escapes the sidebar's clipped scroll container, and closes on outside click, Escape, a scroll of the sidebar list's own `scrollContainer`, or window blur. The scroll listener is scoped to that one container (not a global capture listener) so the chat's streaming auto-scroll — which fires window-level scroll events on every chunk — no longer dismisses the menu mid-stream. "Move to project" swaps the menu to a second in-place page listing every distinct context folder (`projectChoices`) plus **New folder…** ([[src/preload/index.ts]] `selectFolder`) and **Remove from project**, rather than a hover flyout.
|
||||
|
||||
Transitions are `motion/react`-driven (the same library as [[src/renderer/src/components/modal/AppModal.tsx#AppModal]]): the whole menu fades/scales/blurs from its top-left anchor on open, and an internal `open` flag plays the exit before the parent unmounts it (`AnimatePresence onExitComplete` → `onClose`). Switching between the main and project pages cross-slides them (direction-aware) inside a `.sidebar-session-menu-body` wrapper whose `layout` prop animates the height difference; the wrapper clips the sliding pages. Viewport clamping measures the offset box, not `getBoundingClientRect`, so an in-flight scale/height animation doesn't skew positioning.
|
||||
|
||||
Each action calls an existing desktop API with an optimistic local update and rollback on failure: Rename → `updateSessionTitle` (inline `.sidebar-recent-session-rename` input), Move → [[src/main/session-context-folder-store.ts#setSessionContextFolder]] then a `hermes-session-context-folder-changed` event so other surfaces re-group, Delete → a confirmation dialog (portal overlay) then [[src/main/sessions.ts#deleteSessionRows|deleteSession]]. Deleting the open chat calls `onSessionDeleted`, which [[src/renderer/src/screens/Layout/Layout.tsx#Layout]] uses to drop to a fresh New Chat.
|
||||
|
||||
Pinned rows are a desktop-only affordance: their ids live in `localStorage` (`hermes.sidebar.pinnedSessions`), and pinned sessions are pulled out of the normal grouping into a collapsible **Pinned** section at the top of the list.
|
||||
|
||||
## Full-list modal
|
||||
|
||||
The Cmd/Ctrl+K menu action opens an 80%×80% modal that reuses the existing Sessions screen rather than a separate route.
|
||||
|
||||
The modal in [[src/renderer/src/screens/Layout/Layout.tsx#Layout]] renders [[src/renderer/src/screens/Sessions/Sessions.tsx]] inside a `.sessions-modal` over the shared `.models-modal-overlay` backdrop. Resuming a session or starting a new chat from the modal closes it; Esc and a backdrop click also close it. Because the Sessions screen owns its own fetching gated on `visible`, it loads only while the modal is open.
|
||||
|
||||
## Profile switch and active chat
|
||||
|
||||
The footer profile switcher keeps the selected shell profile aligned with the visible chat run, while preserving older conversations under their original profiles.
|
||||
|
||||
[[src/renderer/src/screens/Layout/ProfileSwitcher.tsx#ProfileSwitcher]] persists the selected profile through main-process profile switching, then [[src/renderer/src/screens/Layout/Layout.tsx#Layout]] applies [[src/renderer/src/screens/Layout/chatRuns.ts#selectProfileRunTransition]] before rendering Chat. If the active chat is blank, it is re-homed to the selected profile; if it already belongs to another profile, the shell activates an existing blank run for the selected profile or creates a fresh one. This prevents the footer, Settings, recent sessions, and chat transport from disagreeing about which agent is active.
|
||||
|
||||
Opening a sidebar session after switching profiles consumes that blank selected-profile run instead of appending beside it. [[src/renderer/src/screens/Layout/chatRuns.ts#openSessionRunTransition]] replaces the active scratch run when it belongs to the same profile as the resumed session, so the tab strip shows the previous session without an extra "New conversation" tab.
|
||||
|
||||
The switcher trigger preserves the old app-brand label for an unrenamed default profile: when `listProfiles` returns the fallback `name === id === "default"`, the button shows `common.appName`; once a custom name is stored, it shows that user-facing name.
|
||||
|
||||
### SSH tunnel profile routing
|
||||
|
||||
SSH tunnel chat must retarget the tunnel to the selected profile's port before sending a turn — the dashboard port for dashboard transport, or the api_server port on the gateway-only fallback.
|
||||
|
||||
The primary path tunnels to the remote **unified machine dashboard** (see [[main-process#SSH dashboard transport]]): ONE dashboard on a single port serves every profile (scoped via `?profile=`), so [[src/main/ipc/register.ts#getSshDashboardSessionConfig]] / [[src/main/dashboard.ts#sshDashboardConnectionFromConfig]] / the send-message preamble all call [[src/main/ssh-tunnel.ts#ensureSshTunnel]] with that **same** port regardless of profile. This is essential: the single global tunnel can only point at one remote port, so per-profile dashboard ports (an earlier attempt) made concurrent profile queries thrash it. On the gateway-only fallback (no dashboard), the tunnel instead targets the profile's `platforms.api_server.extra.port` via [[src/main/ssh-remote.ts#sshResolveApiServerPort]], which auto-allocates and persists a remote profile port when one is missing. Tunnel starts are serialized by target, and stale SSH process exit/error callbacks cannot clear a newer retargeted tunnel.
|
||||
|
||||
### Remote launcher profile resolution
|
||||
|
||||
Managed SSH installs can store Hermes outside the SSH user's home or under a different `HERMES_HOME`, so Office/Agents must read profiles from the actual remote runtime rather than a `~/.hermes` filesystem scan.
|
||||
|
||||
[[src/main/ssh-remote.ts#buildRemoteHermesCmd]] probes per-user launcher hooks (`$HOME/.config/hermes-desktop/remote-hermes`, `$HOME/.hermes/desktop-remote-hermes`) before the default venv/PATH locations, letting a deployment supply its own wrapper that sets the right command, service user, and `HERMES_HOME`. [[src/main/ssh-remote.ts#sshListProfiles]] detects whether such a launcher actually exists in one round trip, then [[src/main/ssh-remote.ts#selectSshProfiles]] treats a present launcher as authoritative — preferring its profiles over the scan even on an equal count, so a managed `default`-only install shows live gateway state instead of stale home-directory data. Named-profile Schedules route the same way through [[src/main/ssh-remote.ts#sshRunCron]], while the default profile keeps the existing HTTP `/api/jobs` path.
|
||||
|
||||
Every SSH-invoked `hermes` command resolves the CLI through `buildRemoteHermesCmd`, never a bare `hermes` — a non-interactive SSH shell does not source the profile that puts the CLI on PATH, so a bare invocation fails with "command not found" on otherwise-healthy remotes. This covers gateway lifecycle ([[src/main/ssh-remote.ts#buildGatewayStartCommand]] / [[src/main/ssh-remote.ts#buildGatewayStopCommand]] non-systemd branch, for both named and default profiles), skills ([[src/main/ssh-remote.ts#sshInstallSkill]], [[src/main/ssh-remote.ts#sshUninstallSkill]], [[src/main/ssh-remote.ts#sshSearchSkills]]), and profile create/delete ([[src/main/ssh-remote.ts#sshCreateProfile]] / [[src/main/ssh-remote.ts#sshDeleteProfile]], which also use the **singular** `profile` subcommand). The systemd branch still prefers `systemctl` when a `hermes.service` unit exists, and [[src/main/ssh-remote.ts#buildGatewayStatusCommand]] remains a pid-file liveness check. The non-systemd branch launches the gateway with `gateway run` (foreground, backgrounded via `nohup`), **not** `gateway start` — `gateway start` drives the systemd/launchd service and fails with "Gateway service is not installed" on a bare VPS that never ran `hermes gateway install`, whereas `gateway run` launches the gateway and its api_server directly and writes the pid file the status/stop commands read.
|
||||
|
||||
### Remote-mode skills routing
|
||||
|
||||
In remote (HTTP) mode the Skills surface must read and mutate the REMOTE machine's skills — the handlers used to fall through to the local CLI, showing (and installing into!) the wrong machine's skills.
|
||||
|
||||
[[src/main/remote-skills.ts]] routes the four skills IPC handlers to the dashboard API when `conn.mode === "remote"`: list via `GET /api/skills`, content via `GET /api/skills/content?name=`, install/uninstall via `POST /api/skills/hub/install|uninstall`. Remote skills are keyed by NAME + PROFILE on the API but the desktop keys content lookups by path alone, so listed skills carry a `remote-skill:<profile>:<name>` marker path ([[src/main/remote-skills.ts#remoteSkillPath]]) that [[src/main/remote-skills.ts#remoteGetSkillContent]] unwraps. The profile MUST ride in the path (mirroring how local/SSH paths carry the full location) — the content IPC has no profile argument, and falling back to the globally active profile would query the wrong profile whenever the Skills screen is scoped to a named one. Named profiles ride as `?profile=` (the unified-dashboard scoping convention); `default` sends no param. All query params go through `URL.searchParams` so encoding stays consistent whether or not a profile param is appended.
|
||||
|
||||
Two deliberate asymmetries: bundled skills stay local in remote mode (that list is the shipped catalog, not per-machine state), and the hub install/uninstall endpoints SPAWN the CLI on the remote and return `{ok, pid}` immediately — success means "started", not "completed", unlike the local/SSH paths which await and classify the CLI output.
|
||||
|
||||
## Profiles page
|
||||
|
||||
The Profiles page lists every workspace as table-style rows and creates new ones from a modal that can clone a chosen source profile.
|
||||
|
||||
[[src/renderer/src/screens/Agents/Agents.tsx]] renders one `agents-row` per profile (avatar, user-facing `name`, stable `id` when it differs, `provider · skills`, a monospace model chip, and a `Running`/`Off` gateway pill), marking the active profile with a left green bar. Selecting a row switches by profile `id` ([[src/main/ipc/register.ts#registerIpcHandlers]]'s `set-active-profile`), which starts that profile's gateway asynchronously — so the status reads from a pid file that isn't written yet at switch time. In **SSH mode** this persists the selection to the local `~/.hermes/active_profile` (so it survives relaunch instead of resetting to `default`) and starts the profile's gateway on the remote via [[src/main/ssh-remote.ts#sshStartGateway]] (previously SSH was a no-op, so named-profile gateways never started and the selection never stuck). [[src/main/profiles.ts#setActiveProfile]] guarantees that persistence with a read-back fallback: the local `hermes profile use` CLI raises when the profile exists only on the remote (or there is no local install) and that error is swallowed, so when the CLI didn't move `active_profile`, the desktop writes the file directly — otherwise the selection silently never persisted and `activeSshProfile()` scoped the unified dashboard's data to `default`. `list-profiles` over SSH also overrides `isActive` from the local active profile (not the remote CLI's `◆` marker) so the persisted selection is the one highlighted. The page therefore polls `listProfiles` (~700ms, capped near 10s) after a switch until the selected profile reports running, flipping its pill to `Running` on its own rather than only after a manual refresh or revisit. While that poll runs (and the gateway wasn't already up), the switched row shows a `Starting…` pill with a spinner; it settles on the real `Running`/`Off` status once the gateway reports in or the poll gives up. **New Agent** opens an [[src/renderer/src/components/modal/AppModal.tsx#AppModal]] with a user-facing agent-name field, a "clone config & API keys" toggle, and — when cloning — a source-profile `<select>` defaulting to the active profile id. Create calls `window.hermesAPI.createProfile(name, cloneFrom)` where `name` is the user-facing label and `cloneFrom` is the chosen source id or `null` for a fresh profile; the modal stays open on failure so the error is visible and the user can retry. [[src/main/profiles.ts#createProfile]] generates a CLI-safe internal id from that label, stores the label as profile metadata, then maps a non-null `cloneFrom` to the agent CLI's `hermes profile create <id> --clone-from <source>` (which implies `--clone`), validating the source as `default` or a valid named profile. Once the CLI create succeeds, metadata write failures are logged and treated as display-only degradation so the desktop does not report a false create failure or nudge a retry that would generate a second id. [[src/main/ssh-remote.ts#sshCreateProfile]] keeps the remote create path's `{ success, error }` shape and surfacing behavior. [[src/main/registry.ts]] installs a published agent by cloning from `default`.
|
||||
|
||||
The profile modal's inline name editor saves on Enter/blur, but Escape is a real cancel path: it restores the current saved name and suppresses the blur-save that browsers fire as the input unmounts.
|
||||
|
||||
## Office profile labels
|
||||
|
||||
The Office scene shows each profile's user-facing name while keeping profile ids stable for routing.
|
||||
|
||||
[[src/renderer/src/screens/Office/Office.tsx]] loads profiles through `listProfiles()` and maps them with [[src/renderer/src/screens/Office/office3d/agents.ts#profileToOfficeAgent]]. The mapped Office agent keeps `id = profile.id` for selection, CEO persistence, and One Chat routing, but uses `profile.name` as `agent.name`, so the 3D speech bubble, details sidebar, and One Chat labels match the renamed agent. The visible-tab poll uses [[src/renderer/src/screens/Office/office3d/agents.ts#officeAgentsChanged]] so name changes refresh without requiring a manual Office reload.
|
||||
|
||||
## Profile detail modal
|
||||
|
||||
A single global modal (80vw × 80vh) with a left-section nav views and edits a profile, opened from anywhere via a context hook so future profile features share one surface.
|
||||
|
||||
[[src/renderer/src/components/profile/ProfileModalProvider.tsx#ProfileModalProvider]] mounts [[src/renderer/src/components/profile/ProfileModal.tsx#ProfileModal]] at the app root and exposes `openProfile(id, opts)` through [[src/renderer/src/components/profile/ProfileModalContext.ts#useProfileModal]]. The sidebar popover's active profile (a button in [[src/renderer/src/screens/Layout/ProfileSwitcher.tsx#ProfileSwitcher]]) and each profile row's edit control in [[src/renderer/src/screens/Agents/Agents.tsx]] both call `openProfile`, passing `onChanged` to refresh their lists and `onDeleted` to fall back to the default profile when the active one is removed. `opts.initialSection` deep-links to a specific left-nav section on open — the Office tab's bank ATMs use it to jump straight to **Wallet** (see [[office-3d-interiors#Office 3D Interiors#Interactables]]). The header shows the profile avatar and user-facing name; the icon'd left nav (`PROFILE_SECTIONS`) switches the right pane between **Profile** (inline name editing in the identity title, avatar upload/remove, colour, and lucide provider/model/skills/gateway chips), **Persona** (a profile-scoped copy of [[src/renderer/src/screens/Soul/Soul.tsx#Soul]]), **Agent Memory** (a profile-scoped copy of [[src/renderer/src/screens/Memory/MemoryEntries.tsx#MemoryEntries]] loaded through `readMemory(profile.id)`), **Wallet** (a profile-scoped Base wallet pane in [[src/renderer/src/components/profile/ProfileWalletPane.tsx#ProfileWalletPane]]), and **Advanced** (the delete danger zone). Every profile — including default — is editable; only the default profile can't be deleted, so its Advanced pane just says so. The modal self-loads via `listProfiles()` and re-reads after every mutation, replacing the former inline `agents-appearance` modal.
|
||||
|
||||
Agent names are desktop metadata in `profile-meta.json`, surfaced as `ProfileInfo.name` from [[src/main/profiles.ts#listProfiles]] and mutated through [[src/main/profile-meta.ts#setProfileName]]. They do not rename the stable profile id or directory, so profile-scoped memory, wallets, sessions, active profile selection, and gateway routing continue to use `profile.id`. If the save IPC rejects, the inline editor remains open, clears its Saving tag, and shows the name-update error instead of trapping the user in a pending state.
|
||||
|
||||
Legacy renderer state can still contain a run without a profile id during upgrades. Profile avatars and the active-session strip treat a missing profile as `default`, and profile-name IPC handlers accept omitted names as an empty value, so stale state cannot trip a `.trim()` exception and black-screen the app.
|
||||
|
||||
### Profile wallets
|
||||
|
||||
Profile wallets are local Base-network Ethereum wallets, capped per profile and kept separate from chat/provider credentials.
|
||||
|
||||
[[src/renderer/src/components/profile/ProfileWalletPane.tsx#ProfileWalletPane]] lists public wallet metadata from `listWallets(profile)`, opens a create/import modal, and only displays a recovery phrase in the one-time success state after `createWallet` or `importWallet`. [[src/main/wallet-store.ts#createWallet]] generates a BIP-39 recovery phrase with Node crypto entropy, derives the Ethereum address with `ethers`, and stores public metadata plus an encrypted recovery phrase in `wallets.json` under the profile home. [[src/main/wallet-store.ts#importWallet]] validates an existing recovery phrase, rejects duplicate addresses in the same profile, and uses the same Base wallet metadata shape from [[src/shared/wallets.ts#ProfileWallet]].
|
||||
|
||||
### Shared modal shell
|
||||
|
||||
Reusable modals use a single animated shell so dialogs open and close consistently.
|
||||
|
||||
[[src/renderer/src/components/modal/AppModal.tsx#AppModal]] wraps Radix Dialog with Motion's `AnimatePresence`, keeping focus trapping, escape/outside-close behavior, and exit transitions in one memoized component. The shell keeps its Radix portal present through the exit phase and animates the backdrop plus content with visible fade, scale, slide, and blur. Profile modal is the first consumer: [[src/renderer/src/components/profile/ProfileModalProvider.tsx#ProfileModalProvider]] keeps its target profile mounted until `AppModal` finishes the close animation, then clears the modal state.
|
||||
|
||||
## Footer action row
|
||||
|
||||
Administrative destinations sit beside the profile switcher so the conversation nav stays short.
|
||||
|
||||
[[src/renderer/src/screens/Layout/Layout.tsx#Layout]] keeps Providers, Gateway, Tools, and Memory out of the main sidebar list and renders them as icon-only footer actions immediately above [[src/renderer/src/screens/Layout/ProfileSwitcher.tsx#ProfileSwitcher]]. Each button exposes a styled hover/focus tooltip and accessible label, preserving discoverability while freeing vertical room for recent conversations. Settings is no longer a `View`: its footer gear button opens the global settings modal (below) instead of switching panes.
|
||||
|
||||
When the sidebar is collapsed, those footer actions stay in a single centered icon rail anchored to the bottom of the 64px sidebar, with the compact profile switcher below them and no divider line above the footer.
|
||||
|
||||
## Settings modal
|
||||
|
||||
A single global modal (80vw × 80vh) with a grouped left nav presents every app/agent setting, opened from anywhere rather than as a sidebar tab.
|
||||
|
||||
[[src/renderer/src/components/settings/SettingsModalProvider.tsx#SettingsModalProvider]] mounts [[src/renderer/src/components/settings/SettingsModal.tsx]] at the app root (inside `ProfileModalProvider`) and exposes `openSettings(section?, { profile })` through [[src/renderer/src/components/settings/SettingsModalContext.ts#useSettingsModal]]. Three entry points call it: the sidebar-footer gear, the `/settings` command's `onOpenDiagnose` path, and a global **Cmd/Ctrl+,** keydown handler in [[src/renderer/src/screens/Layout/Layout.tsx#Layout]] — each passes the active profile so the modal reads/writes the right config. The modal reuses the shared [[src/renderer/src/components/modal/AppModal.tsx#AppModal]] shell (see [[sidebar-navigation#Profile detail modal#Shared modal shell]]).
|
||||
|
||||
The left nav is two labelled groups — **General** (Appearance, Language, Privacy, Connection, Data) and **Hermes Agent** (About & Updates, Community, Logs & Diagnostics) — and `SETTINGS_NAV`/`resolveSection` in [[src/renderer/src/components/settings/SettingsModal.tsx]] map ids to panes. Network settings (Force IPv4 + proxy) are not a separate tab: they apply to every outgoing connection, so they live as a `Network` subsection at the bottom of [[src/renderer/src/components/settings/ConnectionPane.tsx]], and `resolveSection` aliases the legacy `/settings network` argument to the Connection pane. All shared state, the config-load effect, and the mutation handlers live in [[src/renderer/src/components/settings/useSettingsData.ts#useSettingsData]] (relocated wholesale from the former `Settings` screen) and reach each pane through [[src/renderer/src/components/settings/SettingsDataContext.ts#useSettings]], so the panes (`AppearancePane`, `ConnectionPane`, `AboutPane`, …) stay purely presentational. One exception: `AppearancePane`'s hardware-acceleration field reads `getGpuStatus` from the preload bridge directly, because GPU state is per-launch main-process state rather than profile config (see [[main-process#GPU Fallback#User preference]]). The modal's chrome is `user-select: none` (drag-selection highlighting nav labels and field captions read as broken UI); form fields and `pre`/`code` output — notably the Logs pane — opt back into text selection so they stay copyable.
|
||||
|
||||
## Provisional fresh sessions
|
||||
|
||||
Fresh chat session ids are provisional until a turn produces output or completes successfully, so provider errors do not create visible recent-session rows.
|
||||
|
||||
The main-process transports still send a generated `X-Hermes-Session-Id` on fresh requests to avoid gateway fingerprint collisions, but [[src/main/hermes.ts#sendMessageViaApi]] and the runs transport announce that id to the renderer only after visible output, tool/reasoning activity, or successful completion. Resumed sessions are announced immediately because the renderer already knows they are existing conversations. This keeps [[src/renderer/src/screens/Chat/hooks/useChatIPC.ts#useChatIPC]] from binding a failed first turn to a new sidebar entry.
|
||||
@@ -0,0 +1,50 @@
|
||||
# Wallet & Token Balances
|
||||
|
||||
Profile-scoped Ethereum wallets on Base mainnet, with on-chain token balance reads.
|
||||
|
||||
## Wallet Store
|
||||
|
||||
Profile wallets are stored per-profile in `wallets.json` alongside profile metadata. Keys and recovery phrases never leave the main process.
|
||||
|
||||
[[src/main/wallet-store.ts]] provides create, import, rename, delete, and list operations. Recovery phrases are encrypted via Electron `safeStorage` and stripped by [[src/main/wallet-store.ts#publicWallet]] before any data crosses IPC. The per-profile cap is 10 wallets ([[src/main/wallet-store.ts#MAX_WALLETS_PER_PROFILE]]).
|
||||
|
||||
Wallet metadata types live in [[src/shared/wallets.ts]]: `ProfileWallet` (public shape), `WalletMutationResult` (one-time recovery phrase on create/import), and `ImportWalletInput`.
|
||||
|
||||
Local **creation/import is being retired** in favour of backend-provisioned wallets (see [[wallet-token-balances#Wallet Sync]]). The store's `createWallet`/`importWallet` and their IPC channels are retained for now, but the wallet pane no longer exposes a create/import UI.
|
||||
|
||||
## Wallet Sync
|
||||
|
||||
Wallets provisioned by the Hermes One backend for a profile's linked cloud agent are fetched and shown read-only alongside local ones, so the desktop stops minting wallets itself.
|
||||
|
||||
[[src/main/wallet-sync.ts#syncWalletsForProfile]] resolves the signed-in account and linked cloud-agent id through [[src/main/wallet-sync.ts#resolveLinkedAgent]] — which finds the account ([[src/main/account-store.ts#findAccountProfile]]) and the agent id via [[src/main/agent-sync.ts#getLinkedAgentId]], auto-running [[src/main/agent-sync.ts#syncAgents]] once when the profile has never synced — then GETs `/api/wallets?agentId=…` (bearer + `x-api-key` via [[src/main/hermes-account.ts#apiHeaders]]). Rows are mapped by the pure [[src/main/wallet-sync.ts#mapCloudWallet]] into the shared `WalletView`; rows without an EVM address are dropped. No wallet secret ever reaches the device — these are receive/tracked addresses, matching the backend `docs/agent-sync.md` "Wallets per agent" intent. `resolveLinkedAgent` is shared with the Office's backend wallet actions ([[office-interactions#Office Space Interactions#Backend Wallet Actions]]). It also enforces link ownership ([[src/main/agent-sync.ts#getLinkedAgentAccountId]]): a link recording a different account resolves as `foreign` without any backend call, and a legacy untagged link first gets one sync pass — which stamps the owner when the agent belongs to this account — then resolves as `foreign` if still unowned, so a stale agent id is never sent under a new account's token. The wallet pane / bank panel show a "linked to a different account" note — see [[agent-sync#Cloud agent sync#Sync engine]].
|
||||
|
||||
The `wallet-sync` IPC channel (registered in [[src/main/ipc/register.ts#registerIpcHandlers]]) exposes it as `syncWallets` on `window.hermesAPI`. [[src/renderer/src/components/profile/ProfileWalletPane.tsx]] renders local (`wallets.json`) and cloud wallets in one list, each tagged with a Local/Cloud badge; delete is offered only for local wallets, copy/balance for both. Signed-out, never-synced, or foreign-linked profiles show a hint instead of an error.
|
||||
|
||||
## Token Balances
|
||||
|
||||
On-chain balance reads for Base mainnet ERC-20 tokens, fetched via ethers v6 `JsonRpcProvider`.
|
||||
|
||||
[[src/main/wallet-balances.ts#getTokenBalances]] takes a wallet address and returns a `TokenBalancesResponse` containing native ETH plus all configured ERC-20 token balances. Uses `Promise.allSettled()` so one token RPC failure does not block others — each failed token gets an `error` field.
|
||||
|
||||
Each RPC read is wrapped in [[src/main/wallet-balances.ts#withTimeout]] (10s default; ethers v6 has no per-request timeout) so a hung endpoint surfaces as a per-token timeout error instead of a chip that spins forever.
|
||||
|
||||
Token metadata (contract address, symbol, decimals) lives in [[src/shared/tokens.ts]] as `BASE_TOKENS`. Currently tracks ETH (native) and $HD (`0xfda75f77a22b4f4b783bbbb21915ef64d149bba3`), both 18 decimals. $H1 is held back for a future release.
|
||||
|
||||
### Balance formatting
|
||||
|
||||
[[src/shared/tokens.ts#formatTokenBalance]] converts raw BigInt strings to compact form: zero → "0", ≥1M → "1.5M", ≥1K → "10.5K", tiny non-zero → "< 0.0001", otherwise up to 4 significant digits. [[src/shared/tokens.ts#formatTokenBalanceFull]] produces the same without K/M suffixes — used for tooltip display of exact amounts.
|
||||
|
||||
### IPC & UI
|
||||
|
||||
The `get-token-balances` IPC channel exposes balance reads to the renderer. Balances auto-fetch when the wallet pane loads; previously cached balances display immediately while fresh ones load, then update in place.
|
||||
|
||||
Balance data is cached at module level (keyed by wallet address) so it survives tab switches — when the component remounts, it hydrates from the cache instantly and refreshes in the background. Each balance renders as a chip: token icon (only when a known icon is mapped) + symbol label (exactly once) + compact amount (K/M). Hovering a chip shows a native tooltip with the full amount via `formattedFull`. Wallet deletion uses a confirmation modal with red warnings.
|
||||
|
||||
## Tests
|
||||
|
||||
Vitest test suites for wallet store and balance reads.
|
||||
|
||||
- [[src/main/wallet-store.test.ts]] — wallet CRUD, rename/delete, encryption, dedup, caps, and import error distinction (invalid phrase vs. secure-storage failure)
|
||||
- [[src/main/wallet-sync.test.ts]] — `mapCloudWallet` mapping (default name, addressless drop) and `syncWalletsForProfile` paths: signed-out, linked-agent fetch, auto-sync-then-fetch when unlinked, HTTP error, and the ownership gate (foreign link refused with no backend call; matching owner proceeds; legacy untagged links adopt via one sync pass or refuse as foreign)
|
||||
- [[src/main/wallet-balances.test.ts]] — formatTokenBalance edge cases and big-balance precision, `withTimeout`, getTokenBalances with mocked RPC including timeout handling
|
||||
- [[src/renderer/src/components/profile/ProfileWalletPane.test.tsx]] — balance-chip rendering: one symbol label per token, icon only for known tokens
|
||||
@@ -0,0 +1,20 @@
|
||||
# Web preview webview
|
||||
|
||||
The chat screen can open a split-screen [[src/renderer/src/screens/Chat/WebPreviewPanel.tsx#WebPreviewPanel]] — an embedded Electron `<webview>` with a browser toolbar and an inspect-element mode — so the agent's links and locally served apps render in-app instead of an external browser.
|
||||
|
||||
It auto-opens when an in-app link is clicked or a web tool reports a URL, via the `web-preview:navigate` `CustomEvent` that [[src/renderer/src/screens/Chat/Chat.tsx]] listens for. Inspect mode injects a hover/click overlay into the page and feeds the picked element's pretty-printed HTML back into the chat input.
|
||||
|
||||
The panel's width is free-resizable: a drag handle on its left edge sets a `width` clamped between `MIN_PANEL_WIDTH` and `window.innerWidth - 360`, persisted to `localStorage` under `hermes:webPreviewWidth`. During a drag the webview's `pointer-events` are disabled so it doesn't swallow the move stream.
|
||||
|
||||
## Webview identification and HTTPS policy
|
||||
|
||||
The preview is the only webview allowed to load remote HTTPS; all others stay restricted to loopback HTTP. It is identified by its `partition="web-preview"` attribute, which Electron forwards to both security gates (unlike `name`).
|
||||
|
||||
[[src/main/security.ts#isAllowedWebviewUrl]] gains an `allowHttps` flag: HTTPS and `about:blank` are permitted only when the caller passes it. Two gates set that flag for the preview, each identifying it by a signal Electron actually exposes at that point:
|
||||
|
||||
- **Attach gate** — [[src/main/app/start.ts#startMainProcess]]'s `will-attach-webview` handler reads `params.partition === "web-preview"` (attributes are forwarded as a `Record<string, string>`) and, when true, calls `isAllowedWebviewUrl(src, true)`.
|
||||
- **Navigation gate** — in `web-contents-created`, [[src/main/app/start.ts#startMainProcess]] calls [[src/main/security.ts#hardenAttachedWebContents]], which applies the `allowHttps` flag to web-preview navigations.
|
||||
|
||||
The session comparison is deliberate: `getLastWebPreferences()` is not a public Electron API (returns `undefined`), so reading attributes back from the attached webContents is unreliable — the partition session is the only signal available in `web-contents-created`. Without it, redirects (e.g. `google.com` → `www.google.com`) and subsequent navigations are wrongly blocked even though the initial attach succeeded.
|
||||
|
||||
Remote pages still run fully sandboxed: `hardenWebviewPreferences` forces `nodeIntegration:false`, `contextIsolation:true`, `sandbox:true`, `webSecurity:true` and deletes any preload, so loading arbitrary HTTPS grants the page no host or Node access.
|
||||
@@ -0,0 +1,31 @@
|
||||
# Window title bar and conversation tabs
|
||||
|
||||
The top strip of the main window is a browser-style title bar: it is the window's drag region, and the open-conversation tabs live *on* it rather than in a separate bar below, so no vertical space is spent on a dedicated, always-empty drag strip.
|
||||
|
||||
On macOS the window is frameless (`titleBarStyle: "hiddenInset"`, traffic lights inset at x/y 16 — see [[src/main/app/start.ts#startMainProcess]]), and [[src/renderer/src/App.tsx]] renders a fixed full-width `.drag-region` (`-webkit-app-region: drag`, z-index 1000) so the whole top band — including over the sidebar/traffic-light area — drags the window. This strip is mac-only; other platforms keep the OS title bar.
|
||||
|
||||
`.app` fills the window (`height: 100vh`) so the chrome reaches every edge. The sidebar is a flush full-height panel: it only rounds its left corners (`border-radius: 16px 0 0 16px`) to follow the window's rounded corners, while its right edge is square against the content column.
|
||||
|
||||
## Tabs layered above the drag region
|
||||
|
||||
[[src/renderer/src/screens/Layout/ActiveSessionsBar.tsx#ActiveSessionsBar]] is the content column's title bar. It owns the top band browser-style: empty space drags, the chips stay clickable.
|
||||
|
||||
- The bar itself is `-webkit-app-region: drag` with `position: relative; z-index: 1001`, so it stacks above the global `.drag-region` (z 1000) and is the drag handle for the content column.
|
||||
- Each `.active-session-chip` opts back out with `-webkit-app-region: no-drag`, keeping select/close clickable above the drag layer — the same priority model browsers use for tabs over a draggable tab strip.
|
||||
- `min-height: 34px` (≥ the 28px global drag strip) means content rendered after the bar clears the fixed drag layer, so the old `.is-mac .content { padding-top: 28px }` offset is no longer needed.
|
||||
|
||||
Visually the strip is a Safari-style tab bar: the strip uses the darker `--bg-secondary` toolbar shade; tabs are flat (no border/fill) and separated by thin vertical dividers drawn with an `::before` on each non-first chip. The active tab fills with `--bg-primary` — the same colour as the transparent content area below it — and rounds its top corners, so it docks into the page; the dividers flanking the active tab are hidden for a seamless join.
|
||||
|
||||
## Blank until a real session exists
|
||||
|
||||
The bar always renders so it is always a drag area, but chips stay hidden only while the sole conversation is still a blank scratch chat.
|
||||
|
||||
Chips show when more than one run is open, any run is loading, or any run has a session id/title (`showChips` in [[src/renderer/src/screens/Layout/ActiveSessionsBar.tsx#ActiveSessionsBar]]). When chips show, a browser-style new-tab **"+"** button (`.active-session-new`, `no-drag`) trails them and calls `onNew` → `handleNewChat` in [[src/renderer/src/screens/Layout/Layout.tsx]] to open a fresh conversation.
|
||||
|
||||
Because the bar doubles as the drag strip, [[src/renderer/src/screens/Layout/Layout.tsx]] renders it as the first child of `.content`; the verify-warning banner (when shown) sits just below it, clear of the drag layer.
|
||||
|
||||
## Follow-us modal
|
||||
|
||||
A one-time modal prompting the user to follow Hermes on X. Dismissed permanently via localStorage after either button is clicked.
|
||||
|
||||
[[src/renderer/src/components/FollowUsModal.tsx]] stores the dismissal flag in `localStorage` under `hermes-follow-x-dismissed`. Both "Follow" (opens `https://x.com/HermesOneApp` via `openExternal`) and "Not Now" write the flag and close the modal. It renders in [[src/renderer/src/screens/Chat/Chat.tsx]] only when `connectionModeLoaded && readiness.ok`, so it appears after setup is complete. The modal reuses the `.models-modal-overlay` / `.models-modal` pattern for consistent styling.
|
||||
@@ -0,0 +1,95 @@
|
||||
{
|
||||
"name": "hermes-desktop",
|
||||
"version": "0.7.3",
|
||||
"description": "Hermes Agent Desktop — self-improving AI assistant",
|
||||
"main": "./out/main/index.js",
|
||||
"author": "fathah",
|
||||
"homepage": "https://github.com/fathah/hermes-desktop",
|
||||
"scripts": {
|
||||
"format": "prettier --write .",
|
||||
"lint": "eslint --cache .",
|
||||
"test": "vitest run",
|
||||
"test:coverage": "vitest run --coverage",
|
||||
"test:sandbox": "powershell -NoProfile -ExecutionPolicy Bypass -File scripts/hermes-sandbox.ps1 test",
|
||||
"test:live-visual": "node scripts/drive-live-regression-suite.js",
|
||||
"typecheck:node": "tsc --noEmit -p tsconfig.node.json --composite false",
|
||||
"typecheck:web": "tsc --noEmit -p tsconfig.web.json --composite false",
|
||||
"typecheck": "npm run typecheck:node && npm run typecheck:web",
|
||||
"typecheck:sandbox": "powershell -NoProfile -ExecutionPolicy Bypass -File scripts/hermes-sandbox.ps1 typecheck",
|
||||
"start": "electron-vite preview",
|
||||
"dev": "electron-vite dev",
|
||||
"dev:sandbox": "powershell -NoProfile -ExecutionPolicy Bypass -File scripts/hermes-sandbox.ps1 dev",
|
||||
"sandbox:sync-config": "powershell -NoProfile -ExecutionPolicy Bypass -File scripts/hermes-sandbox.ps1 sync-config",
|
||||
"dev:fresh": "HERMES_HOME=$(mktemp -d -t hermes-fresh) electron-vite dev",
|
||||
"build": "npm run typecheck && electron-vite build",
|
||||
"postinstall": "electron-builder install-app-deps",
|
||||
"build:unpack": "npm run build && electron-builder --dir",
|
||||
"build:win": "npm run build && electron-builder --win",
|
||||
"build:mac": "electron-vite build && electron-builder --mac",
|
||||
"build:linux": "electron-vite build && electron-builder --linux",
|
||||
"build:rpm": "npm run build && electron-builder --linux rpm",
|
||||
"test:watch": "vitest"
|
||||
},
|
||||
"dependencies": {
|
||||
"@electron-toolkit/preload": "^3.0.2",
|
||||
"@electron-toolkit/utils": "^4.0.0",
|
||||
"@radix-ui/react-dialog": "^1.1.17",
|
||||
"@react-three/drei": "^10.7.7",
|
||||
"@react-three/fiber": "^9.5.0",
|
||||
"@types/highlight.js": "^9.12.4",
|
||||
"@types/react-syntax-highlighter": "^15.5.13",
|
||||
"@wesbos/code-icons": "^1.2.4",
|
||||
"better-sqlite3": "^12.8.0",
|
||||
"date-fns": "^4.4.0",
|
||||
"electron-updater": "^6.3.9",
|
||||
"ethers": "^6.17.0",
|
||||
"highlight.js": "^11.11.1",
|
||||
"i18next": "^25.6.0",
|
||||
"lucide-react": "^1.7.0",
|
||||
"motion": "^12.40.0",
|
||||
"react-file-icon": "^1.6.0",
|
||||
"react-hot-toast": "^2.6.0",
|
||||
"react-i18next": "^15.7.3",
|
||||
"react-loader-spinner": "^8.0.2",
|
||||
"react-markdown": "^10.1.0",
|
||||
"react-syntax-highlighter": "^16.1.1",
|
||||
"remark-gfm": "^4.0.1",
|
||||
"three": "^0.183.2",
|
||||
"troika-three-text": "^0.52.4",
|
||||
"vscode-material-icons": "^0.1.1",
|
||||
"ws": "^8.20.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@electron-toolkit/eslint-config-prettier": "^3.0.0",
|
||||
"@electron-toolkit/eslint-config-ts": "^3.1.0",
|
||||
"@electron-toolkit/tsconfig": "^2.0.0",
|
||||
"@tailwindcss/vite": "^4.2.2",
|
||||
"@testing-library/dom": "^10.4.1",
|
||||
"@testing-library/jest-dom": "^6.8.0",
|
||||
"@testing-library/react": "^16.3.0",
|
||||
"@types/better-sqlite3": "^7.6.13",
|
||||
"@types/node": "^22.19.1",
|
||||
"@types/react": "^19.2.7",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@types/three": "^0.183.1",
|
||||
"@vitejs/plugin-react": "^5.1.1",
|
||||
"@vitest/coverage-v8": "^4.1.8",
|
||||
"electron": "^39.2.6",
|
||||
"electron-builder": "^26.0.12",
|
||||
"electron-vite": "^5.0.0",
|
||||
"eslint": "^9.39.1",
|
||||
"eslint-plugin-react": "^7.37.5",
|
||||
"eslint-plugin-react-hooks": "^7.0.1",
|
||||
"eslint-plugin-react-refresh": "^0.4.24",
|
||||
"fast-check": "^4.8.0",
|
||||
"jsdom": "^26.1.0",
|
||||
"playwright": "^1.60.0",
|
||||
"prettier": "^3.7.4",
|
||||
"react": "^19.2.1",
|
||||
"react-dom": "^19.2.1",
|
||||
"tailwindcss": "^4.2.2",
|
||||
"typescript": "^5.9.3",
|
||||
"vite": "^7.2.6",
|
||||
"vitest": "^4.1.4"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
## Summary
|
||||
|
||||
This PR fixes the production white-screen regression and adjusts the GPU fallback so the Office tab does not get stuck on slow software rendering on macOS.
|
||||
|
||||
## What Changed
|
||||
|
||||
- Fixed packaged renderer loading after the main-process refactor.
|
||||
- `electron-vite` emits the bundled main process at `out/main/index.js`.
|
||||
- The release app must resolve `../renderer/index.html` from that runtime directory, not `../../renderer/index.html`.
|
||||
- This fixes the production error: `Not allowed to load local resource: .../renderer/index.html`.
|
||||
|
||||
- Added production diagnostics for startup failures.
|
||||
- `HERMES_OPEN_DEVTOOLS=1` opens DevTools on launch.
|
||||
- Release builds expose `Help -> Toggle Developer Tools`.
|
||||
|
||||
- Aligned packaged renderer CSP with the main-process CSP.
|
||||
- Allows file-backed startup assets for images, media, fonts, and frames.
|
||||
- Keeps object loading blocked and base URI constrained.
|
||||
|
||||
- Tuned GPU fallback behavior.
|
||||
- macOS now ignores and clears stale `disable-gpu.flag` by default to avoid permanently forcing the Office tab onto SwiftShader/software rendering.
|
||||
- Windows/Linux still honor the persistent GPU fallback by default for GPU crash-loop protection.
|
||||
- `HERMES_GPU_FALLBACK=1` can force the persistent fallback on any platform.
|
||||
- `HERMES_DISABLE_GPU=0` still force-enables hardware acceleration and clears the stale flag.
|
||||
|
||||
## Verification
|
||||
|
||||
- `npm run test -- tests/electron-security.test.ts tests/dashboard-csp.test.ts tests/gpu-fallback.test.ts`
|
||||
- `npm run test -- tests/gateway-restart.test.ts`
|
||||
- `npm run build`
|
||||
- `lat check`
|
||||
|
||||
## Notes
|
||||
|
||||
For immediate local recovery from the Office lag on macOS:
|
||||
|
||||
```bash
|
||||
rm "$HOME/Library/Application Support/hermes-desktop/disable-gpu.flag"
|
||||
```
|
||||
|
||||
Then relaunch Hermes One.
|
||||
|
After Width: | Height: | Size: 251 KiB |
|
After Width: | Height: | Size: 326 KiB |
|
After Width: | Height: | Size: 48 KiB |
|
After Width: | Height: | Size: 308 KiB |
|
After Width: | Height: | Size: 91 KiB |
|
After Width: | Height: | Size: 214 KiB |
|
After Width: | Height: | Size: 213 KiB |
|
After Width: | Height: | Size: 298 KiB |
|
After Width: | Height: | Size: 796 KiB |
|
After Width: | Height: | Size: 355 KiB |
|
After Width: | Height: | Size: 401 KiB |