Merge branch 'main' into feat/desktop-web-bridge

This commit is contained in:
emozilla 2026-07-10 22:02:52 -04:00
commit a014dec94a
32 changed files with 887 additions and 45 deletions

3
.gitignore vendored
View file

@ -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.

View file

@ -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

56
agent/reactions.py Normal file
View file

@ -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 ``</3``).
_VIBE_RE = re.compile(
"|".join(
(
r"\bgood\s*bot\b",
r"\bi\s*(?:love|luv)\s*(?:you|u|ya)\b",
r"\b(?:love|luv)\s*(?:you|u|ya)\b",
r"\bily(?:sm)?\b",
r"\bthank\s*(?:you|u)\b",
r"\b(?:thanks|thx|tysm|ty)\b",
r"<3+", # <3, <33 … but not </3
# Hearts + affection faces (❤ ♥ 🥰 😍 😘 💕 💖 💗 💞 💛 💜 💚 💙 💓 💘 💝 🩷).
r"[\u2764\u2665"
r"\U0001F970\U0001F60D\U0001F618"
r"\U0001F495\U0001F496\U0001F497\U0001F49E"
r"\U0001F49B\U0001F49C\U0001F49A\U0001F499"
r"\U0001F493\U0001F498\U0001F49D\U0001FA77]",
)
),
re.IGNORECASE,
)
def detect_reaction(text: str | None) -> 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

View file

@ -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(

View file

@ -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

View file

@ -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

View file

@ -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), {})

View file

@ -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",

View file

@ -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({
</div>
)}
{showChatBar && <ScrollToBottomButton />}
{/* 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 && (
<HeartField
className="absolute inset-x-0 z-30"
config={COMPOSER_HEART_CONFIG}
style={{
top: 0,
bottom: 'calc(var(--composer-measured-height) + var(--status-stack-measured-height) + 0.25rem)'
}}
/>
)}
<ChatDropOverlay kind={dragKind} />
<ChatSwapOverlay profile={gatewaySwapTarget} />
</div>

View file

@ -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<PetZoomAnchor | null>(null)
const petRef = useRef<HTMLDivElement | null>(null)
const inputRef = useRef<HTMLInputElement | null>(null)
// Last mirrored reaction id — a bump means the main window fired a reaction.
const lastReactionRef = useRef<number | null>(null)
const ignoreRef = useRef(true)
const composerOpenRef = useRef(false)
const clickTimerRef = useRef<ReturnType<typeof setTimeout> | 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() {
<div style={{ lineHeight: 0, position: 'relative' }}>
<PetSprite info={info} />
{/* Hearts on the popped-out pet — identical to in-window. */}
<PetHeartField
petH={(info.frameH ?? DEFAULT_FRAME_H) * (info.scale ?? DEFAULT_SCALE)}
petW={(info.frameW ?? DEFAULT_FRAME_W) * (info.scale ?? DEFAULT_SCALE)}
/>
{/* 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);

View file

@ -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))

View file

@ -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<ParticleFieldConfig> = {
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 feethead, so
* rise 100% carries hearts from the feet to ~10-20% above the pet before fading. */
const PET_HEART_CONFIG: Partial<ParticleFieldConfig> = {
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 = (
<svg fill="none" shapeRendering="crispEdges" viewBox="0 0 14 12" xmlns="http://www.w3.org/2000/svg">
<path
d="M13.2 0v5.65714h-1.8857v1.88572H9.42857v1.88571H7.54286v1.88573H5.65714V9.42857H3.77143V7.54286H1.88571V5.65714H0V0h5.65714v1.88571h1.88572V0z"
fill="currentColor"
/>
</svg>
)
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<ParticleFieldConfig>
className?: string
style?: CSSProperties
}
/** Heart-skinned particle field. Caller supplies placement + a config preset. */
export function HeartField({ config, className, style }: HeartFieldProps) {
return (
<ParticleField
className={className}
colors={HEART_COLORS}
config={config}
emitter={emitter}
glyph={HEART_GLYPH}
style={style}
/>
)
}
/**
* 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 (
<HeartField
config={PET_HEART_CONFIG}
style={{
bottom: 0,
height: Math.max(96, petH),
left: '50%',
pointerEvents: 'none',
position: 'absolute',
transform: 'translateX(-50%)',
width: Math.max(90, petW * 1.5),
zIndex: 2
}}
/>
)
}

View file

@ -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 leftright 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);
}
}
}

View file

@ -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<ParticleFieldConfig>
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<Particle[]>([])
const timers = useRef<Set<ReturnType<typeof setTimeout>>>(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 (
<div aria-hidden className={cn('particle-field', className)} style={style}>
{particles.map(p => (
<span
className="particle"
key={p.id}
// Retire on the RISE track only (sway is infinite, pop is shorter).
onAnimationEnd={e => {
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
}
>
<span className="particle__sway">
<span className="particle__glyph">{glyph}</span>
</span>
</span>
))}
</div>
)
}

View file

@ -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() {
>
<PetSprite info={info} rowOverride={walk.row} />
</div>
{/* Hearts puff off the pet; its celebrate ("yay"/jump) pose is driven by
burstVibeHearts's router. */}
<PetHeartField petH={petH} petW={petW} />
</div>
)
}

View file

@ -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<PetReaction | null>(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)
]
}

View file

@ -92,6 +92,9 @@ export function derivePetState(activity: PetActivity): PetState {
export const $petInfo = atom<PetInfo>({ enabled: false })
export const $petActivity = atom<PetActivity>({})
/** 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

6
cli.py
View file

@ -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:

View file

@ -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

View file

@ -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."
'';
};
};

View file

@ -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,

152
scripts/dev-sandbox.sh Executable file
View file

@ -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] [--] <command...>
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

View file

@ -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 </3", # broken heart is not affection
"the ferry departs at 3", # 'ty' must be word-bounded, not match 'ferTY'-like
"commit the changes",
],
)
def test_neutral_or_negative_does_not_fire(text):
assert detect_reaction(text) is None
def test_case_insensitive_invariant():
# Casing must never change the classification.
for text in ("ILY", "iLy", "ily"):
assert detect_reaction(text) == detect_reaction("ily")

View file

@ -3870,6 +3870,9 @@ def _agent_cbs(sid: str) -> 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,

View file

@ -38,7 +38,6 @@ function makeDeps(gw: GatewayClient, over: Partial<SubmitPromptDeps> = {}): Subm
enqueue: vi.fn(),
expand: (t: string) => t,
gw,
maybeGoodVibes: vi.fn(),
setLastUserMsg: vi.fn(),
sys: vi.fn(),
...over

View file

@ -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(

View file

@ -14,6 +14,13 @@ export const $petFlash = atom<PetFlash | null>(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

View file

@ -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) {

View file

@ -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 | number>(null)
const [lastTurnEndedAt, setLastTurnEndedAt] = useState<null | number>(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 <T extends Record<string, any> = Record<string, any>>(
method: string,
@ -690,7 +685,6 @@ export function useMainApp(gw: GatewayClient) {
composerRefs,
composerState,
gw,
maybeGoodVibes,
setLastUserMsg,
slashRef,
submitRef,

View file

@ -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>

View file

@ -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' }
| {

View file

@ -31,12 +31,12 @@ We value contributions in this order:
### Prerequisites
| Requirement | Notes |
|-------------|-------|
| **Git** | With the `git-lfs` extension installed |
| **Python 3.113.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.113.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>(<scope>): <description>
```
| 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