diff --git a/.dockerignore b/.dockerignore index 3c16d71b2..ee3947b25 100644 --- a/.dockerignore +++ b/.dockerignore @@ -3,6 +3,21 @@ .gitignore .gitmodules +# Python +__pycache__ +*.py[cod] +*$py.class +*.so +.Python +*.egg-info/ +dist/ +build/ + +# Virtual environments +venv/ +env/ +ENV/ + # Dependencies node_modules **/node_modules @@ -24,7 +39,20 @@ ui-tui/packages/hermes-ink/dist/ # Environment files .env +.env.* +# IDE +.vscode/ +.idea/ +*.swp +*.swo + +# Testing +.pytest_cache/ +.coverage +htmlcov/ + +# Documentation *.md # Runtime data (bind-mounted at /opt/data; must not leak into build context) diff --git a/.gitattributes b/.gitattributes index 872621689..553e3cd21 100644 --- a/.gitattributes +++ b/.gitattributes @@ -1,2 +1,10 @@ # Auto-generated files — collapse diffs and exclude from language stats web/package-lock.json linguist-generated=true + +# Enforce LF for scripts that run inside Linux containers. +# Without this, Windows checkout converts to CRLF and breaks `exec` in the +# container entrypoint with "no such file or directory". +*.sh text eol=lf +Dockerfile text eol=lf +*.dockerfile text eol=lf +docker/entrypoint.sh text eol=lf diff --git a/.github/pr-screenshots/39327/providers-collapsed.png b/.github/pr-screenshots/39327/providers-collapsed.png new file mode 100755 index 000000000..523bd1b84 Binary files /dev/null and b/.github/pr-screenshots/39327/providers-collapsed.png differ diff --git a/.github/pr-screenshots/39327/providers-expanded.png b/.github/pr-screenshots/39327/providers-expanded.png new file mode 100755 index 000000000..ab8c4213f Binary files /dev/null and b/.github/pr-screenshots/39327/providers-expanded.png differ diff --git a/.github/pr-screenshots/39327/tools-collapsed.png b/.github/pr-screenshots/39327/tools-collapsed.png new file mode 100755 index 000000000..d45ac3e5e Binary files /dev/null and b/.github/pr-screenshots/39327/tools-collapsed.png differ diff --git a/.github/pr-screenshots/39327/tools-expanded.png b/.github/pr-screenshots/39327/tools-expanded.png new file mode 100755 index 000000000..1f57248e6 Binary files /dev/null and b/.github/pr-screenshots/39327/tools-expanded.png differ diff --git a/.gitignore b/.gitignore index f97db5994..b0abe3140 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,6 @@ .DS_Store /venv/ +/venv.old/ /_pycache/ *.pyc* __pycache__/ diff --git a/README.md b/README.md index bda0c5ed3..b8fe21171 100644 --- a/README.md +++ b/README.md @@ -33,7 +33,7 @@ Use any model you want — [Nous Portal](https://portal.nousresearch.com), [Open ### Linux, macOS, WSL2, Termux ```bash -curl -fsSL https://raw.githubusercontent.com/NousResearch/hermes-agent/main/scripts/install.sh | bash +curl -fsSL https://hermes-agent.nousresearch.com/install.sh | bash ``` ### Windows (native, PowerShell) @@ -43,7 +43,7 @@ curl -fsSL https://raw.githubusercontent.com/NousResearch/hermes-agent/main/scri Run this in PowerShell: ```powershell -iex (irm https://raw.githubusercontent.com/NousResearch/hermes-agent/main/scripts/install.ps1) +iex (irm https://hermes-agent.nousresearch.com/install.ps1) ``` The installer handles everything: uv, Python 3.11, Node.js, ripgrep, ffmpeg, **and a portable Git Bash** (MinGit, unpacked to `%LOCALAPPDATA%\hermes\git` — no admin required, completely isolated from any system Git install). Hermes uses this bundled Git Bash to run shell commands. @@ -52,7 +52,7 @@ If you already have Git installed, the installer detects it and uses that instea > **Android / Termux:** The tested manual path is documented in the [Termux guide](https://hermes-agent.nousresearch.com/docs/getting-started/termux). On Termux, Hermes installs a curated `.[termux]` extra because the full `.[all]` extra currently pulls Android-incompatible voice dependencies. > -> **Windows:** Native Windows is fully supported — the PowerShell one-liner above installs everything. If you'd rather use WSL2, the Linux command works there too. Native Windows install lives under `%LOCALAPPDATA%\hermes`; WSL2 installs under `~/.hermes` as on Linux. The only Hermes feature that currently needs WSL2 specifically is the browser-based dashboard chat pane (it uses a POSIX PTY — classic CLI and gateway both run natively). +> **Windows:** Native Windows is fully supported — the PowerShell one-liner above installs everything. If you'd rather use WSL2, the Linux command works there too. Native Windows install lives under `%LOCALAPPDATA%\hermes`; WSL2 installs under `~/.hermes` as on Linux. The only Hermes feature that currently needs WSL2 specifically is the browser-based dashboard chat pane (it uses a POSIX PTY — classic CLI and gateway both run natively). After installation: diff --git a/README.zh-CN.md b/README.zh-CN.md index 38c8bf931..e40b65990 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -31,7 +31,7 @@ ## 快速安装 ```bash -curl -fsSL https://raw.githubusercontent.com/NousResearch/hermes-agent/main/scripts/install.sh | bash +curl -fsSL https://hermes-agent.nousresearch.com/install.sh | bash ``` 支持 Linux、macOS、WSL2 和 Android (Termux)。安装程序会自动处理平台特定的配置。 diff --git a/acp_adapter/session.py b/acp_adapter/session.py index c40553f26..c124229be 100644 --- a/acp_adapter/session.py +++ b/acp_adapter/session.py @@ -457,12 +457,7 @@ class SessionManager: else: # Update model_config (contains cwd) if changed. try: - with db._lock: - db._conn.execute( - "UPDATE sessions SET model_config = ?, model = COALESCE(?, model) WHERE id = ?", - (cwd_json, model_str, state.session_id), - ) - db._conn.commit() + db.update_session_meta(state.session_id, cwd_json, model_str) except Exception: logger.debug("Failed to update ACP session metadata", exc_info=True) diff --git a/agent/model_metadata.py b/agent/model_metadata.py index 724457fd8..0ce9d0c63 100644 --- a/agent/model_metadata.py +++ b/agent/model_metadata.py @@ -441,6 +441,10 @@ def is_local_endpoint(base_url: str) -> bool: # Docker / Podman / Lima internal DNS names (e.g. host.docker.internal) if any(host.endswith(suffix) for suffix in _CONTAINER_LOCAL_SUFFIXES): return True + # Unqualified hostnames (no dots) are local by definition — Docker + # Compose service names, /etc/hosts entries, or mDNS names. + if host and "." not in host: + return True # RFC-1918 private ranges, link-local, and Tailscale CGNAT try: addr = ipaddress.ip_address(host) diff --git a/apps/desktop/README.md b/apps/desktop/README.md index 1f4a693c4..525e9ab77 100644 --- a/apps/desktop/README.md +++ b/apps/desktop/README.md @@ -27,7 +27,7 @@ Add `--include-desktop` to the [one-line installer](../../README.md#quick-install) and it sets up the agent and builds the desktop app in one go: ```bash -curl -fsSL https://raw.githubusercontent.com/NousResearch/hermes-agent/main/scripts/install.sh | bash -s -- --include-desktop +curl -fsSL https://hermes-agent.nousresearch.com/install.sh | bash -s -- --include-desktop ``` Already have the Hermes CLI? Just run: @@ -40,7 +40,7 @@ It builds and launches the GUI against your existing install — same config, ke ### Prebuilt installers -When a release ships desktop installers they're attached to its [releases page](https://github.com/NousResearch/hermes-agent/releases) — `.dmg` (macOS), `.exe` / `.msi` (Windows), `.AppImage` / `.deb` / `.rpm` (Linux). These are published manually, so the install-with-Hermes path above is the most reliable way to get the latest. +Prebuilt installers are built and distributed via [the Hermes Desktop website.](https://hermes-agent.nousresearch.com/desktop). --- @@ -56,10 +56,7 @@ hermes update ## Requirements -The installer handles everything for you (Python 3.11+, a portable Git, ripgrep). The only thing worth knowing: - -- **Windows** — the installer bundles its own Git and Python; no admin rights or system changes required. -- **macOS / Linux** — uses your system Python 3.11+ (installed automatically if missing). +The installer handles everything for you (Python 3.11+, a portable Git, ripgrep). --- diff --git a/apps/desktop/src/app/chat/composer/hooks/use-slash-completions.ts b/apps/desktop/src/app/chat/composer/hooks/use-slash-completions.ts index 62c982d15..f33441580 100644 --- a/apps/desktop/src/app/chat/composer/hooks/use-slash-completions.ts +++ b/apps/desktop/src/app/chat/composer/hooks/use-slash-completions.ts @@ -16,6 +16,7 @@ interface SlashItemMetadata extends Record { command: string display: string meta: string + rawText: string } function textValue(value: unknown, fallback = ''): string { @@ -91,7 +92,13 @@ export function useSlashCompletions(options: { gateway: HermesGateway | null }): const metadata: SlashItemMetadata = { command, display, - meta + meta, + // Provide rawText so hermesDirectiveFormatter.serialize uses the + // direct-insertion path instead of the legacy @type:id fallback. + // Without this, the item.id (which includes a "|index" suffix for + // trigger-adapter uniqueness) leaks into the serialized chip text + // and the submitted command. + rawText: command } return { diff --git a/apps/desktop/src/app/chat/composer/index.tsx b/apps/desktop/src/app/chat/composer/index.tsx index 1c36b161d..4997014e8 100644 --- a/apps/desktop/src/app/chat/composer/index.tsx +++ b/apps/desktop/src/app/chat/composer/index.tsx @@ -18,6 +18,7 @@ import { Button } from '@/components/ui/button' import { useMediaQuery } from '@/hooks/use-media-query' import { useResizeObserver } from '@/hooks/use-resize-observer' import { chatMessageText } from '@/lib/chat-messages' +import { SLASH_COMMAND_RE } from '@/lib/chat-runtime' import { DATA_IMAGE_URL_RE } from '@/lib/embedded-images' import { triggerHaptic } from '@/lib/haptics' import { cn } from '@/lib/utils' @@ -1056,7 +1057,19 @@ export function ChatBar({ if (queueEdit) { exitQueuedEdit('save') } else if (busy) { - if (hasComposerPayload) { + // Slash commands should execute immediately even while the agent is + // busy — they're client-side operations (/yolo, /skin, /new, /help, + // etc.) or self-contained gateway RPCs (/status, /compress). onSubmit + // routes them to executeSlashCommand, which has its own per-command + // busy guard for commands that genuinely need an idle session (skill + // /send directives). Queuing them would make every slash command wait + // for the current turn to finish, which is how the TUI never behaves. + if (!attachments.length && SLASH_COMMAND_RE.test(draft.trim())) { + const submitted = draft + triggerHaptic('submit') + clearDraft() + void onSubmit(submitted) + } else if (hasComposerPayload) { queueCurrentDraft() } else { // Stop button: an explicit interrupt must actually halt the running diff --git a/apps/desktop/src/app/chat/sidebar/index.tsx b/apps/desktop/src/app/chat/sidebar/index.tsx index e31dae018..c84184359 100644 --- a/apps/desktop/src/app/chat/sidebar/index.tsx +++ b/apps/desktop/src/app/chat/sidebar/index.tsx @@ -37,6 +37,7 @@ import { Skeleton } from '@/components/ui/skeleton' import { Tip } from '@/components/ui/tooltip' import { searchSessions, type SessionInfo, type SessionSearchResult } from '@/hermes' import { profileColor } from '@/lib/profile-color' +import { sessionMatchesSearch } from '@/lib/session-search' import { cn } from '@/lib/utils' import { $panesFlipped, @@ -363,11 +364,10 @@ export function ChatSidebar({ return [] } - const needle = trimmedQuery.toLowerCase() const out = new Map() for (const s of sortedSessions) { - if (`${s.title ?? ''} ${s.preview ?? ''} ${s.cwd ?? ''}`.toLowerCase().includes(needle)) { + if (sessionMatchesSearch(s, trimmedQuery)) { out.set(s.id, s) } } diff --git a/apps/desktop/src/app/command-center/index.tsx b/apps/desktop/src/app/command-center/index.tsx index c7b7b23f5..39cf73428 100644 --- a/apps/desktop/src/app/command-center/index.tsx +++ b/apps/desktop/src/app/command-center/index.tsx @@ -158,7 +158,7 @@ export function CommandCenterView({ initialSection, onClose, onDeleteSession, on } return sorted.filter(session => { - const haystack = `${sessionTitle(session)} ${session.id}`.toLowerCase() + const haystack = `${sessionTitle(session)} ${session.id} ${session._lineage_root_id ?? ''}`.toLowerCase() return haystack.includes(needle) }) diff --git a/apps/desktop/src/app/command-palette/index.tsx b/apps/desktop/src/app/command-palette/index.tsx index 5875f1eb3..7fd015efe 100644 --- a/apps/desktop/src/app/command-palette/index.tsx +++ b/apps/desktop/src/app/command-palette/index.tsx @@ -27,6 +27,7 @@ import { Palette, Plus, Settings, + Settings2, Sun, Users, Wrench, @@ -105,7 +106,18 @@ const NON_CONFIG_SETTINGS: ReadonlyArray<{ icon: IconComponent; keywords?: strin tab: 'providers&pview=keys' }, { icon: Globe, keywords: ['connection', 'messaging'], label: 'Gateway', tab: 'gateway' }, - { icon: KeyRound, keywords: ['api', 'secrets', 'tokens', 'credentials'], label: 'Tools & Keys', tab: 'keys' }, + { + icon: KeyRound, + keywords: ['api', 'secrets', 'tokens', 'credentials', 'browser', 'search'], + label: 'Tools & Keys', + tab: 'keys&kview=tools' + }, + { + icon: Settings2, + keywords: ['gateway', 'proxy', 'server', 'webhook', 'env'], + label: 'Tools & Keys settings', + tab: 'keys&kview=settings' + }, { icon: Wrench, keywords: ['servers', 'tools'], label: 'MCP', tab: 'mcp' }, { icon: Archive, keywords: ['history', 'archived'], label: 'Archived Chats', tab: 'sessions' }, { icon: Info, keywords: ['version', 'about'], label: 'About', tab: 'about' } diff --git a/apps/desktop/src/app/cron/cron-job-actions-menu.tsx b/apps/desktop/src/app/cron/cron-job-actions-menu.tsx new file mode 100644 index 000000000..9e576c9ea --- /dev/null +++ b/apps/desktop/src/app/cron/cron-job-actions-menu.tsx @@ -0,0 +1,108 @@ +import type * as React from 'react' + +import { Button } from '@/components/ui/button' +import { Codicon } from '@/components/ui/codicon' +import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from '@/components/ui/dropdown-menu' +import { triggerHaptic } from '@/lib/haptics' + +interface CronJobActions { + busy?: boolean + isPaused: boolean + title: string + onDelete: () => void + onEdit: () => void + onPauseResume: () => void + onTrigger: () => void +} + +interface CronJobActionsMenuProps + extends CronJobActions, Pick, 'align' | 'sideOffset'> { + children: React.ReactNode +} + +export function CronJobActionsMenu({ + align = 'end', + busy = false, + children, + isPaused, + onDelete, + onEdit, + onPauseResume, + onTrigger, + sideOffset = 6, + title +}: CronJobActionsMenuProps) { + return ( + + {children} + + { + triggerHaptic('selection') + onPauseResume() + }} + > + + {isPaused ? 'Resume' : 'Pause'} + + + { + triggerHaptic('selection') + onTrigger() + }} + > + + Trigger now + + + { + triggerHaptic('selection') + onEdit() + }} + > + + Edit + + + { + triggerHaptic('warning') + onDelete() + }} + variant="destructive" + > + + Delete + + + + ) +} + +interface CronJobActionsTriggerProps extends Omit, 'size' | 'variant'> { + title: string +} + +export function CronJobActionsTrigger({ className, title, ...props }: CronJobActionsTriggerProps) { + return ( + + ) +} diff --git a/apps/desktop/src/app/cron/index.tsx b/apps/desktop/src/app/cron/index.tsx index adb5e6ba0..8202dd3d8 100644 --- a/apps/desktop/src/app/cron/index.tsx +++ b/apps/desktop/src/app/cron/index.tsx @@ -17,7 +17,6 @@ import { Input } from '@/components/ui/input' import { SearchField } from '@/components/ui/search-field' import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select' import { Textarea } from '@/components/ui/textarea' -import { Tip } from '@/components/ui/tooltip' import { createCronJob, type CronJob, @@ -29,12 +28,13 @@ import { updateCronJob } from '@/hermes' import { AlertTriangle, Clock } from '@/lib/icons' -import { cn } from '@/lib/utils' import { notify, notifyError } from '@/store/notifications' import { useRefreshHotkey } from '../hooks/use-refresh-hotkey' import { OverlayView } from '../overlays/overlay-view' +import { CronJobActionsMenu, CronJobActionsTrigger } from './cron-job-actions-menu' + const DEFAULT_DELIVER = 'local' const DELIVERY_OPTIONS: ReadonlyArray<{ label: string; value: string }> = [ @@ -548,54 +548,27 @@ function CronJobRow({ )} -
- + - - - - - - - - - - - + event.stopPropagation()} + title={jobTitle(job)} + /> +
) } -function IconAction({ - children, - className, - title, - ...props -}: Omit, 'size' | 'variant'>) { - return ( - - - - ) -} - function EmptyState({ actionLabel, description, diff --git a/apps/desktop/src/app/desktop-controller.tsx b/apps/desktop/src/app/desktop-controller.tsx index 99159a888..00c1d8fa5 100644 --- a/apps/desktop/src/app/desktop-controller.tsx +++ b/apps/desktop/src/app/desktop-controller.tsx @@ -558,6 +558,7 @@ export function DesktopController() { busyRef, createBackendSessionForSend, handleSkinCommand, + refreshSessions, requestGateway, selectedStoredSessionIdRef, startFreshSessionDraft, diff --git a/apps/desktop/src/app/messaging/index.tsx b/apps/desktop/src/app/messaging/index.tsx index be753ecd8..cb75a04e9 100644 --- a/apps/desktop/src/app/messaging/index.tsx +++ b/apps/desktop/src/app/messaging/index.tsx @@ -8,7 +8,6 @@ import { Button } from '@/components/ui/button' import { DisclosureCaret } from '@/components/ui/disclosure-caret' import { Input } from '@/components/ui/input' import { Switch } from '@/components/ui/switch' -import { Tip } from '@/components/ui/tooltip' import { getMessagingPlatforms, type MessagingEnvVarInfo, @@ -22,6 +21,8 @@ import { notify, notifyError } from '@/store/notifications' import { useRefreshHotkey } from '../hooks/use-refresh-hotkey' import { useRouteEnumParam } from '../hooks/use-route-enum-param' import { PageSearchShell } from '../page-search-shell' +import { CREDENTIAL_CONTROL_CLASS } from '../settings/credential-key-ui' +import { ListRow } from '../settings/primitives' import type { SetStatusbarItemGroup } from '../shell/statusbar-controls' import { PlatformAvatar } from './platform-icon' @@ -109,6 +110,47 @@ const FIELD_COPY: Record Required -
+
{requiredFields.length > 0 ? ( requiredFields.map(field => ( 0 && (
Recommended -
+
{optionalFields.map(field => ( {showAdvanced && ( -
+
{advancedFields.map(field => ( -
- - {field.is_set && Saved} -
-
- onEdit(field.key, event.target.value)} - placeholder={field.is_set ? field.redacted_value || 'Replace current value' : copy.placeholder} - type={field.is_password ? 'password' : 'text'} - value={edits[field.key] || ''} - /> - {field.url && ( - - - - )} - {field.is_set && ( - + )} + {field.is_set && ( - - )} -
- {copy.help &&

{copy.help}

} -
+ )} +
+ } + description={copy.help} + title={ + + + {field.is_set && Saved} + + } + /> ) } diff --git a/apps/desktop/src/app/session/hooks/use-prompt-actions.test.tsx b/apps/desktop/src/app/session/hooks/use-prompt-actions.test.tsx new file mode 100644 index 000000000..a27bd2cbd --- /dev/null +++ b/apps/desktop/src/app/session/hooks/use-prompt-actions.test.tsx @@ -0,0 +1,166 @@ +import { cleanup, render } from '@testing-library/react' +import type { MutableRefObject } from 'react' +import { useEffect } from 'react' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +import { $sessions, setSessions } from '@/store/session' +import type { SessionInfo } from '@/types/hermes' + +import { usePromptActions } from './use-prompt-actions' + +vi.mock('@/hermes', () => ({ + transcribeAudio: vi.fn() +})) + +// The active id the desktop holds is the *runtime* session id from +// session.create — deliberately distinct from the stored DB id here, because +// that mismatch is the bug: the REST renameSession endpoint resolves against +// the stored sessions table and 404s on a runtime id. session.title accepts +// the runtime id directly. +const RUNTIME_SESSION_ID = 'rt-abc123' + +function sessionInfo(overrides: Partial = {}): SessionInfo { + return { + ended_at: null, + id: RUNTIME_SESSION_ID, + input_tokens: 0, + is_active: true, + last_active: 0, + message_count: 3, + model: null, + output_tokens: 0, + preview: null, + source: null, + started_at: 0, + title: 'Old title', + tool_call_count: 0, + ...overrides + } +} + +interface HarnessHandle { + submitText: (text: string) => Promise +} + +function Harness({ + onReady, + refreshSessions, + requestGateway +}: { + onReady: (handle: HarnessHandle) => void + refreshSessions: () => Promise + requestGateway: (method: string, params?: Record) => Promise +}) { + const activeSessionIdRef: MutableRefObject = { current: RUNTIME_SESSION_ID } + const selectedStoredSessionIdRef: MutableRefObject = { current: RUNTIME_SESSION_ID } + const busyRef = { current: false } + + const actions = usePromptActions({ + activeSessionId: RUNTIME_SESSION_ID, + activeSessionIdRef, + branchCurrentSession: async () => true, + busyRef, + createBackendSessionForSend: async () => RUNTIME_SESSION_ID, + handleSkinCommand: () => '', + refreshSessions, + requestGateway, + selectedStoredSessionIdRef, + startFreshSessionDraft: () => undefined, + sttEnabled: false, + updateSessionState: (_sessionId, updater) => + updater({ messages: [], busy: false, awaitingResponse: false } as never) + }) + + useEffect(() => { + onReady({ submitText: actions.submitText }) + }, [actions.submitText, onReady]) + + return null +} + +describe('usePromptActions /title', () => { + beforeEach(() => { + setSessions(() => [sessionInfo()]) + }) + + afterEach(() => { + cleanup() + vi.restoreAllMocks() + }) + + it('renames via the session.title RPC (with the runtime id), updates the sidebar store, and refreshes', async () => { + const refreshSessions = vi.fn(async () => undefined) + const requestGateway = vi.fn(async (method: string) => + (method === 'session.title' ? { pending: false, title: 'New title' } : {}) as never + ) + + let handle: HarnessHandle | null = null + render( (handle = h)} refreshSessions={refreshSessions} requestGateway={requestGateway} />) + + await handle!.submitText('/title New title') + + // Routes through session.title with the runtime session id — NOT the slash + // worker (slash.exec) and NOT the REST endpoint. This is the path that + // resolves the runtime id and persists reliably across platforms. + expect(requestGateway).toHaveBeenCalledWith('session.title', { + session_id: RUNTIME_SESSION_ID, + title: 'New title' + }) + expect(requestGateway).not.toHaveBeenCalledWith('slash.exec', expect.anything()) + expect(refreshSessions).toHaveBeenCalledTimes(1) + expect($sessions.get()[0]?.title).toBe('New title') + }) + + it('reports the queued state when the session row is not persisted yet', async () => { + const refreshSessions = vi.fn(async () => undefined) + const requestGateway = vi.fn(async (method: string) => + (method === 'session.title' ? { pending: true, title: 'Fresh chat' } : {}) as never + ) + + let handle: HarnessHandle | null = null + render( (handle = h)} refreshSessions={refreshSessions} requestGateway={requestGateway} />) + + await handle!.submitText('/title Fresh chat') + + expect(requestGateway).toHaveBeenCalledWith('session.title', { + session_id: RUNTIME_SESSION_ID, + title: 'Fresh chat' + }) + // Even when queued, the sidebar reflects the chosen title optimistically. + expect(refreshSessions).toHaveBeenCalledTimes(1) + expect($sessions.get()[0]?.title).toBe('Fresh chat') + }) + + it('falls through to the slash worker for a bare /title (show current title)', async () => { + const refreshSessions = vi.fn(async () => undefined) + const requestGateway = vi.fn(async () => ({ output: 'Title: Old title' }) as never) + + let handle: HarnessHandle | null = null + render( (handle = h)} refreshSessions={refreshSessions} requestGateway={requestGateway} />) + + await handle!.submitText('/title') + + expect(requestGateway).not.toHaveBeenCalledWith('session.title', expect.anything()) + expect(requestGateway).toHaveBeenCalledWith('slash.exec', expect.objectContaining({ command: 'title' })) + }) + + it('surfaces a rename error without touching the sidebar store', async () => { + const refreshSessions = vi.fn(async () => undefined) + const requestGateway = vi.fn(async (method: string) => { + if (method === 'session.title') { + throw new Error('Title too long') + } + + return {} as never + }) + + let handle: HarnessHandle | null = null + render( (handle = h)} refreshSessions={refreshSessions} requestGateway={requestGateway} />) + + await handle!.submitText('/title way too long title') + + expect(requestGateway).toHaveBeenCalledWith('session.title', expect.objectContaining({ title: 'way too long title' })) + expect(refreshSessions).not.toHaveBeenCalled() + expect($sessions.get()[0]?.title).toBe('Old title') + }) +}) diff --git a/apps/desktop/src/app/session/hooks/use-prompt-actions.ts b/apps/desktop/src/app/session/hooks/use-prompt-actions.ts index a2c65dbf2..bd8c5a862 100644 --- a/apps/desktop/src/app/session/hooks/use-prompt-actions.ts +++ b/apps/desktop/src/app/session/hooks/use-prompt-actions.ts @@ -38,10 +38,11 @@ import { setAwaitingResponse, setBusy, setMessages, + setSessions, setYoloActive } from '@/store/session' -import type { ClientSessionState, ImageAttachResponse, SlashExecResponse } from '../../types' +import type { ClientSessionState, ImageAttachResponse, SessionTitleResponse, SlashExecResponse } from '../../types' function blobToDataUrl(blob: Blob): Promise { return new Promise((resolve, reject) => { @@ -78,6 +79,7 @@ interface PromptActionsOptions { branchCurrentSession: () => Promise createBackendSessionForSend: (preview?: string | null) => Promise handleSkinCommand: (arg: string) => string + refreshSessions: () => Promise requestGateway: (method: string, params?: Record) => Promise selectedStoredSessionIdRef: MutableRefObject startFreshSessionDraft: () => void @@ -142,6 +144,7 @@ export function usePromptActions({ branchCurrentSession, createBackendSessionForSend, handleSkinCommand, + refreshSessions, requestGateway, selectedStoredSessionIdRef, startFreshSessionDraft, @@ -501,6 +504,50 @@ export function usePromptActions({ const renderSlashOutput = (text: string) => appendSessionTextMessage(sessionId, 'system', recordInput ? slashStatusText(command, text) : text) + // /title renames the session. Route through the gateway's + // `session.title` RPC — the same path the TUI uses — NOT the REST + // renameSession endpoint and NOT the slash worker. + // + // Why not the slash worker: it's a separate HermesCLI subprocess whose + // SQLite write to the shared state.db can silently fail (notably on + // Windows), and it never refreshes the sidebar. + // + // Why not REST renameSession: `sessionId` here is the *runtime* session + // id returned by session.create — it is NOT the stored DB `sessions.id`, + // and session.create deliberately does not persist a DB row until the + // first turn. The REST PATCH endpoint resolves against the sessions + // table, so a runtime id (or a brand-new, not-yet-persisted session) + // 404s with "Session not found" on every platform. See #38508 / #38576. + // + // session.title maps the runtime id to the in-memory session, writes + // through the gateway's own DB connection, and QUEUES the title + // (`pending: true`) when the row isn't persisted yet — so it works for a + // fresh chat too. refreshSessions() then pulls the authoritative title + // back into the sidebar. A bare `/title` (no arg) still falls through to + // the worker to display the current title. + if (normalizedName === 'title' && arg) { + try { + const result = await requestGateway('session.title', { + session_id: sessionId, + title: arg + }) + const finalTitle = (result?.title || arg).trim() + const queued = result?.pending === true + + setSessions(prev => prev.map(s => (s.id === sessionId ? { ...s, title: finalTitle || null } : s))) + await refreshSessions().catch(() => undefined) + renderSlashOutput( + finalTitle + ? `Session title set: ${finalTitle}${queued ? ' (queued while session initializes)' : ''}` + : 'Session title cleared.' + ) + } catch (err) { + renderSlashOutput(`error: ${err instanceof Error ? err.message : String(err)}`) + } + + return + } + if (normalizedName === 'skin') { renderSlashOutput(handleSkinCommand(arg)) @@ -601,6 +648,7 @@ export function usePromptActions({ busyRef, createBackendSessionForSend, handleSkinCommand, + refreshSessions, requestGateway, startFreshSessionDraft, submitPromptText diff --git a/apps/desktop/src/app/settings/credential-key-ui.tsx b/apps/desktop/src/app/settings/credential-key-ui.tsx new file mode 100644 index 000000000..8003b3487 --- /dev/null +++ b/apps/desktop/src/app/settings/credential-key-ui.tsx @@ -0,0 +1,363 @@ +import { type ChangeEvent, type KeyboardEvent } from 'react' + +import { Button } from '@/components/ui/button' +import { Input } from '@/components/ui/input' +import { ChevronDown, ExternalLink, Loader2, Save } from '@/lib/icons' +import { cn } from '@/lib/utils' +import type { EnvVarInfo } from '@/types/hermes' + +import { CONTROL_TEXT } from './constants' +import { prettyName, withoutKey } from './helpers' +import { ListRow } from './primitives' +import type { EnvRowProps } from './types' + +export type KeyRowProps = Omit + +/** Matches Advanced / config field controls (ListRow + Input). */ +export const CREDENTIAL_CONTROL_CLASS = cn('h-8', CONTROL_TEXT) + +export const isKeyVar = (key: string, info: EnvVarInfo) => + info.is_password || /(?:_API_KEY|_TOKEN|_KEY)$/.test(key) + +export const friendlyFieldLabel = (key: string, info: EnvVarInfo) => + info.description?.trim() || + key + .replace(/_/g, ' ') + .toLowerCase() + .replace(/\b\w/g, c => c.toUpperCase()) + +export const credentialPlaceholder = (key: string, info: EnvVarInfo, label: string): string => + isKeyVar(key, info) ? `Paste ${label} key` : /URL$/i.test(key) ? 'https://…' : 'Optional' + +// A single credential field: a set key shows as a filled read-only input +// (redacted value) that edits in place on click. Save appears once typed; a set +// key also offers Remove, and Esc cancels without closing the overlay. +export function KeyField({ + info, + placeholder, + rowProps, + varKey +}: { + info: EnvVarInfo + placeholder?: string + rowProps: KeyRowProps + varKey: string +}) { + const { edits, onClear, onSave, saving, setEdits } = rowProps + const editing = edits[varKey] !== undefined + const draft = edits[varKey] ?? '' + const dirty = draft.trim().length > 0 + const busy = saving === varKey + const masked = info.redacted_value ?? '••••••••' + const startEdit = () => setEdits(c => ({ ...c, [varKey]: '' })) + const cancel = () => setEdits(c => withoutKey(c, varKey)) + const update = (e: ChangeEvent) => setEdits(c => ({ ...c, [varKey]: e.target.value })) + + const keydown = (e: KeyboardEvent) => { + if (e.key === 'Enter' && dirty) { + void onSave(varKey) + } else if (e.key === 'Escape' && editing) { + e.preventDefault() + e.stopPropagation() + cancel() + } + } + + const editType = info.is_password ? 'password' : 'text' + + if (info.is_set && !editing) { + return ( + + ) + } + + return ( +
+
+ + {dirty && ( + + )} +
+ {editing && ( +
+ {info.is_set && ( + <> + + or + + )} + esc to cancel +
+ )} +
+ ) +} + +function CredentialDocsLink({ href }: { href: string }) { + return ( + e.stopPropagation()} + rel="noreferrer" + target="_blank" + > + Get a key + + + ) +} + +/** One credential row — collapsible; description and docs link expand on click. */ +export function CredentialKeyCard({ + expanded, + info, + label, + onExpand, + onToggle, + placeholder, + rowProps, + varKey +}: CredentialKeyCardProps) { + const docsUrl = info.url?.trim() + const description = info.description?.trim() + const expandable = Boolean(description || docsUrl) + + return ( +
{ + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault() + onToggle() + } + } + : undefined + } + role={expandable ? 'button' : undefined} + tabIndex={expandable ? 0 : undefined} + > +
+
+ + + + {label} + + + {expandable && ( + + )} +
+ +
e.stopPropagation()} + onFocus={() => { + if (expandable && !expanded) { + onExpand() + } + }} + > + +
+
+ + {expandable && expanded && ( +
e.stopPropagation()}> + {description && ( +

+ {description} +

+ )} + + {docsUrl && } +
+ )} +
+ ) +} + +/** Provider API key group — collapsible card; description, docs link, and advanced fields expand on click. */ +export function ProviderKeyRows({ expanded, group, onExpand, onToggle, rowProps }: ProviderKeyRowsProps) { + const docsUrl = group.docsUrl?.trim() + const description = group.description?.trim() + const expandable = Boolean(description || docsUrl || group.advanced.length > 0) + + return ( +
{ + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault() + onToggle() + } + } + : undefined + } + role={expandable ? 'button' : undefined} + tabIndex={expandable ? 0 : undefined} + > +
+
+ + + + {group.name} + + + {expandable && ( + + )} +
+ +
e.stopPropagation()} + onFocus={() => { + if (expandable && !expanded) { + onExpand() + } + }} + > + +
+
+ + {expandable && expanded && ( +
e.stopPropagation()}> + {description && ( +

+ {description} +

+ )} + + {group.advanced.map(([key, info]) => { + const fieldLabel = isKeyVar(key, info) + ? prettyName(key.replace(/(?:_API_KEY|_TOKEN|_KEY)$/i, '')) + : friendlyFieldLabel(key, info) + + return ( + + } + key={key} + title={fieldLabel} + /> + ) + })} + + {docsUrl && } +
+ )} +
+ ) +} + +export function credentialRowLabel(varKey: string, info: EnvVarInfo): string { + if (isKeyVar(varKey, info)) { + return prettyName(varKey.replace(/(?:_API_KEY|_TOKEN|_KEY)$/i, '')) + } + + return prettyName(varKey) +} + +interface CredentialKeyCardProps { + expanded: boolean + info: EnvVarInfo + label: string + onExpand: () => void + onToggle: () => void + placeholder: string + rowProps: KeyRowProps + varKey: string +} + +interface ProviderKeyRowsProps { + expanded: boolean + group: ProviderKeyRowGroup + onExpand: () => void + onToggle: () => void + rowProps: KeyRowProps +} + +export interface ProviderKeyRowGroup { + advanced: [string, EnvVarInfo][] + description?: string + docsUrl?: string + hasAnySet: boolean + name: string + primary: [string, EnvVarInfo] +} diff --git a/apps/desktop/src/app/settings/env-credentials.tsx b/apps/desktop/src/app/settings/env-credentials.tsx index 867efac7e..f0ea858ad 100644 --- a/apps/desktop/src/app/settings/env-credentials.tsx +++ b/apps/desktop/src/app/settings/env-credentials.tsx @@ -1,16 +1,10 @@ import { useEffect, useState } from 'react' -import { Button } from '@/components/ui/button' -import { Codicon } from '@/components/ui/codicon' -import { Input } from '@/components/ui/input' -import { Tip } from '@/components/ui/tooltip' import { deleteEnvVar, getEnvVars, revealEnvVar, setEnvVar } from '@/hermes' -import { Check, Eye, EyeOff, type IconComponent, Save, Trash2 } from '@/lib/icons' -import { cn } from '@/lib/utils' +import { type IconComponent } from '@/lib/icons' import { notify, notifyError } from '@/store/notifications' import type { EnvVarInfo } from '@/types/hermes' -import { CONTROL_TEXT } from './constants' import { asText, includesQuery, redactedValue, withoutKey } from './helpers' import { Pill } from './primitives' import type { EnvRowProps } from './types' @@ -33,143 +27,6 @@ export function filterEnv(info: EnvVarInfo, key: string, q: string, cat: string, ) } -function EnvActions({ - varKey, - info, - saving, - onEdit, - onClear, - onReveal, - isRevealed, - showReveal = true -}: EnvActionsProps) { - return ( -
- {info.url && ( - - )} - {info.is_set && showReveal && ( - - - - )} - - {info.is_set && ( - - - - )} -
- ) -} - -export function EnvVarRow({ - varKey, - info, - edits, - revealed, - saving, - setEdits, - onSave, - onClear, - onReveal, - compact = false -}: EnvRowProps) { - const isEditing = edits[varKey] !== undefined - const isRevealed = revealed[varKey] !== undefined - const value = isRevealed ? revealed[varKey] : info.redacted_value - const startEdit = () => setEdits(c => ({ ...c, [varKey]: '' })) - - if (compact && !isEditing) { - return ( -
-
-
{varKey}
-
{info.description}
-
- -
- ) - } - - return ( -
-
-
-
- {varKey} - - {info.is_set && } - {info.is_set ? 'Set' : 'Not set'} - -
-

{info.description}

-
- -
- - {!isEditing && info.is_set && ( -
- {value || '---'} -
- )} - - {isEditing && ( -
- setEdits(c => ({ ...c, [varKey]: e.target.value }))} - placeholder={info.is_set ? 'Replace current value' : 'Enter value'} - type={info.is_password ? 'password' : 'text'} - value={edits[varKey]} - /> - - -
- )} -
- ) -} - export function SettingsCategoryHeading({ count, icon: Icon, title }: CategoryHeadingProps) { return (
@@ -330,17 +187,6 @@ interface CategoryHeadingProps { title: string } -interface EnvActionsProps { - varKey: string - info: EnvVarInfo - saving: string | null - onEdit: () => void - onClear: (key: string) => void - onReveal: (key: string) => void - isRevealed: boolean - showReveal?: boolean -} - interface UseEnvCredentials { rowProps: Omit saveValue: (key: string, value: string) => Promise<{ message?: string; ok: boolean }> diff --git a/apps/desktop/src/app/settings/env-var-actions-menu.tsx b/apps/desktop/src/app/settings/env-var-actions-menu.tsx new file mode 100644 index 000000000..709d3aee9 --- /dev/null +++ b/apps/desktop/src/app/settings/env-var-actions-menu.tsx @@ -0,0 +1,130 @@ +import type * as React from 'react' + +import { Button } from '@/components/ui/button' +import { Codicon } from '@/components/ui/codicon' +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuSeparator, + DropdownMenuTrigger +} from '@/components/ui/dropdown-menu' +import { Eye, EyeOff, ExternalLink, Trash2 } from '@/lib/icons' +import { triggerHaptic } from '@/lib/haptics' +import { cn } from '@/lib/utils' + +interface EnvVarActionsMenuProps + extends Pick, 'align' | 'sideOffset'> { + children: React.ReactNode + clearDisabled?: boolean + docsUrl?: string | null + isRevealed?: boolean + isSet: boolean + label: string + onClear?: () => void + onEdit: () => void + onReveal?: () => void + showReveal?: boolean +} + +export function EnvVarActionsMenu({ + align = 'end', + children, + clearDisabled = false, + docsUrl, + isRevealed = false, + isSet, + label, + onClear, + onEdit, + onReveal, + showReveal = true, + sideOffset = 6 +}: EnvVarActionsMenuProps) { + const hasClear = isSet && onClear + const hasReveal = isSet && showReveal && onReveal + const hasDocs = Boolean(docsUrl?.trim()) + + return ( + + {children} + + {hasDocs && ( + { + event.preventDefault() + triggerHaptic('selection') + window.open(docsUrl!, '_blank', 'noopener,noreferrer') + }} + > + + Docs + + )} + + {hasReveal && ( + { + triggerHaptic('selection') + onReveal() + }} + > + {isRevealed ? : } + {isRevealed ? 'Hide value' : 'Reveal value'} + + )} + + { + triggerHaptic('selection') + onEdit() + }} + > + + {isSet ? 'Replace' : 'Set'} + + + {hasClear && ( + <> + + { + triggerHaptic('warning') + onClear() + }} + variant="destructive" + > + + Clear + + + )} + + + ) +} + +interface EnvVarActionsTriggerProps extends Omit, 'size' | 'variant'> { + label: string +} + +export function EnvVarActionsTrigger({ className, label, ...props }: EnvVarActionsTriggerProps) { + return ( + + ) +} diff --git a/apps/desktop/src/app/settings/helpers.test.ts b/apps/desktop/src/app/settings/helpers.test.ts index 097b9cfed..ff793e4a0 100644 --- a/apps/desktop/src/app/settings/helpers.test.ts +++ b/apps/desktop/src/app/settings/helpers.test.ts @@ -2,7 +2,7 @@ import { describe, expect, it } from 'vitest' import type { HermesConfigRecord } from '@/types/hermes' -import { getNested, providerGroup, setNested } from './helpers' +import { getNested, providerGroup, setNested, stripToolsetLabel, toolsetDisplayLabel } from './helpers' describe('settings helpers', () => { it('reads and writes nested config paths', () => { @@ -21,6 +21,26 @@ describe('settings helpers', () => { expect(({} as Record).polluted).toBeUndefined() }) + describe('stripToolsetLabel', () => { + it('removes leading emoji prefixes from registry labels', () => { + expect(stripToolsetLabel('⏰ Cron Jobs')).toBe('Cron Jobs') + expect(stripToolsetLabel('⚡ Code Execution')).toBe('Code Execution') + expect(stripToolsetLabel('❓ Clarifying Questions')).toBe('Clarifying Questions') + expect(stripToolsetLabel('🌐 Browser Automation')).toBe('Browser Automation') + expect(stripToolsetLabel('🎨 Image Generation')).toBe('Image Generation') + }) + + it('leaves plain titles unchanged', () => { + expect(stripToolsetLabel('Terminal & Processes')).toBe('Terminal & Processes') + }) + }) + + describe('toolsetDisplayLabel', () => { + it('strips emoji from toolset rows', () => { + expect(toolsetDisplayLabel({ name: 'cronjob', label: '⏰ Cron Jobs' })).toBe('Cron Jobs') + }) + }) + describe('providerGroup', () => { it('maps a provider env var to its labeled group', () => { expect(providerGroup('XAI_API_KEY')).toBe('xAI') diff --git a/apps/desktop/src/app/settings/helpers.ts b/apps/desktop/src/app/settings/helpers.ts index 1c4f61f9a..d08bc5a60 100644 --- a/apps/desktop/src/app/settings/helpers.ts +++ b/apps/desktop/src/app/settings/helpers.ts @@ -8,6 +8,13 @@ export const includesQuery = (v: unknown, q: string) => asText(v).toLowerCase(). export const prettyName = (v: string) => v.replace(/_/g, ' ').replace(/\b\w/g, c => c.toUpperCase()) +/** Strip leading emoji from toolset titles (CLI registry prefixes labels with icons). */ +export const stripToolsetLabel = (label: string): string => + label.replace(/^[\p{Emoji}\p{Extended_Pictographic}\s]+/u, '').trim() || label + +export const toolsetDisplayLabel = (toolset: Pick): string => + stripToolsetLabel(asText(toolset.label || toolset.name)) + export const toolNames = (t: ToolsetInfo) => (Array.isArray(t.tools) ? t.tools.map(asText).filter(Boolean) : []) export const withoutKey = (record: Record, key: string) => { diff --git a/apps/desktop/src/app/settings/index.tsx b/apps/desktop/src/app/settings/index.tsx index 864b46e37..47c18df26 100644 --- a/apps/desktop/src/app/settings/index.tsx +++ b/apps/desktop/src/app/settings/index.tsx @@ -4,7 +4,7 @@ import { useRef } from 'react' import { Tip } from '@/components/ui/tooltip' import { getHermesConfigDefaults, getHermesConfigRecord, saveHermesConfig } from '@/hermes' import { triggerHaptic } from '@/lib/haptics' -import { Archive, Globe, Info, KeyRound, Sparkles, Wrench, Zap } from '@/lib/icons' +import { Archive, Globe, Info, KeyRound, Settings2, Sparkles, Wrench, Zap } from '@/lib/icons' import { notifyError } from '@/store/notifications' import { useRouteEnumParam } from '../hooks/use-route-enum-param' @@ -17,7 +17,7 @@ import { AppearanceSettings } from './appearance-settings' import { ConfigSettings } from './config-settings' import { SECTIONS } from './constants' import { GatewaySettings } from './gateway-settings' -import { KeysSettings } from './keys-settings' +import { KEYS_VIEWS, KeysSettings, type KeysView } from './keys-settings' import { McpSettings } from './mcp-settings' import { PROVIDER_VIEWS, ProvidersSettings, type ProviderView } from './providers-settings' import { SessionsSettings } from './sessions-settings' @@ -38,12 +38,18 @@ export function SettingsView({ gateway, onClose, onConfigSaved, onMainModelChang // Providers subnav (Accounts vs API keys) lives in its own param so each // sub-view is deep-linkable and survives a refresh. const [providerView, setProviderView] = useRouteEnumParam('pview', PROVIDER_VIEWS, 'accounts') + const [keysView, setKeysView] = useRouteEnumParam('kview', KEYS_VIEWS, 'tools') const openProviderView = (view: ProviderView) => { setActiveView('providers') setProviderView(view) } + const openKeysView = (view: KeysView) => { + setActiveView('keys') + setKeysView(view) + } + const importInputRef = useRef(null) const exportConfig = async () => { @@ -130,6 +136,24 @@ export function SettingsView({ gateway, onClose, onConfigSaved, onMainModelChang label="Tools & Keys" onClick={() => setActiveView('keys')} /> + {activeView === 'keys' && ( +
+ openKeysView('tools')} + /> + openKeysView('settings')} + /> +
+ )} ) : activeView === 'keys' ? ( - + ) : activeView === 'mcp' ? ( ) : ( diff --git a/apps/desktop/src/app/settings/keys-settings.tsx b/apps/desktop/src/app/settings/keys-settings.tsx index a09950f1c..89545acc4 100644 --- a/apps/desktop/src/app/settings/keys-settings.tsx +++ b/apps/desktop/src/app/settings/keys-settings.tsx @@ -1,155 +1,83 @@ -import { useMemo, useState } from 'react' +import { useEffect, useMemo, useState } from 'react' -import { Settings2, Wrench } from '@/lib/icons' -import { cn } from '@/lib/utils' import type { EnvVarInfo } from '@/types/hermes' -import { EnvVarRow, useEnvCredentials } from './env-credentials' +import { CredentialKeyCard, credentialPlaceholder, credentialRowLabel } from './credential-key-ui' +import { useEnvCredentials } from './env-credentials' import { asText } from './helpers' import { LoadingState, SettingsContent } from './primitives' +// Sub-views surfaced as sidebar subnav under Tools & Keys (see settings/index.tsx). +export const KEYS_VIEWS = ['tools', 'settings'] as const + +export type KeysView = (typeof KEYS_VIEWS)[number] + // Providers live on their own page; messaging-platform credentials live on the // dedicated Messaging page (and are hidden here via `channel_managed`). This // view covers tool API keys plus server/setting env vars (API server, webhook, -// gateway), which fold into the Settings tab. -const KEY_TABS = [ - { icon: Wrench, id: 'tool', label: 'Tools' }, - { icon: Settings2, id: 'setting', label: 'Settings' } -] as const +// gateway), which fold into the Settings subnav. -type KeyCategoryId = (typeof KEY_TABS)[number]['id'] - -const CATEGORY_LABELS: Record = { - setting: 'Settings', - tool: 'Tools' +// Backend categories that surface under each subnav. Platform credentials use the +// `messaging` category but are flagged ``channel_managed`` and configured on +// the Messaging page; only gateway-wide ``messaging`` rows (e.g. GATEWAY_PROXY) +// appear here alongside ``setting``. +const VIEW_CATEGORIES: Record = { + settings: ['setting', 'messaging'], + tools: ['tool'] } -// Backend categories that surface under each tab. Server/gateway vars carry the -// `messaging` category server-side but belong with general settings here, since -// the platform-credential half of `messaging` is owned by the Messaging page. -const TAB_CATEGORIES: Record = { - setting: ['setting', 'messaging'], - tool: ['tool'] -} - -function tabForCategory(category: string): KeyCategoryId | null { - for (const tab of KEY_TABS) { - if (TAB_CATEGORIES[tab.id].includes(category)) { - return tab.id - } - } - - return null -} - -function CategoryTabs({ - active, - counts, - onSelect -}: { - active: KeyCategoryId - counts: Record - onSelect: (id: KeyCategoryId) => void -}) { - return ( -
- {KEY_TABS.map(tab => { - const isActive = active === tab.id - const count = counts[tab.id] - - return ( - - ) - })} -
- ) -} - -export function KeysSettings() { +export function KeysSettings({ view }: KeysSettingsProps) { const { rowProps, vars } = useEnvCredentials() - const [activeCategory, setActiveCategory] = useState('tool') + const [openKey, setOpenKey] = useState(null) + + useEffect(() => { + setOpenKey(null) + }, [view]) const groups = useMemo(() => { if (!vars) { return [] } - return KEY_TABS.map(t => t.id).flatMap(tab => { - const cats = TAB_CATEGORIES[tab] + return KEYS_VIEWS.flatMap(v => { + const cats = VIEW_CATEGORIES[v] const entries = Object.entries(vars) .filter(([, info]) => !info.channel_managed && cats.includes(asText(info.category))) .sort(([a], [b]) => a.localeCompare(b)) - return entries.length === 0 ? [] : [{ category: tab, label: CATEGORY_LABELS[tab], entries }] + return entries.length === 0 ? [] : [{ category: v, entries }] }) }, [vars]) - // Tab badge counts reflect how many keys are set per tab. Channel-managed - // credentials are owned by the Messaging page and excluded here. - const categoryCounts = useMemo>(() => { - const counts: Record = { setting: 0, tool: 0 } - - if (!vars) { - return counts - } - - for (const info of Object.values(vars)) { - if (!info.is_set || info.channel_managed) { - continue - } - - const tab = tabForCategory(asText(info.category)) - - if (tab) { - counts[tab] += 1 - } - } - - return counts - }, [vars]) - if (!vars) { return } - const visible = groups.filter(g => g.category === activeCategory) + const visible = groups.filter(g => g.category === view) return ( - - {visible.map(group => ( -
-
- {group.entries.map(([key, info]: [string, EnvVarInfo]) => ( - - ))} -
-
+
+ {group.entries.map(([key, info]: [string, EnvVarInfo]) => { + const label = credentialRowLabel(key, info) + + return ( + setOpenKey(key)} + onToggle={() => setOpenKey(prev => (prev === key ? null : key))} + placeholder={credentialPlaceholder(key, info, label)} + rowProps={rowProps} + varKey={key} + /> + ) + })} +
))} {visible.length === 0 && ( @@ -160,3 +88,7 @@ export function KeysSettings() {
) } + +interface KeysSettingsProps { + view: KeysView +} diff --git a/apps/desktop/src/app/settings/providers-settings.tsx b/apps/desktop/src/app/settings/providers-settings.tsx index a29f5440a..413ebd282 100644 --- a/apps/desktop/src/app/settings/providers-settings.tsx +++ b/apps/desktop/src/app/settings/providers-settings.tsx @@ -1,5 +1,5 @@ import { useStore } from '@nanostores/react' -import { type ChangeEvent, type KeyboardEvent, useEffect, useMemo, useState } from 'react' +import { useEffect, useMemo, useState } from 'react' import { FEATURED_ID, @@ -9,43 +9,25 @@ import { sortProviders } from '@/components/desktop-onboarding-overlay' import { Button } from '@/components/ui/button' -import { Input } from '@/components/ui/input' import { listOAuthProviders } from '@/hermes' -import { ChevronDown, ExternalLink, KeyRound, Loader2, Save } from '@/lib/icons' +import { ChevronDown, KeyRound } from '@/lib/icons' import { cn } from '@/lib/utils' import { $desktopOnboarding, startManualProviderOAuth } from '@/store/onboarding' import type { EnvVarInfo, OAuthProvider } from '@/types/hermes' +import { isKeyVar, ProviderKeyRows } from './credential-key-ui' import { SettingsCategoryHeading, useEnvCredentials } from './env-credentials' -import { providerGroup, providerMeta, providerPriority, withoutKey } from './helpers' +import { providerGroup, providerMeta, providerPriority } from './helpers' import { LoadingState, SettingsContent } from './primitives' -import type { EnvRowProps } from './types' // Sub-views surfaced as a sidebar subnav: account sign-in vs raw API keys. export const PROVIDER_VIEWS = ['accounts', 'keys'] as const export type ProviderView = (typeof PROVIDER_VIEWS)[number] -const isKeyVar = (key: string, info: EnvVarInfo) => info.is_password || /(?:_API_KEY|_TOKEN|_KEY)$/.test(key) - -const friendlyFieldLabel = (key: string, info: EnvVarInfo) => - info.description?.trim() || - key - .replace(/_/g, ' ') - .toLowerCase() - .replace(/\b\w/g, c => c.toUpperCase()) - -// Advanced (non-primary) fields are mostly base-URL / endpoint overrides, not -// keys — so don't reuse the "Paste key" placeholder that makes them read as a -// duplicate key input. URL-ish vars get a URL hint; everything else stays optional. -const advancedPlaceholder = (key: string, info: EnvVarInfo): string => - isKeyVar(key, info) ? 'Paste key' : /URL$/i.test(key) ? 'https://…' : 'Optional' - -// Group the env catalog by provider so the keys view can render one collapsible -// row per vendor: a primary key field inline, with any secondary / advanced vars -// (base URL overrides, alt tokens) revealed when the row is focused/expanded. -// Mirrors what Cursor's API-keys section does. Groups without a key field (e.g. -// Nous Portal's lone base-URL override) and the "Other" bucket are skipped. +// Group the env catalog by provider — one ListRow per vendor plus optional +// advanced overrides (base URL, region, etc.). Groups without a key field and +// the "Other" bucket are skipped. function buildProviderKeyGroups(vars: Record): ProviderKeyGroup[] { const buckets = new Map() @@ -94,228 +76,6 @@ function buildProviderKeyGroups(vars: Record): ProviderKeyGr return groups.sort((a, b) => a.priority - b.priority || a.name.localeCompare(b.name)) } -// A single credential field: a set key shows as a filled read-only input -// (redacted value) that edits in place on click. Save appears once typed; a set -// key also offers Remove, and Esc cancels without closing the overlay. -function KeyField({ - compact = false, - info, - label, - placeholder, - rowProps, - varKey -}: { - compact?: boolean - info: EnvVarInfo - label?: string - placeholder?: string - rowProps: KeyRowProps - varKey: string -}) { - const { edits, onClear, onSave, saving, setEdits } = rowProps - const editing = edits[varKey] !== undefined - const draft = edits[varKey] ?? '' - const dirty = draft.trim().length > 0 - const busy = saving === varKey - const masked = info.redacted_value ?? '••••••••' - const startEdit = () => setEdits(c => ({ ...c, [varKey]: '' })) - const cancel = () => setEdits(c => withoutKey(c, varKey)) - const update = (e: ChangeEvent) => setEdits(c => ({ ...c, [varKey]: e.target.value })) - - // Enter saves; Esc cancels in place without bubbling to the overlay's window - // Escape listener (which would otherwise close the whole settings panel). - const keydown = (e: KeyboardEvent) => { - if (e.key === 'Enter' && dirty) { - void onSave(varKey) - } else if (e.key === 'Escape' && editing) { - e.preventDefault() - e.stopPropagation() - cancel() - } - } - - // Advanced overrides render quieter (xs) than the primary key field so the key - // stays the visual anchor. Padding-driven sizing — no fixed heights. - const inputSize = compact ? 'xs' : 'sm' - const editType = info.is_password ? 'password' : 'text' - - // A set value reads as a single filled, read-only field (showing the redacted - // value). Clicking it drops into edit mode in place — no Replace/Cancel chrome. - const control = - info.is_set && !editing ? ( - - ) : ( -
-
- - {dirty && ( - - )} -
- {editing && ( -
- {info.is_set && ( - <> - - or - - )} - esc to cancel -
- )} -
- ) - - // Standard stacked form field: small muted label above, input below. Same shape - // for the primary key and every advanced override — just smaller when compact. - // Empty advanced inputs (not labels) fade back, brightening on hover/focus/set. - const dim = compact && !info.is_set - - return ( -
- {label && ( - - )} - {dim ? ( -
{control}
- ) : ( - control - )} -
- ) -} - -function ProviderKeyCard({ - expanded, - group, - onExpand, - onToggle, - rowProps -}: { - expanded: boolean - group: ProviderKeyGroup - onExpand: () => void - onToggle: () => void - rowProps: KeyRowProps -}) { - // Expandable when there's anything to reveal — advanced overrides and/or a - // "Get a key" docs link (which lives at the bottom of the expanded panel). - const expandable = group.advanced.length > 0 || Boolean(group.docsUrl) - - return ( -
{ - if (e.key === 'Enter' || e.key === ' ') { - e.preventDefault() - onToggle() - } - } - : undefined - } - role={expandable ? 'button' : undefined} - tabIndex={expandable ? 0 : undefined} - > -
-
- - {group.name} - {expandable && ( - - )} -
-
e.stopPropagation()} - onFocus={() => { - if (expandable && !expanded) { - onExpand() - } - }} - > - -
-
- {expandable && expanded && ( -
e.stopPropagation()}> - {group.advanced.map(([key, info]) => ( - - ))} - {group.docsUrl && ( - e.stopPropagation()} - rel="noreferrer" - target="_blank" - > - Get a key - - - )} -
- )} -
- ) -} - // Deliberately a near-1:1 replica of the first-run onboarding picker // (`Picker` in desktop-onboarding-overlay): same recommended card, same // provider rows, same "Other providers" disclosure, same OpenRouter quick-key @@ -405,7 +165,6 @@ function NoProviderKeys() { export function ProvidersSettings({ onViewChange, view }: ProvidersSettingsProps) { const { rowProps, vars } = useEnvCredentials() const [oauthProviders, setOauthProviders] = useState([]) - // Single-open accordion for the per-provider "advanced options" panels. const [openProvider, setOpenProvider] = useState(null) // The onboarding overlay owns the OAuth flow. Watch its `manual` flag so we // re-read connection state when the user finishes (or dismisses) a sign-in @@ -452,7 +211,7 @@ export function ProvidersSettings({ onViewChange, view }: ProvidersSettingsProps {keyGroups.length > 0 ? (
{keyGroups.map(group => ( - - interface ProviderKeyGroup { advanced: [string, EnvVarInfo][] description?: string diff --git a/apps/desktop/src/app/settings/toolset-config-panel.tsx b/apps/desktop/src/app/settings/toolset-config-panel.tsx index 986176982..d766f9267 100644 --- a/apps/desktop/src/app/settings/toolset-config-panel.tsx +++ b/apps/desktop/src/app/settings/toolset-config-panel.tsx @@ -3,13 +3,13 @@ import { useCallback, useEffect, useMemo, useState } from 'react' import { PageLoader } from '@/components/page-loader' import { Button } from '@/components/ui/button' import { Input } from '@/components/ui/input' -import { Tip } from '@/components/ui/tooltip' import { deleteEnvVar, getToolsetConfig, revealEnvVar, selectToolsetProvider, setEnvVar } from '@/hermes' -import { Check, ExternalLink, Eye, EyeOff, Loader2, Save, Trash2 } from '@/lib/icons' +import { Check, Loader2, Save } from '@/lib/icons' import { cn } from '@/lib/utils' import { notify, notifyError } from '@/store/notifications' import type { ToolEnvVar, ToolProvider, ToolsetConfig } from '@/types/hermes' +import { EnvVarActionsMenu, EnvVarActionsTrigger } from './env-var-actions-menu' import { Pill } from './primitives' interface ToolsetConfigPanelProps { @@ -109,33 +109,20 @@ function EnvVarField({ envVar, isSet, onSaved, onCleared }: EnvVarFieldProps) {

{envVar.prompt}

)}
-
- {envVar.url && ( - - )} - {isSet && ( - - - - )} - - {isSet && ( - - - - )} -
+ {!editing && ( + void handleClear()} + onEdit={() => setEditing(true)} + onReveal={() => void handleReveal()} + > + event.stopPropagation()} /> + + )}
{isSet && revealed !== null && ( diff --git a/apps/desktop/src/app/skills/index.test.tsx b/apps/desktop/src/app/skills/index.test.tsx index 1243cc1d8..9f195f786 100644 --- a/apps/desktop/src/app/skills/index.test.tsx +++ b/apps/desktop/src/app/skills/index.test.tsx @@ -74,6 +74,17 @@ describe('SkillsView toolset management', () => { await waitFor(() => expect(toggleToolset).toHaveBeenCalledWith('web', false)) }) + it('renders toolset titles without leading emoji', async () => { + getToolsets.mockResolvedValue([ + toolset({ name: 'cronjob', label: '⏰ Cron Jobs', description: 'cron tools' }) + ]) + + await renderSkills() + + expect(screen.getByText('Cron Jobs')).toBeTruthy() + expect(screen.queryByText(/⏰/)).toBeNull() + }) + it('keeps the configured pill alongside the switch', async () => { await renderSkills() diff --git a/apps/desktop/src/app/skills/index.tsx b/apps/desktop/src/app/skills/index.tsx index 74cbc53d7..7661efef9 100644 --- a/apps/desktop/src/app/skills/index.tsx +++ b/apps/desktop/src/app/skills/index.tsx @@ -14,7 +14,7 @@ import { useRefreshHotkey } from '../hooks/use-refresh-hotkey' import { useRouteEnumParam } from '../hooks/use-route-enum-param' import { PAGE_INSET_X } from '../layout-constants' import { PageSearchShell } from '../page-search-shell' -import { asText, includesQuery, prettyName, toolNames } from '../settings/helpers' +import { asText, includesQuery, prettyName, toolNames, toolsetDisplayLabel } from '../settings/helpers' import { ToolsetConfigPanel } from '../settings/toolset-config-panel' import type { SetStatusbarItemGroup } from '../shell/statusbar-controls' @@ -52,14 +52,17 @@ function filteredToolsets(toolsets: ToolsetInfo[], query: string): ToolsetInfo[] return true } + const label = toolsetDisplayLabel(toolset) + return ( includesQuery(toolset.name, q) || + includesQuery(label, q) || includesQuery(toolset.label, q) || includesQuery(toolset.description, q) || toolNames(toolset).some(name => includesQuery(name, q)) ) }) - .sort((a, b) => asText(a.label || a.name).localeCompare(asText(b.label || b.name))) + .sort((a, b) => toolsetDisplayLabel(a).localeCompare(toolsetDisplayLabel(b))) } interface SkillsViewProps extends React.ComponentProps<'section'> { @@ -167,10 +170,10 @@ export function SkillsView({ setStatusbarItemGroup: _setStatusbarItemGroup, ...p notify({ kind: 'success', title: enabled ? 'Toolset enabled' : 'Toolset disabled', - message: `${asText(toolset.label || toolset.name)} applies to new sessions.` + message: `${toolsetDisplayLabel(toolset)} applies to new sessions.` }) } catch (err) { - notifyError(err, `Failed to update ${asText(toolset.label || toolset.name)}`) + notifyError(err, `Failed to update ${toolsetDisplayLabel(toolset)}`) } finally { setSavingToolset(null) } @@ -264,7 +267,7 @@ export function SkillsView({ setStatusbarItemGroup: _setStatusbarItemGroup, ...p
{visibleToolsets.map(toolset => { const tools = toolNames(toolset) - const label = asText(toolset.label || toolset.name) + const label = toolsetDisplayLabel(toolset) const expanded = expandedToolset === toolset.name return ( diff --git a/apps/desktop/src/app/types.ts b/apps/desktop/src/app/types.ts index d6164a70d..1ad9e3be9 100644 --- a/apps/desktop/src/app/types.ts +++ b/apps/desktop/src/app/types.ts @@ -25,6 +25,14 @@ export interface SlashExecResponse { warning?: string } +export interface SessionTitleResponse { + title?: string + // True when the session row isn't persisted yet and the title was queued + // to be applied on the first turn (see tui_gateway session.title handler). + pending?: boolean + session_key?: string +} + export interface ExecCommandDispatchResponse { type: 'exec' | 'plugin' output?: string diff --git a/apps/desktop/src/components/assistant-ui/thread.tsx b/apps/desktop/src/components/assistant-ui/thread.tsx index 513aaabca..96c0b71a5 100644 --- a/apps/desktop/src/components/assistant-ui/thread.tsx +++ b/apps/desktop/src/components/assistant-ui/thread.tsx @@ -438,7 +438,7 @@ const ReasoningAccordionGroup: FC<{ children?: ReactNode; endIndex: number; star s.thread.isRunning && s.message.status?.type === 'running' && s.message.parts - .slice(Math.max(0, startIndex), Math.min(s.message.parts.length, endIndex)) + .slice(Math.max(0, startIndex)) .some(p => p?.type === 'reasoning' && p.status?.type !== 'complete') ) diff --git a/apps/desktop/src/components/boot-failure-overlay.tsx b/apps/desktop/src/components/boot-failure-overlay.tsx index 943981302..b8cc2205e 100644 --- a/apps/desktop/src/components/boot-failure-overlay.tsx +++ b/apps/desktop/src/components/boot-failure-overlay.tsx @@ -2,11 +2,23 @@ import { useStore } from '@nanostores/react' import { useEffect, useState } from 'react' import { Button } from '@/components/ui/button' -import { AlertTriangle, FileText, Loader2, RefreshCw, Wrench } from '@/lib/icons' +import type { DesktopConnectionConfig } from '@/global' +import { AlertTriangle, FileText, Loader2, LogIn, RefreshCw, Wrench } from '@/lib/icons' import { $desktopBoot } from '@/store/boot' +import { notify, notifyError } from '@/store/notifications' import { $desktopOnboarding } from '@/store/onboarding' -type BusyAction = 'local' | 'repair' | 'retry' | null +import type { RemoteReauth } from './boot-failure-reauth' +import { deriveProviderShape, isRemoteReauthFailure, signInLabel } from './boot-failure-reauth' + +type BusyAction = 'local' | 'repair' | 'retry' | 'signin' | null + +// A remote gateway whose access cookie has lapsed (e.g. the dashboard +// restarted on the remote box) boots into this overlay with a reauth-shaped +// error. The local-recovery buttons (Retry resets the local bootstrap latch; +// Repair re-runs the installer) are no-ops for that case — the only fix is to +// re-establish the remote session. The detection + copy helpers live in +// ./boot-failure-reauth so they're unit-testable without a React render. // Recovery surface for a hard boot failure (gateway never came up, backend // exited during startup, bootstrap latched, …). Without this the app shell @@ -18,6 +30,7 @@ export function BootFailureOverlay() { const [busy, setBusy] = useState(null) const [logs, setLogs] = useState([]) const [showLogs, setShowLogs] = useState(false) + const [remoteReauth, setRemoteReauth] = useState(null) const visible = Boolean(boot.error) && !boot.running // While first-run onboarding owns the picker/flow we let it surface its own @@ -36,6 +49,59 @@ export function BootFailureOverlay() { .catch(() => undefined) }, [visible]) + // Resolve whether this boot failure is a remote-gateway reauth so we can + // offer the actionable "Sign in" path instead of the local-only recovery + // buttons. Runs whenever the overlay becomes visible. + useEffect(() => { + if (!visible) { + setRemoteReauth(null) + + return + } + + let cancelled = false + + void (async () => { + const desktop = window.hermesDesktop + + if (!desktop?.getConnectionConfig) { + return + } + + let config: DesktopConnectionConfig + + try { + config = await desktop.getConnectionConfig() + } catch { + return + } + + if (cancelled || !isRemoteReauthFailure(config)) { + return + } + + // Best-effort probe for the provider shape so the button copy matches + // what the user will see in the login window (password form vs OAuth + // redirect). Probe failure just keeps the generic copy. + let shape = deriveProviderShape(null) + + try { + const probe = await desktop.probeConnectionConfig(config.remoteUrl) + shape = deriveProviderShape(probe?.providers) + } catch { + // Generic copy is fine. + } + + if (!cancelled) { + setRemoteReauth({ url: config.remoteUrl, ...shape }) + } + })() + + return () => { + cancelled = true + } + }, [visible]) + if (!visible || suppressed) { return null } @@ -59,8 +125,44 @@ export function BootFailureOverlay() { setBusy(null) } + // Open the gateway's login window (renders the username/password form for a + // basic gateway, or the OAuth redirect otherwise — the desktop drives both + // through the same window). On a successful sign-in the session cookie is + // re-established in the persistent partition; reload so boot re-runs and the + // reconnect now mints a ticket against a live session. + const signInRemote = async () => { + if (!remoteReauth) { + return + } + + setBusy('signin') + + try { + const result = await window.hermesDesktop?.oauthLoginConnectionConfig(remoteReauth.url) + + if (result?.connected) { + notify({ kind: 'success', title: 'Signed in', message: 'Reconnecting to the remote gateway…' }) + window.location.reload() + + return + } + + notify({ + kind: 'warning', + title: 'Sign-in incomplete', + message: 'The login window closed before authentication finished.' + }) + } catch (err) { + notifyError(err, 'Sign-in failed') + } finally { + setBusy(null) + } + } + const openLogs = () => void window.hermesDesktop?.revealLogs().catch(() => undefined) + const label = signInLabel(remoteReauth) + return (
@@ -69,10 +171,13 @@ export function BootFailureOverlay() {
-

Hermes couldn't start

+

+ {remoteReauth ? 'Remote gateway sign-in required' : "Hermes couldn't start"} +

- The background gateway didn't come up. Try one of the recovery steps below — nothing here deletes your - chats or settings. + {remoteReauth + ? 'Your remote gateway session has expired (the dashboard likely restarted). Sign in again to reconnect — nothing here deletes your chats or settings.' + : "The background gateway didn't come up. Try one of the recovery steps below — nothing here deletes your chats or settings."}

@@ -84,14 +189,23 @@ export function BootFailureOverlay() {
- - + {remoteReauth ? ( + + ) : ( + + )} + {!remoteReauth ? ( + + ) : null}

- Repair re-runs the installer and can take a few minutes on a fresh machine. + {remoteReauth + ? 'Opens the gateway login window. Use “Use local gateway” to switch to the bundled backend instead.' + : 'Repair re-runs the installer and can take a few minutes on a fresh machine.'}

diff --git a/apps/desktop/src/components/boot-failure-reauth.test.ts b/apps/desktop/src/components/boot-failure-reauth.test.ts new file mode 100644 index 000000000..21d7f8229 --- /dev/null +++ b/apps/desktop/src/components/boot-failure-reauth.test.ts @@ -0,0 +1,99 @@ +import { describe, expect, it } from 'vitest' + +import type { DesktopConnectionConfig } from '@/global' + +import { deriveProviderShape, isRemoteReauthFailure, signInLabel } from './boot-failure-reauth' + +function config(overrides: Partial = {}): DesktopConnectionConfig { + return { + envOverride: false, + mode: 'remote', + remoteAuthMode: 'oauth', + remoteOauthConnected: false, + remoteTokenPreview: null, + remoteTokenSet: false, + remoteUrl: 'https://box:9119', + ...overrides + } +} + +describe('isRemoteReauthFailure', () => { + it('true for a remote, gated, disconnected gateway with a URL', () => { + expect(isRemoteReauthFailure(config())).toBe(true) + }) + + it('false when the oauth session is still connected', () => { + expect(isRemoteReauthFailure(config({ remoteOauthConnected: true }))).toBe(false) + }) + + it('false for a local gateway', () => { + expect(isRemoteReauthFailure(config({ mode: 'local' }))).toBe(false) + }) + + it('false for a token (non-gated) remote gateway', () => { + expect(isRemoteReauthFailure(config({ remoteAuthMode: 'token' }))).toBe(false) + }) + + it('false when there is no remote URL to sign in against', () => { + expect(isRemoteReauthFailure(config({ remoteUrl: '' }))).toBe(false) + }) + + it('false for null/undefined config', () => { + expect(isRemoteReauthFailure(null)).toBe(false) + expect(isRemoteReauthFailure(undefined)).toBe(false) + }) +}) + +describe('deriveProviderShape', () => { + it('generic copy when there are no providers', () => { + expect(deriveProviderShape([])).toEqual({ isPassword: false, providerLabel: 'your identity provider' }) + expect(deriveProviderShape(null)).toEqual({ isPassword: false, providerLabel: 'your identity provider' }) + }) + + it('password shape when the sole provider supports password', () => { + expect( + deriveProviderShape([{ name: 'basic', displayName: 'Username & Password', supportsPassword: true }]) + ).toEqual({ isPassword: true, providerLabel: 'Username & Password' }) + }) + + it('OAuth shape when the provider is a redirect IDP', () => { + expect(deriveProviderShape([{ name: 'nous', displayName: 'Nous Research', supportsPassword: false }])).toEqual({ + isPassword: false, + providerLabel: 'Nous Research' + }) + }) + + it('mixed deployment keeps generic OAuth copy (not every provider is password)', () => { + const shape = deriveProviderShape([ + { name: 'basic', displayName: 'Username & Password', supportsPassword: true }, + { name: 'nous', displayName: 'Nous Research', supportsPassword: false } + ]) + + expect(shape.isPassword).toBe(false) + expect(shape.providerLabel).toBe('Username & Password / Nous Research') + }) + + it('falls back to name when displayName is empty', () => { + expect(deriveProviderShape([{ name: 'basic', displayName: '', supportsPassword: true }]).providerLabel).toBe( + 'basic' + ) + }) +}) + +describe('signInLabel', () => { + it('password gateway gets the plain "Sign in to remote gateway" copy', () => { + expect(signInLabel({ url: 'x', isPassword: true, providerLabel: 'Username & Password' })).toBe( + 'Sign in to remote gateway' + ) + }) + + it('OAuth gateway names the provider', () => { + expect(signInLabel({ url: 'x', isPassword: false, providerLabel: 'Nous Research' })).toBe( + 'Sign in with Nous Research' + ) + }) + + it('null reauth falls back to the generic provider phrase', () => { + expect(signInLabel(null)).toBe('Sign in with your identity provider') + }) +}) diff --git a/apps/desktop/src/components/boot-failure-reauth.ts b/apps/desktop/src/components/boot-failure-reauth.ts new file mode 100644 index 000000000..20ac68618 --- /dev/null +++ b/apps/desktop/src/components/boot-failure-reauth.ts @@ -0,0 +1,67 @@ +import type { DesktopAuthProvider, DesktopConnectionConfig } from '@/global' + +// Pure helpers for the boot-failure overlay's remote-reauth branch. Kept out +// of the .tsx so they can be unit-tested without a React/jsdom render (the +// jsx-dev-runtime resolution in this repo's vitest setup is flaky for +// component renders, but these are plain functions). + +export interface RemoteReauth { + url: string + // True when every advertised provider is username/password — drives the + // button copy ("Sign in to remote gateway" vs "Sign in with "), + // mirroring the gateway-settings page. Probe is best-effort. + isPassword: boolean + providerLabel: string +} + +// A remote, gated (oauth-bucket), not-currently-connected gateway is a +// remote-reauth boot failure: the access cookie lapsed (e.g. the remote +// dashboard restarted) and the local-recovery buttons (Retry/Repair) can't +// fix it — only re-establishing the remote session can. A connected oauth +// session, or a token/local gateway, boots for some other reason the +// local-recovery buttons address, so those return false here. +export function isRemoteReauthFailure(config: DesktopConnectionConfig | null | undefined): boolean { + if (!config) { + return false + } + + return ( + config.mode === 'remote' && + config.remoteAuthMode === 'oauth' && + !config.remoteOauthConnected && + Boolean(config.remoteUrl) + ) +} + +// Derive the password flag + display label from the probed providers. A +// gateway is treated as password-style only when EVERY advertised provider +// supports password (a mixed deployment keeps the generic OAuth copy), so the +// button copy matches the login window the user is about to see. +export function deriveProviderShape(providers: DesktopAuthProvider[] | null | undefined): { + isPassword: boolean + providerLabel: string +} { + const list = providers ?? [] + + if (list.length === 0) { + return { isPassword: false, providerLabel: 'your identity provider' } + } + + const isPassword = list.every(p => Boolean(p.supportsPassword)) + + const providerLabel = + list.length === 1 + ? list[0].displayName || list[0].name + : list.map(p => p.displayName || p.name).join(' / ') + + return { isPassword, providerLabel } +} + +// Button copy for the remote sign-in action. +export function signInLabel(reauth: RemoteReauth | null): string { + if (reauth?.isPassword) { + return 'Sign in to remote gateway' + } + + return `Sign in with ${reauth?.providerLabel ?? 'your identity provider'}` +} diff --git a/apps/desktop/src/lib/session-search.test.ts b/apps/desktop/src/lib/session-search.test.ts new file mode 100644 index 000000000..aa40fe59c --- /dev/null +++ b/apps/desktop/src/lib/session-search.test.ts @@ -0,0 +1,58 @@ +import { describe, expect, it } from 'vitest' + +import type { SessionInfo } from '@/types/hermes' + +import { sessionMatchesSearch } from './session-search' + +function makeSession(overrides: Partial = {}): SessionInfo { + return { + archived: false, + cwd: '/home/user/projects/hermes-agent', + ended_at: null, + id: '20260603_090200_abcd12', + input_tokens: 0, + is_active: false, + last_active: 1_000, + message_count: 2, + model: 'claude', + output_tokens: 0, + preview: 'Fix Desktop session search', + source: 'cli', + started_at: 1_000, + title: 'Desktop Search Feature', + tool_call_count: 0, + ...overrides + } +} + +describe('sessionMatchesSearch', () => { + it('matches loaded sessions by full and partial session id', () => { + const session = makeSession() + + expect(sessionMatchesSearch(session, '20260603_090200_abcd12')).toBe(true) + expect(sessionMatchesSearch(session, '090200')).toBe(true) + expect(sessionMatchesSearch(session, 'ABCD12')).toBe(true) + }) + + it('matches projected compression sessions by lineage root id', () => { + const session = makeSession({ + _lineage_root_id: '20260602_235959_root99', + id: '20260603_010000_tip01' + }) + + expect(sessionMatchesSearch(session, 'root99')).toBe(true) + expect(sessionMatchesSearch(session, '20260602')).toBe(true) + }) + + it('preserves title, preview, and workspace matching', () => { + const session = makeSession() + + expect(sessionMatchesSearch(session, 'desktop search')).toBe(true) + expect(sessionMatchesSearch(session, 'session search')).toBe(true) + expect(sessionMatchesSearch(session, 'hermes-agent')).toBe(true) + }) + + it('does not match unrelated queries', () => { + expect(sessionMatchesSearch(makeSession(), 'totally-unrelated')).toBe(false) + }) +}) diff --git a/apps/desktop/src/lib/session-search.ts b/apps/desktop/src/lib/session-search.ts new file mode 100644 index 000000000..b8ee6ebf3 --- /dev/null +++ b/apps/desktop/src/lib/session-search.ts @@ -0,0 +1,19 @@ +import type { SessionInfo } from '@/types/hermes' + +import { sessionTitle } from './chat-runtime' + +export function sessionMatchesSearch(session: SessionInfo, query: string): boolean { + const needle = query.trim().toLowerCase() + + if (!needle) { + return true + } + + return [ + session.id, + session._lineage_root_id ?? '', + sessionTitle(session), + session.preview ?? '', + session.cwd ?? '' + ].some(value => value.toLowerCase().includes(needle)) +} diff --git a/cli.py b/cli.py index 03ed1df00..d429e14fc 100644 --- a/cli.py +++ b/cli.py @@ -7166,7 +7166,11 @@ class HermesCLI: except Exception: pass - # Create the new session with parent link + # Create the new session with parent link. + # Persist a stable ``_branched_from`` marker in model_config so + # list_sessions_rich() can keep the branch visible in /resume and + # /sessions even after the parent is reopened and re-ended with a + # different end_reason (e.g. tui_shutdown overwriting 'branched'). try: self._session_db.create_session( session_id=new_session_id, @@ -7175,6 +7179,7 @@ class HermesCLI: model_config={ "max_iterations": self.max_turns, "reasoning_config": self.reasoning_config, + "_branched_from": parent_session_id, }, parent_session_id=parent_session_id, ) diff --git a/gateway/platforms/base.py b/gateway/platforms/base.py index 89806a739..b8f744851 100644 --- a/gateway/platforms/base.py +++ b/gateway/platforms/base.py @@ -967,14 +967,35 @@ def _media_delivery_denied_paths() -> List[Path]: def _path_under_denied_prefix(resolved: Path) -> bool: - """Return True if ``resolved`` lives under a deny-listed system path.""" + """Return True if ``resolved`` lives under a deny-listed system path. + + One narrow exception: when a denied prefix IS the running user's own home, + the home itself is not treated as denied. ``/root`` is on the system-path + denylist so that a non-root gateway can't deliver another user's home, but + on a root-run gateway ``$HOME=/root`` and the operator's own deliverables + (``/root/work/proposal.docx``) live directly under it. The credential + sub-directories inside home (``~/.ssh``, ``~/.aws``, ...) and Hermes + secrets (``~/.hermes/.env``, ``auth.json``) are *separate, more-specific* + denied paths, so they stay blocked regardless of this exception — it can + only un-block a plain file sitting in the running user's home tree, never a + credential location or another user's home. + """ + try: + home = Path(os.path.expanduser("~")).resolve(strict=False) + except (OSError, RuntimeError, ValueError): + home = None for denied in _media_delivery_denied_paths(): try: resolved_denied = denied.expanduser().resolve(strict=False) except (OSError, RuntimeError, ValueError): continue - if _path_is_within(resolved, resolved_denied) or resolved == resolved_denied: - return True + if not (_path_is_within(resolved, resolved_denied) or resolved == resolved_denied): + continue + # Allow the running user's own home tree; its credential sub-dirs are + # caught by their own (more-specific) denylist entries above. + if home is not None and resolved_denied == home: + continue + return True return False diff --git a/gateway/platforms/matrix.py b/gateway/platforms/matrix.py index cccb4d70e..a649bb91e 100644 --- a/gateway/platforms/matrix.py +++ b/gateway/platforms/matrix.py @@ -2799,11 +2799,11 @@ class MatrixAdapter(BasePlatformAdapter): def _markdown_to_html(self, text: str) -> str: """Convert Markdown to Matrix-compatible HTML (org.matrix.custom.html). - Uses the ``markdown`` library when available (installed with the - ``matrix`` extra). Falls back to a comprehensive regex converter - that handles fenced code blocks, inline code, headers, bold, - italic, strikethrough, links, blockquotes, lists, and horizontal - rules — everything the Matrix HTML spec allows. + Uses the ``markdown`` library (a core dependency) when available. + Falls back to a comprehensive regex converter that handles fenced + code blocks, inline code, headers, bold, italic, strikethrough, + links, blockquotes, lists, and horizontal rules — everything the + Matrix HTML spec allows. """ try: import markdown as _md diff --git a/gateway/run.py b/gateway/run.py index 049b07a80..7887ec23c 100644 --- a/gateway/run.py +++ b/gateway/run.py @@ -8524,18 +8524,14 @@ class GatewayRunner: logger.debug("goal continuation hook failed: %s", _goal_exc) return _agent_result finally: - # If _run_agent replaced the sentinel with a real agent and - # then cleaned it up, this is a no-op. If we exited early - # (exception, command fallthrough, etc.) the sentinel must - # not linger or the session would be permanently locked out. - if self._running_agents.get(_quick_key) is _AGENT_PENDING_SENTINEL: - self._release_running_agent_state(_quick_key) - else: - # Agent path already cleaned _running_agents; make sure - # the paired metadata dicts are gone too. - self._running_agents_ts.pop(_quick_key, None) - if hasattr(self, "_busy_ack_ts"): - self._busy_ack_ts.pop(_quick_key, None) + # Unconditional release covers every exit path. _release_running_agent_state + # is idempotent (pop-on-absent is harmless) and, called without a + # run_generation guard, always clears the slot regardless of which + # generation it holds. This evicts the zombie left when session_reset + # bumps the generation (N -> N+1) mid-flight: gen-N's guarded release + # inside _run_agent returns False, and the old sentinel-only check here + # missed the leftover real agent — locking the session out forever (#28686). + self._release_running_agent_state(_quick_key) async def _prepare_inbound_message_text( self, @@ -10032,6 +10028,12 @@ class GatewayRunner: # Get existing session key session_key = self._session_key_for_source(source) self._invalidate_session_run_generation(session_key, reason="session_reset") + # Evict the running-agent slot now that the generation is bumped. The + # in-flight run's own guarded release (run_generation=old) will return + # False and leave its dead agent behind; clearing here keeps the slot + # from becoming a zombie that silently drops all later messages (#28686). + # Idempotent, so the run's finally calling it again is harmless. + self._release_running_agent_state(session_key) # Snapshot the old entry so on_session_finalize can report the # expiring session id before reset_session() rotates it. @@ -13954,12 +13956,17 @@ class GatewayRunner: parent_session_id = current_entry.session_id - # Create the new session with parent link + # Create the new session with parent link. + # Persist a stable ``_branched_from`` marker in model_config so + # list_sessions_rich() keeps the branch visible in /resume and + # /sessions even after the parent is reopened and re-ended with a + # different end_reason (e.g. tui_shutdown overwriting 'branched'). try: self._session_db.create_session( session_id=new_session_id, source=source.platform.value if source.platform else "gateway", model=(self.config.get("model", {}) or {}).get("default") if isinstance(self.config, dict) else None, + model_config={"_branched_from": parent_session_id}, parent_session_id=parent_session_id, ) except Exception as e: diff --git a/gateway/stream_consumer.py b/gateway/stream_consumer.py index 16d1cecd3..33910c7b4 100644 --- a/gateway/stream_consumer.py +++ b/gateway/stream_consumer.py @@ -530,7 +530,19 @@ class GatewayStreamConsumer: if split_at < _safe_limit // 2: split_at = _safe_limit chunk = self._accumulated[:split_at] - ok = await self._send_or_edit(chunk) + # finalize=True so the adapter applies platform-specific + # rich-text markup (e.g. Telegram MarkdownV2). This + # sealed chunk will never be edited again — _message_id + # is reset to None right below — so it must receive its + # final formatting pass now, or early split messages + # render raw markdown while only the last chunk renders. + # is_turn_final=False: this is the first of several split + # messages, NOT the turn-final answer, so the fresh-final + # path (opt-in fresh_final_after_seconds) must not mark + # the turn delivered on it (#29346 semantics). + ok = await self._send_or_edit( + chunk, finalize=True, is_turn_final=False, + ) if self._fallback_final_send or not ok: # Edit failed (or backed off due to flood control) # while attempting to split an oversized message. diff --git a/hermes_cli/auth.py b/hermes_cli/auth.py index 165410bcd..021905c3e 100644 --- a/hermes_cli/auth.py +++ b/hermes_cli/auth.py @@ -1322,38 +1322,10 @@ def get_provider_auth_state(provider_id: str) -> Optional[Dict[str, Any]]: return _load_provider_state(auth_store, provider_id) -def _active_provider_from_store(auth_store: Dict[str, Any]) -> Optional[str]: - """Return the active provider for a loaded auth store. - - In profile mode, falls back to the global-root ``auth.json`` when the - profile store has no ``active_provider`` set. This mirrors the per-provider - shadowing already used by ``_load_provider_state`` and - ``read_credential_pool``: a named profile that never selected its own - provider still resolves the provider the user authenticated at the global - root (e.g. a Nous OAuth login), so ``model.provider: auto`` works under a - profile. A profile that has its own ``active_provider`` always wins; the - fallback only fires when the profile has none. Returns ``None`` when - neither scope has one. In classic mode ``_load_global_auth_store`` returns - an empty dict, so this is a no-op. See issue #18594 follow-up. - """ - active = auth_store.get("active_provider") - if active: - return active - global_store = _load_global_auth_store() - if global_store: - return global_store.get("active_provider") - return None - - def get_active_provider() -> Optional[str]: - """Return the currently active provider ID from auth store. - - In profile mode this falls back to the global-root ``active_provider`` - when the profile has not selected one of its own — see - ``_active_provider_from_store``. - """ + """Return the currently active provider ID from auth store.""" auth_store = _load_auth_store() - return _active_provider_from_store(auth_store) + return auth_store.get("active_provider") def is_provider_explicitly_configured(provider_id: str) -> bool: @@ -1575,14 +1547,10 @@ def resolve_provider( if explicit_api_key or explicit_base_url: return "openrouter" - # Check auth store for an active OAuth provider. In profile mode this - # honors the global-root active_provider when the profile has none of its - # own, mirroring the credential-pool / provider-state fallbacks so a - # named profile running model.provider: auto can use a globally - # authenticated provider. See issue #18594 follow-up. + # Check auth store for an active OAuth provider try: auth_store = _load_auth_store() - active = _active_provider_from_store(auth_store) + active = auth_store.get("active_provider") if active and active in PROVIDER_REGISTRY: status = get_auth_status(active) if status.get("logged_in"): diff --git a/hermes_cli/config.py b/hermes_cli/config.py index a6948c23f..d0b4493dd 100644 --- a/hermes_cli/config.py +++ b/hermes_cli/config.py @@ -5958,6 +5958,11 @@ def set_config_value(key: str, value: str): "terminal.docker_persist_across_processes": "TERMINAL_DOCKER_PERSIST_ACROSS_PROCESSES", "terminal.docker_orphan_reaper": "TERMINAL_DOCKER_ORPHAN_REAPER", "terminal.docker_env": "TERMINAL_DOCKER_ENV", + # JSON-valued keys (terminal_tool parses these via json.loads). The user + # passes JSON on the CLI, so str(value) below already yields valid JSON — + # same as terminal.docker_env. cli.py and gateway/run.py bridge these too. + "terminal.docker_volumes": "TERMINAL_DOCKER_VOLUMES", + "terminal.docker_forward_env": "TERMINAL_DOCKER_FORWARD_ENV", # terminal.cwd intentionally excluded — CLI resolves at runtime, # gateway bridges it in gateway/run.py. Persisting to .env causes # stale values to poison child processes. diff --git a/hermes_cli/main.py b/hermes_cli/main.py index efe3c22d3..afb7ce9f7 100644 --- a/hermes_cli/main.py +++ b/hermes_cli/main.py @@ -8039,7 +8039,10 @@ def _update_via_zip(args): # may point to a Python without FTS5. Rebuild it so the new managed # uv provides a fresh interpreter with FTS5 guaranteed. if fresh_bootstrap and uv_bin: - rebuild_venv(uv_bin, PROJECT_ROOT / "venv") + if not rebuild_venv(uv_bin, PROJECT_ROOT / "venv"): + raise RuntimeError( + "venv rebuild failed; aborting update before dependency install" + ) pip_cmd = [sys.executable, "-m", "pip"] if not uv_bin: @@ -10224,7 +10227,7 @@ def _cmd_update_impl(args, gateway_mode: bool): return print("✗ Not a git repository. Please reinstall:") print( - " curl -fsSL https://raw.githubusercontent.com/NousResearch/hermes-agent/main/scripts/install.sh | bash" + " curl -fsSL https://hermes-agent.nousresearch.com/install.sh | bash" ) sys.exit(1) @@ -10582,7 +10585,10 @@ def _cmd_update_impl(args, gateway_mode: bool): # may point to a Python without FTS5. Rebuild it so the new managed # uv provides a fresh interpreter with FTS5 guaranteed. if fresh_bootstrap and uv_bin: - rebuild_venv(uv_bin, PROJECT_ROOT / "venv") + if not rebuild_venv(uv_bin, PROJECT_ROOT / "venv"): + raise RuntimeError( + "venv rebuild failed; aborting update before dependency install" + ) pip_cmd = [sys.executable, "-m", "pip"] if not uv_bin: diff --git a/hermes_cli/managed_uv.py b/hermes_cli/managed_uv.py index 31bbbc8b9..722cf5ba3 100644 --- a/hermes_cli/managed_uv.py +++ b/hermes_cli/managed_uv.py @@ -106,41 +106,69 @@ def rebuild_venv(uv_bin: str, venv_dir: Path, python_version: str = "3.11") -> b fresh interpreter from the current managed uv. Returns ``True`` on success. - On Windows, ``shutil.rmtree(..., ignore_errors=True)`` can silently leave - the venv directory partially intact when another process is holding an - open handle to a file inside it (typical culprits: a running - ``hermes.exe`` REPL, the gateway, AV scanners). If we don't notice that - and just call ``uv venv``, uv refuses with - ``Caused by: A directory already exists at: venv`` and the *whole - update* falls back to installing on top of the stale venv — which has - historically produced partial installs where a freshly added dependency - (e.g. ``pathspec``) silently fails to land. Retry with ``--clear`` to - force uv past that condition before giving up. + The old venv is moved aside *atomically* (``os.replace`` to ``.old``) + before recreating — never deleted in place. On Windows a still-running + ``hermes.exe`` (gateway/desktop) holds ``venv\\Scripts\\python.exe`` open; + ``shutil.rmtree(ignore_errors=True)`` would delete everything it *can* + (site-packages, certifi's cert bundle) and silently leave a half-gutted + venv that the following ``uv venv`` then refuses to overwrite ("directory + already exists") — bricking the install with no recovery (every later HTTPS + call dies with ``FileNotFoundError`` for the missing cert bundle). + ``--clear`` alone does not fix this: when the locked interpreter is *inside* + the venv being rebuilt, neither ``rmtree`` nor ``uv venv --clear`` can + delete the held ``python.exe``. ``os.replace`` of the parent directory *is* + allowed (Windows tracks a running ``.exe`` by handle, not path), so the + rebuild completes while the running process keeps using the moved-aside copy + until it restarts. If the venv genuinely cannot be moved, we abort cleanly + and leave it fully intact; and if the rebuild itself fails we move the old + venv back so Hermes is never left with no venv at all. """ + backup: Optional[Path] = None if venv_dir.exists(): print(f" → Rebuilding venv (old Python may lack FTS5)...") - shutil.rmtree(venv_dir, ignore_errors=True) + backup = venv_dir.with_name(venv_dir.name + ".old") + shutil.rmtree(backup, ignore_errors=True) # clear any stale backup + try: + # Atomic move — fails (without partial deletion) if a process still + # holds files inside the venv, which is exactly the Windows + # file-lock case that previously bricked the install. + os.replace(venv_dir, backup) + except OSError as exc: + logger.warning("venv rebuild aborted — venv in use: %s", exc) + print( + " ✗ venv rebuild aborted — the venv is in use; stop the " + f"gateway/desktop and retry ({exc})" + ) + return False - def _run_uv_venv(extra_args: list[str]) -> subprocess.CompletedProcess[str]: - return subprocess.run( - [uv_bin, "venv", str(venv_dir), "--python", python_version, *extra_args], - capture_output=True, - text=True, - check=False, - ) + result = subprocess.run( + [uv_bin, "venv", str(venv_dir), "--python", python_version, "--clear"], + capture_output=True, + text=True, + check=False, + ) - result = _run_uv_venv([]) - - # If uv refused because the directory still exists (rmtree above was - # blocked by an open file handle, common on Windows), retry with - # --clear so uv overwrites it. Match on stderr because uv's exit code - # alone doesn't distinguish "dir exists" from real failures. - if result.returncode != 0 and "already exists" in (result.stderr or "").lower(): - print(" → venv dir not fully removed (likely an open file handle); retrying with --clear...") - result = _run_uv_venv(["--clear"]) + def _restore_backup() -> None: + if backup is not None and backup.exists(): + shutil.rmtree(venv_dir, ignore_errors=True) + try: + os.replace(backup, venv_dir) + print(" ↩ Restored previous venv after failed rebuild.") + except OSError: + pass if result.returncode == 0: venv_python = venv_dir / ("Scripts" if platform.system() == "Windows" else "bin") / "python" + # uv can exit 0 yet leave no usable interpreter (e.g. a half-written + # venv). Don't report success on a venv that has no python — restore the + # moved-aside copy so the caller can abort without losing a working env. + if not venv_python.exists(): + logger.warning("venv rebuild reported success but %s is missing", venv_python) + print(f" ✗ venv rebuild failed: Python interpreter missing at {venv_python}") + _restore_backup() + return False + if backup is not None: + shutil.rmtree(backup, ignore_errors=True) py_ver = subprocess.run( [str(venv_python), "--version"], capture_output=True, @@ -150,6 +178,9 @@ def rebuild_venv(uv_bin: str, venv_dir: Path, python_version: str = "3.11") -> b print(f" ✓ venv rebuilt ({py_ver})") return True else: + # Rebuild failed — restore the old venv so we never leave Hermes with no + # venv (the bricked-install failure mode this function exists to avoid). + _restore_backup() logger.warning("venv rebuild failed: %s", result.stderr) print(f" ✗ venv rebuild failed: {result.stderr.strip()}") return False diff --git a/hermes_cli/mcp_config.py b/hermes_cli/mcp_config.py index 377c6b20b..bb8f89487 100644 --- a/hermes_cli/mcp_config.py +++ b/hermes_cli/mcp_config.py @@ -225,13 +225,15 @@ def _probe_single_server( server = await asyncio.wait_for( _connect_server(name, config), timeout=connect_timeout ) - for t in server._tools: - desc = getattr(t, "description", "") or "" - # Truncate long descriptions for display - if len(desc) > 80: - desc = desc[:77] + "..." - tools_found.append((t.name, desc)) - await server.shutdown() + try: + for t in server._tools: + desc = getattr(t, "description", "") or "" + # Truncate long descriptions for display + if len(desc) > 80: + desc = desc[:77] + "..." + tools_found.append((t.name, desc)) + finally: + await server.shutdown() try: _run_on_mcp_loop(_probe(), timeout=connect_timeout + 10) diff --git a/hermes_cli/models.py b/hermes_cli/models.py index 558eb008a..0ca52f6bf 100644 --- a/hermes_cli/models.py +++ b/hermes_cli/models.py @@ -52,6 +52,7 @@ OPENROUTER_MODELS: list[tuple[str, str]] = [ ("deepseek/deepseek-v4-flash", ""), # Qwen ("qwen/qwen3.7-max", ""), + ("qwen/qwen3.7-plus", ""), ("qwen/qwen3.6-35b-a3b", ""), # MoonshotAI ("moonshotai/kimi-k2.6", "recommended"), @@ -169,6 +170,7 @@ _PROVIDER_MODELS: dict[str, list[str]] = { "deepseek/deepseek-v4-flash", # Qwen "qwen/qwen3.7-max", + "qwen/qwen3.7-plus", "qwen/qwen3.6-35b-a3b", # MoonshotAI "moonshotai/kimi-k2.6", @@ -588,7 +590,8 @@ def union_with_portal_free_recommendations( pair where: * Portal free recommendations missing from ``curated_ids`` are - appended at the front (so the picker shows them first). + appended after the curated list (so the in-repo curated models + show first and Portal-only picks follow). * ``pricing`` gets a synthetic ``{"prompt": "0", "completion": "0"}`` entry for any free recommendation missing from the live pricing map, so :func:`partition_nous_models_by_tier` keeps it. @@ -623,11 +626,11 @@ def union_with_portal_free_recommendations( augmented_ids = list(curated_ids) seen = set(augmented_ids) - # Prepend Portal free recommendations that aren't already curated, so - # they appear first in the picker. + # Append Portal free recommendations that aren't already curated, so the + # in-repo curated ("HA") models show first and Portal-only picks follow. new_ones = [mid for mid in portal_free_ids if mid not in seen] if new_ones: - augmented_ids = new_ones + augmented_ids + augmented_ids = augmented_ids + new_ones return (augmented_ids, augmented_pricing) @@ -653,7 +656,8 @@ def union_with_portal_paid_recommendations( ``(model_ids, pricing)`` pair where: * Portal paid recommendations missing from ``curated_ids`` are - appended at the front (so the picker shows them first). + appended after the curated list (so the in-repo curated models + show first and Portal-only picks follow). * ``pricing`` is left untouched — we deliberately do NOT synthesize pricing entries for paid models. Live pricing is fetched separately via :func:`get_pricing_for_provider`; if the live endpoint hasn't @@ -688,11 +692,11 @@ def union_with_portal_paid_recommendations( augmented_ids = list(curated_ids) seen = set(augmented_ids) - # Prepend Portal paid recommendations that aren't already curated, so - # the Portal-blessed picks surface first in the picker. + # Append Portal paid recommendations that aren't already curated, so the + # in-repo curated ("HA") models show first and Portal-only picks follow. new_ones = [mid for mid in portal_paid_ids if mid not in seen] if new_ones: - augmented_ids = new_ones + augmented_ids + augmented_ids = augmented_ids + new_ones return (augmented_ids, dict(pricing)) diff --git a/hermes_cli/tools_config.py b/hermes_cli/tools_config.py index 57b1adb75..50f1f9f86 100644 --- a/hermes_cli/tools_config.py +++ b/hermes_cli/tools_config.py @@ -82,6 +82,22 @@ CONFIGURABLE_TOOLSETS = [ ("computer_use", "🖱️ Computer Use (macOS)", "background desktop control via cua-driver"), ] + +def gui_toolset_label(label: str) -> str: + """Strip leading emoji/icons from toolset titles for GUI surfaces. + + Registry labels use `` ``; plugin toolsets prefix with ``🔌``. + CLI/TUI keeps the raw ``label`` — only HTTP APIs call this helper. + """ + text = (label or "").strip() + if not text: + return text + parts = text.split(None, 1) + if len(parts) == 2 and parts[0] and not any(ch.isascii() and ch.isalnum() for ch in parts[0]): + return parts[1].strip() + return text + + # Toolsets that are OFF by default for new installs. # They're still in _HERMES_CORE_TOOLS (available at runtime if enabled), # but the setup checklist won't pre-select them for first-time users. diff --git a/hermes_cli/uninstall.py b/hermes_cli/uninstall.py index 2c666ddd2..d6b809fe0 100644 --- a/hermes_cli/uninstall.py +++ b/hermes_cli/uninstall.py @@ -734,9 +734,9 @@ def run_uninstall(args): print() print("To reinstall later with your existing settings:") if _is_windows(): - print(color(" iex (irm https://raw.githubusercontent.com/NousResearch/hermes-agent/main/scripts/install.ps1)", Colors.DIM)) + print(color(" iex (irm https://hermes-agent.nousresearch.com/install.ps1)", Colors.DIM)) else: - print(color(" curl -fsSL https://raw.githubusercontent.com/NousResearch/hermes-agent/main/scripts/install.sh | bash", Colors.DIM)) + print(color(" curl -fsSL https://hermes-agent.nousresearch.com/install.sh | bash", Colors.DIM)) print() if _is_windows(): diff --git a/hermes_cli/web_server.py b/hermes_cli/web_server.py index 234818451..3798f71e8 100644 --- a/hermes_cli/web_server.py +++ b/hermes_cli/web_server.py @@ -14,11 +14,14 @@ from contextlib import asynccontextmanager import asyncio import base64 import binascii +from dataclasses import dataclass +from datetime import datetime, timezone import hmac import importlib.util import json import logging import os +import re import secrets import stat import subprocess @@ -26,6 +29,7 @@ import sys import tempfile import threading import time +import urllib.error import urllib.parse import urllib.request from pathlib import Path @@ -557,6 +561,14 @@ class MessagingPlatformUpdate(BaseModel): clear_env: List[str] = [] +class TelegramOnboardingStart(BaseModel): + bot_name: Optional[str] = None + + +class TelegramOnboardingApply(BaseModel): + allowed_user_ids: List[str] + + class AudioTranscriptionRequest(BaseModel): data_url: str mime_type: Optional[str] = None @@ -1720,14 +1732,15 @@ async def get_profiles_sessions( @app.get("/api/sessions/search") async def search_sessions(q: str = "", limit: int = 20): - """Full-text search across session message content using FTS5. + """Search sessions by ID plus full-text message content using FTS5. - Results are deduped by compression lineage, not by raw ``session_id``. - Auto-compression rotates a conversation onto a fresh session id (and leaves - the old segment's messages in the FTS index), so one logical chat can own - many ``sessions`` rows that all match the same query. Branches also use - ``parent_session_id``, but they are real alternate conversations; don't - collapse branch-specific hits back into the parent. + Direct session-id matches are surfaced first, then FTS message-content + matches. Results are deduped by compression lineage, not by raw + ``session_id``. Auto-compression rotates a conversation onto a fresh + session id (and leaves the old segment's messages in the FTS index), so one + logical chat can own many ``sessions`` rows that all match the same query. + Branches also use ``parent_session_id``, but they are real alternate + conversations; don't collapse branch-specific hits back into the parent. """ if not q or not q.strip(): return {"results": []} @@ -1735,21 +1748,7 @@ async def search_sessions(q: str = "", limit: int = 20): from hermes_state import SessionDB db = SessionDB() try: - # Auto-add prefix wildcards so partial words match - # e.g. "nimb" → "nimb*" matches "nimby" - # Preserve quoted phrases and existing wildcards as-is - import re - terms = [] - for token in re.findall(r'"[^"]*"|\S+', q.strip()): - if token.startswith('"') or token.endswith("*"): - terms.append(token) - else: - terms.append(token + "*") - prefix_query = " ".join(terms) - # Over-fetch so lineage dedup can still surface `limit` distinct - # conversations even when several hits collapse onto one root. - fetch_limit = max(limit * 5, 50) - matches = db.search_messages(query=prefix_query, limit=fetch_limit) + safe_limit = max(1, min(int(limit or 20), 100)) # Walk parent_session_id to the compression root, memoized so a # chain of compression segments only costs one walk. We deliberately @@ -1821,24 +1820,71 @@ async def search_sessions(q: str = "", limit: int = 20): tip_cache[root_id] = tip return tip - # Keep the best (first / most relevant) hit per compression root. + # Both ID matches and content matches share one keyspace, keyed by + # compression lineage root, so an id-hit and a content-hit on the + # same logical conversation collapse to a single result. The first + # hit for a lineage wins; ID matches run first and take priority. seen: dict = {} - for m in matches: - raw_sid = m["session_id"] + + def add_lineage_result(raw_sid: str, payload: dict) -> None: + if not raw_sid: + return root = compression_root(raw_sid) - if root in seen: - continue - seen[root] = { - "session_id": lineage_tip(root), - "lineage_root": root, - "snippet": m.get("snippet", ""), - "role": m.get("role"), - "source": m.get("source"), - "model": m.get("model"), - "session_started": m.get("session_started"), - } - if len(seen) >= limit: + if root in seen or len(seen) >= safe_limit: + return + payload = dict(payload) + payload["session_id"] = lineage_tip(root) + payload["lineage_root"] = root + seen[root] = payload + + # Direct ID matches first: users often paste a session id from CLI, + # logs, or another Hermes surface. FTS can't find those unless the + # id happens to appear in message text. search_sessions_by_id is + # SQL-bounded, so this stays cheap even with thousands of sessions. + for row in db.search_sessions_by_id(q, limit=safe_limit, include_archived=True): + sid = row.get("id") + preview = (row.get("preview") or "").strip() + snippet = preview or f"Session ID: {sid}" + add_lineage_result( + sid, + { + "snippet": snippet, + "role": None, + "source": row.get("source"), + "model": row.get("model"), + "session_started": row.get("started_at"), + }, + ) + + # Auto-add prefix wildcards so partial words match + # e.g. "nimb" → "nimb*" matches "nimby" + # Preserve quoted phrases and existing wildcards as-is + import re + terms = [] + for token in re.findall(r'"[^"]*"|\S+', q.strip()): + if token.startswith('"') or token.endswith("*"): + terms.append(token) + else: + terms.append(token + "*") + prefix_query = " ".join(terms) + # Over-fetch so lineage dedup can still surface `limit` distinct + # conversations even when several hits collapse onto one root. + fetch_limit = max(safe_limit * 5, 50) + matches = db.search_messages(query=prefix_query, limit=fetch_limit) + + for m in matches: + if len(seen) >= safe_limit: break + add_lineage_result( + m["session_id"], + { + "snippet": m.get("snippet", ""), + "role": m.get("role"), + "source": m.get("source"), + "model": m.get("model"), + "session_started": m.get("session_started"), + }, + ) return {"results": list(seen.values())} finally: db.close() @@ -2908,18 +2954,66 @@ def _channel_managed_env_keys() -> frozenset[str]: return frozenset() +# Cross-cutting gateway / relay knobs stay on the Keys → Settings tab even though +# they use the ``messaging`` category in OPTIONAL_ENV_VARS. Platform-scoped vars +# (``DISCORD_*``, ``MATRIX_*``, …) are owned by the Messaging UI instead. +_MESSAGING_KEYS_PAGE_KEYS = frozenset({ + "GATEWAY_ALLOW_ALL_USERS", + "GATEWAY_PROXY_KEY", + "GATEWAY_PROXY_URL", +}) + + +def _platform_env_prefixes(platform_id: str) -> tuple[str, ...]: + """Env-var prefixes owned by a messaging platform card.""" + aliases: dict[str, tuple[str, ...]] = { + "email": ("EMAIL_",), + "homeassistant": ("HASS_",), + "qqbot": ("QQ_", "QQBOT_"), + "sms": ("TWILIO_",), + "wecom": ("WECOM_BOT_", "WECOM_SECRET"), + "wecom_callback": ("WECOM_CALLBACK_",), + } + if platform_id in aliases: + return aliases[platform_id] + return (platform_id.upper().replace("-", "_") + "_",) + + +def _discover_platform_env_vars(platform_id: str) -> tuple[str, ...]: + """All messaging-category env vars for a platform (override + plugin + prefix).""" + prefixes = _platform_env_prefixes(platform_id) + keys: list[str] = [] + for name, info in OPTIONAL_ENV_VARS.items(): + if info.get("category") != "messaging": + continue + if name in _MESSAGING_KEYS_PAGE_KEYS: + continue + if not any(name.startswith(prefix) for prefix in prefixes): + continue + keys.append(name) + return tuple(sorted(set(keys))) + + +def _merge_platform_env_vars( + platform_id: str, + override: dict[str, Any], + plugin_entry: Any | None, +) -> tuple[str, ...]: + """Canonical env-var list for a messaging platform card.""" + discovered = _discover_platform_env_vars(platform_id) + if "env_vars" in override: + return tuple(dict.fromkeys((*override["env_vars"], *discovered))) + if plugin_entry is not None and plugin_entry.required_env: + return tuple(dict.fromkeys((*tuple(plugin_entry.required_env), *discovered))) + return discovered + + def _build_catalog_entry( platform_id: str, plugin_entry: Any | None = None ) -> dict[str, Any]: override = _PLATFORM_OVERRIDES.get(platform_id, {}) - if "env_vars" in override: - env_vars: tuple[str, ...] = tuple(override["env_vars"]) - elif plugin_entry is not None and plugin_entry.required_env: - env_vars = tuple(plugin_entry.required_env) - else: - prefix = platform_id.upper() + "_" - env_vars = tuple(k for k in OPTIONAL_ENV_VARS if k.startswith(prefix)) + env_vars = _merge_platform_env_vars(platform_id, override, plugin_entry) if "required_env" in override: required_env = tuple(override["required_env"]) @@ -3077,6 +3171,329 @@ def _write_platform_enabled(platform_id: str, enabled: bool) -> None: save_config(config) +_TELEGRAM_ONBOARDING_DEFAULT_URL = "https://setup.hermes-agent.nousresearch.com" +_TELEGRAM_USER_ID_RE = re.compile(r"^\d+$") + + +@dataclass +class _TelegramOnboardingPairing: + poll_token: str + expires_at: str + expires_at_ts: float + bot_token: str | None = None + bot_username: str | None = None + owner_user_id: str | None = None + + +_telegram_onboarding_pairings: dict[str, _TelegramOnboardingPairing] = {} +_telegram_onboarding_lock = threading.RLock() + + +def _telegram_onboarding_base_url() -> str: + return ( + os.getenv("TELEGRAM_ONBOARDING_URL", _TELEGRAM_ONBOARDING_DEFAULT_URL) + .strip() + .rstrip("/") + ) + + +def _parse_expiry_ts(value: str) -> float: + try: + normalized = value.replace("Z", "+00:00") + parsed = datetime.fromisoformat(normalized) + if parsed.tzinfo is None: + parsed = parsed.replace(tzinfo=timezone.utc) + return parsed.timestamp() + except Exception: + return time.time() + 600 + + +def _prune_telegram_onboarding_pairings() -> None: + now = time.time() + expired = [ + pairing_id + for pairing_id, record in _telegram_onboarding_pairings.items() + if record.expires_at_ts <= now + ] + for pairing_id in expired: + _telegram_onboarding_pairings.pop(pairing_id, None) + + +def _normalize_telegram_user_id(value: Any) -> str | None: + normalized = str(value or "").strip() + if _TELEGRAM_USER_ID_RE.fullmatch(normalized): + return normalized + return None + + +def _telegram_onboarding_error_message(error: str, fallback: str) -> str: + return { + "not_found": "Telegram pairing was not found. Start a new setup.", + "expired": "Telegram setup expired. Start a new setup.", + "claimed": "Telegram setup was already claimed. Start a new setup.", + "unauthorized": "Telegram setup service rejected this request.", + "telegram_manager_bot_token_not_configured": "Telegram setup service is not configured.", + "telegram_token_fetch_failed": "Telegram could not finish bot setup. Try again.", + }.get(error, fallback) + + +def _telegram_onboarding_request_sync( + method: str, + path: str, + *, + body: dict[str, Any] | None = None, + bearer_token: str | None = None, +) -> dict[str, Any]: + data = None + headers = {"Accept": "application/json"} + if body is not None: + data = json.dumps(body).encode("utf-8") + headers["Content-Type"] = "application/json" + if bearer_token: + headers["Authorization"] = f"Bearer {bearer_token}" + + request = urllib.request.Request( + f"{_telegram_onboarding_base_url()}{path}", + data=data, + headers=headers, + method=method, + ) + try: + with urllib.request.urlopen(request, timeout=10) as response: + payload = response.read() + except urllib.error.HTTPError as exc: + payload = exc.read() + try: + parsed = json.loads(payload.decode("utf-8")) + except Exception: + parsed = {} + error = str(parsed.get("error") or parsed.get("status") or "") + detail = _telegram_onboarding_error_message( + error, + "Telegram setup service returned an error.", + ) + status_code = 404 if exc.code == 404 else 502 + if error in {"expired", "claimed"}: + status_code = 410 + raise HTTPException(status_code=status_code, detail=detail) from exc + except Exception as exc: + raise HTTPException( + status_code=502, + detail="Telegram setup service is unavailable. Try again shortly.", + ) from exc + + try: + parsed = json.loads(payload.decode("utf-8")) + except Exception as exc: + raise HTTPException( + status_code=502, + detail="Telegram setup service returned an invalid response.", + ) from exc + if not isinstance(parsed, dict): + raise HTTPException( + status_code=502, + detail="Telegram setup service returned an invalid response.", + ) + return parsed + + +async def _telegram_onboarding_request( + method: str, + path: str, + *, + body: dict[str, Any] | None = None, + bearer_token: str | None = None, +) -> dict[str, Any]: + return await asyncio.to_thread( + _telegram_onboarding_request_sync, + method, + path, + body=body, + bearer_token=bearer_token, + ) + + +@app.post("/api/messaging/telegram/onboarding/start") +async def start_telegram_onboarding(body: TelegramOnboardingStart): + bot_name = (body.bot_name or "Hermes Agent").strip() or "Hermes Agent" + payload = await _telegram_onboarding_request( + "POST", + "/v1/telegram/pairings", + body={"bot_name": bot_name}, + ) + + pairing_id = str(payload.get("pairing_id") or "").strip() + poll_token = str(payload.get("poll_token") or "").strip() + expires_at = str(payload.get("expires_at") or "").strip() + deep_link = str(payload.get("deep_link") or "").strip() + qr_payload = str(payload.get("qr_payload") or deep_link).strip() + suggested_username = str(payload.get("suggested_username") or "").strip() + if not pairing_id or not poll_token or not expires_at or not deep_link: + raise HTTPException( + status_code=502, + detail="Telegram setup service returned an incomplete response.", + ) + + with _telegram_onboarding_lock: + _prune_telegram_onboarding_pairings() + _telegram_onboarding_pairings[pairing_id] = _TelegramOnboardingPairing( + poll_token=poll_token, + expires_at=expires_at, + expires_at_ts=_parse_expiry_ts(expires_at), + ) + + return { + "pairing_id": pairing_id, + "suggested_username": suggested_username, + "deep_link": deep_link, + "qr_payload": qr_payload, + "expires_at": expires_at, + } + + +@app.get("/api/messaging/telegram/onboarding/{pairing_id}") +async def get_telegram_onboarding_status(pairing_id: str): + with _telegram_onboarding_lock: + _prune_telegram_onboarding_pairings() + record = _telegram_onboarding_pairings.get(pairing_id) + if not record: + raise HTTPException( + status_code=404, + detail="Telegram setup session was not found. Start a new setup.", + ) + if record.bot_token: + return { + "status": "ready", + "bot_username": record.bot_username, + "owner_user_id": record.owner_user_id, + "expires_at": record.expires_at, + } + poll_token = record.poll_token + + payload = await _telegram_onboarding_request( + "GET", + f"/v1/telegram/pairings/{urllib.parse.quote(pairing_id, safe='')}", + bearer_token=poll_token, + ) + status = str(payload.get("status") or "").strip() + if status == "waiting": + with _telegram_onboarding_lock: + current = _telegram_onboarding_pairings.get(pairing_id) + expires_at = current.expires_at if current else "" + return {"status": "waiting", "expires_at": expires_at} + + if status == "ready": + bot_token = str(payload.get("token") or "").strip() + bot_username = str(payload.get("bot_username") or "").strip() + if not bot_token: + raise HTTPException( + status_code=502, + detail="Telegram setup service returned an incomplete response.", + ) + owner_user_id = _normalize_telegram_user_id(payload.get("owner_user_id")) + with _telegram_onboarding_lock: + record = _telegram_onboarding_pairings.get(pairing_id) + if not record: + raise HTTPException( + status_code=404, + detail="Telegram setup session was not found. Start a new setup.", + ) + record.bot_token = bot_token + record.bot_username = bot_username or None + record.owner_user_id = owner_user_id + return { + "status": "ready", + "bot_username": record.bot_username, + "owner_user_id": record.owner_user_id, + "expires_at": record.expires_at, + } + + if status in {"expired", "claimed"}: + with _telegram_onboarding_lock: + _telegram_onboarding_pairings.pop(pairing_id, None) + raise HTTPException( + status_code=410, + detail=_telegram_onboarding_error_message( + status, + "Telegram setup is no longer available. Start a new setup.", + ), + ) + + raise HTTPException( + status_code=502, + detail="Telegram setup service returned an unknown status.", + ) + + +@app.post("/api/messaging/telegram/onboarding/{pairing_id}/apply") +async def apply_telegram_onboarding( + pairing_id: str, body: TelegramOnboardingApply +): + allowed_user_ids = [] + seen = set() + for raw_id in body.allowed_user_ids: + normalized = _normalize_telegram_user_id(raw_id) + if not normalized: + raise HTTPException( + status_code=400, + detail="Allowed Telegram user IDs must be numeric.", + ) + if normalized not in seen: + seen.add(normalized) + allowed_user_ids.append(normalized) + if not allowed_user_ids: + raise HTTPException( + status_code=400, + detail="Add at least one allowed Telegram user ID.", + ) + + with _telegram_onboarding_lock: + _prune_telegram_onboarding_pairings() + record = _telegram_onboarding_pairings.get(pairing_id) + if not record: + raise HTTPException( + status_code=404, + detail="Telegram setup session was not found. Start a new setup.", + ) + bot_token = record.bot_token + bot_username = record.bot_username + if not bot_token: + raise HTTPException( + status_code=409, + detail="Telegram setup is not ready yet.", + ) + + try: + save_env_value("TELEGRAM_BOT_TOKEN", bot_token) + save_env_value("TELEGRAM_ALLOWED_USERS", ",".join(allowed_user_ids)) + _write_platform_enabled("telegram", True) + except ValueError as exc: + raise HTTPException(status_code=400, detail=str(exc)) from exc + except Exception as exc: + _log.exception("Telegram onboarding apply failed") + raise HTTPException( + status_code=500, + detail="Failed to save Telegram setup.", + ) from exc + + with _telegram_onboarding_lock: + _telegram_onboarding_pairings.pop(pairing_id, None) + + return { + "ok": True, + "platform": "telegram", + "bot_username": bot_username, + "needs_restart": True, + } + + +@app.delete("/api/messaging/telegram/onboarding/{pairing_id}") +async def cancel_telegram_onboarding(pairing_id: str): + with _telegram_onboarding_lock: + _telegram_onboarding_pairings.pop(pairing_id, None) + return {"ok": True} + + @app.get("/api/messaging/platforms") async def get_messaging_platforms(): env_on_disk = load_env() @@ -6771,6 +7188,7 @@ async def get_toolsets(): _get_effective_configurable_toolsets, _get_platform_tools, _toolset_has_keys, + gui_toolset_label, ) from toolsets import resolve_toolset @@ -6788,7 +7206,9 @@ async def get_toolsets(): tools = [] is_enabled = name in enabled_toolsets result.append({ - "name": name, "label": label, "description": desc, + "name": name, + "label": gui_toolset_label(label), + "description": desc, "enabled": is_enabled, "available": is_enabled, "configured": _toolset_has_keys(name, config), @@ -7135,8 +7555,6 @@ async def get_models_analytics(days: int = 30): # though uvicorn binds to 127.0.0.1. # --------------------------------------------------------------------------- -import re - # PTY bridge is POSIX-only (depends on fcntl/termios/ptyprocess). On native # Windows the import raises; catch and leave PtyBridge=None so the rest of # the dashboard (sessions, jobs, metrics, config editor) still loads and the diff --git a/hermes_state.py b/hermes_state.py index 6a345a4b5..256bcc8c4 100644 --- a/hermes_state.py +++ b/hermes_state.py @@ -615,17 +615,27 @@ class SessionDB: ) def _try_wal_checkpoint(self) -> None: - """Best-effort PASSIVE WAL checkpoint. Never blocks, never raises. + """Best-effort TRUNCATE WAL checkpoint. Never raises. - Flushes committed WAL frames back into the main DB file for any - frames that no other connection currently needs. Keeps the WAL - from growing unbounded when many processes hold persistent + Flushes committed WAL frames back into the main DB file and + truncates the WAL file to zero bytes. Keeps the WAL from + growing unbounded when many processes hold persistent connections. + + PASSIVE checkpoint was previously used here, but it never + truncates the WAL file — the file stays at its high-water + mark until an explicit TRUNCATE is called (which only + happened inside the infrequent vacuum()). + + TRUNCATE may block writers briefly while checkpointing, but + _try_wal_checkpoint is called off the hot path (every 50 + writes) and already runs under ``self._lock``, so the + additional hold time is negligible. """ try: with self._lock: result = self._conn.execute( - "PRAGMA wal_checkpoint(PASSIVE)" + "PRAGMA wal_checkpoint(TRUNCATE)" ).fetchone() if result and result[1] > 0: logger.debug( @@ -638,13 +648,13 @@ class SessionDB: def close(self): """Close the database connection. - Attempts a PASSIVE WAL checkpoint first so that exiting processes - help keep the WAL file from growing unbounded. + Attempts a TRUNCATE WAL checkpoint first so that exiting processes + help shrink the WAL file. """ with self._lock: if self._conn: try: - self._conn.execute("PRAGMA wal_checkpoint(PASSIVE)") + self._conn.execute("PRAGMA wal_checkpoint(TRUNCATE)") except Exception: pass self._conn.close() @@ -1124,6 +1134,24 @@ class SessionDB: return None return row["holder"] if isinstance(row, sqlite3.Row) else row[0] + def update_session_meta( + self, + session_id: str, + model_config_json: str, + model: Optional[str] = None, + ) -> None: + """Update model_config and optionally model for an existing session. + + Uses COALESCE so that passing model=None leaves the stored model + column unchanged. Routes through _execute_write for the standard + BEGIN IMMEDIATE + jitter-retry + lock guarantee. + """ + def _do(conn): + conn.execute( + "UPDATE sessions SET model_config = ?, model = COALESCE(?, model) WHERE id = ?", + (model_config_json, model, session_id), + ) + self._execute_write(_do) def update_system_prompt(self, session_id: str, system_prompt: str) -> None: """Store the full assembled system prompt snapshot.""" @@ -1585,6 +1613,7 @@ class SessionDB: order_by_last_active: bool = False, include_archived: bool = False, archived_only: bool = False, + id_query: str = None, ) -> List[Dict[str, Any]]: """List sessions with preview (first user message) and last active timestamp. @@ -1617,13 +1646,23 @@ class SessionDB: params = [] if not include_children: - # Show root sessions and branch sessions (whose parent ended with - # end_reason='branched' before the child was created), while still - # hiding sub-agent runs and compression continuations (which also - # carry a parent_session_id but were spawned while the parent was - # still live — i.e., started_at < parent.ended_at). + # Show root sessions and branch sessions, while still hiding + # sub-agent runs and compression continuations (which also carry a + # parent_session_id but were spawned while the parent was still + # live — i.e., started_at < parent.ended_at). + # + # Branch sessions are identified two ways, OR'd for robustness: + # 1. A stable ``_branched_from`` marker in model_config, written + # by /branch at creation time. This survives the parent being + # reopened and re-ended with a different end_reason (e.g. + # tui_shutdown overwriting 'branched'), which otherwise hides + # the branch — see issue #20856. + # 2. The legacy heuristic (parent ended with 'branched' before the + # child started), covering branch sessions created before the + # marker existed. where_clauses.append( "(s.parent_session_id IS NULL" + " OR json_extract(s.model_config, '$._branched_from') IS NOT NULL" " OR EXISTS (SELECT 1 FROM sessions p" " WHERE p.id = s.parent_session_id" " AND p.end_reason = 'branched'" @@ -1646,6 +1685,16 @@ class SessionDB: where_clauses.append("s.archived = 0") where_sql = f"WHERE {' AND '.join(where_clauses)}" if where_clauses else "" + + # Optional session-id filter, pushed into SQL so callers (Desktop + # session-id search) don't have to fetch every row and filter in + # Python. ``id_query`` is matched as a case-insensitive substring + # against each surfaced row's id AND every id in its forward + # compression chain — so searching a compression *root* id or a *tip* + # id both resolve to the same projected conversation. Only used in the + # order_by_last_active path (which builds the chain CTE); other callers + # pass id_query=None. + id_needle = (id_query or "").strip().lower() if order_by_last_active: # Compute effective_last_active by walking each surfaced session's # compression-continuation chain forward in SQL and taking the MAX @@ -1658,6 +1707,28 @@ class SessionDB: # compression-continuation edges using the same criteria as # get_compression_tip (parent.end_reason='compression' AND # child.started_at >= parent.ended_at). + outer_where = where_sql + id_params: List[Any] = [] + if id_needle: + # Admit a surfaced row if its own id or any id in its forward + # compression chain matches the needle. LIKE with a leading + # wildcard can't use an index, but the chain membership and + # the small result set keep this bounded — far cheaper than + # fetching every session and scanning in Python. + id_clause = ( + "EXISTS (SELECT 1 FROM chain cq" + " WHERE cq.root_id = s.id" + " AND LOWER(cq.cur_id) LIKE ? ESCAPE '\\')" + ) + like_pattern = ( + "%" + + id_needle.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_") + + "%" + ) + id_params = [like_pattern] + outer_where = ( + f"{where_sql} AND {id_clause}" if where_sql else f"WHERE {id_clause}" + ) query = f""" WITH RECURSIVE chain(root_id, cur_id) AS ( SELECT s.id, s.id FROM sessions s {where_sql} @@ -1694,12 +1765,13 @@ class SessionDB: COALESCE(cm.effective_last_active, s.started_at) AS _effective_last_active FROM sessions s LEFT JOIN chain_max cm ON cm.root_id = s.id - {where_sql} + {outer_where} ORDER BY _effective_last_active DESC, s.started_at DESC, s.id DESC LIMIT ? OFFSET ? """ - # WHERE params apply twice (CTE seed + outer select). - params = params + params + [limit, offset] + # WHERE params apply twice (CTE seed + outer select); the id filter + # only applies to the outer select. + params = params + params + id_params + [limit, offset] else: query = f""" SELECT s.*, @@ -3027,6 +3099,53 @@ class SessionDB: return matches + def search_sessions_by_id( + self, + query: str, + limit: int = 20, + include_archived: bool = True, + ) -> List[Dict[str, Any]]: + """Search surfaced sessions by exact/prefix/substring session id. + + Desktop search uses this alongside FTS message search so users can paste + a session id from logs, CLI output, or another Hermes surface and jump + straight to that conversation. Matching also checks ``_lineage_root_id`` + for projected compression-chain tips, so an old root id still resolves to + the live continuation row. + """ + needle = (query or "").strip().lower() + if not needle or limit <= 0: + return [] + + # SQL-bounded: list_sessions_rich pushes the id LIKE filter into the + # query (matching the row's own id AND any id in its forward + # compression chain), so we only materialize matching rows instead of + # scanning every session. Fetch a small multiple of `limit` so the + # in-Python exact/prefix/substring ranking below has enough candidates + # to order, then truncate. + candidates = self.list_sessions_rich( + limit=max(limit * 4, limit), + offset=0, + include_archived=include_archived, + order_by_last_active=True, + id_query=needle, + ) + + def score(row: Dict[str, Any]) -> int: + ids = [str(row.get("id") or ""), str(row.get("_lineage_root_id") or "")] + normalized = [value.lower() for value in ids if value] + if any(value == needle for value in normalized): + return 0 + if any(value.startswith(needle) for value in normalized): + return 1 + return 2 + + ranked = sorted( + enumerate(candidates), + key=lambda item: (score(item[1]), item[0]), + ) + return [row for _, row in ranked[:limit]] + def search_sessions( self, source: str = None, diff --git a/nix/lib.nix b/nix/lib.nix index b3aa020ac..9ef9b1acd 100644 --- a/nix/lib.nix +++ b/nix/lib.nix @@ -21,7 +21,7 @@ let # Single npm deps fetch from the workspace root lockfile. # All workspace packages share this derivation. - npmDepsHash = "sha256-2CoB0uUc8Pf9iNR0I1EzVqgL89B5sADnC9sxGah8ndU="; + npmDepsHash = "sha256-T9UtpXgBCl/GywDZyrvG4a69RkV8oD6p1UOT7GPgAS0="; npmDeps = pkgs.fetchNpmDeps { inherit src; diff --git a/package-lock.json b/package-lock.json index 5b47e07c6..b8fbc0c13 100644 --- a/package-lock.json +++ b/package-lock.json @@ -143,6 +143,9 @@ "vite": "^8.0.10", "vitest": "^4.1.5", "wait-on": "^9.0.5" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" } }, "apps/desktop/node_modules/@nous-research/ui": { @@ -8353,6 +8356,16 @@ "xmlbuilder": ">=11.0.1" } }, + "node_modules/@types/qrcode": { + "version": "1.5.6", + "resolved": "https://registry.npmjs.org/@types/qrcode/-/qrcode-1.5.6.tgz", + "integrity": "sha512-te7NQcV2BOvdj2b1hCAHzAoMNuj65kNBMz0KBaxM6c3VGBOhU0dURQKOtH8CFNI/dsKkwlv32p26qYQTWoB5bw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@types/react": { "version": "19.2.14", "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz", @@ -8985,7 +8998,6 @@ "version": "5.0.1", "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", - "dev": true, "license": "MIT", "engines": { "node": ">=8" @@ -8995,7 +9007,6 @@ "version": "4.3.0", "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", - "dev": true, "license": "MIT", "dependencies": { "color-convert": "^2.0.1" @@ -9791,6 +9802,15 @@ "node": ">=6" } }, + "node_modules/camelcase": { + "version": "5.3.1", + "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-5.3.1.tgz", + "integrity": "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/caniuse-lite": { "version": "1.0.30001787", "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001787.tgz", @@ -10061,7 +10081,6 @@ "version": "2.0.1", "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", - "dev": true, "license": "MIT", "dependencies": { "color-name": "~1.1.4" @@ -10074,7 +10093,6 @@ "version": "1.1.4", "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", - "dev": true, "license": "MIT" }, "node_modules/colord": { @@ -10921,6 +10939,15 @@ } } }, + "node_modules/decamelize": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/decamelize/-/decamelize-1.2.0.tgz", + "integrity": "sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/decimal.js": { "version": "10.6.0", "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz", @@ -11083,6 +11110,12 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/dijkstrajs": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/dijkstrajs/-/dijkstrajs-1.0.3.tgz", + "integrity": "sha512-qiSlmBq9+BCdCA/L46dw8Uy93mloxsPSbwnm5yrKn2vMPiy8KyAskTF6zuV/j5BMsmOGZDPs7KjU+mjb670kfA==", + "license": "MIT" + }, "node_modules/dir-compare": { "version": "4.2.0", "resolved": "https://registry.npmjs.org/dir-compare/-/dir-compare-4.2.0.tgz", @@ -11542,7 +11575,6 @@ "version": "8.0.0", "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", - "dev": true, "license": "MIT" }, "node_modules/end-of-stream": { @@ -12704,7 +12736,6 @@ "version": "2.0.5", "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", - "dev": true, "license": "ISC", "engines": { "node": "6.* || 8.* || >= 10.*" @@ -14133,7 +14164,6 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", - "dev": true, "license": "MIT", "engines": { "node": ">=8" @@ -16823,6 +16853,15 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/p-try": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz", + "integrity": "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/package-manager-detector": { "version": "1.6.0", "resolved": "https://registry.npmjs.org/package-manager-detector/-/package-manager-detector-1.6.0.tgz", @@ -16905,7 +16944,6 @@ "version": "4.0.0", "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", - "dev": true, "license": "MIT", "engines": { "node": ">=8" @@ -17020,6 +17058,15 @@ "node": ">=8.0" } }, + "node_modules/pngjs": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/pngjs/-/pngjs-5.0.0.tgz", + "integrity": "sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw==", + "license": "MIT", + "engines": { + "node": ">=10.13.0" + } + }, "node_modules/points-on-curve": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/points-on-curve/-/points-on-curve-0.2.0.tgz", @@ -17262,6 +17309,141 @@ "node": ">=6" } }, + "node_modules/qrcode": { + "version": "1.5.4", + "resolved": "https://registry.npmjs.org/qrcode/-/qrcode-1.5.4.tgz", + "integrity": "sha512-1ca71Zgiu6ORjHqFBDpnSMTR2ReToX4l1Au1VFLyVeBTFavzQnv5JxMFr3ukHVKpSrSA2MCk0lNJSykjUfz7Zg==", + "license": "MIT", + "dependencies": { + "dijkstrajs": "^1.0.1", + "pngjs": "^5.0.0", + "yargs": "^15.3.1" + }, + "bin": { + "qrcode": "bin/qrcode" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/qrcode/node_modules/cliui": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-6.0.0.tgz", + "integrity": "sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ==", + "license": "ISC", + "dependencies": { + "string-width": "^4.2.0", + "strip-ansi": "^6.0.0", + "wrap-ansi": "^6.2.0" + } + }, + "node_modules/qrcode/node_modules/find-up": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz", + "integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==", + "license": "MIT", + "dependencies": { + "locate-path": "^5.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/qrcode/node_modules/locate-path": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz", + "integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==", + "license": "MIT", + "dependencies": { + "p-locate": "^4.1.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/qrcode/node_modules/p-limit": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz", + "integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==", + "license": "MIT", + "dependencies": { + "p-try": "^2.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/qrcode/node_modules/p-locate": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz", + "integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==", + "license": "MIT", + "dependencies": { + "p-limit": "^2.2.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/qrcode/node_modules/wrap-ansi": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-6.2.0.tgz", + "integrity": "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==", + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/qrcode/node_modules/y18n": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/y18n/-/y18n-4.0.3.tgz", + "integrity": "sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ==", + "license": "ISC" + }, + "node_modules/qrcode/node_modules/yargs": { + "version": "15.4.1", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-15.4.1.tgz", + "integrity": "sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A==", + "license": "MIT", + "dependencies": { + "cliui": "^6.0.0", + "decamelize": "^1.2.0", + "find-up": "^4.1.0", + "get-caller-file": "^2.0.1", + "require-directory": "^2.1.1", + "require-main-filename": "^2.0.0", + "set-blocking": "^2.0.0", + "string-width": "^4.2.0", + "which-module": "^2.0.0", + "y18n": "^4.0.0", + "yargs-parser": "^18.1.2" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/qrcode/node_modules/yargs-parser": { + "version": "18.1.3", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-18.1.3.tgz", + "integrity": "sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ==", + "license": "ISC", + "dependencies": { + "camelcase": "^5.0.0", + "decamelize": "^1.2.0" + }, + "engines": { + "node": ">=6" + } + }, "node_modules/quick-lru": { "version": "5.1.1", "resolved": "https://registry.npmjs.org/quick-lru/-/quick-lru-5.1.1.tgz", @@ -17985,7 +18167,6 @@ "version": "2.1.1", "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==", - "dev": true, "license": "MIT", "engines": { "node": ">=0.10.0" @@ -18000,6 +18181,12 @@ "node": ">=0.10.0" } }, + "node_modules/require-main-filename": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/require-main-filename/-/require-main-filename-2.0.0.tgz", + "integrity": "sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==", + "license": "ISC" + }, "node_modules/resedit": { "version": "1.7.2", "resolved": "https://registry.npmjs.org/resedit/-/resedit-1.7.2.tgz", @@ -18485,6 +18672,12 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/set-blocking": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/set-blocking/-/set-blocking-2.0.0.tgz", + "integrity": "sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==", + "license": "ISC" + }, "node_modules/set-cookie-parser": { "version": "2.7.2", "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.2.tgz", @@ -18915,7 +19108,6 @@ "version": "4.2.3", "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", - "dev": true, "license": "MIT", "dependencies": { "emoji-regex": "^8.0.0", @@ -19042,7 +19234,6 @@ "version": "6.0.1", "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", - "dev": true, "license": "MIT", "dependencies": { "ansi-regex": "^5.0.1" @@ -20930,6 +21121,12 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/which-module": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/which-module/-/which-module-2.0.1.tgz", + "integrity": "sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ==", + "license": "ISC" + }, "node_modules/which-typed-array": { "version": "1.1.20", "resolved": "https://registry.npmjs.org/which-typed-array/-/which-typed-array-1.1.20.tgz", @@ -21972,6 +22169,7 @@ "leva": "^0.10.1", "lucide-react": "^0.577.0", "motion": "^12.38.0", + "qrcode": "^1.5.4", "react": "^19.2.4", "react-dom": "^19.2.4", "react-router-dom": "^7.14.1", @@ -21982,6 +22180,7 @@ "devDependencies": { "@eslint/js": "^9.39.4", "@types/node": "^24.12.0", + "@types/qrcode": "^1.5.6", "@types/react": "^19.2.14", "@types/react-dom": "^19.2.3", "@vitejs/plugin-react": "^5.2.0", diff --git a/plugins/disk-cleanup/disk_cleanup.py b/plugins/disk-cleanup/disk_cleanup.py index 8d984273e..fddb62dac 100755 --- a/plugins/disk-cleanup/disk_cleanup.py +++ b/plugins/disk-cleanup/disk_cleanup.py @@ -145,6 +145,33 @@ ALLOWED_CATEGORIES = { } +# Paths under $HERMES_HOME that must NEVER be deleted by quick(), +# regardless of what the stored category says. This is a defense-in-depth +# guard against stale tracked.json entries from before #34840. +_PROTECTED_CRON_PATHS: set[str] = set() + + +def _is_protected_cron_path(p: Path) -> bool: + """Return True if *p* is a cron control-plane file/directory that must + never be deleted. + + This only matches the directory itself and known control-plane files + (``jobs.json``, ``.tick.lock``) — it does NOT blanket-protect + everything under ``cron/`` because ``cron/output/`` is disposable. + """ + # Lazily build the set once per process so HERMES_HOME is resolved + # exactly once. + if not _PROTECTED_CRON_PATHS: + hermes_home = get_hermes_home() + for parent in ("cron", "cronjobs"): + base = hermes_home / parent + _PROTECTED_CRON_PATHS.add(str(base)) + _PROTECTED_CRON_PATHS.add(str(base / "jobs.json")) + _PROTECTED_CRON_PATHS.add(str(base / ".tick.lock")) + resolved = str(p.resolve()) + return resolved in _PROTECTED_CRON_PATHS + + def fmt_size(n: float) -> str: for unit in ("B", "KB", "MB", "GB", "TB"): if n < 1024: @@ -226,6 +253,14 @@ def dry_run() -> Tuple[List[Dict], List[Dict]]: cat = item["category"] size = item["size"] + # Re-validate stale "cron-output" entries (fixes #37721). + if cat == "cron-output": + re_cat = guess_category(p) + if re_cat != "cron-output": + # Stale entry — would be skipped by quick(); omit from + # dry-run output too. + continue + if cat == "test": auto.append(item) elif cat == "temp" and age > 7: @@ -269,6 +304,28 @@ def quick() -> Dict[str, Any]: age = (now - datetime.fromisoformat(item["timestamp"])).days + # ---- stale-state migration (fixes #37721) ---- + # Old tracked.json entries may carry a "cron-output" category for + # paths that are NOT under cron/output/ (e.g. cron/jobs.json). + # guess_category() was fixed in #34840, but existing entries are + # never re-validated. Re-classify here so stale entries for cron + # control-plane state are not deleted. + if cat == "cron-output": + re_cat = guess_category(p) + if re_cat != "cron-output": + _log( + f"SKIP stale cron-output entry: {p} " + f"(re-classified as {re_cat!r})" + ) + # Drop the stale entry — it was misclassified. + continue + + # Hard safety net: never delete cron control-plane state even if + # the category somehow slipped through re-validation above. + if _is_protected_cron_path(p): + _log(f"SKIP protected cron path: {p}") + continue + should_delete = ( cat == "test" or (cat == "temp" and age > 7) diff --git a/plugins/memory/openviking/__init__.py b/plugins/memory/openviking/__init__.py index 42925fa74..810f2db43 100644 --- a/plugins/memory/openviking/__init__.py +++ b/plugins/memory/openviking/__init__.py @@ -627,9 +627,9 @@ class OpenVikingMemoryProvider(MemoryProvider): logger.warning("OpenViking session commit failed: %s", e) def _build_memory_uri(self, subdir: str) -> str: - """Build a viking:// memory URI under the configured user/subdir.""" + """Build a viking:// memory URI under the configured user/agent/subdir.""" slug = uuid.uuid4().hex[:12] - return f"viking://user/{self._user}/memories/{subdir}/mem_{slug}.md" + return f"viking://user/{self._user}/agent/{self._agent}/memories/{subdir}/mem_{slug}.md" def on_memory_write( self, diff --git a/plugins/model-providers/xiaomi/__init__.py b/plugins/model-providers/xiaomi/__init__.py index aed0d8424..93c7dbb29 100644 --- a/plugins/model-providers/xiaomi/__init__.py +++ b/plugins/model-providers/xiaomi/__init__.py @@ -9,6 +9,7 @@ xiaomi = ProviderProfile( env_vars=("XIAOMI_API_KEY",), base_url="https://api.xiaomimimo.com/v1", supports_health_check=False, # /v1/models returns 401 even with valid key + supports_vision=True, # mimo-v2-omni is vision-capable ) register_provider(xiaomi) diff --git a/plugins/platforms/google_chat/adapter.py b/plugins/platforms/google_chat/adapter.py index 0fdf1ea9d..f91a54417 100644 --- a/plugins/platforms/google_chat/adapter.py +++ b/plugins/platforms/google_chat/adapter.py @@ -1390,7 +1390,7 @@ class GoogleChatAdapter(BasePlatformAdapter): if arg == "start": if not oauth_helper._client_secret_path().exists(): await _reply( - "⚠️ No client credentials stored on the host. Send " + "⚠️ No client credentials stored for this profile. Send " "`/setup-files` (no args) for setup instructions." ) return True diff --git a/plugins/platforms/google_chat/oauth.py b/plugins/platforms/google_chat/oauth.py index 3b1101106..3d481b3ea 100644 --- a/plugins/platforms/google_chat/oauth.py +++ b/plugins/platforms/google_chat/oauth.py @@ -50,10 +50,8 @@ Token storage layout ``${HERMES_HOME}/google_chat_user_oauth_pending/<sanitized_email>.json`` - Legacy pending state: ``${HERMES_HOME}/google_chat_user_oauth_pending.json`` -- Shared OAuth client (one per host, anchored at the default Hermes root so - every profile sees it; a profile-local copy under ``${HERMES_HOME}`` wins - when present): - ``<default-root>/google_chat_user_client_secret.json`` (default ``~/.hermes``) +- OAuth client secret (profile-scoped — each profile registers its own): + ``${HERMES_HOME}/google_chat_user_client_secret.json`` """ from __future__ import annotations @@ -77,11 +75,7 @@ logger = logging.getLogger("gateway.platforms.google_chat_user_oauth") # Use the project's HERMES_HOME helper so the token follows the user's # profile (e.g. tests can override via HERMES_HOME=/tmp/...). try: - from hermes_constants import ( - display_hermes_home, - get_default_hermes_root, - get_hermes_home, - ) + from hermes_constants import display_hermes_home, get_hermes_home except (ModuleNotFoundError, ImportError): # Fallback for environments where hermes_constants isn't importable # (mirrors the same fallback used by the google-workspace skill's @@ -90,24 +84,6 @@ except (ModuleNotFoundError, ImportError): val = os.environ.get("HERMES_HOME", "").strip() return Path(val) if val else Path.home() / ".hermes" - def get_default_hermes_root() -> Path: - # Mirror hermes_constants.get_default_hermes_root(): resolve the - # profile root so host-wide files (the shared client secret) are - # found regardless of which profile is active. - native_home = Path.home() / ".hermes" - env_home = os.environ.get("HERMES_HOME", "").strip() - if not env_home: - return native_home - env_path = Path(env_home) - try: - env_path.resolve().relative_to(native_home.resolve()) - return native_home - except ValueError: - pass - if env_path.parent.name == "profiles": - return env_path.parent.parent - return env_path - def display_hermes_home() -> str: home = get_hermes_home() try: @@ -164,24 +140,7 @@ def _token_path(email: Optional[str] = None) -> Path: def _client_secret_path() -> Path: - """Path to the shared OAuth client secret (one per host). - - The client secret identifies the OAuth *app*, not a user or a profile, - so it is anchored at the default Hermes root (``~/.hermes`` — or the - Docker root) rather than the active profile's ``HERMES_HOME``. That way - the one-time ``--client-secret`` host setup is visible to gateways - running under any named profile, exactly as the docs describe ("one - file per host is enough no matter how many users authorize later"). - - A profile-local secret (``$HERMES_HOME/google_chat_user_client_secret.json``) - still takes precedence when present, for installs that seeded one under - the previous profile-scoped behavior or that deliberately run a separate - OAuth app per profile. - """ - profile_local = _hermes_home() / "google_chat_user_client_secret.json" - if profile_local.exists(): - return profile_local - return get_default_hermes_root() / "google_chat_user_client_secret.json" + return _hermes_home() / "google_chat_user_client_secret.json" def _pending_auth_path(email: Optional[str] = None) -> Path: diff --git a/providers/base.py b/providers/base.py index 01023ff55..d7ff470d8 100644 --- a/providers/base.py +++ b/providers/base.py @@ -56,6 +56,15 @@ class ProviderProfile: auth_type: str = "api_key" # api_key|oauth_device_code|oauth_external|copilot|aws_sdk supports_health_check: bool = True # False → doctor skips /models probe for this provider + # ── Vision support ──────────────────────────────────────── + # True when the provider's API accepts image content inside + # tool-result messages natively. Set on providers that expose + # multimodal models via tool results (Anthropic Messages API, + # OpenAI Chat Completions, Gemini, Xiaomi, MiniMax, etc.). + # Falls back to model-catalog lookup when False and the provider + # has no registered profile. + supports_vision: bool = False + # ── Model catalog ───────────────────────────────────────── # fallback_models: curated list shown in /model picker when live fetch fails. # Only agentic models that support tool calling should appear here. diff --git a/pyproject.toml b/pyproject.toml index 602279dad..2b12246e0 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -61,6 +61,16 @@ dependencies = [ "prompt_toolkit==3.0.52", # Cron scheduler (built-in feature — scheduled cron/interval jobs use croniter). "croniter==6.0.0", + # Markdown -> HTML conversion for rich message delivery (Matrix + # `formatted_body`, and the `send_message` tool's HTML path). Now on the + # DEFAULT delivery path, not matrix-specific: without it both + # gateway/platforms/matrix.py and tools/send_message_tool.py silently fall + # back to plain text, so cron/agent deliveries render raw `##`/`**`/tables + # in clients like Element (see #32486). Pure-Python py3-none-any wheel + # (~108KB, no compiled extensions, no platform constraints), so unlike the + # matrix extra's `mautrix`/`python-olm` it's safe to ship everywhere — keeps + # it out of the lazy-install path that exists only for the heavy matrix deps. + "Markdown==3.10.2", # Skills Hub (GitHub App JWT auth — optional, only needed for bot identity) "PyJWT[crypto]==2.12.1", # CVE-2026-32597 # Windows has no IANA tzdata shipped with the OS, so Python's ``zoneinfo`` @@ -104,7 +114,7 @@ dev = ["debugpy==1.8.20", "pytest==9.0.2", "pytest-asyncio==1.3.0", "pytest-time messaging = ["python-telegram-bot[webhooks]==22.6", "discord.py[voice]==2.7.1", "aiohttp==3.13.3", "brotlicffi==1.2.0.1", "slack-bolt==1.27.0", "slack-sdk==3.40.1", "qrcode==7.4.2"] cron = [] # croniter is now a core dependency; this extra kept for back-compat slack = ["slack-bolt==1.27.0", "slack-sdk==3.40.1", "aiohttp==3.13.3"] -matrix = ["mautrix[encryption]==0.21.0", "Markdown==3.10.2", "aiosqlite==0.22.1", "asyncpg==0.31.0", "aiohttp-socks==0.11.0"] +matrix = ["mautrix[encryption]==0.21.0", "aiosqlite==0.22.1", "asyncpg==0.31.0", "aiohttp-socks==0.11.0"] # WeCom callback-mode adapter — parses untrusted XML POST bodies from # WeCom-controlled callback endpoints, so we use defusedxml (drop-in # replacement for stdlib xml.etree.ElementTree) to block billion-laughs @@ -230,7 +240,6 @@ all = [ # where the user is expected to have a toolchain available. "hermes-agent[cron]", "hermes-agent[cli]", - "hermes-agent[dev]", "hermes-agent[pty]", "hermes-agent[mcp]", "hermes-agent[homeassistant]", diff --git a/scripts/install.cmd b/scripts/install.cmd index 23e40ed65..e60b4ff34 100644 --- a/scripts/install.cmd +++ b/scripts/install.cmd @@ -8,7 +8,7 @@ REM Usage: REM curl -fsSL https://raw.githubusercontent.com/NousResearch/hermes-agent/main/scripts/install.cmd -o install.cmd && install.cmd && del install.cmd REM REM Or if you're already in PowerShell, use the direct command instead: -REM iex (irm https://raw.githubusercontent.com/NousResearch/hermes-agent/main/scripts/install.ps1) +REM iex (irm https://hermes-agent.nousresearch.com/install.ps1) REM ============================================================================ echo. @@ -16,12 +16,12 @@ echo Hermes Agent Installer echo Launching PowerShell installer... echo. -powershell -ExecutionPolicy ByPass -NoProfile -Command "iex (irm https://raw.githubusercontent.com/NousResearch/hermes-agent/main/scripts/install.ps1)" +powershell -ExecutionPolicy ByPass -NoProfile -Command "iex (irm https://hermes-agent.nousresearch.com/install.ps1)" if %ERRORLEVEL% NEQ 0 ( echo. echo Installation failed. Please try running PowerShell directly: - echo powershell -ExecutionPolicy ByPass -c "iex (irm https://raw.githubusercontent.com/NousResearch/hermes-agent/main/scripts/install.ps1)" + echo powershell -ExecutionPolicy ByPass -c "iex (irm https://hermes-agent.nousresearch.com/install.ps1)" echo. pause exit /b 1 diff --git a/scripts/install.ps1 b/scripts/install.ps1 index a66ba9e8d..d295749eb 100644 --- a/scripts/install.ps1 +++ b/scripts/install.ps1 @@ -5,7 +5,7 @@ # Uses uv for fast Python provisioning and package management. # # Usage: -# iex (irm https://raw.githubusercontent.com/NousResearch/hermes-agent/main/scripts/install.ps1) +# iex (irm https://hermes-agent.nousresearch.com/install.ps1) # # Or download and run with options: # .\install.ps1 -NoVenv -SkipSetup @@ -1138,7 +1138,7 @@ function Install-Repository { Write-Info "Trying SSH clone..." $env:GIT_SSH_COMMAND = "ssh -o BatchMode=yes -o ConnectTimeout=5" try { - git -c windows.appendAtomically=false clone --branch $Branch $RepoUrlSsh $InstallDir + git -c windows.appendAtomically=false clone --depth 1 --branch $Branch $RepoUrlSsh $InstallDir if ($LASTEXITCODE -eq 0) { $cloneSuccess = $true } } catch { } $env:GIT_SSH_COMMAND = $null @@ -1147,7 +1147,7 @@ function Install-Repository { if (Test-Path $InstallDir) { Remove-Item -Recurse -Force $InstallDir -ErrorAction SilentlyContinue } Write-Info "SSH failed, trying HTTPS..." try { - git -c windows.appendAtomically=false clone --branch $Branch $RepoUrlHttps $InstallDir + git -c windows.appendAtomically=false clone --depth 1 --branch $Branch $RepoUrlHttps $InstallDir if ($LASTEXITCODE -eq 0) { $cloneSuccess = $true } } catch { } } @@ -2844,7 +2844,7 @@ try { Write-Err "Installation failed: $_" Write-Host "" Write-Info "If the error is unclear, try downloading and running the script directly:" - Write-Host " Invoke-WebRequest -Uri 'https://raw.githubusercontent.com/NousResearch/hermes-agent/main/scripts/install.ps1' -OutFile install.ps1" -ForegroundColor Yellow + Write-Host " Invoke-WebRequest -Uri 'https://hermes-agent.nousresearch.com/install.ps1' -OutFile install.ps1" -ForegroundColor Yellow Write-Host " .\install.ps1" -ForegroundColor Yellow Write-Host "" } diff --git a/scripts/install.sh b/scripts/install.sh index c5d6732e4..06758f1f0 100755 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -6,7 +6,7 @@ # Uses uv for desktop/server installs and Python's stdlib venv + pip on Termux. # # Usage: -# curl -fsSL https://raw.githubusercontent.com/NousResearch/hermes-agent/main/scripts/install.sh | bash +# curl -fsSL https://hermes-agent.nousresearch.com/install.sh | bash # # Or with options: # curl -fsSL ... | bash -s -- --no-venv --skip-setup @@ -451,7 +451,7 @@ detect_os() { OS="windows" DISTRO="windows" log_error "Windows detected. Please use the PowerShell installer:" - log_info " iex (irm https://raw.githubusercontent.com/NousResearch/hermes-agent/main/scripts/install.ps1)" + log_info " iex (irm https://hermes-agent.nousresearch.com/install.ps1)" exit 1 ;; *) @@ -1126,12 +1126,12 @@ clone_repo() { # so SSH fails fast instead of hanging when no key is configured. log_info "Trying SSH clone..." if GIT_SSH_COMMAND="ssh -o BatchMode=yes -o ConnectTimeout=5" \ - git clone --branch "$BRANCH" "$REPO_URL_SSH" "$INSTALL_DIR" 2>/dev/null; then + git clone --depth 1 --branch "$BRANCH" "$REPO_URL_SSH" "$INSTALL_DIR" 2>/dev/null; then log_success "Cloned via SSH" else rm -rf "$INSTALL_DIR" 2>/dev/null # Clean up partial SSH clone log_info "SSH failed, trying HTTPS..." - if git clone --branch "$BRANCH" "$REPO_URL_HTTPS" "$INSTALL_DIR"; then + if git clone --depth 1 --branch "$BRANCH" "$REPO_URL_HTTPS" "$INSTALL_DIR"; then log_success "Cloned via HTTPS" else log_error "Failed to clone repository" diff --git a/scripts/release.py b/scripts/release.py index 5ca6a2e93..68c141d56 100755 --- a/scripts/release.py +++ b/scripts/release.py @@ -45,8 +45,11 @@ ACP_REGISTRY_MANIFEST = REPO_ROOT / "acp_registry" / "agent.json" # Auto-extracted from noreply emails + manual overrides AUTHOR_MAP = { + "dirtyren@users.noreply.github.com": "dirtyren", "zhaolei.vc@bytedance.com": "zhaoleibd", + "jeffrobodie@gmail.com": "jeffrobodie-glitch", "kyssta-exe@users.noreply.github.com": "kyssta-exe", + "ali.zakaee.1997@gmail.com": "ITheEqualizer", "copii.list@gmail.com": "stremtec", "solaiagent@gmail.com": "solaitken", "cryptoworlldz@gmail.com": "worlldz", @@ -63,6 +66,8 @@ AUTHOR_MAP = { "david.gutowsky@gmail.com": "davidgut1982", "drpelagik@gmail.com": "SeaXen", "lengr@users.noreply.github.com": "LengR", + "Kewe63@users.noreply.github.com": "Kewe63", + "kewe.3217@gmail.com": "Kewe63", "17255546+CharZhou@users.noreply.github.com": "CharZhou", "metalclaudbot@gmail.com": "HashClawAI", "tonybear55665566@gmail.com": "TonyPepeBear", diff --git a/skills/autonomous-ai-agents/hermes-agent/SKILL.md b/skills/autonomous-ai-agents/hermes-agent/SKILL.md index d6188ec49..08a4fd2b4 100644 --- a/skills/autonomous-ai-agents/hermes-agent/SKILL.md +++ b/skills/autonomous-ai-agents/hermes-agent/SKILL.md @@ -35,7 +35,7 @@ People use Hermes for software development, research, system administration, dat ```bash # Install -curl -fsSL https://raw.githubusercontent.com/NousResearch/hermes-agent/main/scripts/install.sh | bash +curl -fsSL https://hermes-agent.nousresearch.com/install.sh | bash # Interactive chat (default) hermes diff --git a/tests/acp/test_session_db_private_access.py b/tests/acp/test_session_db_private_access.py new file mode 100644 index 000000000..8c1015b5b --- /dev/null +++ b/tests/acp/test_session_db_private_access.py @@ -0,0 +1,201 @@ +"""Tests for the update_session_meta fix. + +Verifies that: +1. SessionDB.update_session_meta() exists and works correctly via the + public _execute_write path (not db._lock / db._conn directly). +2. session.py _persist() no longer touches db._lock or db._conn. +3. update_session_meta updates the correct columns atomically. +""" + +import ast +import json +import tempfile +from pathlib import Path +from unittest.mock import MagicMock, patch, call + +import pytest + +from hermes_state import SessionDB +from acp_adapter.session import SessionManager + + +def _tmp_db(tmp_path): + return SessionDB(db_path=tmp_path / "state.db") + + +def _mock_agent(): + return MagicMock(name="MockAIAgent") + + +# --------------------------------------------------------------------------- +# hermes_state.SessionDB.update_session_meta — unit tests +# --------------------------------------------------------------------------- + +class TestUpdateSessionMeta: + """Direct unit tests for the new public method.""" + + def test_method_exists(self, tmp_path): + db = _tmp_db(tmp_path) + assert hasattr(db, "update_session_meta"), ( + "SessionDB must have update_session_meta() public method" + ) + assert callable(db.update_session_meta) + + def test_updates_model_config(self, tmp_path): + db = _tmp_db(tmp_path) + db.create_session("s1", source="acp", model="gpt-4") + + new_meta = json.dumps({"cwd": "/new/path", "provider": "openai"}) + db.update_session_meta("s1", new_meta, model=None) + + row = db.get_session("s1") + stored = json.loads(row["model_config"]) + assert stored["cwd"] == "/new/path" + assert stored["provider"] == "openai" + + def test_updates_model_when_provided(self, tmp_path): + db = _tmp_db(tmp_path) + db.create_session("s2", source="acp", model="gpt-3.5") + + db.update_session_meta("s2", json.dumps({"cwd": "."}), model="gpt-4o") + + row = db.get_session("s2") + assert row["model"] == "gpt-4o" + + def test_preserves_existing_model_when_none(self, tmp_path): + """Passing model=None must leave the stored model unchanged (COALESCE).""" + db = _tmp_db(tmp_path) + db.create_session("s3", source="acp", model="claude-3") + + db.update_session_meta("s3", json.dumps({"cwd": "."}), model=None) + + row = db.get_session("s3") + assert row["model"] == "claude-3" + + def test_uses_execute_write_not_private_api(self, tmp_path): + """update_session_meta must route through _execute_write, not _conn directly.""" + db = _tmp_db(tmp_path) + db.create_session("s4", source="acp") + + call_count = [0] + original = db._execute_write + + def patched(fn): + call_count[0] += 1 + return original(fn) + + db._execute_write = patched + db.update_session_meta("s4", json.dumps({"cwd": "."}), model="m") + + assert call_count[0] >= 1, ( + "update_session_meta must call _execute_write at least once" + ) + + def test_noop_on_nonexistent_session(self, tmp_path): + """Updating a non-existent session must not raise.""" + db = _tmp_db(tmp_path) + db.update_session_meta("ghost", json.dumps({"cwd": "."}), model=None) + + +# --------------------------------------------------------------------------- +# AST check: session.py must not access db._lock or db._conn +# --------------------------------------------------------------------------- + +class TestNoPrviateDBAccess: + """_persist() in session.py must not access db._lock or db._conn.""" + + def test_no_db_private_lock_access(self): + with open("acp_adapter/session.py", encoding="utf-8") as f: + source = f.read() + + tree = ast.parse(source) + + violations = [] + for node in ast.walk(tree): + # Looking for: db._lock or db._conn + if isinstance(node, ast.Attribute): + if isinstance(node.value, ast.Name) and node.value.id == "db": + if node.attr in ("_lock", "_conn"): + violations.append( + f"db.{node.attr} at line {node.lineno}" + ) + + assert violations == [], ( + "session.py accesses private SessionDB internals: " + + ", ".join(violations) + + " — use db.update_session_meta() instead" + ) + + def test_persist_calls_update_session_meta(self): + """AST check: _persist must call db.update_session_meta().""" + with open("acp_adapter/session.py", encoding="utf-8") as f: + tree = ast.parse(f.read()) + + found = False + for node in ast.walk(tree): + if isinstance(node, ast.FunctionDef) and node.name == "_persist": + for child in ast.walk(node): + if isinstance(child, ast.Call): + func = child.func + if isinstance(func, ast.Attribute): + if func.attr == "update_session_meta": + found = True + break + break + + assert found, ( + "_persist() must call db.update_session_meta() " + "instead of db._conn.execute() directly" + ) + + +# --------------------------------------------------------------------------- +# Integration: _persist round-trip via SessionManager +# --------------------------------------------------------------------------- + +class TestPersistRoundTrip: + """End-to-end: save a session and verify DB state is correct.""" + + def test_cwd_persisted_via_update_session_meta(self, tmp_path): + db = _tmp_db(tmp_path) + manager = SessionManager(agent_factory=_mock_agent, db=db) + + state = manager.create_session(cwd="/original") + assert db.get_session(state.session_id) is not None + + # Simulate cwd change and save + state.cwd = "/updated" + manager.save_session(state.session_id) + + row = db.get_session(state.session_id) + mc = json.loads(row["model_config"]) + assert mc["cwd"] == "/updated" + + def test_model_persisted_via_update_session_meta(self, tmp_path): + db = _tmp_db(tmp_path) + manager = SessionManager(agent_factory=_mock_agent, db=db) + + state = manager.create_session() + state.model = "new-model-xyz" + manager.save_session(state.session_id) + + row = db.get_session(state.session_id) + assert row["model"] == "new-model-xyz" + + def test_existing_model_not_cleared_on_save(self, tmp_path): + """If state.model is empty, the DB model column must not be overwritten.""" + db = _tmp_db(tmp_path) + manager = SessionManager(agent_factory=_mock_agent, db=db) + + state = manager.create_session() + # Manually set a model in DB + db.update_session_meta(state.session_id, json.dumps({"cwd": "."}), model="stored-model") + + # Now save with empty model + state.model = "" + manager.save_session(state.session_id) + + row = db.get_session(state.session_id) + assert row["model"] == "stored-model", ( + "COALESCE must preserve the existing model when new value is NULL" + ) diff --git a/tests/agent/test_local_stream_timeout.py b/tests/agent/test_local_stream_timeout.py index 0252633f3..91ca7f404 100644 --- a/tests/agent/test_local_stream_timeout.py +++ b/tests/agent/test_local_stream_timeout.py @@ -98,6 +98,16 @@ class TestIsLocalEndpoint: def test_container_dns_names(self, url): assert is_local_endpoint(url) is True + @pytest.mark.parametrize("url", [ + "http://ollama:11434", + "http://litellm:4000/v1", + "http://hermes-litellm:8080", + "http://vllm:8000", + ]) + def test_unqualified_docker_hostnames(self, url): + """Unqualified hostnames (no dots) are local — Docker Compose, /etc/hosts, etc.""" + assert is_local_endpoint(url) is True + @pytest.mark.parametrize("url", [ "https://api.openai.com", "https://openrouter.ai/api", diff --git a/tests/gateway/test_google_chat.py b/tests/gateway/test_google_chat.py index 03ab232eb..b75902785 100644 --- a/tests/gateway/test_google_chat.py +++ b/tests/gateway/test_google_chat.py @@ -16,7 +16,6 @@ import json import os import sys import types -from pathlib import Path from unittest.mock import AsyncMock, MagicMock, patch import pytest @@ -1651,48 +1650,6 @@ class TestUserOAuthHelper: }, ) - def test_client_secret_is_shared_across_profiles(self, tmp_path, monkeypatch): - """The OAuth client secret is host-wide infra: a secret seeded at the - default root by the documented one-time `--client-secret` host step - must be visible to a gateway running under a named profile. - - Regression: `_client_secret_path()` used to scope to the active - HERMES_HOME, so a profile gateway reported 'No client credentials - stored on the host' even after the host setup had been run. - """ - root = tmp_path / ".hermes" - profile_home = root / "profiles" / "bot1" - profile_home.mkdir(parents=True) - monkeypatch.setattr(Path, "home", lambda: tmp_path) - - # Seed the secret at the default root, as the host setup does. - secret = root / "google_chat_user_client_secret.json" - secret.write_text("{}", encoding="utf-8") - - # Resolve from inside a named profile. - monkeypatch.setenv("HERMES_HOME", str(profile_home)) - from plugins.platforms.google_chat.oauth import _client_secret_path - assert _client_secret_path() == secret - assert _client_secret_path().exists() - - def test_profile_local_client_secret_takes_precedence(self, tmp_path, monkeypatch): - """A profile-local secret (separate OAuth app per bot, or a legacy - profile-scoped seed) overrides the host-wide default when present.""" - root = tmp_path / ".hermes" - profile_home = root / "profiles" / "bot1" - profile_home.mkdir(parents=True) - monkeypatch.setattr(Path, "home", lambda: tmp_path) - monkeypatch.setenv("HERMES_HOME", str(profile_home)) - - (root / "google_chat_user_client_secret.json").write_text( - "{}", encoding="utf-8" - ) - profile_secret = profile_home / "google_chat_user_client_secret.json" - profile_secret.write_text("{}", encoding="utf-8") - - from plugins.platforms.google_chat.oauth import _client_secret_path - assert _client_secret_path() == profile_secret - def test_store_client_secret_writes_private_json(self, tmp_path, monkeypatch): monkeypatch.setenv("HERMES_HOME", str(tmp_path)) src = tmp_path / "client_secret.json" diff --git a/tests/gateway/test_platform_base.py b/tests/gateway/test_platform_base.py index 10a924764..3f8ecd932 100644 --- a/tests/gateway/test_platform_base.py +++ b/tests/gateway/test_platform_base.py @@ -954,6 +954,120 @@ class TestMediaDeliveryDefaultMode: out = BasePlatformAdapter.filter_local_delivery_paths([str(notes)]) assert out == [str(notes.resolve())] + def test_root_home_deliverable_is_accepted(self, tmp_path, monkeypatch): + """The motivating bug (#38106): a root-run gateway has ``$HOME=/root``, + which is on the system-prefix denylist. A plain deliverable the agent + produced in its working dir (``/root/work/proposal.docx``) must still + deliver — the home itself is not a credential location. + """ + self._patch_roots(monkeypatch) + + fake_home = tmp_path / "root" + workdir = fake_home / "work" + workdir.mkdir(parents=True) + doc = workdir / "proposal.docx" + doc.write_bytes(b"PK\x03\x04") + monkeypatch.setenv("HOME", str(fake_home)) + # $HOME is itself on the denied-prefix list, mirroring /root. + monkeypatch.setattr( + "gateway.platforms.base._MEDIA_DELIVERY_DENIED_PREFIXES", + (str(fake_home),), + ) + + assert ( + BasePlatformAdapter.validate_media_delivery_path(str(doc)) + == str(doc.resolve()) + ) + + def test_root_home_credential_subdir_still_blocked(self, tmp_path, monkeypatch): + """The $HOME exception must NOT un-block credential sub-dirs inside + home. ``/root/.ssh/id_rsa`` stays denied because ``~/.ssh`` is a + separate, more-specific denylist entry — even when $HOME is itself a + denied prefix. + """ + self._patch_roots(monkeypatch) + + fake_home = tmp_path / "root" + ssh_dir = fake_home / ".ssh" + ssh_dir.mkdir(parents=True) + key = ssh_dir / "id_rsa" + key.write_bytes(b"-----BEGIN OPENSSH PRIVATE KEY-----") + monkeypatch.setenv("HOME", str(fake_home)) + monkeypatch.setattr( + "gateway.platforms.base._MEDIA_DELIVERY_DENIED_PREFIXES", + (str(fake_home),), + ) + + assert BasePlatformAdapter.validate_media_delivery_path(str(key)) is None + + def test_root_home_hermes_env_still_blocked(self, tmp_path, monkeypatch): + """``~/.hermes/.env`` stays blocked under the $HOME exception — it is a + more-specific denied path, not reachable just because home is allowed. + """ + self._patch_roots(monkeypatch) + + fake_home = tmp_path / "root" + hermes_dir = fake_home / ".hermes" + hermes_dir.mkdir(parents=True) + env_file = hermes_dir / ".env" + env_file.write_text("OPENROUTER_API_KEY=sk-...") + monkeypatch.setenv("HOME", str(fake_home)) + monkeypatch.setattr( + "gateway.platforms.base._MEDIA_DELIVERY_DENIED_PREFIXES", + (str(fake_home),), + ) + monkeypatch.setattr("gateway.platforms.base._HERMES_HOME", hermes_dir) + + assert BasePlatformAdapter.validate_media_delivery_path(str(env_file)) is None + + def test_other_users_home_still_blocked_for_nonroot(self, tmp_path, monkeypatch): + """The exception only un-blocks the *running user's own* home. A + non-root gateway ($HOME=/home/me) must not deliver another user's home + (``/root/...``) — that prefix stays denied because it isn't $HOME. + """ + self._patch_roots(monkeypatch) + + my_home = tmp_path / "home" / "me" + my_home.mkdir(parents=True) + other_home = tmp_path / "root" + other_home.mkdir() + other_file = other_home / "secret.docx" + other_file.write_bytes(b"PK\x03\x04") + monkeypatch.setenv("HOME", str(my_home)) + # Both my home and the other home are denied prefixes; only my home is + # the running user's $HOME, so the other home must stay blocked. + monkeypatch.setattr( + "gateway.platforms.base._MEDIA_DELIVERY_DENIED_PREFIXES", + (str(my_home), str(other_home)), + ) + + assert ( + BasePlatformAdapter.validate_media_delivery_path(str(other_file)) is None + ) + + def test_root_home_workdir_symlink_to_credential_blocked(self, tmp_path, monkeypatch): + """A symlink in the workdir pointing at a credential is rejected on its + resolved target, even under the $HOME exception. + """ + self._patch_roots(monkeypatch) + + fake_home = tmp_path / "root" + ssh_dir = fake_home / ".ssh" + ssh_dir.mkdir(parents=True) + key = ssh_dir / "id_rsa" + key.write_bytes(b"-----BEGIN OPENSSH PRIVATE KEY-----") + workdir = fake_home / "work" + workdir.mkdir() + link = workdir / "innocent.pdf" + link.symlink_to(key) + monkeypatch.setenv("HOME", str(fake_home)) + monkeypatch.setattr( + "gateway.platforms.base._MEDIA_DELIVERY_DENIED_PREFIXES", + (str(fake_home),), + ) + + assert BasePlatformAdapter.validate_media_delivery_path(str(link)) is None + # --------------------------------------------------------------------------- # should_send_media_as_audio diff --git a/tests/gateway/test_session_state_cleanup.py b/tests/gateway/test_session_state_cleanup.py index ffbb465b7..dfde65eb3 100644 --- a/tests/gateway/test_session_state_cleanup.py +++ b/tests/gateway/test_session_state_cleanup.py @@ -228,3 +228,54 @@ class TestSessionDbCloseOnShutdown: flaky_db.close.assert_called_once() healthy_db.close.assert_called_once() + + +class TestSessionResetZombieRace: + """Regression for #28686 — a session_reset racing the in-flight run's + guarded release must not leave a dead agent locking the slot forever. + """ + + def test_generation_guard_blocks_then_unconditional_release_evicts(self): + runner = _make_runner() + runner._session_run_generation = {} + key = "agent:main:telegram:private:1" + + gen_n = runner._begin_session_run_generation(key) + dead_agent = MagicMock() + runner._running_agents[key] = dead_agent + runner._running_agents_ts[key] = 1.0 + runner._busy_ack_ts[key] = 1.0 + + # session_reset bumps the generation while gen-N is still in flight. + runner._invalidate_session_run_generation(key, reason="session_reset") + + # gen-N's own guarded release is correctly blocked — slot would be a + # zombie if nothing else cleared it (the pre-fix behaviour). + assert runner._release_running_agent_state(key, run_generation=gen_n) is False + assert runner._running_agents.get(key) is dead_agent + + # The fix: unconditional release (no run_generation) always clears it. + assert runner._release_running_agent_state(key) is True + assert key not in runner._running_agents + assert key not in runner._running_agents_ts + assert key not in runner._busy_ack_ts + + def test_normal_completion_is_not_evicted_by_outer_release(self): + """Guarded release with the current generation succeeds; the outer + unconditional release that follows is a harmless no-op. + """ + runner = _make_runner() + runner._session_run_generation = {} + key = "agent:main:telegram:private:2" + + gen = runner._begin_session_run_generation(key) + runner._running_agents[key] = MagicMock() + runner._running_agents_ts[key] = 1.0 + runner._busy_ack_ts[key] = 1.0 + + assert runner._release_running_agent_state(key, run_generation=gen) is True + assert key not in runner._running_agents + # Outer finally runs the unconditional release after — nothing stranded. + assert runner._release_running_agent_state(key) is True + assert key not in runner._running_agents_ts + assert key not in runner._busy_ack_ts diff --git a/tests/hermes_cli/test_auth_profile_fallback.py b/tests/hermes_cli/test_auth_profile_fallback.py index d041b4efa..5210404c4 100644 --- a/tests/hermes_cli/test_auth_profile_fallback.py +++ b/tests/hermes_cli/test_auth_profile_fallback.py @@ -17,18 +17,12 @@ from pathlib import Path import pytest -def _make_auth_store( - pool: dict | None = None, - providers: dict | None = None, - active_provider: str | None = None, -) -> dict: +def _make_auth_store(pool: dict | None = None, providers: dict | None = None) -> dict: store: dict = {"version": 1} if pool is not None: store["credential_pool"] = pool if providers is not None: store["providers"] = providers - if active_provider is not None: - store["active_provider"] = active_provider return store @@ -456,101 +450,3 @@ def test_write_credential_pool_targets_profile_not_global(profile_env): # Subsequent read returns profile (shadows global). assert [e["id"] for e in read_credential_pool("openrouter")] == ["prof-new"] - - -# --------------------------------------------------------------------------- -# get_active_provider — global active_provider fallback (issue #18594 follow-up) -# -# The per-provider state/pool fallbacks let a profile *read* a provider that -# was only authenticated at the global root, but ``resolve_provider()`` picks -# the ``auto`` provider from ``active_provider`` — which only ever read the -# profile store. A named profile running ``model.provider: auto`` could see -# the global Nous login (``get_provider_auth_state('nous')`` succeeds) yet -# still fail to select it. These pin the active_provider shadowing so the -# selection mirrors the state/pool fallbacks: profile wins when present, fall -# back to global when the profile never chose its own provider. -# --------------------------------------------------------------------------- - - -def test_active_provider_falls_back_to_global(profile_env): - """An empty profile inherits the global-root active_provider selection.""" - from hermes_cli.auth import get_active_provider - - _write(profile_env["global"] / "auth.json", _make_auth_store( - providers={"nous": {"access_token": "nous-global"}}, - active_provider="nous", - )) - _write(profile_env["profile"] / "auth.json", _make_auth_store(providers={})) - - assert get_active_provider() == "nous" - - -def test_active_provider_profile_wins_over_global(profile_env): - """A profile that selected its own provider shadows the global selection.""" - from hermes_cli.auth import get_active_provider - - _write(profile_env["global"] / "auth.json", _make_auth_store( - providers={"nous": {"access_token": "nous-global"}}, - active_provider="nous", - )) - _write(profile_env["profile"] / "auth.json", _make_auth_store( - providers={"anthropic": {"access_token": "ant-profile"}}, - active_provider="anthropic", - )) - - assert get_active_provider() == "anthropic" - - -def test_active_provider_none_when_neither_has_it(profile_env): - """No selection anywhere stays None — the fallback must not invent one.""" - from hermes_cli.auth import get_active_provider - - _write(profile_env["global"] / "auth.json", _make_auth_store(providers={})) - _write(profile_env["profile"] / "auth.json", _make_auth_store(providers={})) - - assert get_active_provider() is None - - -def test_active_provider_classic_mode_reads_profile(tmp_path, monkeypatch): - """In classic mode there is no global to fall back to; behavior is unchanged.""" - fake_home = tmp_path / "home" - fake_home.mkdir() - monkeypatch.setattr(Path, "home", lambda: fake_home) - hermes_home = tmp_path / "classic" - hermes_home.mkdir() - monkeypatch.setenv("HERMES_HOME", str(hermes_home)) - - _write(hermes_home / "auth.json", _make_auth_store( - providers={"nous": {"access_token": "classic-token"}}, - active_provider="nous", - )) - - from hermes_cli.auth import get_active_provider - - assert get_active_provider() == "nous" - - -def test_resolve_provider_uses_global_active_provider(profile_env, monkeypatch): - """resolve_provider('auto') honors the global-root active_provider. - - This is the user-visible contract: a named profile with no provider entry - of its own, started with ``model.provider: auto`` while a valid login - exists at the global root, resolves that provider instead of raising - ``No inference provider configured``. ``get_auth_status`` is stubbed so the - login check stays offline (no Nous token refresh / network). - """ - import hermes_cli.auth as auth - - _write(profile_env["global"] / "auth.json", _make_auth_store( - providers={"nous": {"access_token": "nous-global"}}, - active_provider="nous", - )) - _write(profile_env["profile"] / "auth.json", _make_auth_store(providers={})) - - monkeypatch.setattr( - auth, - "get_auth_status", - lambda provider=None: {"logged_in": True, "provider": provider}, - ) - - assert auth.resolve_provider("auto") == "nous" diff --git a/tests/hermes_cli/test_gateway_service.py b/tests/hermes_cli/test_gateway_service.py index c529fa59a..b1d979200 100644 --- a/tests/hermes_cli/test_gateway_service.py +++ b/tests/hermes_cli/test_gateway_service.py @@ -8,6 +8,7 @@ from types import SimpleNamespace import pytest pwd = pytest.importorskip("pwd") +grp = pytest.importorskip("grp") import hermes_cli.gateway as gateway_cli from gateway import status @@ -1331,7 +1332,6 @@ class TestSystemServiceIdentityRootHandling: def test_explicit_root_is_allowed(self, monkeypatch): """When root is explicitly passed via --run-as-user root, allow it.""" - import grp root_info = pwd.getpwnam("root") root_group = grp.getgrgid(root_info.pw_gid).gr_name diff --git a/tests/hermes_cli/test_managed_uv.py b/tests/hermes_cli/test_managed_uv.py index f1394f6ef..aff6b8de7 100644 --- a/tests/hermes_cli/test_managed_uv.py +++ b/tests/hermes_cli/test_managed_uv.py @@ -108,121 +108,108 @@ class TestEnsureUv: # --------------------------------------------------------------------------- class TestRebuildVenv: - def test_removes_old_venv_and_creates_new(self, tmp_path): + def test_moves_old_venv_aside_and_creates_new(self, tmp_path): + """The old venv is moved aside to <venv>.old (never rmtree'd in place), + uv is invoked with --clear, the moved-aside backup is removed on + success, and the rebuilt interpreter is reported.""" venv_dir = tmp_path / "venv" venv_dir.mkdir() (venv_dir / "old_file").write_text("stale") uv_bin = str(tmp_path / "bin" / "uv") + call_log: list[list[str]] = [] def fake_run(cmd, **kwargs): - m = MagicMock(returncode=0) - if cmd[1] == "venv": - # Simulate uv creating the venv dir - venv_dir.mkdir(exist_ok=True) - bin_dir = venv_dir / "bin" + call_log.append(list(cmd)) + m = MagicMock(returncode=0, stderr="", stdout="") + if len(cmd) >= 2 and cmd[1] == "venv": + # Simulate uv creating the venv dir with a python interpreter + bin_dir = venv_dir / ("Scripts" if os.name == "nt" else "bin") bin_dir.mkdir(parents=True, exist_ok=True) - (bin_dir / "python").write_text("#!/bin/sh\necho Python 3.11.0") + python_name = "python.exe" if os.name == "nt" else "python" + (bin_dir / python_name).write_text("#!/bin/sh\necho Python 3.11.0") elif "--version" in cmd: m.stdout = "Python 3.11.0" return m - with patch("hermes_cli.managed_uv.subprocess.run", side_effect=fake_run), \ - patch("hermes_cli.managed_uv.shutil.rmtree") as mock_rmtree: + with patch("hermes_cli.managed_uv.subprocess.run", side_effect=fake_run): from hermes_cli.managed_uv import rebuild_venv result = rebuild_venv(uv_bin, venv_dir) - assert result is True - mock_rmtree.assert_called_once_with(venv_dir, ignore_errors=True) + + assert result is True + # uv venv was invoked exactly once, always with --clear. + venv_calls = [c for c in call_log if len(c) >= 2 and c[1] == "venv"] + assert len(venv_calls) == 1, f"expected 1 venv call, got {venv_calls}" + assert "--clear" in venv_calls[0] + # The moved-aside backup is cleaned up after a successful rebuild. + assert not (tmp_path / "venv.old").exists() + + def test_aborts_without_deleting_when_venv_in_use(self, tmp_path): + """If os.replace fails (Windows file lock — venv in use), we must abort + cleanly WITHOUT deleting the venv and WITHOUT invoking uv.""" + venv_dir = tmp_path / "venv" + venv_dir.mkdir() + (venv_dir / "locked") .write_text("held open") + uv_bin = str(tmp_path / "bin" / "uv") + call_log: list[list[str]] = [] + + def fake_run(cmd, **kwargs): + call_log.append(list(cmd)) + return MagicMock(returncode=0, stderr="", stdout="") + + with patch("hermes_cli.managed_uv.subprocess.run", side_effect=fake_run), \ + patch("hermes_cli.managed_uv.os.replace", side_effect=OSError("in use")): + from hermes_cli.managed_uv import rebuild_venv + result = rebuild_venv(uv_bin, venv_dir) + + assert result is False + # venv left fully intact, uv never invoked. + assert venv_dir.exists() and (venv_dir / "locked").exists() + assert [c for c in call_log if len(c) >= 2 and c[1] == "venv"] == [] + + def test_restores_backup_when_rebuild_fails(self, tmp_path): + """If uv venv exits non-zero, the moved-aside venv is restored so we + never leave Hermes with no venv at all.""" + venv_dir = tmp_path / "venv" + venv_dir.mkdir() + (venv_dir / "marker").write_text("original") + uv_bin = str(tmp_path / "bin" / "uv") + + def fake_run(cmd, **kwargs): + return MagicMock(returncode=1, stderr="boom", stdout="") + + with patch("hermes_cli.managed_uv.subprocess.run", side_effect=fake_run): + from hermes_cli.managed_uv import rebuild_venv + result = rebuild_venv(uv_bin, venv_dir) + + assert result is False + # Original venv restored from the .old backup. + assert venv_dir.exists() and (venv_dir / "marker").read_text() == "original" + assert not (tmp_path / "venv.old").exists() def test_rebuild_failure_returns_false(self, tmp_path): venv_dir = tmp_path / "venv" uv_bin = str(tmp_path / "bin" / "uv") - with patch("hermes_cli.managed_uv.subprocess.run") as mock_run, \ - patch("hermes_cli.managed_uv.shutil.rmtree"): + with patch("hermes_cli.managed_uv.subprocess.run") as mock_run: mock_run.return_value = MagicMock(returncode=1, stderr="nope") from hermes_cli.managed_uv import rebuild_venv result = rebuild_venv(uv_bin, venv_dir) assert result is False - def test_retries_with_clear_when_dir_already_exists(self, tmp_path): - """On Windows, rmtree can silently fail when an open handle holds a - file in the venv (running hermes.exe, gateway, AV scanner). uv then - refuses with ``Caused by: A directory already exists at: venv``. - Make sure we don't give up — retry with ``--clear`` to force uv past - the stale directory and rebuild successfully.""" - venv_dir = tmp_path / "venv" - venv_dir.mkdir() - (venv_dir / "stale_open_handle").write_text("rmtree couldn't delete me") - - uv_bin = str(tmp_path / "bin" / "uv") - call_log: list[list[str]] = [] - - def fake_run(cmd, **kwargs): - call_log.append(list(cmd)) - m = MagicMock() - if cmd[1] == "venv" and "--clear" not in cmd: - # First attempt: uv refuses because dir still exists - m.returncode = 1 - m.stderr = ( - "error: Failed to create virtual environment\n" - " Caused by: A directory already exists at: venv\n" - "hint: Use the `--clear` flag or set `UV_VENV_CLEAR=1` to replace the existing directory\n" - ) - m.stdout = "" - return m - if cmd[1] == "venv" and "--clear" in cmd: - # Retry: succeeds. Simulate uv writing the python shim. - m.returncode = 0 - m.stderr = "" - m.stdout = "" - bin_dir = venv_dir / ("Scripts" if os.name == "nt" else "bin") - bin_dir.mkdir(parents=True, exist_ok=True) - python_name = "python.exe" if os.name == "nt" else "python" - (bin_dir / python_name).write_text("#!/bin/sh\necho Python 3.11.0") - return m - if "--version" in cmd: - m.returncode = 0 - m.stdout = "Python 3.11.0" - m.stderr = "" - return m - m.returncode = 0 - return m - - with patch("hermes_cli.managed_uv.subprocess.run", side_effect=fake_run), \ - patch("hermes_cli.managed_uv.shutil.rmtree"): - from hermes_cli.managed_uv import rebuild_venv - result = rebuild_venv(uv_bin, venv_dir) - - assert result is True, "rebuild should succeed after --clear retry" - # We expect exactly two ``uv venv`` calls: one without --clear, one with. - venv_calls = [c for c in call_log if len(c) >= 2 and c[1] == "venv"] - assert len(venv_calls) == 2, f"expected 2 venv calls, got {venv_calls}" - assert "--clear" not in venv_calls[0], "first call should not pass --clear" - assert "--clear" in venv_calls[1], "retry must pass --clear" - - def test_does_not_retry_when_first_failure_is_not_dir_exists(self, tmp_path): - """If uv venv fails for some other reason (e.g. interpreter download - failed, disk full), we should NOT silently retry with --clear — - that would mask a real problem. Just surface the original failure.""" + def test_rebuild_success_without_python_returns_false(self, tmp_path): + """uv can exit 0 yet leave no interpreter; that must not count as success + (guard adapted from #38511).""" venv_dir = tmp_path / "venv" uv_bin = str(tmp_path / "bin" / "uv") - call_log: list[list[str]] = [] - def fake_run(cmd, **kwargs): - call_log.append(list(cmd)) - m = MagicMock(returncode=1, stderr="error: No space left on device", stdout="") - return m - - with patch("hermes_cli.managed_uv.subprocess.run", side_effect=fake_run), \ - patch("hermes_cli.managed_uv.shutil.rmtree"): + with patch("hermes_cli.managed_uv.subprocess.run") as mock_run: + mock_run.return_value = MagicMock(returncode=0, stdout="", stderr="") from hermes_cli.managed_uv import rebuild_venv result = rebuild_venv(uv_bin, venv_dir) - - assert result is False - venv_calls = [c for c in call_log if len(c) >= 2 and c[1] == "venv"] - assert len(venv_calls) == 1, "should not retry on non-dir-exists failures" - assert "--clear" not in venv_calls[0] + assert result is False + # Returned before the `python --version` probe ran (only the uv venv call). + assert mock_run.call_count == 1 # --------------------------------------------------------------------------- diff --git a/tests/hermes_cli/test_models.py b/tests/hermes_cli/test_models.py index d6ae4b1dd..21f1557d7 100644 --- a/tests/hermes_cli/test_models.py +++ b/tests/hermes_cli/test_models.py @@ -411,7 +411,7 @@ class TestUnionWithPortalFreeRecommendations: } def test_adds_portal_free_model_missing_from_curated(self): - """A Portal-advertised free model not in curated is prepended + priced free.""" + """A Portal-advertised free model not in curated is appended + priced free.""" curated = ["anthropic/claude-opus-4.6"] pricing = {"anthropic/claude-opus-4.6": self._PAID} with patch( @@ -420,8 +420,9 @@ class TestUnionWithPortalFreeRecommendations: ): ids, p = union_with_portal_free_recommendations(curated, pricing, "") - assert ids[0] == "qwen/qwen3.6-plus" # prepended - assert "anthropic/claude-opus-4.6" in ids + # Curated ("HA") models stay first; Portal-only picks follow. + assert ids[0] == "anthropic/claude-opus-4.6" + assert ids[-1] == "qwen/qwen3.6-plus" # appended # Synthetic free pricing entry created assert p["qwen/qwen3.6-plus"] == self._FREE # Existing pricing untouched @@ -509,7 +510,7 @@ class TestUnionWithPortalFreeRecommendations: }, ): ids, p = union_with_portal_free_recommendations(curated, pricing, "") - assert ids == ["qwen/qwen3.6-plus", "a"] + assert ids == ["a", "qwen/qwen3.6-plus"] assert p["qwen/qwen3.6-plus"] == self._FREE @@ -535,7 +536,7 @@ class TestUnionWithPortalPaidRecommendations: } def test_adds_portal_paid_model_missing_from_curated(self): - """A Portal-advertised paid model not in curated is prepended.""" + """A Portal-advertised paid model not in curated is appended.""" curated = ["anthropic/claude-opus-4.6"] pricing = {"anthropic/claude-opus-4.6": self._PAID} with patch( @@ -544,8 +545,9 @@ class TestUnionWithPortalPaidRecommendations: ): ids, p = union_with_portal_paid_recommendations(curated, pricing, "") - assert ids[0] == "openai/gpt-5.4" # prepended - assert "anthropic/claude-opus-4.6" in ids + # Curated ("HA") models stay first; Portal-only picks follow. + assert ids[0] == "anthropic/claude-opus-4.6" + assert ids[-1] == "openai/gpt-5.4" # appended # Existing pricing untouched assert p["anthropic/claude-opus-4.6"] == self._PAID @@ -634,12 +636,12 @@ class TestUnionWithPortalPaidRecommendations: }, ): ids, p = union_with_portal_paid_recommendations(curated, pricing, "") - assert ids == ["openai/gpt-5.4", "a"] + assert ids == ["a", "openai/gpt-5.4"] # No synthetic entry — pricing is untouched. assert "openai/gpt-5.4" not in p def test_preserves_relative_order_of_new_paid_models(self): - """Multiple new paid models are prepended in payload order.""" + """Multiple new paid models are appended in payload order, after curated.""" curated = ["anthropic/claude-opus-4.6"] pricing = {"anthropic/claude-opus-4.6": self._PAID} with patch( @@ -648,9 +650,9 @@ class TestUnionWithPortalPaidRecommendations: ): ids, _ = union_with_portal_paid_recommendations(curated, pricing, "") assert ids == [ + "anthropic/claude-opus-4.6", "openai/gpt-5.4", "openai/gpt-5.5", - "anthropic/claude-opus-4.6", ] diff --git a/tests/hermes_cli/test_tools_config.py b/tests/hermes_cli/test_tools_config.py index 008ffe1fd..5b24d2b6e 100644 --- a/tests/hermes_cli/test_tools_config.py +++ b/tests/hermes_cli/test_tools_config.py @@ -21,6 +21,7 @@ from hermes_cli.tools_config import ( _toolset_needs_configuration_prompt, CONFIGURABLE_TOOLSETS, TOOL_CATEGORIES, + gui_toolset_label, _visible_providers, tools_command, ) @@ -79,6 +80,13 @@ def test_get_platform_tools_uses_default_when_platform_not_configured(): assert enabled.isdisjoint(_DEFAULT_OFF_TOOLSETS) +def test_gui_toolset_label_strips_leading_emoji(): + assert gui_toolset_label("🔍 Web Search & Scraping") == "Web Search & Scraping" + assert gui_toolset_label("👁️ Vision / Image Analysis") == "Vision / Image Analysis" + assert gui_toolset_label("🔌 My Plugin") == "My Plugin" + assert gui_toolset_label("Terminal & Processes") == "Terminal & Processes" + + def test_configurable_toolsets_include_messaging(): assert any(ts_key == "messaging" for ts_key, _, _ in CONFIGURABLE_TOOLSETS) diff --git a/tests/hermes_cli/test_update_autostash.py b/tests/hermes_cli/test_update_autostash.py index adcd24bf3..b29179077 100644 --- a/tests/hermes_cli/test_update_autostash.py +++ b/tests/hermes_cli/test_update_autostash.py @@ -423,6 +423,41 @@ def test_cmd_update_succeeds_with_extras(monkeypatch, tmp_path): assert ".[all]" in install_cmds[0] +def test_cmd_update_aborts_when_fresh_managed_uv_rebuild_fails(monkeypatch, tmp_path): + """A failed fresh managed-uv venv rebuild must not continue into pip install + (guard adapted from #38511).""" + _setup_update_mocks(monkeypatch, tmp_path) + monkeypatch.setattr("shutil.which", lambda name: "/usr/bin/uv" if name == "uv" else None) + monkeypatch.setattr(hermes_main, "_is_termux_env", lambda env=None: False) + + recorded = [] + + def fake_run(cmd, **kwargs): + recorded.append(cmd) + # Tolerant matching: the update flow's exact git invocations vary by + # checkout, so key off the verb. Branch detection must return a real name + # and rev-list a parseable count, or the flow aborts early before it ever + # reaches the venv rebuild this test exercises. + if isinstance(cmd, (list, tuple)) and cmd and cmd[0] == "git": + if "rev-parse" in cmd: + return SimpleNamespace(stdout="main\n", stderr="", returncode=0) + if "rev-list" in cmd: + return SimpleNamespace(stdout="1\n", stderr="", returncode=0) + if "pull" in cmd: + return SimpleNamespace(stdout="Updating\n", stderr="", returncode=0) + return SimpleNamespace(returncode=0, stdout="", stderr="") + + monkeypatch.setattr(hermes_main.subprocess, "run", fake_run) + + with patch("hermes_cli.managed_uv.ensure_uv", return_value=("/usr/bin/uv", True)), \ + patch("hermes_cli.managed_uv.rebuild_venv", return_value=False), \ + pytest.raises(RuntimeError, match="venv rebuild failed"): + hermes_main.cmd_update(SimpleNamespace()) + + install_cmds = [c for c in recorded if "pip" in c and "install" in c] + assert install_cmds == [] + + def test_install_with_optional_fallback_honors_custom_group(monkeypatch): """Termux update path should target .[termux-all] when requested.""" calls = [] diff --git a/tests/hermes_cli/test_web_server.py b/tests/hermes_cli/test_web_server.py index adc2fb8ff..e690b43d4 100644 --- a/tests/hermes_cli/test_web_server.py +++ b/tests/hermes_cli/test_web_server.py @@ -815,6 +815,24 @@ class TestWebServerEndpoints: for key, info in data.items(): assert info["channel_managed"] is (key in channel_keys) + def test_platform_scoped_messaging_env_vars_are_channel_managed(self): + from hermes_cli.web_server import ( + _MESSAGING_KEYS_PAGE_KEYS, + _build_catalog_entry, + _channel_managed_env_keys, + ) + + discord = _build_catalog_entry("discord") + assert "DISCORD_HOME_CHANNEL" in discord["env_vars"] + assert "DISCORD_ALLOW_ALL_USERS" in discord["env_vars"] + + managed = _channel_managed_env_keys() + assert "DISCORD_HOME_CHANNEL" in managed + assert "BLUEBUBBLES_ALLOW_ALL_USERS" in managed + assert "MATTERMOST_ALLOW_ALL_USERS" in managed + assert "GATEWAY_PROXY_URL" not in managed + assert "GATEWAY_PROXY_URL" in _MESSAGING_KEYS_PAGE_KEYS + def test_reveal_env_var(self, tmp_path): """POST /api/env/reveal should return the real unredacted value.""" from hermes_cli.config import save_env_value @@ -974,6 +992,157 @@ class TestWebServerEndpoints: assert data["state"] == "not_configured" assert "DISCORD_BOT_TOKEN" in data["message"] + def test_telegram_onboarding_start_strips_poll_token(self, monkeypatch): + import hermes_cli.web_server as ws + + with ws._telegram_onboarding_lock: + ws._telegram_onboarding_pairings.clear() + + calls = [] + + def fake_request(method, path, *, body=None, bearer_token=None): + calls.append((method, path, body, bearer_token)) + return { + "pairing_id": "pair123", + "poll_token": "poll-secret", + "suggested_username": "hermes_pair123_bot", + "deep_link": "https://t.me/newbot/HermesSetupBot/hermes_pair123_bot", + "qr_payload": "https://t.me/newbot/HermesSetupBot/hermes_pair123_bot", + "expires_at": "2027-05-18T00:00:00.000Z", + } + + monkeypatch.setattr(ws, "_telegram_onboarding_request_sync", fake_request) + + resp = self.client.post( + "/api/messaging/telegram/onboarding/start", + json={"bot_name": "Hosted Hermes"}, + ) + + assert resp.status_code == 200 + data = resp.json() + assert data["pairing_id"] == "pair123" + assert "poll_token" not in data + assert calls == [ + ( + "POST", + "/v1/telegram/pairings", + {"bot_name": "Hosted Hermes"}, + None, + ) + ] + + def test_telegram_onboarding_ready_and_apply_never_returns_bot_token(self, monkeypatch): + import hermes_cli.web_server as ws + from hermes_cli.config import load_config, load_env + + with ws._telegram_onboarding_lock: + ws._telegram_onboarding_pairings.clear() + + def fake_request(method, path, *, body=None, bearer_token=None): + if method == "POST": + return { + "pairing_id": "pair-ready", + "poll_token": "poll-secret", + "suggested_username": "hermes_pair_ready_bot", + "deep_link": "https://t.me/newbot/HermesSetupBot/hermes_pair_ready_bot", + "qr_payload": "https://t.me/newbot/HermesSetupBot/hermes_pair_ready_bot", + "expires_at": "2027-05-18T00:00:00.000Z", + } + assert method == "GET" + assert path == "/v1/telegram/pairings/pair-ready" + assert bearer_token == "poll-secret" + return { + "status": "ready", + "bot_username": "hermes_pair_ready_bot", + "owner_user_id": 123456789, + "token": "123456:SECRET", + } + + monkeypatch.setattr(ws, "_telegram_onboarding_request_sync", fake_request) + + start = self.client.post("/api/messaging/telegram/onboarding/start", json={}) + assert start.status_code == 200 + + ready = self.client.get("/api/messaging/telegram/onboarding/pair-ready") + assert ready.status_code == 200 + ready_data = ready.json() + assert ready_data["status"] == "ready" + assert ready_data["owner_user_id"] == "123456789" + assert "token" not in ready_data + + applied = self.client.post( + "/api/messaging/telegram/onboarding/pair-ready/apply", + json={"allowed_user_ids": ["123456789", "123456789"]}, + ) + assert applied.status_code == 200 + applied_data = applied.json() + assert applied_data == { + "ok": True, + "platform": "telegram", + "bot_username": "hermes_pair_ready_bot", + "needs_restart": True, + } + env = load_env() + assert env["TELEGRAM_BOT_TOKEN"] == "123456:SECRET" + assert env["TELEGRAM_ALLOWED_USERS"] == "123456789" + assert load_config()["platforms"]["telegram"]["enabled"] is True + + def test_telegram_onboarding_apply_requires_ready_pairing(self, monkeypatch): + import hermes_cli.web_server as ws + + with ws._telegram_onboarding_lock: + ws._telegram_onboarding_pairings.clear() + + def fake_request(method, path, *, body=None, bearer_token=None): + return { + "pairing_id": "pair-waiting", + "poll_token": "poll-secret", + "suggested_username": "hermes_pair_waiting_bot", + "deep_link": "https://t.me/newbot/HermesSetupBot/hermes_pair_waiting_bot", + "qr_payload": "https://t.me/newbot/HermesSetupBot/hermes_pair_waiting_bot", + "expires_at": "2027-05-18T00:00:00.000Z", + } + + monkeypatch.setattr(ws, "_telegram_onboarding_request_sync", fake_request) + + start = self.client.post("/api/messaging/telegram/onboarding/start", json={}) + assert start.status_code == 200 + + resp = self.client.post( + "/api/messaging/telegram/onboarding/pair-waiting/apply", + json={"allowed_user_ids": ["123456789"]}, + ) + + assert resp.status_code == 409 + assert "not ready" in resp.json()["detail"] + + def test_telegram_onboarding_cancel_clears_local_session(self, monkeypatch): + import hermes_cli.web_server as ws + + with ws._telegram_onboarding_lock: + ws._telegram_onboarding_pairings.clear() + + def fake_request(method, path, *, body=None, bearer_token=None): + return { + "pairing_id": "pair-cancel", + "poll_token": "poll-secret", + "suggested_username": "hermes_pair_cancel_bot", + "deep_link": "https://t.me/newbot/HermesSetupBot/hermes_pair_cancel_bot", + "qr_payload": "https://t.me/newbot/HermesSetupBot/hermes_pair_cancel_bot", + "expires_at": "2027-05-18T00:00:00.000Z", + } + + monkeypatch.setattr(ws, "_telegram_onboarding_request_sync", fake_request) + + start = self.client.post("/api/messaging/telegram/onboarding/start", json={}) + assert start.status_code == 200 + + cancel = self.client.delete("/api/messaging/telegram/onboarding/pair-cancel") + assert cancel.status_code == 200 + + status = self.client.get("/api/messaging/telegram/onboarding/pair-cancel") + assert status.status_code == 404 + def test_session_token_endpoint_removed(self): """GET /api/auth/session-token should no longer exist (token injected via HTML).""" resp = self.client.get("/api/auth/session-token") @@ -1967,7 +2136,7 @@ class TestNewEndpoints: assert resp.json() == [ { "name": "web", - "label": "🔍 Web Search & Scraping", + "label": "Web Search & Scraping", "description": "web_search, web_extract", "enabled": True, "available": True, @@ -1976,7 +2145,7 @@ class TestNewEndpoints: }, { "name": "skills", - "label": "📚 Skills", + "label": "Skills", "description": "list, view, manage", "enabled": True, "available": True, @@ -1985,7 +2154,7 @@ class TestNewEndpoints: }, { "name": "memory", - "label": "💾 Memory", + "label": "Memory", "description": "persistent memory across sessions", "enabled": False, "available": False, @@ -4015,4 +4184,3 @@ class TestValidateProviderCredential: def test_empty_value_rejected(self): data = self._post("OPENAI_API_KEY", " ").json() assert data["ok"] is False - diff --git a/tests/hermes_cli/test_web_server_session_search.py b/tests/hermes_cli/test_web_server_session_search.py new file mode 100644 index 000000000..e233e29bb --- /dev/null +++ b/tests/hermes_cli/test_web_server_session_search.py @@ -0,0 +1,90 @@ +import asyncio + +from hermes_cli import web_server + + +class _FakeSessionDB: + """Fake backing the /api/sessions/search endpoint. + + The endpoint surfaces direct session-id matches first, then FTS message + matches, deduping both by compression lineage root. This fake has no + compression chains (get_session returns no parent), so each session is its + own lineage root. + """ + + closed = False + + def search_sessions_by_id(self, query, limit=20, include_archived=True): + assert query == "20260603" + assert include_archived is True + return [ + { + "id": "20260603_090200_exact", + "preview": "ID match preview", + "source": "cli", + "model": "claude", + "started_at": 100, + } + ] + + def search_messages(self, query, limit=20): + assert query == "20260603*" + return [ + { + "session_id": "20260603_090200_exact", + "snippet": "duplicate content hit should not replace ID hit", + "role": "user", + "source": "cli", + "model": "claude", + "session_started": 100, + }, + { + "session_id": "content_session", + "snippet": "content hit", + "role": "assistant", + "source": "desktop", + "model": "gpt", + "session_started": 200, + }, + ] + + def get_session(self, session_id): + # No compression chains in this fixture — every session is its own root. + return {"id": session_id, "parent_session_id": None} + + def get_compression_tip(self, session_id): + return session_id + + def close(self): + self.closed = True + + +def test_desktop_session_search_merges_id_matches_before_content_matches(monkeypatch): + monkeypatch.setattr("hermes_state.SessionDB", _FakeSessionDB) + + response = asyncio.run(web_server.search_sessions(q="20260603", limit=2)) + + # ID match surfaces first; the content hit on the SAME session is deduped + # by lineage root (not double-listed); the unrelated content hit follows. + assert response == { + "results": [ + { + "session_id": "20260603_090200_exact", + "lineage_root": "20260603_090200_exact", + "snippet": "ID match preview", + "role": None, + "source": "cli", + "model": "claude", + "session_started": 100, + }, + { + "session_id": "content_session", + "lineage_root": "content_session", + "snippet": "content hit", + "role": "assistant", + "source": "desktop", + "model": "gpt", + "session_started": 200, + }, + ] + } diff --git a/tests/openviking_plugin/test_openviking.py b/tests/openviking_plugin/test_openviking.py index 6848afc47..505ac54eb 100644 --- a/tests/openviking_plugin/test_openviking.py +++ b/tests/openviking_plugin/test_openviking.py @@ -231,3 +231,53 @@ class TestOpenVikingBrowse: "/api/v1/fs/ls", {"uri": "viking://user/hermes"}, )] + + +class TestOpenVikingMemoryUriBuilder: + """Regression tests for _build_memory_uri — fixes #36969. + + Before the fix the URI omitted /agent/{agent}/, causing all agents + under the same user to share the same memory namespace. + """ + + def _make_provider(self, user="alice", agent="coder"): + p = OpenVikingMemoryProvider.__new__(OpenVikingMemoryProvider) + p._user = user + p._agent = agent + return p + + def test_uri_layout_includes_agent_segment(self): + """URI must contain /agent/{agent}/ between user and memories.""" + p = self._make_provider(user="alice", agent="coder") + uri = p._build_memory_uri("preferences") + assert uri.startswith("viking://user/alice/agent/coder/memories/preferences/mem_") + assert uri.endswith(".md") + + def test_uri_uses_configured_agent_not_default(self): + """_agent value must be interpolated — not hardcoded to 'hermes'.""" + p = self._make_provider(user="alice", agent="research-bot") + uri = p._build_memory_uri("entities") + assert "/agent/research-bot/" in uri + assert "/agent/hermes/" not in uri + + def test_uri_slug_is_twelve_hex_chars_and_unique(self): + """Slug must be 12 hex chars and differ between calls.""" + import re + p = self._make_provider() + uri1 = p._build_memory_uri("preferences") + uri2 = p._build_memory_uri("preferences") + slug1 = uri1.split("/mem_")[1].replace(".md", "") + slug2 = uri2.split("/mem_")[1].replace(".md", "") + assert re.fullmatch(r"[0-9a-f]{12}", slug1) + assert re.fullmatch(r"[0-9a-f]{12}", slug2) + assert slug1 != slug2 + + def test_uri_subdir_placed_correctly_for_all_categories(self): + """All five category subdirs must appear between memories/ and slug.""" + p = self._make_provider(user="u", agent="a") + subdirs = ["preferences", "entities", "events", "cases", "patterns"] + for subdir in subdirs: + uri = p._build_memory_uri(subdir) + assert f"/memories/{subdir}/mem_" in uri, ( + f"subdir '{subdir}' not placed correctly in URI: {uri}" + ) diff --git a/tests/plugins/test_disk_cleanup_plugin.py b/tests/plugins/test_disk_cleanup_plugin.py index 4f7f66e02..783644d38 100644 --- a/tests/plugins/test_disk_cleanup_plugin.py +++ b/tests/plugins/test_disk_cleanup_plugin.py @@ -170,6 +170,135 @@ class TestGuessCategory: assert dg.guess_category(p) is None +class TestStaleCronEntryMigration: + """Regression tests for #37721 — stale cron-output entries in tracked.json.""" + + def test_quick_skips_stale_cron_output_for_jobs_json(self, _isolate_env): + """A stale tracked.json entry with category="cron-output" for + cron/jobs.json must NOT be deleted by quick(). + + This is the exact scenario from #37721: an old tracked.json has + {"path": ".../cron/jobs.json", "category": "cron-output"} which + would pass the delete filter but must be skipped because + guess_category() now returns None for non-output cron paths. + """ + dg = _load_lib() + cron_dir = _isolate_env / "cron" + cron_dir.mkdir() + jobs_json = cron_dir / "jobs.json" + jobs_json.write_text('{"jobs": []}') + + # Simulate a stale tracked.json entry from before #34840 by + # directly writing the tracked file (track() would reject it). + tracked_file = _isolate_env / "disk-cleanup" / "tracked.json" + tracked_file.parent.mkdir(parents=True, exist_ok=True) + tracked_file.write_text(json.dumps([{ + "path": str(jobs_json), + "category": "cron-output", + "timestamp": "2025-01-01T00:00:00+00:00", # very old + "size": 123, + }])) + + summary = dg.quick() + assert summary["deleted"] == 0, "cron/jobs.json must not be deleted" + assert jobs_json.exists(), "jobs.json must still exist" + # The stale entry should have been dropped from tracking. + remaining = json.loads(tracked_file.read_text()) + assert len(remaining) == 0 + + def test_quick_skips_stale_cron_output_for_cron_dir(self, _isolate_env): + """Stale entry for the cron/ directory itself must not be deleted.""" + dg = _load_lib() + cron_dir = _isolate_env / "cron" + cron_dir.mkdir() + output_dir = cron_dir / "output" + output_dir.mkdir() + (output_dir / "run.md").write_text("x") + + tracked_file = _isolate_env / "disk-cleanup" / "tracked.json" + tracked_file.parent.mkdir(parents=True, exist_ok=True) + tracked_file.write_text(json.dumps([{ + "path": str(cron_dir), + "category": "cron-output", + "timestamp": "2025-01-01T00:00:00+00:00", + "size": 0, + }])) + + summary = dg.quick() + assert summary["deleted"] == 0, "cron/ dir must not be deleted" + assert cron_dir.exists() + + def test_quick_skips_protected_cron_paths_defense_in_depth(self, _isolate_env): + """Defense-in-depth: even if guess_category returned cron-output + (hypothetically), protected cron paths are never deleted.""" + dg = _load_lib() + cron_dir = _isolate_env / "cron" + cron_dir.mkdir() + tick_lock = cron_dir / ".tick.lock" + tick_lock.write_text("") + + # Manually inject a stale entry with "test" category (would normally + # be auto-deleted) — the protected path guard must still block it. + tracked_file = _isolate_env / "disk-cleanup" / "tracked.json" + tracked_file.parent.mkdir(parents=True, exist_ok=True) + tracked_file.write_text(json.dumps([{ + "path": str(tick_lock), + "category": "test", + "timestamp": "2025-01-01T00:00:00+00:00", + "size": 0, + }])) + + summary = dg.quick() + assert summary["deleted"] == 0, ".tick.lock must not be deleted" + assert tick_lock.exists() + + def test_dry_run_omits_stale_cron_output(self, _isolate_env): + """dry_run() should also skip stale cron-output entries.""" + dg = _load_lib() + cron_dir = _isolate_env / "cron" + cron_dir.mkdir() + jobs_json = cron_dir / "jobs.json" + jobs_json.write_text("[]") + + tracked_file = _isolate_env / "disk-cleanup" / "tracked.json" + tracked_file.parent.mkdir(parents=True, exist_ok=True) + tracked_file.write_text(json.dumps([{ + "path": str(jobs_json), + "category": "cron-output", + "timestamp": "2025-01-01T00:00:00+00:00", + "size": 123, + }])) + + auto, prompt = dg.dry_run() + assert len(auto) == 0, "stale cron-output for jobs.json must not appear" + assert len(prompt) == 0 + + def test_legitimate_cron_output_still_deleted(self, _isolate_env): + """A valid cron-output entry under cron/output/ must still be deleted.""" + dg = _load_lib() + output_dir = _isolate_env / "cron" / "output" / "job_1" + output_dir.mkdir(parents=True) + run_md = output_dir / "run.md" + run_md.write_text("x") + + # Old enough to be deleted (>14 days) + from datetime import datetime, timezone, timedelta + old_ts = (datetime.now(timezone.utc) - timedelta(days=20)).isoformat() + + tracked_file = _isolate_env / "disk-cleanup" / "tracked.json" + tracked_file.parent.mkdir(parents=True, exist_ok=True) + tracked_file.write_text(json.dumps([{ + "path": str(run_md), + "category": "cron-output", + "timestamp": old_ts, + "size": 10, + }])) + + summary = dg.quick() + assert summary["deleted"] == 1, "valid old cron-output should be deleted" + assert not run_md.exists() + + class TestTrackForgetQuick: def test_track_then_quick_deletes_test(self, _isolate_env): dg = _load_lib() diff --git a/tests/test_hermes_state.py b/tests/test_hermes_state.py index 572fd6489..8d0b55775 100644 --- a/tests/test_hermes_state.py +++ b/tests/test_hermes_state.py @@ -2716,6 +2716,44 @@ class TestListSessionsRich: ids = [s["id"] for s in sessions] assert "branch" in ids, "Branch session should be visible in default list" + def test_branch_session_visible_after_parent_reopen_and_reend(self, db): + """Branch sessions stay visible after the parent is reopened and re-ended. + + Regression for issue #20856: /branch (aka /fork) sessions vanished from + /resume and /sessions once the parent was reopened (e.g. resumed) and + re-ended with a different end_reason — tui_shutdown overwriting + 'branched' — which broke the legacy end_reason heuristic. The stable + _branched_from marker in model_config keeps them visible. + """ + import json as _json + + db.create_session("parent", "cli") + db.end_session("parent", "branched") + db.create_session( + "branch", + "cli", + model_config={"_branched_from": "parent"}, + parent_session_id="parent", + ) + db.append_message("branch", "user", "Exploring the alternative approach") + + # Marker is persisted at creation time. + branch_row = db.get_session("branch") + cfg = _json.loads(branch_row["model_config"]) if branch_row["model_config"] else {} + assert cfg.get("_branched_from") == "parent" + + # Visible immediately after branching. + assert "branch" in [s["id"] for s in db.list_sessions_rich()] + + # Parent reopened + re-ended with a different reason (the bug trigger). + db.reopen_session("parent") + db.end_session("parent", "tui_shutdown") + + # Branch must STILL be visible — the marker survives the parent's + # end_reason churn, unlike the legacy 'branched' heuristic. + ids = [s["id"] for s in db.list_sessions_rich()] + assert "branch" in ids, "Branch should stay visible after parent re-end" + def test_subagent_session_still_hidden(self, db): """Sub-agent children (parent NOT ended with 'branched') remain hidden.""" db.create_session("root", "cli") @@ -3786,3 +3824,57 @@ class TestSessionArchive: both = {s["id"] for s in db.list_sessions_rich(include_archived=True)} assert both == {"live", "hidden"} assert db.session_count(include_archived=True) == 2 + + + +class TestSessionIdSearch: + """Session id search backs Desktop's Search Sessions UX.""" + + def _seed(self, db, sid, *, content="ordinary message", archived=False): + db.create_session(session_id=sid, source="cli", model="test-model") + db.append_message(session_id=sid, role="user", content=content) + if archived: + db.set_session_archived(sid, True) + + def test_search_sessions_by_id_matches_exact_prefix_and_substring(self, db): + self._seed(db, "20260603_090200_abcd12", content="content without id") + self._seed(db, "20260602_111111_other99", content="other content") + + assert [s["id"] for s in db.search_sessions_by_id("20260603_090200_abcd12")] == [ + "20260603_090200_abcd12" + ] + assert [s["id"] for s in db.search_sessions_by_id("20260603")] == ["20260603_090200_abcd12"] + assert [s["id"] for s in db.search_sessions_by_id("ABCD12")] == ["20260603_090200_abcd12"] + + def test_search_sessions_by_id_respects_limit_and_prioritizes_exact_matches(self, db): + self._seed(db, "20260603_090200_abcd12") + self._seed(db, "20260603_090200_abcd12_child") + self._seed(db, "x_20260603_090200_abcd12") + + ids = [s["id"] for s in db.search_sessions_by_id("20260603_090200_abcd12", limit=2)] + + assert ids == ["20260603_090200_abcd12", "20260603_090200_abcd12_child"] + + def test_search_sessions_by_id_can_include_or_exclude_archived(self, db): + self._seed(db, "20260603_090200_live") + self._seed(db, "20260603_090200_archived", archived=True) + + included = {s["id"] for s in db.search_sessions_by_id("20260603_090200", include_archived=True)} + excluded = {s["id"] for s in db.search_sessions_by_id("20260603_090200", include_archived=False)} + + assert included == {"20260603_090200_live", "20260603_090200_archived"} + assert excluded == {"20260603_090200_live"} + + def test_search_sessions_by_id_matches_projected_lineage_root_id(self, db): + root = "20260602_235959_root99" + tip = "20260603_010000_tip01" + db.create_session(session_id=root, source="cli") + db.append_message(root, role="user", content="root conversation") + db.end_session(root, "compression") + db.create_session(session_id=tip, source="cli", parent_session_id=root) + db.append_message(tip, role="user", content="continued conversation") + + matches = db.search_sessions_by_id("root99") + + assert [s["id"] for s in matches] == [tip] + assert matches[0]["_lineage_root_id"] == root diff --git a/tests/test_model_tools_async_bridge.py b/tests/test_model_tools_async_bridge.py index 81ffb2cc6..54fce36d2 100644 --- a/tests/test_model_tools_async_bridge.py +++ b/tests/test_model_tools_async_bridge.py @@ -372,7 +372,8 @@ class TestVisionDispatchLoopSafety: side_effect=lambda url, dest, **kw: _write_fake_image(dest), ), patch( - "tools.vision_tools._validate_image_url", + "tools.vision_tools._validate_image_url_async", + new_callable=AsyncMock, return_value=True, ), patch( @@ -416,7 +417,8 @@ class TestVisionDispatchLoopSafety: side_effect=lambda url, dest, **kw: _write_fake_image(dest), ), patch( - "tools.vision_tools._validate_image_url", + "tools.vision_tools._validate_image_url_async", + new_callable=AsyncMock, return_value=True, ), patch( diff --git a/tests/test_project_metadata.py b/tests/test_project_metadata.py index 29f32c6bb..4ad532c7c 100644 --- a/tests/test_project_metadata.py +++ b/tests/test_project_metadata.py @@ -88,6 +88,17 @@ def test_lazy_installable_extras_excluded_from_all(): ) +def test_dev_extra_excluded_from_all(): + """End-user installs should not pull test/lint/debug tooling.""" + optional_dependencies = _load_optional_dependencies() + + assert "dev" in optional_dependencies + assert not any( + spec == "hermes-agent[dev]" + for spec in optional_dependencies["all"] + ) + + def test_messaging_extra_includes_qrcode_for_weixin_setup(): optional_dependencies = _load_optional_dependencies() diff --git a/tests/test_tui_gateway_server.py b/tests/test_tui_gateway_server.py index ef94dc27a..7899a6de4 100644 --- a/tests/test_tui_gateway_server.py +++ b/tests/test_tui_gateway_server.py @@ -5474,6 +5474,8 @@ def test_notification_poller_requeues_when_busy(monkeypatch): assert requeued["session_id"] == "proc_busy_test" finally: server._sessions.pop("sid_busy", None) + while not process_registry.completion_queue.empty(): + process_registry.completion_queue.get_nowait() def test_session_save_writes_under_hermes_home_with_system_prompt(monkeypatch, tmp_path): @@ -5533,3 +5535,95 @@ def test_session_save_writes_under_hermes_home_with_system_prompt(monkeypatch, t assert payload["session_start"] == "2026-01-01T12:00:00" assert payload["system_prompt"] == "You are Hermes." assert payload["messages"] == history + + +def test_notification_event_dedup_key_preserves_distinct_watch_matches(): + """Watch-match identity includes match content, not just session/type.""" + base = { + "type": "watch_match", + "session_id": "proc_watch", + "command": "tail -f app.log", + "pattern": "READY", + "output": "READY on port 8000", + "suppressed": 0, + } + + identical = dict(base) + distinct_output = {**base, "output": "READY on port 9000"} + distinct_pattern = {**base, "pattern": "MIGRATION_DONE"} + + base_key = server._notification_event_dedup_key(base) + assert server._notification_event_dedup_key(identical) == base_key + assert server._notification_event_dedup_key(distinct_output) != base_key + assert server._notification_event_dedup_key(distinct_pattern) != base_key + + +def test_notification_poller_emits_distinct_watch_matches_once(monkeypatch): + """Distinct watch matches from one process emit; exact replay is deduped.""" + from tools.process_registry import process_registry + + turns = [] + emitted = [] + + def _fake_run_prompt_submit(rid, sid, session, text): + turns.append(text) + with session["history_lock"]: + session["running"] = False + + sess = _session() + server._sessions["sid_watch_dedup"] = sess + monkeypatch.setattr(server, "_emit", lambda *a, **kw: emitted.append(a)) + monkeypatch.setattr(server, "_run_prompt_submit", _fake_run_prompt_submit) + + while not process_registry.completion_queue.empty(): + process_registry.completion_queue.get_nowait() + + base = { + "type": "watch_match", + "session_id": "proc_watch_dedup", + "command": "tail -f app.log", + "pattern": "READY", + "output": "READY on port 8000", + "suppressed": 0, + } + process_registry.completion_queue.put(base) + process_registry.completion_queue.put({**base, "output": "READY on port 9000"}) + process_registry.completion_queue.put(dict(base)) + + stop = threading.Event() + stop.set() + + try: + server._notification_poller_loop(stop, "sid_watch_dedup", sess) + status_calls = [a for a in emitted if a[0] == "status.update"] + assert len(status_calls) == 2 + status_text = "\n".join(call[2]["text"] for call in status_calls) + assert "READY on port 8000" in status_text + assert "READY on port 9000" in status_text + assert len(turns) == 3 + finally: + server._sessions.pop("sid_watch_dedup", None) + while not process_registry.completion_queue.empty(): + process_registry.completion_queue.get_nowait() + + +def test_notification_event_dedup_key_keeps_completions_one_shot(): + """Completion identity remains process-session scoped to avoid floods.""" + first = { + "type": "completion", + "session_id": "proc_done", + "command": "make build", + "exit_code": 0, + "output": "first output", + } + replay = { + "type": "completion", + "session_id": "proc_done", + "command": "make build --again", + "exit_code": 1, + "output": "different output should not change completion key", + } + + assert server._notification_event_dedup_key(first) == server._notification_event_dedup_key( + replay + ) diff --git a/tests/tools/test_docker_environment.py b/tests/tools/test_docker_environment.py index 099e167e7..04935d81d 100644 --- a/tests/tools/test_docker_environment.py +++ b/tests/tools/test_docker_environment.py @@ -44,6 +44,7 @@ def _make_dummy_env(**kwargs): auto_mount_cwd=kwargs.get("auto_mount_cwd", False), env=kwargs.get("env"), run_as_host_user=kwargs.get("run_as_host_user", False), + persist_across_processes=kwargs.get("persist_across_processes", True), ) @@ -786,6 +787,84 @@ def test_reuse_falls_back_to_fresh_run_when_start_fails(monkeypatch): assert run_invocations, "fallback to fresh docker run must happen on start failure" +def test_failed_docker_run_cleans_up_orphaned_container(monkeypatch): + """When ``docker run`` fails (e.g. exit 125), the partially-created + container must be removed by name. + + Docker can create the container object before failing to start it, + leaving a stale ``Created`` container. The exited-only orphan reaper + (``reap_orphan_containers``, ``status=exited``) never catches a + ``Created`` orphan, so without this cleanup it leaks permanently. + Regression for #7439. Salvage of #7440 (@Tranquil-Flow). + """ + monkeypatch.setattr(docker_env, "find_docker", lambda: "/usr/bin/docker") + monkeypatch.setattr(docker_env, "_get_active_profile_name", lambda: "default") + + cleanup_calls = [] + + def _run(cmd, **kwargs): + if isinstance(cmd, list) and len(cmd) >= 2: + sub = cmd[1] + if sub == "version": + return subprocess.CompletedProcess(cmd, 0, stdout="Docker version", stderr="") + if sub == "ps": + # No reusable container -> fall through to a fresh `docker run`. + return subprocess.CompletedProcess(cmd, 0, stdout="", stderr="") + if sub == "run": + raise subprocess.CalledProcessError( + 125, cmd, output="", stderr="docker: Error response from daemon" + ) + if sub == "rm": + cleanup_calls.append(list(cmd)) + return subprocess.CompletedProcess(cmd, 0, stdout="", stderr="") + return subprocess.CompletedProcess(cmd, 0, stdout="", stderr="") + + monkeypatch.setattr(docker_env.subprocess, "run", _run) + + with pytest.raises(subprocess.CalledProcessError): + _make_dummy_env() + + assert len(cleanup_calls) == 1, "docker rm should be called once for the orphaned container" + rm_cmd = cleanup_calls[0] + assert rm_cmd[1] == "rm" and rm_cmd[2] == "-f" + assert rm_cmd[3].startswith("hermes-"), "should remove the container by its generated name" + + +def test_docker_run_timeout_cleans_up_orphaned_container(monkeypatch): + """When ``docker run`` times out (e.g. slow image pull), the + partially-created container must be removed. Salvage of #7440 + (@Tranquil-Flow); regression for #7439. + """ + monkeypatch.setattr(docker_env, "find_docker", lambda: "/usr/bin/docker") + monkeypatch.setattr(docker_env, "_get_active_profile_name", lambda: "default") + + cleanup_calls = [] + + def _run(cmd, **kwargs): + if isinstance(cmd, list) and len(cmd) >= 2: + sub = cmd[1] + if sub == "version": + return subprocess.CompletedProcess(cmd, 0, stdout="Docker version", stderr="") + if sub == "ps": + return subprocess.CompletedProcess(cmd, 0, stdout="", stderr="") + if sub == "run": + raise subprocess.TimeoutExpired(cmd, 120) + if sub == "rm": + cleanup_calls.append(list(cmd)) + return subprocess.CompletedProcess(cmd, 0, stdout="", stderr="") + return subprocess.CompletedProcess(cmd, 0, stdout="", stderr="") + + monkeypatch.setattr(docker_env.subprocess, "run", _run) + + with pytest.raises(subprocess.TimeoutExpired): + _make_dummy_env() + + assert len(cleanup_calls) == 1, "docker rm should be called once for the orphaned container" + rm_cmd = cleanup_calls[0] + assert rm_cmd[1] == "rm" and rm_cmd[2] == "-f" + assert rm_cmd[3].startswith("hermes-"), "should remove the container by its generated name" + + def test_no_reuse_when_persist_across_processes_disabled(monkeypatch): """Opt-out path: ``persist_across_processes=False`` skips the ps probe entirely and always starts a fresh container, matching the pre-fix @@ -1629,3 +1708,128 @@ def test_plain_image_keeps_docker_init_and_run_noexec(monkeypatch): assert "noexec" in run_mounts[0], ( f"/run must stay noexec for non-s6 images, got: {run_mounts[0]}" ) + + +# --------------------------------------------------------------------------- +# Out-of-band container removal recovery (issue #36266, PR #36631) +# --------------------------------------------------------------------------- + + +def test_is_container_gone_matches_removal_errors(monkeypatch): + """``_is_container_gone`` recognizes the docker errors that mean the + container no longer exists, and does NOT match ordinary command failures. + """ + monkeypatch.setattr(docker_env, "find_docker", lambda: "/usr/bin/docker") + _mock_subprocess_run(monkeypatch) + env = _make_dummy_env() + + # Positive: the daemon's "container gone" phrasings. + assert env._is_container_gone( + "Error response from daemon: No such container: hermes-abc123" + ) + assert env._is_container_gone("Error: No such container: deadbeef") + assert env._is_container_gone( + "Error response from daemon: Container abc is not running" + ) + + # Control / negative: a real command failure must NOT be misclassified as + # the container being gone — otherwise every non-zero exit would trigger a + # spurious container recreation. + assert not env._is_container_gone("bash: nonsuch: command not found") + assert not env._is_container_gone("Traceback (most recent call last): ...") + assert not env._is_container_gone("") + assert not env._is_container_gone("permission denied") + + +def test_execute_recovers_from_out_of_band_removal(monkeypatch): + """When a persistent container is removed out-of-band, ``execute`` detects + the "No such container" error, recreates the container, and retries once — + returning success transparently. + """ + monkeypatch.setattr(docker_env, "find_docker", lambda: "/usr/bin/docker") + _mock_subprocess_run(monkeypatch) + env = _make_dummy_env( + persistent_filesystem=True, + persist_across_processes=True, + ) + + # First execute() sees a dead container; second (post-recovery) succeeds. + outputs = iter([ + {"output": "Error response from daemon: No such container: hermes-x", "returncode": 1}, + {"output": "ok", "returncode": 0}, + ]) + + def _fake_super_execute(self, command, cwd="", **kwargs): + return next(outputs) + + recreate_calls = [] + + def _fake_recreate(self): + recreate_calls.append(True) + self._container_id = "recovered-container-id" + return True + + monkeypatch.setattr(docker_env.BaseEnvironment, "execute", _fake_super_execute) + monkeypatch.setattr( + docker_env.DockerEnvironment, "_recreate_container", _fake_recreate + ) + + result = env.execute("echo hi") + + assert recreate_calls == [True], "recovery should have been attempted exactly once" + assert result.get("returncode") == 0, f"expected success after recovery, got {result!r}" + assert result.get("output") == "ok" + + +def test_execute_does_not_recover_when_not_persistent(monkeypatch): + """A non-persistent session must NOT trigger container recreation on a + "No such container" error — recovery is only meaningful for the persistent, + cross-process container that can be removed out-of-band. + """ + monkeypatch.setattr(docker_env, "find_docker", lambda: "/usr/bin/docker") + _mock_subprocess_run(monkeypatch) + env = _make_dummy_env( + persistent_filesystem=True, + persist_across_processes=False, + ) + + def _fake_super_execute(self, command, cwd="", **kwargs): + return {"output": "No such container: x", "returncode": 1} + + def _fail_recreate(self): + pytest.fail("recreation must not run when persist_across_processes is False") + + monkeypatch.setattr(docker_env.BaseEnvironment, "execute", _fake_super_execute) + monkeypatch.setattr( + docker_env.DockerEnvironment, "_recreate_container", _fail_recreate + ) + + result = env.execute("echo hi") + assert result.get("returncode") == 1, "the original error must pass through unchanged" + + +def test_execute_does_not_recover_on_ordinary_failure(monkeypatch): + """A genuine non-zero exit that is NOT a container-gone error must pass + through without triggering recovery (guards against over-eager recreation). + """ + monkeypatch.setattr(docker_env, "find_docker", lambda: "/usr/bin/docker") + _mock_subprocess_run(monkeypatch) + env = _make_dummy_env( + persistent_filesystem=True, + persist_across_processes=True, + ) + + def _fake_super_execute(self, command, cwd="", **kwargs): + return {"output": "bash: badcmd: command not found", "returncode": 127} + + def _fail_recreate(self): + pytest.fail("recreation must not run for an ordinary command failure") + + monkeypatch.setattr(docker_env.BaseEnvironment, "execute", _fake_super_execute) + monkeypatch.setattr( + docker_env.DockerEnvironment, "_recreate_container", _fail_recreate + ) + + result = env.execute("badcmd") + assert result.get("returncode") == 127 + assert "command not found" in result.get("output", "") diff --git a/tests/tools/test_send_message_tool.py b/tests/tools/test_send_message_tool.py index 10a486865..56b196a47 100644 --- a/tests/tools/test_send_message_tool.py +++ b/tests/tools/test_send_message_tool.py @@ -595,6 +595,7 @@ class TestSendToPlatformChunking: "***", "C123", "*hello* from <https://example.com|Hermes>", + thread_ts=None, ) def test_slack_bold_italic_formatted_before_send(self, monkeypatch): diff --git a/tests/tools/test_ssh_bulk_upload.py b/tests/tools/test_ssh_bulk_upload.py index a2fa82e6c..c7d38f182 100644 --- a/tests/tools/test_ssh_bulk_upload.py +++ b/tests/tools/test_ssh_bulk_upload.py @@ -90,7 +90,12 @@ class TestSSHBulkUpload: assert "/home/testuser/.hermes/credentials" in mkdir_str def test_staging_symlinks_mirror_remote_layout(self, mock_env, tmp_path): - """Symlinks in staging dir should mirror the .hermes-relative layout.""" + """Staged file in staging dir should mirror the remote path structure. + + On platforms where symlinks are available (Linux/macOS) the staged + entry is a symlink; on Windows it may be a regular copy. Either way + the file must exist at the expected path and contain the right data. + """ f1 = tmp_path / "local_a.txt" f1.write_text("content a") @@ -105,11 +110,14 @@ class TestSSHBulkUpload: # Capture the staging dir from -C argument c_idx = cmd.index("-C") staging_dir = cmd[c_idx + 1] - # Check the symlink exists + # Check the staged entry exists at the base-relative path expected = os.path.join(staging_dir, "skills/my_skill.md") staging_paths.append(expected) - assert os.path.islink(expected), f"Expected symlink at {expected}" - assert os.readlink(expected) == os.path.abspath(str(f1)) + # File must exist (either as symlink or copy) + assert os.path.exists(expected), f"Expected staged file at {expected}" + # Content must match the source + with open(expected, "r") as fh: + assert fh.read() == "content a" mock = MagicMock() mock.stdout = MagicMock() diff --git a/tests/tools/test_terminal_config_env_sync.py b/tests/tools/test_terminal_config_env_sync.py index 161318434..a3b9b14dd 100644 --- a/tests/tools/test_terminal_config_env_sync.py +++ b/tests/tools/test_terminal_config_env_sync.py @@ -156,14 +156,15 @@ def test_cli_and_gateway_env_maps_agree(): def test_save_config_set_supports_critical_bridged_keys(): """``hermes config set terminal.X true`` must propagate to .env for - known-critical keys. This used to be an all-keys invariant but several - pre-existing terminal keys (ssh_*, docker_forward_env, docker_volumes) - aren't in _config_to_env_sync and are instead handled via the separate - api_keys TERMINAL_SSH_* fallback path or user-edits-yaml-directly. + known-critical keys. This used to be an all-keys invariant but the SSH + terminal keys (ssh_*) aren't in _config_to_env_sync and are instead + handled via the separate api_keys TERMINAL_SSH_* fallback path or + user-edits-yaml-directly. Until those gaps are audited and fixed, pin the specific keys that are - load-bearing for the docker backend's ownership flag so the bug we just - fixed cannot silently regress. + load-bearing for the docker backend so the bugs we fixed cannot silently + regress. (docker_volumes / docker_forward_env, previously listed here as + gaps, are now bridged — see the dedicated tests below.) """ save_keys = _save_config_env_sync_keys() required = { @@ -260,3 +261,37 @@ def test_docker_orphan_reaper_is_bridged_everywhere(): assert "docker_orphan_reaper" in _gateway_env_map_keys() assert "docker_orphan_reaper" in _save_config_env_sync_keys() assert "TERMINAL_DOCKER_ORPHAN_REAPER" in _terminal_tool_env_var_names() + + +def test_docker_volumes_is_bridged_everywhere(): + """Regression pin for ``terminal.docker_volumes`` being silently dropped by + ``hermes config set``. + + The JSON list of ``host:container`` bind mounts was bridged by cli.py and + gateway/run.py and consumed by terminal_tool (via json.loads), but was + missing from set_config_value's _config_to_env_sync. So + ``hermes config set terminal.docker_volumes '["/host:/workspace"]'`` wrote + config.yaml yet left the running process's TERMINAL_DOCKER_VOLUMES stale — + the mounts didn't apply until a full restart. Same four-site bridge + invariant as docker_env / docker_run_as_host_user. + """ + assert "docker_volumes" in _cli_env_map_keys() + assert "docker_volumes" in _gateway_env_map_keys() + assert "docker_volumes" in _save_config_env_sync_keys() + assert "TERMINAL_DOCKER_VOLUMES" in _terminal_tool_env_var_names() + + +def test_docker_forward_env_is_bridged_everywhere(): + """Regression pin for ``terminal.docker_forward_env`` — the sibling gap to + docker_volumes. + + The JSON list of host env-var names forwarded into the container was + bridged by cli.py and gateway/run.py and consumed by terminal_tool (via + json.loads), but missing from set_config_value's _config_to_env_sync, so + ``hermes config set terminal.docker_forward_env '["GITHUB_TOKEN"]'`` had no + effect on the running process until restart. + """ + assert "docker_forward_env" in _cli_env_map_keys() + assert "docker_forward_env" in _gateway_env_map_keys() + assert "docker_forward_env" in _save_config_env_sync_keys() + assert "TERMINAL_DOCKER_FORWARD_ENV" in _terminal_tool_env_var_names() diff --git a/tests/tools/test_url_safety.py b/tests/tools/test_url_safety.py index 8513a848b..a5e00dcf6 100644 --- a/tests/tools/test_url_safety.py +++ b/tests/tools/test_url_safety.py @@ -5,6 +5,7 @@ from unittest.mock import patch from tools.url_safety import ( is_safe_url, + async_is_safe_url, is_always_blocked_url, _is_blocked_ip, _global_allow_private_urls, @@ -195,6 +196,24 @@ class TestIsSafeUrl: assert is_safe_url("https://multimedia.nt.qq.com.cn/download?id=123") is False +class TestAsyncIsSafeUrl: + """async_is_safe_url must match is_safe_url (runs DNS in a thread pool).""" + + @pytest.mark.asyncio + async def test_public_url_allowed(self): + with patch("socket.getaddrinfo", return_value=[ + (2, 1, 6, "", ("93.184.216.34", 0)), + ]): + assert await async_is_safe_url("https://example.com/x") is True + + @pytest.mark.asyncio + async def test_localhost_blocked(self): + with patch("socket.getaddrinfo", return_value=[ + (2, 1, 6, "", ("127.0.0.1", 0)), + ]): + assert await async_is_safe_url("http://localhost:8080/") is False + + class TestIsBlockedIp: """Direct tests for the _is_blocked_ip helper.""" diff --git a/tests/tools/test_vision_tools.py b/tests/tools/test_vision_tools.py index 2edff071e..9373d08f2 100644 --- a/tests/tools/test_vision_tools.py +++ b/tests/tools/test_vision_tools.py @@ -297,7 +297,7 @@ class TestErrorLoggingExcInfo: async def test_analysis_error_logs_exc_info(self, caplog): """When vision_analyze_tool encounters an error, it should log with exc_info.""" with ( - patch("tools.vision_tools._validate_image_url", return_value=True), + patch("tools.vision_tools._validate_image_url_async", new_callable=AsyncMock, return_value=True), patch( "tools.vision_tools._download_image", new_callable=AsyncMock, @@ -329,7 +329,7 @@ class TestErrorLoggingExcInfo: return dest with ( - patch("tools.vision_tools._validate_image_url", return_value=True), + patch("tools.vision_tools._validate_image_url_async", new_callable=AsyncMock, return_value=True), patch("tools.vision_tools._download_image", side_effect=fake_download), patch( "tools.vision_tools._image_to_base64_data_url", @@ -451,7 +451,7 @@ class TestVisionSafetyGuards: with ( patch("tools.vision_tools.check_website_access", return_value=blocked), - patch("tools.vision_tools._validate_image_url", return_value=True), + patch("tools.vision_tools._validate_image_url_async", new_callable=AsyncMock, return_value=True), patch("tools.vision_tools._download_image", new_callable=AsyncMock) as mock_download, ): result = json.loads(await vision_analyze_tool("https://blocked.test/cat.png", "describe")) @@ -549,7 +549,9 @@ class TestTildeExpansion: img = fake_home / "test_image.png" img.write_bytes(b"\x89PNG\r\n\x1a\n" + b"\x00" * 8) + # Windows expanduser() prefers USERPROFILE over HOME; POSIX uses HOME. monkeypatch.setenv("HOME", str(fake_home)) + monkeypatch.setenv("USERPROFILE", str(fake_home)) mock_response = MagicMock() mock_choice = MagicMock() @@ -580,6 +582,7 @@ class TestTildeExpansion: fake_home = tmp_path / "fakehome" fake_home.mkdir() monkeypatch.setenv("HOME", str(fake_home)) + monkeypatch.setenv("USERPROFILE", str(fake_home)) result = await vision_analyze_tool( "~/nonexistent.png", "describe this", "test/model" diff --git a/tests/tools/test_web_providers_brave_free.py b/tests/tools/test_web_providers_brave_free.py index a75b9d38e..7801b28bd 100644 --- a/tests/tools/test_web_providers_brave_free.py +++ b/tests/tools/test_web_providers_brave_free.py @@ -259,7 +259,10 @@ class TestBraveFreeSearchOnlyErrors: monkeypatch.setattr(web_tools, "_load_web_config", lambda: {"backend": "brave-free"}) monkeypatch.setenv("BRAVE_SEARCH_API_KEY", "BSAkey123") monkeypatch.setattr(web_tools, "_is_tool_gateway_ready", lambda: False) - monkeypatch.setattr(web_tools, "is_safe_url", lambda url: True) + async def _allow_ssrf(_url: str) -> bool: + return True + + monkeypatch.setattr(web_tools, "async_is_safe_url", _allow_ssrf) monkeypatch.setattr("tools.interrupt.is_interrupted", lambda: False, raising=False) result_str = asyncio.get_event_loop().run_until_complete( diff --git a/tests/tools/test_web_providers_ddgs.py b/tests/tools/test_web_providers_ddgs.py index 791993161..283a25f0a 100644 --- a/tests/tools/test_web_providers_ddgs.py +++ b/tests/tools/test_web_providers_ddgs.py @@ -229,7 +229,10 @@ class TestDDGSSearchOnlyErrors: monkeypatch.setattr(web_tools, "_load_web_config", lambda: {"backend": "ddgs"}) monkeypatch.setattr(web_tools, "_ddgs_package_importable", lambda: True) monkeypatch.setattr(web_tools, "_is_tool_gateway_ready", lambda: False) - monkeypatch.setattr(web_tools, "is_safe_url", lambda url: True) + async def _allow_ssrf(_url: str) -> bool: + return True + + monkeypatch.setattr(web_tools, "async_is_safe_url", _allow_ssrf) monkeypatch.setattr("tools.interrupt.is_interrupted", lambda: False, raising=False) result_str = asyncio.get_event_loop().run_until_complete( diff --git a/tests/tools/test_web_providers_searxng.py b/tests/tools/test_web_providers_searxng.py index 31bbaeb47..3a4f6d8d6 100644 --- a/tests/tools/test_web_providers_searxng.py +++ b/tests/tools/test_web_providers_searxng.py @@ -318,7 +318,10 @@ class TestSearXNGOnlyExtractCrawlErrors: monkeypatch.setattr(web_tools, "_load_web_config", lambda: {"backend": "searxng"}) monkeypatch.setenv("SEARXNG_URL", "http://localhost:8080") monkeypatch.setattr(web_tools, "_is_tool_gateway_ready", lambda: False) - monkeypatch.setattr(web_tools, "is_safe_url", lambda url: True) + async def _allow_ssrf(_url: str) -> bool: + return True + + monkeypatch.setattr(web_tools, "async_is_safe_url", _allow_ssrf) monkeypatch.setattr("tools.interrupt.is_interrupted", lambda: False, raising=False) result_str = asyncio.get_event_loop().run_until_complete( diff --git a/tests/tools/test_website_policy.py b/tests/tools/test_website_policy.py index bfe222ef8..712a37286 100644 --- a/tests/tools/test_website_policy.py +++ b/tests/tools/test_website_policy.py @@ -372,7 +372,10 @@ class TestWebToolPolicy: from plugins.web.firecrawl import provider as firecrawl_provider # Allow test URLs past SSRF check so website policy is what gets tested - monkeypatch.setattr(web_tools, "is_safe_url", lambda url: True) + async def _allow_ssrf(_url: str) -> bool: + return True + + monkeypatch.setattr(web_tools, "async_is_safe_url", _allow_ssrf) # The per-URL website-policy gate moved into the firecrawl plugin's # extract() during the web-provider migration. Patch it at the new # location. @@ -406,7 +409,10 @@ class TestWebToolPolicy: from plugins.web.firecrawl import provider as firecrawl_provider # Allow test URLs past SSRF check so website policy is what gets tested - monkeypatch.setattr(web_tools, "is_safe_url", lambda url: True) + async def _allow_ssrf(_url: str) -> bool: + return True + + monkeypatch.setattr(web_tools, "async_is_safe_url", _allow_ssrf) def fake_check(url): if url == "https://allowed.test": diff --git a/tests/tui_gateway/test_protocol.py b/tests/tui_gateway/test_protocol.py index 2c20e77a1..9a6b7d30b 100644 --- a/tests/tui_gateway/test_protocol.py +++ b/tests/tui_gateway/test_protocol.py @@ -394,6 +394,290 @@ def test_session_resume_handles_multimodal_list_content(server, monkeypatch): ] +def test_session_resume_reuses_existing_live_session(server, monkeypatch): + """Repeated resume must not allocate duplicate live agents.""" + + target = "20260409_010101_abc123" + created_sids: list[str] = [] + closed_sids: list[str] = [] + first_agent_started = threading.Event() + agent_can_finish = threading.Event() + + class _DB: + def get_session(self, _sid): + return {"id": target} + + def get_session_by_title(self, _title): + return None + + def reopen_session(self, _sid): + return None + + def get_messages_as_conversation(self, _sid, include_ancestors=False): + return [ + {"role": "user", "content": "hello"}, + {"role": "assistant", "content": "yo"}, + ] + + class _Worker: + def close(self): + pass + + class _Agent: + def __init__(self, sid, session_id): + self.sid = sid + self.model = "test/model" + self.session_id = session_id + + def close(self): + closed_sids.append(self.sid) + + def make_agent(sid, key, session_id=None): + created_sids.append(sid) + first_agent_started.set() + assert agent_can_finish.wait(timeout=1) + return _Agent(sid, session_id or key) + + monkeypatch.setattr(server, "_get_db", lambda: _DB()) + monkeypatch.setattr(server, "_make_agent", make_agent) + monkeypatch.setattr(server, "_SlashWorker", lambda _key, _model: _Worker()) + monkeypatch.setattr( + server, + "_start_notification_poller", + lambda _sid, _session: threading.Event(), + ) + monkeypatch.setattr(server, "_notify_session_boundary", lambda *_args, **_kwargs: None) + monkeypatch.setattr(server, "_wire_callbacks", lambda _sid: None) + monkeypatch.setattr(server, "_emit", lambda *_args, **_kwargs: None) + monkeypatch.setattr( + server, + "_session_info", + lambda _agent, _session=None: {"model": "test/model"}, + ) + + fake_approval = types.SimpleNamespace( + load_permanent_allowlist=lambda: None, + register_gateway_notify=lambda *_args, **_kwargs: None, + ) + + with patch.dict(sys.modules, {"tools.approval": fake_approval}): + first_holder = {} + + def resume_first(): + first_holder["resp"] = server.handle_request( + { + "id": "first", + "method": "session.resume", + "params": {"session_id": target, "cols": 100}, + } + ) + + first_thread = threading.Thread(target=resume_first) + first_thread.start() + assert first_agent_started.wait(timeout=1) + + second_holder = {} + + def resume_second(): + second_holder["resp"] = server.handle_request( + { + "id": "second", + "method": "session.resume", + "params": {"session_id": target, "cols": 120}, + } + ) + + second_thread = threading.Thread(target=resume_second) + second_thread.start() + agent_can_finish.set() + + first_thread.join(timeout=1) + second_thread.join(timeout=1) + assert not first_thread.is_alive() + assert not second_thread.is_alive() + first = first_holder["resp"] + second = second_holder["resp"] + + assert "error" not in first + assert "error" not in second + # Both resumes resolve to the SAME single live session — the core invariant. + assert second["result"]["session_id"] == first["result"]["session_id"] + assert len(server._sessions) == 1 + assert [s.get("session_key") for s in server._sessions.values()].count(target) == 1 + winner = first["result"]["session_id"] + # The agent build happens outside the resume lock, so a racing resume may + # build a redundant agent; double-checked locking keeps only one live + # session and closes any loser's agent (no worker/poller is wired for it). + assert winner in created_sids + survivors = [sid for sid in created_sids if sid not in closed_sids] + assert survivors == [winner] + assert all(sid == winner for sid in server._sessions) + + +def test_session_resume_live_payload_uses_current_history_with_ancestors(server, monkeypatch): + """Live resume should not reuse a stale ancestor-inclusive snapshot.""" + + target = "20260409_010101_child" + ancestor_history = [{"role": "user", "content": "ancestor"}] + current_history = [ + {"role": "user", "content": "current"}, + {"role": "assistant", "content": "current reply"}, + ] + + class _DB: + def get_session(self, _sid): + return {"id": target} + + def get_session_by_title(self, _title): + return None + + def reopen_session(self, _sid): + return None + + def get_messages_as_conversation(self, _sid, include_ancestors=False): + if include_ancestors: + return ancestor_history + current_history + return list(current_history) + + class _Worker: + def close(self): + pass + + monkeypatch.setattr(server, "_get_db", lambda: _DB()) + monkeypatch.setattr( + server, + "_make_agent", + lambda _sid, key, session_id=None: types.SimpleNamespace( + model="test/model", session_id=session_id or key + ), + ) + monkeypatch.setattr(server, "_SlashWorker", lambda _key, _model: _Worker()) + monkeypatch.setattr( + server, + "_start_notification_poller", + lambda _sid, _session: threading.Event(), + ) + monkeypatch.setattr(server, "_notify_session_boundary", lambda *_args, **_kwargs: None) + monkeypatch.setattr(server, "_wire_callbacks", lambda _sid: None) + monkeypatch.setattr(server, "_emit", lambda *_args, **_kwargs: None) + monkeypatch.setattr( + server, + "_session_info", + lambda _agent, _session=None: {"model": "test/model"}, + ) + + fake_approval = types.SimpleNamespace( + load_permanent_allowlist=lambda: None, + register_gateway_notify=lambda *_args, **_kwargs: None, + ) + + with patch.dict(sys.modules, {"tools.approval": fake_approval}): + first = server.handle_request( + { + "id": "first", + "method": "session.resume", + "params": {"session_id": target, "cols": 100}, + } + ) + + assert "error" not in first + sid = first["result"]["session_id"] + assert first["result"]["messages"] == [ + {"role": "user", "text": "ancestor"}, + {"role": "user", "text": "current"}, + {"role": "assistant", "text": "current reply"}, + ] + + with server._sessions[sid]["history_lock"]: + server._sessions[sid]["history"] = current_history + [ + {"role": "user", "content": "new live turn"}, + {"role": "assistant", "content": "new live reply"}, + ] + + second = server.handle_request( + { + "id": "second", + "method": "session.resume", + "params": {"session_id": target, "cols": 120}, + } + ) + + assert "error" not in second + assert second["result"]["session_id"] == sid + assert second["result"]["messages"] == [ + {"role": "user", "text": "ancestor"}, + {"role": "user", "text": "current"}, + {"role": "assistant", "text": "current reply"}, + {"role": "user", "text": "new live turn"}, + {"role": "assistant", "text": "new live reply"}, + ] + + +def test_session_branch_persists_branched_from_marker(server, monkeypatch): + """TUI /branch must persist a _branched_from marker so the branch stays + visible in /resume and /sessions. + + Regression for issue #20856: the TUI branch leaves the parent live (it + never ends it with end_reason='branched'), so list_sessions_rich's legacy + heuristic never surfaces it — the stable model_config marker is the only + thing that keeps a TUI branch visible. + """ + create_calls = [] + + class _DB: + def get_session_title(self, _key): + return "parent-title" + + def get_next_title_in_lineage(self, base): + return f"{base} 2" + + def create_session(self, new_key, **kwargs): + create_calls.append((new_key, kwargs)) + return new_key + + def append_message(self, **_kwargs): + return None + + def set_session_title(self, _key, _title): + return None + + monkeypatch.setattr(server, "_get_db", lambda: _DB()) + monkeypatch.setattr(server, "_resolve_model", lambda: "test/model") + monkeypatch.setattr(server, "_new_session_key", lambda: "20260101_000001_child0") + monkeypatch.setattr( + server, + "_make_agent", + lambda _sid, key, session_id=None: types.SimpleNamespace( + model="test/model", session_id=session_id or key + ), + ) + monkeypatch.setattr(server, "_init_session", lambda *_a, **_k: None) + monkeypatch.setattr(server, "_set_session_context", lambda *_a, **_k: []) + monkeypatch.setattr(server, "_clear_session_context", lambda *_a, **_k: None) + monkeypatch.setattr(server, "_session_cwd", lambda _s: "/tmp/branch-cwd") + + parent_sid = "parent01" + parent_key = "20260101_000000_parent" + server._sessions[parent_sid] = { + "session_key": parent_key, + "history": [{"role": "user", "content": "hello"}], + "history_lock": threading.Lock(), + "cols": 80, + } + + resp = server.handle_request( + {"id": "b1", "method": "session.branch", "params": {"session_id": parent_sid}} + ) + + assert "error" not in resp, resp + assert len(create_calls) == 1 + new_key, kwargs = create_calls[0] + assert new_key == "20260101_000001_child0" + assert kwargs["parent_session_id"] == parent_key + # The marker — without it the branch is invisible in /resume and /sessions. + assert kwargs["model_config"] == {"_branched_from": parent_key} + + def test_make_agent_accepts_list_system_prompt(server, monkeypatch): captured = {} diff --git a/tools/environments/docker.py b/tools/environments/docker.py index 5a0af2692..421eb71be 100644 --- a/tools/environments/docker.py +++ b/tools/environments/docker.py @@ -537,6 +537,10 @@ class DockerEnvironment(BaseEnvironment): self._env = _normalize_env_dict(env) self._container_id: Optional[str] = None self._labels: dict[str, str] = {} + self._image: str = "" + self._container_name: str = "" + self._image_uses_s6_init: bool = False + self._all_run_args: list[str] = [] logger.info(f"DockerEnvironment volumes: {volumes}") # Ensure volumes is a list (config.yaml could be malformed) if volumes is not None and not isinstance(volumes, list): @@ -791,6 +795,12 @@ class DockerEnvironment(BaseEnvironment): "--label", f"hermes-task-id={task_label}", "--label", f"hermes-profile={profile_name}", ] + # Save args for container recreation on "No such container" recovery. + self._image = image + self._container_name = container_name + self._image_uses_s6_init = image_uses_s6_init + self._all_run_args = all_run_args + self._labels = { "hermes-agent": "1", "hermes-task-id": task_label, @@ -854,13 +864,31 @@ class DockerEnvironment(BaseEnvironment): "sleep", "infinity", # no fixed lifetime — idle reaper handles cleanup ] logger.debug(f"Starting container: {' '.join(run_cmd)}") - result = subprocess.run( - run_cmd, - capture_output=True, - text=True, - timeout=120, # image pull may take a while - check=True, - ) + try: + result = subprocess.run( + run_cmd, + capture_output=True, + text=True, + timeout=120, # image pull may take a while + check=True, + ) + except (subprocess.CalledProcessError, subprocess.TimeoutExpired) as e: + # Docker may create the container object before `docker run` + # fails to start it (e.g. exit code 125 when the daemon isn't + # ready, or a timeout mid-pull). That orphan is left in + # "Created" state — which the exited-only orphan reaper + # (reap_orphan_containers, status=exited) never catches, so it + # leaks permanently. Remove it by its known name before + # re-raising. See #7439. + logger.warning( + "docker run failed for %s, cleaning up orphaned container: %s", + container_name, e, + ) + subprocess.run( + [self._docker_exe, "rm", "-f", container_name], + capture_output=True, timeout=10, + ) + raise self._container_id = result.stdout.strip() logger.info(f"Started container {container_name} ({self._container_id[:12]})") @@ -927,6 +955,117 @@ class DockerEnvironment(BaseEnvironment): return _popen_bash(cmd, stdin_data) + # ------------------------------------------------------------------ + # "No such container" recovery (issue #36266) + # ------------------------------------------------------------------ + + _NO_CONTAINER_PATTERNS = ( + "No such container", + "is not running", + "no such container", + ) + + def _is_container_gone(self, output: str) -> bool: + """Return True if the output indicates the container no longer exists.""" + return any(p in output for p in self._NO_CONTAINER_PATTERNS) + + def _recreate_container(self) -> bool: + """Recreate the container after it was removed out-of-band. + + Tries label-based reuse first; if no existing container is found, + starts a fresh one with the same image and run-args. Returns True + on success, False if recreation fails (caller should surface the + original error). + """ + old_id = (self._container_id or "")[:12] + logger.warning( + "Container %s appears to be gone — attempting recovery", old_id, + ) + self._container_id = None + + # 1. Try label-based reuse (another process may have recreated it). + task_label = self._labels.get("hermes-task-id", "") + profile_label = self._labels.get("hermes-profile", "") + existing = self._find_reusable_container(task_label, profile_label) + if existing is not None: + cid, state = existing + if state == "running": + self._container_id = cid + logger.info("Recovery: reusing running container %s", cid[:12]) + else: + try: + subprocess.run( + [self._docker_exe, "start", cid], + capture_output=True, text=True, timeout=30, check=True, + ) + self._container_id = cid + logger.info("Recovery: restarted container %s", cid[:12]) + except (subprocess.CalledProcessError, subprocess.TimeoutExpired) as e: + logger.warning("Recovery: failed to start container %s: %s", cid[:12], e) + + # 2. No reusable container — create a fresh one. + if not self._container_id: + if not self._image: + logger.error("Recovery: no saved image name, cannot recreate container") + return False + try: + import uuid as _uuid + new_name = f"hermes-{_uuid.uuid4().hex[:8]}" + init_args = [] if self._image_uses_s6_init else ["--init"] + label_args = [] + for k, v in self._labels.items(): + label_args.extend(["--label", f"{k}={v}"]) + run_cmd = [ + self._docker_exe, "run", "-d", + *init_args, + "--name", new_name, + *label_args, + "-w", self.cwd, + *self._all_run_args, + self._image, + "sleep", "infinity", + ] + result = subprocess.run( + run_cmd, capture_output=True, text=True, timeout=120, check=True, + ) + self._container_id = result.stdout.strip() + self._container_name = new_name + logger.info( + "Recovery: created fresh container %s (%s)", + new_name, self._container_id[:12], + ) + except (subprocess.CalledProcessError, subprocess.TimeoutExpired, OSError) as e: + logger.error("Recovery: failed to create new container: %s", e) + return False + + # 3. Re-initialize session snapshot in the (re)created container. + try: + self._snapshot_ready = False + self.init_session() + except Exception as e: + logger.error("Recovery: init_session failed in new container: %s", e) + return False + + logger.info("Recovery successful — new container %s", (self._container_id or "")[:12]) + return True + + def execute(self, command: str, cwd: str = "", **kwargs) -> dict: + """Execute a command, auto-recovering from dead containers. + + If the container was removed out-of-band (idle reaper, docker prune, + OOM kill, daemon restart), detect the error and recreate the container + transparently before retrying once. + """ + result = super().execute(command, cwd, **kwargs) + if ( + result.get("returncode", 0) != 0 + and self._is_container_gone(result.get("output", "")) + and self._persist_across_processes + ): + if self._recreate_container(): + result = super().execute(command, cwd, **kwargs) + return result + @staticmethod def _storage_opt_supported() -> bool: """Check if Docker's storage driver supports --storage-opt size=. diff --git a/tools/environments/file_sync.py b/tools/environments/file_sync.py index 6de78c87b..89f712693 100644 --- a/tools/environments/file_sync.py +++ b/tools/environments/file_sync.py @@ -9,6 +9,7 @@ view) and don't need this. import hashlib import logging import os +import posixpath import shlex import shutil import signal @@ -87,7 +88,7 @@ def quoted_mkdir_command(dirs: list[str]) -> str: def unique_parent_dirs(files: list[tuple[str, str]]) -> list[str]: """Extract sorted unique parent directories from (host, remote) pairs.""" - return sorted({str(Path(remote).parent) for _, remote in files}) + return sorted({posixpath.dirname(remote) for _, remote in files}) def _sha256_file(path: str) -> str: diff --git a/tools/environments/ssh.py b/tools/environments/ssh.py index 8924d7689..509e88ef8 100644 --- a/tools/environments/ssh.py +++ b/tools/environments/ssh.py @@ -179,6 +179,10 @@ class SSHEnvironment(BaseEnvironment): raise RuntimeError(f"remote mkdir failed: {result.stderr.strip()}") # Symlink staging avoids fragile GNU tar --transform rules. + # On Windows without Developer Mode, symlink creation raises + # OSError with winerror 1314 (privilege not held). Catch only + # that specific error and fall back to a plain copy; all other + # OSErrors (e.g. disk full, bad path) are re-raised as normal. with tempfile.TemporaryDirectory(prefix="hermes-ssh-bulk-") as staging: for host_path, remote_path in files: try: @@ -195,7 +199,14 @@ class SSHEnvironment(BaseEnvironment): staged = os.path.join(staging, rel_remote) os.makedirs(os.path.dirname(staged), exist_ok=True) - os.symlink(os.path.abspath(host_path), staged) + try: + os.symlink(os.path.abspath(host_path), staged) + except OSError as e: + # WinError 1314: symlink privilege not held (Windows without Dev Mode) + if getattr(e, "winerror", None) == 1314: + shutil.copy2(host_path, staged) + else: + raise tar_cmd = ["tar", "-chf", "-", "-C", staging, "."] ssh_cmd = self._build_ssh_command() diff --git a/tools/lazy_deps.py b/tools/lazy_deps.py index 0c0a2a6e9..5b5878eb4 100644 --- a/tools/lazy_deps.py +++ b/tools/lazy_deps.py @@ -135,7 +135,6 @@ LAZY_DEPS: dict[str, tuple[str, ...]] = { ), "platform.matrix": ( "mautrix[encryption]==0.21.0", - "Markdown==3.10.2", "aiosqlite==0.22.1", "asyncpg==0.31.0", "aiohttp-socks==0.11.0", diff --git a/tools/send_message_tool.py b/tools/send_message_tool.py index 88bcb4005..f8386a51e 100644 --- a/tools/send_message_tool.py +++ b/tools/send_message_tool.py @@ -767,7 +767,7 @@ async def _send_to_platform(platform, pconfig, chat_id, message, thread_id=None, last_result = None for chunk in chunks: if platform == Platform.SLACK: - result = await _send_slack(pconfig.token, chat_id, chunk) + result = await _send_slack(pconfig.token, chat_id, chunk, thread_ts=thread_id) elif platform == Platform.WHATSAPP: result = await _send_whatsapp(pconfig.extra, chat_id, chunk) elif platform == Platform.SIGNAL: @@ -1049,7 +1049,7 @@ async def _send_telegram(token, chat_id, message, media_files=None, thread_id=No return _error(f"Telegram send failed: {e}") -async def _send_slack(token, chat_id, message): +async def _send_slack(token, chat_id, message, thread_ts=None): """Send via Slack Web API.""" try: import aiohttp @@ -1063,6 +1063,8 @@ async def _send_slack(token, chat_id, message): headers = {"Authorization": f"Bearer {token}", "Content-Type": "application/json"} async with aiohttp.ClientSession(timeout=aiohttp.ClientTimeout(total=30), **_sess_kw) as session: payload = {"channel": chat_id, "text": message, "mrkdwn": True} + if thread_ts: + payload["thread_ts"] = thread_ts async with session.post(url, headers=headers, json=payload, **_req_kw) as resp: data = await resp.json() if data.get("ok"): diff --git a/tools/url_safety.py b/tools/url_safety.py index a0ce297a9..13117d760 100644 --- a/tools/url_safety.py +++ b/tools/url_safety.py @@ -27,6 +27,7 @@ import ipaddress import logging import os import socket +import asyncio from urllib.parse import urlparse from utils import is_truthy_value @@ -349,3 +350,12 @@ def is_safe_url(url: str) -> bool: # become SSRF bypass vectors logger.warning("Blocked request — URL safety check error for %s: %s", url, exc) return False + + +async def async_is_safe_url(url: str) -> bool: + """Same rules as :func:`is_safe_url`, but run the DNS work off the event loop. + + ``socket.getaddrinfo`` can block; call this from async code paths (gateway, + ``web_extract_tool``, vision download hooks) instead of ``is_safe_url``. + """ + return await asyncio.to_thread(is_safe_url, url) diff --git a/tools/vision_tools.py b/tools/vision_tools.py index 10e97298a..3187f5476 100644 --- a/tools/vision_tools.py +++ b/tools/vision_tools.py @@ -74,35 +74,36 @@ _VISION_DOWNLOAD_TIMEOUT = _resolve_download_timeout() _VISION_MAX_DOWNLOAD_BYTES = 50 * 1024 * 1024 -def _validate_image_url(url: str) -> bool: - """ - Basic validation of image URL format. - - Args: - url (str): The URL to validate - - Returns: - bool: True if URL appears to be valid, False otherwise - """ +def _image_url_shape_ok(url: str) -> bool: + """HTTP(S) shape check only (scheme, netloc). No DNS.""" if not url or not isinstance(url, str): return False - # Basic HTTP/HTTPS URL check if not url.startswith(("http://", "https://")): return False - # Parse to ensure we at least have a network location; still allow URLs # without file extensions (e.g. CDN endpoints that redirect to images). parsed = urlparse(url) if not parsed.netloc: return False + return True + +def _validate_image_url(url: str) -> bool: + """Validate image URL for sync callers and tests (SSRF via sync DNS check).""" + if not _image_url_shape_ok(url): + return False # Block private/internal addresses to prevent SSRF from tools.url_safety import is_safe_url - if not is_safe_url(url): - return False + return is_safe_url(url) - return True + +async def _validate_image_url_async(url: str) -> bool: + """Validate remote image URL without blocking the event loop on DNS.""" + if not _image_url_shape_ok(url): + return False + from tools.url_safety import async_is_safe_url + return await async_is_safe_url(url) def _detect_image_mime_type(image_path: Path) -> Optional[str]: @@ -181,8 +182,8 @@ async def _download_image(image_url: str, destination: Path, max_retries: int = """ if response.is_redirect and response.next_request: redirect_url = str(response.next_request.url) - from tools.url_safety import is_safe_url - if not is_safe_url(redirect_url): + from tools.url_safety import async_is_safe_url + if not await async_is_safe_url(redirect_url): raise ValueError( f"Blocked redirect to private/internal address: {redirect_url}" ) @@ -540,7 +541,8 @@ def _supports_media_in_tool_results(provider: str, model: str) -> bool: results. Older Gemini does NOT. For unknown / legacy providers we conservatively return False — the - caller falls back to the legacy aux-LLM text path. + caller falls back to the legacy aux-LLM text path. The check is relaxed + when the provider's ``ProviderProfile`` declares ``supports_vision=True``. """ if not isinstance(provider, str): return False @@ -577,6 +579,17 @@ def _supports_media_in_tool_results(provider: str, model: str) -> bool: return True return False + # Check the provider's registered profile for the supports_vision flag. + # This covers vision-capable providers like xiaomi, minimax, etc. that + # aren't in the hardcoded list above. + try: + from providers import get_provider_profile + profile = get_provider_profile(p) + if profile is not None and profile.supports_vision: + return True + except Exception: + pass + # Other vision-capable provider stacks. Conservative default: False. # Add explicit entries here as we verify each provider's tool-result # multimodal support empirically. @@ -704,7 +717,7 @@ async def _vision_analyze_native( if local_path.is_file(): temp_image_path = local_path should_cleanup = False - elif _validate_image_url(image_url): + elif await _validate_image_url_async(image_url): blocked = check_website_access(image_url) if blocked: return tool_error(blocked["message"], success=False) @@ -858,7 +871,7 @@ async def vision_analyze_tool( logger.info("Using local image file: %s", image_url) temp_image_path = local_path should_cleanup = False # Don't delete cached/local files - elif _validate_image_url(image_url): + elif await _validate_image_url_async(image_url): # Remote URL -- download to a temporary location blocked = check_website_access(image_url) if blocked: @@ -1253,8 +1266,8 @@ async def _download_video(video_url: str, destination: Path, max_retries: int = async def _ssrf_redirect_guard(response): if response.is_redirect and response.next_request: redirect_url = str(response.next_request.url) - from tools.url_safety import is_safe_url - if not is_safe_url(redirect_url): + from tools.url_safety import async_is_safe_url + if not await async_is_safe_url(redirect_url): raise ValueError( f"Blocked redirect to private/internal address: {redirect_url}" ) @@ -1360,7 +1373,7 @@ async def video_analyze_tool( logger.info("Using local video file: %s", video_url) temp_video_path = local_path should_cleanup = False - elif _validate_image_url(video_url): + elif await _validate_image_url_async(video_url): blocked = check_website_access(video_url) if blocked: raise PermissionError(blocked["message"]) diff --git a/tools/web_tools.py b/tools/web_tools.py index 8f5275da2..a97370c48 100644 --- a/tools/web_tools.py +++ b/tools/web_tools.py @@ -102,7 +102,7 @@ from tools.tool_backend_helpers import ( # noqa: F401 nous_tool_gateway_unavailable_message, prefers_gateway, ) -from tools.url_safety import is_safe_url +from tools.url_safety import async_is_safe_url import sys logger = logging.getLogger(__name__) @@ -934,7 +934,7 @@ async def web_extract_tool( safe_urls = [] ssrf_blocked: List[Dict[str, Any]] = [] for url in urls: - if not is_safe_url(url): + if not await async_is_safe_url(url): ssrf_blocked.append({ "url": url, "title": "", "content": "", "error": "Blocked: URL targets a private or internal network address", diff --git a/tui_gateway/server.py b/tui_gateway/server.py index 338218cd8..61822b6da 100644 --- a/tui_gateway/server.py +++ b/tui_gateway/server.py @@ -128,6 +128,7 @@ _cfg_lock = threading.Lock() _cfg_cache: dict | None = None _cfg_mtime: float | None = None _cfg_path = None +_session_resume_lock = threading.Lock() try: _slash_timeout = float(os.environ.get("HERMES_TUI_SLASH_TIMEOUT_S") or "45") except (ValueError, TypeError): @@ -2979,6 +2980,10 @@ def _(rid, params: dict) -> dict: target = params.get("session_id", "") if not target: return _err(rid, 4006, "session_id required") + try: + cols = int(params.get("cols", 80)) + except (TypeError, ValueError): + cols = 80 db = _get_db() if db is None: return _db_unavailable_error(rid, code=5000) @@ -2989,6 +2994,25 @@ def _(rid, params: dict) -> dict: target = found["id"] else: return _err(rid, 4007, "session not found") + # Fast path: if the session is already live, reuse it under the lock. + with _session_resume_lock: + live = _find_live_session_by_key(target) + if live is not None: + sid, session = live + payload = _live_session_payload( + sid, + session, + cols=cols, + touch=True, + transport=current_transport() or _stdio_transport, + ) + payload["resumed"] = target + return _ok(rid, payload) + + # Build the agent OUTSIDE the lock — _make_agent can block for seconds + # (MCP discovery, prompt/skill build, AIAgent construction). Holding + # _session_resume_lock across it would stall session.close on the main + # dispatch thread (it's not a _LONG_HANDLER), blocking fast-path RPCs. sid = uuid.uuid4().hex[:8] _enable_gateway_prompts() try: @@ -2997,15 +3021,46 @@ def _(rid, params: dict) -> dict: display_history = db.get_messages_as_conversation( target, include_ancestors=True ) + display_history_prefix = display_history[ + : max(0, len(display_history) - len(history)) + ] messages = _history_to_messages(display_history) tokens = _set_session_context(target) try: agent = _make_agent(sid, target, session_id=target) finally: _clear_session_context(tokens) - _init_session(sid, target, agent, history, cols=int(params.get("cols", 80))) except Exception as e: return _err(rid, 5000, f"resume failed: {e}") + + # Double-checked locking: another concurrent resume may have created the + # live session while we were building. Re-check under the lock; if it won, + # discard our just-built agent and reuse theirs (no worker/poller wired yet). + with _session_resume_lock: + live = _find_live_session_by_key(target) + if live is not None: + try: + if hasattr(agent, "close"): + agent.close() + except Exception: + pass + other_sid, other_session = live + payload = _live_session_payload( + other_sid, + other_session, + cols=cols, + touch=True, + transport=current_transport() or _stdio_transport, + ) + payload["resumed"] = target + return _ok(rid, payload) + try: + _init_session(sid, target, agent, history, cols=cols) + if sid in _sessions: + _sessions[sid]["display_history_prefix"] = display_history_prefix + except Exception as e: + return _err(rid, 5000, f"resume failed: {e}") + session = _sessions.get(sid) or {} return _ok( rid, { @@ -3013,7 +3068,12 @@ def _(rid, params: dict) -> dict: "resumed": target, "message_count": len(messages), "messages": messages, - "info": _session_info(agent, _sessions.get(sid)), + "info": _session_info(agent, session), + "inflight": None, + "running": False, + "session_key": target, + "started_at": float(session.get("created_at") or time.time()), + "status": "idle", }, ) @@ -3106,6 +3166,15 @@ def _session_live_item(sid: str, session: dict, current_sid: str = "") -> dict: } +def _find_live_session_by_key(session_key: str) -> tuple[str, dict] | None: + for sid, session in list(_sessions.items()): + if session.get("_finalized"): + continue + if str(session.get("session_key") or "") == session_key: + return sid, session + return None + + def _fallback_session_info(session: dict) -> dict: agent = session.get("agent") if agent is not None: @@ -3119,6 +3188,41 @@ def _fallback_session_info(session: dict) -> dict: } +def _live_session_payload( + sid: str, + session: dict, + *, + cols: int | None = None, + touch: bool = False, + transport: Transport | None = None, +) -> dict: + with session["history_lock"]: + if cols is not None: + session["cols"] = cols + if transport is not None: + session["transport"] = transport + if touch: + session["last_active"] = time.time() + history = list(session.get("display_history_prefix") or []) + list( + session.get("history") or [] + ) + inflight = _inflight_snapshot(session) + running = bool(session.get("running")) + payload = { + "info": _fallback_session_info(session), + "message_count": len(history), + "messages": _history_to_messages(history), + "running": running, + "session_id": sid, + "session_key": session.get("session_key") or sid, + "started_at": float(session.get("created_at") or time.time()), + "status": _session_live_status(sid, session), + } + if inflight: + payload["inflight"] = inflight + return payload + + @method("session.active_list") def _(rid, params: dict) -> dict: """Return live TUI sessions in this gateway process. @@ -3152,27 +3256,9 @@ def _(rid, params: dict) -> dict: if err: return err - with session["history_lock"]: - session["last_active"] = time.time() - history = list(session.get("display_history") or session.get("history") or []) - inflight = _inflight_snapshot(session) - running = bool(session.get("running")) - status = _session_live_status(sid, session) - payload = { - "info": _fallback_session_info(session), - "message_count": len(history), - "messages": _history_to_messages(history), - "running": running, - "session_id": sid, - "session_key": session.get("session_key") or sid, - "started_at": float(session.get("created_at") or time.time()), - "status": status, - } - if inflight: - payload["inflight"] = inflight return _ok( rid, - payload, + _live_session_payload(sid, session, touch=True), ) @@ -3558,28 +3644,32 @@ def _(rid, params: dict) -> dict: @method("session.close") def _(rid, params: dict) -> dict: sid = params.get("session_id", "") - session = _sessions.pop(sid, None) - if not session: + current = _sessions.get(sid) + if not current: return _ok(rid, {"closed": False}) - _finalize_session(session) - try: - from tools.approval import unregister_gateway_notify + with _session_resume_lock: + session = _sessions.pop(sid, None) + if not session: + return _ok(rid, {"closed": False}) + _finalize_session(session) + try: + from tools.approval import unregister_gateway_notify - unregister_gateway_notify(session["session_key"]) - except Exception: - pass - try: - agent = session.get("agent") - if agent and hasattr(agent, "close"): - agent.close() - except Exception: - pass - try: - worker = session.get("slash_worker") - if worker: - worker.close() - except Exception: - pass + unregister_gateway_notify(session["session_key"]) + except Exception: + pass + try: + agent = session.get("agent") + if agent and hasattr(agent, "close"): + agent.close() + except Exception: + pass + try: + worker = session.get("slash_worker") + if worker: + worker.close() + except Exception: + pass return _ok(rid, {"closed": True}) @@ -3612,6 +3702,12 @@ def _(rid, params: dict) -> dict: new_key, source="tui", model=_resolve_model(), + # Stable _branched_from marker so list_sessions_rich() keeps the + # branch visible in /resume and /sessions. The TUI branch leaves + # the parent live (no end_reason='branched'), so the legacy + # end_reason heuristic never matches it — the marker is the only + # thing that surfaces TUI branches. See issue #20856. + model_config={"_branched_from": old_key}, parent_session_id=old_key, cwd=_session_cwd(session), ) @@ -4013,6 +4109,38 @@ def _notification_event_belongs_elsewhere(session: dict, evt: dict) -> bool: ) +def _notification_event_dedup_key(evt: dict) -> tuple: + """Return the UI-emission identity for a process notification event. + + Completion events are terminal notifications for a background process, so + they remain one-shot per process session. Watch-match events are not + terminal: a single background process can legitimately match the same or + different patterns many times, so include event-specific content to avoid + suppressing later distinct matches from the same process. + """ + evt_type = evt.get("type", "completion") + evt_sid = evt.get("session_id", "") + if evt_type == "watch_match": + return ( + evt_sid, + evt_type, + evt.get("command", ""), + evt.get("pattern", ""), + evt.get("output", ""), + evt.get("suppressed", 0), + evt.get("message_id", ""), + ) + if evt_type.startswith("watch_overflow_") or evt_type == "watch_disabled": + return ( + evt_sid, + evt_type, + evt.get("command", ""), + evt.get("message", ""), + evt.get("suppressed", 0), + ) + return (evt_sid, evt_type) + + def _notification_poller_loop( stop_event: threading.Event, sid: str, session: dict ) -> None: @@ -4029,6 +4157,7 @@ def _notification_poller_loop( """ from tools.process_registry import process_registry, format_process_notification + _emitted = set() # dedup re-queued events so same completion isn't emitted 50 times while session is busy while not stop_event.is_set() and not session.get("_finalized"): try: evt = process_registry.completion_queue.get(timeout=0.5) @@ -4053,7 +4182,14 @@ def _notification_poller_loop( if not text: continue - _emit("status.update", sid, {"kind": "process", "text": text}) + # Only emit the same notification identity to TUI once — re-queued + # completions get re-emitted every 0.5s otherwise when session is busy, + # while distinct watch_match events from the same process must remain + # visible independently. + _dedup_key = _notification_event_dedup_key(evt) + if _dedup_key not in _emitted: + _emit("status.update", sid, {"kind": "process", "text": text}) + _emitted.add(_dedup_key) with session["history_lock"]: if session.get("running"): @@ -4093,7 +4229,10 @@ def _notification_poller_loop( if not text: continue - _emit("status.update", sid, {"kind": "process", "text": text}) + _dedup_key = _notification_event_dedup_key(evt) + if _dedup_key not in _emitted: + _emit("status.update", sid, {"kind": "process", "text": text}) + _emitted.add(_dedup_key) with session["history_lock"]: if session.get("running"): diff --git a/ui-tui/src/__tests__/createGatewayEventHandler.test.ts b/ui-tui/src/__tests__/createGatewayEventHandler.test.ts index afebc4d10..1b433220b 100644 --- a/ui-tui/src/__tests__/createGatewayEventHandler.test.ts +++ b/ui-tui/src/__tests__/createGatewayEventHandler.test.ts @@ -1113,4 +1113,68 @@ describe('createGatewayEventHandler', () => { vi.useRealTimers() } }) + + it('persists an abandoned (timed-out) clarify into the transcript when the clarify tool completes', () => { + const appended: Msg[] = [] + const onEvent = createGatewayEventHandler(buildCtx(appended)) + + // Backend clarify timed out: the overlay is still live (Python returned an + // empty answer), and the clarify tool's own tool.complete then fires. + patchOverlayState({ + clarify: { choices: ['Scope A', 'Scope B'], question: 'How do you want to scope?', requestId: 'req-1' } + }) + + onEvent({ payload: { duration_s: 300, name: 'clarify', tool_id: 'clar-1' }, type: 'tool.complete' } as any) + + const record = appended.find(msg => msg.role === 'system' && msg.text.startsWith('ask How do you want to scope?')) + expect(record).toBeDefined() + expect(record?.text).toContain('1. Scope A') + expect(record?.text).toContain('2. Scope B') + expect(record?.text).toContain('timed out — no selection') + // The live overlay is cleared so it doesn't double-render with the record. + expect(getOverlayState().clarify).toBeNull() + }) + + it('only persists an abandoned clarify once even if tool.complete fires twice', () => { + const appended: Msg[] = [] + const onEvent = createGatewayEventHandler(buildCtx(appended)) + + patchOverlayState({ + clarify: { choices: ['A'], question: 'Pick?', requestId: 'req-3' } + }) + + onEvent({ payload: { name: 'clarify', tool_id: 'clar-1' }, type: 'tool.complete' } as any) + // A duplicate clarify tool.complete must not re-persist the same prompt. + onEvent({ payload: { name: 'clarify', tool_id: 'clar-1' }, type: 'tool.complete' } as any) + + const records = appended.filter(msg => msg.role === 'system' && msg.text.startsWith('ask Pick?')) + expect(records).toHaveLength(1) + }) + + it('does not flush the clarify overlay when a non-clarify tool completes', () => { + const appended: Msg[] = [] + const onEvent = createGatewayEventHandler(buildCtx(appended)) + + // A clarify is live, but it's a *different* tool that just completed — the + // clarify itself is still pending, so we must not persist or clear it. + patchOverlayState({ + clarify: { choices: ['A', 'B'], question: 'Pick?', requestId: 'req-4' } + }) + + onEvent({ payload: { name: 'search', tool_id: 'tool-1' }, type: 'tool.complete' } as any) + + expect(appended.some(msg => msg.role === 'system' && msg.text.startsWith('ask '))).toBe(false) + expect(getOverlayState().clarify).not.toBeNull() + }) + + it('does not persist when an answered clarify already cleared the overlay before tool.complete', () => { + const appended: Msg[] = [] + const onEvent = createGatewayEventHandler(buildCtx(appended)) + + // Answered path (answerClarify) clears the overlay before the agent's + // tool.complete arrives, so there's nothing live to persist. + onEvent({ payload: { duration_s: 4.2, name: 'clarify', tool_id: 'clar-1' }, type: 'tool.complete' } as any) + + expect(appended.some(msg => msg.role === 'system' && msg.text.startsWith('ask '))).toBe(false) + }) }) diff --git a/ui-tui/src/app/createGatewayEventHandler.ts b/ui-tui/src/app/createGatewayEventHandler.ts index 987518a44..6e40e1c75 100644 --- a/ui-tui/src/app/createGatewayEventHandler.ts +++ b/ui-tui/src/app/createGatewayEventHandler.ts @@ -11,7 +11,7 @@ import type { } from '../gatewayTypes.js' import { rpcErrorMessage } from '../lib/rpc.js' import { topLevelSubagents } from '../lib/subagentTree.js' -import { formatToolCall, stripAnsi } from '../lib/text.js' +import { formatAbandonedClarify, formatToolCall, stripAnsi } from '../lib/text.js' import { fromSkin } from '../theme.js' import type { Msg, SubagentProgress, SubagentStatus } from '../types.js' @@ -87,6 +87,35 @@ export function createGatewayEventHandler(ctx: GatewayEventHandlerContext): (ev: let thinkingStatusTimer: null | ReturnType<typeof setTimeout> = null let startupPromptSubmitted = false + // Request IDs of clarify prompts we've already flushed to the transcript as + // an abandoned-prompt record, so the tool.complete and message.complete + // paths can't both persist the same prompt twice. + const persistedAbandonedClarify = new Set<string>() + + // When a clarify prompt is dismissed without an answer (the backend _block + // timed out and returned an empty string), the live ClarifyPrompt overlay is + // left set until the next turn's idle() silently nulls it — so the question + // and options vanish from the screen while the agent's follow-up still refers + // to them. The reliable signal is the clarify tool's own tool.complete (and, + // as a backstop, message.complete): at those points the overlay is provably + // still set on a timeout, but already cleared by answerClarify() on a real + // answer (so this no-ops there). Flush the question + options into the + // transcript as a persistent system line, then clear the overlay. + const flushAbandonedClarify = () => { + const { clarify } = getOverlayState() + + if (!clarify || persistedAbandonedClarify.has(clarify.requestId)) { + return + } + + persistedAbandonedClarify.add(clarify.requestId) + appendMessage({ + role: 'system', + text: formatAbandonedClarify(clarify.question, clarify.choices, 'timed out') + }) + patchOverlayState({ clarify: null }) + } + // Inject the disk-save callback into turnController so recordMessageComplete // can fire-and-forget a persist without having to plumb a gateway ref around. turnController.persistSpawnTree = async (subagents, sessionId) => { @@ -624,6 +653,14 @@ export function createGatewayEventHandler(ctx: GatewayEventHandlerContext): (ev: return case 'tool.complete': { + // The clarify tool finishing with its overlay still live means it was + // abandoned (backend _block timed out, empty answer). A real answer + // clears the overlay in answerClarify() before this fires, so this + // no-ops there. Persist the question + options so they don't vanish. + if (ev.payload.name === 'clarify') { + flushAbandonedClarify() + } + const inlineDiffText = ev.payload.inline_diff && getUiState().inlineDiffs ? stripAnsi(String(ev.payload.inline_diff)).trim() : '' diff --git a/ui-tui/src/app/useMainApp.ts b/ui-tui/src/app/useMainApp.ts index 6c48c56f9..509e87758 100644 --- a/ui-tui/src/app/useMainApp.ts +++ b/ui-tui/src/app/useMainApp.ts @@ -25,7 +25,7 @@ import { appendTranscriptMessage } from '../lib/messages.js' import { DEFAULT_VOICE_RECORD_KEY, isMac, type ParsedVoiceRecordKey } from '../lib/platform.js' import { asRpcResult, rpcErrorMessage } from '../lib/rpc.js' import { terminalParityHints } from '../lib/terminalParity.js' -import { buildToolTrailLine, sameToolTrailGroup, toolTrailLabel } from '../lib/text.js' +import { buildToolTrailLine, formatAbandonedClarify, sameToolTrailGroup, toolTrailLabel } from '../lib/text.js' import { estimatedMsgHeight, messageHeightKey } from '../lib/virtualHeights.js' import type { Msg, PanelSection, SlashCatalog } from '../types.js' @@ -608,13 +608,19 @@ export function useMainApp(gw: GatewayClient) { appendMessage({ role: 'user', text: answer }) patchUiState({ status: 'running…' }) } else { - sys('prompt cancelled') + // Esc / Ctrl+C cancel: persist the question + options as a system + // line (not a transient "prompt cancelled" flash) so the prompt + // survives on screen as standard output, matching the timeout path. + appendMessage({ + role: 'system', + text: formatAbandonedClarify(clarify.question, clarify.choices, 'cancelled') + }) } patchOverlayState({ clarify: null }) }) }, - [appendMessage, overlay.clarify, rpc, sys] + [appendMessage, overlay.clarify, rpc] ) const paste = useCallback( diff --git a/ui-tui/src/app/useSessionLifecycle.ts b/ui-tui/src/app/useSessionLifecycle.ts index c2c1ddac8..e95dafef2 100644 --- a/ui-tui/src/app/useSessionLifecycle.ts +++ b/ui-tui/src/app/useSessionLifecycle.ts @@ -302,38 +302,47 @@ export function useSessionLifecycle(opts: UseSessionLifecycleOptions) { return } - closeSession(getUiState().sid === id ? null : getUiState().sid).then(() => - gw - .request<SessionResumeResponse>('session.resume', { cols: colsRef.current, session_id: id }) - .then(raw => { - const r = asRpcResult<SessionResumeResponse>(raw) + const previousSid = getUiState().sid - if (!r) { - sys('error: invalid response: session.resume') + gw.request<SessionResumeResponse>('session.resume', { cols: colsRef.current, session_id: id }) + .then(raw => { + const r = asRpcResult<SessionResumeResponse>(raw) - return patchUiState({ status: 'ready' }) - } + if (!r) { + sys('error: invalid response: session.resume') - resetSession() - setSessionStartedAt(Date.now()) + return patchUiState({ status: 'ready' }) + } - const resumed = toTranscriptMessages(r.messages) + const info = r.info ?? null + const running = Boolean(r.running || r.status === 'working' || r.status === 'waiting') - setHistoryItems(r.info ? [introMsg(r.info), ...resumed] : resumed) - writeActiveSessionFile(r.resumed ?? r.session_id) - patchUiState({ - info: r.info ?? null, - sid: r.session_id, - status: 'ready', - usage: usageFrom(r.info ?? null) - }) - setTimeout(() => scrollRef.current?.scrollToBottom(), 0) + resetSession() + setSessionStartedAt(r.started_at ? r.started_at * 1000 : Date.now()) + + const resumed = [...toTranscriptMessages(r.messages), ...liveSessionInflightMessages(r.inflight)] + + setHistoryItems(info ? [introMsg(info), ...resumed] : resumed) + writeActiveSessionFile(r.resumed ?? r.session_id) + patchUiState({ + busy: running, + info, + sid: r.session_id, + status: statusFromLiveSession(r.status, running), + usage: usageFrom(info) }) - .catch((e: Error) => { - sys(`error: ${e.message}`) - patchUiState({ status: 'ready' }) - }) - ) + hydrateLiveSessionInflight(r.inflight) + + if (previousSid && previousSid !== r.session_id) { + void closeSession(previousSid) + } + + setTimeout(() => scrollRef.current?.scrollToBottom(), 0) + }) + .catch((e: Error) => { + sys(`error: ${e.message}`) + patchUiState({ status: 'ready' }) + }) }) }, [closeSession, colsRef, gw, panel, resetSession, rpc, scrollRef, setHistoryItems, setSessionStartedAt, sys] diff --git a/ui-tui/src/gatewayTypes.ts b/ui-tui/src/gatewayTypes.ts index c56a1aebf..0a946d9b6 100644 --- a/ui-tui/src/gatewayTypes.ts +++ b/ui-tui/src/gatewayTypes.ts @@ -122,11 +122,15 @@ export interface SessionCreateResponse { } export interface SessionResumeResponse { + inflight?: null | SessionInflightTurn info?: SessionInfo message_count?: number messages: GatewayTranscriptMessage[] resumed?: string + running?: boolean session_id: string + started_at?: number + status?: LiveSessionStatus } export type LiveSessionStatus = 'idle' | 'starting' | 'waiting' | 'working' diff --git a/ui-tui/src/lib/text.test.ts b/ui-tui/src/lib/text.test.ts index 1a3800ec7..ebea2f5b5 100644 --- a/ui-tui/src/lib/text.test.ts +++ b/ui-tui/src/lib/text.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from 'vitest' -import { stripTrailingPasteNewlines } from './text.js' +import { formatAbandonedClarify, stripTrailingPasteNewlines } from './text.js' describe('stripTrailingPasteNewlines', () => { it('removes trailing newline runs from pasted text', () => { @@ -16,3 +16,34 @@ describe('stripTrailingPasteNewlines', () => { expect(stripTrailingPasteNewlines('\n\n')).toBe('\n\n') }) }) + +describe('formatAbandonedClarify', () => { + it('renders the question, numbered options, and reason', () => { + const out = formatAbandonedClarify('How do you want to scope?', ['Option A', 'Option B', 'Option C'], 'timed out') + + expect(out).toBe( + ['ask How do you want to scope?', ' 1. Option A', ' 2. Option B', ' 3. Option C', ' (timed out — no selection)'].join( + '\n' + ) + ) + }) + + it('handles a prompt with no choices (free-text clarify)', () => { + const out = formatAbandonedClarify('What is the target branch?', null, 'cancelled') + + expect(out).toBe(['ask What is the target branch?', ' (cancelled — no selection)'].join('\n')) + }) + + it('trims surrounding whitespace on the question', () => { + const out = formatAbandonedClarify(' trailing space ', [], 'timed out') + + expect(out.split('\n')[0]).toBe('ask trailing space') + }) + + it('numbers options 1-based to match the live ClarifyPrompt', () => { + const out = formatAbandonedClarify('q', ['first'], 'timed out') + + expect(out).toContain(' 1. first') + expect(out).not.toContain(' 0.') + }) +}) diff --git a/ui-tui/src/lib/text.ts b/ui-tui/src/lib/text.ts index feb3547a3..b1e86e367 100644 --- a/ui-tui/src/lib/text.ts +++ b/ui-tui/src/lib/text.ts @@ -338,6 +338,22 @@ export const estimateRows = (text: string, w: number, compact = false) => { return Math.max(1, rows) } +/** + * Render an unanswered clarify prompt (timed out, or cancelled with Esc/Ctrl+C) + * as a persistent transcript block. The live `ClarifyPrompt` overlay is torn + * down the moment the turn settles, so without this the question + options + * vanish from the screen while the agent's follow-up still refers to "the + * options above". Mirrors the option formatting in ClarifyPrompt (the same + * 1-based numbered list) so the persisted record reads identically to what was + * on screen. `reason` states why the prompt ended ("timed out", "cancelled"). + */ +export const formatAbandonedClarify = (question: string, choices: string[] | null, reason: string) => { + const head = `ask ${question.trim()}` + const opts = (choices ?? []).map((c, i) => ` ${i + 1}. ${c}`) + + return [head, ...opts, ` (${reason} — no selection)`].join('\n') +} + export const flat = (r: Record<string, string[]>) => Object.values(r).flat() const COMPACT_NUMBER = new Intl.NumberFormat('en-US', { maximumFractionDigits: 1, notation: 'compact' }) diff --git a/uv.lock b/uv.lock index 49f290f1f..c90be9add 100644 --- a/uv.lock +++ b/uv.lock @@ -1398,6 +1398,7 @@ dependencies = [ { name = "fire" }, { name = "httpx", extra = ["socks"] }, { name = "jinja2" }, + { name = "markdown" }, { name = "openai" }, { name = "pathspec" }, { name = "prompt-toolkit" }, @@ -1423,20 +1424,13 @@ acp = [ all = [ { name = "agent-client-protocol" }, { name = "aiohttp" }, - { name = "debugpy" }, { name = "fastapi" }, { name = "google-api-python-client" }, { name = "google-auth-httplib2" }, { name = "google-auth-oauthlib" }, { name = "mcp" }, - { name = "pytest" }, - { name = "pytest-asyncio" }, - { name = "pytest-timeout" }, - { name = "ruff" }, - { name = "setuptools" }, { name = "simple-term-menu" }, { name = "starlette" }, - { name = "ty" }, { name = "uvicorn", extra = ["standard"] }, { name = "youtube-transcript-api" }, ] @@ -1509,7 +1503,6 @@ matrix = [ { name = "aiohttp-socks" }, { name = "aiosqlite" }, { name = "asyncpg" }, - { name = "markdown" }, { name = "mautrix", extra = ["encryption"] }, ] mcp = [ @@ -1629,7 +1622,6 @@ requires-dist = [ { name = "hermes-agent", extras = ["cli"], marker = "extra == 'termux'" }, { name = "hermes-agent", extras = ["cron"], marker = "extra == 'all'" }, { name = "hermes-agent", extras = ["cron"], marker = "extra == 'termux'" }, - { name = "hermes-agent", extras = ["dev"], marker = "extra == 'all'" }, { name = "hermes-agent", extras = ["google"], marker = "extra == 'all'" }, { name = "hermes-agent", extras = ["google"], marker = "extra == 'termux-all'" }, { name = "hermes-agent", extras = ["homeassistant"], marker = "extra == 'all'" }, @@ -1650,7 +1642,7 @@ requires-dist = [ { name = "httpx", extras = ["socks"], specifier = "==0.28.1" }, { name = "jinja2", specifier = "==3.1.6" }, { name = "lark-oapi", marker = "extra == 'feishu'", specifier = "==1.5.3" }, - { name = "markdown", marker = "extra == 'matrix'", specifier = "==3.10.2" }, + { name = "markdown", specifier = "==3.10.2" }, { name = "mautrix", extras = ["encryption"], marker = "extra == 'matrix'", specifier = "==0.21.0" }, { name = "mcp", marker = "extra == 'computer-use'", specifier = "==1.26.0" }, { name = "mcp", marker = "extra == 'dev'", specifier = "==1.26.0" }, diff --git a/web/package.json b/web/package.json index 7615a0976..72f6dc4f8 100644 --- a/web/package.json +++ b/web/package.json @@ -25,6 +25,7 @@ "leva": "^0.10.1", "lucide-react": "^0.577.0", "motion": "^12.38.0", + "qrcode": "^1.5.4", "react": "^19.2.4", "react-dom": "^19.2.4", "react-router-dom": "^7.14.1", @@ -35,6 +36,7 @@ "devDependencies": { "@eslint/js": "^9.39.4", "@types/node": "^24.12.0", + "@types/qrcode": "^1.5.6", "@types/react": "^19.2.14", "@types/react-dom": "^19.2.3", "@vitejs/plugin-react": "^5.2.0", diff --git a/web/src/lib/api.ts b/web/src/lib/api.ts index 2f59f095e..2ee4c8335 100644 --- a/web/src/lib/api.ts +++ b/web/src/lib/api.ts @@ -589,6 +589,36 @@ export const api = { `/api/messaging/platforms/${encodeURIComponent(id)}/test`, { method: "POST" }, ), + startTelegramOnboarding: (body: { bot_name?: string }) => + fetchJSON<TelegramOnboardingStartResponse>( + "/api/messaging/telegram/onboarding/start", + { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + }, + ), + getTelegramOnboardingStatus: (pairingId: string) => + fetchJSON<TelegramOnboardingStatusResponse>( + `/api/messaging/telegram/onboarding/${encodeURIComponent(pairingId)}`, + ), + applyTelegramOnboarding: ( + pairingId: string, + body: { allowed_user_ids: string[] }, + ) => + fetchJSON<TelegramOnboardingApplyResponse>( + `/api/messaging/telegram/onboarding/${encodeURIComponent(pairingId)}/apply`, + { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + }, + ), + cancelTelegramOnboarding: (pairingId: string) => + fetchJSON<{ ok: boolean }>( + `/api/messaging/telegram/onboarding/${encodeURIComponent(pairingId)}`, + { method: "DELETE" }, + ), // Gateway / update actions restartGateway: () => @@ -1293,6 +1323,30 @@ export interface EnvVarInfo { channel_managed?: boolean; } +export interface TelegramOnboardingStartResponse { + pairing_id: string; + suggested_username: string; + deep_link: string; + qr_payload: string; + expires_at: string; +} + +export type TelegramOnboardingStatusResponse = + | { status: "waiting"; expires_at: string } + | { + status: "ready"; + bot_username: string; + owner_user_id?: string; + expires_at: string; + }; + +export interface TelegramOnboardingApplyResponse { + ok: boolean; + platform: "telegram"; + bot_username?: string; + needs_restart: true; +} + export interface SessionMessage { role: "user" | "assistant" | "system" | "tool"; content: string | null; diff --git a/web/src/pages/ChannelsPage.tsx b/web/src/pages/ChannelsPage.tsx index 4320d5f86..98c9b7a77 100644 --- a/web/src/pages/ChannelsPage.tsx +++ b/web/src/pages/ChannelsPage.tsx @@ -1,15 +1,19 @@ import { useCallback, useEffect, useLayoutEffect, useMemo, useState } from "react"; import { AlertTriangle, + Check, CheckCircle2, ExternalLink, PlugZap, + QrCode, Radio, RotateCw, + Save, Settings2, WifiOff, X, } from "lucide-react"; +import * as QRCode from "qrcode"; import { Badge } from "@nous-research/ui/ui/components/badge"; import { Button } from "@nous-research/ui/ui/components/button"; import { Card, CardContent } from "@nous-research/ui/ui/components/card"; @@ -24,6 +28,7 @@ import type { MessagingPlatform, MessagingPlatformEnvVar, MessagingPlatformUpdate, + TelegramOnboardingStartResponse, } from "@/lib/api"; import { useModalBehavior } from "@/hooks/useModalBehavior"; import { usePageHeader } from "@/contexts/usePageHeader"; @@ -48,6 +53,22 @@ function stateBadge(state: string) { return STATE_BADGE[state] ?? { tone: "outline" as const, label: state }; } +const TELEGRAM_USER_ID_RE = /^\d+$/; + +function formatExpiry(expiresAt: string): string { + const ms = Date.parse(expiresAt) - Date.now(); + if (!Number.isFinite(ms) || ms <= 0) return "expired"; + const seconds = Math.ceil(ms / 1000); + const minutes = Math.floor(seconds / 60); + const rest = seconds % 60; + return `${minutes}:${rest.toString().padStart(2, "0")}`; +} + +function isTerminalTelegramOnboardingError(error: unknown): boolean { + const message = error instanceof Error ? error.message : String(error); + return /\b410\b/.test(message) && /\b(expired|claimed|gone)\b/i.test(message); +} + export default function ChannelsPage() { const [platforms, setPlatforms] = useState<MessagingPlatform[]>([]); const [loading, setLoading] = useState(true); @@ -353,72 +374,83 @@ export default function ChannelsPage() { : Radio; return ( <Card key={platform.id} className="border-border"> - <CardContent className="flex flex-col gap-3 p-4 sm:flex-row sm:items-center sm:justify-between"> - <div className="flex items-start gap-3 min-w-0"> - <StateIcon - className={cn( - "h-5 w-5 shrink-0 mt-0.5", - platform.state === "connected" - ? "text-success" - : platform.state === "fatal" - ? "text-destructive" - : "text-muted-foreground", - )} - /> - <div className="flex flex-col gap-0.5 min-w-0"> - <div className="flex items-center gap-2 flex-wrap"> - <span className="font-mondwest normal-case text-sm font-medium"> - {platform.name} + <CardContent className="flex flex-col gap-4 p-4"> + <div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between"> + <div className="flex items-start gap-3 min-w-0"> + <StateIcon + className={cn( + "h-5 w-5 shrink-0 mt-0.5", + platform.state === "connected" + ? "text-success" + : platform.state === "fatal" + ? "text-destructive" + : "text-muted-foreground", + )} + /> + <div className="flex flex-col gap-0.5 min-w-0"> + <div className="flex items-center gap-2 flex-wrap"> + <span className="font-mondwest normal-case text-sm font-medium"> + {platform.name} + </span> + <Badge tone={badge.tone}>{badge.label}</Badge> + </div> + <span className="text-xs text-muted-foreground"> + {platform.description} </span> - <Badge tone={badge.tone}>{badge.label}</Badge> + {platform.error_message && ( + <span className="text-xs text-destructive"> + {platform.error_message} + </span> + )} </div> - <span className="text-xs text-muted-foreground"> - {platform.description} - </span> - {platform.error_message && ( - <span className="text-xs text-destructive"> - {platform.error_message} - </span> - )} </div> - </div> - <div className="flex items-center gap-2 shrink-0 self-start sm:self-center"> - <div className="flex items-center gap-1.5"> - {busy ? ( - <Spinner className="text-sm" /> - ) : ( - <Switch - checked={platform.enabled} - onCheckedChange={() => void handleToggle(platform)} - aria-label={`Enable ${platform.name}`} - /> - )} - </div> - <Button - ghost - size="sm" - onClick={() => handleTest(platform)} - disabled={testingId === platform.id} - prefix={ - testingId === platform.id ? ( - <Spinner /> + <div className="flex items-center gap-2 shrink-0 self-start sm:self-center"> + <div className="flex items-center gap-1.5"> + {busy ? ( + <Spinner className="text-sm" /> ) : ( - <PlugZap className="h-4 w-4" /> - ) - } - > - Test - </Button> - <Button - size="sm" - className="uppercase" - onClick={() => openConfig(platform)} - prefix={<Settings2 className="h-4 w-4" />} - > - Configure - </Button> + <Switch + checked={platform.enabled} + onCheckedChange={() => void handleToggle(platform)} + aria-label={`Enable ${platform.name}`} + /> + )} + </div> + <Button + ghost + size="sm" + onClick={() => handleTest(platform)} + disabled={testingId === platform.id} + prefix={ + testingId === platform.id ? ( + <Spinner /> + ) : ( + <PlugZap className="h-4 w-4" /> + ) + } + > + Test + </Button> + <Button + size="sm" + className="uppercase" + onClick={() => openConfig(platform)} + prefix={<Settings2 className="h-4 w-4" />} + > + Configure + </Button> + </div> </div> + {platform.id === "telegram" && ( + <TelegramOnboardingPanel + onChanged={load} + onRestartNeeded={() => setRestartNeeded(true)} + platform={platform} + setRestartNeeded={setRestartNeeded} + showToast={showToast} + /> + )} </CardContent> </Card> ); @@ -427,3 +459,314 @@ export default function ChannelsPage() { </div> ); } + +function TelegramOnboardingPanel({ + onChanged, + onRestartNeeded, + platform, + setRestartNeeded, + showToast, +}: { + onChanged: () => Promise<void>; + onRestartNeeded: () => void; + platform: MessagingPlatform; + setRestartNeeded: (needed: boolean) => void; + showToast: (message: string, type: "success" | "error") => void; +}) { + const [setup, setSetup] = useState<TelegramOnboardingStartResponse | null>( + null, + ); + const [qrDataUrl, setQrDataUrl] = useState(""); + const [phase, setPhase] = useState< + "idle" | "starting" | "waiting" | "ready" | "applying" + >("idle"); + const [botUsername, setBotUsername] = useState<string | null>(null); + const [allowedIds, setAllowedIds] = useState<string[]>([]); + const [detectedOwnerId, setDetectedOwnerId] = useState<string | null>(null); + const [newAllowedId, setNewAllowedId] = useState(""); + const [error, setError] = useState(""); + const [tick, setTick] = useState(0); + + useEffect(() => { + if (!setup || phase !== "waiting") return; + let cancelled = false; + let timeout: ReturnType<typeof setTimeout> | null = null; + + const poll = async () => { + try { + const status = await api.getTelegramOnboardingStatus(setup.pairing_id); + if (cancelled) return; + if (status.status === "ready") { + setPhase("ready"); + setBotUsername(status.bot_username ?? null); + setError(""); + if ( + status.owner_user_id && + TELEGRAM_USER_ID_RE.test(status.owner_user_id) + ) { + setDetectedOwnerId(status.owner_user_id); + setAllowedIds([status.owner_user_id]); + } + return; + } + setError(""); + timeout = setTimeout(poll, 2000); + } catch (pollError) { + if (cancelled) return; + + const expiresAt = Date.parse(setup.expires_at); + const expired = + Number.isFinite(expiresAt) && Date.now() >= expiresAt; + if (isTerminalTelegramOnboardingError(pollError) || expired) { + setSetup(null); + setQrDataUrl(""); + setPhase("idle"); + setError("Telegram pairing expired. Start a new QR setup to try again."); + return; + } + + setError(`Still waiting for Telegram. Retrying after: ${pollError}`); + timeout = setTimeout(poll, 2000); + } + }; + + timeout = setTimeout(poll, 1200); + return () => { + cancelled = true; + if (timeout) clearTimeout(timeout); + }; + }, [phase, setup]); + + useEffect(() => { + if (!setup) return; + const timer = setInterval(() => setTick((value) => value + 1), 1000); + return () => clearInterval(timer); + }, [setup]); + + const resetSetup = () => { + setSetup(null); + setQrDataUrl(""); + setPhase("idle"); + setBotUsername(null); + setAllowedIds([]); + setDetectedOwnerId(null); + setNewAllowedId(""); + setError(""); + }; + + const start = async () => { + setPhase("starting"); + setError(""); + setBotUsername(null); + setAllowedIds([]); + setDetectedOwnerId(null); + setNewAllowedId(""); + try { + const res = await api.startTelegramOnboarding({ bot_name: "Hermes Agent" }); + const dataUrl = await QRCode.toDataURL(res.qr_payload, { + errorCorrectionLevel: "M", + margin: 1, + width: 224, + }); + setSetup(res); + setQrDataUrl(dataUrl); + setPhase("waiting"); + } catch (startError) { + setPhase("idle"); + setError(String(startError)); + } + }; + + const cancel = async () => { + if (setup) { + try { + await api.cancelTelegramOnboarding(setup.pairing_id); + } catch { + /* local cleanup still wins */ + } + } + resetSetup(); + }; + + const addAllowedId = () => { + const trimmed = newAllowedId.trim(); + if (!TELEGRAM_USER_ID_RE.test(trimmed)) { + setError("Allowed Telegram user IDs must be numeric."); + return; + } + setError(""); + setAllowedIds((ids) => (ids.includes(trimmed) ? ids : [...ids, trimmed])); + setNewAllowedId(""); + }; + + const apply = async () => { + if (!setup) return; + if (allowedIds.length === 0) { + setError("Add at least one allowed Telegram user ID."); + return; + } + setPhase("applying"); + setError(""); + try { + await api.applyTelegramOnboarding(setup.pairing_id, { + allowed_user_ids: allowedIds, + }); + resetSetup(); + showToast("Telegram saved", "success"); + try { + await api.restartGateway(); + showToast("Gateway restarting…", "success"); + setRestartNeeded(false); + setTimeout(() => void onChanged(), 4000); + } catch (restartError) { + onRestartNeeded(); + showToast(`Telegram saved; restart failed: ${restartError}`, "error"); + } + await onChanged(); + } catch (applyError) { + setPhase("ready"); + setError(String(applyError)); + } + }; + + const expiresIn = useMemo( + () => (setup ? formatExpiry(setup.expires_at) : ""), + // tick keeps the memo fresh without recalculating on every render branch. + // eslint-disable-next-line react-hooks/exhaustive-deps + [setup, tick], + ); + + return ( + <div className="rounded-sm border border-border bg-background/35 p-4"> + <div className="flex flex-wrap items-center gap-2"> + <Button + size="sm" + className="uppercase" + onClick={() => void start()} + disabled={phase === "starting" || phase === "waiting" || phase === "applying"} + prefix={phase === "starting" ? <Spinner /> : <QrCode className="h-4 w-4" />} + > + {phase === "starting" ? "Starting…" : "Set up with QR"} + </Button> + {platform.configured && ( + <span className="text-xs text-muted-foreground"> + Existing Telegram credentials are configured. + </span> + )} + </div> + + {error && ( + <div className="mt-3 border border-destructive/40 bg-destructive/10 px-3 py-2 text-sm text-destructive"> + {error} + </div> + )} + + {setup && qrDataUrl && ( + <div className="mt-4 grid gap-4 lg:grid-cols-[minmax(0,1fr)_260px]"> + <div className="grid gap-3"> + {(phase === "ready" || phase === "applying") && ( + <div className="grid gap-3"> + <div className="flex flex-wrap items-center gap-2"> + <Badge tone="success">Ready</Badge> + {botUsername && ( + <span className="font-courier text-sm text-muted-foreground"> + @{botUsername} + </span> + )} + </div> + + <div className="grid gap-2"> + <div className="flex flex-wrap items-center gap-2"> + <span className="text-xs uppercase tracking-[0.12em] text-muted-foreground"> + Allowed users + </span> + {detectedOwnerId && allowedIds.includes(detectedOwnerId) && ( + <Badge tone="success">owner detected</Badge> + )} + </div> + <div className="flex flex-wrap gap-2"> + {allowedIds.map((id) => ( + <button + key={id} + type="button" + className="inline-flex items-center gap-1 border border-border px-2 py-1 font-courier text-xs text-foreground hover:border-destructive/50" + onClick={() => + setAllowedIds((ids) => + ids.filter((existing) => existing !== id), + ) + } + > + {id} + <X className="h-3 w-3" /> + </button> + ))} + {allowedIds.length === 0 && ( + <span className="text-sm text-muted-foreground"> + Add at least one Telegram user ID. + </span> + )} + </div> + </div> + + <div className="flex flex-col gap-2 sm:flex-row"> + <Input + value={newAllowedId} + onChange={(event) => setNewAllowedId(event.target.value)} + placeholder="Telegram user ID" + className="font-courier" + /> + <Button size="sm" outlined onClick={addAllowedId} prefix={<Check />}> + Add + </Button> + </div> + + <div className="flex flex-wrap gap-2"> + <Button + size="sm" + className="uppercase" + onClick={() => void apply()} + disabled={phase === "applying"} + prefix={phase === "applying" ? <Spinner /> : <Save className="h-4 w-4" />} + > + {phase === "applying" ? "Saving…" : "Save and restart"} + </Button> + <Button size="sm" ghost onClick={() => void cancel()}> + Cancel + </Button> + </div> + </div> + )} + </div> + + <div className="flex flex-col items-center justify-center gap-3"> + <img + src={qrDataUrl} + alt="Telegram setup QR code" + className="h-56 w-56 bg-white p-2" + /> + <div className="flex flex-wrap items-center justify-center gap-2 text-sm"> + <Badge tone={expiresIn === "expired" ? "destructive" : "outline"}> + {expiresIn} + </Badge> + {phase === "waiting" && <Badge tone="warning">waiting</Badge>} + </div> + <div className="flex flex-wrap justify-center gap-2"> + <a + href={setup.deep_link} + target="_blank" + rel="noreferrer" + className="inline-flex h-8 items-center gap-1 border border-border px-3 text-xs uppercase text-foreground hover:border-foreground/40" + > + <ExternalLink className="h-4 w-4" /> + Open Telegram + </a> + <Button size="sm" ghost onClick={() => void cancel()}> + Cancel + </Button> + </div> + </div> + </div> + )} + </div> + ); +} diff --git a/web/src/pages/SkillsPage.tsx b/web/src/pages/SkillsPage.tsx index d7a9ef669..e26c807fe 100644 --- a/web/src/pages/SkillsPage.tsx +++ b/web/src/pages/SkillsPage.tsx @@ -432,9 +432,7 @@ export default function SkillsPage() { <div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3"> {filteredToolsets.map((ts) => { const TsIcon = toolsetIcon(ts.name); - const labelText = - ts.label.replace(/^[\p{Emoji}\s]+/u, "").trim() || - ts.name; + const labelText = ts.label.trim() || ts.name; return ( <Card key={ts.name} className="relative rounded-none"> diff --git a/website/docs/getting-started/installation.md b/website/docs/getting-started/installation.md index d1b13ed55..c47f70d9e 100644 --- a/website/docs/getting-started/installation.md +++ b/website/docs/getting-started/installation.md @@ -6,80 +6,37 @@ description: "Install Hermes Agent on Linux, macOS, WSL2, native Windows, or And # Installation -Get Hermes Agent up and running in under two minutes with the one-line installer. +Get Hermes Agent up and running in under two minutes! ## Quick Install +### With the Hermes Desktop installer on macOS or Windows (recommended) +To easily install the command-line and desktop applications, [download the Hermes Desktop installer](https://hermes-agent.nousresearch.com/desktop) from our website and run it. -### Desktop App (macOS + Windows) - -Prefer a native installer? - -- **Desktop downloads:** [GitHub Releases](https://github.com/NousResearch/hermes-agent/releases/latest) - -Desktop builds ship signed/notarized macOS artifacts and Windows installers with checksum files. - -### One-Line CLI Installer (Linux / macOS / WSL2) - -For a git-based install that tracks `main` and gives you the latest changes immediately: - +### With the Hermes Desktop installer on Linux: ```bash -curl -fsSL https://raw.githubusercontent.com/NousResearch/hermes-agent/main/scripts/install.sh | bash +curl -fsSL https://hermes-agent.nousresearch.com/install.sh --include-desktop | bash ``` -### Windows (native, PowerShell) +### Without Hermes Desktop: +For a command-line only install without Hermes Desktop, run: -Native Windows runs Hermes without WSL — the CLI, gateway, TUI, and tools all work natively. (Both native and WSL2 installs coexist cleanly; see the feature note below for the one WSL2-only feature.) Found a bug? Please [file issues](https://github.com/NousResearch/hermes-agent/issues). +#### Linux / macOS / WSL2 / Android (Termux) +```bash +curl -fsSL https://hermes-agent.nousresearch.com/install.sh | bash +``` -Open PowerShell and run: +#### Windows (native) +Run in powershell: ```powershell -iex (irm https://raw.githubusercontent.com/NousResearch/hermes-agent/main/scripts/install.ps1) +iex (irm https://hermes-agent.nousresearch.com/install.ps1) ``` -The installer handles **everything**: `uv`, Python 3.11, Node.js 22, `ripgrep`, `ffmpeg`, **and a portable Git Bash** (PortableGit — a self-contained Git-for-Windows distribution that ships `bash.exe` and the full POSIX toolchain Hermes uses for shell commands; on 32-bit Windows the installer falls back to MinGit, which lacks bash and disables terminal-tool / agent-browser features). It clones the repo under `%LOCALAPPDATA%\hermes\hermes-agent`, creates a virtualenv, and adds `hermes` to your **User PATH**. Restart your terminal (or open a new PowerShell window) after the install so PATH picks up. - -**How Git is handled:** -1. If `git` is already on your PATH, the installer uses your existing install. -2. Otherwise it downloads portable **PortableGit** (~50MB, from the official `git-for-windows` GitHub release) and unpacks it to `%LOCALAPPDATA%\hermes\git`. No admin rights required. Completely isolated — it won't interfere with any system Git install, broken or otherwise. (On 32-bit Windows it falls back to MinGit because PortableGit ships only 64-bit and ARM64 assets; bash-dependent Hermes features won't work on 32-bit hosts.) - -**Why not use winget?** Earlier designs auto-installed Git via `winget install Git.Git`, but winget fails badly when a system Git install is in a partial or broken state (exactly when users need the installer to just work). The portable Git approach sidesteps winget, the Windows installer registry, and any existing system Git entirely. If the Hermes Git install itself ever breaks, `Remove-Item %LOCALAPPDATA%\hermes\git` and re-run the installer — no system impact, no uninstall drama. - -The installer also sets `HERMES_GIT_BASH_PATH` to the located `bash.exe` so Hermes resolves it deterministically in fresh shells. - -If you prefer WSL2, the Linux installer above works inside it; both native and WSL installs can coexist without conflict (native data lives under `%LOCALAPPDATA%\hermes`, WSL data lives under `~/.hermes`). - -**Desktop installer (alternative):** A thin GUI installer is also available — download Hermes Desktop, run the `.exe`, and on first launch it calls `install.ps1` under the hood to provision Python (via `uv`), Node, PortableGit, and the rest of the dependencies. The desktop app and the PowerShell-installed CLI share the same install and data directories, so you can use either or both. See the [Windows (Native) guide](../user-guide/windows-native#desktop-installer-alternative) for details. - -### Android / Termux - -Hermes now ships a Termux-aware installer path too: - +If you want to install & run Hermes Desktop after a command-line only install, simply run ```bash -curl -fsSL https://raw.githubusercontent.com/NousResearch/hermes-agent/main/scripts/install.sh | bash +hermes desktop ``` -The installer detects Termux automatically and switches to a tested Android flow: -- uses Termux `pkg` for system dependencies (`git`, `python`, `nodejs`, `ripgrep`, `ffmpeg`, build tools) -- creates the virtualenv with `python -m venv` -- exports `ANDROID_API_LEVEL` automatically for Android wheel builds -- prefers the broad `.[termux-all]` extra and falls back to the smaller `.[termux]` extra (and finally a base install) if the first attempt fails to compile -- skips the untested browser / WhatsApp bootstrap by default - -If you want the fully explicit path, follow the dedicated [Termux guide](./termux.md). - -:::note Windows Feature Parity - -Everything except the browser-based dashboard chat terminal runs natively on Windows: -- **CLI (`hermes chat`, `hermes setup`, `hermes gateway`, …)** — native, uses your default terminal -- **Gateway (Telegram, Discord, Slack, …)** — native, runs as a background PowerShell process -- **Cron scheduler** — native -- **Browser tool** — native (Chromium via Node.js) -- **MCP servers** — native (stdio and HTTP transports both supported) -- **Dashboard `/chat` terminal pane** — **WSL2 only** (uses a POSIX PTY; native Windows has no equivalent). The rest of the dashboard (sessions, jobs, metrics) works natively — only the embedded PTY terminal tab is gated. - -Set `HERMES_DISABLE_WINDOWS_UTF8=1` in your environment if you hit an encoding-related bug and want to fall back to the legacy cp1252 stdio path (useful for bisecting). -::: - ### What the Installer Does The installer handles everything automatically — all dependencies (Python, Node.js, ripgrep, ffmpeg), the repo clone, virtual environment, global `hermes` command setup, and LLM provider configuration. By the end, you're ready to chat. @@ -129,9 +86,7 @@ That logs you in, sets Nous as your provider, and turns on the Tool Gateway in o ## Prerequisites -**pip install:** No prerequisites beyond Python 3.11+. Everything else is handled automatically. - -**Git installer:** The only prerequisite is **Git**. The installer automatically handles everything else: +**Installer:** On non-Windows platforms, the only prerequisite is **Git**. The installer automatically handles everything else: - **uv** (fast Python package manager) - **Python 3.11** (via uv, no sudo needed) @@ -169,12 +124,12 @@ Running Hermes as a dedicated unprivileged user (e.g. a `hermes` systemd service 2. **As the unprivileged service user**, run the regular installer. It will detect the missing sudo, skip `--with-deps`, and install Chromium into the user's local Playwright cache: ```bash - curl -fsSL https://raw.githubusercontent.com/NousResearch/hermes-agent/main/scripts/install.sh | bash + curl -fsSL https://hermes-agent.nousresearch.com/install.sh | bash ``` If you want to skip the Playwright step entirely — for example because you're running headless and don't need browser automation — pass `--skip-browser`: ```bash - curl -fsSL https://raw.githubusercontent.com/NousResearch/hermes-agent/main/scripts/install.sh | bash -s -- --skip-browser + curl -fsSL https://hermes-agent.nousresearch.com/install.sh | bash -s -- --skip-browser ``` 3. **Make `hermes` available to the service user's shells.** The installer writes the launcher to `~/.local/bin/hermes`. System service accounts often have a minimal PATH that doesn't include `~/.local/bin`. Either add it to the user's environment, or symlink the launcher into a system location: diff --git a/website/docs/getting-started/quickstart.md b/website/docs/getting-started/quickstart.md index f76b99374..6a40593ca 100644 --- a/website/docs/getting-started/quickstart.md +++ b/website/docs/getting-started/quickstart.md @@ -47,35 +47,32 @@ Pick the row that matches your goal: --- ## 1. Install Hermes Agent +### With the Hermes Desktop installer on macOS or Windows (recommended) +To easily install the command-line and desktop applications, [download the Hermes Desktop installer](https://hermes-agent.nousresearch.com/desktop) from our website and run it. -**Option A — pip (simplest):** - +### With the Hermes Desktop installer on Linux: ```bash -pip install hermes-agent -hermes postinstall # optional: installs Node.js, browser, ripgrep, ffmpeg + runs setup +curl -fsSL https://hermes-agent.nousresearch.com/install.sh --include-desktop | bash +``` +### Without Hermes Desktop: +For a command-line only install without Hermes Desktop, run: + +#### Linux / macOS / WSL2 / Android (Termux) +```bash +curl -fsSL https://hermes-agent.nousresearch.com/install.sh | bash ``` -PyPI releases track tagged versions (major/minor releases), not every commit on `main`. For bleeding-edge, use Option B. +#### Windows (native) -**Option B — git installer (tracks main branch):** - -```bash -# Linux / macOS / WSL2 / Android (Termux) -curl -fsSL https://raw.githubusercontent.com/NousResearch/hermes-agent/main/scripts/install.sh | bash +Run in powershell: +```powershell +iex (irm https://hermes-agent.nousresearch.com/install.ps1) ``` -Prefer native installers for desktop use? - -- **Desktop downloads:** [GitHub Releases](https://github.com/NousResearch/hermes-agent/releases/latest) - :::tip Android / Termux If you're installing on a phone, see the dedicated [Termux guide](./termux.md) for the tested manual path, supported extras, and current Android-specific limitations. ::: -:::tip Windows Users -Install [WSL2](https://learn.microsoft.com/en-us/windows/wsl/install) first, then run the command above inside your WSL2 terminal. -::: - After it finishes, reload your shell: ```bash diff --git a/website/docs/getting-started/termux.md b/website/docs/getting-started/termux.md index 41647dbc8..80aae287a 100644 --- a/website/docs/getting-started/termux.md +++ b/website/docs/getting-started/termux.md @@ -46,7 +46,7 @@ That does not stop Hermes from working well as a phone-native CLI agent — it j Hermes now ships a Termux-aware installer path: ```bash -curl -fsSL https://raw.githubusercontent.com/NousResearch/hermes-agent/main/scripts/install.sh | bash +curl -fsSL https://hermes-agent.nousresearch.com/install.sh | bash ``` On Termux, the installer automatically: diff --git a/website/docs/guides/run-nemotron-3-ultra-free.md b/website/docs/guides/run-nemotron-3-ultra-free.md index 9f8934f46..0192fe105 100644 --- a/website/docs/guides/run-nemotron-3-ultra-free.md +++ b/website/docs/guides/run-nemotron-3-ultra-free.md @@ -42,14 +42,22 @@ Click **Start chatting**. That's it — you're talking to Nemotron 3 Ultra, free ## Option B — Command line -Prefer the terminal? You'll need macOS, Linux, or Windows via [WSL](https://learn.microsoft.com/en-us/windows/wsl/install) with `curl` installed (`curl` is preinstalled on most systems). +Prefer the terminal? ### 1. Install Hermes Agent +On macOS/Linux/WSL2/Android, run + ```bash curl -fsSL https://hermes-agent.nousresearch.com/install.sh | bash ``` +On Windows, run + +```powershell +iex (irm https://hermes-agent.nousresearch.com/install.ps1) +``` + Prefer to review first? Download [`install.sh`](https://hermes-agent.nousresearch.com/install.sh), inspect it, then run it. After it finishes, reload your shell: diff --git a/website/docs/index.mdx b/website/docs/index.mdx index 9a93638e5..2d5bc4443 100644 --- a/website/docs/index.mdx +++ b/website/docs/index.mdx @@ -7,34 +7,90 @@ hide_table_of_contents: true displayed_sidebar: docs --- -import Link from '@docusaurus/Link'; +import Link from "@docusaurus/Link"; # Hermes Agent The self-improving AI agent built by [Nous Research](https://nousresearch.com). The only agent with a built-in learning loop — it creates skills from experience, improves them during use, nudges itself to persist knowledge, and builds a deepening model of who you are across sessions. -<div style={{display: 'flex', gap: '1rem', marginBottom: '2rem', flexWrap: 'wrap'}}> - <Link to="/getting-started/installation" style={{display: 'inline-block', padding: '0.6rem 1.2rem', backgroundColor: '#FFD700', color: '#07070d', borderRadius: '8px', fontWeight: 600, textDecoration: 'none'}}>Get Started →</Link> - <a href="https://github.com/NousResearch/hermes-agent/releases/latest" style={{display: 'inline-block', padding: '0.6rem 1.2rem', border: '1px solid rgba(255,215,0,0.2)', borderRadius: '8px', textDecoration: 'none'}}>Download Desktop</a> - <a href="https://github.com/NousResearch/hermes-agent" style={{display: 'inline-block', padding: '0.6rem 1.2rem', border: '1px solid rgba(255,215,0,0.2)', borderRadius: '8px', textDecoration: 'none'}}>View on GitHub</a> +<div + style={{ + display: "flex", + gap: "1rem", + marginBottom: "2rem", + flexWrap: "wrap", + }} +> + <Link + to="/getting-started/installation" + style={{ + display: "inline-block", + padding: "0.6rem 1.2rem", + backgroundColor: "#FFD700", + color: "#07070d", + borderRadius: "8px", + fontWeight: 600, + textDecoration: "none", + }} + > + Get Started → + </Link> + <a + href="https://hermes-agent.nousresearch.com/desktop" + style={{ + display: "inline-block", + padding: "0.6rem 1.2rem", + border: "1px solid rgba(255,215,0,0.2)", + borderRadius: "8px", + textDecoration: "none", + }} + > + Download Desktop + </a> + <a + href="https://github.com/NousResearch/hermes-agent" + style={{ + display: "inline-block", + padding: "0.6rem 1.2rem", + border: "1px solid rgba(255,215,0,0.2)", + borderRadius: "8px", + textDecoration: "none", + }} + > + View on GitHub + </a> </div> ## Install -**Linux / macOS / WSL2** +### Windows or macOS + +To easily install the command-line and desktop applications, [download the Hermes Desktop installer](https://hermes-agent.nousresearch.com/desktop) from our website and run it. + +### Linux ```bash -curl -fsSL https://raw.githubusercontent.com/NousResearch/hermes-agent/main/scripts/install.sh | bash +curl -fsSL https://hermes-agent.nousresearch.com/install.sh --include-desktop | bash ``` -**Windows (native, PowerShell)** — *[details →](/user-guide/windows-native)* +### Without Hermes Desktop: + +For a command-line only install without Hermes Desktop, run: + +#### Linux / macOS / WSL2 / Android (Termux) + +```bash +curl -fsSL https://hermes-agent.nousresearch.com/install.sh | bash +``` + +#### Windows (native) + +Run in powershell: ```powershell -iex (irm https://raw.githubusercontent.com/NousResearch/hermes-agent/main/scripts/install.ps1) +iex (irm https://hermes-agent.nousresearch.com/install.ps1) ``` -**Android (Termux)** — same curl one-liner as Linux; the installer auto-detects Termux. - See the full **[Installation Guide](/getting-started/installation)** for what the installer does, the per-user vs root layout, and Windows-specific notes. :::tip Fastest path to a working agent @@ -47,26 +103,26 @@ It's not a coding copilot tethered to an IDE or a chatbot wrapper around a singl ## Quick Links -| | | -|---|---| -| 🚀 **[Installation](/getting-started/installation)** | Install in 60 seconds on Linux, macOS, WSL2, or native Windows | -| 📖 **[Quickstart Tutorial](/getting-started/quickstart)** | Your first conversation and key features to try | -| 🗺️ **[Learning Path](/getting-started/learning-path)** | Find the right docs for your experience level | -| ⚙️ **[Configuration](/user-guide/configuration)** | Config file, providers, models, and options | -| 💬 **[Messaging Gateway](/user-guide/messaging)** | Set up Telegram, Discord, Slack, WhatsApp, Teams, or more | -| 🔧 **[Tools & Toolsets](/user-guide/features/tools)** | 60+ built-in tools and how to configure them | -| 🧠 **[Memory System](/user-guide/features/memory)** | Persistent memory that grows across sessions | -| 📚 **[Skills System](/user-guide/features/skills)** | Procedural memory the agent creates and reuses | -| 🔌 **[MCP Integration](/user-guide/features/mcp)** | Connect to MCP servers, filter their tools, and extend Hermes safely | -| 🧭 **[Use MCP with Hermes](/guides/use-mcp-with-hermes)** | Practical MCP setup patterns, examples, and tutorials | -| 🎙️ **[Voice Mode](/user-guide/features/voice-mode)** | Real-time voice interaction in CLI, Telegram, Discord, and Discord VC | -| 🗣️ **[Use Voice Mode with Hermes](/guides/use-voice-mode-with-hermes)** | Hands-on setup and usage patterns for Hermes voice workflows | -| 🎭 **[Personality & SOUL.md](/user-guide/features/personality)** | Define Hermes' default voice with a global SOUL.md | -| 📄 **[Context Files](/user-guide/features/context-files)** | Project context files that shape every conversation | -| 🔒 **[Security](/user-guide/security)** | Command approval, authorization, container isolation | -| 💡 **[Tips & Best Practices](/guides/tips)** | Quick wins to get the most out of Hermes | -| 🏗️ **[Architecture](/developer-guide/architecture)** | How it works under the hood | -| ❓ **[FAQ & Troubleshooting](/reference/faq)** | Common questions and solutions | +| | | +| ----------------------------------------------------------------------- | --------------------------------------------------------------------- | +| 🚀 **[Installation](/getting-started/installation)** | Install in 60 seconds on Linux, macOS, WSL2, or native Windows | +| 📖 **[Quickstart Tutorial](/getting-started/quickstart)** | Your first conversation and key features to try | +| 🗺️ **[Learning Path](/getting-started/learning-path)** | Find the right docs for your experience level | +| ⚙️ **[Configuration](/user-guide/configuration)** | Config file, providers, models, and options | +| 💬 **[Messaging Gateway](/user-guide/messaging)** | Set up Telegram, Discord, Slack, WhatsApp, Teams, or more | +| 🔧 **[Tools & Toolsets](/user-guide/features/tools)** | 60+ built-in tools and how to configure them | +| 🧠 **[Memory System](/user-guide/features/memory)** | Persistent memory that grows across sessions | +| 📚 **[Skills System](/user-guide/features/skills)** | Procedural memory the agent creates and reuses | +| 🔌 **[MCP Integration](/user-guide/features/mcp)** | Connect to MCP servers, filter their tools, and extend Hermes safely | +| 🧭 **[Use MCP with Hermes](/guides/use-mcp-with-hermes)** | Practical MCP setup patterns, examples, and tutorials | +| 🎙️ **[Voice Mode](/user-guide/features/voice-mode)** | Real-time voice interaction in CLI, Telegram, Discord, and Discord VC | +| 🗣️ **[Use Voice Mode with Hermes](/guides/use-voice-mode-with-hermes)** | Hands-on setup and usage patterns for Hermes voice workflows | +| 🎭 **[Personality & SOUL.md](/user-guide/features/personality)** | Define Hermes' default voice with a global SOUL.md | +| 📄 **[Context Files](/user-guide/features/context-files)** | Project context files that shape every conversation | +| 🔒 **[Security](/user-guide/security)** | Command approval, authorization, container isolation | +| 💡 **[Tips & Best Practices](/guides/tips)** | Quick wins to get the most out of Hermes | +| 🏗️ **[Architecture](/developer-guide/architecture)** | How it works under the hood | +| ❓ **[FAQ & Troubleshooting](/reference/faq)** | Common questions and solutions | ## Key Features diff --git a/website/docs/reference/faq.md b/website/docs/reference/faq.md index 59968f1c8..d3db90f03 100644 --- a/website/docs/reference/faq.md +++ b/website/docs/reference/faq.md @@ -33,7 +33,7 @@ Set your provider with `hermes model` or by editing `~/.hermes/.env`. See the [E **Not natively.** Hermes Agent requires a Unix-like environment. On Windows, install [WSL2](https://learn.microsoft.com/en-us/windows/wsl/install) and run Hermes from inside it. The standard install command works perfectly in WSL2: ```bash -curl -fsSL https://raw.githubusercontent.com/NousResearch/hermes-agent/main/scripts/install.sh | bash +curl -fsSL https://hermes-agent.nousresearch.com/install.sh | bash ``` ### I run Hermes in WSL2. What's the best way to control my normal Windows Chrome? @@ -61,7 +61,7 @@ Yes — Hermes now has a tested Termux install path for Android phones. Quick install: ```bash -curl -fsSL https://raw.githubusercontent.com/NousResearch/hermes-agent/main/scripts/install.sh | bash +curl -fsSL https://hermes-agent.nousresearch.com/install.sh | bash ``` For the fully explicit manual steps, supported extras, and current limitations, see the [Termux guide](../getting-started/termux.md). @@ -225,7 +225,7 @@ source ~/.bashrc # If you previously installed with sudo, clean up: sudo rm /usr/local/bin/hermes # Then re-run the standard installer -curl -fsSL https://raw.githubusercontent.com/NousResearch/hermes-agent/main/scripts/install.sh | bash +curl -fsSL https://hermes-agent.nousresearch.com/install.sh | bash ``` --- @@ -751,7 +751,7 @@ Skills with very long descriptions are truncated to 40 characters in the Telegra 1. Install Hermes Agent on the new machine: ```bash - curl -fsSL https://raw.githubusercontent.com/NousResearch/hermes-agent/main/scripts/install.sh | bash + curl -fsSL https://hermes-agent.nousresearch.com/install.sh | bash ``` 2. On the **source machine**, create a full backup: diff --git a/website/docs/user-guide/desktop.md b/website/docs/user-guide/desktop.md index 561d6c428..3c9e16a4a 100644 --- a/website/docs/user-guide/desktop.md +++ b/website/docs/user-guide/desktop.md @@ -22,19 +22,7 @@ Pick whichever fits the moment. They share state, so you can start a session in ## Install -### With the Hermes Desktop installer on MacOS or Windows (recommended) - -[Download the Hermes Desktop installer](https://hermes-agent.nousresearch.com/desktop) from our website and run it. - -### With the CLI installer on Linux, MacOS, or Windows - -Add `--include-desktop` to the regular install script. - -```bash -curl -fsSL https://raw.githubusercontent.com/NousResearch/hermes-agent/main/scripts/install.sh | bash -s -- --include-desktop -``` - -### With an existing Hermes installation +Follow the [installation instructions for Hermes Desktop](../getting-started/installation.md). If you already have Hermes installed, simply run @@ -110,6 +98,10 @@ The packaged app ships only the Electron shell. On first launch it installs the By default the app starts and manages its own **local** backend. You can instead point it at a Hermes backend running on another machine — a VPS, a home server, or a Mini behind Tailscale. +:::info The remote backend is a running `hermes dashboard` process +"Remote backend" means a **`hermes dashboard`** server running on the remote machine — that is the process the desktop app connects to. Nothing in this section works unless that dashboard is actually up and reachable. The desktop app does not start it for you; you (or a `systemd` service) keep `hermes dashboard` running on the remote host, and the app attaches to it. If you also use messaging channels (Telegram, Discord, etc.), the **gateway** is a *separate* long-running process you start independently — see the note after the setup steps. +::: + The connection has two halves: on the backend you protect the dashboard with a **username and password**, and in the app you enter the backend's URL and sign in with those credentials. Binding the dashboard to a non-loopback address automatically engages its auth gate, so the username/password provider is what lets the desktop app through. ### On the backend (the remote machine) @@ -133,7 +125,9 @@ chmod 600 ~/.hermes/.env hermes dashboard --no-open --host 0.0.0.0 --port 9119 ``` -Make sure the **gateway is running** on the remote host as well if you rely on messaging channels — the desktop app drives the agent, but your gateway sessions are managed separately. See [Messaging](./messaging/index.md) for gateway setup. +Keep that `hermes dashboard` process running for as long as you want the desktop app to be able to connect — if it stops, the app can no longer reach the backend. Run it under `systemd`, `tmux`, or your process manager of choice so it survives logout and reboots. + +Separately, make sure the **gateway is running** on the remote host if you rely on messaging channels — the dashboard backend is what the desktop app talks to, but your Telegram/Discord/Slack gateway sessions are a different process that you start and keep running on their own. See [Messaging](./messaging/index.md) for gateway setup. Prefer not to keep a plaintext password at rest? Set `HERMES_DASHBOARD_BASIC_AUTH_PASSWORD_HASH` to a scrypt hash instead — compute it with `python -c "from plugins.dashboard_auth.basic import hash_password; print(hash_password('PW'))"`. Full configuration surface (config.yaml keys, every env var, the rate limiter): [Web Dashboard → Username/password provider](./features/web-dashboard.md#usernamepassword-provider-no-oauth-idp). diff --git a/website/docs/user-guide/docker.md b/website/docs/user-guide/docker.md index de1e5587f..c86fff431 100644 --- a/website/docs/user-guide/docker.md +++ b/website/docs/user-guide/docker.md @@ -17,6 +17,19 @@ This page covers option 1. The container stores all user data (config, API keys, If this is your first time running Hermes Agent, create a data directory on the host and start the container interactively to run the setup wizard: +:::caution Avoid browser-based VPS consoles for the install commands +Some VPS providers (Hetzner Cloud, and several others) offer a browser-based +console for managing hosts. These consoles transmit special characters +incorrectly — `:` may arrive as `;`, `@` may be mis-rendered, and non-English +keyboard layouts fare worse — which silently corrupts `docker run` arguments +like `-v ~/.hermes:/opt/data`, `-e KEY=value`, and pasted API keys / tokens. + +**Connect over SSH instead** (`ssh root@<host>`) for copy-paste-safe command +entry. If you must use the browser console, type the commands manually +instead of pasting, and double-check every `:`, `@`, `=`, and `/` in the +result before hitting Enter. +::: + ```sh mkdir -p ~/.hermes docker run -it --rm \ diff --git a/website/docs/user-guide/features/web-dashboard.md b/website/docs/user-guide/features/web-dashboard.md index 798a76c1b..50292bbf0 100644 --- a/website/docs/user-guide/features/web-dashboard.md +++ b/website/docs/user-guide/features/web-dashboard.md @@ -95,6 +95,10 @@ To point [Hermes Desktop](#connecting-hermes-desktop-to-a-remote-backend) at a d Hermes Desktop normally launches its own local backend, but it can also attach to a dashboard running on a remote machine (a VM, a homelab box, etc.) via **Settings → Gateway → Remote gateway**. This is the most common source of "Desktop says the backend is ready but chat never works" reports, because Desktop's readiness check verifies less than the live chat connection actually needs. +:::info Prerequisite: a `hermes dashboard` must be running on the remote host +The "remote backend" Desktop connects to **is** a `hermes dashboard` process running on the remote machine — the same server this page documents. It has to be up and reachable before any of the steps below matter; Desktop attaches to it, it doesn't start it for you. Keep it running under `systemd`/`tmux`/etc. so it survives logout and reboots. The **gateway** (Telegram/Discord/Slack/etc.) is a *separate* long-running process — start it independently if you rely on messaging channels; it is not the thing the desktop app connects to. +::: + Desktop's "remote backend is ready" probe only hits `GET /api/status`, which is a public endpoint — it answers as soon as *any* dashboard is running on the host. The live chat connection is a **separate** WebSocket to `/api/ws` (and `/api/pty`), and that socket is gated by two more checks the status probe never touches: 1. **You must be authenticated.** When the dashboard is bound to a non-loopback address it engages its auth gate. Protect it with a username and password (the bundled [username/password provider](#usernamepassword-provider-no-oauth-idp)); Desktop signs in once and reuses the resulting session for the WebSocket via a single-use ticket. Without a configured provider, a non-loopback dashboard **fails closed at startup**. diff --git a/website/docs/user-guide/messaging/google_chat.md b/website/docs/user-guide/messaging/google_chat.md index d34ebbd2e..eeeb69c6c 100644 --- a/website/docs/user-guide/messaging/google_chat.md +++ b/website/docs/user-guide/messaging/google_chat.md @@ -231,28 +231,29 @@ There's no IAM role or scope that fixes this. The endpoint only accepts user credentials. So the bot has to act *as a user* whenever it uploads a file — specifically, as the user who asked for the file. -### One-time host setup +### One-time setup (per profile) 1. Go to **APIs & Services → Credentials** in the same GCP project. 2. **Create credentials → OAuth client ID → Desktop app**. 3. Download the JSON. Move it onto the host that runs Hermes. -4. On the host, register the client with Hermes: +4. Register the client with Hermes (run under the profile you want it scoped to): ```bash +# Default profile: python -m plugins.platforms.google_chat.oauth \ --client-secret /path/to/client_secret.json + +# A named profile gets its own separate registration: +hermes -p <profile> python -m plugins.platforms.google_chat.oauth \ + --client-secret /path/to/client_secret.json ``` -That writes `~/.hermes/google_chat_user_client_secret.json`. This is shared -infrastructure — it identifies the OAuth *app*, not any individual user. One -file per host is enough no matter how many users authorize later. - -This file lives at the default Hermes root, so a gateway running under a named -profile (`hermes -p <name> gateway …`) finds the same host-wide secret — you do -**not** re-run this step per profile. To deliberately use a separate OAuth app -for one profile, drop a `google_chat_user_client_secret.json` inside that -profile's `HERMES_HOME` and it takes precedence. Per-user tokens always stay -scoped to the active profile. +That writes the client secret into the active profile's Hermes home (e.g. +`~/.hermes/google_chat_user_client_secret.json` for the default profile). The +client secret is **profile-scoped, not shared across profiles** — each profile +registers its own. This is deliberate: profiles are isolated auth boundaries, so +two profiles can point at different Google OAuth apps / accounts. Register it +once per profile that needs Google Chat attachment delivery. ### Per-user authorization (in chat) @@ -333,14 +334,20 @@ The asker has no per-user OAuth token and there's no legacy fallback. Run `/setup-files` in their DM and follow Step 10. After the exchange completes the next file request uploads natively without a gateway restart. -**`/setup-files start` says "No client credentials stored on the host."** +**`/setup-files start` says "No client credentials stored."** -The one-time host setup wasn't done. From a terminal on the host that runs -Hermes: +The one-time setup wasn't done *for this profile* (the client secret is +profile-scoped, so a registration under one profile won't be seen by another). +From a terminal, run it under the profile the gateway uses: ```bash +# Default profile: python -m plugins.platforms.google_chat.oauth \ --client-secret /path/to/client_secret.json + +# Named profile: +hermes -p <profile> python -m plugins.platforms.google_chat.oauth \ + --client-secret /path/to/client_secret.json ``` Then send `/setup-files start` again. diff --git a/website/docs/user-guide/skills/bundled/autonomous-ai-agents/autonomous-ai-agents-hermes-agent.md b/website/docs/user-guide/skills/bundled/autonomous-ai-agents/autonomous-ai-agents-hermes-agent.md index dc43372f1..77f81db14 100644 --- a/website/docs/user-guide/skills/bundled/autonomous-ai-agents/autonomous-ai-agents-hermes-agent.md +++ b/website/docs/user-guide/skills/bundled/autonomous-ai-agents/autonomous-ai-agents-hermes-agent.md @@ -52,7 +52,7 @@ People use Hermes for software development, research, system administration, dat ```bash # Install -curl -fsSL https://raw.githubusercontent.com/NousResearch/hermes-agent/main/scripts/install.sh | bash +curl -fsSL https://hermes-agent.nousresearch.com/install.sh | bash # Interactive chat (default) hermes diff --git a/website/docs/user-guide/windows-native.md b/website/docs/user-guide/windows-native.md index a52631c80..d15711fa7 100644 --- a/website/docs/user-guide/windows-native.md +++ b/website/docs/user-guide/windows-native.md @@ -17,10 +17,12 @@ If you prefer a real POSIX environment (for the dashboard's embedded terminal, ` ## Quick install -Open **PowerShell** (or Windows Terminal) and run: +[Download the Hermes Desktop installer](https://hermes-agent.nousresearch.com/desktop) from our website and run it. + +Or, for a command-line only install, open **PowerShell** (or Windows Terminal) and run: ```powershell -iex (irm https://raw.githubusercontent.com/NousResearch/hermes-agent/main/scripts/install.ps1) +iex (irm https://hermes-agent.nousresearch.com/install.ps1) ``` No admin rights required. The installer goes to `%LOCALAPPDATA%\hermes\` and adds `hermes` to your **User PATH** — open a new terminal after it finishes. @@ -28,38 +30,32 @@ No admin rights required. The installer goes to `%LOCALAPPDATA%\hermes\` and add **Installer options** (requires the scriptblock form to pass parameters): ```powershell -& ([scriptblock]::Create((irm https://raw.githubusercontent.com/NousResearch/hermes-agent/main/scripts/install.ps1))) -NoVenv -SkipSetup -Branch main +& ([scriptblock]::Create((irm https://hermes-agent.nousresearch.com/install.ps1))) -NoVenv -SkipSetup -Branch main ``` -| Parameter | Default | Purpose | -|---|---|---| -| `-Branch` | `main` | Clone a specific branch (useful for testing PRs) | -| `-Commit` | unset | Pin install to a specific commit SHA (overrides `-Branch`) | -| `-Tag` | unset | Pin install to a specific git tag (e.g. `v0.14.0`) | -| `-NoVenv` | off | Skip venv creation (advanced — you manage Python yourself) | -| `-SkipSetup` | off | Skip the post-install `hermes setup` wizard | -| `-HermesHome` | `%LOCALAPPDATA%\hermes` | Override data directory | -| `-InstallDir` | `%LOCALAPPDATA%\hermes\hermes-agent` | Override code location | +| Parameter | Default | Purpose | +| ------------- | ------------------------------------ | ---------------------------------------------------------- | +| `-Branch` | `main` | Clone a specific branch (useful for testing PRs) | +| `-Commit` | unset | Pin install to a specific commit SHA (overrides `-Branch`) | +| `-Tag` | unset | Pin install to a specific git tag (e.g. `v0.14.0`) | +| `-NoVenv` | off | Skip venv creation (advanced — you manage Python yourself) | +| `-SkipSetup` | off | Skip the post-install `hermes setup` wizard | +| `-HermesHome` | `%LOCALAPPDATA%\hermes` | Override data directory | +| `-InstallDir` | `%LOCALAPPDATA%\hermes\hermes-agent` | Override code location | The installer auto-retries flaky git fetches and strips BOM from any downloaded `install.ps1` payload, so a UTF-8 BOM picked up during HTTP transit no longer breaks the `[scriptblock]::Create((irm ...))` form. -### Desktop installer (alternative) - -A thin GUI installer is also available — useful if you'd rather double-click an `.exe` than open PowerShell. Download Hermes Desktop, run the installer, and on first launch the GUI calls `install.ps1` under the hood to provision Python (via `uv`), Node, PortableGit, and the rest of the dependency bootstrap described below. After the first run, the desktop app and the PowerShell-installed `hermes` CLI share the same `%LOCALAPPDATA%\hermes\hermes-agent` install and `%USERPROFILE%\.hermes` data directory — switch between the GUI and the CLI freely. - -Use the desktop installer when you want a familiar Windows install experience or you're handing Hermes to a non-developer; use the PowerShell one-liner when you're already in a terminal. - ### Dependency bootstrap (`dep_ensure`) On first launch (and on demand when a missing tool is detected), Hermes runs a small Python bootstrapper — `hermes_cli/dep_ensure.py` — that checks for and lazily installs the non-Python dependencies it needs. On Windows, the relevant ones are: -| Dependency | Why Hermes needs it | -|---|---| -| **PortableGit** | Provides `bash.exe` for the terminal tool and `git` for in-session clones. Provisioned at install time, not by `dep_ensure`. | -| **Node.js 22** | Required for the browser tool (`agent-browser`), the TUI's web bridge, and the WhatsApp bridge. | -| **ffmpeg** | Audio format conversion for TTS / voice messages. | -| **ripgrep** | Fast file search — falls back to `grep` if unavailable. | -| **npm packages** | `agent-browser`, Playwright Chromium, and any per-toolset Node deps are installed once at first browser-tool use. | +| Dependency | Why Hermes needs it | +| ---------------- | ---------------------------------------------------------------------------------------------------------------------------- | +| **PortableGit** | Provides `bash.exe` for the terminal tool and `git` for in-session clones. Provisioned at install time, not by `dep_ensure`. | +| **Node.js 22** | Required for the browser tool (`agent-browser`), the TUI's web bridge, and the WhatsApp bridge. | +| **ffmpeg** | Audio format conversion for TTS / voice messages. | +| **ripgrep** | Fast file search — falls back to `grep` if unavailable. | +| **npm packages** | `agent-browser`, Playwright Chromium, and any per-toolset Node deps are installed once at first browser-tool use. | Each dep has a `shutil.which(...)`-style check; if a binary is missing and the run is interactive, `dep_ensure` offers to install it (deferring to `scripts\install.ps1 -ensure <dep>` for the actual install logic). Non-interactive runs (gateway, cron, headless desktop launches) skip the prompt and surface a clear `this feature needs <dep>` error instead. @@ -86,18 +82,18 @@ On Windows, per-tool API key setup (Firecrawl, FAL, Browser Use, OpenAI TTS) is Everything except the dashboard's embedded terminal pane runs natively on Windows. -| Feature | Native Windows | WSL2 | -|---|---|---| -| CLI (`hermes chat`, `hermes setup`, `hermes gateway`, …) | ✓ | ✓ | -| Interactive TUI (`hermes --tui`) | ✓ | ✓ | -| Messaging gateway (Telegram, Discord, Slack, WhatsApp, 15+ platforms) | ✓ | ✓ | -| Cron scheduler | ✓ | ✓ | -| Browser tool (Chromium via Node) | ✓ | ✓ | -| MCP servers (stdio and HTTP) | ✓ | ✓ | -| Local Ollama / LM Studio / llama-server | ✓ | ✓ (via WSL networking) | -| Web dashboard (sessions, jobs, metrics, config) | ✓ | ✓ | -| Dashboard `/chat` embedded terminal pane | ✗ (needs POSIX PTY) | ✓ | -| Auto-start at login | ✓ (schtasks) | ✓ (systemd) | +| Feature | Native Windows | WSL2 | +| --------------------------------------------------------------------- | ------------------- | ---------------------- | +| CLI (`hermes chat`, `hermes setup`, `hermes gateway`, …) | ✓ | ✓ | +| Interactive TUI (`hermes --tui`) | ✓ | ✓ | +| Messaging gateway (Telegram, Discord, Slack, WhatsApp, 15+ platforms) | ✓ | ✓ | +| Cron scheduler | ✓ | ✓ | +| Browser tool (Chromium via Node) | ✓ | ✓ | +| MCP servers (stdio and HTTP) | ✓ | ✓ | +| Local Ollama / LM Studio / llama-server | ✓ | ✓ (via WSL networking) | +| Web dashboard (sessions, jobs, metrics, config) | ✓ | ✓ | +| Dashboard `/chat` embedded terminal pane | ✗ (needs POSIX PTY) | ✓ | +| Auto-start at login | ✓ (schtasks) | ✓ (systemd) | The dashboard's `/chat` tab embeds a real terminal via a POSIX PTY (`ptyprocess`). Native Windows has no equivalent primitive; Python's `pywinpty` / Windows ConPTY would work but is a separate implementation — treat as future work. **The rest of the dashboard works natively** — only that one tab shows a "use WSL2 for this" banner. @@ -140,12 +136,12 @@ Hermes's Windows stdio shim now sets `EDITOR=notepad` as a default. Notepad ship **User overrides still win** (they're checked before the setdefault): -| Editor | PowerShell command | -|---|---| -| VS Code | `$env:EDITOR = "code --wait"` | +| Editor | PowerShell command | +| --------- | ---------------------------------------------------------------------------------- | +| VS Code | `$env:EDITOR = "code --wait"` | | Notepad++ | `$env:EDITOR = "'C:\Program Files\Notepad++\notepad++.exe' -multiInst -nosession"` | -| Neovim | `$env:EDITOR = "nvim"` | -| Helix | `$env:EDITOR = "hx"` | +| Neovim | `$env:EDITOR = "nvim"` | +| Helix | `$env:EDITOR = "hx"` | The `--wait` flag on VS Code is critical — without it the editor returns immediately and Hermes gets a blank buffer back. @@ -200,13 +196,13 @@ Services require admin rights to install and tie the gateway's lifecycle to mach ## Data layout -| Path | Contents | -|---|---| -| `%LOCALAPPDATA%\hermes\hermes-agent\` | Git checkout + venv. Safe to `Remove-Item -Recurse` and reinstall. | -| `%LOCALAPPDATA%\hermes\git\` | PortableGit (only if the installer provisioned it). | -| `%LOCALAPPDATA%\hermes\node\` | Portable Node.js (only if the installer provisioned it). | -| `%LOCALAPPDATA%\hermes\bin\` | `hermes.cmd` shim, added to User PATH. | -| `%USERPROFILE%\.hermes\` | Your config, auth, skills, sessions, logs. **Survives reinstalls.** | +| Path | Contents | +| ------------------------------------- | ------------------------------------------------------------------- | +| `%LOCALAPPDATA%\hermes\hermes-agent\` | Git checkout + venv. Safe to `Remove-Item -Recurse` and reinstall. | +| `%LOCALAPPDATA%\hermes\git\` | PortableGit (only if the installer provisioned it). | +| `%LOCALAPPDATA%\hermes\node\` | Portable Node.js (only if the installer provisioned it). | +| `%LOCALAPPDATA%\hermes\bin\` | `hermes.cmd` shim, added to User PATH. | +| `%USERPROFILE%\.hermes\` | Your config, auth, skills, sessions, logs. **Survives reinstalls.** | The split is deliberate: `%LOCALAPPDATA%\hermes` is disposable infrastructure (you can blow it away and the one-liner restores it). `%USERPROFILE%\.hermes` is your data — config, memory, skills, session history — and is identical in shape to a Linux install. Mirror it between machines and your Hermes moves with you. @@ -248,11 +244,11 @@ Don't put secrets in User environment variables unless you specifically want eve These only affect native Windows installs: -| Variable | Effect | -|---|---| -| `HERMES_GIT_BASH_PATH` | Override bash.exe discovery. Point at any bash — full Git-for-Windows, WSL bash via symlink, MSYS2, Cygwin. The installer sets this automatically. | -| `HERMES_DISABLE_WINDOWS_UTF8` | Set to `1` to disable the UTF-8 stdio shim and fall back to the locale code page. Useful for bisecting an encoding bug. | -| `EDITOR` / `VISUAL` | Your editor for `/edit` and `Ctrl-X Ctrl-E`. Hermes defaults to `notepad` if both are unset. | +| Variable | Effect | +| ----------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------- | +| `HERMES_GIT_BASH_PATH` | Override bash.exe discovery. Point at any bash — full Git-for-Windows, WSL bash via symlink, MSYS2, Cygwin. The installer sets this automatically. | +| `HERMES_DISABLE_WINDOWS_UTF8` | Set to `1` to disable the UTF-8 stdio shim and fall back to the locale code page. Useful for bisecting an encoding bug. | +| `EDITOR` / `VISUAL` | Your editor for `/edit` and `Ctrl-X Ctrl-E`. Hermes defaults to `notepad` if both are unset. | ## Uninstall @@ -287,7 +283,7 @@ Consequence: any codepath that said "check if this PID is alive" via `os.kill(pi ## Common pitfalls **`hermes: command not found` right after install.** -Open a new PowerShell window. The installer added `%LOCALAPPDATA%\hermes\bin` to User PATH, but existing shells need to be restarted to pick it up. In the meantime you can run `& "$env:LOCALAPPDATA\hermes\bin\hermes.cmd"`. +Open a new PowerShell window. The installer added `%LOCALAPPDATA%\hermes\bin` to User PATH, but existing shells need to be restarted to pick it up. **`WinError 193: %1 is not a valid Win32 application` when running a tool.** You hit a shebang-script invocation that bypassed the `.cmd` shim. Hermes resolves commands through `shutil.which(cmd, path=local_bin)` so PATHEXT picks up `.CMD` — if you're invoking the tool via a hardcoded path instead, switch to the `.cmd` variant (e.g., `npx.cmd`, not `npx`). diff --git a/website/docs/user-guide/windows-wsl-quickstart.md b/website/docs/user-guide/windows-wsl-quickstart.md index 937c643a4..2128b3be9 100644 --- a/website/docs/user-guide/windows-wsl-quickstart.md +++ b/website/docs/user-guide/windows-wsl-quickstart.md @@ -100,7 +100,7 @@ The `metadata` mount option above is important — without it, files on `/mnt/c/ Once you have a WSL2 shell open: ```bash -curl -fsSL https://raw.githubusercontent.com/NousResearch/hermes-agent/main/scripts/install.sh | bash +curl -fsSL https://hermes-agent.nousresearch.com/install.sh | bash source ~/.bashrc hermes ``` diff --git a/website/docusaurus.config.ts b/website/docusaurus.config.ts index 265e1e5da..9e55ad2d0 100644 --- a/website/docusaurus.config.ts +++ b/website/docusaurus.config.ts @@ -114,7 +114,7 @@ const config: Config = { position: 'left', }, { - href: 'https://github.com/NousResearch/hermes-agent/releases/latest', + href: 'https://hermes-agent.nousresearch.com/desktop', label: 'Download', position: 'left', }, @@ -155,14 +155,14 @@ const config: Config = { title: 'Community', items: [ { label: 'Discord', href: 'https://discord.gg/NousResearch' }, - { label: 'GitHub Discussions', href: 'https://github.com/NousResearch/hermes-agent/discussions' }, + { label: 'GitHub Issues', href: 'https://github.com/NousResearch/hermes-agent/issues' }, { label: 'Skills Hub', href: 'https://agentskills.io' }, ], }, { title: 'More', items: [ - { label: 'Desktop Download', href: 'https://github.com/NousResearch/hermes-agent/releases/latest' }, + { label: 'Desktop Download', href: 'https://hermes-agent.nousresearch.com/desktop' }, { label: 'GitHub', href: 'https://github.com/NousResearch/hermes-agent' }, { label: 'Nous Research', href: 'https://nousresearch.com' }, ], diff --git a/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/getting-started/installation.md b/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/getting-started/installation.md index 700b1aaed..6e91995e7 100644 --- a/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/getting-started/installation.md +++ b/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/getting-started/installation.md @@ -15,7 +15,7 @@ description: "在 Linux、macOS、WSL2、原生 Windows 或通过 Termux 在 And 基于 git 的安装方式,跟踪 `main` 分支,可立即获取最新变更: ```bash -curl -fsSL https://raw.githubusercontent.com/NousResearch/hermes-agent/main/scripts/install.sh | bash +curl -fsSL https://hermes-agent.nousresearch.com/install.sh | bash ``` ### Windows(原生,PowerShell) @@ -25,12 +25,13 @@ curl -fsSL https://raw.githubusercontent.com/NousResearch/hermes-agent/main/scri 打开 PowerShell 并运行: ```powershell -iex (irm https://raw.githubusercontent.com/NousResearch/hermes-agent/main/scripts/install.ps1) +iex (irm https://hermes-agent.nousresearch.com/install.ps1) ``` 安装程序处理**一切**:`uv`、Python 3.11、Node.js 22、`ripgrep`、`ffmpeg`,**以及一个便携式 Git Bash**(PortableGit——一个自包含的 Git-for-Windows 发行版,附带 `bash.exe` 和 Hermes 用于 shell 命令的完整 POSIX 工具链;在 32 位 Windows 上安装程序会回退到 MinGit,后者缺少 bash,终端工具和 agent 浏览器功能将被禁用)。它将仓库克隆到 `%LOCALAPPDATA%\hermes\hermes-agent`,创建虚拟环境,并将 `hermes` 添加到**用户 PATH**。安装完成后请重启终端(或打开新的 PowerShell 窗口)以使 PATH 生效。 **Git 的处理方式:** + 1. 如果 `git` 已在你的 PATH 中,安装程序将使用现有安装。 2. 否则,它会下载便携式 **PortableGit**(约 50MB,来自官方 `git-for-windows` GitHub 发布页)并解压到 `%LOCALAPPDATA%\hermes\git`。无需管理员权限,完全隔离——不会干扰任何系统 Git 安装,无论其状态如何。(在 32 位 Windows 上会回退到 MinGit,因为 PortableGit 仅提供 64 位和 ARM64 资产;依赖 bash 的 Hermes 功能在 32 位主机上无法使用。) @@ -47,10 +48,11 @@ iex (irm https://raw.githubusercontent.com/NousResearch/hermes-agent/main/script Hermes 现在也提供 Termux 感知的安装路径: ```bash -curl -fsSL https://raw.githubusercontent.com/NousResearch/hermes-agent/main/scripts/install.sh | bash +curl -fsSL https://hermes-agent.nousresearch.com/install.sh | bash ``` 安装程序会自动检测 Termux 并切换到经过测试的 Android 流程: + - 使用 Termux `pkg` 安装系统依赖(`git`、`python`、`nodejs`、`ripgrep`、`ffmpeg`、构建工具) - 使用 `python -m venv` 创建虚拟环境 - 自动导出 `ANDROID_API_LEVEL` 以用于 Android wheel 构建 @@ -62,6 +64,7 @@ curl -fsSL https://raw.githubusercontent.com/NousResearch/hermes-agent/main/scri :::note Windows 功能对等性 除基于浏览器的 dashboard 聊天终端外,其余功能均可在 Windows 上原生运行: + - **CLI(`hermes chat`、`hermes setup`、`hermes gateway` 等)** — 原生,使用默认终端 - **Gateway(Telegram、Discord、Slack 等)** — 原生,作为后台 PowerShell 进程运行 - **Cron 调度器** — 原生 @@ -80,11 +83,11 @@ curl -fsSL https://raw.githubusercontent.com/NousResearch/hermes-agent/main/scri 安装程序的存放位置取决于你是以普通用户还是 root 身份安装: -| 安装方式 | 代码位置 | `hermes` 二进制 | 数据目录 | -|---|---|---|---| -| pip install | Python site-packages | `~/.local/bin/hermes`(console_scripts) | `~/.hermes/` | -| 用户级(git 安装程序) | `~/.hermes/hermes-agent/` | `~/.local/bin/hermes`(符号链接) | `~/.hermes/` | -| Root 模式(`sudo curl … \| sudo bash`) | `/usr/local/lib/hermes-agent/` | `/usr/local/bin/hermes` | `/root/.hermes/`(或 `$HERMES_HOME`) | +| 安装方式 | 代码位置 | `hermes` 二进制 | 数据目录 | +| --------------------------------------- | ------------------------------ | ---------------------------------------- | ------------------------------------- | +| pip install | Python site-packages | `~/.local/bin/hermes`(console_scripts) | `~/.hermes/` | +| 用户级(git 安装程序) | `~/.hermes/hermes-agent/` | `~/.local/bin/hermes`(符号链接) | `~/.hermes/` | +| Root 模式(`sudo curl … \| sudo bash`) | `/usr/local/lib/hermes-agent/` | `/usr/local/bin/hermes` | `/root/.hermes/`(或 `$HERMES_HOME`) | Root 模式的 **FHS 布局**(`/usr/local/lib/…`、`/usr/local/bin/hermes`)与其他系统级开发工具在 Linux 上的安装位置一致。适用于共享机器部署场景,一次系统安装可服务所有用户。每个用户的个人配置(认证、技能、会话)仍位于各自的 `~/.hermes/` 或显式指定的 `HERMES_HOME` 下。 @@ -154,22 +157,27 @@ hermes setup --portal **推荐的分步方式(Debian/Ubuntu):** 1. **一次性操作,以具有 sudo 权限的管理员用户身份**,安装 Chromium 所需的系统库: + ```bash sudo npx playwright install-deps chromium ``` + (可在任意位置运行——`npx` 会自动获取 Playwright。) 2. **以非特权服务用户身份**,运行常规安装程序。它会检测到缺少 sudo,跳过 `--with-deps`,并将 Chromium 安装到用户本地的 Playwright 缓存中: + ```bash - curl -fsSL https://raw.githubusercontent.com/NousResearch/hermes-agent/main/scripts/install.sh | bash + curl -fsSL https://hermes-agent.nousresearch.com/install.sh | bash ``` 如果想完全跳过 Playwright 步骤——例如在无头环境中运行且不需要浏览器自动化——传入 `--skip-browser`: + ```bash - curl -fsSL https://raw.githubusercontent.com/NousResearch/hermes-agent/main/scripts/install.sh | bash -s -- --skip-browser + curl -fsSL https://hermes-agent.nousresearch.com/install.sh | bash -s -- --skip-browser ``` 3. **使 `hermes` 对服务用户的 shell 可用。** 安装程序将启动器写入 `~/.local/bin/hermes`。系统服务账户通常具有不包含 `~/.local/bin` 的最小 PATH。可以将其添加到用户环境,或将启动器符号链接到系统位置: + ```bash # 方案 A — 添加到服务用户的 profile echo 'export PATH="$HOME/.local/bin:$PATH"' >> ~/.bashrc @@ -186,14 +194,14 @@ hermes setup --portal ## 故障排查 -| 问题 | 解决方案 | -|---------|----------| -| `hermes: command not found` | 重新加载 shell(`source ~/.bashrc`)或检查 PATH | -| `API key not set` | 运行 `hermes model` 配置提供商,或 `hermes config set OPENROUTER_API_KEY your_key` | -| 更新后配置丢失 | 运行 `hermes config check`,然后运行 `hermes config migrate` | +| 问题 | 解决方案 | +| --------------------------- | ---------------------------------------------------------------------------------- | +| `hermes: command not found` | 重新加载 shell(`source ~/.bashrc`)或检查 PATH | +| `API key not set` | 运行 `hermes model` 配置提供商,或 `hermes config set OPENROUTER_API_KEY your_key` | +| 更新后配置丢失 | 运行 `hermes config check`,然后运行 `hermes config migrate` | 如需更多诊断信息,运行 `hermes doctor`——它会告诉你确切缺少什么以及如何修复。 ## 安装方式自动检测 -Hermes 会自动检测安装方式(`pip`、git 安装程序、Homebrew 或 NixOS),`hermes update` 会打印对应路径的更新命令。无需设置任何环境变量——检测基于安装目录结构(Python site-packages、`~/.hermes/hermes-agent/`、Homebrew 前缀或 Nix store 路径)。`hermes doctor` 也会在其环境摘要中显示检测到的安装方式。 \ No newline at end of file +Hermes 会自动检测安装方式(`pip`、git 安装程序、Homebrew 或 NixOS),`hermes update` 会打印对应路径的更新命令。无需设置任何环境变量——检测基于安装目录结构(Python site-packages、`~/.hermes/hermes-agent/`、Homebrew 前缀或 Nix store 路径)。`hermes doctor` 也会在其环境摘要中显示检测到的安装方式。 diff --git a/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/getting-started/quickstart.md b/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/getting-started/quickstart.md index 2978485d9..7651bc95d 100644 --- a/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/getting-started/quickstart.md +++ b/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/getting-started/quickstart.md @@ -61,7 +61,7 @@ PyPI 发布版本跟踪带标签的版本(主/次版本发布),而非 `mai ```bash # Linux / macOS / WSL2 / Android (Termux) -curl -fsSL https://raw.githubusercontent.com/NousResearch/hermes-agent/main/scripts/install.sh | bash +curl -fsSL https://hermes-agent.nousresearch.com/install.sh | bash ``` :::tip Android / Termux diff --git a/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/getting-started/termux.md b/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/getting-started/termux.md index 72fdad973..1500cc39e 100644 --- a/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/getting-started/termux.md +++ b/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/getting-started/termux.md @@ -46,7 +46,7 @@ python -m pip install -e '.[termux]' -c constraints-termux.txt Hermes 现已内置 Termux 感知的安装路径: ```bash -curl -fsSL https://raw.githubusercontent.com/NousResearch/hermes-agent/main/scripts/install.sh | bash +curl -fsSL https://hermes-agent.nousresearch.com/install.sh | bash ``` 在 Termux 上,安装程序会自动: diff --git a/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/index.mdx b/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/index.mdx index da6a3fa10..b4f751568 100644 --- a/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/index.mdx +++ b/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/index.mdx @@ -7,15 +7,46 @@ hide_table_of_contents: true displayed_sidebar: docs --- -import Link from '@docusaurus/Link'; +import Link from "@docusaurus/Link"; # Hermes Agent 由 [Nous Research](https://nousresearch.com) 构建的自我改进 AI 智能体。唯一内置学习循环的智能体——它从经验中创建技能,在使用过程中持续改进,主动提示自身持久化知识,并在会话间不断深化对你的建模。 -<div style={{display: 'flex', gap: '1rem', marginBottom: '2rem', flexWrap: 'wrap'}}> - <Link to="/getting-started/installation" style={{display: 'inline-block', padding: '0.6rem 1.2rem', backgroundColor: '#FFD700', color: '#07070d', borderRadius: '8px', fontWeight: 600, textDecoration: 'none'}}>快速开始 →</Link> - <a href="https://github.com/NousResearch/hermes-agent" style={{display: 'inline-block', padding: '0.6rem 1.2rem', border: '1px solid rgba(255,215,0,0.2)', borderRadius: '8px', textDecoration: 'none'}}>在 GitHub 上查看</a> +<div + style={{ + display: "flex", + gap: "1rem", + marginBottom: "2rem", + flexWrap: "wrap", + }} +> + <Link + to="/getting-started/installation" + style={{ + display: "inline-block", + padding: "0.6rem 1.2rem", + backgroundColor: "#FFD700", + color: "#07070d", + borderRadius: "8px", + fontWeight: 600, + textDecoration: "none", + }} + > + 快速开始 → + </Link> + <a + href="https://github.com/NousResearch/hermes-agent" + style={{ + display: "inline-block", + padding: "0.6rem 1.2rem", + border: "1px solid rgba(255,215,0,0.2)", + borderRadius: "8px", + textDecoration: "none", + }} + > + 在 GitHub 上查看 + </a> </div> ## 安装 @@ -23,13 +54,13 @@ import Link from '@docusaurus/Link'; **Linux / macOS / WSL2** ```bash -curl -fsSL https://raw.githubusercontent.com/NousResearch/hermes-agent/main/scripts/install.sh | bash +curl -fsSL https://hermes-agent.nousresearch.com/install.sh | bash ``` -**Windows(原生,PowerShell)** — *早期测试版,[详情 →](/user-guide/windows-native)* +**Windows(原生,PowerShell)** — _早期测试版,[详情 →](/user-guide/windows-native)_ ```powershell -iex (irm https://raw.githubusercontent.com/NousResearch/hermes-agent/main/scripts/install.ps1) +iex (irm https://hermes-agent.nousresearch.com/install.ps1) ``` **Android(Termux)** — 与 Linux 相同的 curl 一行命令;安装程序会自动检测 Termux。 @@ -42,26 +73,26 @@ iex (irm https://raw.githubusercontent.com/NousResearch/hermes-agent/main/script ## 快速链接 -| | | -|---|---| -| 🚀 **[安装](/getting-started/installation)** | 在 Linux、macOS、WSL2 或原生 Windows(早期测试版)上 60 秒完成安装 | -| 📖 **[快速入门教程](/getting-started/quickstart)** | 第一次对话及值得尝试的核心功能 | -| 🗺️ **[学习路径](/getting-started/learning-path)** | 根据你的经验水平找到合适的文档 | -| ⚙️ **[配置](/user-guide/configuration)** | 配置文件、提供商、模型及选项 | -| 💬 **[消息网关](/user-guide/messaging)** | 配置 Telegram、Discord、Slack、WhatsApp、Teams 等平台 | -| 🔧 **[工具与工具集](/user-guide/features/tools)** | 70+ 内置工具及其配置方式 | -| 🧠 **[记忆系统](/user-guide/features/memory)** | 跨会话持续增长的持久记忆 | -| 📚 **[技能系统](/user-guide/features/skills)** | 智能体创建并复用的程序性记忆 | -| 🔌 **[MCP 集成](/user-guide/features/mcp)** | 连接 MCP 服务器、过滤其工具,并安全扩展 Hermes | -| 🧭 **[在 Hermes 中使用 MCP](/guides/use-mcp-with-hermes)** | 实用的 MCP 配置模式、示例与教程 | -| 🎙️ **[语音模式](/user-guide/features/voice-mode)** | 在 CLI、Telegram、Discord 及 Discord 语音频道中进行实时语音交互 | -| 🗣️ **[在 Hermes 中使用语音模式](/guides/use-voice-mode-with-hermes)** | Hermes 语音工作流的实操配置与使用模式 | -| 🎭 **[个性与 SOUL.md](/user-guide/features/personality)** | 通过全局 SOUL.md 定义 Hermes 的默认风格 | -| 📄 **[上下文文件](/user-guide/features/context-files)** | 影响每次对话的项目上下文文件 | -| 🔒 **[安全](/user-guide/security)** | 命令审批、授权与容器隔离 | -| 💡 **[技巧与最佳实践](/guides/tips)** | 快速上手,充分发挥 Hermes 的潜力 | -| 🏗️ **[架构](/developer-guide/architecture)** | 底层工作原理 | -| ❓ **[常见问题与故障排查](/reference/faq)** | 常见问题及解决方案 | +| | | +| --------------------------------------------------------------------- | ------------------------------------------------------------------ | +| 🚀 **[安装](/getting-started/installation)** | 在 Linux、macOS、WSL2 或原生 Windows(早期测试版)上 60 秒完成安装 | +| 📖 **[快速入门教程](/getting-started/quickstart)** | 第一次对话及值得尝试的核心功能 | +| 🗺️ **[学习路径](/getting-started/learning-path)** | 根据你的经验水平找到合适的文档 | +| ⚙️ **[配置](/user-guide/configuration)** | 配置文件、提供商、模型及选项 | +| 💬 **[消息网关](/user-guide/messaging)** | 配置 Telegram、Discord、Slack、WhatsApp、Teams 等平台 | +| 🔧 **[工具与工具集](/user-guide/features/tools)** | 70+ 内置工具及其配置方式 | +| 🧠 **[记忆系统](/user-guide/features/memory)** | 跨会话持续增长的持久记忆 | +| 📚 **[技能系统](/user-guide/features/skills)** | 智能体创建并复用的程序性记忆 | +| 🔌 **[MCP 集成](/user-guide/features/mcp)** | 连接 MCP 服务器、过滤其工具,并安全扩展 Hermes | +| 🧭 **[在 Hermes 中使用 MCP](/guides/use-mcp-with-hermes)** | 实用的 MCP 配置模式、示例与教程 | +| 🎙️ **[语音模式](/user-guide/features/voice-mode)** | 在 CLI、Telegram、Discord 及 Discord 语音频道中进行实时语音交互 | +| 🗣️ **[在 Hermes 中使用语音模式](/guides/use-voice-mode-with-hermes)** | Hermes 语音工作流的实操配置与使用模式 | +| 🎭 **[个性与 SOUL.md](/user-guide/features/personality)** | 通过全局 SOUL.md 定义 Hermes 的默认风格 | +| 📄 **[上下文文件](/user-guide/features/context-files)** | 影响每次对话的项目上下文文件 | +| 🔒 **[安全](/user-guide/security)** | 命令审批、授权与容器隔离 | +| 💡 **[技巧与最佳实践](/guides/tips)** | 快速上手,充分发挥 Hermes 的潜力 | +| 🏗️ **[架构](/developer-guide/architecture)** | 底层工作原理 | +| ❓ **[常见问题与故障排查](/reference/faq)** | 常见问题及解决方案 | ## 核心功能 @@ -83,4 +114,4 @@ iex (irm https://raw.githubusercontent.com/NousResearch/hermes-agent/main/script - **[`/llms.txt`](/llms.txt)** — 每个文档页面的精选索引,附简短描述。约 17 KB,可安全加载到 LLM 上下文中。 - **[`/llms-full.txt`](/llms-full.txt)** — 所有文档页面拼接为单一 markdown 文件,支持一次性摄取。约 1.8 MB。 -两个文件同样可通过 `/docs/llms.txt` 和 `/docs/llms-full.txt` 访问。每次部署时全新生成。 \ No newline at end of file +两个文件同样可通过 `/docs/llms.txt` 和 `/docs/llms-full.txt` 访问。每次部署时全新生成。 diff --git a/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/reference/faq.md b/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/reference/faq.md index 9cb1cd024..36fc3c313 100644 --- a/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/reference/faq.md +++ b/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/reference/faq.md @@ -33,7 +33,7 @@ Hermes Agent 可与任何兼容 OpenAI 的 API 配合使用。支持的提供商 **原生不支持。** Hermes Agent 需要类 Unix 环境。在 Windows 上,请安装 [WSL2](https://learn.microsoft.com/en-us/windows/wsl/install) 并在其中运行 Hermes。标准安装命令在 WSL2 中可完美运行: ```bash -curl -fsSL https://raw.githubusercontent.com/NousResearch/hermes-agent/main/scripts/install.sh | bash +curl -fsSL https://hermes-agent.nousresearch.com/install.sh | bash ``` ### 我在 WSL2 中运行 Hermes,如何控制 Windows 上的普通 Chrome? @@ -61,7 +61,7 @@ curl -fsSL https://raw.githubusercontent.com/NousResearch/hermes-agent/main/scri 快速安装: ```bash -curl -fsSL https://raw.githubusercontent.com/NousResearch/hermes-agent/main/scripts/install.sh | bash +curl -fsSL https://hermes-agent.nousresearch.com/install.sh | bash ``` 完整的手动步骤、支持的扩展及当前限制,请参阅 [Termux 指南](../getting-started/termux.md)。 @@ -225,7 +225,7 @@ source ~/.bashrc # 如果之前使用 sudo 安装,请先清理: sudo rm /usr/local/bin/hermes # 然后重新运行标准安装程序 -curl -fsSL https://raw.githubusercontent.com/NousResearch/hermes-agent/main/scripts/install.sh | bash +curl -fsSL https://hermes-agent.nousresearch.com/install.sh | bash ``` --- @@ -750,7 +750,7 @@ skills: 1. 在新机器上安装 Hermes Agent: ```bash - curl -fsSL https://raw.githubusercontent.com/NousResearch/hermes-agent/main/scripts/install.sh | bash + curl -fsSL https://hermes-agent.nousresearch.com/install.sh | bash ``` 2. 在**源机器**上创建完整备份: diff --git a/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/user-guide/skills/bundled/autonomous-ai-agents/autonomous-ai-agents-hermes-agent.md b/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/user-guide/skills/bundled/autonomous-ai-agents/autonomous-ai-agents-hermes-agent.md index da96b2f18..eee73a2b4 100644 --- a/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/user-guide/skills/bundled/autonomous-ai-agents/autonomous-ai-agents-hermes-agent.md +++ b/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/user-guide/skills/bundled/autonomous-ai-agents/autonomous-ai-agents-hermes-agent.md @@ -52,7 +52,7 @@ Hermes 的差异化特性: ```bash # 安装 -curl -fsSL https://raw.githubusercontent.com/NousResearch/hermes-agent/main/scripts/install.sh | bash +curl -fsSL https://hermes-agent.nousresearch.com/install.sh | bash # 交互式聊天(默认) hermes diff --git a/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/user-guide/windows-native.md b/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/user-guide/windows-native.md index 1d3b81677..89555b02c 100644 --- a/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/user-guide/windows-native.md +++ b/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/user-guide/windows-native.md @@ -1,4 +1,4 @@ ---- +P--- title: "Windows(原生)指南" description: "在 Windows 10 / 11 上原生运行 Hermes Agent — 安装、功能矩阵、UTF-8 控制台、Git Bash、将 gateway 作为计划任务、编辑器处理、PATH、卸载及常见问题" sidebar_label: "Windows(原生)" @@ -20,7 +20,7 @@ Hermes 可在 Windows 10 和 Windows 11 上原生运行——无需 WSL、Cygwin 打开 **PowerShell**(或 Windows Terminal)并运行: ```powershell -iex (irm https://raw.githubusercontent.com/NousResearch/hermes-agent/main/scripts/install.ps1) +iex (irm https://hermes-agent.nousresearch.com/install.ps1) ``` 无需管理员权限。安装程序会写入 `%LOCALAPPDATA%\hermes\`,并将 `hermes` 添加到你的**用户 PATH**——安装完成后打开新终端即可使用。 @@ -28,18 +28,18 @@ iex (irm https://raw.githubusercontent.com/NousResearch/hermes-agent/main/script **安装程序选项**(需要使用 scriptblock 形式传递参数): ```powershell -& ([scriptblock]::Create((irm https://raw.githubusercontent.com/NousResearch/hermes-agent/main/scripts/install.ps1))) -NoVenv -SkipSetup -Branch main +& ([scriptblock]::Create((irm https://hermes-agent.nousresearch.com/install.ps1))) -NoVenv -SkipSetup -Branch main ``` -| 参数 | 默认值 | 用途 | -|---|---|---| -| `-Branch` | `main` | 克隆指定分支(用于测试 PR) | -| `-Commit` | 未设置 | 将安装固定到指定 commit SHA(覆盖 `-Branch`) | -| `-Tag` | 未设置 | 将安装固定到指定 git tag(如 `v0.14.0`) | -| `-NoVenv` | 关闭 | 跳过 venv 创建(高级用法——由你自行管理 Python) | -| `-SkipSetup` | 关闭 | 跳过安装后的 `hermes setup` 向导 | -| `-HermesHome` | `%LOCALAPPDATA%\hermes` | 覆盖数据目录 | -| `-InstallDir` | `%LOCALAPPDATA%\hermes\hermes-agent` | 覆盖代码存放位置 | +| 参数 | 默认值 | 用途 | +| ------------- | ------------------------------------ | ----------------------------------------------- | +| `-Branch` | `main` | 克隆指定分支(用于测试 PR) | +| `-Commit` | 未设置 | 将安装固定到指定 commit SHA(覆盖 `-Branch`) | +| `-Tag` | 未设置 | 将安装固定到指定 git tag(如 `v0.14.0`) | +| `-NoVenv` | 关闭 | 跳过 venv 创建(高级用法——由你自行管理 Python) | +| `-SkipSetup` | 关闭 | 跳过安装后的 `hermes setup` 向导 | +| `-HermesHome` | `%LOCALAPPDATA%\hermes` | 覆盖数据目录 | +| `-InstallDir` | `%LOCALAPPDATA%\hermes\hermes-agent` | 覆盖代码存放位置 | 安装程序会自动重试不稳定的 git 拉取,并剥离下载的 `install.ps1` 内容中的 BOM,因此 HTTP 传输中携带的 UTF-8 BOM 不再会破坏 `[scriptblock]::Create((irm ...))` 形式。 @@ -53,13 +53,13 @@ iex (irm https://raw.githubusercontent.com/NousResearch/hermes-agent/main/script 在首次启动时(以及检测到缺少工具时按需触发),Hermes 会运行一个小型 Python 引导程序——`hermes_cli/dep_ensure.py`——检查并懒加载安装所需的非 Python 依赖。在 Windows 上,相关依赖如下: -| 依赖 | Hermes 需要它的原因 | -|---|---| -| **PortableGit** | 为终端工具提供 `bash.exe`,为会话内克隆提供 `git`。在安装时配置,而非由 `dep_ensure` 负责。 | -| **Node.js 22** | 浏览器工具(`agent-browser`)、TUI 的 web 桥接以及 WhatsApp 桥接所必需。 | -| **ffmpeg** | TTS / 语音消息的音频格式转换。 | -| **ripgrep** | 快速文件搜索——不可用时回退到 `grep`。 | -| **npm 包** | `agent-browser`、Playwright Chromium 以及各工具集的 Node 依赖,在首次使用浏览器工具时安装一次。 | +| 依赖 | Hermes 需要它的原因 | +| --------------- | ----------------------------------------------------------------------------------------------- | +| **PortableGit** | 为终端工具提供 `bash.exe`,为会话内克隆提供 `git`。在安装时配置,而非由 `dep_ensure` 负责。 | +| **Node.js 22** | 浏览器工具(`agent-browser`)、TUI 的 web 桥接以及 WhatsApp 桥接所必需。 | +| **ffmpeg** | TTS / 语音消息的音频格式转换。 | +| **ripgrep** | 快速文件搜索——不可用时回退到 `grep`。 | +| **npm 包** | `agent-browser`、Playwright Chromium 以及各工具集的 Node 依赖,在首次使用浏览器工具时安装一次。 | 每个依赖都有类似 `shutil.which(...)` 的检查;如果二进制文件缺失且当前为交互式运行,`dep_ensure` 会提示安装(实际安装逻辑委托给 `scripts\install.ps1 -ensure <dep>`)。非交互式运行(gateway、cron、无头桌面启动)会跳过提示,并直接给出清晰的 `this feature needs <dep>` 错误。 @@ -86,18 +86,18 @@ iex (irm https://raw.githubusercontent.com/NousResearch/hermes-agent/main/script 除 dashboard 内嵌终端面板外,所有功能均可在 Windows 上原生运行。 -| 功能 | 原生 Windows | WSL2 | -|---|---|---| -| CLI(`hermes chat`、`hermes setup`、`hermes gateway` 等) | ✓ | ✓ | -| 交互式 TUI(`hermes --tui`) | ✓ | ✓ | -| 消息 gateway(Telegram、Discord、Slack、WhatsApp,15+ 平台) | ✓ | ✓ | -| Cron 调度器 | ✓ | ✓ | -| 浏览器工具(通过 Node 驱动 Chromium) | ✓ | ✓ | -| MCP 服务器(stdio 和 HTTP) | ✓ | ✓ | -| 本地 Ollama / LM Studio / llama-server | ✓ | ✓(通过 WSL 网络) | -| Web dashboard(会话、任务、指标、配置) | ✓ | ✓ | -| Dashboard `/chat` 内嵌终端面板 | ✗(需要 POSIX PTY) | ✓ | -| 登录时自动启动 | ✓(schtasks) | ✓(systemd) | +| 功能 | 原生 Windows | WSL2 | +| ------------------------------------------------------------ | ------------------- | ------------------ | +| CLI(`hermes chat`、`hermes setup`、`hermes gateway` 等) | ✓ | ✓ | +| 交互式 TUI(`hermes --tui`) | ✓ | ✓ | +| 消息 gateway(Telegram、Discord、Slack、WhatsApp,15+ 平台) | ✓ | ✓ | +| Cron 调度器 | ✓ | ✓ | +| 浏览器工具(通过 Node 驱动 Chromium) | ✓ | ✓ | +| MCP 服务器(stdio 和 HTTP) | ✓ | ✓ | +| 本地 Ollama / LM Studio / llama-server | ✓ | ✓(通过 WSL 网络) | +| Web dashboard(会话、任务、指标、配置) | ✓ | ✓ | +| Dashboard `/chat` 内嵌终端面板 | ✗(需要 POSIX PTY) | ✓ | +| 登录时自动启动 | ✓(schtasks) | ✓(systemd) | Dashboard 的 `/chat` 标签页通过 POSIX PTY(`ptyprocess`)内嵌了真实终端。原生 Windows 没有等效的原语;Python 的 `pywinpty` / Windows ConPTY 可以实现,但需要单独的实现——视为未来工作。**dashboard 的其余部分均可原生运行**——只有该标签页会显示"请使用 WSL2"的提示横幅。 @@ -140,12 +140,12 @@ Hermes 的 Windows stdio 垫片现在将 `EDITOR=notepad` 设为默认值。Note **用户覆盖仍然优先**(在 setdefault 之前检查): -| 编辑器 | PowerShell 命令 | -|---|---| -| VS Code | `$env:EDITOR = "code --wait"` | +| 编辑器 | PowerShell 命令 | +| --------- | ---------------------------------------------------------------------------------- | +| VS Code | `$env:EDITOR = "code --wait"` | | Notepad++ | `$env:EDITOR = "'C:\Program Files\Notepad++\notepad++.exe' -multiInst -nosession"` | -| Neovim | `$env:EDITOR = "nvim"` | -| Helix | `$env:EDITOR = "hx"` | +| Neovim | `$env:EDITOR = "nvim"` | +| Helix | `$env:EDITOR = "hx"` | VS Code 的 `--wait` 标志至关重要——没有它,编辑器会立即返回,Hermes 收到的是空缓冲区。 @@ -200,13 +200,13 @@ hermes gateway uninstall # 移除 schtasks 条目、Startup 快捷方式、pid ## 数据布局 -| 路径 | 内容 | -|---|---| +| 路径 | 内容 | +| ------------------------------------- | --------------------------------------------------------------- | | `%LOCALAPPDATA%\hermes\hermes-agent\` | Git 检出 + venv。可安全执行 `Remove-Item -Recurse` 后重新安装。 | -| `%LOCALAPPDATA%\hermes\git\` | PortableGit(仅在安装程序配置时存在)。 | -| `%LOCALAPPDATA%\hermes\node\` | 便携式 Node.js(仅在安装程序配置时存在)。 | -| `%LOCALAPPDATA%\hermes\bin\` | `hermes.cmd` 垫片,已添加到用户 PATH。 | -| `%USERPROFILE%\.hermes\` | 你的配置、认证、技能、会话、日志。**重装后保留。** | +| `%LOCALAPPDATA%\hermes\git\` | PortableGit(仅在安装程序配置时存在)。 | +| `%LOCALAPPDATA%\hermes\node\` | 便携式 Node.js(仅在安装程序配置时存在)。 | +| `%LOCALAPPDATA%\hermes\bin\` | `hermes.cmd` 垫片,已添加到用户 PATH。 | +| `%USERPROFILE%\.hermes\` | 你的配置、认证、技能、会话、日志。**重装后保留。** | 这种分离是有意为之:`%LOCALAPPDATA%\hermes` 是可丢弃的基础设施(可以删除后用一行命令恢复)。`%USERPROFILE%\.hermes` 是你的数据——配置、记忆、技能、会话历史——其结构与 Linux 安装完全相同。在机器间同步它,你的 Hermes 就随之迁移。 @@ -248,11 +248,11 @@ TELEGRAM_BOT_TOKEN=... 这些变量仅影响原生 Windows 安装: -| 变量 | 效果 | -|---|---| -| `HERMES_GIT_BASH_PATH` | 覆盖 bash.exe 的发现逻辑。可指向任意 bash——完整 Git-for-Windows、通过符号链接的 WSL bash、MSYS2、Cygwin。安装程序会自动设置此变量。 | -| `HERMES_DISABLE_WINDOWS_UTF8` | 设为 `1` 可禁用 UTF-8 stdio 垫片,回退到区域设置代码页。用于排查编码 bug。 | -| `EDITOR` / `VISUAL` | 用于 `/edit` 和 `Ctrl-X Ctrl-E` 的编辑器。如果两者均未设置,Hermes 默认使用 `notepad`。 | +| 变量 | 效果 | +| ----------------------------- | ----------------------------------------------------------------------------------------------------------------------------------- | +| `HERMES_GIT_BASH_PATH` | 覆盖 bash.exe 的发现逻辑。可指向任意 bash——完整 Git-for-Windows、通过符号链接的 WSL bash、MSYS2、Cygwin。安装程序会自动设置此变量。 | +| `HERMES_DISABLE_WINDOWS_UTF8` | 设为 `1` 可禁用 UTF-8 stdio 垫片,回退到区域设置代码页。用于排查编码 bug。 | +| `EDITOR` / `VISUAL` | 用于 `/edit` 和 `Ctrl-X Ctrl-E` 的编辑器。如果两者均未设置,Hermes 默认使用 `notepad`。 | ## 卸载 @@ -322,4 +322,4 @@ UTF-8 stdio 垫片未激活。检查 `HERMES_DISABLE_WINDOWS_UTF8` 是否**未** - **[Windows(WSL2)指南](./windows-wsl-quickstart.md)** — 如果你需要 POSIX 语义或 dashboard 终端面板。 - **[CLI 参考](../reference/cli-commands.md)** — 所有 `hermes` 子命令。 - **[FAQ](../reference/faq.md)** — 常见的非 Windows 专属问题。 -- **[消息 Gateway](./messaging/index.md)** — 在 Windows 上运行 Telegram/Discord/Slack。 \ No newline at end of file +- **[消息 Gateway](./messaging/index.md)** — 在 Windows 上运行 Telegram/Discord/Slack。 diff --git a/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/user-guide/windows-wsl-quickstart.md b/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/user-guide/windows-wsl-quickstart.md index e428ab305..7b108b8f8 100644 --- a/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/user-guide/windows-wsl-quickstart.md +++ b/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/user-guide/windows-wsl-quickstart.md @@ -100,7 +100,7 @@ wsl --shutdown 打开 WSL2 shell 后执行: ```bash -curl -fsSL https://raw.githubusercontent.com/NousResearch/hermes-agent/main/scripts/install.sh | bash +curl -fsSL https://hermes-agent.nousresearch.com/install.sh | bash source ~/.bashrc hermes ``` diff --git a/website/static/api/model-catalog.json b/website/static/api/model-catalog.json index 8cf88bc5c..a3669541f 100644 --- a/website/static/api/model-catalog.json +++ b/website/static/api/model-catalog.json @@ -1,6 +1,6 @@ { "version": 1, - "updated_at": "2026-06-01T08:20:18Z", + "updated_at": "2026-06-04T23:57:51Z", "metadata": { "source": "hermes-agent repo", "docs": "https://hermes-agent.nousresearch.com/docs/reference/model-catalog" @@ -68,6 +68,10 @@ "id": "qwen/qwen3.7-max", "description": "" }, + { + "id": "qwen/qwen3.7-plus", + "description": "" + }, { "id": "qwen/qwen3.6-35b-a3b", "description": "" @@ -171,6 +175,9 @@ { "id": "qwen/qwen3.7-max" }, + { + "id": "qwen/qwen3.7-plus" + }, { "id": "qwen/qwen3.6-35b-a3b" },