Compare commits

..
73 changed files with 1315 additions and 2239 deletions
-42
View File
@@ -63,45 +63,3 @@ data/
# Compose/profile runtime state (bind-mounted; avoid ownership/secret issues)
hermes-config/
runtime/
# ---------- Not needed inside the Docker image ----------
# Desktop app source (Tauri/Electron); never installed in the container
apps/
# Test suite — not shipped in production images
tests/
# Documentation site (Docusaurus) and supplementary docs
website/
docs/
# Assets only used by the GitHub README
assets/
infographic/
# Plugin-level docs (hermes-achievements ships docs/ but the runtime doesn't read them)
plugins/hermes-achievements/docs/
# Nix / Homebrew / AUR packaging metadata — irrelevant to Docker
nix/
flake.nix
flake.lock
packaging/
# Design and planning documents
plans/
.plans/
# ACP registry manifest (icon + agent.json) — not consumed at runtime
acp_registry/
# Repo-level dotfiles that are git-only or dev-tooling config
.env.example
.envrc
.gitattributes
.hadolint.yaml
.mailmap
# Top-level LICENSE (not matched by *.md); not needed inside the container
LICENSE
-6
View File
@@ -114,12 +114,6 @@ docs/superpowers/*
# treat it as a local edit and autostash it on every run (#38529).
.hermes-bootstrap-complete
# Interrupted-update breadcrumb + recovery lock written next to the shared venv
# by `hermes update` / launch-time self-heal. Runtime state, never a code change
# — ignore so `git status` stays clean and update's autostash skips them.
.update-incomplete
.update-incomplete.lock
# Tool Search live-test harness output — non-deterministic model transcripts,
# regenerated by scripts/tool_search_livetest.py. Never an artifact of the repo.
scripts/out/
+9 -20
View File
@@ -25,7 +25,7 @@ ENV PLAYWRIGHT_BROWSERS_PATH=/opt/hermes/.playwright
# hermes process, the dashboard, and per-profile gateways.
RUN apt-get update && \
apt-get install -y --no-install-recommends \
ca-certificates curl iputils-ping python3 python-is-python3 ripgrep ffmpeg gcc g++ make cmake python3-dev python3-venv libffi-dev libolm-dev procps git openssh-client docker-cli xz-utils && \
ca-certificates curl iputils-ping python3 python-is-python3 ripgrep ffmpeg gcc python3-dev python3-venv libffi-dev libolm-dev procps git openssh-client docker-cli xz-utils && \
rm -rf /var/lib/apt/lists/*
# ---------- s6-overlay install ----------
@@ -146,9 +146,9 @@ RUN npm install --prefer-offline --no-audit && \
#
# `uv sync --frozen --no-install-project --extra all --extra messaging`
# installs the deps reachable through the composite `[all]` extra
# (handpicked set intended for the production image — excludes `[dev]`),
# plus gateway messaging adapters that should work in the published image
# without a first-boot lazy install. We do NOT use `--all-extras`:
# (handpicked set intended for the production image), plus gateway
# messaging adapters that should work in the published image without a
# first-boot lazy install. We do NOT use `--all-extras`:
# that would pull in `[rl]` (atroposlib + tinker + torch + wandb from
# git), `[yc-bench]` (another git dep), and `[termux-all]` (Android
# redundancy), none of which belong in the published container.
@@ -164,30 +164,19 @@ RUN npm install --prefer-offline --no-audit && \
# image update and recall/retain then fails with
# `ModuleNotFoundError: No module named 'hindsight_client'` (#38128).
#
# The Matrix gateway's deps ([matrix] extra) are baked in because
# python-olm (transitive via mautrix[encryption]) builds from source on
# Python/image combinations without usable wheels. The Docker image is
# Linux-only, so keeping the native libolm/build-toolchain packages here
# avoids the cross-platform failures that kept [matrix] out of [all]
# while still making Matrix work in the published container. Fixes #30399.
#
# The editable link is created after the source copy below.
COPY pyproject.toml uv.lock ./
RUN touch ./README.md
RUN uv sync --frozen --no-install-project --extra all --extra messaging --extra anthropic --extra bedrock --extra azure-identity --extra hindsight --extra matrix
# ---------- Frontend build (cached independently from Python source) ----------
# Copy only the frontend source trees first so that Python-only changes don't
# invalidate the (relatively slow) web + ui-tui build layer.
COPY web/ web/
COPY ui-tui/ ui-tui/
RUN cd web && npm run build && \
cd ../ui-tui && npm run build
RUN uv sync --frozen --no-install-project --extra all --extra messaging --extra anthropic --extra bedrock --extra azure-identity --extra hindsight
# ---------- Source code ----------
# .dockerignore excludes node_modules, so the installs above survive.
COPY --chown=hermes:hermes . .
# Build browser dashboard and terminal UI assets.
RUN cd web && npm run build && \
cd ../ui-tui && npm run build
# ---------- Permissions ----------
# Make install dir world-readable so any HERMES_UID can read it at runtime.
# The venv needs to be traversable too.
+15 -2
View File
@@ -25,6 +25,7 @@ import json
import logging
import os
import re
import tempfile
import threading
from datetime import datetime, timedelta, timezone
from pathlib import Path
@@ -32,7 +33,6 @@ from typing import Any, Callable, Dict, List, NamedTuple, Optional, Set
from hermes_constants import get_hermes_home
from tools import skill_usage
from utils import atomic_json_write
logger = logging.getLogger(__name__)
@@ -97,7 +97,20 @@ def load_state() -> Dict[str, Any]:
def save_state(data: Dict[str, Any]) -> None:
path = _state_file()
try:
atomic_json_write(path, data, indent=2, sort_keys=True)
path.parent.mkdir(parents=True, exist_ok=True)
fd, tmp = tempfile.mkstemp(dir=str(path.parent), prefix=".curator_state_", suffix=".tmp")
try:
with os.fdopen(fd, "w", encoding="utf-8") as f:
json.dump(data, f, indent=2, sort_keys=True, ensure_ascii=False)
f.flush()
os.fsync(f.fileno())
os.replace(tmp, path)
except BaseException:
try:
os.unlink(tmp)
except OSError:
pass
raise
except Exception as e:
logger.debug("Failed to save curator state: %s", e, exc_info=True)
+2 -11
View File
@@ -40,15 +40,6 @@ const path = require('node:path')
const https = require('node:https')
const { spawn } = require('node:child_process')
const IS_WINDOWS = process.platform === 'win32'
function hiddenWindowsChildOptions(options = {}) {
if (!IS_WINDOWS || Object.prototype.hasOwnProperty.call(options, 'windowsHide')) {
return options
}
return { ...options, windowsHide: true }
}
const STAMP_COMMIT_RE = /^[0-9a-f]{7,40}$/i
// Stages flagged needs_user_input=true in the manifest are skipped by the
@@ -293,7 +284,7 @@ function spawnPowerShell(scriptPath, args, { emit, stageName, abortSignal, herme
const ps = process.platform === 'win32' ? resolveWindowsPowerShell() : 'pwsh'
const fullArgs = ['-NoProfile', '-ExecutionPolicy', 'Bypass', '-File', scriptPath, ...args]
const child = spawn(ps, fullArgs, hiddenWindowsChildOptions({
const child = spawn(ps, fullArgs, {
stdio: ['ignore', 'pipe', 'pipe'],
env: {
...process.env,
@@ -301,7 +292,7 @@ function spawnPowerShell(scriptPath, args, { emit, stageName, abortSignal, herme
// choice rather than re-computing the default.
HERMES_HOME: hermesHome || process.env.HERMES_HOME || ''
}
}))
})
let stdout = ''
let stderr = ''
+15 -22
View File
@@ -107,13 +107,6 @@ const IS_WINDOWS = process.platform === 'win32'
const IS_WSL = isWslEnvironment()
const APP_ROOT = app.getAppPath()
function hiddenWindowsChildOptions(options = {}) {
if (!IS_WINDOWS || Object.prototype.hasOwnProperty.call(options, 'windowsHide')) {
return options
}
return { ...options, windowsHide: true }
}
// Remote displays (SSH X11 forwarding, VNC, RDP) make Chromium's GPU
// compositor flicker — accelerated layers can't be presented cleanly over the
// wire, so the window flashes during scroll/streaming/animation. Local
@@ -1113,7 +1106,7 @@ function findSystemPython() {
const out = execFileSync(
'reg',
['query', `${hive}\\SOFTWARE\\Python\\PythonCore\\${version}\\InstallPath`, '/ve', '/reg:64'],
hiddenWindowsChildOptions({ encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] })
{ encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }
)
// Output format: " (Default) REG_SZ C:\Path\To\Python\"
const match = out.match(/REG_SZ\s+(.+?)\s*$/m)
@@ -1149,10 +1142,10 @@ function findSystemPython() {
if (pyExe) {
for (const version of SUPPORTED_VERSIONS) {
try {
const out = execFileSync(pyExe, [`-${version}`, '-c', 'import sys; print(sys.executable)'], hiddenWindowsChildOptions({
const out = execFileSync(pyExe, [`-${version}`, '-c', 'import sys; print(sys.executable)'], {
encoding: 'utf8',
stdio: ['ignore', 'pipe', 'ignore']
}))
})
const candidate = out.trim()
if (candidate && fileExists(candidate)) return candidate
} catch {
@@ -1287,11 +1280,11 @@ function resolveUpdateRoot() {
function runGit(args, options = {}) {
return new Promise((resolve, reject) => {
const child = spawn(resolveGitBinary(), IS_WINDOWS ? ['-c', 'windows.appendAtomically=false', ...args] : args, hiddenWindowsChildOptions({
const child = spawn(resolveGitBinary(), IS_WINDOWS ? ['-c', 'windows.appendAtomically=false', ...args] : args, {
cwd: options.cwd,
env: { ...process.env, ...(options.env || {}), GIT_TERMINAL_PROMPT: '0' },
stdio: ['ignore', 'pipe', 'pipe']
}))
})
let stdout = ''
let stderr = ''
@@ -1501,7 +1494,7 @@ function forceKillProcessTree(pid) {
if (!IS_WINDOWS) return
if (!Number.isInteger(pid) || pid <= 0) return
try {
execFileSync('taskkill', ['/PID', String(pid), '/T', '/F'], hiddenWindowsChildOptions({ stdio: 'ignore' }))
execFileSync('taskkill', ['/PID', String(pid), '/T', '/F'], { stdio: 'ignore' })
} catch {
// Already gone, or no permission — best effort; the unlock wait below is
// the real gate.
@@ -1687,11 +1680,11 @@ function runStreamedUpdate(command, args, { cwd, env, stage } = {}) {
return new Promise(resolve => {
let child
try {
child = spawn(command, args, hiddenWindowsChildOptions({
child = spawn(command, args, {
cwd,
env: { ...process.env, ...(env || {}) },
stdio: ['ignore', 'pipe', 'pipe']
}))
})
} catch (err) {
resolve({ code: 1, error: err.message })
return
@@ -2678,7 +2671,7 @@ function fetchHtmlTitleWithCurl(rawUrl) {
'--raw',
url
]
const child = spawn('curl', args, hiddenWindowsChildOptions({ stdio: ['ignore', 'pipe', 'ignore'] }))
const child = spawn('curl', args, { stdio: ['ignore', 'pipe', 'ignore'] })
const chunks = []
let bytes = 0
@@ -4498,7 +4491,7 @@ async function spawnPoolBackend(profile, entry) {
rememberLog(`Starting Hermes backend for profile "${profile}" via ${backend.label}`)
const child = spawn(backend.command, backend.args, hiddenWindowsChildOptions({
const child = spawn(backend.command, backend.args, {
cwd: hermesCwd,
env: {
...process.env,
@@ -4516,7 +4509,7 @@ async function spawnPoolBackend(profile, entry) {
},
shell: backend.shell,
stdio: ['ignore', 'pipe', 'pipe']
}))
})
entry.process = child
entry.port = port
entry.token = token
@@ -4698,7 +4691,7 @@ async function startHermes() {
await advanceBootProgress('backend.spawn', `Starting Hermes backend via ${backend.label}`, 84)
rememberLog(`Starting Hermes backend via ${backend.label}`)
hermesProcess = spawn(backend.command, backend.args, hiddenWindowsChildOptions({
hermesProcess = spawn(backend.command, backend.args, {
cwd: hermesCwd,
env: {
...process.env,
@@ -4721,7 +4714,7 @@ async function startHermes() {
},
shell: backend.shell,
stdio: ['ignore', 'pipe', 'pipe']
}))
})
hermesProcess.stdout.on('data', rememberLog)
hermesProcess.stderr.on('data', rememberLog)
@@ -5993,11 +5986,11 @@ async function getUninstallSummary() {
resolve(value)
}
try {
const child = spawn(py, ['-m', 'hermes_cli.main', 'uninstall', '--gui-summary'], hiddenWindowsChildOptions({
const child = spawn(py, ['-m', 'hermes_cli.main', 'uninstall', '--gui-summary'], {
cwd: agentRoot,
env: { ...process.env, HERMES_HOME, NO_COLOR: '1' },
stdio: ['ignore', 'pipe', 'ignore']
}))
})
child.stdout.on('data', chunk => {
stdout += chunk.toString()
})
@@ -1,54 +0,0 @@
'use strict'
const test = require('node:test')
const assert = require('node:assert/strict')
const fs = require('node:fs')
const path = require('node:path')
const ELECTRON_DIR = __dirname
function readElectronFile(name) {
return fs.readFileSync(path.join(ELECTRON_DIR, name), 'utf8')
}
function requireHiddenChildOptions(source, needle) {
const index = source.indexOf(needle)
assert.notEqual(index, -1, `missing call site: ${needle}`)
const snippet = source.slice(index, index + 700)
assert.match(
snippet,
/hiddenWindowsChildOptions\(/,
`expected ${needle} to wrap child-process options with hiddenWindowsChildOptions`
)
}
test('desktop background child processes opt into hidden Windows consoles', () => {
const source = readElectronFile('main.cjs')
assert.match(source, /function hiddenWindowsChildOptions\(options = \{\}\)/)
requireHiddenChildOptions(source, "execFileSync(\n 'reg'")
requireHiddenChildOptions(source, 'execFileSync(pyExe')
requireHiddenChildOptions(source, 'spawn(resolveGitBinary()')
requireHiddenChildOptions(source, "execFileSync('taskkill'")
requireHiddenChildOptions(source, 'spawn(command, args')
requireHiddenChildOptions(source, "spawn('curl'")
requireHiddenChildOptions(source, 'spawn(backend.command, backend.args')
requireHiddenChildOptions(source, 'hermesProcess = spawn(backend.command, backend.args')
requireHiddenChildOptions(source, "spawn(py, ['-m', 'hermes_cli.main', 'uninstall', '--gui-summary']")
})
test('intentional or interactive desktop child processes stay documented', () => {
const source = readElectronFile('main.cjs')
assert.match(source, /windowsHide: false/)
assert.match(source, /nodePty\.spawn\(command, args/)
assert.match(source, /spawn\('cmd\.exe', \['\/c', 'start'/)
})
test('bootstrap PowerShell runner hides Windows console children', () => {
const source = readElectronFile('bootstrap-runner.cjs')
assert.match(source, /function hiddenWindowsChildOptions\(options = \{\}\)/)
requireHiddenChildOptions(source, 'spawn(ps, fullArgs')
})
+1 -1
View File
@@ -35,7 +35,7 @@
"test:desktop:nsis": "node scripts/test-desktop.mjs nsis",
"test:desktop:existing": "node scripts/test-desktop.mjs existing",
"test:desktop:fresh": "node scripts/test-desktop.mjs fresh",
"test:desktop:platforms": "node --test electron/bootstrap-platform.test.cjs electron/hardening.test.cjs electron/backend-probes.test.cjs electron/bootstrap-runner.test.cjs electron/connection-config.test.cjs electron/gateway-ws-probe.test.cjs electron/oauth-net-request.test.cjs electron/desktop-uninstall.test.cjs electron/session-windows.test.cjs electron/workspace-cwd.test.cjs electron/windows-child-process.test.cjs",
"test:desktop:platforms": "node --test electron/bootstrap-platform.test.cjs electron/hardening.test.cjs electron/backend-probes.test.cjs electron/bootstrap-runner.test.cjs electron/connection-config.test.cjs electron/gateway-ws-probe.test.cjs electron/oauth-net-request.test.cjs electron/desktop-uninstall.test.cjs electron/session-windows.test.cjs electron/workspace-cwd.test.cjs",
"type-check": "tsc -b",
"lint": "eslint src/ electron/",
"lint:fix": "eslint src/ electron/ --fix",
@@ -13,6 +13,7 @@ import { Streamdown } from 'streamdown'
import { HERMES_PATHS_MIME } from '@/app/chat/hooks/use-composer-actions'
import { PageLoader } from '@/components/page-loader'
import { translateNow, useI18n } from '@/i18n'
import { readDesktopFileDataUrl, readDesktopFileText } from '@/lib/desktop-fs'
import { cn } from '@/lib/utils'
import type { PreviewTarget } from '@/store/preview'
@@ -180,15 +181,13 @@ function looksBinaryBytes(bytes: Uint8Array) {
}
async function readTextPreview(filePath: string) {
if (window.hermesDesktop.readFileText) {
try {
return await window.hermesDesktop.readFileText(filePath)
} catch (error) {
const message = error instanceof Error ? error.message : String(error)
try {
return await readDesktopFileText(filePath)
} catch (error) {
const message = error instanceof Error ? error.message : String(error)
if (!message.includes("No handler registered for 'hermes:readFileText'")) {
throw error
}
if (!message.includes("No handler registered for 'hermes:readFileText'")) {
throw error
}
}
@@ -448,7 +447,7 @@ export function LocalFilePreview({ reloadKey, target }: { reloadKey: number; tar
if (isImage) {
// Prefer bytes the caller already handed us (a pasted/dropped
// screenshot) over re-reading a path that may be transient/unreadable.
const dataUrl = target.dataUrl || (await window.hermesDesktop.readFileDataUrl(filePath))
const dataUrl = target.dataUrl || (await readDesktopFileDataUrl(filePath))
if (active) {
setState({ dataUrl, loading: false })
@@ -1,11 +1,50 @@
import { act, cleanup, render } from '@testing-library/react'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { $connection } from '@/store/session'
import { PreviewPane } from './preview-pane'
describe('PreviewPane console state', () => {
beforeEach(() => {
vi.stubGlobal('requestAnimationFrame', (callback: FrameRequestCallback) => window.setTimeout(() => callback(Date.now()), 0))
vi.stubGlobal('cancelAnimationFrame', (id: number) => window.clearTimeout(id))
})
afterEach(() => {
cleanup()
$connection.set(null)
vi.unstubAllGlobals()
})
it('does not watch backend-only remote filesystem previews locally', () => {
const watchPreviewFile = vi.fn(async () => ({ id: 'watch-1', path: '/remote/file.txt' }))
const onPreviewFileChanged = vi.fn(() => vi.fn())
$connection.set({ mode: 'remote' } as never)
vi.stubGlobal('window', {
...window,
hermesDesktop: {
onPreviewFileChanged,
watchPreviewFile
}
})
render(
<PreviewPane
setTitlebarToolGroup={vi.fn()}
target={{
kind: 'file',
label: 'file.txt',
path: '/remote/file.txt',
previewKind: 'text',
source: '/remote/file.txt',
url: 'file:///remote/file.txt'
}}
/>
)
expect(watchPreviewFile).not.toHaveBeenCalled()
expect(onPreviewFileChanged).not.toHaveBeenCalled()
})
it('does not rebuild the pane titlebar group for streamed console logs', () => {
@@ -5,6 +5,7 @@ import { useCallback, useEffect, useRef, useState } from 'react'
import type { SetTitlebarToolGroup, TitlebarTool } from '@/app/shell/titlebar-controls'
import { Tip } from '@/components/ui/tooltip'
import { type Translations, useI18n } from '@/i18n'
import { isDesktopFsRemoteMode } from '@/lib/desktop-fs'
import { Bug } from '@/lib/icons'
import { cn } from '@/lib/utils'
import { notify, notifyError } from '@/store/notifications'
@@ -406,6 +407,7 @@ export function PreviewPane({
useEffect(() => {
if (
target.kind !== 'file' ||
isDesktopFsRemoteMode() ||
!window.hermesDesktop?.watchPreviewFile ||
!window.hermesDesktop?.onPreviewFileChanged
) {
+2 -2
View File
@@ -38,7 +38,6 @@ import { Skeleton } from '@/components/ui/skeleton'
import { Tip } from '@/components/ui/tooltip'
import { searchSessions, type SessionInfo, type SessionSearchResult } from '@/hermes'
import { useI18n } from '@/i18n'
import { normalizeCombo } from '@/lib/keybinds/combo'
import { profileColor } from '@/lib/profile-color'
import { sessionMatchesSearch } from '@/lib/session-search'
import { normalizeSessionSource, sessionSourceLabel } from '@/lib/session-source'
@@ -112,7 +111,8 @@ const NON_SESSION_LOAD_STEP = 10
// Render the modifier key the user actually presses on this platform. The
// global accelerator is bound to both Cmd+N (macOS) and Ctrl+N (everywhere
// else) in desktop-controller.tsx, but the hint should match muscle memory.
const NEW_SESSION_KBD: readonly string[] =normalizeCombo('mod+n')
const NEW_SESSION_KBD: readonly string[] =
typeof navigator !== 'undefined' && navigator.platform.toLowerCase().includes('mac') ? ['⌘', 'N'] : ['Ctrl', 'N']
const SIDEBAR_NAV: SidebarNavItem[] = [
{
@@ -10,7 +10,6 @@ import type { SessionInfo } from '@/hermes'
import { type Translations, useI18n } from '@/i18n'
import { sessionTitle } from '@/lib/chat-runtime'
import { triggerHaptic } from '@/lib/haptics'
import { modKey } from '@/lib/keybinds/combo'
import { handoffOriginSource, sessionSourceLabel } from '@/lib/session-source'
import { cn } from '@/lib/utils'
import { $attentionSessionIds } from '@/store/session'
@@ -134,11 +133,11 @@ export function SidebarSessionRow({
return
}
// ⌘-click (mac) / Ctrl-click (win/linux) pops the chat into its own
// ⌘-click (mac) / -click (win/linux) pops the chat into its own
// window — the universal "open in a new window" gesture. Archive
// lives in the row's ⋯ and right-click menus. Falls through to a
// normal resume when standalone windows aren't available (web embed).
if (event[modKey] && canOpenSessionWindow()) {
if ((event.metaKey || event.ctrlKey) && canOpenSessionWindow()) {
event.preventDefault()
event.stopPropagation()
triggerHaptic('selection')
+1 -2
View File
@@ -91,7 +91,6 @@ import { CommandPalette } from './command-palette'
import { useGatewayBoot } from './gateway/hooks/use-gateway-boot'
import { useGatewayRequest } from './gateway/hooks/use-gateway-request'
import { useKeybinds } from './hooks/use-keybinds'
import { modKey } from '@/lib/keybinds/combo'
import { SIDEBAR_COLLAPSE_MEDIA_QUERY } from './layout-constants'
import { ModelPickerOverlay } from './model-picker-overlay'
import { ModelVisibilityOverlay } from './model-visibility-overlay'
@@ -272,7 +271,7 @@ export function DesktopController() {
return
}
if (event[modKey] && !event.altKey && !event.shiftKey && event.key.toLowerCase() === 'w') {
if ((event.metaKey || event.ctrlKey) && !event.altKey && !event.shiftKey && event.key.toLowerCase() === 'w') {
event.preventDefault()
event.stopPropagation()
closeActiveRightRailTab()
@@ -3,6 +3,7 @@ import { useEffect, useRef } from 'react'
import type { HermesConnection } from '@/global'
import { HermesGateway } from '@/hermes'
import { translateNow } from '@/i18n'
import { desktopDefaultCwd } from '@/lib/desktop-fs'
import { isGatewayReauthRequired, resolveGatewayWsUrl } from '@/lib/gateway-ws-url'
import {
$desktopBoot,
@@ -25,12 +26,16 @@ import {
import { notify, notifyError } from '@/store/notifications'
import { $activeGatewayProfile, normalizeProfileKey, touchActiveGatewayBackend } from '@/store/profile'
import {
$activeSessionId,
$attentionSessionIds,
$connection,
$currentCwd,
$sessions,
$workingSessionIds,
ensureDefaultWorkspaceCwd,
setConnection,
setCurrentBranch,
setCurrentCwd,
setSessionsLoading
} from '@/store/session'
import type { RpcEvent } from '@/types/hermes'
@@ -353,6 +358,11 @@ export function useGatewayBoot({
progress: 97
})
await ensureDefaultWorkspaceCwd()
const remoteDefault = await desktopDefaultCwd().catch(() => null)
if (remoteDefault?.cwd && !$activeSessionId.get() && !$currentCwd.get()) {
setCurrentCwd(remoteDefault.cwd)
setCurrentBranch(remoteDefault.branch || '')
}
await callbacksRef.current.refreshHermesConfig()
if (cancelled) {
+11 -17
View File
@@ -1,5 +1,6 @@
import ignore from 'ignore'
import { desktopFsCacheKey, desktopGitRoot, readDesktopDir, readDesktopFileDataUrl } from '@/lib/desktop-fs'
import type { HermesReadDirEntry, HermesReadDirResult } from '@/global'
export type ProjectTreeEntry = HermesReadDirEntry
@@ -63,15 +64,11 @@ function ancestorDirs(root: string, dir: string) {
}
async function gitRootFor(start: string) {
if (!window.hermesDesktop?.gitRoot) {
return null
}
const key = clean(start)
const key = `${desktopFsCacheKey()}:${clean(start)}`
let cached = gitRootCache.get(key)
if (!cached) {
cached = window.hermesDesktop.gitRoot(key)
cached = desktopGitRoot(start)
gitRootCache.set(key, cached)
}
@@ -80,18 +77,14 @@ async function gitRootFor(start: string) {
/** Read .gitignore at `dir` if it actually exists — never probe missing files. */
async function readGitignore(dir: string): Promise<GitignoreRule | null> {
if (!window.hermesDesktop?.readDir || !window.hermesDesktop.readFileDataUrl) {
return null
}
try {
const listing = await window.hermesDesktop.readDir(dir)
const listing = await readDesktopDir(dir)
if (!listing.entries.some(e => e.name === '.gitignore' && !e.isDirectory)) {
return null
}
const text = decodeDataUrl(await window.hermesDesktop.readFileDataUrl(`${dir}/.gitignore`))
const text = decodeDataUrl(await readDesktopFileDataUrl(`${dir}/.gitignore`))
return { base: dir, ig: ignore().add(text) }
} catch {
@@ -100,11 +93,11 @@ async function readGitignore(dir: string): Promise<GitignoreRule | null> {
}
async function gitignoreFor(dir: string) {
const key = clean(dir)
const key = `${desktopFsCacheKey()}:${clean(dir)}`
let cached = gitignoreCache.get(key)
if (!cached) {
cached = readGitignore(key)
cached = readGitignore(clean(dir))
gitignoreCache.set(key, cached)
}
@@ -142,9 +135,10 @@ export async function readProjectDir(dirPath: string, rootPath = dirPath): Promi
return { entries: [], error: 'no-bridge' }
}
const result = await window.hermesDesktop.readDir(dirPath)
const result = await readDesktopDir(dirPath)
const entries = result?.entries ?? []
return { ...result, entries: await filterIgnored(result.entries, rootPath, dirPath) }
return { ...result, entries: await filterIgnored(entries, rootPath, dirPath) }
}
export function clearProjectDirCache(rootPath?: string) {
@@ -155,7 +149,7 @@ export function clearProjectDirCache(rootPath?: string) {
return
}
const key = clean(rootPath)
const key = `${desktopFsCacheKey()}:${clean(rootPath)}`
gitRootCache.delete(key)
gitignoreCache.delete(key)
}
@@ -0,0 +1,177 @@
import { useEffect, useMemo, useState } from 'react'
import { Button } from '@/components/ui/button'
import { Codicon } from '@/components/ui/codicon'
import { Dialog, DialogContent, DialogDescription, DialogTitle } from '@/components/ui/dialog'
import { useI18n } from '@/i18n'
import { readDesktopDir, setDesktopFsRemotePicker } from '@/lib/desktop-fs'
import { cn } from '@/lib/utils'
function clean(path: string) {
return path.replace(/\/+$/, '') || '/'
}
function parentDir(path: string) {
const value = clean(path)
if (value === '/') {
return '/'
}
const parent = value.slice(0, value.lastIndexOf('/'))
return parent || '/'
}
function pathName(path: string) {
return path.split('/').filter(Boolean).pop() || path
}
interface PendingSelection {
defaultPath: string
resolve: (paths: string[]) => void
title: string
}
export function RemoteFolderPicker() {
const { t } = useI18n()
const r = t.rightSidebar
const [pending, setPending] = useState<PendingSelection | null>(null)
const [currentPath, setCurrentPath] = useState('/')
const [entries, setEntries] = useState<Array<{ name: string; path: string }>>([])
const [error, setError] = useState<string | null>(null)
const [loading, setLoading] = useState(false)
useEffect(() => {
setDesktopFsRemotePicker({
selectPaths: options =>
new Promise(resolve => {
const defaultPath = clean(options?.defaultPath || '/')
setCurrentPath(defaultPath)
setPending({ defaultPath, resolve, title: options?.title || r.remotePickerTitle })
})
})
return () => setDesktopFsRemotePicker(null)
}, [r.remotePickerTitle])
useEffect(() => {
if (!pending) {
return
}
let active = true
setLoading(true)
setError(null)
void readDesktopDir(currentPath)
.then(result => {
if (!active) {
return
}
if (result.error) {
setError(result.error)
setEntries([])
return
}
setEntries(result.entries.filter(entry => entry.isDirectory).map(entry => ({ name: entry.name, path: entry.path })))
})
.catch(err => {
if (active) {
setError(err instanceof Error ? err.message : String(err))
setEntries([])
}
})
.finally(() => {
if (active) {
setLoading(false)
}
})
return () => {
active = false
}
}, [currentPath, pending])
const crumbs = useMemo(() => {
const parts = clean(currentPath).split('/').filter(Boolean)
const out = [{ label: '/', path: '/' }]
let acc = ''
for (const part of parts) {
acc += `/${part}`
out.push({ label: part, path: acc })
}
return out
}, [currentPath])
const close = (paths: string[] = []) => {
pending?.resolve(paths)
setPending(null)
setEntries([])
setError(null)
}
return (
<Dialog onOpenChange={open => !open && close()} open={Boolean(pending)}>
<DialogContent className="max-w-lg gap-0 overflow-hidden p-0">
<div className="border-b border-border/70 px-4 py-3">
<DialogTitle className="text-sm">{pending?.title || r.remotePickerTitle}</DialogTitle>
<DialogDescription className="mt-1 text-xs">{r.remotePickerDescription}</DialogDescription>
</div>
<div className="flex min-h-[22rem] flex-col">
<div className="flex flex-wrap items-center gap-1 border-b border-border/50 px-3 py-2 text-xs text-muted-foreground">
{crumbs.map((crumb, index) => (
<button
className={cn('rounded px-1.5 py-0.5 hover:bg-muted hover:text-foreground', index === crumbs.length - 1 && 'text-foreground')}
key={crumb.path}
onClick={() => setCurrentPath(crumb.path)}
type="button"
>
{crumb.label}
</button>
))}
</div>
<div className="min-h-0 flex-1 overflow-y-auto p-2">
<FolderRow disabled={currentPath === '/'} name=".." onClick={() => setCurrentPath(parentDir(currentPath))} />
{loading ? (
<div className="flex items-center gap-2 px-2 py-3 text-xs text-muted-foreground">
<Codicon name="loading" size="0.8rem" spinning />
{r.loadingFiles}
</div>
) : error ? (
<div className="px-2 py-3 text-xs text-destructive">{r.unreadableBody(error)}</div>
) : entries.length === 0 ? (
<div className="px-2 py-3 text-xs text-muted-foreground">{r.emptyBody}</div>
) : (
entries.map(entry => <FolderRow key={entry.path} name={pathName(entry.path)} onClick={() => setCurrentPath(entry.path)} />)
)}
</div>
</div>
<div className="flex items-center justify-between gap-2 border-t border-border/70 px-4 py-3">
<div className="min-w-0 truncate text-xs text-muted-foreground">{currentPath}</div>
<div className="flex shrink-0 items-center gap-2">
<Button onClick={() => close()} size="sm" variant="ghost">
{t.common.cancel}
</Button>
<Button onClick={() => close([currentPath])} size="sm">
{r.remotePickerSelect}
</Button>
</div>
</div>
</DialogContent>
</Dialog>
)
}
function FolderRow({ disabled = false, name, onClick }: { disabled?: boolean; name: string; onClick: () => void }) {
return (
<button
className="flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-left text-xs text-(--ui-text-secondary) hover:bg-(--ui-row-hover-background) hover:text-foreground disabled:pointer-events-none disabled:opacity-40"
disabled={disabled}
onClick={onClick}
type="button"
>
<Codicon name="folder" size="0.875rem" />
<span className="min-w-0 truncate">{name}</span>
</button>
)
}
@@ -1,19 +1,24 @@
import { act, renderHook, waitFor } from '@testing-library/react'
import { act, cleanup, renderHook, waitFor } from '@testing-library/react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { $connection } from '@/store/session'
import type { HermesReadDirResult } from '@/global'
import { clearProjectDirCache, readProjectDir } from './ipc'
import { resetProjectTreeState, useProjectTree } from './use-project-tree'
const readDir = vi.fn<(path: string) => Promise<HermesReadDirResult>>()
beforeEach(() => {
$connection.set(null)
resetProjectTreeState()
readDir.mockReset()
;(window as unknown as { hermesDesktop: { readDir: typeof readDir } }).hermesDesktop = { readDir }
})
afterEach(() => {
cleanup()
$connection.set(null)
resetProjectTreeState()
delete (window as unknown as { hermesDesktop?: unknown }).hermesDesktop
})
@@ -106,6 +111,36 @@ describe('useProjectTree', () => {
expect(readDir).toHaveBeenCalledTimes(1)
})
it('reads gitignore from the real path while caching per connection', async () => {
const readFileDataUrl = vi.fn(async () => `data:text/plain;base64,${btoa('ignored.log\n')}`)
const gitRoot = vi.fn(async () => '/repo')
readDir.mockImplementation(async path => {
if (path === '/repo') return ok([{ name: '.gitignore', path: '/repo/.gitignore', isDirectory: false }])
if (path === '/repo/src') {
return ok([
{ name: 'app.ts', path: '/repo/src/app.ts', isDirectory: false },
{ name: 'ignored.log', path: '/repo/src/ignored.log', isDirectory: false }
])
}
throw new Error(`unexpected path ${path}`)
})
;(window as unknown as { hermesDesktop: unknown }).hermesDesktop = { gitRoot, readDir, readFileDataUrl }
$connection.set({ baseUrl: 'local-a', mode: 'local' } as never)
await expect(readProjectDir('/repo/src', '/repo')).resolves.toMatchObject({
entries: [{ name: 'app.ts', path: '/repo/src/app.ts', isDirectory: false }]
})
expect(readDir).toHaveBeenCalledWith('/repo')
expect(readDir).not.toHaveBeenCalledWith(expect.stringContaining('local-a'))
$connection.set({ baseUrl: 'local-b', mode: 'local' } as never)
clearProjectDirCache()
await expect(readProjectDir('/repo/src', '/repo')).resolves.toMatchObject({
entries: [{ name: 'app.ts', path: '/repo/src/app.ts', isDirectory: false }]
})
expect(readDir.mock.calls.filter(([path]) => path === '/repo')).toHaveLength(2)
})
it('captures per-folder error code and leaves the folder expandable but empty', async () => {
readDir.mockResolvedValueOnce(ok([{ name: 'priv', path: '/p/priv', isDirectory: true }]))
readDir.mockResolvedValueOnce({ entries: [], error: 'EACCES' })
@@ -2,6 +2,8 @@ import { useStore } from '@nanostores/react'
import { atom } from 'nanostores'
import { useCallback, useEffect, useMemo } from 'react'
import { $connection } from '@/store/session'
import { clearProjectDirCache, readProjectDir } from './ipc'
export interface TreeNode {
@@ -84,6 +86,7 @@ const initialState: ProjectTreeState = {
const inflight = new Set<string>()
const $projectTree = atom<ProjectTreeState>(initialState)
let nextRootRequestId = 0
let lastConnectionKey = ''
function setProjectTree(updater: (current: ProjectTreeState) => ProjectTreeState) {
$projectTree.set(updater($projectTree.get()))
@@ -145,6 +148,7 @@ async function loadRoot(cwd: string, { force = false }: { force?: boolean } = {}
}
export function resetProjectTreeState() {
lastConnectionKey = ''
clearProjectTree()
clearProjectDirCache()
}
@@ -158,6 +162,8 @@ export function resetProjectTreeState() {
*/
export function useProjectTree(cwd: string): UseProjectTreeResult {
const state = useStore($projectTree)
const connection = useStore($connection)
const connectionKey = `${connection?.mode || 'local'}:${connection?.profile || ''}:${connection?.baseUrl || ''}`
const refreshRoot = useCallback(() => loadRoot(cwd, { force: true }), [cwd])
@@ -236,8 +242,15 @@ export function useProjectTree(cwd: string): UseProjectTreeResult {
)
useEffect(() => {
const connectionChanged = lastConnectionKey !== '' && lastConnectionKey !== connectionKey
lastConnectionKey = connectionKey
if (connectionChanged) {
clearProjectDirCache()
void loadRoot(cwd, { force: true })
return
}
void loadRoot(cwd)
}, [cwd])
}, [connectionKey, cwd])
return useMemo(
() => ({
+5 -1
View File
@@ -7,6 +7,7 @@ import { Codicon } from '@/components/ui/codicon'
import { Loader } from '@/components/ui/loader'
import { Tip } from '@/components/ui/tooltip'
import { useI18n } from '@/i18n'
import { selectDesktopPaths } from '@/lib/desktop-fs'
import { normalizeOrLocalPreviewTarget } from '@/lib/local-preview'
import { cn } from '@/lib/utils'
import { $panesFlipped } from '@/store/layout'
@@ -16,6 +17,7 @@ import { $currentCwd } from '@/store/session'
import { SidebarPanelLabel } from '../shell/sidebar-label'
import { RemoteFolderPicker } from './files/remote-picker'
import { ProjectTree } from './files/tree'
import { useProjectTree } from './files/use-project-tree'
@@ -54,7 +56,7 @@ export function RightSidebarPane({ onActivateFile, onActivateFolder, onChangeCwd
const canCollapse = Object.values(openState).some(Boolean)
const chooseFolder = async () => {
const selected = await window.hermesDesktop?.selectPaths({
const selected = await selectDesktopPaths({
defaultPath: hasCwd ? currentCwd : undefined,
directories: true,
multiple: false,
@@ -90,6 +92,8 @@ export function RightSidebarPane({ onActivateFile, onActivateFolder, onChangeCwd
: 'border-l shadow-[inset_0.0625rem_0_0_color-mix(in_srgb,white_18%,transparent)]'
)}
>
<RemoteFolderPicker />
<FilesystemTab
canCollapse={canCollapse}
collapseNonce={collapseNonce}
@@ -69,7 +69,7 @@ export function TerminalTab({ cwd, onAddSelectionToChat }: TerminalTabProps) {
variant="secondary"
>
{t.rightSidebar.addToChat}
<span className="ml-1 text-[0.6rem] text-(--ui-text-tertiary)">{addSelectionShortcutLabel}</span>
<span className="ml-1 text-[0.6rem] text-(--ui-text-tertiary)">{addSelectionShortcutLabel()}</span>
</Button>
</div>
)}
@@ -1,7 +1,6 @@
import type { ITheme, Terminal } from '@xterm/xterm'
import type { CSSProperties } from 'react'
import { formatCombo, modKey } from '@/lib/keybinds/combo'
import type { DesktopTerminalPalette } from '@/themes/types'
// VS Code's default integrated-terminal palette (terminalColorRegistry.ts) — a
@@ -98,10 +97,12 @@ export function resolveSurfaceColor(fallback: string): string {
return resolved && resolved !== 'rgba(0, 0, 0, 0)' ? resolved : fallback
}
export const addSelectionShortcutLabel = formatCombo('mod+l')
export const isMacPlatform = () => navigator.platform.toLowerCase().includes('mac')
export const addSelectionShortcutLabel = () => (isMacPlatform() ? '⌘L' : 'Ctrl+L')
export function isAddSelectionShortcut(event: KeyboardEvent) {
const mod = event[modKey]
const mod = isMacPlatform() ? event.metaKey : event.ctrlKey
return mod && !event.shiftKey && event.key.toLowerCase() === 'l'
}
+2 -2
View File
@@ -14,7 +14,7 @@ import {
type KeybindActionMeta,
type KeybindReadonly
} from '@/lib/keybinds/actions'
import { formatCombo, formatFakeCombo } from '@/lib/keybinds/combo'
import { formatCombo } from '@/lib/keybinds/combo'
import { arraysEqual } from '@/lib/storage'
import {
$bindings,
@@ -210,7 +210,7 @@ function ReadonlyRow({ shortcut }: { shortcut: KeybindReadonly }) {
<div className="flex shrink-0 items-center gap-1">
{shortcut.keys.map(key => (
<span className="kbd-cap" key={key}>
{formatFakeCombo(key)}
{formatCombo(key)}
</span>
))}
</div>
@@ -722,14 +722,8 @@ function StickyHumanMessageContainer({ children }: { children: ReactNode }) {
// edit composer render the same bubble surface (rounded glass card);
// they only differ in border weight, cursor, and padding-right (the
// read-only view reserves room for the restore icon).
//
// no-drag: sticky bubbles park at --sticky-human-top (~4px), sliding under the
// titlebar's [-webkit-app-region:drag] strips (app-shell.tsx). Electron resolves
// drag regions at the compositor level — z-index and pointer-events don't help —
// so without the carve-out, clicking a stuck bubble drags the window instead of
// opening the edit composer.
const USER_BUBBLE_BASE_CLASS =
'composer-human-message standalone-glass relative flex w-full min-w-0 max-w-full flex-col gap-1.5 overflow-hidden rounded-xl border bg-(--dt-user-bubble) px-3 py-2 text-left [-webkit-app-region:no-drag]'
'composer-human-message standalone-glass relative flex w-full min-w-0 max-w-full flex-col gap-1.5 overflow-hidden rounded-xl border bg-(--dt-user-bubble) px-3 py-2 text-left'
const USER_ACTION_ICON_BUTTON_CLASS =
'grid place-items-center rounded-md bg-transparent text-(--ui-text-secondary) transition-colors hover:bg-(--ui-control-active-background) hover:text-foreground disabled:cursor-default disabled:text-(--ui-text-quaternary) disabled:opacity-70'
@@ -16,7 +16,6 @@ import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigge
import { useI18n } from '@/i18n'
import { triggerHaptic } from '@/lib/haptics'
import { ChevronDown, Loader2 } from '@/lib/icons'
import { formatCombo } from '@/lib/keybinds/combo'
import { $gateway } from '@/store/gateway'
import { notifyError } from '@/store/notifications'
import { $approvalRequest, type ApprovalRequest, clearApprovalRequest } from '@/store/prompts'
@@ -51,6 +50,8 @@ export const PendingToolApproval: FC<{ part: ToolPart }> = ({ part }) => {
return <ApprovalBar request={request} />
}
const isMac = typeof navigator !== 'undefined' && /Mac|iP(hone|ad|od)/.test(navigator.platform)
const ApprovalBar: FC<{ request: ApprovalRequest }> = ({ request }) => {
const { t } = useI18n()
const copy = t.assistant.approval
@@ -126,7 +127,7 @@ const ApprovalBar: FC<{ request: ApprovalRequest }> = ({ request }) => {
variant="ghost"
>
{submitting === 'once' ? <Loader2 className="size-3 animate-spin" /> : copy.run}
{submitting !== 'once' && <span className="text-[0.625rem] text-primary/60">{formatCombo('mod+enter')}</span>}
{submitting !== 'once' && <span className="text-[0.625rem] text-primary/60">{isMac ? '⌘⏎' : 'Ctrl⏎'}</span>}
</Button>
<span aria-hidden className="w-px self-stretch bg-primary/20" />
<DropdownMenu>
@@ -13,9 +13,9 @@ import { DisclosureRow } from '@/components/chat/disclosure-row'
import { PreviewAttachment } from '@/components/chat/preview-attachment'
import { ZoomableImage } from '@/components/chat/zoomable-image'
import { BrailleSpinner } from '@/components/ui/braille-spinner'
import { Codicon } from '@/components/ui/codicon'
import { CopyButton } from '@/components/ui/copy-button'
import { FadeText } from '@/components/ui/fade-text'
import { ToolIcon } from '@/components/ui/tool-icon'
import { useI18n } from '@/i18n'
import { PrettyLink, LinkifiedText as SharedLinkifiedText, urlSlugTitleLabel } from '@/lib/external-link'
import { AlertCircle, CheckCircle2 } from '@/lib/icons'
@@ -136,7 +136,7 @@ function ToolGlyph({ copy, icon, status }: { copy: ToolStatusCopy; icon?: string
const node = status ? (
statusGlyph(status, copy)
) : icon ? (
<ToolIcon className="text-(--ui-text-tertiary)" name={icon} size="0.875rem" />
<Codicon className="text-(--ui-text-tertiary)" name={icon} size="0.875rem" />
) : null
return node ? <span className={TOOL_HEADER_GLYPH_WRAP_CLASS}>{node}</span> : null
@@ -1,141 +0,0 @@
import { ExportedMessageRepository } from '@assistant-ui/core/internal'
// Clicking a user bubble must open the inline edit composer — through the
// app's incremental external-store runtime (which reimplements capability
// resolution, incl. `edit: onEdit !== undefined`) and the stock runtime.
//
// Note: this covers the React/runtime wiring only. The Electron-level failure
// mode (titlebar -webkit-app-region:drag swallowing clicks on *stuck* sticky
// bubbles) is not reproducible in jsdom — see USER_BUBBLE_BASE_CLASS's no-drag
// carve-out in thread.tsx.
import { AssistantRuntimeProvider, type ThreadMessage, useExternalStoreRuntime } from '@assistant-ui/react'
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import { describe, expect, it, vi } from 'vitest'
import { useIncrementalExternalStoreRuntime } from '@/lib/incremental-external-store-runtime'
import { Thread } from './thread'
const createdAt = new Date('2026-05-01T00:00:00.000Z')
class TestResizeObserver {
observe() {}
unobserve() {}
disconnect() {}
}
vi.stubGlobal('ResizeObserver', TestResizeObserver)
vi.stubGlobal('requestAnimationFrame', (callback: FrameRequestCallback) =>
window.setTimeout(() => callback(performance.now()), 0)
)
vi.stubGlobal('cancelAnimationFrame', (id: number) => window.clearTimeout(id))
Element.prototype.scrollTo = function scrollTo() {}
function stubOffsetDimension(
prop: 'offsetHeight' | 'offsetWidth',
clientProp: 'clientHeight' | 'clientWidth',
fallback: number
) {
const previous = Object.getOwnPropertyDescriptor(HTMLElement.prototype, prop)
Object.defineProperty(HTMLElement.prototype, prop, {
configurable: true,
get() {
return previous?.get?.call(this) || (this as HTMLElement)[clientProp] || fallback
}
})
}
stubOffsetDimension('offsetWidth', 'clientWidth', 800)
stubOffsetDimension('offsetHeight', 'clientHeight', 600)
function userMessage(): ThreadMessage {
return {
id: 'user-1',
role: 'user',
content: [{ type: 'text', text: 'edit me please' }],
attachments: [],
createdAt,
metadata: { custom: {} }
} as ThreadMessage
}
function assistantMessage(): ThreadMessage {
return {
id: 'assistant-1',
role: 'assistant',
content: [{ type: 'text', text: 'done' }],
status: { type: 'complete', reason: 'stop' },
createdAt,
metadata: {
unstable_state: null,
unstable_annotations: [],
unstable_data: [],
steps: [],
custom: {}
}
} as ThreadMessage
}
// Mirrors chat/index.tsx: incremental runtime + messageRepository + onEdit.
function IncrementalHarness({ onEdit }: { onEdit: () => Promise<void> }) {
const repository = ExportedMessageRepository.fromArray([userMessage(), assistantMessage()])
const runtime = useIncrementalExternalStoreRuntime<ThreadMessage>({
messageRepository: repository,
isRunning: false,
setMessages: () => {},
onNew: async () => {},
onEdit,
onCancel: async () => {},
onReload: async () => {}
})
return (
<AssistantRuntimeProvider runtime={runtime}>
<Thread />
</AssistantRuntimeProvider>
)
}
// Control: stock external store runtime.
function StockHarness({ onEdit }: { onEdit: () => Promise<void> }) {
const runtime = useExternalStoreRuntime<ThreadMessage>({
messages: [userMessage(), assistantMessage()],
isRunning: false,
onNew: async () => {},
onEdit
})
return (
<AssistantRuntimeProvider runtime={runtime}>
<Thread />
</AssistantRuntimeProvider>
)
}
describe('click-to-edit user message', () => {
it('opens the edit composer with the incremental runtime', async () => {
const { container } = render(<IncrementalHarness onEdit={async () => {}} />)
const bubble = await screen.findByRole('button', { name: 'Edit message' })
fireEvent.click(bubble)
await waitFor(() => {
expect(container.querySelector('[data-slot="aui_edit-composer-root"]')).toBeTruthy()
})
})
it('opens the edit composer with the stock runtime', async () => {
const { container } = render(<StockHarness onEdit={async () => {}} />)
const bubble = await screen.findByRole('button', { name: 'Edit message' })
fireEvent.click(bubble)
await waitFor(() => {
expect(container.querySelector('[data-slot="aui_edit-composer-root"]')).toBeTruthy()
})
})
})
@@ -1,65 +0,0 @@
import type * as React from 'react'
import { Codicon } from '@/components/ui/codicon'
import { cn } from '@/lib/utils'
// Solid (filled) glyphs for in-thread tool rows. Codicons are an outline icon
// *font*, so an outline glyph has no separate fillable region — a filled look
// can't be derived from it (stroke-thickening just bolds the outline). To get
// the Cursor-style filled tool icons we render dedicated solid SVG paths,
// keyed by the same names used in `TOOL_META` (tool-fallback-model.ts).
//
// Paths are Phosphor Icons (MIT) "fill" weight, 256×256 viewBox. Inlining the
// path data mirrors the existing precedent in `directive-text.tsx`.
const TOOL_ICON_PATHS: Record<string, string> = {
diff: 'M118.18,213.08c-.11.14-.24.27-.36.4l-.16.18-.17.15a4.83,4.83,0,0,1-.42.37,3.92,3.92,0,0,1-.32.25l-.3.22-.38.23a2.91,2.91,0,0,1-.3.17l-.37.19-.34.15-.36.13a2.84,2.84,0,0,1-.38.13l-.36.1c-.14,0-.26.07-.4.09l-.42.07-.35.05a7,7,0,0,1-.79,0H64a8,8,0,0,1,0-16H92.69L55,162.34a23.85,23.85,0,0,1-7-17V95a32,32,0,1,1,16,0v50.38A8,8,0,0,0,66.34,151L104,188.69V160a8,8,0,0,1,16,0v48a7,7,0,0,1,0,.8c0,.11,0,.21,0,.32s0,.3-.07.46a2.83,2.83,0,0,1-.09.37c0,.13-.06.26-.1.39s-.08.23-.12.35l-.14.39-.15.31c-.06.13-.12.27-.19.4s-.11.18-.16.28l-.24.39-.21.28ZM208,161V110.63a23.85,23.85,0,0,0-7-17L163.31,56H192a8,8,0,0,0,0-16H143.82l-.6,0c-.14,0-.28,0-.41.06l-.37,0-.43.11-.33.08-.4.14-.34.13-.35.16-.36.18a3.14,3.14,0,0,0-.31.18c-.12.07-.25.14-.36.22a3.55,3.55,0,0,0-.31.23,3.81,3.81,0,0,0-.32.24c-.15.12-.28.24-.42.37l-.17.15-.16.18c-.12.13-.25.26-.36.4l-.26.35-.21.28-.24.39c-.05.1-.11.19-.16.28s-.13.27-.19.4l-.15.31-.14.39c0,.12-.09.23-.12.35s-.07.26-.1.39a2.83,2.83,0,0,0-.09.37c0,.16,0,.31-.07.46s0,.21-.05.32a7,7,0,0,0,0,.8V96a8,8,0,0,0,16,0V67.31L189.66,105a8,8,0,0,1,2.34,5.66V161a32,32,0,1,0,16,0Z',
edit: 'M227.31,73.37,182.63,28.68a16,16,0,0,0-22.63,0L36.69,152A15.86,15.86,0,0,0,32,163.31V208a16,16,0,0,0,16,16H92.69A15.86,15.86,0,0,0,104,219.31L227.31,96a16,16,0,0,0,0-22.63ZM192,108.68,147.31,64l24-24L216,84.68Z',
eye: 'M247.31,124.76c-.35-.79-8.82-19.58-27.65-38.41C194.57,61.26,162.88,48,128,48S61.43,61.26,36.34,86.35C17.51,105.18,9,124,8.69,124.76a8,8,0,0,0,0,6.5c.35.79,8.82,19.57,27.65,38.4C61.43,194.74,93.12,208,128,208s66.57-13.26,91.66-38.34c18.83-18.83,27.3-37.61,27.65-38.4A8,8,0,0,0,247.31,124.76ZM128,168a40,40,0,1,1,40-40A40,40,0,0,1,128,168Z',
file: 'M213.66,82.34l-56-56A8,8,0,0,0,152,24H56A16,16,0,0,0,40,40V216a16,16,0,0,0,16,16H200a16,16,0,0,0,16-16V88A8,8,0,0,0,213.66,82.34ZM152,88V44l44,44Z',
'file-media':
'M216,40H40A16,16,0,0,0,24,56V200a16,16,0,0,0,16,16H216a16,16,0,0,0,16-16V56A16,16,0,0,0,216,40ZM156,88a12,12,0,1,1-12,12A12,12,0,0,1,156,88Zm60,112H40V160.69l46.34-46.35a8,8,0,0,1,11.32,0h0L165,181.66a8,8,0,0,0,11.32-11.32l-17.66-17.65L173,138.34a8,8,0,0,1,11.31,0L216,170.07V200Z',
files:
'M213.66,66.34l-40-40A8,8,0,0,0,168,24H88A16,16,0,0,0,72,40V56H56A16,16,0,0,0,40,72V216a16,16,0,0,0,16,16H168a16,16,0,0,0,16-16V200h16a16,16,0,0,0,16-16V72A8,8,0,0,0,213.66,66.34ZM136,192H88a8,8,0,0,1,0-16h48a8,8,0,0,1,0,16Zm0-32H88a8,8,0,0,1,0-16h48a8,8,0,0,1,0,16Zm64,24H184V104a8,8,0,0,0-2.34-5.66l-40-40A8,8,0,0,0,136,56H88V40h76.69L200,75.31Z',
globe:
'M128,24h0A104,104,0,1,0,232,128,104.12,104.12,0,0,0,128,24Zm78.36,64H170.71a135.28,135.28,0,0,0-22.3-45.6A88.29,88.29,0,0,1,206.37,88ZM216,128a87.61,87.61,0,0,1-3.33,24H174.16a157.44,157.44,0,0,0,0-48h38.51A87.61,87.61,0,0,1,216,128ZM128,43a115.27,115.27,0,0,1,26,45H102A115.11,115.11,0,0,1,128,43ZM102,168H154a115.11,115.11,0,0,1-26,45A115.27,115.27,0,0,1,102,168Zm-3.9-16a140.84,140.84,0,0,1,0-48h59.88a140.84,140.84,0,0,1,0,48Zm50.35,61.6a135.28,135.28,0,0,0,22.3-45.6h35.66A88.29,88.29,0,0,1,148.41,213.6Z',
question:
'M128,24A104,104,0,1,0,232,128,104.11,104.11,0,0,0,128,24Zm0,168a12,12,0,1,1,12-12A12,12,0,0,1,128,192Zm8-48.72V144a8,8,0,0,1-16,0v-8a8,8,0,0,1,8-8c13.23,0,24-9,24-20s-10.77-20-24-20-24,9-24,20v4a8,8,0,0,1-16,0v-4c0-19.85,17.94-36,40-36s40,16.15,40,36C168,125.38,154.24,139.93,136,143.28Z',
search:
'M168,112a56,56,0,1,1-56-56A56,56,0,0,1,168,112Zm61.66,117.66a8,8,0,0,1-11.32,0l-50.06-50.07a88,88,0,1,1,11.32-11.31l50.06,50.06A8,8,0,0,1,229.66,229.66ZM112,184a72,72,0,1,0-72-72A72.08,72.08,0,0,0,112,184Z',
terminal:
'M216,40H40A16,16,0,0,0,24,56V200a16,16,0,0,0,16,16H216a16,16,0,0,0,16-16V56A16,16,0,0,0,216,40Zm-91,94.25-40,32a8,8,0,1,1-10-12.5L107.19,128,75,102.25a8,8,0,1,1,10-12.5l40,32a8,8,0,0,1,0,12.5ZM176,168H136a8,8,0,0,1,0-16h40a8,8,0,0,1,0,16Z',
tools:
'M232,96a72,72,0,0,1-100.94,66L79,222.22c-.12.14-.26.29-.39.42a32,32,0,0,1-45.26-45.26c.14-.13.28-.27.43-.39L94,124.94a72.07,72.07,0,0,1,83.54-98.78,8,8,0,0,1,3.93,13.19L144,80l5.66,26.35L176,112l40.65-37.52a8,8,0,0,1,13.19,3.93A72.6,72.6,0,0,1,232,96Z',
watch:
'M128,24A104,104,0,1,0,232,128,104.11,104.11,0,0,0,128,24Zm56,112H128a8,8,0,0,1-8-8V72a8,8,0,0,1,16,0v48h48a8,8,0,0,1,0,16Z'
}
export interface ToolIconProps {
className?: string
name: string
size?: number | string
}
/** Filled tool glyph. Falls back to the outline codicon font for any name not
* covered by the solid set so new tools still render an icon. */
export function ToolIcon({ className, name, size = '0.875rem' }: ToolIconProps) {
const path = TOOL_ICON_PATHS[name]
if (!path) {
return <Codicon className={className} name={name} size={size} />
}
const dimension: React.CSSProperties = { height: size, width: size }
return (
<svg
aria-hidden="true"
className={cn('shrink-0', className)}
fill="currentColor"
style={dimension}
viewBox="0 0 256 256"
>
<path d={path} />
</svg>
)
}
+6 -4
View File
@@ -1,5 +1,4 @@
import { FIELD_DESCRIPTIONS, FIELD_LABELS } from '@/app/settings/constants'
import { formatCombo } from '@/lib/keybinds/combo'
import type { Translations } from './types'
@@ -519,7 +518,7 @@ export const en: Translations = {
loading: 'Loading archived sessions…',
archivedTitle: 'Archived sessions',
archivedIntro:
`Archived chats are hidden from the sidebar but keep all their messages. ${formatCombo('mod')}-click a chat in the sidebar to archive it.`,
'Archived chats are hidden from the sidebar but keep all their messages. Ctrl/⌘-click a chat in the sidebar to archive it.',
emptyArchivedTitle: 'Nothing archived',
emptyArchivedDesc: 'Archive a chat to hide it here.',
unarchive: 'Unarchive',
@@ -530,7 +529,7 @@ export const en: Translations = {
defaultDirTitle: 'Default project directory',
defaultDirDesc:
'New sessions start in this folder unless you pick another. Leave it unset to use your home directory.',
defaultDirUpdated: `Default project directory updated — start a new chat (${formatCombo('mod+n')}) for it to take effect`,
defaultDirUpdated: 'Default project directory updated — start a new chat (Ctrl/⌘+N) for it to take effect',
defaultsTo: label => `Defaults to ${label}.`,
change: 'Change',
choose: 'Choose',
@@ -1533,6 +1532,9 @@ export const en: Translations = {
terminal: 'Terminal',
noFolderSelected: 'No folder selected',
changeCwdTitle: 'Change working directory',
remotePickerTitle: 'Choose remote folder',
remotePickerDescription: 'Browse folders on the connected backend.',
remotePickerSelect: 'Select folder',
folderTip: cwd => `${cwd} — click to change folder`,
openFolder: 'Open folder',
refreshTree: 'Refresh tree',
@@ -1678,7 +1680,7 @@ export const en: Translations = {
loadingQuestion: 'Loading question…',
other: 'Other (type your answer)',
placeholder: 'Type your answer…',
shortcut: `${formatCombo('mod+enter')} to send`,
shortcut: '⌘/Ctrl + Enter to send',
back: 'Back',
skip: 'Skip',
send: 'Send'
+5 -3
View File
@@ -1,5 +1,4 @@
import { defineFieldCopy } from '@/app/settings/field-copy'
import { formatCombo } from '@/lib/keybinds/combo'
import { defineLocale } from './define-locale'
@@ -643,7 +642,7 @@ export const ja = defineLocale({
loading: 'アーカイブ済みセッションを読み込み中…',
archivedTitle: 'アーカイブ済みセッション',
archivedIntro:
`アーカイブ済みチャットはサイドバーでは非表示になりますが、すべてのメッセージは保持されます。サイドバーのチャットを ${formatCombo('mod')} クリックするとアーカイブできます。`,
'アーカイブ済みチャットはサイドバーでは非表示になりますが、すべてのメッセージは保持されます。サイドバーのチャットを Ctrl/⌘ クリックするとアーカイブできます。',
emptyArchivedTitle: 'アーカイブがありません',
emptyArchivedDesc: 'チャットをアーカイブするとここに表示されます。',
unarchive: 'アーカイブを解除',
@@ -1666,6 +1665,9 @@ export const ja = defineLocale({
terminal: 'ターミナル',
noFolderSelected: 'フォルダーが選択されていません',
changeCwdTitle: '作業ディレクトリを変更',
remotePickerTitle: 'リモートフォルダーを選択',
remotePickerDescription: '接続中のバックエンド上のフォルダーを参照します。',
remotePickerSelect: 'フォルダーを選択',
folderTip: cwd => `${cwd} — クリックしてフォルダーを変更`,
openFolder: 'フォルダーを開く',
refreshTree: 'ツリーを更新',
@@ -1812,7 +1814,7 @@ export const ja = defineLocale({
loadingQuestion: '質問を読み込み中…',
other: 'その他(回答を入力)',
placeholder: '回答を入力…',
shortcut: `${formatCombo('mod+enter')} で送信`,
shortcut: '⌘/Ctrl + Enter で送信',
back: '戻る',
skip: 'スキップ',
send: '送信'
+3
View File
@@ -1194,6 +1194,9 @@ export interface Translations {
terminal: string
noFolderSelected: string
changeCwdTitle: string
remotePickerTitle: string
remotePickerDescription: string
remotePickerSelect: string
folderTip: (cwd: string) => string
openFolder: string
refreshTree: string
+5 -3
View File
@@ -1,5 +1,4 @@
import { defineFieldCopy } from '@/app/settings/field-copy'
import { formatCombo } from '@/lib/keybinds/combo'
import { defineLocale } from './define-locale'
@@ -628,7 +627,7 @@ export const zhHant = defineLocale({
loading: '正在載入已封存工作階段…',
archivedTitle: '已封存工作階段',
archivedIntro:
`已封存的聊天會從側邊欄隱藏,但保留全部訊息。在側邊欄 ${formatCombo('mod')} 點擊聊天即可封存。`,
'已封存的聊天會從側邊欄隱藏,但保留全部訊息。在側邊欄 Ctrl/⌘ 點擊聊天即可封存。',
emptyArchivedTitle: '暫無封存',
emptyArchivedDesc: '封存一個聊天後會顯示在這裡。',
unarchive: '取消封存',
@@ -1627,6 +1626,9 @@ export const zhHant = defineLocale({
terminal: '終端機',
noFolderSelected: '未選擇資料夾',
changeCwdTitle: '變更工作目錄',
remotePickerTitle: '選擇遠端資料夾',
remotePickerDescription: '瀏覽已連線後端上的資料夾。',
remotePickerSelect: '選擇資料夾',
folderTip: cwd => `${cwd} — 點擊以變更資料夾`,
openFolder: '開啟資料夾',
refreshTree: '重新整理檔案樹',
@@ -1773,7 +1775,7 @@ export const zhHant = defineLocale({
loadingQuestion: '正在載入問題…',
other: '其他(輸入您的答案)',
placeholder: '輸入您的答案…',
shortcut: `${formatCombo('mod+enter')} 傳送`,
shortcut: '⌘/Ctrl + Enter 傳送',
back: '返回',
skip: '略過',
send: '傳送'
+5 -3
View File
@@ -1,5 +1,4 @@
import { defineFieldCopy } from '@/app/settings/field-copy'
import { formatCombo } from '@/lib/keybinds/combo'
import type { Translations } from './types'
@@ -713,7 +712,7 @@ export const zh: Translations = {
sessions: {
loading: '正在加载已归档会话…',
archivedTitle: '已归档会话',
archivedIntro: `已归档对话会从侧边栏隐藏,但会保留全部消息。在侧边栏 ${formatCombo('mod')} 点击对话即可归档。`,
archivedIntro: '已归档对话会从侧边栏隐藏,但会保留全部消息。在侧边栏 Ctrl/⌘ 点击对话即可归档。',
emptyArchivedTitle: '暂无归档',
emptyArchivedDesc: '归档一个对话后会显示在这里。',
unarchive: '取消归档',
@@ -1713,6 +1712,9 @@ export const zh: Translations = {
terminal: '终端',
noFolderSelected: '未选择文件夹',
changeCwdTitle: '更改工作目录',
remotePickerTitle: '选择远程文件夹',
remotePickerDescription: '浏览已连接后端上的文件夹。',
remotePickerSelect: '选择文件夹',
folderTip: cwd => `${cwd} — 点击更改文件夹`,
openFolder: '打开文件夹',
refreshTree: '刷新文件树',
@@ -1857,7 +1859,7 @@ export const zh: Translations = {
loadingQuestion: '正在加载问题…',
other: '其他 (输入你的答案)',
placeholder: '输入你的答案…',
shortcut: `${formatCombo('mod+enter')} 发送`,
shortcut: '⌘/Ctrl + Enter 发送',
back: '返回',
skip: '跳过',
send: '发送'
+116
View File
@@ -0,0 +1,116 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { $connection } from '@/store/session'
import {
desktopDefaultCwd,
desktopGitRoot,
readDesktopDir,
readDesktopFileDataUrl,
readDesktopFileText,
selectDesktopPaths,
setDesktopFsRemotePicker
} from './desktop-fs'
const readDir = vi.fn(async () => ({ entries: [{ name: 'local', path: '/local', isDirectory: true }] }))
const readFileText = vi.fn(async () => ({ path: '/local/file.txt', text: 'local', byteSize: 5 }))
const readFileDataUrl = vi.fn(async () => 'data:text/plain;base64,bG9jYWw=')
const gitRoot = vi.fn(async () => '/local')
const selectPaths = vi.fn(async () => ['/local'])
const api = vi.fn(async ({ path }: { path: string }) => {
if (path.startsWith('/api/fs/list?')) return { entries: [{ name: 'remote', path: '/remote', isDirectory: true }] }
if (path.startsWith('/api/fs/read-text?')) return { path: '/remote/file.txt', text: 'remote', byteSize: 6 }
if (path.startsWith('/api/fs/read-data-url?')) return { dataUrl: 'data:text/plain;base64,cmVtb3Rl' }
if (path.startsWith('/api/fs/git-root?')) return { root: '/remote' }
if (path === '/api/fs/default-cwd') return { cwd: '/backend/project', branch: 'main' }
throw new Error(`unexpected path ${path}`)
})
function stubBridge() {
vi.stubGlobal('window', {
hermesDesktop: {
api,
gitRoot,
readDir,
readFileDataUrl,
readFileText,
selectPaths
}
})
}
describe('desktop filesystem facade', () => {
beforeEach(() => {
stubBridge()
$connection.set(null)
})
afterEach(() => {
vi.unstubAllGlobals()
vi.clearAllMocks()
$connection.set(null)
setDesktopFsRemotePicker(null)
})
it('uses local Electron filesystem methods in local mode', async () => {
$connection.set({ mode: 'local' } as never)
await expect(readDesktopDir('/work')).resolves.toEqual({ entries: [{ name: 'local', path: '/local', isDirectory: true }] })
await expect(readDesktopFileText('/work/file.txt')).resolves.toMatchObject({ text: 'local' })
await expect(readDesktopFileDataUrl('/work/file.txt')).resolves.toBe('data:text/plain;base64,bG9jYWw=')
await expect(desktopGitRoot('/work')).resolves.toBe('/local')
await expect(selectDesktopPaths({ directories: true })).resolves.toEqual(['/local'])
expect(readDir).toHaveBeenCalledWith('/work')
expect(readFileText).toHaveBeenCalledWith('/work/file.txt')
expect(readFileDataUrl).toHaveBeenCalledWith('/work/file.txt')
expect(gitRoot).toHaveBeenCalledWith('/work')
expect(selectPaths).toHaveBeenCalledWith({ directories: true })
expect(api).not.toHaveBeenCalled()
})
it('routes filesystem reads through authenticated backend REST in remote mode', async () => {
$connection.set({ mode: 'remote' } as never)
await expect(readDesktopDir('/home/user/project')).resolves.toMatchObject({ entries: [{ name: 'remote' }] })
await expect(readDesktopFileText('/home/user/project/a b.txt')).resolves.toMatchObject({ text: 'remote' })
await expect(readDesktopFileDataUrl('/home/user/project/a b.txt')).resolves.toBe('data:text/plain;base64,cmVtb3Rl')
await expect(desktopGitRoot('/home/user/project')).resolves.toBe('/remote')
await expect(desktopDefaultCwd()).resolves.toEqual({ cwd: '/backend/project', branch: 'main' })
expect(api).toHaveBeenCalledWith({ path: '/api/fs/list?path=%2Fhome%2Fuser%2Fproject' })
expect(api).toHaveBeenCalledWith({ path: '/api/fs/read-text?path=%2Fhome%2Fuser%2Fproject%2Fa%20b.txt' })
expect(api).toHaveBeenCalledWith({ path: '/api/fs/read-data-url?path=%2Fhome%2Fuser%2Fproject%2Fa%20b.txt' })
expect(api).toHaveBeenCalledWith({ path: '/api/fs/git-root?path=%2Fhome%2Fuser%2Fproject' })
expect(api).toHaveBeenCalledWith({ path: '/api/fs/default-cwd' })
expect(readDir).not.toHaveBeenCalled()
expect(readFileText).not.toHaveBeenCalled()
expect(readFileDataUrl).not.toHaveBeenCalled()
expect(gitRoot).not.toHaveBeenCalled()
})
it('uses the registered in-app directory picker in remote mode', async () => {
const remoteSelect = vi.fn(async () => ['/remote/project'])
$connection.set({ mode: 'remote' } as never)
setDesktopFsRemotePicker({ selectPaths: remoteSelect })
await expect(selectDesktopPaths({ defaultPath: '/remote', directories: true, multiple: false })).resolves.toEqual([
'/remote/project'
])
expect(remoteSelect).toHaveBeenCalledWith({ defaultPath: '/remote', directories: true, multiple: false })
expect(selectPaths).not.toHaveBeenCalled()
})
it('does not treat the remote directory picker as a general file picker', async () => {
const remoteSelect = vi.fn(async () => ['/remote/project'])
$connection.set({ mode: 'remote' } as never)
setDesktopFsRemotePicker({ selectPaths: remoteSelect })
await expect(selectDesktopPaths({ directories: false, multiple: false })).resolves.toEqual([])
await expect(selectDesktopPaths({ directories: true, multiple: true })).resolves.toEqual([])
expect(remoteSelect).not.toHaveBeenCalled()
expect(selectPaths).not.toHaveBeenCalled()
})
})
+95
View File
@@ -0,0 +1,95 @@
import { $connection } from '@/store/session'
import type { HermesConnection, HermesReadDirResult, HermesReadFileTextResult, HermesSelectPathsOptions } from '@/global'
export interface DesktopFsRemotePicker {
selectPaths: (options?: HermesSelectPathsOptions) => Promise<string[]>
}
let remotePicker: DesktopFsRemotePicker | null = null
export function setDesktopFsRemotePicker(next: DesktopFsRemotePicker | null) {
remotePicker = next
}
function connectionCacheKey(connection: HermesConnection | null) {
if (!connection) {
return 'local:'
}
return `${connection.mode || 'local'}:${connection.profile || ''}:${connection.baseUrl || ''}`
}
export function desktopFsCacheKey() {
return connectionCacheKey($connection.get())
}
export function isDesktopFsRemoteMode() {
return $connection.get()?.mode === 'remote'
}
function fsPath(endpoint: string, filePath: string) {
return `/api/fs/${endpoint}?path=${encodeURIComponent(filePath)}`
}
function bridge() {
const desktop = window.hermesDesktop
if (!desktop) {
throw new Error('Hermes Desktop bridge is unavailable')
}
return desktop
}
export async function readDesktopDir(path: string): Promise<HermesReadDirResult> {
const desktop = bridge()
if (!isDesktopFsRemoteMode()) {
return desktop.readDir(path)
}
return desktop.api<HermesReadDirResult>({ path: fsPath('list', path) })
}
export async function readDesktopFileText(path: string): Promise<HermesReadFileTextResult> {
const desktop = bridge()
if (!isDesktopFsRemoteMode()) {
return desktop.readFileText(path)
}
return desktop.api<HermesReadFileTextResult>({ path: fsPath('read-text', path) })
}
export async function readDesktopFileDataUrl(path: string): Promise<string> {
const desktop = bridge()
if (!isDesktopFsRemoteMode()) {
return desktop.readFileDataUrl(path)
}
const result = await desktop.api<string | { dataUrl?: string }>({ path: fsPath('read-data-url', path) })
return typeof result === 'string' ? result : result.dataUrl || ''
}
export async function desktopGitRoot(path: string): Promise<string | null> {
const desktop = bridge()
if (!isDesktopFsRemoteMode()) {
return desktop.gitRoot ? desktop.gitRoot(path) : null
}
const result = await desktop.api<{ root: string | null }>({ path: fsPath('git-root', path) })
return result.root
}
export async function desktopDefaultCwd(): Promise<{ branch: string; cwd: string } | null> {
if (!isDesktopFsRemoteMode()) {
return null
}
return bridge().api<{ branch: string; cwd: string }>({ path: '/api/fs/default-cwd' })
}
export async function selectDesktopPaths(options?: HermesSelectPathsOptions): Promise<string[]> {
const desktop = bridge()
if (!isDesktopFsRemoteMode()) {
return desktop.selectPaths(options)
}
if (!options?.directories || options.multiple !== false) {
return []
}
return remotePicker ? remotePicker.selectPaths(options) : []
}
+11 -17
View File
@@ -5,9 +5,6 @@
// like navigate / theme); labels come from i18n (`t.keybinds.actions[id]`). To
// add a hotkey, add a row here and a handler there — nothing else.
import type { Combo, FakeCombo } from "./combo";
export type KeybindCategory = 'composer' | 'profiles' | 'session' | 'navigation' | 'view'
// The self-referential opener — bound + dispatched like any action, but shown in
@@ -30,16 +27,15 @@ export interface KeybindActionMeta {
// `profile.default`) — ⌘` is macOS-reserved (window cycling) and ⌘0 is reset-zoom.
export const PROFILE_SLOT_COUNT = 18
const PROFILE_SWITCH_ACTIONS: KeybindActionMeta[] = Array.from({ length: PROFILE_SLOT_COUNT }, (_, i) => {
const slot = i+1
const combo = (slot <= 9 ? `mod+${slot}` : `mod+alt+${slot - 9}`) as Combo
function comboForSlot(slot: number): string {
return slot <= 9 ? `mod+${slot}` : `mod+alt+${slot - 9}`
}
return ({
id: `profile.switch.${i + 1}`,
category: 'profiles' as const,
defaults: [combo]
})
})
const PROFILE_SWITCH_ACTIONS: KeybindActionMeta[] = Array.from({ length: PROFILE_SLOT_COUNT }, (_, i) => ({
id: `profile.switch.${i + 1}`,
category: 'profiles' as const,
defaults: [comboForSlot(i + 1)]
}))
// ⌘` on macOS / Ctrl+` elsewhere (the `~` key), plus the Shift/tilde variant.
// `mod` keeps one binding cross-platform; on macOS this shadows the system
@@ -108,12 +104,10 @@ export function keybindAction(id: string): KeybindActionMeta | undefined {
return ACTION_BY_ID.get(id)
}
export type KeybindBindings = Record<string, Combo[]>
export type KeybindBindings = Record<string, string[]>
export function defaultBindings(): KeybindBindings {
return Object.fromEntries<string, Combo[]>(
KEYBIND_ACTIONS.map(action => [action.id, [...action.defaults] as Combo[]])
)
return Object.fromEntries(KEYBIND_ACTIONS.map(action => [action.id, [...action.defaults]]))
}
// Fixed, non-rebindable shortcuts surfaced read-only in the panel so the map is
@@ -123,7 +117,7 @@ export function defaultBindings(): KeybindBindings {
export interface KeybindReadonly {
id: string
category: KeybindCategory
keys: readonly FakeCombo[]
keys: readonly string[]
}
export const KEYBIND_READONLY: readonly KeybindReadonly[] = [
+56 -97
View File
@@ -10,13 +10,11 @@
// Control+Tab. Off macOS, Control already *is* `mod`, so `canonicalizeCombo`
// folds `ctrl` → `mod`.
const IS_MAC = typeof navigator !== 'undefined' && /mac/i.test(navigator.platform || navigator.userAgent || '')
export const modKey = IS_MAC ? 'metaKey' as const : 'ctrlKey' as const
export const IS_MAC = typeof navigator !== 'undefined' && /mac/i.test(navigator.platform || navigator.userAgent || '')
// event.code → canonical base token. Letters/digits map to their lowercase
// character; everything else uses an explicit name so combos read cleanly.
const CODE_TO_KEY = {
const CODE_TO_KEY: Record<string, string> = {
Backquote: '`',
Backslash: '\\',
BracketLeft: '[',
@@ -37,50 +35,8 @@ const CODE_TO_KEY = {
ArrowDown: 'down',
ArrowLeft: 'left',
ArrowRight: 'right'
} as const satisfies Record<Capitalize<string>, Lowercase<string>>
type SpecialKey = typeof CODE_TO_KEY[keyof typeof CODE_TO_KEY]
type Alpha = 'a'|'b'|'c'|'d'|'e'|'f'|'g'|'h'|'i'|'j'|'k'|'l'|'m'
| 'n'|'o'|'p'|'q'|'r'|'s'|'t'|'u'|'v'|'w'|'x'|'y'|'z'
export type Digit = '0'|'1'|'2'|'3'|'4'|'5'|'6'|'7'|'8'|'9'
type FKey =
| 'f1' | 'f2' | 'f3' | 'f4' | 'f5' | 'f6'
| 'f7' | 'f8' | 'f9' | 'f10' | 'f11' | 'f12'
| 'f13' | 'f14' | 'f15' | 'f16' | 'f17' | 'f18'
| 'f19' | 'f20' | 'f21' | 'f22' | 'f23' | 'f24'
type BaseKey = Alpha | Digit | FKey | SpecialKey
// subset of https://developer.mozilla.org/en-US/docs/Web/API/UI_Events/Keyboard_event_code_values
type KeyCode = Uppercase<FKey> | `Digit${Digit}` | `Key${Uppercase<Alpha>}` | keyof typeof CODE_TO_KEY
function baseKeyFromCode(code: KeyCode): BaseKey | null {
if (code.startsWith('Key')) {
return code.slice(3).toLowerCase() as Alpha
}
if (code.startsWith('Digit')) {
return code.slice(5) as Digit
}
if (code.startsWith('Numpad')) {
const rest = code.slice(6)
return /^[0-9]$/.test(rest) ? rest as Digit : null
}
if (code.startsWith('F') && /^F\d{1,2}$/.test(code)) {
return code.toLowerCase() as FKey
}
return CODE_TO_KEY[code as keyof typeof CODE_TO_KEY] ?? null
}
const MODIFIER_CODES = new Set([
'AltLeft',
'AltRight',
@@ -92,20 +48,42 @@ const MODIFIER_CODES = new Set([
'ShiftRight'
])
function baseKeyFromCode(code: string): string | null {
if (code.startsWith('Key')) {
return code.slice(3).toLowerCase()
}
if (code.startsWith('Digit')) {
return code.slice(5)
}
if (code.startsWith('Numpad')) {
const rest = code.slice(6)
return /^[0-9]$/.test(rest) ? rest : null
}
if (code.startsWith('F') && /^F\d{1,2}$/.test(code)) {
return code.toLowerCase()
}
return CODE_TO_KEY[code] ?? null
}
// Returns the canonical combo for a keydown, or null while only modifiers are
// held (so capture mode keeps waiting for a real key).
export function comboFromEvent(event: KeyboardEvent): Combo | null {
export function comboFromEvent(event: KeyboardEvent): string | null {
if (MODIFIER_CODES.has(event.code)) {
return null
}
const base = baseKeyFromCode(event.code as KeyCode)
const base = baseKeyFromCode(event.code)
if (!base) {
return null
}
const parts: Combo[] = []
const parts: string[] = []
// macOS reports Cmd (`mod`) and Control (`ctrl`) separately; elsewhere
// Control IS the accelerator, so it folds into `mod`.
@@ -127,7 +105,7 @@ export function comboFromEvent(event: KeyboardEvent): Combo | null {
parts.push(base)
return parts.join('+') as Combo
return parts.join('+')
}
// Rewrites a binding to the form `comboFromEvent` emits, so it indexes under
@@ -137,14 +115,7 @@ export function canonicalizeCombo(combo: string): string {
return IS_MAC ? combo : combo.replace(/\bctrl\b/g, 'mod')
}
const MOD_LABELS = {
mod: IS_MAC ? '⌘' : 'Ctrl',
ctrl: IS_MAC ? '⌃' : 'Ctrl',
alt: IS_MAC ? '⌥' : 'Alt',
shift: IS_MAC ? '⇧' : 'Shift'
} as const
const FANCY_KEY_LABELS = {
const TOKEN_LABELS: Record<string, string> = {
enter: '↵',
escape: 'Esc',
backspace: '⌫',
@@ -153,47 +124,39 @@ const FANCY_KEY_LABELS = {
up: '↑',
down: '↓',
left: '←',
right: '→',
} as const
const TOKEN_LABELS: Record<string, string> = {
...MOD_LABELS,
...FANCY_KEY_LABELS
right: '→'
}
function labelForToken(token: string): string {
if (TOKEN_LABELS[token]) {
return TOKEN_LABELS[token]
function labelForBase(base: string): string {
if (TOKEN_LABELS[base]) {
return TOKEN_LABELS[base]
}
if (/^f\d{1,2}$/.test(token)) {
return token.toUpperCase()
if (/^f\d{1,2}$/.test(base)) {
return base.toUpperCase()
}
return token.length === 1 ? token.toUpperCase() : token
return base.length === 1 ? base.toUpperCase() : base
}
//
function labelForMod(mod: string): string {
if (mod === 'mod') {
return IS_MAC ? '⌘' : 'Ctrl'
}
type ModKey = keyof typeof MOD_LABELS
if (mod === 'ctrl') {
return IS_MAC ? '⌃' : 'Ctrl'
}
type ModPrefix = `${'mod+'|''}${'alt+'|''}${'shift+'|''}`
if (mod === 'alt') {
return IS_MAC ? '⌥' : 'Alt'
}
type ModPrefixedCombo<Suffix extends string> =
| `${ModPrefix}${Suffix}`
| ModKey
| 'mod+alt' | 'mod+shift' | 'alt+shift' | 'mod+alt+shift'
| 'ctrl+tab' | 'ctrl+shift+tab'
| `ctrl+${Digit}`
if (mod === 'shift') {
return IS_MAC ? '⇧' : 'Shift'
}
export type Combo = ModPrefixedCombo<BaseKey>
export type FakeCombo = ModPrefixedCombo<BaseKey | '@' | '?'>
// Human-readable keys, e.g. "mod+shift+k" returns ["⌘","⇧","K"] on macos, ["Ctrl","Shift","K"] elsewhere.
export function normalizeCombo(combo: Combo): string[] {
const parts = combo.split('+')
return parts.map(p => labelForToken(p.trim()))
return mod
}
// Per-key display tokens, e.g. ["⌘", "K"] on macOS, ["Ctrl", "K"] elsewhere —
@@ -202,18 +165,14 @@ export function comboTokens(combo: string): string[] {
const parts = combo.split('+')
const base = parts.pop() ?? ''
return [...parts.map(labelForToken), labelForToken(base)]
return [...parts.map(labelForMod), labelForBase(base)]
}
// Human-readable label, e.g. "mod+shift+k" returns "⌘⇧K" on macOS, "Ctrl+Shift+K" elsewhere.
export function formatCombo(combo: Combo): string {
return normalizeCombo(combo).join(IS_MAC ? '' : '+')
}
// Human-readable label, e.g. "⌘⇧K" on macOS, "Ctrl+Shift+K" elsewhere.
export function formatCombo(combo: string): string {
const tokens = comboTokens(combo)
// like `formatCombo` but allows any input like `@`
export function formatFakeCombo(combo: FakeCombo): string {
return normalizeCombo(combo as Combo).join(IS_MAC ? '' : '+')
return IS_MAC ? tokens.join('') : tokens.join('+')
}
// True when focus is in a text-entry surface, so bare-key shortcuts don't fire
@@ -231,6 +190,6 @@ export function isEditableTarget(target: EventTarget | null): boolean {
// A primary modifier (Cmd/Ctrl/Control) fires even while typing (e.g. ⌘K or
// ⌃Tab from the composer); bare/Shift-only combos are suppressed in inputs.
export function comboAllowedInInput(combo: Combo): boolean {
export function comboAllowedInInput(combo: string): boolean {
return /^(?:mod|ctrl)(?:\+|$)/.test(combo)
}
+23 -2
View File
@@ -1,3 +1,4 @@
import { isDesktopFsRemoteMode, readDesktopFileText } from '@/lib/desktop-fs'
import type { PreviewTarget } from '@/store/preview'
const HTML_EXTENSIONS = new Set(['.htm', '.html'])
@@ -107,6 +108,26 @@ export function localPreviewTarget(rawTarget: string, cwd?: string | null): Prev
}
}
async function enrichPreviewTarget(target: PreviewTarget | null): Promise<PreviewTarget | null> {
if (!isDesktopFsRemoteMode() || !target || target.kind !== 'file' || target.previewKind === 'image') {
return target
}
try {
const result = await readDesktopFileText(target.path || target.source)
return {
...target,
binary: result.binary,
byteSize: result.byteSize,
language: result.language || target.language,
large: (result.byteSize ?? 0) > 512 * 1024,
mimeType: result.mimeType
}
} catch {
return target
}
}
export async function normalizeOrLocalPreviewTarget(
rawTarget: string,
cwd?: string | null
@@ -115,12 +136,12 @@ export async function normalizeOrLocalPreviewTarget(
const normalized = await window.hermesDesktop?.normalizePreviewTarget?.(rawTarget, cwd || undefined)
if (normalized) {
return normalized
return enrichPreviewTarget(normalized)
}
} catch {
// Running Electron may still have the old HTML-only preview IPC. Fall
// through to renderer-side local classification so text/images still open.
}
return localPreviewTarget(rawTarget, cwd)
return enrichPreviewTarget(localPreviewTarget(rawTarget, cwd))
}
+3 -4
View File
@@ -7,7 +7,6 @@ import {
type KeybindBindings
} from '@/lib/keybinds/actions'
import { canonicalizeCombo } from '@/lib/keybinds/combo'
import type { Combo } from '@/lib/keybinds/combo'
import { arraysEqual, persistString, storedString } from '@/lib/storage'
const STORAGE_KEY = 'hermes.desktop.keybinds'
@@ -29,7 +28,7 @@ function loadBindings(): KeybindBindings {
const value = parsed[id]
if (Array.isArray(value)) {
base[id] = value.filter((combo): combo is string => typeof combo === 'string') as Combo[]
base[id] = value.filter((combo): combo is string => typeof combo === 'string')
}
}
} catch {
@@ -79,7 +78,7 @@ export const $comboIndex = computed($bindings, bindings => {
return index
})
export function setBinding(actionId: string, combos: Combo[]): void {
export function setBinding(actionId: string, combos: string[]): void {
if (!keybindAction(actionId)) {
return
}
@@ -102,7 +101,7 @@ export function resetAllBindings(): void {
}
// Other actions that already use `combo` (excluding `actionId` itself).
export function conflictsFor(actionId: string, combo: Combo): string[] {
export function conflictsFor(actionId: string, combo: string): string[] {
const bindings = $bindings.get()
return KEYBIND_ACTION_IDS.filter(id => id !== actionId && (bindings[id] ?? []).includes(combo))
+25
View File
@@ -5,12 +5,14 @@ import type { SessionInfo } from '@/types/hermes'
import {
$activeSessionId,
$attentionSessionIds,
$connection,
$currentCwd,
$workingSessionIds,
applyConfiguredDefaultProjectDir,
getRecentlySettledSessionIds,
mergeSessionPage,
sessionPinId,
setCurrentCwd,
setSessionAttention,
setSessionWorking,
workspaceCwdForNewSession
@@ -145,9 +147,12 @@ describe('mergeSessionPage', () => {
describe('workspaceCwdForNewSession', () => {
afterEach(() => {
applyConfiguredDefaultProjectDir(null)
$connection.set(null)
$currentCwd.set('')
$activeSessionId.set(null)
window.localStorage.removeItem('hermes.desktop.workspace-cwd')
window.localStorage.removeItem('hermes.desktop.workspace-cwd.remote.http%3A%2F%2Fbackend-a.default')
window.localStorage.removeItem('hermes.desktop.workspace-cwd.remote.http%3A%2F%2Fbackend-b.default')
})
it('prefers the configured default over the sticky remembered workspace', () => {
@@ -177,6 +182,26 @@ describe('workspaceCwdForNewSession', () => {
expect($currentCwd.get()).toBe('/live/session/path')
expect(workspaceCwdForNewSession()).toBe('/home/user/configured')
})
it('keeps remote workspace memory separate from local and other remotes', () => {
window.localStorage.setItem('hermes.desktop.workspace-cwd', '/local/project')
$currentCwd.set('/live/session/path')
$connection.set({ baseUrl: 'http://backend-a', mode: 'remote' } as never)
expect(workspaceCwdForNewSession()).toBe('')
setCurrentCwd('/backend/project-a')
expect(workspaceCwdForNewSession()).toBe('/backend/project-a')
$connection.set({ baseUrl: 'http://backend-b', mode: 'remote' } as never)
expect(workspaceCwdForNewSession()).toBe('')
setCurrentCwd('/backend/project-b')
expect(workspaceCwdForNewSession()).toBe('/backend/project-b')
$connection.set(null)
expect(workspaceCwdForNewSession()).toBe('/local/project')
})
})
describe('getRecentlySettledSessionIds', () => {
+30 -14
View File
@@ -10,13 +10,19 @@ type Updater<T> = T | ((current: T) => T)
const WORKSPACE_CWD_KEY = 'hermes.desktop.workspace-cwd'
// Cached copy of Settings → Sessions → Default project directory. The main
// process persists this in project-dir.json, but the renderer must also honor it
// when seeding $currentCwd — otherwise PR #37586's sticky localStorage home dir
// wins and new sessions ignore the user's explicit picker choice.
let configuredDefaultProjectDir = ''
export const getRememberedWorkspaceCwd = (): string => storedString(WORKSPACE_CWD_KEY)?.trim() || ''
function workspaceCwdKey(connection: HermesConnection | null = $connection.get()): string {
if (connection?.mode !== 'remote') {
return WORKSPACE_CWD_KEY
}
const base = encodeURIComponent(connection.baseUrl || 'remote')
const profile = encodeURIComponent(connection.profile || 'default')
return `${WORKSPACE_CWD_KEY}.remote.${base}.${profile}`
}
export const getRememberedWorkspaceCwd = (): string => storedString(workspaceCwdKey())?.trim() || ''
export const getConfiguredDefaultProjectDir = (): string => configuredDefaultProjectDir
@@ -54,6 +60,13 @@ export async function ensureDefaultWorkspaceCwd(): Promise<void> {
}
}
const remembered = getRememberedWorkspaceCwd()
if ($connection.get()?.mode === 'remote') {
seedLiveCwd(remembered)
return
}
if (configured) {
const { cwd } = await sanitize(configured)
seedLiveCwd(cwd)
@@ -61,8 +74,10 @@ export async function ensureDefaultWorkspaceCwd(): Promise<void> {
return
}
const { cwd } = await sanitize(getRememberedWorkspaceCwd())
seedLiveCwd(cwd)
if (remembered) {
const { cwd } = await sanitize(remembered)
seedLiveCwd(cwd)
}
}
export function applyConfiguredDefaultProjectDir(dir: null | string | undefined): void {
@@ -229,15 +244,16 @@ export const setYoloActive = (next: Updater<boolean>) => updateAtom($yoloActive,
export const setCurrentCwd = (next: Updater<string>) => {
updateAtom($currentCwd, next)
// Keep localStorage in sync with the atom: a real folder is remembered, an
// empty cwd clears the key (|| null → removeItem).
persistString(WORKSPACE_CWD_KEY, $currentCwd.get().trim() || null)
persistString(workspaceCwdKey(), $currentCwd.get().trim() || null)
}
/** Workspace for a brand-new chat. Explicit Settings override wins; otherwise
* fall back to the sticky last-used folder, then whatever is already live. */
export const workspaceCwdForNewSession = (): string =>
getConfiguredDefaultProjectDir() || getRememberedWorkspaceCwd() || $currentCwd.get().trim()
export const workspaceCwdForNewSession = (): string => {
if ($connection.get()?.mode === 'remote') {
return getRememberedWorkspaceCwd()
}
return getConfiguredDefaultProjectDir() || getRememberedWorkspaceCwd() || $currentCwd.get().trim()
}
export const setCurrentBranch = (next: Updater<string>) => updateAtom($currentBranch, next)
export const setCurrentUsage = (next: Updater<UsageStats>) => updateAtom($currentUsage, next)
+1 -24
View File
@@ -415,8 +415,7 @@ prompt_caching:
# Auxiliary Models (Advanced — Experimental)
# =============================================================================
# Hermes uses lightweight "auxiliary" models for side tasks: image analysis,
# browser screenshot analysis, web page summarization, TTS audio-tag insertion,
# and context compression.
# browser screenshot analysis, web page summarization, and context compression.
#
# By default these use Gemini Flash via OpenRouter or Nous Portal and are
# auto-detected from your credentials. You do NOT need to change anything
@@ -461,12 +460,6 @@ prompt_caching:
# provider: "auto"
# model: ""
#
# # Gemini 3.1 TTS hidden audio-tag insertion
# tts_audio_tags:
# provider: "auto" # empty model = your main chat model
# model: ""
# timeout: 30
#
# # Session search — summarizes matching past sessions
# session_search:
# provider: "auto"
@@ -842,22 +835,6 @@ platform_toolsets:
# max_tool_rounds: 5 # tool loop limit (0 = disable)
# log_level: "info" # audit verbosity
# =============================================================================
# Text-to-Speech
# =============================================================================
# TTS defaults to Edge TTS unless changed in ~/.hermes/config.yaml.
# Gemini TTS supports persona/director prompt files, and Gemini 3.1 Flash TTS
# can use a hidden auxiliary rewrite pass to insert expressive square-bracket
# audio tags into the TTS script without showing tags in chat.
#
# tts:
# provider: "gemini"
# gemini:
# model: "gemini-3.1-flash-tts-preview"
# voice: "Kore"
# audio_tags: false
# persona_prompt_file: "" # e.g. ~/.hermes/tts/radio-host.md
# =============================================================================
# Voice Transcription (Speech-to-Text)
# =============================================================================
-12
View File
@@ -1804,18 +1804,6 @@ class BasePlatformAdapter(ABC):
# preview (see gateway/run.py progress_callback).
supports_code_blocks: bool = False
# The command prefix users can always TYPE on this platform to reach
# Hermes commands. Default "/" (most platforms deliver "/approve" etc.
# as plain message text). Platforms where typing a leading "/" is
# intercepted or restricted by the client (Slack blocks native slash
# commands inside threads; Matrix clients reserve "/" for client-local
# commands) ship a "!" alias rewrite in their adapter and set this to
# "!" so user-facing instruction text ("Reply `!approve` ...") tells
# users the form that actually works everywhere. Capability flag —
# shared prompt builders read it via getattr(adapter,
# "typed_command_prefix", "/"); no per-platform branching at call sites.
typed_command_prefix: str = "/"
def __init__(self, config: PlatformConfig, platform: Platform):
self.config = config
self.platform = platform
+4 -9
View File
@@ -422,11 +422,6 @@ class MatrixAdapter(BasePlatformAdapter):
supports_code_blocks = True # Matrix renders fenced code blocks (HTML/markdown)
# Matrix clients commonly reserve typed "/" for client-local commands;
# the adapter accepts "!command" as the alias that always reaches Hermes
# (see _normalize_matrix_bang_command), so instruction text shows "!".
typed_command_prefix = "!"
# Threshold for detecting Matrix client-side message splits.
# When a chunk is near the ~4000-char practical limit, a continuation
# is almost certain.
@@ -1355,11 +1350,11 @@ class MatrixAdapter(BasePlatformAdapter):
"⚠️ **Dangerous command requires approval**\n"
f"```\n{cmd_preview}\n```\n"
f"Reason: {description}\n\n"
"Reply `!approve` to execute, `!approve session` to approve this pattern for the session, "
"`!approve always` to approve permanently, or `!deny` to cancel.\n\n"
"Reply `/approve` to execute, `/approve session` to approve this pattern for the session, "
"`/approve always` to approve permanently, or `/deny` to cancel.\n\n"
"You can also click the reaction to approve:\n"
"✅ = approve\n"
"❎ = deny"
"✅ = /approve\n"
"❎ = /deny"
)
result = await self.send(chat_id, text, metadata=metadata)
+8 -25
View File
@@ -318,11 +318,6 @@ class SlackAdapter(BasePlatformAdapter):
MAX_MESSAGE_LENGTH = 39000 # Slack API allows 40,000 chars; leave margin
supports_code_blocks = True # Slack mrkdwn renders fenced code blocks
# Slack blocks typed native slash commands inside threads ("/approve is
# not supported in threads. Sorry!"). The adapter rewrites a leading
# "!" to "/" for known commands (see _handle_slack_message), so "!" is
# the prefix that works everywhere — instruction text must show it.
typed_command_prefix = "!"
def __init__(self, config: PlatformConfig):
super().__init__(config, Platform.SLACK)
@@ -2697,26 +2692,19 @@ class SlackAdapter(BasePlatformAdapter):
return SendResult(success=False, error="Not connected")
try:
cmd_preview = command[:2900] + "..." if len(command) > 2900 else command
thread_ts = self._resolve_thread_ts(None, metadata)
# Slack hard-caps a section block's text at 3000 chars; an
# oversized block fails the whole send with ``invalid_blocks``
# and the gateway falls back to the plain-text prompt (no
# buttons). execute_code approvals embed the entire script in
# ``command``, so budget the preview against the fixed parts
# instead of a flat truncation that overflows once the header +
# reason are added.
header = ":warning: *Command Approval Required*\n"
reason = f"Reason: {description[:500]}"
budget = 3000 - len(header) - len(reason) - len("``````\n") - len("...")
cmd_preview = command[:budget] + "..." if len(command) > budget else command
blocks = [
{
"type": "section",
"text": {
"type": "mrkdwn",
"text": f"{header}```{cmd_preview}```\n{reason}",
"text": (
f":warning: *Command Approval Required*\n"
f"```{cmd_preview}```\n"
f"Reason: {description}"
),
},
},
{
@@ -2784,13 +2772,8 @@ class SlackAdapter(BasePlatformAdapter):
return SendResult(success=False, error="Not connected")
try:
body = message[:2900] + "..." if len(message) > 2900 else message
thread_ts = self._resolve_thread_ts(None, metadata)
# Same 3000-char section-block cap as send_exec_approval: budget
# the body against the rendered title so the wrapper never pushes
# the block over the limit (overflow → invalid_blocks → no buttons).
_title = (title or "Confirm")[:150]
budget = 3000 - len(f"*{_title}*\n\n") - len("...")
body = message[:budget] + "..." if len(message) > budget else message
# Encode session_key and confirm_id into the button value so the
# callback handler can resolve without extra bookkeeping.
value = f"{session_key}|{confirm_id}"
@@ -2800,7 +2783,7 @@ class SlackAdapter(BasePlatformAdapter):
"type": "section",
"text": {
"type": "mrkdwn",
"text": f"*{_title}*\n\n{body}",
"text": f"*{title or 'Confirm'}*\n\n{body}",
},
},
{
+8 -23
View File
@@ -6473,12 +6473,6 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew
_tool_approval_live = False
if _pending_confirm and not _tool_approval_live:
_raw_reply = (event.text or "").strip()
# Accept bang-prefixed replies (`!always`, `!cancel`) verbatim.
# Slack/Matrix instruction text shows the `!` prefix (typed `/`
# is blocked in Slack threads), but the adapters only rewrite
# `!<known-command>` — `always`/`cancel` are confirm keywords,
# not registered commands, so the `!` survives to here.
_norm_reply = _raw_reply.lstrip("!/").lower()
_cmd_reply = event.get_command()
_confirm_choice = None
if _cmd_reply in {"approve", "yes", "ok", "confirm"}:
@@ -6487,11 +6481,11 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew
_confirm_choice = "always"
elif _cmd_reply in {"cancel", "no", "deny", "nevermind"}:
_confirm_choice = "cancel"
elif _norm_reply in {"approve", "approve once", "once"}:
elif _raw_reply.lower() in {"approve", "approve once", "once"}:
_confirm_choice = "once"
elif _norm_reply in {"always", "always approve"}:
elif _raw_reply.lower() in {"always", "always approve"}:
_confirm_choice = "always"
elif _norm_reply in {"cancel", "nevermind", "no"}:
elif _raw_reply.lower() in {"cancel", "nevermind", "no"}:
_confirm_choice = "cancel"
if _confirm_choice is not None:
_resolved = await _slash_confirm_mod.resolve(
@@ -7065,9 +7059,6 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew
if canonical == "memory":
return await self._handle_memory_command(event)
if canonical == "skills":
return await self._handle_skills_command(event)
if canonical == "fast":
return await self._handle_fast_command(event)
@@ -10637,7 +10628,6 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew
return result
return result
_p = self._typed_command_prefix_for(event.source.platform)
prompt_message = (
f"⚠️ **Confirm /{command}**\n\n"
f"{detail}\n\n"
@@ -10645,7 +10635,7 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew
"• **Approve Once** — proceed this time only\n"
"• **Always Approve** — proceed and silence this prompt permanently\n"
"• **Cancel** — keep current conversation\n\n"
f"_Text fallback: reply `{_p}approve`, `{_p}always`, or `{_p}cancel`._"
"_Text fallback: reply `/approve`, `/always`, or `/cancel`._"
)
return await self._request_slash_confirm(
event=event,
@@ -11040,12 +11030,11 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew
logger.debug("Button-based update prompt failed: %s", btn_err)
if not sent_buttons:
default_hint = f" (default: {default})" if default else ""
_p = getattr(adapter, "typed_command_prefix", "/")
await adapter.send(
chat_id,
f"⚕ **Update needs your input:**\n\n"
f"{prompt_text}{default_hint}\n\n"
f"Reply `{_p}approve` (yes) or `{_p}deny` (no), "
f"Reply `/approve` (yes) or `/deny` (no), "
f"or type your answer directly.",
metadata=metadata,
)
@@ -14112,18 +14101,14 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew
"Button-based approval failed, falling back to text: %s", _e
)
# Fallback: plain text approval prompt. Use the adapter's
# typed prefix so Slack/Matrix users are told the form they
# can actually type (`!approve`) — typed "/" is blocked in
# Slack threads and reserved by Matrix clients.
_p = getattr(_status_adapter, "typed_command_prefix", "/")
# Fallback: plain text approval prompt
cmd_preview = cmd[:200] + "..." if len(cmd) > 200 else cmd
msg = (
f"⚠️ **Dangerous command requires approval:**\n"
f"```\n{cmd_preview}\n```\n"
f"Reason: {desc}\n\n"
f"Reply `{_p}approve` to execute, `{_p}approve session` to approve this pattern "
f"for the session, `{_p}approve always` to approve permanently, or `{_p}deny` to cancel."
f"Reply `/approve` to execute, `/approve session` to approve this pattern "
f"for the session, `/approve always` to approve permanently, or `/deny` to cancel."
)
try:
_approval_send_fut = safe_schedule_threadsafe(
+1 -73
View File
@@ -47,19 +47,6 @@ logger = logging.getLogger("gateway.run")
class GatewaySlashCommandsMixin:
"""In-session slash-command handlers for GatewayRunner."""
def _typed_command_prefix_for(self, platform) -> str:
"""Return the prefix users can always type to reach Hermes commands.
Reads the adapter's ``typed_command_prefix`` capability flag
(default "/"). Slack and Matrix return "!" because typed "/"
commands are blocked in Slack threads / reserved by Matrix clients;
their adapters rewrite "!command" to "/command" on receive.
Instruction text built for those platforms must show the prefix
that actually works when typed.
"""
adapter = self.adapters.get(platform) if getattr(self, "adapters", None) else None
return getattr(adapter, "typed_command_prefix", "/") if adapter is not None else "/"
async def _handle_reset_command(self, event: MessageEvent) -> Union[str, EphemeralReply]:
"""Handle /new or /reset command."""
source = event.source
@@ -1337,14 +1324,13 @@ class GatewaySlashCommandsMixin:
# an explicit decision).
return await _finish_switch()
_p = self._typed_command_prefix_for(event.source.platform)
return await self._request_slash_confirm(
event=event,
command="model",
title="Expensive Model Warning",
message=(
f"⚠️ **Expensive Model Warning**\n\n{_cost_warning.message}\n\n"
f"_Text fallback: reply `{_p}approve` to switch or `{_p}cancel` to keep "
"_Text fallback: reply `/approve` to switch or `/cancel` to keep "
"the current model._"
),
handler=_on_cost_confirm,
@@ -2058,64 +2044,6 @@ class GatewaySlashCommandsMixin:
"reject <id>, approval <on|off>.")
return out
async def _handle_skills_command(self, event: MessageEvent) -> str:
"""Handle /skills on the gateway — pending skill-write review only.
The full skills hub (search/browse/install) stays CLI-only; this
handler covers the write-approval review surface (pending / approve /
reject / diff / approval) so a skill staged from a gateway session can
be reviewed from that same session. Gated by ``skills.write_approval``
via the CommandDef's ``gateway_config_gate``; also answers when staged
writes still exist after the gate was turned off (so they are never
stranded).
``diff`` output is truncated for chat bubbles the full diff lives in
the CLI (``/skills diff <id>``) and the pending JSON file.
"""
from gateway.run import _hermes_home
from hermes_cli.write_approval_commands import handle_pending_subcommand
from tools import write_approval as wa
raw_args = event.get_command_args().strip()
args = raw_args.split() if raw_args else []
session_key = self._session_key_for_source(event.source)
config_path = _hermes_home / "config.yaml"
gate_on = wa.write_approval_enabled(wa.SKILLS)
wants_toggle = bool(args) and args[0].lower() in {"approval", "mode"}
if not gate_on and not wants_toggle and wa.pending_count(wa.SKILLS) == 0:
return ("Skill write approval is off (skills.write_approval). "
"Enable it with /skills approval on, then review staged "
"writes here with /skills pending.")
def _set_approval(enabled: bool):
import yaml
user_config = {}
if config_path.exists():
with open(config_path, encoding="utf-8") as f:
user_config = yaml.safe_load(f) or {}
user_config.setdefault("skills", {})["write_approval"] = bool(enabled)
atomic_yaml_write(config_path, user_config)
# New setting must take effect next message → drop cached agent.
self._evict_cached_agent(session_key)
out = handle_pending_subcommand(
wa.SKILLS, args, set_mode_fn=_set_approval,
)
if out is None:
return ("Unknown /skills subcommand on this platform. Use: pending, "
"approve <id>, reject <id>, diff <id>, approval <on|off>. "
"(Search/install are CLI-only.)")
# Chat bubbles can't hold a full skill diff — truncate and point at
# the real review surfaces.
if args and args[0].lower() == "diff" and len(out) > 3000:
pending_id = args[1] if len(args) > 1 else "<id>"
out = (out[:3000]
+ f"\n… (truncated — full diff: `/skills diff {pending_id}` "
f"on the CLI, or ~/.hermes/pending/skills/{pending_id}.json)")
return out
async def _handle_fast_command(self, event: MessageEvent) -> str:
"""Handle /fast — mirror the CLI Priority Processing toggle in gateway chats."""
from gateway.run import _hermes_home, _load_gateway_config, _resolve_gateway_model
+15 -60
View File
@@ -19,74 +19,29 @@ __release_date__ = "2026.6.5"
def _ensure_utf8():
"""Force UTF-8 stdout/stderr to prevent UnicodeEncodeError crashes.
"""Force UTF-8 stdout/stderr on Windows to prevent UnicodeEncodeError.
Several environments select a legacy, non-UTF-8 encoding for the standard
streams:
- Windows services and terminals default to cp1252.
- Linux hosts with a latin-1 / C / POSIX locale (common on minimal Debian
installs and Raspberry Pi) select latin-1 or ASCII.
The CLI prints box-drawing characters () and the glyph in the setup
wizard, doctor, and status banners. Encoding those under a non-UTF-8 codec
raises an unhandled UnicodeEncodeError that crashes the command before it
can even start e.g. `hermes setup` on a fresh Pi.
This runs at import time so it protects every CLI subcommand, on any
platform. It re-wraps stdout/stderr as UTF-8 when their encoding is not
already UTF-8, preferring TextIOWrapper.reconfigure() so the existing
stream object is fixed in place (cached `sys.stdout` references keep
working) and falling back to reopening the file descriptor with
closefd=False (the CPython-recommended safe variant).
No-op when the streams are already UTF-8: a healthy UTF-8 system sees no
stream change and no environment mutation.
Note: this is intentionally the earliest, platform-agnostic guard.
hermes_cli/stdio.py::configure_windows_stdio() runs later from the entry
points and layers on the Windows-only extras (console code-page flip,
EDITOR default, PATH augmentation); its stream reconfiguration is a
harmless idempotent no-op once we have already repaired the streams here.
Windows services and terminals default to cp1252, which cannot encode
box-drawing characters used in CLI output. This causes unhandled
UnicodeEncodeError crashes on gateway startup.
"""
repaired = False
if sys.platform != "win32":
return
os.environ.setdefault("PYTHONUTF8", "1")
os.environ.setdefault("PYTHONIOENCODING", "utf-8")
for stream_name in ("stdout", "stderr"):
stream = getattr(sys, stream_name, None)
if stream is None:
continue
try:
encoding = (getattr(stream, "encoding", "") or "").lower().replace("-", "")
if encoding == "utf8":
continue
# Preferred: reconfigure the existing TextIOWrapper in place. This
# preserves object identity so any code already holding a reference
# to the old sys.stdout benefits from the repair too.
reconfigure = getattr(stream, "reconfigure", None)
if callable(reconfigure):
reconfigure(encoding="utf-8", errors="replace")
repaired = True
continue
# Fallback: reopen the underlying file descriptor as UTF-8. Used
# for streams that don't expose reconfigure() (e.g. some wrapped
# or replaced streams). closefd=False keeps the original fd open.
new_stream = open(
stream.fileno(), "w", encoding="utf-8",
errors="replace", buffering=1, closefd=False,
)
setattr(sys, stream_name, new_stream)
repaired = True
except (AttributeError, OSError, ValueError):
if getattr(stream, "encoding", "").lower().replace("-", "") != "utf8":
new_stream = open(
stream.fileno(), "w", encoding="utf-8",
buffering=1, closefd=False,
)
setattr(sys, stream_name, new_stream)
except (AttributeError, OSError):
pass
# Only nudge child processes toward UTF-8 when we actually detected a
# non-UTF-8 locale. On a healthy UTF-8 host children inherit UTF-8 from the
# locale already, so leave the environment untouched (minimal footprint).
if repaired:
os.environ.setdefault("PYTHONUTF8", "1")
os.environ.setdefault("PYTHONIOENCODING", "utf-8")
_ensure_utf8()
-1
View File
@@ -167,7 +167,6 @@ COMMAND_REGISTRY: list[CommandDef] = [
cli_only=True),
CommandDef("skills", "Search, install, inspect, or manage skills",
"Tools & Skills", cli_only=True,
gateway_config_gate="skills.write_approval",
subcommands=("search", "browse", "inspect", "install", "audit",
"pending", "approve", "reject", "diff", "approval")),
CommandDef("memory", "Review pending memory writes / toggle the approval gate",
+2 -27
View File
@@ -1290,14 +1290,6 @@ DEFAULT_CONFIG = {
"timeout": 30,
"extra_body": {},
},
"tts_audio_tags": {
"provider": "auto",
"model": "",
"base_url": "",
"api_key": "",
"timeout": 30,
"extra_body": {},
},
# Triage specifier — flesh out a rough one-liner in the Kanban
# Triage column into a concrete spec, then promote it to ``todo``.
# Invoked by ``hermes kanban specify`` (single id or --all). Set a
@@ -1564,7 +1556,7 @@ DEFAULT_CONFIG = {
# Each provider supports an optional `max_text_length:` override for the
# per-request input-character cap. Omit it to use the provider's documented
# limit (OpenAI 4096, xAI 15000, MiniMax 10000, ElevenLabs 5k-40k model-aware,
# Gemini 32000, Edge 5000, Mistral 4000, NeuTTS/KittenTTS 2000).
# Gemini 5000, Edge 5000, Mistral 4000, NeuTTS/KittenTTS 2000).
"tts": {
"provider": "edge", # "edge" (free) | "elevenlabs" (premium) | "openai" | "xai" | "minimax" | "mistral" | "gemini" | "neutts" (local) | "kittentts" (local) | "piper" (local)
"edge": {
@@ -1580,19 +1572,6 @@ DEFAULT_CONFIG = {
"voice": "alloy",
# Voices: alloy, echo, fable, onyx, nova, shimmer
},
"gemini": {
"model": "gemini-2.5-flash-preview-tts",
"voice": "Kore",
# When true, Gemini 3.1 TTS uses a hidden auxiliary-model rewrite
# pass to insert freeform square-bracket audio tags into the TTS
# script. Visible chat replies are unchanged.
"audio_tags": False,
# Optional local Markdown/text file with Gemini TTS performance
# direction. It may include AUDIO PROFILE, SCENE, DIRECTOR'S NOTES,
# SAMPLE CONTEXT, and either a `{transcript}` placeholder or no
# transcript section; Hermes appends the live transcript when absent.
"persona_prompt_file": "",
},
"xai": {
"voice_id": "eve", # or custom voice ID — see https://docs.x.ai/developers/model-capabilities/audio/custom-voices
"language": "en",
@@ -5829,22 +5808,18 @@ def remove_env_value(key: str) -> bool:
f.flush()
os.fsync(f.fileno())
atomic_replace(tmp_path, env_path)
# Preserve the original file mode (e.g. 0640 for Docker volume
# mounts) instead of letting _secure_file unconditionally tighten
# to 0600. Mirrors save_env_value().
if original_mode is not None:
try:
os.chmod(env_path, original_mode)
except OSError:
pass
else:
_secure_file(env_path)
except BaseException:
try:
os.unlink(tmp_path)
except OSError:
pass
raise
_secure_file(env_path)
os.environ.pop(key, None)
invalidate_env_cache()
-191
View File
@@ -2980,7 +2980,6 @@ _AUX_TASKS: list[tuple[str, str, str]] = [
("approval", "Approval", "smart command approval"),
("mcp", "MCP", "MCP tool reasoning"),
("title_generation", "Title generation", "session titles"),
("tts_audio_tags", "TTS audio tags", "Gemini TTS tag insertion"),
("skills_hub", "Skills hub", "skills search/install"),
("triage_specifier", "Triage specifier", "kanban spec fleshing"),
("kanban_decomposer", "Kanban decomposer", "task decomposition"),
@@ -6393,167 +6392,6 @@ def _load_installable_optional_extras(group: str = "all") -> list[str]:
return referenced
# Install-scoped breadcrumb dropped right before ``hermes update`` mutates the
# venv and cleared only after the dependency install verifies clean. If a user
# kills the update mid-install (Ctrl-C, terminal close, WSL OOM), the marker
# survives and the next ``hermes`` launch finishes the install instead of
# limping along on a half-built venv (e.g. pip wiped, a core dep like Pillow
# never landed). Lives next to the venv (not under $HERMES_HOME) because the
# venv is shared across all profiles, so a single marker covers every profile.
def _update_marker_path() -> Path:
return PROJECT_ROOT / ".update-incomplete"
def _write_update_incomplete_marker() -> None:
"""Drop the interrupted-install breadcrumb. Never raises."""
try:
_update_marker_path().write_text(
f"started={_time.time()}\npid={os.getpid()}\n", encoding="utf-8"
)
except OSError as exc:
logger.debug("Could not write update-incomplete marker: %s", exc)
def _clear_update_incomplete_marker() -> None:
"""Remove the interrupted-install breadcrumb. Never raises."""
try:
_update_marker_path().unlink()
except FileNotFoundError:
pass
except OSError as exc:
logger.debug("Could not clear update-incomplete marker: %s", exc)
def _recover_from_interrupted_install() -> None:
"""Finish a dependency install that a prior ``hermes update`` left half-done.
Triggered on launch when ``.update-incomplete`` is present meaning the
code was pulled but the dep install was killed before it verified clean.
Unconditionally bootstraps pip via ``ensurepip`` (a killed ``pip install``
can wipe pip from the venv entirely, which blocks the venv from recovering
on its own), then re-runs the editable ``.[all]`` install + core-dependency
verification, then clears the marker.
Never raises: a recovery failure must not block launch. If it can't
self-heal it prints the one-line manual command and leaves the marker so
the next launch tries again.
Concurrency: the marker lives next to the shared venv, so a gateway start
plus a CLI launch (or two profiles starting at once) can both see it. An
``O_EXCL`` lockfile ensures only one process runs the reinstall; the
others skip and let the winner clear the marker.
Output: everything our status lines AND the streamed pip/uv install
(which inherits fd 1) is routed to stderr. Launches whose stdout is a
protocol stream (``hermes acp`` speaks JSON-RPC on stdout) must never get
install noise on stdout.
"""
if not _update_marker_path().exists():
return
# Skip in managed/Docker installs and on PyPI installs with no git checkout:
# those don't run the source-tree update path, so a stray marker is not ours
# to act on. Just clear it.
if not (PROJECT_ROOT / "pyproject.toml").is_file():
_clear_update_incomplete_marker()
return
# Single-flight guard: atomically claim the recovery lock. If another
# process holds it, skip — it is running the same reinstall into the same
# shared venv right now. A crashed holder leaves a stale lock; break it
# after an hour (well past any realistic install) so recovery can't be
# wedged forever.
lock_path = PROJECT_ROOT / ".update-incomplete.lock"
try:
fd = os.open(lock_path, os.O_CREAT | os.O_EXCL | os.O_WRONLY)
os.write(fd, f"{os.getpid()}\n".encode())
os.close(fd)
except FileExistsError:
try:
if _time.time() - lock_path.stat().st_mtime > 3600:
lock_path.unlink()
except OSError:
pass
return
except OSError as exc:
# Couldn't create the lock (read-only fs, perms). Proceed unlocked —
# the install itself will surface the real problem.
logger.debug("Could not create install-recovery lock: %s", exc)
saved_stdout_fd = None
saved_sys_stdout = sys.stdout
try:
# Route Python-level prints AND subprocess-inherited fd 1 to stderr
# for the duration of recovery (see docstring: ACP stdout safety).
try:
saved_stdout_fd = os.dup(1)
os.dup2(2, 1)
except OSError:
saved_stdout_fd = None
sys.stdout = sys.stderr
print(
"⚠ A previous `hermes update` was interrupted mid-install — "
"finishing dependency installation now..."
)
try:
from hermes_cli.managed_uv import ensure_uv
# Always bootstrap pip first: a killed install can leave the venv with
# no pip module at all, and uv may also be gone. ensurepip restores a
# known-good pip so at least the plain-pip path below can proceed.
try:
subprocess.run(
[sys.executable, "-m", "ensurepip", "--upgrade", "--default-pip"],
cwd=PROJECT_ROOT,
capture_output=True,
)
except Exception as exc:
logger.debug("ensurepip during install recovery failed: %s", exc)
uv_bin = ensure_uv()
if uv_bin:
uv_env = {**os.environ, "VIRTUAL_ENV": str(PROJECT_ROOT / "venv")}
if _is_termux_env(uv_env):
uv_env.pop("PYTHONPATH", None)
uv_env.pop("PYTHONHOME", None)
_install_python_dependencies_with_optional_fallback(
[uv_bin, "pip"],
env=uv_env,
group="termux-all" if _is_termux_env(uv_env) else "all",
)
else:
_install_python_dependencies_with_optional_fallback(
[sys.executable, "-m", "pip"],
group="termux-all" if _is_termux_env() else "all",
)
_clear_update_incomplete_marker()
print("✓ Dependency installation recovered — your install is healthy again.")
except Exception as exc:
# Leave the marker in place so the next launch retries. Give the user
# the exact manual recovery command in the meantime.
logger.debug("Interrupted-install recovery failed: %s", exc)
print("✗ Could not auto-recover the interrupted install.")
print(" Recover manually with:")
print(f" cd {PROJECT_ROOT}")
print(f" {sys.executable} -m ensurepip --upgrade")
print(f" {sys.executable} -m pip install -e '.[all]'")
finally:
sys.stdout = saved_sys_stdout
if saved_stdout_fd is not None:
try:
os.dup2(saved_stdout_fd, 1)
os.close(saved_stdout_fd)
except OSError:
pass
try:
lock_path.unlink()
except OSError:
pass
def _run_install_with_heartbeat(
cmd: list[str],
*,
@@ -8485,13 +8323,6 @@ def _cmd_update_impl(args, gateway_mode: bool):
# Reinstall Python dependencies. Prefer .[all], but if one optional extra
# breaks on this machine, keep base deps and reinstall the remaining extras
# individually so update does not silently strip working capabilities.
#
# Drop the interrupted-install breadcrumb BEFORE touching the venv. If
# the install is killed mid-flight (Ctrl-C, terminal close, WSL OOM),
# the marker survives and the next ``hermes`` launch finishes the
# install via ``_recover_from_interrupted_install``. Cleared only after
# the install + core-dependency verification completes below.
_write_update_incomplete_marker()
print("→ Updating Python dependencies...")
from hermes_cli.managed_uv import ensure_uv, update_managed_uv
@@ -8545,12 +8376,6 @@ def _cmd_update_impl(args, gateway_mode: bool):
_install_psutil_android_compat(pip_cmd)
_install_python_dependencies_with_optional_fallback(pip_cmd, group=install_group)
# Core Python deps installed AND verified (the fallback helper runs
# _verify_core_dependencies_installed). Clear the interrupted-install
# breadcrumb now — the remaining steps (lazy refresh, node deps, web
# UI, desktop rebuild) are non-core and can't brick the venv.
_clear_update_incomplete_marker()
_refresh_active_lazy_features()
_update_node_dependencies()
@@ -10865,22 +10690,6 @@ def main():
except Exception:
pass
# Self-heal a venv left half-built by an interrupted ``hermes update``
# (Ctrl-C, terminal close, WSL OOM mid-install). Skip when the user is
# *running* update — that flow writes and clears its own marker, and we
# don't want a recovery install racing the real one. Never raises.
#
# The substring match is deliberately loose: argv isn't parsed yet at this
# point, and the failure modes are asymmetric. Over-matching (e.g.
# ``hermes skills install update``) merely defers recovery one launch;
# under-matching (missing ``hermes -p work update``) would race a recovery
# install against the real one. Loose wins.
try:
if "update" not in sys.argv[1:]:
_recover_from_interrupted_install()
except Exception:
pass
if _try_termux_fast_tui_launch():
return
if _try_termux_fast_cli_launch():
+253
View File
@@ -18,6 +18,7 @@ from dataclasses import dataclass
from datetime import datetime, timezone
import hmac
import importlib.util
import mimetypes
import json
import logging
import os
@@ -820,6 +821,177 @@ _MEDIA_CONTENT_TYPES = {
}
_MEDIA_MAX_BYTES = 25 * 1024 * 1024
_FS_READDIR_HIDDEN = {
".git",
".hg",
".svn",
".cache",
".next",
".turbo",
".venv",
"__pycache__",
"build",
"dist",
"node_modules",
"target",
"venv",
}
_FS_DATA_URL_MAX_BYTES = 16 * 1024 * 1024
_FS_TEXT_SOURCE_MAX_BYTES = 64 * 1024 * 1024
_FS_TEXT_PREVIEW_MAX_BYTES = 512 * 1024
_FS_PREVIEW_LANGUAGE_BY_EXT = {
".c": "c",
".conf": "ini",
".cpp": "cpp",
".css": "css",
".csv": "csv",
".go": "go",
".graphql": "graphql",
".h": "c",
".hpp": "cpp",
".html": "html",
".java": "java",
".js": "javascript",
".json": "json",
".jsx": "jsx",
".kt": "kotlin",
".lua": "lua",
".md": "markdown",
".mjs": "javascript",
".py": "python",
".rb": "ruby",
".rs": "rust",
".sh": "shell",
".sql": "sql",
".svg": "xml",
".toml": "toml",
".ts": "typescript",
".tsx": "tsx",
".txt": "text",
".xml": "xml",
".yaml": "yaml",
".yml": "yaml",
".zsh": "shell",
}
_FS_MIME_TYPES = {
".avi": "video/x-msvideo",
".bmp": "image/bmp",
".flac": "audio/flac",
".gif": "image/gif",
".jpeg": "image/jpeg",
".jpg": "image/jpeg",
".m4a": "audio/mp4",
".mkv": "video/x-matroska",
".mov": "video/quicktime",
".mp3": "audio/mpeg",
".mp4": "video/mp4",
".ogg": "audio/ogg",
".opus": "audio/ogg; codecs=opus",
".png": "image/png",
".svg": "image/svg+xml",
".wav": "audio/wav",
".webm": "video/webm",
".webp": "image/webp",
}
def _fs_path(raw_path: str) -> Path:
raw = str(raw_path or "").strip()
if not raw:
raise HTTPException(status_code=400, detail="Path is required")
if "\0" in raw:
raise HTTPException(status_code=400, detail="Invalid path")
try:
if raw.lower().startswith("file:"):
parsed = urllib.parse.urlparse(raw)
if parsed.netloc and parsed.netloc not in {"", "localhost"}:
raise ValueError
raw = urllib.request.url2pathname(parsed.path)
candidate = Path(raw).expanduser()
if not candidate.is_absolute():
candidate = Path.cwd() / candidate
return candidate.resolve(strict=False)
except (OSError, RuntimeError, ValueError):
raise HTTPException(status_code=400, detail="Invalid path")
def _fs_mime_type(path: Path) -> str:
suffix = path.suffix.lower()
if suffix in _FS_MIME_TYPES:
return _FS_MIME_TYPES[suffix]
guessed, _ = mimetypes.guess_type(str(path))
return guessed or "application/octet-stream"
def _fs_looks_binary(data: bytes) -> bool:
if not data:
return False
if b"\0" in data:
return True
suspicious = sum(1 for byte in data if byte < 32 and byte not in {9, 10, 13})
return suspicious / len(data) > 0.12
def _fs_regular_file(path: Path) -> tuple[Path, os.stat_result]:
target = _fs_path(str(path))
try:
st = target.stat()
except FileNotFoundError:
raise HTTPException(status_code=404, detail="File not found")
except NotADirectoryError:
raise HTTPException(status_code=404, detail="File not found")
except PermissionError:
raise HTTPException(status_code=403, detail="File is not readable")
except OSError as exc:
raise HTTPException(status_code=400, detail=str(exc) or "Invalid path")
if stat.S_ISDIR(st.st_mode):
raise HTTPException(status_code=400, detail="Path points to a directory")
if not stat.S_ISREG(st.st_mode):
raise HTTPException(status_code=400, detail="Only regular files can be read")
return target, st
def _fs_find_git_root(start: Path) -> str | None:
directory = start
for _ in range(50):
try:
if (directory / ".git").exists():
return str(directory)
except OSError:
return None
parent = directory.parent
if parent == directory:
return None
directory = parent
return None
def _fs_default_cwd() -> str:
cfg_terminal = load_config().get("terminal") or {}
raw = str(cfg_terminal.get("cwd") or os.environ.get("TERMINAL_CWD") or "").strip()
if raw and raw not in {".", "auto", "cwd"}:
try:
candidate = Path(raw).expanduser().resolve(strict=False)
if candidate.is_dir():
return str(candidate)
except (OSError, RuntimeError):
pass
return str(Path.cwd())
def _fs_git_branch(cwd: str) -> str:
try:
result = subprocess.run(
["git", "-C", cwd, "branch", "--show-current"],
capture_output=True,
text=True,
timeout=2,
check=False,
)
return result.stdout.strip() if result.returncode == 0 else ""
except Exception:
return ""
def _media_serve_roots() -> list[Path]:
"""Directories ``GET /api/media`` is allowed to read from.
@@ -874,6 +1046,87 @@ async def get_media(path: str):
return {"data_url": f"data:{_MEDIA_CONTENT_TYPES[target.suffix.lower()]};base64,{encoded}"}
@app.get("/api/fs/list")
async def fs_list(path: str):
target = _fs_path(path)
try:
entries = []
with os.scandir(target) as scan:
for entry in scan:
if entry.name in _FS_READDIR_HIDDEN:
continue
entries.append({
"name": entry.name,
"path": str(target / entry.name),
"isDirectory": entry.is_dir(follow_symlinks=False),
})
entries.sort(key=lambda item: (not item["isDirectory"], item["name"].lower(), item["name"]))
return {"entries": entries}
except FileNotFoundError:
return {"entries": [], "error": "ENOENT"}
except NotADirectoryError:
return {"entries": [], "error": "ENOTDIR"}
except PermissionError:
return {"entries": [], "error": "EACCES"}
except OSError as exc:
return {"entries": [], "error": getattr(exc, "strerror", None) or "read-error"}
@app.get("/api/fs/read-text")
async def fs_read_text(path: str):
target, st = _fs_regular_file(_fs_path(path))
if st.st_size > _FS_TEXT_SOURCE_MAX_BYTES:
raise HTTPException(status_code=413, detail="File too large")
bytes_to_read = min(st.st_size, _FS_TEXT_PREVIEW_MAX_BYTES)
try:
with target.open("rb") as handle:
data = handle.read(bytes_to_read)
except PermissionError:
raise HTTPException(status_code=403, detail="File is not readable")
except OSError as exc:
raise HTTPException(status_code=400, detail=str(exc) or "File read failed")
return {
"binary": _fs_looks_binary(data[:4096]),
"byteSize": st.st_size,
"language": _FS_PREVIEW_LANGUAGE_BY_EXT.get(target.suffix.lower(), "text"),
"mimeType": _fs_mime_type(target),
"path": str(target),
"text": data.decode("utf-8", errors="replace"),
"truncated": st.st_size > _FS_TEXT_PREVIEW_MAX_BYTES,
}
@app.get("/api/fs/read-data-url")
async def fs_read_data_url(path: str):
target, st = _fs_regular_file(_fs_path(path))
if st.st_size > _FS_DATA_URL_MAX_BYTES:
raise HTTPException(status_code=413, detail="File too large")
try:
encoded = base64.b64encode(target.read_bytes()).decode("ascii")
except PermissionError:
raise HTTPException(status_code=403, detail="File is not readable")
except OSError as exc:
raise HTTPException(status_code=400, detail=str(exc) or "File read failed")
return {"dataUrl": f"data:{_fs_mime_type(target)};base64,{encoded}"}
@app.get("/api/fs/git-root")
async def fs_git_root(path: str):
target = _fs_path(path)
try:
st = target.stat()
start = target if stat.S_ISDIR(st.st_mode) else target.parent
except OSError:
start = target
return {"root": _fs_find_git_root(start)}
@app.get("/api/fs/default-cwd")
async def fs_default_cwd():
cwd = _fs_default_cwd()
return {"cwd": cwd, "branch": _fs_git_branch(cwd)}
@app.get("/api/status")
async def get_status():
current_ver, latest_ver = check_config_version()
+3 -23
View File
@@ -116,8 +116,6 @@ class OpenRouterProfile(ProviderProfile):
the same backend server across turns.
"""
extra_body: dict[str, Any] = {}
top_level: dict[str, Any] = {}
extra_headers: dict[str, Any] = {}
if supports_reasoning:
# Reasoning-mandatory Anthropic models (Claude 4.6+ / fable /
# future named models) use *adaptive* thinking: the model decides
@@ -134,36 +132,18 @@ class OpenRouterProfile(ProviderProfile):
# The only reliable behavior is to omit ``reasoning`` and let the
# model default to adaptive. See hermes-agent#42991 (disable case)
# and the tool-replay follow-up.
#
# ``reasoning.effort`` being ignored does NOT mean these models have
# no effort lever — OpenRouter honors the requested effort on the
# top-level ``verbosity`` field instead (it maps to Anthropic's
# ``output_config.effort``; ``reasoning.effort`` is accepted but
# ignored — confirmed by OpenRouter's Claude migration docs and a
# live token-spend probe in hermes-agent#43432). Route the existing
# ``reasoning_config["effort"]`` (sourced from
# ``agent.reasoning_effort``) onto ``verbosity`` so the knob the user
# already sets keeps working for these models. We still send NO
# ``reasoning`` field, preserving the #42991 400 fix.
if _anthropic_reasoning_is_mandatory(model):
cfg = reasoning_config or {}
effort = cfg.get("effort")
# Only emit when effort is actually requested and reasoning
# isn't explicitly disabled. Otherwise omit ``verbosity`` so the
# model keeps its own adaptive default (``high``).
if cfg.get("enabled", True) is not False and effort and effort != "none":
top_level["verbosity"] = effort
pass # omit reasoning entirely → adaptive default
elif reasoning_config is not None:
extra_body["reasoning"] = dict(reasoning_config)
else:
extra_body["reasoning"] = {"enabled": True, "effort": "medium"}
extra_headers: dict[str, Any] = {}
if session_id and model and model.startswith(("x-ai/grok-", "xai/grok-")):
extra_headers["x-grok-conv-id"] = session_id
if extra_headers:
top_level["extra_headers"] = extra_headers
return extra_body, top_level
return extra_body, {"extra_headers": extra_headers} if extra_headers else {}
openrouter = OpenRouterProfile(
-1
View File
@@ -45,7 +45,6 @@ ACP_REGISTRY_MANIFEST = REPO_ROOT / "acp_registry" / "agent.json"
# Auto-extracted from noreply emails + manual overrides
AUTHOR_MAP = {
"barronlroth@gmail.com": "barronlroth",
"ondrej.drapalik@gmail.com": "OndrejDrapalik",
"tomasz.panek@gmail.com": "tomekpanek",
"philipadsouza@gmail.com": "PhilipAD",
+2 -2
View File
@@ -668,8 +668,8 @@ def test_state_atomic_write_no_tmp_leftovers(curator_env):
c = curator_env["curator"]
c.save_state({"paused": True})
parent = c._state_file().parent
tmp_files = [p.name for p in parent.iterdir() if p.name.endswith(".tmp")]
assert tmp_files == []
for p in parent.iterdir():
assert not p.name.startswith(".curator_state_"), f"tmp leftover: {p.name}"
def test_state_preserves_last_report_path(curator_env):
+1 -27
View File
@@ -415,17 +415,14 @@ class TestSendVoiceReply:
@pytest.mark.asyncio
async def test_calls_tts_and_send_voice(self, runner):
from gateway.config import Platform
mock_adapter = AsyncMock()
mock_adapter.send_voice = AsyncMock()
event = _make_event()
event.source.platform = Platform.TELEGRAM
runner.adapters[event.source.platform] = mock_adapter
tts_result = json.dumps({"success": True, "file_path": "/tmp/test.ogg"})
with patch("tools.tts_tool.text_to_speech_tool", return_value=tts_result) as mock_tts, \
with patch("tools.tts_tool.text_to_speech_tool", return_value=tts_result), \
patch("tools.tts_tool._strip_markdown_for_tts", side_effect=lambda t: t), \
patch("os.path.isfile", return_value=True), \
patch("os.unlink"), \
@@ -433,32 +430,9 @@ class TestSendVoiceReply:
await runner._send_voice_reply(event, "Hello world")
mock_adapter.send_voice.assert_called_once()
assert mock_tts.call_args.kwargs["output_path"].endswith(".ogg")
call_args = mock_adapter.send_voice.call_args
assert call_args.kwargs.get("chat_id") == "123"
@pytest.mark.asyncio
async def test_non_telegram_auto_voice_reply_uses_mp3(self, runner):
from gateway.config import Platform
mock_adapter = AsyncMock()
mock_adapter.send_voice = AsyncMock()
event = _make_event()
event.source.platform = Platform.SLACK
runner.adapters[event.source.platform] = mock_adapter
tts_result = json.dumps({"success": True, "file_path": "/tmp/test.mp3"})
with patch("tools.tts_tool.text_to_speech_tool", return_value=tts_result) as mock_tts, \
patch("tools.tts_tool._strip_markdown_for_tts", side_effect=lambda t: t), \
patch("os.path.isfile", return_value=True), \
patch("os.unlink"), \
patch("os.makedirs"):
await runner._send_voice_reply(event, "Hello world")
mock_adapter.send_voice.assert_called_once()
assert mock_tts.call_args.kwargs["output_path"].endswith(".mp3")
@pytest.mark.asyncio
async def test_auto_voice_reply_uses_thread_metadata_helper(self, runner):
from gateway.config import Platform
-22
View File
@@ -354,28 +354,6 @@ class TestRemoveEnvValue:
remove_env_value("ORPHAN_KEY")
assert "ORPHAN_KEY" not in os.environ
def test_remove_env_value_preserves_existing_file_mode_on_posix(self, tmp_path):
"""Regression: pre-existing .env mode (e.g. 0640 for a Docker
bind-mount the operator chose) survives a remove just as it does a
save. Previously _secure_file ran unconditionally after the
mode-restore branch and re-tightened to 0600 the same bug fixed
in save_env_value (#33699), in the sibling remove path.
"""
if os.name == "nt":
return
env_path = tmp_path / ".env"
env_path.write_text("KEEP=value\nDROP=gone\n")
os.chmod(env_path, 0o640)
with patch.dict(os.environ, {"HERMES_HOME": str(tmp_path), "DROP": "gone"}):
removed = remove_env_value("DROP")
assert removed is True
assert "DROP" not in env_path.read_text()
env_mode = env_path.stat().st_mode & 0o777
assert env_mode == 0o640, f"expected 0o640, got {oct(env_mode)}"
class TestSaveConfigAtomicity:
"""Verify save_config uses atomic writes (tempfile + os.replace)."""
-179
View File
@@ -1,179 +0,0 @@
"""Regression tests for hermes_cli._ensure_utf8().
Covers the crash class where the setup wizard (and other banner-printing
commands) emit box-drawing characters and the glyph, which raise
UnicodeEncodeError when stdout/stderr are bound to a non-UTF-8 codec.
Historically the repair was gated on ``sys.platform == "win32"`` and only
caught the Windows cp1252 case. Linux hosts with a latin-1 / C / POSIX locale
(common on minimal Debian installs and Raspberry Pi) hit the identical crash
in ``hermes setup`` because the repair returned early. See the Raspberry Pi
report: latin-1 locale UnicodeEncodeError before the wizard could start.
"""
import io
import os
import sys
import hermes_cli
# The exact glyphs the setup wizard / banners print (setup.py ~line 2962+).
_BANNER = "┌─────┐\n│ ⚕ Hermes │\n└─────┘"
class _FakeStream:
"""Minimal text stream backed by an in-memory byte buffer with a codec.
Mirrors how CPython binds sys.stdout to the locale encoding: writes that
can't be encoded raise UnicodeEncodeError, just like a real latin-1 TTY.
"""
def __init__(self, encoding, *, supports_reconfigure=True):
self.encoding = encoding
self._supports_reconfigure = supports_reconfigure
self.errors = "strict"
self._buf = io.BytesIO()
def write(self, s):
self._buf.write(s.encode(self.encoding, self.errors))
return len(s)
def flush(self):
pass
def reconfigure(self, *, encoding=None, errors=None):
if not self._supports_reconfigure:
raise AttributeError("reconfigure")
if encoding is not None:
self.encoding = encoding
if errors is not None:
self.errors = errors
def getvalue(self):
return self._buf.getvalue()
def _run_with_streams(monkeypatch, out, err):
monkeypatch.setattr(sys, "stdout", out, raising=False)
monkeypatch.setattr(sys, "stderr", err, raising=False)
hermes_cli._ensure_utf8()
def test_latin1_stdout_is_repaired_to_utf8(monkeypatch):
"""A latin-1 stdout (the Raspberry Pi case) becomes UTF-8 capable."""
out = _FakeStream("latin-1")
err = _FakeStream("latin-1")
# Sanity: before the fix, the banner cannot be encoded.
try:
out.write(_BANNER)
pre_fix_crashes = False
except UnicodeEncodeError:
pre_fix_crashes = True
assert pre_fix_crashes, "fixture should reproduce the original crash"
out = _FakeStream("latin-1")
err = _FakeStream("latin-1")
_run_with_streams(monkeypatch, out, err)
assert sys.stdout.encoding.lower().replace("-", "") == "utf8"
assert sys.stderr.encoding.lower().replace("-", "") == "utf8"
# The banner now encodes without raising.
sys.stdout.write(_BANNER)
assert "".encode("utf-8") in sys.stdout.getvalue()
def test_ascii_posix_locale_is_repaired(monkeypatch):
"""C/POSIX locale resolves to ascii stdout — also must be repaired."""
out = _FakeStream("ascii")
err = _FakeStream("ascii")
_run_with_streams(monkeypatch, out, err)
assert sys.stdout.encoding.lower().replace("-", "") == "utf8"
sys.stdout.write(_BANNER) # no raise
def test_utf8_stream_left_untouched(monkeypatch):
"""Already-UTF-8 streams are a no-op: object identity preserved AND the
process environment is left untouched (no PYTHONUTF8/PYTHONIOENCODING
burned in on a healthy UTF-8 host)."""
out = _FakeStream("utf-8")
err = _FakeStream("utf-8")
sentinel_out, sentinel_err = out, err
monkeypatch.delenv("PYTHONUTF8", raising=False)
monkeypatch.delenv("PYTHONIOENCODING", raising=False)
_run_with_streams(monkeypatch, out, err)
assert sys.stdout is sentinel_out
assert sys.stderr is sentinel_err
# Healthy UTF-8 host: no environment mutation (minimal footprint).
assert "PYTHONUTF8" not in os.environ
assert "PYTHONIOENCODING" not in os.environ
def test_repair_sets_child_process_env(monkeypatch):
"""When a real repair happens, child-process UTF-8 hints are set."""
monkeypatch.delenv("PYTHONUTF8", raising=False)
monkeypatch.delenv("PYTHONIOENCODING", raising=False)
_run_with_streams(monkeypatch, _FakeStream("latin-1"), _FakeStream("latin-1"))
assert os.environ.get("PYTHONUTF8") == "1"
assert os.environ.get("PYTHONIOENCODING") == "utf-8"
def test_repair_does_not_override_explicit_env(monkeypatch):
"""A user's explicit PYTHONIOENCODING is respected (setdefault, not set)."""
monkeypatch.setenv("PYTHONIOENCODING", "utf-16")
monkeypatch.delenv("PYTHONUTF8", raising=False)
_run_with_streams(monkeypatch, _FakeStream("latin-1"), _FakeStream("latin-1"))
assert os.environ["PYTHONIOENCODING"] == "utf-16"
def test_fallback_when_reconfigure_unavailable(monkeypatch, tmp_path):
"""Streams without reconfigure() fall back to reopening the fd as UTF-8."""
real_path = tmp_path / "out.txt"
fh = open(real_path, "w", encoding="latin-1")
class _NoReconfigure:
"""latin-1 stream exposing a real fileno() but no reconfigure()."""
encoding = "latin-1"
def fileno(self):
return fh.fileno()
stream = _NoReconfigure()
monkeypatch.setattr(sys, "stdout", stream, raising=False)
monkeypatch.setattr(sys, "stderr", stream, raising=False)
hermes_cli._ensure_utf8()
# Replaced with a new UTF-8 stream object (not reconfigured in place).
assert sys.stdout is not stream
assert sys.stdout.encoding.lower().replace("-", "") == "utf8"
sys.stdout.write(_BANNER)
sys.stdout.flush()
fh.close()
assert "".encode("utf-8") in real_path.read_bytes()
def test_broken_stream_does_not_raise(monkeypatch):
"""A stream whose repair raises must be swallowed, never crash import."""
class _Hostile:
encoding = "latin-1"
def reconfigure(self, *a, **k):
raise OSError("nope")
def fileno(self):
raise OSError("no fd")
monkeypatch.setattr(sys, "stdout", _Hostile(), raising=False)
monkeypatch.setattr(sys, "stderr", _Hostile(), raising=False)
# Must not propagate.
hermes_cli._ensure_utf8()
def test_none_streams_do_not_raise(monkeypatch):
"""pythonw / detached streams (sys.stdout is None) must be tolerated."""
monkeypatch.setattr(sys, "stdout", None, raising=False)
monkeypatch.setattr(sys, "stderr", None, raising=False)
hermes_cli._ensure_utf8()
@@ -1,218 +0,0 @@
"""Tests for interrupted-install self-heal (the ``.update-incomplete`` marker).
Covers the breadcrumb lifecycle and the launch-time recovery guard added so a
``hermes update`` killed mid-install (Ctrl-C, terminal close, WSL OOM) gets
finished automatically on the next launch instead of leaving a half-built venv.
"""
from __future__ import annotations
from pathlib import Path
import hermes_cli.main as m
def test_marker_round_trip(tmp_path, monkeypatch):
monkeypatch.setattr(m, "PROJECT_ROOT", tmp_path)
marker = m._update_marker_path()
assert marker == tmp_path / ".update-incomplete"
assert not marker.exists()
m._write_update_incomplete_marker()
assert marker.exists()
body = marker.read_text()
assert "started=" in body
assert "pid=" in body
m._clear_update_incomplete_marker()
assert not marker.exists()
def test_clear_when_absent_is_noop(tmp_path, monkeypatch):
monkeypatch.setattr(m, "PROJECT_ROOT", tmp_path)
# Must not raise when the marker was never written.
m._clear_update_incomplete_marker()
assert not m._update_marker_path().exists()
def test_recovery_noop_without_marker(tmp_path, monkeypatch):
monkeypatch.setattr(m, "PROJECT_ROOT", tmp_path)
called = {"install": False}
monkeypatch.setattr(
m,
"_install_python_dependencies_with_optional_fallback",
lambda *a, **k: called.__setitem__("install", True),
)
m._recover_from_interrupted_install()
assert called["install"] is False, "recovery must not install when no marker"
def test_recovery_clears_stray_marker_without_pyproject(tmp_path, monkeypatch):
# No pyproject.toml (PyPI/Docker install) — a stray marker is not ours to
# act on; recovery should just clear it without trying to install.
monkeypatch.setattr(m, "PROJECT_ROOT", tmp_path)
m._write_update_incomplete_marker()
called = {"install": False}
monkeypatch.setattr(
m,
"_install_python_dependencies_with_optional_fallback",
lambda *a, **k: called.__setitem__("install", True),
)
m._recover_from_interrupted_install()
assert called["install"] is False
assert not m._update_marker_path().exists()
def test_recovery_runs_install_and_clears_marker(tmp_path, monkeypatch):
# Source-tree install (pyproject present) with marker set → recovery should
# run the dep install and clear the marker on success.
monkeypatch.setattr(m, "PROJECT_ROOT", tmp_path)
(tmp_path / "pyproject.toml").write_text("[project]\nname='x'\n")
m._write_update_incomplete_marker()
seen = {"ensurepip": False, "install": False}
def fake_run(cmd, *a, **k):
if "ensurepip" in cmd:
seen["ensurepip"] = True
class R:
returncode = 0
return R()
monkeypatch.setattr(m.subprocess, "run", fake_run)
monkeypatch.setattr(m, "_is_termux_env", lambda *a, **k: False)
monkeypatch.setattr("hermes_cli.managed_uv.ensure_uv", lambda: None)
monkeypatch.setattr(
m,
"_install_python_dependencies_with_optional_fallback",
lambda *a, **k: seen.__setitem__("install", True),
)
m._recover_from_interrupted_install()
assert seen["ensurepip"] is True, "ensurepip must run unconditionally first"
assert seen["install"] is True, "dep install must run"
assert not m._update_marker_path().exists(), "marker cleared on success"
def test_recovery_keeps_marker_on_failure(tmp_path, monkeypatch):
# If the install itself blows up, the marker must survive so the next
# launch retries — and recovery must not raise.
monkeypatch.setattr(m, "PROJECT_ROOT", tmp_path)
(tmp_path / "pyproject.toml").write_text("[project]\nname='x'\n")
m._write_update_incomplete_marker()
class R:
returncode = 0
monkeypatch.setattr(m.subprocess, "run", lambda *a, **k: R())
monkeypatch.setattr(m, "_is_termux_env", lambda *a, **k: False)
monkeypatch.setattr("hermes_cli.managed_uv.ensure_uv", lambda: None)
def boom(*a, **k):
raise RuntimeError("install died")
monkeypatch.setattr(
m, "_install_python_dependencies_with_optional_fallback", boom
)
# Must not raise.
m._recover_from_interrupted_install()
assert m._update_marker_path().exists(), "marker preserved for retry on failure"
def _stub_install_env(monkeypatch, m, seen):
"""Common stubs so recovery's install path is inert and observable."""
class R:
returncode = 0
monkeypatch.setattr(m.subprocess, "run", lambda *a, **k: R())
monkeypatch.setattr(m, "_is_termux_env", lambda *a, **k: False)
monkeypatch.setattr("hermes_cli.managed_uv.ensure_uv", lambda: None)
monkeypatch.setattr(
m,
"_install_python_dependencies_with_optional_fallback",
lambda *a, **k: seen.__setitem__("install", True),
)
def test_recovery_skips_when_lock_held(tmp_path, monkeypatch):
# Another process is mid-recovery (fresh lockfile) — this launch must skip
# the install entirely and leave both marker and lock untouched.
monkeypatch.setattr(m, "PROJECT_ROOT", tmp_path)
(tmp_path / "pyproject.toml").write_text("[project]\nname='x'\n")
m._write_update_incomplete_marker()
lock = tmp_path / ".update-incomplete.lock"
lock.write_text("12345\n")
seen = {"install": False}
_stub_install_env(monkeypatch, m, seen)
m._recover_from_interrupted_install()
assert seen["install"] is False, "must not install while another holds the lock"
assert m._update_marker_path().exists(), "marker left for the lock holder"
assert lock.exists(), "fresh lock must not be broken"
def test_recovery_breaks_stale_lock(tmp_path, monkeypatch):
# A lock older than an hour is from a crashed holder — it gets removed so
# the NEXT launch can recover (this launch still skips).
import os as _os
monkeypatch.setattr(m, "PROJECT_ROOT", tmp_path)
(tmp_path / "pyproject.toml").write_text("[project]\nname='x'\n")
m._write_update_incomplete_marker()
lock = tmp_path / ".update-incomplete.lock"
lock.write_text("12345\n")
stale = m._time.time() - 7200
_os.utime(lock, (stale, stale))
seen = {"install": False}
_stub_install_env(monkeypatch, m, seen)
m._recover_from_interrupted_install()
assert not lock.exists(), "stale lock must be broken"
assert m._update_marker_path().exists()
# Next launch proceeds normally.
m._recover_from_interrupted_install()
assert seen["install"] is True
assert not m._update_marker_path().exists()
assert not lock.exists(), "lock released after recovery"
def test_recovery_releases_lock_after_run(tmp_path, monkeypatch):
monkeypatch.setattr(m, "PROJECT_ROOT", tmp_path)
(tmp_path / "pyproject.toml").write_text("[project]\nname='x'\n")
m._write_update_incomplete_marker()
seen = {"install": False}
_stub_install_env(monkeypatch, m, seen)
m._recover_from_interrupted_install()
assert seen["install"] is True
assert not (tmp_path / ".update-incomplete.lock").exists()
def test_recovery_output_goes_to_stderr(tmp_path, monkeypatch, capfd):
# ACP speaks JSON-RPC on stdout — recovery output (including the streamed
# install, which inherits fd 1) must land on stderr only.
monkeypatch.setattr(m, "PROJECT_ROOT", tmp_path)
(tmp_path / "pyproject.toml").write_text("[project]\nname='x'\n")
m._write_update_incomplete_marker()
seen = {"install": False}
_stub_install_env(monkeypatch, m, seen)
m._recover_from_interrupted_install()
out, err = capfd.readouterr()
assert "interrupted mid-install" not in out
assert "interrupted mid-install" in err
assert "recovered" in err
+188
View File
@@ -0,0 +1,188 @@
import base64
from pathlib import Path
import pytest
from hermes_cli import web_server
pytest.importorskip("starlette.testclient")
from starlette.testclient import TestClient
@pytest.fixture
def client(monkeypatch):
previous_auth_required = getattr(web_server.app.state, "auth_required", None)
web_server.app.state.auth_required = False
test_client = TestClient(web_server.app)
test_client.headers[web_server._SESSION_HEADER_NAME] = web_server._SESSION_TOKEN
try:
yield test_client
finally:
if previous_auth_required is None:
try:
delattr(web_server.app.state, "auth_required")
except AttributeError:
pass
else:
web_server.app.state.auth_required = previous_auth_required
def test_fs_list_sorts_and_hides_noise(client, tmp_path):
root = tmp_path / "project"
root.mkdir()
(root / "b.txt").write_text("b")
(root / "a_dir").mkdir()
(root / "a.txt").write_text("a")
(root / "node_modules").mkdir()
(root / ".git").mkdir()
response = client.get("/api/fs/list", params={"path": str(root)})
assert response.status_code == 200
entries = response.json()["entries"]
assert [entry["name"] for entry in entries] == ["a_dir", "a.txt", "b.txt"]
assert entries[0] == {"name": "a_dir", "path": str(root / "a_dir"), "isDirectory": True}
assert all(entry["name"] not in {".git", "node_modules"} for entry in entries)
def test_fs_list_accepts_relative_paths(client, tmp_path, monkeypatch):
monkeypatch.chdir(tmp_path)
(tmp_path / "rel").mkdir()
(tmp_path / "rel" / "file.txt").write_text("ok")
response = client.get("/api/fs/list", params={"path": "rel"})
assert response.status_code == 200
assert response.json()["entries"] == [
{"name": "file.txt", "path": str(tmp_path / "rel" / "file.txt"), "isDirectory": False}
]
def test_fs_list_missing_path_returns_structured_error(client, tmp_path):
response = client.get("/api/fs/list", params={"path": str(tmp_path / "missing")})
assert response.status_code == 200
assert response.json() == {"entries": [], "error": "ENOENT"}
def test_fs_read_text_matches_preview_shape_and_truncates(client, tmp_path, monkeypatch):
monkeypatch.setattr(web_server, "_FS_TEXT_SOURCE_MAX_BYTES", 32)
monkeypatch.setattr(web_server, "_FS_TEXT_PREVIEW_MAX_BYTES", 5)
target = tmp_path / "sample.py"
target.write_text("print('hello')")
response = client.get("/api/fs/read-text", params={"path": str(target)})
assert response.status_code == 200
assert response.json() == {
"binary": False,
"byteSize": 14,
"language": "python",
"mimeType": "text/x-python",
"path": str(target),
"text": "print",
"truncated": True,
}
def test_fs_read_text_rejects_source_over_cap(client, tmp_path, monkeypatch):
monkeypatch.setattr(web_server, "_FS_TEXT_SOURCE_MAX_BYTES", 4)
target = tmp_path / "large.txt"
target.write_text("12345")
response = client.get("/api/fs/read-text", params={"path": str(target)})
assert response.status_code == 413
def test_fs_read_text_flags_binary(client, tmp_path):
target = tmp_path / "blob.bin"
target.write_bytes(b"hello\x00world")
response = client.get("/api/fs/read-text", params={"path": str(target)})
assert response.status_code == 200
body = response.json()
assert body["binary"] is True
assert body["text"].startswith("hello")
def test_fs_read_data_url_returns_capped_data_url(client, tmp_path, monkeypatch):
monkeypatch.setattr(web_server, "_FS_DATA_URL_MAX_BYTES", 16)
target = tmp_path / "image.png"
target.write_bytes(b"pngbytes")
response = client.get("/api/fs/read-data-url", params={"path": str(target)})
assert response.status_code == 200
assert response.json() == {"dataUrl": "data:image/png;base64," + base64.b64encode(b"pngbytes").decode("ascii")}
def test_fs_read_data_url_rejects_over_cap(client, tmp_path, monkeypatch):
monkeypatch.setattr(web_server, "_FS_DATA_URL_MAX_BYTES", 3)
target = tmp_path / "image.png"
target.write_bytes(b"1234")
response = client.get("/api/fs/read-data-url", params={"path": str(target)})
assert response.status_code == 413
def test_fs_git_root_for_nested_file(client, tmp_path):
(tmp_path / ".git").mkdir()
nested = tmp_path / "pkg" / "mod"
nested.mkdir(parents=True)
target = nested / "file.py"
target.write_text("x")
response = client.get("/api/fs/git-root", params={"path": str(target)})
assert response.status_code == 200
assert response.json() == {"root": str(tmp_path)}
def test_fs_git_root_returns_null_outside_repo(client, tmp_path):
response = client.get("/api/fs/git-root", params={"path": str(tmp_path)})
assert response.status_code == 200
assert response.json() == {"root": None}
def test_fs_default_cwd_prefers_existing_terminal_cwd(client, tmp_path, monkeypatch):
monkeypatch.setattr(web_server, "load_config", lambda: {"terminal": {"cwd": str(tmp_path)}})
monkeypatch.setenv("TERMINAL_CWD", str(tmp_path / "env"))
monkeypatch.setattr(web_server.Path, "cwd", lambda: tmp_path / "process")
monkeypatch.setattr(web_server, "_fs_git_branch", lambda cwd: "main")
response = client.get("/api/fs/default-cwd")
assert response.status_code == 200
assert response.json() == {"cwd": str(tmp_path), "branch": "main"}
def test_fs_default_cwd_falls_back_when_terminal_cwd_is_invalid(client, tmp_path, monkeypatch):
fallback = tmp_path / "backend"
fallback.mkdir()
monkeypatch.setattr(web_server, "load_config", lambda: {"terminal": {"cwd": "/client/missing"}})
monkeypatch.setenv("TERMINAL_CWD", "/client/missing")
monkeypatch.setattr(web_server.Path, "cwd", lambda: fallback)
monkeypatch.setattr(web_server, "_fs_git_branch", lambda cwd: "")
response = client.get("/api/fs/default-cwd")
assert response.status_code == 200
assert response.json() == {"cwd": str(fallback), "branch": ""}
def test_fs_endpoints_require_auth(tmp_path):
client = TestClient(web_server.app)
target = tmp_path / "secret.txt"
target.write_text("secret")
list_response = client.get("/api/fs/list", params={"path": str(tmp_path)})
read_response = client.get("/api/fs/read-text", params={"path": str(target)})
default_response = client.get("/api/fs/default-cwd")
assert list_response.status_code == 401
assert read_response.status_code == 401
assert default_response.status_code == 401
-115
View File
@@ -291,121 +291,6 @@ class TestOpenRouterProfile:
assert eb["reasoning"] == {"enabled": True, "effort": "high"}
assert tl["extra_headers"]["x-grok-conv-id"] == "sess-123"
# --- reasoning-mandatory Anthropic effort → top-level verbosity (#43432) ---
#
# These models (Claude 4.6+ / fable / mythos-class) ignore
# ``reasoning.effort`` and use adaptive thinking. OpenRouter honors the
# requested effort on the top-level ``verbosity`` field instead (maps to
# Anthropic ``output_config.effort``). The profile must route the existing
# ``reasoning_config["effort"]`` there while still NEVER emitting a
# ``reasoning`` field (which would 400 — see #42991). Gate every fixture on
# the real predicate so this stays a behavior contract, not a name snapshot.
@staticmethod
def _is_mandatory(model):
import inspect
p = get_provider_profile("openrouter")
mod = inspect.getmodule(type(p))
return mod._anthropic_reasoning_is_mandatory(model)
def test_mandatory_anthropic_effort_routes_to_verbosity(self):
"""effort set + reasoning enabled → top-level verbosity == effort,
and NO reasoning field in extra_body.
Covers the full real config range produced by
``hermes_constants.parse_reasoning_effort``
``VALID_REASONING_EFFORTS = (minimal, low, medium, high, xhigh)``.
"""
p = get_provider_profile("openrouter")
model = "anthropic/claude-fable-5"
assert self._is_mandatory(model) # fixture really is mandatory
for effort in ("minimal", "low", "medium", "high", "xhigh"):
eb, tl = p.build_api_kwargs_extras(
reasoning_config={"enabled": True, "effort": effort},
supports_reasoning=True,
model=model,
)
assert tl["verbosity"] == effort, (effort, tl)
assert "reasoning" not in eb, (effort, eb)
def test_mandatory_anthropic_effort_without_enabled_key_routes(self):
"""effort present without an explicit ``enabled`` key still routes to
verbosity (enabled defaults to True)."""
p = get_provider_profile("openrouter")
eb, tl = p.build_api_kwargs_extras(
reasoning_config={"effort": "xhigh"},
supports_reasoning=True,
model="anthropic/claude-fable-5",
)
assert tl["verbosity"] == "xhigh"
assert "reasoning" not in eb
def test_mandatory_anthropic_verbosity_is_value_agnostic_passthrough(self):
"""The mapping passes the effort value through verbatim — it must NOT
clamp or whitelist. ``xhigh`` is a real config value; ``max`` is not
producible by ``parse_reasoning_effort`` today but OpenRouter accepts it
for Claude (live-proven in #43432), so a forward value must survive
rather than be silently dropped. The OpenAI SDK type only literals
``low|medium|high`` but it's a TypedDict (no runtime validation), so the
extended scale reaches the wire untouched."""
p = get_provider_profile("openrouter")
for effort in ("xhigh", "max"):
_, tl = p.build_api_kwargs_extras(
reasoning_config={"enabled": True, "effort": effort},
supports_reasoning=True,
model="anthropic/claude-fable-5",
)
assert tl["verbosity"] == effort
def test_mandatory_anthropic_no_verbosity_when_effort_absent(self):
"""No effort / none / disabled → no verbosity emitted, so the model
keeps its own adaptive default. Still no reasoning field."""
p = get_provider_profile("openrouter")
model = "anthropic/claude-fable-5"
for cfg in (
None,
{},
{"enabled": True},
{"effort": "none"},
{"enabled": True, "effort": "none"},
{"enabled": False, "effort": "high"}, # explicitly disabled wins
):
eb, tl = p.build_api_kwargs_extras(
reasoning_config=cfg,
supports_reasoning=True,
model=model,
)
assert "verbosity" not in tl, (cfg, tl)
assert "reasoning" not in eb, (cfg, eb)
def test_non_mandatory_reasoning_model_unchanged_no_verbosity(self):
"""Non-mandatory reasoning models (DeepSeek, Qwen, GPT) keep getting
``reasoning`` in extra_body and never get a ``verbosity`` field the
new path must not touch them."""
p = get_provider_profile("openrouter")
for model in ("deepseek/deepseek-chat", "qwen/qwen3-max", "openai/gpt-5.4"):
assert not self._is_mandatory(model) # fixture really is non-mandatory
eb, tl = p.build_api_kwargs_extras(
reasoning_config={"enabled": True, "effort": "high"},
supports_reasoning=True,
model=model,
)
assert eb["reasoning"] == {"enabled": True, "effort": "high"}, (model, eb)
assert "verbosity" not in tl, (model, tl)
def test_mandatory_anthropic_verbosity_coexists_with_grok_header(self):
"""A reasoning-mandatory Anthropic model is never a Grok model, but the
top-level dict must remain a single merged dict verify the verbosity
path doesn't clobber the extra_headers slot used by Grok affinity."""
p = get_provider_profile("openrouter")
# mandatory anthropic + effort → verbosity, no extra_headers
_, tl = p.build_api_kwargs_extras(
reasoning_config={"enabled": True, "effort": "high"},
supports_reasoning=True,
model="anthropic/claude-fable-5",
)
assert tl == {"verbosity": "high"}
class TestNousProfile:
def test_tags(self):
@@ -172,31 +172,6 @@ def test_dockerfile_preinstalls_gateway_messaging_dependencies(dockerfile_text):
)
def test_dockerfile_preinstalls_matrix_dependencies(dockerfile_text):
sync_steps = [
step for step in _run_steps(dockerfile_text)
if "uv sync" in step and "--no-install-project" in step
]
assert sync_steps, "Dockerfile must install Python dependencies with uv sync"
assert any("--extra matrix" in step for step in sync_steps), (
"Published Docker images must preload the [matrix] extra so the "
"Matrix gateway has mautrix[encryption]/python-olm available at "
"runtime instead of relying on first-boot lazy installation into "
"the container venv (#30399)."
)
def test_dockerfile_installs_matrix_native_build_dependencies(dockerfile_text):
instructions = _instruction_text(dockerfile_text)
for package in ("libolm-dev", "cmake", "g++", "make"):
assert package in instructions, (
"Docker image must include native build dependencies needed by "
f"python-olm when preinstalling the [matrix] extra (#30399): {package}"
)
def test_dockerfile_preinstalls_hindsight_memory_dependency(dockerfile_text):
sync_steps = [
step for step in _run_steps(dockerfile_text)
+2 -9
View File
@@ -321,19 +321,12 @@ class TestStdioPgroupReaping:
psutil = pytest.importorskip("psutil")
# Grandchild: sleep forever, write its pid then wait. The pid file
# is written to a temp path and os.replace()d into place so the
# polling reader below can never observe a created-but-empty file
# (CI flake: int('') ValueError when the reader won the race between
# open('w') creating the file and write() filling it).
# Grandchild: sleep forever, write its pid then wait.
grandchild_pid_file = tmp_path / "grandchild.pid"
grandchild_script = tmp_path / "grandchild.py"
grandchild_script.write_text(
"import os, sys, time\n"
f"tmp = {str(grandchild_pid_file)!r} + '.tmp'\n"
"with open(tmp, 'w') as f:\n"
" f.write(str(os.getpid()))\n"
f"os.replace(tmp, {str(grandchild_pid_file)!r})\n"
f"open({str(grandchild_pid_file)!r}, 'w').write(str(os.getpid()))\n"
"while True:\n"
" time.sleep(0.5)\n"
)
-164
View File
@@ -2,7 +2,6 @@
import base64
import struct
from types import SimpleNamespace
from unittest.mock import MagicMock, patch
import pytest
@@ -256,169 +255,6 @@ class TestGenerateGeminiTts:
assert mock_post.call_args[0][0].startswith("https://custom-gemini.example.com/v1beta/")
def test_persona_prompt_file_appends_labeled_transcript(
self, tmp_path, monkeypatch, mock_gemini_response
):
from tools.tts_tool import _generate_gemini_tts
persona_file = tmp_path / "voice-persona.md"
persona_file.write_text(
"# AUDIO PROFILE: Dry Butler\n\n### DIRECTOR'S NOTES\nStyle: Understated.",
encoding="utf-8",
)
config = {"gemini": {"persona_prompt_file": str(persona_file)}}
monkeypatch.setenv("GEMINI_API_KEY", "test-key")
with patch("requests.post", return_value=mock_gemini_response) as mock_post:
_generate_gemini_tts("Hi", str(tmp_path / "test.wav"), config)
prompt_text = mock_post.call_args[1]["json"]["contents"][0]["parts"][0]["text"]
assert "Synthesize speech from the TRANSCRIPT only" in prompt_text
assert "# AUDIO PROFILE: Dry Butler" in prompt_text
assert "### DIRECTOR'S NOTES\nStyle: Understated." in prompt_text
assert "#### TRANSCRIPT\nHi" in prompt_text
def test_persona_prompt_file_supports_transcript_placeholder(
self, tmp_path, monkeypatch, mock_gemini_response
):
from tools.tts_tool import _generate_gemini_tts
persona_file = tmp_path / "voice-persona.md"
persona_file.write_text(
"### DIRECTOR'S NOTES\nPacing: Slow.\n\n#### TRANSCRIPT\n{{ transcript }}",
encoding="utf-8",
)
config = {"gemini": {"persona_prompt_file": str(persona_file)}}
monkeypatch.setenv("GEMINI_API_KEY", "test-key")
with patch("requests.post", return_value=mock_gemini_response) as mock_post:
_generate_gemini_tts("Read this.", str(tmp_path / "test.wav"), config)
prompt_text = mock_post.call_args[1]["json"]["contents"][0]["parts"][0]["text"]
assert "{{ transcript }}" not in prompt_text
assert "#### TRANSCRIPT\nRead this." in prompt_text
def test_missing_persona_prompt_file_warns_and_continues(
self, tmp_path, monkeypatch, caplog, mock_gemini_response
):
from tools.tts_tool import _generate_gemini_tts
config = {"gemini": {"persona_prompt_file": str(tmp_path / "missing.md")}}
monkeypatch.setenv("GEMINI_API_KEY", "test-key")
with patch("requests.post", return_value=mock_gemini_response) as mock_post:
_generate_gemini_tts("Hi", str(tmp_path / "test.wav"), config)
prompt_text = mock_post.call_args[1]["json"]["contents"][0]["parts"][0]["text"]
assert prompt_text == "Hi"
assert "persona prompt file unavailable" in caplog.text
def test_audio_tags_disabled_does_not_call_rewriter(
self, tmp_path, monkeypatch, mock_gemini_response
):
from tools.tts_tool import _generate_gemini_tts
config = {
"gemini": {
"model": "gemini-3.1-flash-tts-preview",
"audio_tags": False,
}
}
monkeypatch.setenv("GEMINI_API_KEY", "test-key")
with patch("agent.auxiliary_client.call_llm") as mock_call_llm, \
patch("requests.post", return_value=mock_gemini_response) as mock_post:
_generate_gemini_tts("Hi there.", str(tmp_path / "test.wav"), config)
mock_call_llm.assert_not_called()
prompt_text = mock_post.call_args[1]["json"]["contents"][0]["parts"][0]["text"]
assert prompt_text == "Hi there."
def test_audio_tags_enabled_rewrites_hidden_tts_script(
self, tmp_path, monkeypatch, mock_gemini_response
):
from tools.tts_tool import _generate_gemini_tts
persona_file = tmp_path / "voice-persona.md"
persona_file.write_text(
"### DIRECTOR'S NOTES\nStyle: Warm and amused.",
encoding="utf-8",
)
response = SimpleNamespace(
choices=[
SimpleNamespace(
message=SimpleNamespace(content="[warmly] Hi there. [soft laugh]")
)
]
)
config = {
"gemini": {
"model": "gemini-3.1-flash-tts-preview",
"audio_tags": True,
"persona_prompt_file": str(persona_file),
}
}
monkeypatch.setenv("GEMINI_API_KEY", "test-key")
with patch("agent.auxiliary_client.call_llm", return_value=response) as mock_call_llm, \
patch("requests.post", return_value=mock_gemini_response) as mock_post:
_generate_gemini_tts("Hi there.", str(tmp_path / "test.wav"), config)
mock_call_llm.assert_called_once()
call_kwargs = mock_call_llm.call_args.kwargs
assert call_kwargs["task"] == "tts_audio_tags"
assert "Audio tags are inline square-bracket modifiers" in call_kwargs["messages"][0]["content"]
assert "Style: Warm and amused." in call_kwargs["messages"][1]["content"]
assert "Hi there." in call_kwargs["messages"][1]["content"]
prompt_text = mock_post.call_args[1]["json"]["contents"][0]["parts"][0]["text"]
assert "Synthesize speech from the TRANSCRIPT only" in prompt_text
assert "### DIRECTOR'S NOTES\nStyle: Warm and amused." in prompt_text
assert "#### TRANSCRIPT\n[warmly] Hi there. [soft laugh]" in prompt_text
def test_audio_tags_enabled_skips_non_tag_capable_model(
self, tmp_path, monkeypatch, mock_gemini_response, caplog
):
from tools.tts_tool import _generate_gemini_tts
config = {
"gemini": {
"model": "gemini-2.5-flash-preview-tts",
"audio_tags": True,
}
}
monkeypatch.setenv("GEMINI_API_KEY", "test-key")
with patch("agent.auxiliary_client.call_llm") as mock_call_llm, \
patch("requests.post", return_value=mock_gemini_response) as mock_post:
_generate_gemini_tts("Hi there.", str(tmp_path / "test.wav"), config)
mock_call_llm.assert_not_called()
prompt_text = mock_post.call_args[1]["json"]["contents"][0]["parts"][0]["text"]
assert prompt_text == "Hi there."
assert "not known to support Gemini audio tags" in caplog.text
def test_audio_tag_rewrite_failure_falls_back_to_original_text(
self, tmp_path, monkeypatch, mock_gemini_response, caplog
):
from tools.tts_tool import _generate_gemini_tts
config = {
"gemini": {
"model": "gemini-3.1-flash-tts-preview",
"audio_tags": True,
}
}
monkeypatch.setenv("GEMINI_API_KEY", "test-key")
with patch("agent.auxiliary_client.call_llm", side_effect=RuntimeError("boom")), \
patch("requests.post", return_value=mock_gemini_response) as mock_post:
_generate_gemini_tts("Hi there.", str(tmp_path / "test.wav"), config)
prompt_text = mock_post.call_args[1]["json"]["contents"][0]["parts"][0]["text"]
assert prompt_text == "Hi there."
assert "audio tag rewrite failed" in caplog.text
class TestGeminiInCheckRequirements:
def test_gemini_api_key_satisfies_requirements(self, monkeypatch):
-114
View File
@@ -268,117 +268,3 @@ def test_handle_unknown_subcommand_returns_none(hermes_home):
# the CLI falls through to the skills hub.
out = handle_pending_subcommand(wa.SKILLS, ["search", "foo"])
assert out is None
# ---------------------------------------------------------------------------
# Inline (interactive CLI) approval path — regression for the bug where the
# per-thread approval callback was never passed to prompt_dangerous_approval,
# so every gated foreground memory write was silently denied.
# ---------------------------------------------------------------------------
@pytest.fixture
def approval_callback_cleanup():
yield
from tools.terminal_tool import set_approval_callback
set_approval_callback(None)
def test_memory_inline_approve_writes(hermes_home, approval_callback_cleanup):
from tools.memory_tool import memory_tool, MemoryStore
from tools.terminal_tool import set_approval_callback
from tools import write_approval as wa
_set_approval("memory", True)
calls = []
def approve_cb(command, description, **kw):
calls.append((command, description))
return "once"
set_approval_callback(approve_cb)
store = MemoryStore(); store.load_from_disk()
r = json.loads(memory_tool("add", "memory", "approved fact", store=store))
assert r["success"] is True
assert r.get("staged") is None # real write, not staged
assert store.memory_entries == ["approved fact"]
assert wa.pending_count("memory") == 0
# The registered callback must actually be invoked (not the input() path).
assert len(calls) == 1
assert "approved fact" in calls[0][0]
def test_memory_inline_deny_blocks(hermes_home, approval_callback_cleanup):
from tools.memory_tool import memory_tool, MemoryStore
from tools.terminal_tool import set_approval_callback
from tools import write_approval as wa
_set_approval("memory", True)
set_approval_callback(lambda command, description, **kw: "deny")
store = MemoryStore(); store.load_from_disk()
r = json.loads(memory_tool("add", "memory", "denied fact", store=store))
assert r["success"] is False
assert "denied" in r["error"].lower()
assert store.memory_entries == []
assert wa.pending_count("memory") == 0 # denied, not staged
def test_memory_inline_callback_error_stages(hermes_home, approval_callback_cleanup):
# If the prompt machinery fails, fall back to staging — never drop silently.
from tools.memory_tool import memory_tool, MemoryStore
from tools.terminal_tool import set_approval_callback
from tools import write_approval as wa
_set_approval("memory", True)
def broken_cb(command, description, **kw):
raise RuntimeError("boom")
set_approval_callback(broken_cb)
store = MemoryStore(); store.load_from_disk()
r = json.loads(memory_tool("add", "memory", "fallback fact", store=store))
assert r.get("staged") is True
assert wa.pending_count("memory") == 1
def test_gateway_context_stages_not_prompts(hermes_home, monkeypatch):
# A gateway session has no per-thread CLI callback; the dangerous-command
# /approve round-trip lives in the pending-queue machinery which the gate
# does not use. The gate must stage, never attempt an inline prompt
# (which would hit the input() fallback and silently deny).
from tools.memory_tool import memory_tool, MemoryStore
from tools import write_approval as wa
_set_approval("memory", True)
monkeypatch.setenv("HERMES_GATEWAY_SESSION", "1")
store = MemoryStore(); store.load_from_disk()
r = json.loads(memory_tool("add", "memory", "gateway fact", store=store))
assert r.get("staged") is True
assert store.memory_entries == []
assert wa.pending_count("memory") == 1
def test_skills_never_prompt_inline_even_with_callback(hermes_home, approval_callback_cleanup):
# Skills always stage — even when an interactive callback is registered.
from tools.skill_manager_tool import skill_manage
from tools.terminal_tool import set_approval_callback
from tools import write_approval as wa
_set_approval("skills", True)
calls = []
set_approval_callback(lambda c, d, **kw: calls.append(1) or "once")
r = json.loads(skill_manage(
action="create", name="test-inline-skill",
content="---\nname: test-inline-skill\ndescription: x\n---\nbody\n"))
assert r.get("staged") is True
assert calls == [] # never prompted
assert wa.pending_count("skills") == 1
def test_memory_invalid_params_rejected_before_staging(hermes_home):
# Param validation must run BEFORE the gate so a broken write is rejected
# immediately instead of staged and failing at approve time.
from tools.memory_tool import memory_tool, MemoryStore
from tools import write_approval as wa
_set_approval("memory", True)
store = MemoryStore(); store.load_from_disk()
r = json.loads(memory_tool("add", "memory", None, store=store))
assert r["success"] is False
assert wa.pending_count("memory") == 0
+10 -12
View File
@@ -681,29 +681,27 @@ def memory_tool(
if target not in {"memory", "user"}:
return tool_error(f"Invalid target '{target}'. Use 'memory' or 'user'.", success=False)
# Validate required params BEFORE the gate so an invalid write is rejected
# immediately instead of being staged and only failing at approve time.
if action == "add" and not content:
return tool_error("Content is required for 'add' action.", success=False)
if action == "replace" and (not old_text or not content):
missing = "old_text" if not old_text else "content"
return tool_error(f"{missing} is required for 'replace' action.", success=False)
if action == "remove" and not old_text:
return tool_error("old_text is required for 'remove' action.", success=False)
# Approval gate: when on, stages the write (background/gateway) or prompts
# inline (interactive CLI); when off (default) passes straight through.
# Approval gate: when on, stages the write (background) or prompts inline
# (foreground); when off (default) passes straight through.
gate_result = _apply_write_gate(action, target, content, old_text)
if gate_result is not None:
return gate_result
if action == "add":
if not content:
return tool_error("Content is required for 'add' action.", success=False)
result = store.add(target, content)
elif action == "replace":
if not old_text:
return tool_error("old_text is required for 'replace' action.", success=False)
if not content:
return tool_error("content is required for 'replace' action.", success=False)
result = store.replace(target, old_text, content)
elif action == "remove":
if not old_text:
return tool_error("old_text is required for 'remove' action.", success=False)
result = store.remove(target, old_text)
else:
+19 -196
View File
@@ -190,8 +190,6 @@ DEFAULT_XAI_BASE_URL = "https://api.x.ai/v1"
DEFAULT_GEMINI_TTS_MODEL = "gemini-2.5-flash-preview-tts"
DEFAULT_GEMINI_TTS_VOICE = "Kore"
DEFAULT_GEMINI_TTS_BASE_URL = "https://generativelanguage.googleapis.com/v1beta"
DEFAULT_GEMINI_AUDIO_TAGS = False
GEMINI_AUDIO_TAG_REWRITE_TASK = "tts_audio_tags"
# PCM output specs for Gemini TTS (fixed by the API)
GEMINI_TTS_SAMPLE_RATE = 24000
GEMINI_TTS_CHANNELS = 1
@@ -206,8 +204,8 @@ DEFAULT_OUTPUT_DIR = _get_default_output_dir()
# ---------------------------------------------------------------------------
# Per-provider input-character limits (from official provider docs).
# A single global cap was wrong: OpenAI is 4096, xAI is 15k, MiniMax is 10k,
# ElevenLabs is model-dependent (5k / 10k / 30k / 40k), Gemini has a 32k-token
# context window. Users can override any of these via
# ElevenLabs is model-dependent (5k / 10k / 30k / 40k), Gemini caps at ~8k
# input tokens. Users can override any of these via
# ``tts.<provider>.max_text_length`` in config.yaml.
# ---------------------------------------------------------------------------
PROVIDER_MAX_TEXT_LENGTH: Dict[str, int] = {
@@ -216,7 +214,7 @@ PROVIDER_MAX_TEXT_LENGTH: Dict[str, int] = {
"xai": 15000, # https://docs.x.ai/developers/model-capabilities/audio/text-to-speech
"minimax": 10000, # https://platform.minimax.io/docs/api-reference/speech-t2a-http (sync)
"mistral": 4000, # conservative; no published per-request cap
"gemini": 32000, # Gemini TTS has a 32k-token context window; char cap is conservative
"gemini": 5000, # Gemini TTS caps at ~8k input tokens / ~655s audio
"elevenlabs": 10000, # fallback when model-aware lookup can't resolve (multilingual_v2)
"neutts": 2000, # local model, quality falls off on long text
"kittentts": 2000, # local 25MB model
@@ -235,23 +233,6 @@ ELEVENLABS_MODEL_MAX_TEXT_LENGTH: Dict[str, int] = {
"eleven_flash_v2_5": 40000,
}
def _config_bool(value: Any, default: bool = False) -> bool:
"""Coerce common YAML/env bool spellings without treating random strings as true."""
if isinstance(value, bool):
return value
if value is None:
return default
if isinstance(value, (int, float)):
return bool(value)
if isinstance(value, str):
normalized = value.strip().lower()
if normalized in {"1", "true", "yes", "on", "enabled"}:
return True
if normalized in {"0", "false", "no", "off", "disabled"}:
return False
return default
# Final fallback when provider isn't recognised at all.
FALLBACK_MAX_TEXT_LENGTH = 4000
@@ -1088,7 +1069,20 @@ _XAI_FIRST_SENTENCE_RE = re.compile(r"^(.{12,120}?[.!?…])\s+(?=\S)", flags=re.
def _xai_bool_config(value: Any, default: bool = False) -> bool:
return _config_bool(value, default=default)
"""Coerce common YAML/env bool spellings without treating random strings as true."""
if isinstance(value, bool):
return value
if value is None:
return default
if isinstance(value, (int, float)):
return bool(value)
if isinstance(value, str):
normalized = value.strip().lower()
if normalized in {"1", "true", "yes", "on", "enabled"}:
return True
if normalized in {"0", "false", "no", "off", "disabled"}:
return False
return default
def _apply_xai_auto_speech_tags(text: str) -> str:
@@ -1400,160 +1394,6 @@ def _wrap_pcm_as_wav(
return riff_header + fmt_chunk + data_chunk_header + pcm_bytes
def _resolve_gemini_persona_prompt_path(gemini_config: Dict[str, Any]) -> Optional[Path]:
"""Return the configured persona prompt file path, if any."""
raw = gemini_config.get("persona_prompt_file")
if not isinstance(raw, str) or not raw.strip():
return None
expanded = os.path.expandvars(raw.strip())
path = Path(expanded).expanduser()
if not path.is_absolute():
try:
from hermes_constants import get_hermes_home
path = get_hermes_home() / path
except Exception:
path = Path.cwd() / path
return path
def _read_gemini_persona_prompt(gemini_config: Dict[str, Any]) -> str:
"""Read the Gemini persona prompt file, failing soft on config mistakes."""
path = _resolve_gemini_persona_prompt_path(gemini_config)
if path is None:
return ""
try:
return path.read_text(encoding="utf-8").strip()
except (OSError, UnicodeDecodeError) as exc:
logger.warning(
"Gemini TTS persona prompt file unavailable at %s: %s",
path,
exc,
)
return ""
def _gemini_model_supports_audio_tags(model: str) -> bool:
"""Return True for Gemini TTS models known to support expressive audio tags."""
normalized = (model or "").strip().lower().rsplit("/", 1)[-1]
return "gemini-3.1" in normalized and "tts" in normalized
def _gemini_audio_tags_enabled(gemini_config: Dict[str, Any], model: str) -> bool:
raw = gemini_config.get("audio_tags")
if isinstance(raw, dict):
raw = raw.get("enabled")
enabled = _config_bool(raw, default=DEFAULT_GEMINI_AUDIO_TAGS)
if not enabled:
return False
if not _gemini_model_supports_audio_tags(model):
logger.warning(
"Gemini TTS audio_tags enabled, but model %s is not known to support "
"Gemini audio tags; skipping hidden tag rewrite",
model,
)
return False
return True
def _clean_gemini_audio_tag_rewrite(content: str) -> str:
clean = (content or "").strip()
fence = re.fullmatch(r"```(?:[A-Za-z0-9_-]+)?\s*(.*?)\s*```", clean, flags=re.DOTALL)
if fence:
clean = fence.group(1).strip()
return clean
def _extract_auxiliary_message_content(response: Any) -> str:
try:
choice = response.choices[0]
message = getattr(choice, "message", None)
if isinstance(message, dict):
return str(message.get("content") or "")
return str(getattr(message, "content", "") or "")
except Exception:
return ""
def _rewrite_gemini_tts_audio_tags(text: str, persona_prompt: str = "") -> str:
"""Use the configured auxiliary model to insert Gemini audio tags."""
transcript = text.strip()
if not transcript:
return text
system_prompt = (
"You rewrite transcripts for Gemini 3.1 Flash TTS by inserting expressive "
"audio tags.\n\n"
"Audio tags are inline square-bracket modifiers such as [whispers], "
"[excitedly], [very slow], [sarcastically], [laughs], [sighs], or [gasp]. "
"There is no fixed allowlist. Use creative freeform tags generously but "
"naturally to control tone, pace, emotional vibe, emphasis, section-level "
"delivery, and non-verbal sounds. Use English audio tags even when the "
"spoken transcript is not English.\n\n"
"Rules:\n"
"- Preserve the spoken words, order, and meaning.\n"
"- Do not add new spoken sentences or remove existing spoken words.\n"
"- Use square brackets for every audio tag.\n"
"- Do not use SSML or XML tags.\n"
"- Do not explain or comment.\n"
"- Return only the tagged TTS script."
)
context = persona_prompt.strip() or "(none)"
user_prompt = (
"PERSONA AND DIRECTOR CONTEXT:\n"
f"{context}\n\n"
"TRANSCRIPT TO TAG:\n"
f"{transcript}"
)
try:
from agent.auxiliary_client import call_llm
response = call_llm(
task=GEMINI_AUDIO_TAG_REWRITE_TASK,
messages=[
{"role": "system", "content": system_prompt},
{"role": "user", "content": user_prompt},
],
temperature=0.7,
)
tagged = _clean_gemini_audio_tag_rewrite(_extract_auxiliary_message_content(response))
return tagged or text
except Exception as exc:
logger.warning("Gemini TTS audio tag rewrite failed; using untagged text: %s", exc)
return text
def _compose_gemini_tts_prompt(
text: str,
gemini_config: Dict[str, Any],
persona_prompt: Optional[str] = None,
) -> str:
"""Build the Gemini prompt from persona direction plus the live transcript."""
transcript = text.strip()
if persona_prompt is None:
persona_prompt = _read_gemini_persona_prompt(gemini_config)
if not persona_prompt:
return transcript
preamble = (
"Synthesize speech from the TRANSCRIPT only. Treat AUDIO PROFILE, "
"SCENE, DIRECTOR'S NOTES, and SAMPLE CONTEXT as performance direction; "
"do not speak those sections aloud."
)
placeholder_patterns = (
re.compile(r"\{\{\s*transcript\s*\}\}", flags=re.IGNORECASE),
re.compile(r"\{\s*transcript\s*\}", flags=re.IGNORECASE),
)
prompt = persona_prompt
for pattern in placeholder_patterns:
if pattern.search(prompt):
prompt = pattern.sub(transcript, prompt)
return f"{preamble}\n\n{prompt}".strip()
return f"{preamble}\n\n{persona_prompt}\n\n#### TRANSCRIPT\n{transcript}".strip()
def _generate_gemini_tts(text: str, output_path: str, tts_config: Dict[str, Any]) -> str:
"""Generate audio using Google Gemini TTS.
@@ -1579,8 +1419,7 @@ def _generate_gemini_tts(text: str, output_path: str, tts_config: Dict[str, Any]
"GEMINI_API_KEY not set. Get one at https://aistudio.google.com/app/apikey"
)
raw_gemini_config = tts_config.get("gemini", {})
gemini_config = raw_gemini_config if isinstance(raw_gemini_config, dict) else {}
gemini_config = tts_config.get("gemini", {})
model = str(gemini_config.get("model", DEFAULT_GEMINI_TTS_MODEL)).strip() or DEFAULT_GEMINI_TTS_MODEL
voice = str(gemini_config.get("voice", DEFAULT_GEMINI_TTS_VOICE)).strip() or DEFAULT_GEMINI_TTS_VOICE
base_url = str(
@@ -1588,25 +1427,9 @@ def _generate_gemini_tts(text: str, output_path: str, tts_config: Dict[str, Any]
or get_env_value("GEMINI_BASE_URL")
or DEFAULT_GEMINI_TTS_BASE_URL
).strip().rstrip("/")
persona_prompt = _read_gemini_persona_prompt(gemini_config)
tts_script = text
if _gemini_audio_tags_enabled(gemini_config, model):
tts_script = _rewrite_gemini_tts_audio_tags(text, persona_prompt=persona_prompt)
prompt_text = _compose_gemini_tts_prompt(
tts_script,
gemini_config,
persona_prompt=persona_prompt,
)
max_len = _resolve_max_text_length("gemini", tts_config)
if len(prompt_text) > max_len:
logger.warning(
"Gemini TTS composed prompt too long (%d chars), truncating to %d",
len(prompt_text), max_len,
)
prompt_text = prompt_text[:max_len]
payload: Dict[str, Any] = {
"contents": [{"parts": [{"text": prompt_text}]}],
"contents": [{"parts": [{"text": text}]}],
"generationConfig": {
"responseModalities": ["AUDIO"],
"speechConfig": {
+46 -58
View File
@@ -15,25 +15,24 @@ Both stores are written from two origins:
turn and autonomously decides what to save (the source of the
"wrong assumptions" users complained about)
This module lets the user gate those writes per-subsystem with a boolean
``write_approval``:
This module lets the user gate those writes per-subsystem with a tri-state
``write_mode``:
* ``false`` (default) write freely (the pre-gate behaviour)
* ``true`` require approval: do not commit the write; either
prompt inline (memory, interactive CLI only) or **stage** it to a pending
store and surface it for the user to approve or reject out-of-band
* ``on`` write freely (current behaviour, default)
* ``off`` never write; the tool returns a clean "disabled" result
* ``approve`` do not commit the write; **stage** it to a pending store and
surface it for the user to approve or reject out-of-band
The size asymmetry between memory and skills is real and unavoidable: a memory
entry can be reviewed inline in a chat bubble; a 100 KB SKILL.md cannot. So
the gate stages BOTH to disk, but review affordances differ by subsystem
``approve`` mode stages BOTH to disk, but review affordances differ by subsystem
(see ``hermes_cli`` slash handlers): memory shows full content, skills show
metadata + a one-line gist + a ``diff`` escape hatch (CLI/dashboard/file).
Staging is mandatory for background-origin writes (a daemon thread cannot
block on an interactive prompt) and for gateway sessions (no inline prompt
channel review happens via ``/memory pending``). Foreground CLI memory
writes prompt inline via the dangerous-command approval callback; skill
writes always stage (too big to eyeball mid-loop).
Staging is mandatory for background-origin writes under ``approve`` (a daemon
thread cannot block on an interactive prompt). Foreground memory writes may
additionally block inline via the dangerous-command approval gate; foreground
skill writes always stage (too big to eyeball mid-loop).
Pending records live under ``<HERMES_HOME>/pending/{memory,skills}/<id>.json``
so they survive process restarts and can be reviewed from CLI, gateway, or the
@@ -231,14 +230,14 @@ class GateDecision:
"""Result of evaluating the write gate for a single write attempt.
Exactly one of the boolean flags is True:
* ``allow`` proceed with the real write (gate off, or an inline
* ``allow`` proceed with the real write (mode ``on``, or an inline
approval was granted).
* ``blocked`` refuse the write (the user denied an inline approval
prompt). ``message`` explains why; surface it to the agent.
* ``blocked`` refuse the write (mode ``off``, or an inline approval was
denied). ``message`` explains why; surface it to the agent.
* ``stage`` do not write; the caller should stage the payload via
``stage_write`` (gate on, and no inline prompt is available gateway,
background review, script, or any skill write). ``message`` is the
user-facing "staged for approval" note.
``stage_write`` (mode ``approve`` for a background write, or a
foreground write with no interactive prompt available). ``message`` is
the user-facing "staged for approval" note.
"""
__slots__ = ("allow", "blocked", "stage", "message")
@@ -262,10 +261,10 @@ def evaluate_gate(subsystem: str, *, inline_summary: str = "",
are small; skills never take the inline path).
Decision matrix:
gate off (default) allow (writes flow freely)
gate on, memory + interactive CLI inline approve/deny prompt
gate on, memory + gateway/script/bg stage
gate on, skills (any origin) stage (too big to review inline)
gate off (default) allow (writes flow freely)
gate on, memory + foreground inline approve/deny prompt
gate on, memory + background stage
gate on, skills (any origin) stage (too big to review inline)
Note: there is no config-driven "blocked" outcome the gate only ever
delays a write for approval, never silently refuses it. ``blocked`` is
@@ -288,10 +287,10 @@ def evaluate_gate(subsystem: str, *, inline_summary: str = "",
),
)
# Memory + foreground: if an interactive approval channel exists (a CLI
# approval callback registered on this thread), prompt inline — entries
# are small enough to show in full. Otherwise (gateway, script, batch,
# no listener) stage instead of forcing a blind deny.
# Memory + foreground: if an interactive approval channel exists (CLI
# prompt_toolkit callback, or a gateway approve/deny round-trip), prompt
# inline — entries are small enough to show in full. Otherwise (script,
# batch, no listener) stage instead of forcing a blind deny.
if _interactive_approval_available():
granted = _prompt_inline_memory_approval(inline_summary, inline_detail)
if granted is True:
@@ -315,21 +314,19 @@ def evaluate_gate(subsystem: str, *, inline_summary: str = "",
def _interactive_approval_available() -> bool:
"""True when a foreground memory write can be approved inline.
Inline prompting requires a per-thread approval callback registered by the
interactive CLI (``tools.terminal_tool.set_approval_callback``). Every
other surface stages instead:
* **Gateway/API sessions** the dangerous-command ``/approve`` round-trip
lives in the pending-approval queue (``submit_pending`` +
``_await_gateway_decision``), which ``prompt_dangerous_approval`` never
reaches; trying to prompt from a gateway session would hit the
``input()`` fallback and silently deny. Staging gives the user a real
review affordance (``/memory pending``) instead.
* Scripts, cron, and background threads no user present.
Either a per-thread approval callback is registered (interactive CLI), or
the call is inside a gateway/API session that supports the /approve //deny
round-trip. Scripts, cron, and background threads have neither stage.
"""
try:
from tools.terminal_tool import _get_approval_callback
return _get_approval_callback() is not None
if _get_approval_callback() is not None:
return True
except Exception:
pass
try:
from tools.approval import _is_gateway_approval_context
return bool(_is_gateway_approval_context())
except Exception:
return False
@@ -338,37 +335,28 @@ def _prompt_inline_memory_approval(summary: str, detail: str) -> Optional[bool]:
"""Prompt the user inline to approve a memory write.
Returns True (approved), False (denied), or None (no interactive prompt
available / prompt failed caller should stage instead).
available on this thread caller should stage instead).
Reuses the per-thread CLI approval callback registered for dangerous
commands (``tools.terminal_tool.set_approval_callback``). The callback is
invoked directly NOT via ``prompt_dangerous_approval`` because that
wrapper falls back to ``input()`` (deadlock-prone under prompt_toolkit,
see #15216) and converts callback errors into a silent deny; here a
failed prompt must stage the write instead.
Reuses the dangerous-command approval machinery so the CLI prompt_toolkit
callback and the gateway ``/approve`` ``/deny`` round-trip both work without
duplicating that plumbing.
"""
try:
from tools.terminal_tool import _get_approval_callback
from tools.approval import prompt_dangerous_approval
except Exception:
return None
callback = _get_approval_callback()
if callback is None:
# No interactive channel on this thread — stage rather than risk the
# input() fallback (deadlock under prompt_toolkit, EOF-deny in tests).
return None
header = summary.strip() or "Save to memory?"
body = detail.strip()
description = f"Save to memory: {header}"
command = body if body else header
# Invoke the callback directly instead of via prompt_dangerous_approval:
# that wrapper swallows callback exceptions into "deny", which would
# silently refuse the write. Direct invocation lets a crashed prompt fall
# back to staging (the gate only ever delays a write, never drops it).
try:
choice = callback(command, description, allow_permanent=False)
except Exception as e:
choice = prompt_dangerous_approval(
command,
description,
allow_permanent=False,
)
except Exception as e: # pragma: no cover
logger.error("Inline memory approval prompt failed: %s", e)
return None
@@ -131,9 +131,8 @@ class AcmeProfile(ProviderProfile):
def build_api_kwargs_extras(self, *, reasoning_config=None, **context):
"""Returns (extra_body_additions, top_level_kwargs). Needed when some
fields go top-level (Kimi's reasoning_effort, OpenRouter's verbosity for
adaptive Anthropic models) and some go in extra_body (OpenRouter's
reasoning dict). Default: ({}, {})."""
fields go top-level (Kimi's reasoning_effort) and some go in extra_body
(OpenRouter's reasoning dict). Default: ({}, {})."""
return {}, {}
def fetch_models(self, *, api_key=None, timeout=8.0) -> list[str] | None:
+1 -23
View File
@@ -835,7 +835,6 @@ $ hermes model
[ ] vision currently: auto / main model
[ ] web_extract currently: auto / main model
[ ] title_generation currently: openrouter / google/gemini-3-flash-preview
[ ] tts_audio_tags currently: auto / main model
[ ] compression currently: auto / main model
[ ] approval currently: auto / main model
[ ] triage_specifier currently: auto / main model
@@ -912,14 +911,6 @@ auxiliary:
api_key: ""
timeout: 30 # seconds
# Gemini 3.1 TTS hidden audio-tag insertion
tts_audio_tags:
provider: "auto"
model: "" # empty = main chat model
base_url: ""
api_key: ""
timeout: 30
# Context compression timeout (separate from compression.* config)
compression:
timeout: 120 # seconds — compression summarizes long conversations, needs more time
@@ -1124,17 +1115,6 @@ agent:
When unset (default), reasoning effort defaults to "medium" — a balanced level that works well for most tasks. Setting a value overrides it — higher reasoning effort gives better results on complex tasks at the cost of more tokens and latency.
:::note Adaptive-thinking models (Claude 4.6+, Fable/Mythos-class) over OpenRouter
These models use *adaptive* thinking and don't accept the usual `reasoning.effort`
field — OpenRouter ignores it for them. Hermes transparently routes your
`reasoning_effort` to OpenRouter's `verbosity` parameter instead (which maps to
Anthropic's `output_config.effort`), so the same `low`/`medium`/`high`/`xhigh`
knob keeps working — no extra configuration needed. `none` (or unset) leaves the
model on its own adaptive default. (`max` is accepted on the wire but is not a
selectable `reasoning_effort` value; `xhigh` is the configurable ceiling.) The
native Anthropic provider already controls effort directly and is unaffected.
:::
You can also change the reasoning effort at runtime with the `/reasoning` command:
```
@@ -1206,10 +1186,8 @@ tts:
model: "voxtral-mini-tts-2603"
voice_id: "c69964a6-ab8b-4f8a-9465-ec0925096ec8" # Paul - Neutral (default)
gemini:
model: "gemini-2.5-flash-preview-tts" # or gemini-3.1-flash-tts-preview
model: "gemini-2.5-flash-preview-tts" # or gemini-2.5-pro-preview-tts
voice: "Kore" # 30 prebuilt voices: Zephyr, Puck, Kore, Enceladus, etc.
audio_tags: false # Hidden Gemini 3.1 TTS audio-tag insertion
persona_prompt_file: "" # Optional Markdown/text file with Gemini voice direction
xai:
voice_id: "eve" # xAI TTS voice
language: "en" # ISO 639-1
+1 -1
View File
@@ -222,7 +222,7 @@ first, set `memory.write_approval: true`. It's a simple on/off gate applied to
| `write_approval` | Behaviour |
|------------------|-----------|
| `false` (default) | Write freely — the gate is off (the pre-gate behaviour). |
| `true` | Require approval before anything is saved. In the interactive CLI, foreground writes prompt you inline (entries are small enough to read in full). Everywhere else — messaging platforms, scripts, and the background self-improvement review writes are **staged** for review with `/memory pending`. |
| `true` | Require approval before anything is saved. Foreground writes prompt you inline (entries are small enough to read in a chat bubble). Background-review writes are **staged** instead of committed (a background thread can't block on a prompt). |
> To turn memory off entirely (not just gate it), set `memory_enabled: false`.
+2 -32
View File
@@ -66,10 +66,8 @@ tts:
model: "voxtral-mini-tts-2603"
voice_id: "c69964a6-ab8b-4f8a-9465-ec0925096ec8" # Paul - Neutral (default)
gemini:
model: "gemini-2.5-flash-preview-tts" # or gemini-3.1-flash-tts-preview
model: "gemini-2.5-flash-preview-tts" # or gemini-2.5-pro-preview-tts
voice: "Kore" # 30 prebuilt voices: Zephyr, Puck, Kore, Enceladus, Gacrux, etc.
audio_tags: false # Enable hidden Gemini 3.1 TTS audio-tag insertion
persona_prompt_file: "" # Optional Markdown/text file with Gemini voice direction
xai:
voice_id: "eve" # or a custom voice ID — see docs below
language: "en" # ISO 639-1 code
@@ -99,34 +97,6 @@ tts:
**Speed control**: The global `tts.speed` value applies to all providers by default. Each provider can override it with its own `speed` setting (e.g., `tts.openai.speed: 1.5`). Provider-specific speed takes precedence over the global value. Default is `1.0` (normal speed).
### Gemini Persona Prompts
Gemini TTS can follow natural-language performance direction. Set `tts.gemini.persona_prompt_file` to a local Markdown or text file that describes the voice persona. The file can include Gemini-style sections such as `AUDIO PROFILE`, `SCENE`, `DIRECTOR'S NOTES`, `SAMPLE CONTEXT`, and `TRANSCRIPT`.
If the file contains `{transcript}` or `{{ transcript }}`, Hermes replaces that placeholder with the live TTS text. Otherwise, Hermes appends a labeled `TRANSCRIPT` section automatically. The persona prompt stays local and is not shown in the chat reply.
```yaml
tts:
provider: gemini
gemini:
voice: Algieba
persona_prompt_file: ~/.hermes/tts/butler-voice.md
```
### Gemini Audio Tags
Gemini 3.1 Flash TTS supports freeform square-bracket audio tags such as `[whispers]`, `[excitedly]`, `[very slow]`, `[laughs]`, and other expressive delivery notes. Enable `tts.gemini.audio_tags` to have Hermes run a hidden rewrite pass before Gemini TTS. The rewrite inserts inline tags into the TTS script only; the visible chat reply stays unchanged.
```yaml
tts:
provider: gemini
gemini:
model: gemini-3.1-flash-tts-preview
audio_tags: true
```
The rewrite uses `auxiliary.tts_audio_tags` and defaults to your main chat model. Override that auxiliary task if you want tag insertion handled by a cheaper or faster model.
### Input length limits
@@ -139,7 +109,7 @@ Each provider has a documented per-request input-character cap. Hermes truncates
| xAI | 15000 |
| MiniMax | 10000 |
| Mistral | 4000 |
| Google Gemini | 32000 |
| Google Gemini | 5000 |
| ElevenLabs | Model-aware (see below) |
| NeuTTS | 2000 |
| KittenTTS | 2000 |
@@ -280,11 +280,6 @@ thread.
Only the first token is checked against the known command list, so
casual messages like `!nice work` pass through to the agent unchanged.
Approval prompts (dangerous command / `execute_code` approval) normally
render as interactive buttons. When buttons can't be delivered and
Hermes falls back to a text prompt, the prompt instructs you to reply
with `!approve` / `!deny` — the form that works inside threads.
### Advanced: emit only the slash-commands array
If you maintain your Slack manifest by hand and just want the slash