diff --git a/.gitignore b/.gitignore index e4240ea36e7..c48fcb3515a 100644 --- a/.gitignore +++ b/.gitignore @@ -119,6 +119,9 @@ docs/superpowers/* # treat it as a local edit and autostash it on every run (#38529). .hermes-bootstrap-complete +# Persistent dev sandbox dir (scripts/dev-sandbox.sh --persistent) +.hermes-sandbox/ + # Interrupted-update breadcrumb + recovery lock written next to the shared venv # by `hermes update` / launch-time self-heal. Runtime state, never a code change # — ignore so `git status` stays clean and update's autostash skips them. diff --git a/agent/agent_init.py b/agent/agent_init.py index f1666a1aafb..0f9376d6c79 100644 --- a/agent/agent_init.py +++ b/agent/agent_init.py @@ -302,6 +302,7 @@ def init_agent( notice_callback: callable = None, notice_clear_callback: callable = None, event_callback: Optional[Callable[[str, dict], None]] = None, + reaction_callback: Optional[Callable[[str], None]] = None, max_tokens: int = None, reasoning_config: Dict[str, Any] = None, service_tier: str = None, @@ -535,6 +536,7 @@ def init_agent( agent.notice_callback = notice_callback agent.notice_clear_callback = notice_clear_callback agent.event_callback = event_callback + agent.reaction_callback = reaction_callback agent.tool_gen_callback = tool_gen_callback diff --git a/agent/reactions.py b/agent/reactions.py new file mode 100644 index 00000000000..375366ff709 --- /dev/null +++ b/agent/reactions.py @@ -0,0 +1,56 @@ +"""Token-free detection of user *reactions* to the agent. + +Currently the only reaction is ``vibe`` — an expression of affection or +gratitude toward the agent (``ily``, ``<3``, ``love you``, ``good bot``, a heart +emoji, …). Detection is a curated regex/lexicon: **no model call, no tokens**. + +This is the single source of truth shared by every surface — the CLI pet, the +TUI heart, and the desktop floating hearts all react off the same signal, +delivered via ``AIAgent.reaction_callback`` (wired per interactive host). + +Generalized on purpose: :func:`detect_reaction` returns a reaction *kind* +string, so new kinds (other emoji reactions, etc.) can be added here without +touching any caller. We match affection specifically — not general positive +sentiment — so "this is great" does NOT fire, but "good bot" / "❤️" do. +""" + +from __future__ import annotations + +import re + +#: The affection/gratitude reaction — the only kind today. +VIBE = "vibe" + +# Curated affection lexicon. Kept deliberately narrow: gratitude + love aimed at +# the agent, heart emoji, and ``<3`` (but not the broken heart `` str | None: + """Return the reaction kind for *text* (currently :data:`VIBE`), or ``None``. + + Pure, token-free, and safe to call on every user turn. + """ + if not text: + return None + + return VIBE if _VIBE_RE.search(text) else None diff --git a/agent/turn_context.py b/agent/turn_context.py index 4fd6ddff2c3..a5e738588e4 100644 --- a/agent/turn_context.py +++ b/agent/turn_context.py @@ -319,6 +319,20 @@ def build_turn_context( current_turn_user_idx = len(messages) - 1 agent._persist_user_message_idx = current_turn_user_idx + # Cosmetic side-signal: detect an affection "reaction" (ily / <3 / good bot) + # and notify the host so it can play hearts. Token-free, never touches the + # conversation, and never fatal — a purely optional UI beat. + reaction_callback = getattr(agent, "reaction_callback", None) + if reaction_callback is not None: + try: + from agent.reactions import detect_reaction + + kind = detect_reaction(original_user_message) + if kind: + reaction_callback(kind) + except Exception: + pass + if not agent.quiet_mode: _print_preview = summarize_user_message_for_log(user_message) agent._safe_print( diff --git a/apps/desktop/README.md b/apps/desktop/README.md index d342f0255f6..276704523f8 100644 --- a/apps/desktop/README.md +++ b/apps/desktop/README.md @@ -67,6 +67,8 @@ npm run dev # Vite renderer + Electron, which boots the Python backend Point the app at a specific source checkout, or sandbox it away from your real config: ```bash +# throwaway HERMES_HOME, separate Electron userData, distinct app name to avoid the single-instance lock +../scripts/dev-sandbox.sh npm run dev HERMES_DESKTOP_HERMES_ROOT=/path/to/clone npm run dev HERMES_HOME=/tmp/throwaway npm run dev npm run dev:fake-boot # exercise the startup overlay with deterministic delays diff --git a/apps/desktop/electron/main.ts b/apps/desktop/electron/main.ts index ba50d6d7cc1..a19201147ce 100644 --- a/apps/desktop/electron/main.ts +++ b/apps/desktop/electron/main.ts @@ -424,7 +424,7 @@ const BOOT_FAKE_STEP_MS = (() => { return Math.max(120, raw) })() -const APP_NAME = 'Hermes' +const APP_NAME = process.env.HERMES_DESKTOP_APP_NAME || 'Hermes' const TITLEBAR_HEIGHT = 34 const MACOS_TRAFFIC_LIGHTS_HEIGHT = 14 diff --git a/apps/desktop/electron/update-relaunch.test.ts b/apps/desktop/electron/update-relaunch.test.ts index d42582552d0..13f2d1df0b6 100644 --- a/apps/desktop/electron/update-relaunch.test.ts +++ b/apps/desktop/electron/update-relaunch.test.ts @@ -162,6 +162,7 @@ test('collectRelaunchEnv preserves HERMES_HOME + HERMES_DESKTOP_* + sandbox opt- HERMES_DESKTOP_REMOTE_URL: 'http://box:9119', HERMES_DESKTOP_REMOTE_TOKEN: 'secret', HERMES_DESKTOP_HERMES_ROOT: '/home/u/dev/hermes', + HERMES_DESKTOP_APP_NAME: 'HermesSandbox', ELECTRON_DISABLE_SANDBOX: '1', // sandbox opt-out — preserved PATH: '/usr/bin', // not preserved HOME: '/home/u', // not preserved @@ -173,6 +174,7 @@ test('collectRelaunchEnv preserves HERMES_HOME + HERMES_DESKTOP_* + sandbox opt- HERMES_DESKTOP_REMOTE_URL: 'http://box:9119', HERMES_DESKTOP_REMOTE_TOKEN: 'secret', HERMES_DESKTOP_HERMES_ROOT: '/home/u/dev/hermes', + HERMES_DESKTOP_APP_NAME: 'HermesSandbox', ELECTRON_DISABLE_SANDBOX: '1' }) assert.deepEqual(collectRelaunchEnv(null), {}) diff --git a/apps/desktop/package.json b/apps/desktop/package.json index d7c099c140a..05e193790bf 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -18,6 +18,7 @@ "profile:main": "wait-on http://127.0.0.1:5174 && node scripts/bundle-electron-main.mjs --dev && cross-env XCURSOR_SIZE=24 HERMES_DESKTOP_DEV_SERVER=http://127.0.0.1:5174 electron --inspect=9229 .", "profile:main:cpu": "wait-on http://127.0.0.1:5174 && node scripts/bundle-electron-main.mjs --dev && cross-env XCURSOR_SIZE=24 NODE_OPTIONS=--cpu-prof HERMES_DESKTOP_DEV_SERVER=http://127.0.0.1:5174 electron .", "start": "npm run build && electron .", + "prebuild": "tsc -b . --clean", "build": "node scripts/assert-root-install.mjs && node scripts/write-build-stamp.mjs && vite build && node scripts/bundle-electron-main.mjs && node scripts/stage-native-deps.mjs", "postbuild": "node scripts/assert-dist-built.mjs", "prebuilder": "node scripts/patch-electron-builder-mac-binary.mjs", diff --git a/apps/desktop/src/app/chat/index.tsx b/apps/desktop/src/app/chat/index.tsx index bece9b49152..fb8175102b0 100644 --- a/apps/desktop/src/app/chat/index.tsx +++ b/apps/desktop/src/app/chat/index.tsx @@ -12,6 +12,7 @@ import { useLocation } from 'react-router-dom' import { Thread } from '@/components/assistant-ui/thread' import { Backdrop } from '@/components/Backdrop' +import { COMPOSER_HEART_CONFIG, HeartField } from '@/components/chat/vibe-hearts' import { PromptOverlays } from '@/components/prompt-overlays' import { Button } from '@/components/ui/button' import { Codicon } from '@/components/ui/codicon' @@ -30,6 +31,8 @@ import { useIncrementalExternalStoreRuntime } from '@/lib/incremental-external-s import { cn } from '@/lib/utils' import type { ComposerAttachment } from '@/store/composer' import { $pinnedSessionIds } from '@/store/layout' +import { $petActive } from '@/store/pet' +import { $petOverlayActive } from '@/store/pet-overlay' import { $gatewaySwapTarget } from '@/store/profile' import { $activeSessionId, @@ -297,6 +300,10 @@ export function ChatView({ const currentCwd = useStore($currentCwd) const currentModel = useStore($currentModel) const currentProvider = useStore($currentProvider) + // A pet anywhere (in-window or popped out) owns the hearts; composer only when none. + const petActive = useStore($petActive) + const petOverlayActive = useStore($petOverlayActive) + const petPresent = petActive || petOverlayActive const freshDraftReady = useStore($freshDraftReady) const gatewayState = useStore($gatewayState) const gatewaySwapTarget = useStore($gatewaySwapTarget) @@ -491,6 +498,18 @@ export function ChatView({ )} {showChatBar && } + {/* Vibe hearts rise from the composer only when no pet is out (else + they play on the pet). Fired by the core `reaction` event. */} + {!petPresent && ( + + )} diff --git a/apps/desktop/src/app/pet-overlay/pet-overlay-app.tsx b/apps/desktop/src/app/pet-overlay/pet-overlay-app.tsx index ae3c1860b7c..d635186e34e 100644 --- a/apps/desktop/src/app/pet-overlay/pet-overlay-app.tsx +++ b/apps/desktop/src/app/pet-overlay/pet-overlay-app.tsx @@ -1,6 +1,7 @@ import { useStore } from '@nanostores/react' import { useCallback, useEffect, useRef, useState } from 'react' +import { PetHeartField, playVibeHearts } from '@/components/chat/vibe-hearts' import { PetBubble } from '@/components/pet/pet-bubble' import { PetSprite } from '@/components/pet/pet-sprite' import { type PetZoomAnchor, usePetZoomGesture } from '@/components/pet/use-pet-zoom-gesture' @@ -72,6 +73,8 @@ export function PetOverlayApp() { const zoomAnchorRef = useRef(null) const petRef = useRef(null) const inputRef = useRef(null) + // Last mirrored reaction id — a bump means the main window fired a reaction. + const lastReactionRef = useRef(null) const ignoreRef = useRef(true) const composerOpenRef = useRef(false) const clickTimerRef = useRef | undefined>(undefined) @@ -91,6 +94,19 @@ export function PetOverlayApp() { setBusy(Boolean(payload.busy)) setAwaitingResponse(Boolean(payload.awaiting)) setUnread(Boolean(payload.unread)) + + // Play a reaction on a new id (ignore the first sync, which just primes it). + const reaction = payload.reaction ?? null + + if (lastReactionRef.current === null) { + lastReactionRef.current = reaction?.id ?? 0 + } else if (reaction && reaction.id > lastReactionRef.current) { + lastReactionRef.current = reaction.id + + if (reaction.kind === 'vibe') { + playVibeHearts() + } + } }) // Tell the main renderer we're mounted so it pushes the current frame (the @@ -416,6 +432,12 @@ export function PetOverlayApp() {
+ {/* Hearts on the popped-out pet — identical to in-window. */} + + {/* Mail icon: only when a finish landed while you were away. Jumps to the app's most recent thread. Anchored to the sprite (kept inside its box so the overlay's click-through hit-test still catches it); diff --git a/apps/desktop/src/app/session/hooks/use-message-stream/gateway-event.ts b/apps/desktop/src/app/session/hooks/use-message-stream/gateway-event.ts index f057bca4f24..664e4b9d53e 100644 --- a/apps/desktop/src/app/session/hooks/use-message-stream/gateway-event.ts +++ b/apps/desktop/src/app/session/hooks/use-message-stream/gateway-event.ts @@ -4,6 +4,7 @@ import { type MutableRefObject, useCallback } from 'react' import { writeAgentTerminalChunk } from '@/app/right-sidebar/terminal/agent-terminal-stream' import { readActiveTerminal } from '@/app/right-sidebar/terminal/buffer' import { closeAgentTerminalByProc } from '@/app/right-sidebar/terminal/terminals' +import { burstVibeHearts } from '@/components/chat/vibe-hearts' import { translateNow } from '@/i18n' import { type GatewayEventPayload, textPart } from '@/lib/chat-messages' import { coerceGatewayText, coerceThinkingText, normalizePersonalityValue } from '@/lib/chat-runtime' @@ -264,6 +265,12 @@ export function useGatewayEventHandler(deps: GatewayEventDeps) { // KawaiiSpinner), not real reasoning. The bottom-of-thread loading // indicator already covers that UX, so we ignore these events to // avoid a duplicative "Thinking" disclosure showing spinner text. + } else if (event.type === 'reaction') { + // Core-detected affection (ily / <3 / good bot) on the user's message. + // Play hearts only for the visible session so background turns stay quiet. + if (isActiveEvent && (payload?.kind ?? 'vibe') === 'vibe') { + burstVibeHearts() + } } else if (event.type === 'reasoning.delta') { if (sessionId) { appendReasoningDelta(sessionId, coerceThinkingText(payload?.text)) diff --git a/apps/desktop/src/components/chat/vibe-hearts.tsx b/apps/desktop/src/components/chat/vibe-hearts.tsx new file mode 100644 index 00000000000..b4f5e7e1a24 --- /dev/null +++ b/apps/desktop/src/components/chat/vibe-hearts.tsx @@ -0,0 +1,119 @@ +import { type CSSProperties } from 'react' + +import { + createParticleEmitter, + ParticleField, + type ParticleFieldConfig +} from '@/components/particles/particle-field' +import { $petActive, flashPetActivity } from '@/store/pet' +import { $petOverlayActive, forwardPetReaction } from '@/store/pet-overlay' + +/** + * TikTok-style floating hearts — a thin skin over {@link ParticleField} (pixel + * heart glyph + pink). Placed two ways: rising from the composer when no pet is + * out, or from the pet when one is. Fired by the core `reaction` event (affection + * in a user message) via {@link burstVibeHearts}. + */ + +// Light pink reads on both light and dark chat surfaces. +const HEART_COLORS = ['#ff9ec4'] as const + +/** Composer placement: hearts rise the thread height (rise = % of the tall lane). */ +export const COMPOSER_HEART_CONFIG: Partial = { + count: 12, + size: [6, 13], + rise: [6.75, 15.75], + duration: [320, 700] +} + +/** Pet placement: a compact puff off the pet. The field box spans feet→head, so + * rise ≥100% carries hearts from the feet to ~10-20% above the pet before fading. */ +const PET_HEART_CONFIG: Partial = { + count: 10, + spawnWindowMs: 450, + size: [6, 12], + rise: [98, 118], + duration: [480, 880], + swayAmp: [5, 14], + bank: [6, 14] +} + +// Pixel-art heart from @nous-research/ui (14×12), crisp + `currentColor`. +const HEART_GLYPH = ( + + + +) + +const emitter = createParticleEmitter() + +/** Play hearts in THIS window (whichever HeartField is mounted). The overlay + * window calls this directly off the mirrored vibe signal. */ +export const playVibeHearts = (count?: number) => emitter.burst(count) + +/** + * Fire a vibe burst (from the core `reaction` event). Routes to where the + * affection should land: + * - pet popped out → forward to the overlay window + celebrate (mirrored) + * - pet in-window → play here (on the pet) + celebrate + * - no pet → play here (composer) + */ +export const burstVibeHearts = (count?: number) => { + const overlay = $petOverlayActive.get() + + if (overlay || $petActive.get()) { + flashPetActivity({ celebrate: true }) + } + + if (overlay) { + forwardPetReaction('vibe') + } else { + playVibeHearts(count) + } +} + +export interface HeartFieldProps { + config?: Partial + className?: string + style?: CSSProperties +} + +/** Heart-skinned particle field. Caller supplies placement + a config preset. */ +export function HeartField({ config, className, style }: HeartFieldProps) { + return ( + + ) +} + +/** + * Pet-anchored hearts, feet→~10-20% above. One place owns the geometry so the + * in-window pet and the popped-out overlay stay identical. `petW`/`petH` are the + * rendered sprite dimensions (frame × scale). + */ +export function PetHeartField({ petW, petH }: { petW: number; petH: number }) { + return ( + + ) +} diff --git a/apps/desktop/src/components/particles/particle-field.css b/apps/desktop/src/components/particles/particle-field.css new file mode 100644 index 00000000000..94bf2258aae --- /dev/null +++ b/apps/desktop/src/components/particles/particle-field.css @@ -0,0 +1,126 @@ +/* ── Particle field ───────────────────────────────────────────────────────── + Reusable float-up emitter. Three nested layers, each owning ONE transform so + they never fight: + .particle — full-height track: vertical rise + fade (linear) + .particle__sway — horizontal weave + bank/tilt on its OWN period + (ease-in-out, alternating), so the path desyncs from + the rise and never repeats — the organic-motion trick + .particle__glyph — one-shot springy pop-in scale + The track is full-height and anchored at the field bottom, so the rise's + `translateY(-rise%)` measures against the field height, not the glyph's. All + tuning arrives as inline `--particle-*` custom properties. */ +.particle-field { + pointer-events: none; + overflow: visible; +} + +.particle { + position: absolute; + inset-block: 0; + left: var(--particle-left); + display: flex; + align-items: flex-end; + justify-content: center; + width: var(--particle-size); + margin-left: calc(var(--particle-size) / -2); + will-change: transform, opacity; + animation: particle-rise var(--particle-duration) linear var(--particle-delay) both; +} + +.particle__sway { + display: block; + will-change: transform; + animation: particle-sway var(--particle-sway-duration) ease-in-out var(--particle-sway-delay) + infinite alternate; +} + +.particle__glyph { + display: block; + width: var(--particle-size); + color: var(--particle-color); + will-change: transform; + animation: particle-pop 260ms cubic-bezier(0.34, 1.56, 0.64, 1) var(--particle-delay) both; +} + +.particle__glyph > svg { + display: block; + width: 100%; + height: auto; +} + +/* Rise: straight up the field, holds opacity through most of the climb, fades + near the top. */ +@keyframes particle-rise { + 0% { + opacity: 0; + transform: translate3d(0, 0, 0); + } + + 6% { + opacity: 1; + transform: translate3d(0, calc(var(--particle-rise) * -0.06%), 0); + } + + 70% { + opacity: 1; + transform: translate3d(0, calc(var(--particle-rise) * -0.7%), 0); + } + + 100% { + opacity: 0; + transform: translate3d(0, calc(var(--particle-rise) * -1%), 0); + } +} + +/* Sway + bank: swings left↔right and tilts INTO the direction of travel, like a + leaf on a breeze. Runs on its own clock. */ +@keyframes particle-sway { + from { + transform: translateX(calc(var(--particle-sway) * -1)) rotate(calc(var(--particle-bank) * -1)); + } + + to { + transform: translateX(var(--particle-sway)) rotate(var(--particle-bank)); + } +} + +/* Pop: a springy scale-in overshoot (the bezier is a spring, not a path wobble). */ +@keyframes particle-pop { + 0% { + transform: scale(0.3); + } + + 100% { + transform: scale(1); + } +} + +@media (prefers-reduced-motion: reduce) { + .particle { + inset-block: auto 0; + height: var(--particle-size); + animation: particle-flash 650ms ease-out var(--particle-delay) both; + } + + .particle__sway, + .particle__glyph { + animation: none; + } + + @keyframes particle-flash { + 0% { + opacity: 0; + transform: translate3d(0, 0, 0) scale(0.6); + } + + 20% { + opacity: 1; + transform: translate3d(0, -0.5rem, 0) scale(1.1); + } + + 100% { + opacity: 0; + transform: translate3d(0, -1rem, 0) scale(1); + } + } +} diff --git a/apps/desktop/src/components/particles/particle-field.tsx b/apps/desktop/src/components/particles/particle-field.tsx new file mode 100644 index 00000000000..a7e70c933c6 --- /dev/null +++ b/apps/desktop/src/components/particles/particle-field.tsx @@ -0,0 +1,204 @@ +import './particle-field.css' + +import { type CSSProperties, type ReactNode, useEffect, useMemo, useRef, useState } from 'react' + +import { cn } from '@/lib/utils' + +/** + * Reusable float-up particle emitter. It owns the motion (rise + organic sway + + * springy pop) and lifecycle (staggered burst, lifetime, cleanup); callers just + * hand it a `glyph` (any element using `currentColor`) and `colors`, then place + * it with `className` / `style`. See {@link VibeHearts} for the chat-hearts use. + */ + +type Range = readonly [min: number, max: number] + +const rand = ([min, max]: Range) => min + Math.random() * (max - min) +/** Sample a range along `t` (0→min, 1→max) — couples travel/lifetime to `life`. */ +const lerp = ([min, max]: Range, t: number) => min + (max - min) * t + +export interface ParticleFieldConfig { + /** Particles per burst when `burst()` is called without a count. */ + count: number + /** Window (ms) over which a burst releases its particles — small = one poof. */ + spawnWindowMs: number + /** Glyph edge size (px), uniform. */ + size: Range + /** Vertical travel before fade-out (% of field height), `life`-biased. */ + rise: Range + /** Rise duration (ms), `life`-biased so short-lived particles also rise less. */ + duration: Range + /** Sway amplitude each side of center (px), uniform. */ + swayAmp: Range + /** Peak tilt into the sway (deg), uniform. */ + bank: Range + /** Sway period (ms), uniform — independent of the rise so paths never repeat. */ + swayDuration: Range + /** Cap on simultaneously-alive particles. */ + maxAlive: number +} + +export const DEFAULT_PARTICLE_CONFIG: ParticleFieldConfig = { + count: 12, + spawnWindowMs: 550, + size: [6, 13], + rise: [6.75, 15.75], + duration: [320, 700], + swayAmp: [9, 24], + bank: [7, 16], + swayDuration: [1300, 2800], + maxAlive: 200 +} + +export interface ParticleEmitter { + /** Fire a burst (defaults to the field's configured `count`). */ + burst: (count?: number) => void + /** Internal: field subscription. */ + subscribe: (fn: (count?: number) => void) => () => void +} + +/** Create an emitter handle. `burst()` is safe to call from anywhere. */ +export function createParticleEmitter(): ParticleEmitter { + const listeners = new Set<(count?: number) => void>() + + return { + burst: count => listeners.forEach(fn => fn(count)), + subscribe: fn => { + listeners.add(fn) + + return () => void listeners.delete(fn) + } + } +} + +interface Particle { + id: number + leftPct: number + size: number + color: string + delayMs: number + durationMs: number + rise: number + swayAmp: number + bank: number + swayDurationMs: number + swayDelayMs: number +} + +let nextId = 1 + +function spawn(cfg: ParticleFieldConfig, colors: readonly string[]): Particle { + // Short-lived particles fade out lower; a few live longer and rise higher. + const life = Math.random() ** 1.7 + const swayDurationMs = Math.round(rand(cfg.swayDuration)) + + return { + id: nextId++, + // Spread edge to edge across the lane, not clustered near center. + leftPct: 4 + Math.random() * 92, + size: rand(cfg.size), + color: colors[Math.floor(Math.random() * colors.length)]!, + delayMs: Math.round(Math.random() * 120), + durationMs: Math.round(lerp(cfg.duration, life)), + rise: lerp(cfg.rise, life), + swayAmp: rand(cfg.swayAmp), + bank: rand(cfg.bank), + swayDurationMs, + // Negative delay drops each particle in mid-swing (desynced phases). + swayDelayMs: -Math.round(Math.random() * swayDurationMs) + } +} + +const prefersReducedMotion = () => + typeof window !== 'undefined' && Boolean(window.matchMedia?.('(prefers-reduced-motion: reduce)').matches) + +export interface ParticleFieldProps { + emitter: ParticleEmitter + /** Any element that paints with `currentColor` (SVG, glyph, …). */ + glyph: ReactNode + colors: readonly string[] + config?: Partial + className?: string + style?: CSSProperties +} + +export function ParticleField({ emitter, glyph, colors, config, className, style }: ParticleFieldProps) { + const cfg = useMemo(() => ({ ...DEFAULT_PARTICLE_CONFIG, ...config }), [config]) + const [particles, setParticles] = useState([]) + const timers = useRef>>(new Set()) + + useEffect(() => { + const pool = timers.current + const add = () => setParticles(prev => [...prev, spawn(cfg, colors)].slice(-cfg.maxAlive)) + + // Release a burst across a tight window so it reads as one poof, each node + // with its own random birth time; reduced motion gets a single flash. + const onBurst = (count?: number) => { + const n = Math.max(1, Math.min(cfg.maxAlive, Math.round(count ?? cfg.count))) + + if (prefersReducedMotion()) { + add() + + return + } + + for (let i = 0; i < n; i++) { + const timer = setTimeout(() => { + pool.delete(timer) + add() + }, Math.random() * cfg.spawnWindowMs) + + pool.add(timer) + } + } + + const unsubscribe = emitter.subscribe(onBurst) + + return () => { + unsubscribe() + pool.forEach(clearTimeout) + pool.clear() + } + }, [cfg, colors, emitter]) + + const remove = (id: number) => setParticles(prev => prev.filter(p => p.id !== id)) + + if (particles.length === 0) { + return null + } + + return ( +
+ {particles.map(p => ( + { + if (e.animationName === 'particle-rise' || e.animationName === 'particle-flash') { + remove(p.id) + } + }} + style={ + { + '--particle-left': `${p.leftPct}%`, + '--particle-size': `${p.size}px`, + '--particle-color': p.color, + '--particle-delay': `${p.delayMs}ms`, + '--particle-duration': `${p.durationMs}ms`, + '--particle-rise': p.rise, + '--particle-sway': `${p.swayAmp}px`, + '--particle-bank': `${p.bank}deg`, + '--particle-sway-duration': `${p.swayDurationMs}ms`, + '--particle-sway-delay': `${p.swayDelayMs}ms` + } as CSSProperties + } + > + + {glyph} + + + ))} +
+ ) +} diff --git a/apps/desktop/src/components/pet/floating-pet.tsx b/apps/desktop/src/components/pet/floating-pet.tsx index 280aa5d4f4b..399ed1bf485 100644 --- a/apps/desktop/src/components/pet/floating-pet.tsx +++ b/apps/desktop/src/components/pet/floating-pet.tsx @@ -4,6 +4,7 @@ import { useCallback, useEffect, useRef, useState } from 'react' import { useGatewayRequest } from '@/app/gateway/hooks/use-gateway-request' import { useOnProfileSwitch } from '@/app/hooks/use-on-profile-switch' import { useRouteOverlayActive } from '@/app/hooks/use-route-overlay-active' +import { PetHeartField } from '@/components/chat/vibe-hearts' import { persistString, storedString } from '@/lib/storage' import { $petAtRest, @@ -447,6 +448,9 @@ export function FloatingPet() { >
+ {/* Hearts puff off the pet; its celebrate ("yay"/jump) pose is driven by + burstVibeHearts's router. */} + ) } diff --git a/apps/desktop/src/store/pet-overlay.ts b/apps/desktop/src/store/pet-overlay.ts index cffa6b822b5..62fb3f4ba37 100644 --- a/apps/desktop/src/store/pet-overlay.ts +++ b/apps/desktop/src/store/pet-overlay.ts @@ -46,6 +46,8 @@ export interface PetOverlayStatePayload { awaiting: boolean /** Drives the overlay's mail icon: a finish landed while you were away. */ unread: boolean + /** Latest reaction — bumping its id forwards a burst to the overlay. */ + reaction: PetReaction | null } export type PetOverlayControl = @@ -67,6 +69,22 @@ export const $petOverlayActive = atom(storedBoolean(OVERLAY_ACTIVE_KEY, false)) // Persist the in/out choice so a popped-out pet comes back popped out. $petOverlayActive.subscribe(active => persistBoolean(OVERLAY_ACTIVE_KEY, active)) +/** + * Reaction signal forwarded to the popped-out overlay window via the state + * mirror below. `id` is a monotonic nonce so the overlay fires once per bump; + * `kind` selects the renderer (today only `vibe` → hearts). Generic on purpose + * so future reactions (emoji, etc.) ride the same channel. + */ +export interface PetReaction { + id: number + kind: string +} + +export const $petReaction = atom(null) + +export const forwardPetReaction = (kind: string) => + $petReaction.set({ id: ($petReaction.get()?.id ?? 0) + 1, kind }) + function loadSavedBounds(): null | PetOverlayBounds { try { const raw = storedString(OVERLAY_BOUNDS_KEY) @@ -129,7 +147,8 @@ function currentPayload(): PetOverlayStatePayload { activity: $petActivity.get(), busy: $busy.get(), awaiting: $awaitingResponse.get(), - unread: $petUnread.get() + unread: $petUnread.get(), + reaction: $petReaction.get() } } @@ -165,7 +184,8 @@ function openOverlay(request: PetOverlayOpenRequest): void { $petActivity.subscribe(pushNow), $busy.subscribe(pushNow), $awaitingResponse.subscribe(pushNow), - $petUnread.subscribe(pushNow) + $petUnread.subscribe(pushNow), + $petReaction.subscribe(pushNow) ] } diff --git a/apps/desktop/src/store/pet.ts b/apps/desktop/src/store/pet.ts index 1b189ac8291..b1e2e9d214e 100644 --- a/apps/desktop/src/store/pet.ts +++ b/apps/desktop/src/store/pet.ts @@ -92,6 +92,9 @@ export function derivePetState(activity: PetActivity): PetState { export const $petInfo = atom({ enabled: false }) export const $petActivity = atom({}) +/** Pet installed + enabled with a loaded spritesheet (ready to show/react). */ +export const $petActive = computed($petInfo, info => info.enabled && Boolean(info.spritesheetBase64)) + /** * Profile the pet RPCs should resolve against. Pets are per-profile — the active * pet (`display.pet.*`) and the installed sprites live under each profile's diff --git a/cli.py b/cli.py index 379ca15df1a..560b5d9a2e3 100644 --- a/cli.py +++ b/cli.py @@ -4862,6 +4862,12 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin): self._pet_event = state self._pet_event_until = time.monotonic() + secs + def _on_reaction(self, kind: str) -> None: + """User affection (ily / <3 / good bot), core-detected — the pet's share + of the vibe signal that plays hearts on the TUI/desktop. Flash a celebrate.""" + if kind == "vibe": + self._pet_flash("jump") + def _pet_react_turn_end(self) -> None: """Flash the end-of-turn beat: failed on error, jump on a finished plan, else wave.""" if not self._pet_enabled: diff --git a/hermes_cli/cli_agent_setup_mixin.py b/hermes_cli/cli_agent_setup_mixin.py index a71d8835698..d3c967405ad 100644 --- a/hermes_cli/cli_agent_setup_mixin.py +++ b/hermes_cli/cli_agent_setup_mixin.py @@ -390,6 +390,7 @@ class CLIAgentSetupMixin: tool_gen_callback=self._on_tool_gen_start if self.streaming_enabled else None, notice_callback=self._on_notice, notice_clear_callback=self._on_notice_clear, + reaction_callback=self._on_reaction, ) # Store reference for atexit memory provider shutdown. # NOTE: this MUST write to the ``cli`` module's global, not a diff --git a/nix/devShell.nix b/nix/devShell.nix index b7dd91fae11..e93e10ef056 100644 --- a/nix/devShell.nix +++ b/nix/devShell.nix @@ -32,6 +32,10 @@ mkdir -p $out/bin install -Dm755 ${../hermes} $out/bin/hermes '') + (pkgs.runCommand "dev-sandbox" { } '' + mkdir -p $out/bin + install -Dm755 ${../scripts/dev-sandbox.sh} $out/bin/sandbox + '') uv ] ++ self'.packages.default.passthru.devDeps; @@ -42,7 +46,7 @@ # for the devshell to pick up the src export HERMES_PYTHON_SRC_ROOT=$(git rev-parse --show-toplevel) echo "Hermes Agent dev shell in $HERMES_PYTHON_SRC_ROOT" - echo "Ready. Run 'hermes' to start." + echo "Ready. Run 'hermes' or 'sandbox hermes' to start." ''; }; }; diff --git a/run_agent.py b/run_agent.py index 2209a1cb249..51448bb9cc4 100644 --- a/run_agent.py +++ b/run_agent.py @@ -458,6 +458,7 @@ class AIAgent: notice_callback: callable = None, notice_clear_callback: callable = None, event_callback: Optional[Callable[[str, dict], None]] = None, + reaction_callback: Optional[Callable[[str], None]] = None, max_tokens: int = None, reasoning_config: Dict[str, Any] = None, service_tier: str = None, @@ -533,6 +534,7 @@ class AIAgent: notice_callback=notice_callback, notice_clear_callback=notice_clear_callback, event_callback=event_callback, + reaction_callback=reaction_callback, max_tokens=max_tokens, reasoning_config=reasoning_config, service_tier=service_tier, diff --git a/scripts/dev-sandbox.sh b/scripts/dev-sandbox.sh new file mode 100755 index 00000000000..afb942539f7 --- /dev/null +++ b/scripts/dev-sandbox.sh @@ -0,0 +1,152 @@ +#!/usr/bin/env bash +# Run a Hermes instance in an isolated sandbox — separate HERMES_HOME, +# separate Electron userData, and a distinct Desktop app name so it doesn't compete +# with your main desktop instance's single-instance lock. +# +# By default the sandbox is throwaway: a temp dir is created and removed on +# exit. Use --persistent to keep the sandbox across restarts (stored under +# .hermes-sandbox/ in the worktree git root). +# +# Usage: +# scripts/dev-sandbox.sh python -m hermes_cli.main +# scripts/dev-sandbox.sh hermes desktop +# scripts/dev-sandbox.sh electron . +# scripts/dev-sandbox.sh -- npm run dev # from apps/desktop/ +# scripts/dev-sandbox.sh --persistent hermes desktop +# scripts/dev-sandbox.sh --persistent -- npm run dev +# +# Override the app name (default: HermesSandbox): +# HERMES_DEV_SANDBOX_NAME=Staging scripts/dev-sandbox.sh hermes desktop +# +# Override the persistent sandbox dir name (default: .hermes-sandbox): +# HERMES_DEV_SANDBOX_DIR=.staging-sandbox scripts/dev-sandbox.sh --persistent hermes desktop + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" + +print_help() { + cat <<'EOF' +Usage: dev-sandbox.sh [--persistent] [--] + +Run a Hermes instance in an isolated sandbox. + +Options: + --persistent Keep the sandbox dir across restarts (under the worktree + git root, in .hermes-sandbox/). Without this flag the + sandbox is a temp dir that is removed on exit. + --delete Delete the existing persistent sandbox in .hermes-sandbox. + -h, --help Show this help message. + +Environment: + HERMES_DEV_SANDBOX_NAME Override the app name (default: HermesSandbox) + HERMES_DEV_SANDBOX_DIR Override the persistent dir name (default: .hermes-sandbox) + +Examples: + dev-sandbox.sh hermes desktop + dev-sandbox.sh --persistent hermes desktop + dev-sandbox.sh -- npm run dev +EOF +} + +PERSISTENT=false +DELETE=false + +while [ "$#" -gt 0 ]; do + case "$1" in + --persistent) + PERSISTENT=true + shift + ;; + --delete) + DELETE=true + shift + ;; + -h|--help) + print_help + exit 0 + ;; + --) + shift + break + ;; + *) + break + ;; + esac +done + +if [ "$#" -eq 0 ]; then + print_help >&2 + exit 1 +fi + + +SANDBOX_DIR_NAME="${HERMES_DEV_SANDBOX_DIR:-.hermes-sandbox}" +GIT_ROOT="$(git rev-parse --show-toplevel 2>/dev/null || echo "$SCRIPT_DIR/..")" +GIT_ROOT="$(cd "$GIT_ROOT" && pwd)" +PERSISTENT_SANDBOX_ROOT="$GIT_ROOT/$SANDBOX_DIR_NAME" + +if [ "$DELETE" = true ]; then + if [ -d "$PERSISTENT_SANDBOX_ROOT" ]; then + read -r -p "[sandbox] delete $PERSISTENT_SANDBOX_ROOT? [y/N] " REPLY + case "$REPLY" in + [yY]|[yY][eE][sS]) + echo "[sandbox] deleting $PERSISTENT_SANDBOX_ROOT" >&2 + rm -rf -- "$PERSISTENT_SANDBOX_ROOT" + ;; + *) + echo "[sandbox] aborted" >&2 + exit 1 + ;; + esac + else + echo "[sandbox] nothing to delete at $PERSISTENT_SANDBOX_ROOT" >&2 + fi + exit 0 +fi + +# Derive a per-worktree app name so multiple checkouts don't collide. +# Each worktree has its own toplevel path even though they share one repo, +# so we hash that path into a short, stable suffix. +WORKTREE_ROOT="$(git rev-parse --show-toplevel 2>/dev/null || echo "$SCRIPT_DIR/..")" +WORKTREE_ROOT="$(cd "$WORKTREE_ROOT" && pwd)" +WORKTREE_HASH="$(printf '%s' "$WORKTREE_ROOT" | cksum | cut -d' ' -f1)" +WORKTREE_NAME="$(basename "$WORKTREE_ROOT")" +DEFAULT_SANDBOX_NAME="HermesSandbox-${WORKTREE_NAME}-${WORKTREE_HASH}" + +SANDBOX_NAME="${HERMES_DEV_SANDBOX_NAME:-$DEFAULT_SANDBOX_NAME}" + +if [ "$PERSISTENT" = true ]; then + SANDBOX_ROOT="$PERSISTENT_SANDBOX_ROOT" +else + SANDBOX_ROOT="$(mktemp -d -t hermes-sandbox.XXXXXX)" +fi + +export HERMES_HOME="$SANDBOX_ROOT/hermes-home" +export HERMES_DESKTOP_USER_DATA_DIR="$SANDBOX_ROOT/user-data" +export HERMES_DESKTOP_APP_NAME="$SANDBOX_NAME" + +mkdir -p "$HERMES_HOME" "$HERMES_DESKTOP_USER_DATA_DIR" + +echo "[sandbox] HERMES_HOME=$HERMES_HOME" >&2 +echo "[sandbox] userData=$HERMES_DESKTOP_USER_DATA_DIR" >&2 +echo "[sandbox] appName=$HERMES_DESKTOP_APP_NAME" >&2 +if [ "$PERSISTENT" = true ]; then + echo "[sandbox] persistent: $SANDBOX_ROOT" >&2 +else + echo "[sandbox] ephemeral (will be cleaned up on exit)" >&2 +fi + +if [ "$PERSISTENT" = false ]; then + cleanup() { + chmod -R u+w "$SANDBOX_ROOT" + rm -rf -- "$SANDBOX_ROOT" + } + trap cleanup EXIT + trap 'cleanup; exit 130' INT TERM +fi + +"$@" +rc=$? +exit $rc diff --git a/tests/agent/test_reactions.py b/tests/agent/test_reactions.py new file mode 100644 index 00000000000..3b29b1facf5 --- /dev/null +++ b/tests/agent/test_reactions.py @@ -0,0 +1,56 @@ +"""Behavior tests for the token-free reaction detector.""" + +import pytest + +from agent.reactions import VIBE, detect_reaction + + +@pytest.mark.parametrize( + "text", + [ + "good bot", + "Good Bot!", + "ily", + "ilysm", + "i love you", + "love you", + "love u", + "luv ya", + "thanks", + "thank you", + "thx", + "ty", + "tysm", + "you're the best <3", + "here you go <33", + "❤️", + "🥰 amazing", + "sending 💖", + "great job, thank you so much!", + ], +) +def test_affection_fires_vibe(text): + assert detect_reaction(text) == VIBE + + +@pytest.mark.parametrize( + "text", + [ + "", + None, + "run the tests", + "this is great", # positive sentiment, NOT affection — must not fire + "awesome work on the refactor", + "it's broken dict: "tool_gen_callback": lambda name: _tool_progress_enabled(sid) and _emit("tool.generating", sid, {"name": name}), "thinking_callback": lambda text: _emit("thinking.delta", sid, {"text": text}), + # Affection reaction (ily / <3 / good bot) → hearts. Core-detected, so + # the TUI heart and desktop floating hearts share one signal. + "reaction_callback": lambda kind: _emit("reaction", sid, {"kind": kind}), "reasoning_callback": lambda text: _emit( "reasoning.delta", sid, diff --git a/ui-tui/src/__tests__/submissionCore.test.ts b/ui-tui/src/__tests__/submissionCore.test.ts index 83b89a088c8..23b83e87468 100644 --- a/ui-tui/src/__tests__/submissionCore.test.ts +++ b/ui-tui/src/__tests__/submissionCore.test.ts @@ -38,7 +38,6 @@ function makeDeps(gw: GatewayClient, over: Partial = {}): Subm enqueue: vi.fn(), expand: (t: string) => t, gw, - maybeGoodVibes: vi.fn(), setLastUserMsg: vi.fn(), sys: vi.fn(), ...over diff --git a/ui-tui/src/app/createGatewayEventHandler.ts b/ui-tui/src/app/createGatewayEventHandler.ts index d9cbf30663e..5bb819790d4 100644 --- a/ui-tui/src/app/createGatewayEventHandler.ts +++ b/ui-tui/src/app/createGatewayEventHandler.ts @@ -20,7 +20,7 @@ import type { Msg, SubagentProgress, SubagentStatus } from '../types.js' import { applyDelegationStatus, getDelegationState } from './delegationStore.js' import type { GatewayEventHandlerContext } from './interfaces.js' import { getOverlayState, patchOverlayState } from './overlayStore.js' -import { flashPet } from './petFlashStore.js' +import { flashGoodVibes, flashPet } from './petFlashStore.js' import { turnController } from './turnController.js' import { getTurnState } from './turnStore.js' import { getUiState, patchUiState } from './uiStore.js' @@ -717,6 +717,14 @@ export function createGatewayEventHandler(ctx: GatewayEventHandlerContext): (ev: return + case 'reaction': + // Core-detected affection (ily / <3 / good bot): flash the ♥ and let the + // pet celebrate. Same signal drives the desktop's floating hearts. + flashGoodVibes() + flashPet('jump') + + return + case 'tool.start': turnController.recordTodos(ev.payload.todos) turnController.recordToolStart( diff --git a/ui-tui/src/app/petFlashStore.ts b/ui-tui/src/app/petFlashStore.ts index b1ecf97e995..48841a3c4be 100644 --- a/ui-tui/src/app/petFlashStore.ts +++ b/ui-tui/src/app/petFlashStore.ts @@ -14,6 +14,13 @@ export const $petFlash = atom(null) export const flashPet = (state: PetState, ms = 1600) => $petFlash.set({ state, until: Date.now() + ms }) +// Affection-heart beat: a monotonic tick the status-bar ♥ flashes on. Bumped by +// the gateway `reaction` event (core-detected ily / <3 / good bot) — the TUI's +// share of the same signal that plays the desktop's floating hearts. +export const $goodVibesTick = atom(0) + +export const flashGoodVibes = () => $goodVibesTick.set($goodVibesTick.get() + 1) + // The floating pet's footprint, or null when no pet is shown. The transcript // keeps its text clear of the pet responsively: on wide terminals it reserves a // right gutter (`width`) so lines wrap to the pet's LEFT; on narrow terminals it diff --git a/ui-tui/src/app/submissionCore.ts b/ui-tui/src/app/submissionCore.ts index 7c561b745ba..0ec3921bb3f 100644 --- a/ui-tui/src/app/submissionCore.ts +++ b/ui-tui/src/app/submissionCore.ts @@ -15,7 +15,6 @@ export interface SubmitPromptDeps { enqueue: (text: string) => void expand: (text: string) => string gw: GatewayClient - maybeGoodVibes: (text: string) => void setLastUserMsg: (value: string) => void sys: (text: string) => void } @@ -61,7 +60,6 @@ export function submitPrompt(text: string, deps: SubmitPromptDeps, showUserMessa } turnController.clearStatusTimer() - deps.maybeGoodVibes(submitText) deps.setLastUserMsg(text) if (show) { diff --git a/ui-tui/src/app/useMainApp.ts b/ui-tui/src/app/useMainApp.ts index 33ab9b2f3e3..50a94c9d45a 100644 --- a/ui-tui/src/app/useMainApp.ts +++ b/ui-tui/src/app/useMainApp.ts @@ -37,6 +37,7 @@ import { planGatewayRecovery } from './gatewayRecovery.js' import { getInputSelection } from './inputSelectionStore.js' import { type GatewayRpc, type TranscriptRow } from './interfaces.js' import { $overlayState, patchOverlayState } from './overlayStore.js' +import { $goodVibesTick } from './petFlashStore.js' import { scrollWithSelectionBy } from './scroll.js' import { turnController } from './turnController.js' import { patchTurnState, useTurnSelector } from './turnStore.js' @@ -48,7 +49,6 @@ import { useLongRunToolCharms } from './useLongRunToolCharms.js' import { useSessionLifecycle } from './useSessionLifecycle.js' import { useSubmission } from './useSubmission.js' -const GOOD_VIBES_RE = /\b(good bot|thanks|thank you|thx|ty|ily|love you)\b/i const BRACKET_PASTE_ON = '\x1b[?2004h' const BRACKET_PASTE_OFF = '\x1b[?2004l' const MAX_HEIGHT_CACHE_BUCKETS = 12 @@ -185,7 +185,8 @@ export function useMainApp(gw: GatewayClient) { const [sessionStartedAt, setSessionStartedAt] = useState(() => Date.now()) const [turnStartedAt, setTurnStartedAt] = useState(null) const [lastTurnEndedAt, setLastTurnEndedAt] = useState(null) - const [goodVibesTick, setGoodVibesTick] = useState(0) + // Bumped by the gateway `reaction` event (core-detected affection). + const goodVibesTick = useStore($goodVibesTick) const [bellOnComplete, setBellOnComplete] = useState(false) const ui = useStore($uiState) @@ -445,12 +446,6 @@ export function useMainApp(gw: GatewayClient) { [sys] ) - const maybeGoodVibes = useCallback((text: string) => { - if (GOOD_VIBES_RE.test(text)) { - setGoodVibesTick(v => v + 1) - } - }, []) - const rpc: GatewayRpc = useCallback( async = Record>( method: string, @@ -690,7 +685,6 @@ export function useMainApp(gw: GatewayClient) { composerRefs, composerState, gw, - maybeGoodVibes, setLastUserMsg, slashRef, submitRef, diff --git a/ui-tui/src/app/useSubmission.ts b/ui-tui/src/app/useSubmission.ts index 6ece0bf6412..6e02d349a1d 100644 --- a/ui-tui/src/app/useSubmission.ts +++ b/ui-tui/src/app/useSubmission.ts @@ -37,7 +37,6 @@ export function useSubmission(opts: UseSubmissionOptions) { composerRefs, composerState, gw, - maybeGoodVibes, setLastUserMsg, slashRef, submitRef, @@ -87,14 +86,13 @@ export function useSubmission(opts: UseSubmissionOptions) { enqueue: composerActions.enqueue, expand, gw, - maybeGoodVibes, setLastUserMsg, sys }, showUserMessage ) }, - [appendMessage, composerActions, composerState.pasteSnips, gw, maybeGoodVibes, setLastUserMsg, sys] + [appendMessage, composerActions, composerState.pasteSnips, gw, setLastUserMsg, sys] ) const shellExec = useCallback( @@ -362,7 +360,6 @@ export interface UseSubmissionOptions { composerRefs: ComposerRefs composerState: ComposerState gw: GatewayClient - maybeGoodVibes: (text: string) => void setLastUserMsg: (value: string) => void slashRef: MutableRefObject<(cmd: string) => boolean> submitRef: MutableRefObject<(value: string) => void> diff --git a/ui-tui/src/gatewayTypes.ts b/ui-tui/src/gatewayTypes.ts index ee6b8d78c45..6eb0317fc2f 100644 --- a/ui-tui/src/gatewayTypes.ts +++ b/ui-tui/src/gatewayTypes.ts @@ -618,6 +618,7 @@ export type GatewayEvent = | { payload?: GatewaySkin; session_id?: string; type: 'skin.changed' } | { payload: SessionInfo; session_id?: string; type: 'session.info' } | { payload?: { text?: string }; session_id?: string; type: 'thinking.delta' } + | { payload?: { kind?: string }; session_id?: string; type: 'reaction' } | { payload?: undefined; session_id?: string; type: 'message.start' } | { payload?: { kind?: string; text?: string }; session_id?: string; type: 'status.update' } | { diff --git a/website/docs/developer-guide/contributing.md b/website/docs/developer-guide/contributing.md index 52ec92d28ad..5f5d7fd0490 100644 --- a/website/docs/developer-guide/contributing.md +++ b/website/docs/developer-guide/contributing.md @@ -31,12 +31,12 @@ We value contributions in this order: ### Prerequisites -| Requirement | Notes | -|-------------|-------| -| **Git** | With the `git-lfs` extension installed | -| **Python 3.11–3.13** | uv will install it if missing | -| **uv** | Fast Python package manager ([install](https://docs.astral.sh/uv/)) | -| **Node.js 20+** | Optional — needed for browser tools and WhatsApp bridge (matches root `package.json` engines) | +| Requirement | Notes | +| -------------------- | --------------------------------------------------------------------------------------------- | +| **Git** | With the `git-lfs` extension installed | +| **Python 3.11–3.13** | uv will install it if missing | +| **uv** | Fast Python package manager ([install](https://docs.astral.sh/uv/)) | +| **Node.js 20+** | Optional — needed for browser tools and WhatsApp bridge (matches root `package.json` engines) | ### Install with the standard installer @@ -66,6 +66,14 @@ git checkout -b fix/description scripts/run_tests.sh ``` +You can also run a fully isolated Hermes instance (throwaway HERMES_HOME, separate Electron +userData, distinct Electron app name to avoid the single-instance lock): + +```bash +scripts/dev-sandbox.sh python -m hermes_cli.main +scripts/dev-sandbox.sh --persistent python -m hermes_cli.main desktop # state survives restarts, but lives in the worktree :) +``` + ### Manual clone fallback Use this only if you intentionally do not want Hermes' managed install layout @@ -143,7 +151,7 @@ See **[Platform Support](../getting-started/platform-support.md)**. Native Windo When contributing code, keep these rules in mind: -- **Don't add unguarded `signal.SIGKILL` references.** It's not defined on Windows. Either route through `gateway.status.terminate_pid(pid, force=True)` (the centralized primitive that does `taskkill /T /F` on Windows and SIGKILL on POSIX), or fall back with `getattr(signal, "SIGKILL", signal.SIGTERM)`. +- **Don't add unguarded `signal.SIGKILL` references.** It's not defined on Windows. Either route through `gateway.status.terminate_pid(pid, force=True)` (the centralized primitive that does `taskkill /T /F` on Windows and SIGKILL on POSIX), or fall back with `getattr(signal, "SIGKILL", signal.SIGTERM)`. - **Catch `OSError` alongside `ProcessLookupError` on `os.kill(pid, 0)` probes.** Windows raises `OSError` (WinError 87, "parameter is incorrect") for an already-gone PID instead of `ProcessLookupError`. - **Don't force the terminal to POSIX semantics.** `os.setsid`, `os.killpg`, `os.getpgid`, `os.fork` all raise on Windows — gate them with `if sys.platform != "win32":` or `if os.name != "nt":`. - **Open files with an explicit `encoding="utf-8"`.** The Python default on Windows is the system locale (often cp1252), which mojibakes or crashes on non-Latin text. @@ -198,15 +206,15 @@ Hermes has terminal access. Security matters. ### Existing Protections -| Layer | Implementation | -|-------|---------------| -| **Sudo password piping** | Uses `shlex.quote()` to prevent shell injection | -| **Dangerous command detection** | Regex patterns in `tools/approval.py` with user approval flow | -| **Cron prompt injection** | Scanner blocks instruction-override patterns | -| **Write deny list** | Protected paths resolved via `os.path.realpath()` to prevent symlink bypass | -| **Skills guard** | Security scanner for hub-installed skills | -| **Code execution sandbox** | Child process runs with API keys stripped | -| **Container hardening** | Docker: all capabilities dropped, no privilege escalation, PID limits | +| Layer | Implementation | +| ------------------------------- | --------------------------------------------------------------------------- | +| **Sudo password piping** | Uses `shlex.quote()` to prevent shell injection | +| **Dangerous command detection** | Regex patterns in `tools/approval.py` with user approval flow | +| **Cron prompt injection** | Scanner blocks instruction-override patterns | +| **Write deny list** | Protected paths resolved via `os.path.realpath()` to prevent symlink bypass | +| **Skills guard** | Security scanner for hub-installed skills | +| **Code execution sandbox** | Child process runs with API keys stripped | +| **Container hardening** | Docker: all capabilities dropped, no privilege escalation, PID limits | ### Contributing Security-Sensitive Code @@ -238,6 +246,7 @@ refactor/description # Code restructuring ### PR Description Include: + - **What** changed and **why** - **How to test** it - **What platforms** you tested on @@ -251,18 +260,19 @@ We use [Conventional Commits](https://www.conventionalcommits.org/): (): ``` -| Type | Use for | -|------|---------| -| `fix` | Bug fixes | -| `feat` | New features | -| `docs` | Documentation | -| `test` | Tests | -| `refactor` | Code restructuring | -| `chore` | Build, CI, dependency updates | +| Type | Use for | +| ---------- | ----------------------------- | +| `fix` | Bug fixes | +| `feat` | New features | +| `docs` | Documentation | +| `test` | Tests | +| `refactor` | Code restructuring | +| `chore` | Build, CI, dependency updates | Scopes: `cli`, `gateway`, `tools`, `skills`, `agent`, `install`, `whatsapp`, `security` Examples: + ``` fix(cli): prevent crash in save_config_value when model is a string feat(gateway): add WhatsApp multi-user session isolation