Compare commits

..
Author SHA1 Message Date
teknium1 14e3bb1f27 docs(skills): tighten dynamic-workflow per donovan-yohan review
Address all 5 review points against actual delegate_task behavior:
- child toolsets are subject to delegate restrictions (leaf strips
  delegate_task/clarify/memory/send_message/execute_code), not 'full'
- durable work has lighter options than kanban (cron one-shot,
  managed background terminal) for simpler cases
- unique per-run /tmp/wf_<name>_<uuid> dir + freshness/count check so
  a stale interrupted run isn't read as success
- note that one delegate_task batch is capped by
  delegation.max_concurrent_children; large fan-out needs bounded waves
- delegate_task exposes no per-task model/profile field (per-task keys
  are goal/context/toolsets/role); model/profile-scoped runs go via
  delegation config, cron, kanban, or separate process
2026-06-07 23:45:18 -07:00
teknium1 ba936039be feat(skills): add dynamic-workflow orchestration skill
Adapts Claude Code's research-preview dynamic workflows (plan-in-code
fan-out, hundreds of subagents per session) to Hermes invariants.

The ported mechanic is plan/loop/intermediate-state-out-of-context, not
more subagents. Documents the two real orchestration layers and the hard
capability boundary between them:
- Layer A (execute_code): deterministic fan-out, SANDBOX_ALLOWED_TOOLS
  only, cannot call delegate_task
- Layer B (delegate_task batch): LLM-judgment fan-out

Plus the synchronous trap (delegate_task is turn-scoped, cancelled on new
message; durable/resumable = kanban swarm) and the genuinely-new piece:
the adversarial-convergence verification recipe (N independent attempts
with varied framings + M refuters, keep only located claims that survive
refutation, iterate to convergence).

Self-contained: inlines the load-bearing fan-out hygiene rather than
hard-depending on local-only skills; references the shipped kanban swarm
subsystem for the durable path.
2026-06-02 00:31:25 -07:00
102 changed files with 1496 additions and 7061 deletions
+2 -2
View File
@@ -49,8 +49,8 @@ hermes-agent/
│ ├── hermes-achievements/ # Gamified achievement tracking
│ ├── observability/ # Metrics / traces / logs plugin
│ ├── image_gen/ # Image-generation providers
│ └── <others>/ # disk-cleanup, google_meet, platforms, spotify,
│ # strike-freedom-cockpit, ...
│ └── <others>/ # disk-cleanup, example-dashboard, google_meet, platforms,
│ # spotify, strike-freedom-cockpit, ...
├── optional-skills/ # Heavier/niche skills shipped but NOT active by default
├── skills/ # Built-in skills bundled with the repo
├── ui-tui/ # Ink (React) terminal UI — `hermes --tui`
@@ -482,18 +482,6 @@ async function runBootstrap(opts) {
writeMarker // callback to write the bootstrap-complete marker; main.cjs provides
} = opts
// Bail before spawning anything if the user already cancelled — otherwise an
// already-aborted signal would still fetch the manifest (a spawn) before the
// in-loop abort check fires.
if (abortSignal && abortSignal.aborted) {
if (typeof onEvent === 'function') {
try {
onEvent({ type: 'failed', error: 'bootstrap cancelled by user' })
} catch {}
}
return { ok: false, cancelled: true }
}
const runLog = openRunLog(logRoot || path.join(hermesHome, 'logs'))
// Tee every event to the runLog AND the caller's onEvent. This gives us a
@@ -1,27 +0,0 @@
const assert = require('node:assert/strict')
const test = require('node:test')
const { runBootstrap } = require('./bootstrap-runner.cjs')
test('runBootstrap bails immediately when the signal is already aborted', async () => {
const controller = new AbortController()
controller.abort()
const events = []
const result = await runBootstrap({
installStamp: null,
activeRoot: '/tmp/hermes-runner-test',
sourceRepoRoot: null,
hermesHome: '/tmp/hermes-runner-test',
logRoot: '/tmp/hermes-runner-test',
onEvent: ev => events.push(ev),
abortSignal: controller.signal
})
// Cancelled before any install script is spawned.
assert.deepEqual(result, { ok: false, cancelled: true })
assert.ok(
events.some(ev => ev.type === 'failed' && /cancelled/i.test(ev.error)),
'should emit a cancelled failure event'
)
})
-35
View File
@@ -435,9 +435,6 @@ let connectionPromise = null
// instead of re-running install.ps1 in a hot loop. Cleared explicitly by
// the renderer's "Reload and retry" path or by quitting the app.
let bootstrapFailure = null
// Active first-launch install, so the renderer's Cancel button (and app quit)
// can abort the in-flight install.sh/ps1 instead of leaving it running.
let bootstrapAbortController = null
let connectionConfigCache = null
const hermesLog = []
const previewWatchers = new Map()
@@ -1743,15 +1740,12 @@ async function ensureRuntime(backend) {
})
} catch {}
bootstrapAbortController = new AbortController()
const bootstrapResult = await runBootstrap({
installStamp: backend.installStamp,
activeRoot: backend.activeRoot,
sourceRepoRoot: SOURCE_REPO_ROOT,
hermesHome: HERMES_HOME,
logRoot: path.join(HERMES_HOME, 'logs'),
abortSignal: bootstrapAbortController.signal,
onEvent: ev => {
// Tee every bootstrap event to (a) the desktop log for forensics
// and (b) the renderer for live progress UI. Either may be absent;
@@ -1767,16 +1761,6 @@ async function ensureRuntime(backend) {
writeMarker: writeBootstrapMarker
})
bootstrapAbortController = null
if (bootstrapResult.cancelled) {
const cancelledError = new Error('Hermes install was cancelled.')
cancelledError.isBootstrapFailure = true
cancelledError.bootstrapCancelled = true
bootstrapFailure = cancelledError
throw cancelledError
}
if (!bootstrapResult.ok) {
const bootstrapError = new Error(
`Hermes bootstrap failed${bootstrapResult.failedStage ? ` at stage '${bootstrapResult.failedStage}'` : ''}: ` +
@@ -3272,18 +3256,6 @@ ipcMain.handle('hermes:bootstrap:repair', async () => {
resetHermesConnection()
return { ok: true }
})
ipcMain.handle('hermes:bootstrap:cancel', async () => {
// Renderer's Cancel button during first-launch install. Abort the running
// install script (SIGTERM via the runner's abortSignal). runBootstrap
// resolves with { cancelled: true }, which surfaces the recovery overlay.
if (bootstrapAbortController) {
try {
bootstrapAbortController.abort()
} catch {}
return { ok: true, cancelled: true }
}
return { ok: false, cancelled: false }
})
ipcMain.handle('hermes:boot-progress:get', async () => bootProgressState)
ipcMain.handle('hermes:bootstrap:get', async () => getBootstrapState())
ipcMain.handle('hermes:connection-config:get', async () => sanitizeDesktopConnectionConfig())
@@ -3754,13 +3726,6 @@ app.whenReady().then(() => {
})
app.on('before-quit', () => {
// Quitting mid-install should stop the installer, not orphan it.
if (bootstrapAbortController) {
try {
bootstrapAbortController.abort()
} catch {}
}
if (desktopLogFlushTimer) {
clearTimeout(desktopLogFlushTimer)
desktopLogFlushTimer = null
-1
View File
@@ -91,7 +91,6 @@ contextBridge.exposeInMainWorld('hermesDesktop', {
getBootstrapState: () => ipcRenderer.invoke('hermes:bootstrap:get'),
resetBootstrap: () => ipcRenderer.invoke('hermes:bootstrap:reset'),
repairBootstrap: () => ipcRenderer.invoke('hermes:bootstrap:repair'),
cancelBootstrap: () => ipcRenderer.invoke('hermes:bootstrap:cancel'),
onBootstrapEvent: callback => {
const listener = (_event, payload) => callback(payload)
ipcRenderer.on('hermes:bootstrap:event', listener)
+1 -1
View File
@@ -32,7 +32,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",
"test:desktop:platforms": "node --test electron/bootstrap-platform.test.cjs electron/hardening.test.cjs electron/backend-probes.test.cjs",
"type-check": "tsc -b",
"lint": "eslint src/ electron/",
"lint:fix": "eslint src/ electron/ --fix",
+19 -189
View File
@@ -17,7 +17,7 @@ import {
import { CSS } from '@dnd-kit/utilities'
import { useStore } from '@nanostores/react'
import type * as React from 'react'
import { useEffect, useMemo, useState } from 'react'
import { useMemo, useState } from 'react'
import { Button } from '@/components/ui/button'
import { Codicon } from '@/components/ui/codicon'
@@ -33,7 +33,7 @@ import {
SidebarMenuItem
} from '@/components/ui/sidebar'
import { Skeleton } from '@/components/ui/skeleton'
import { searchSessions, type SessionInfo, type SessionSearchResult } from '@/hermes'
import type { SessionInfo } from '@/hermes'
import { cn } from '@/lib/utils'
import {
$pinnedSessionIds,
@@ -54,8 +54,7 @@ import {
$sessions,
$sessionsLoading,
$sessionsTotal,
$workingSessionIds,
sessionPinId
$workingSessionIds
} from '@/store/session'
import { type AppView, ARTIFACTS_ROUTE, MESSAGING_ROUTE, SKILLS_ROUTE } from '../../routes'
@@ -74,12 +73,7 @@ const SIDEBAR_NAV: SidebarNavItem[] = [
icon: props => <Codicon name="robot" {...props} />,
action: 'new-session'
},
{
id: 'skills',
label: 'Skills & Tools',
icon: props => <Codicon name="symbol-misc" {...props} />,
route: SKILLS_ROUTE
},
{ id: 'skills', label: 'Skills', icon: props => <Codicon name="symbol-misc" {...props} />, route: SKILLS_ROUTE },
{ id: 'messaging', label: 'Messaging', icon: props => <Codicon name="comment" {...props} />, route: MESSAGING_ROUTE },
{ id: 'artifacts', label: 'Artifacts', icon: props => <Codicon name="files" {...props} />, route: ARTIFACTS_ROUTE }
]
@@ -126,31 +120,6 @@ const baseName = (path: string) =>
.filter(Boolean)
.pop()
// FTS results cover sessions that aren't in the loaded page; synthesize a
// minimal SessionInfo so they render in the same row component (resume works
// by id; the snippet stands in for the preview).
function searchResultToSession(result: SessionSearchResult): SessionInfo {
const ts = result.session_started ?? Date.now() / 1000
return {
archived: false,
cwd: null,
ended_at: null,
id: result.session_id,
input_tokens: 0,
is_active: false,
last_active: ts,
message_count: 0,
model: result.model ?? null,
output_tokens: 0,
preview: result.snippet?.trim() || null,
source: result.source ?? null,
started_at: ts,
title: null,
tool_call_count: 0
}
}
function workspaceGroupsFor(sessions: SessionInfo[]): SidebarSessionGroup[] {
const groups = new Map<string, SidebarSessionGroup>()
@@ -164,14 +133,6 @@ function workspaceGroupsFor(sessions: SessionInfo[]): SidebarSessionGroup[] {
groups.set(id, group)
}
// Groups keep recency order (Map insertion = first-seen in the recency-sorted
// input, so an active project floats up), but rows *within* a group sort by
// creation time so they don't reshuffle every time a message lands — keeps
// muscle memory intact.
for (const group of groups.values()) {
group.sessions.sort((a, b) => b.started_at - a.started_at)
}
return [...groups.values()]
}
@@ -218,9 +179,6 @@ export function ChatSidebar({
const workingSessionIds = useStore($workingSessionIds)
const [agentOrderIds, setAgentOrderIds] = useState<string[]>([])
const [workspaceOrderIds, setWorkspaceOrderIds] = useState<string[]>([])
const [searchQuery, setSearchQuery] = useState('')
const [serverMatches, setServerMatches] = useState<SessionSearchResult[]>([])
const trimmedQuery = searchQuery.trim()
const activeSidebarSessionId = currentView === 'chat' ? selectedSessionId : null
@@ -231,99 +189,24 @@ export function ChatSidebar({
const sortedSessions = useMemo(() => [...sessions].sort((a, b) => sessionTime(b) - sessionTime(a)), [sessions])
const sessionsById = useMemo(() => new Map(sessions.map(s => [s.id, s])), [sessions])
const workingSessionIdSet = useMemo(() => new Set(workingSessionIds), [workingSessionIds])
// Index sessions by both their live id and their lineage-root id so a pin
// stored as the pre-compression root resolves to the live continuation tip.
const sessionByAnyId = useMemo(() => {
const map = new Map<string, SessionInfo>()
const visiblePinnedIds = useMemo(
() => pinnedSessionIds.filter(id => sessionsById.has(id)),
[pinnedSessionIds, sessionsById]
)
for (const s of sessions) {
map.set(s.id, s)
const visiblePinnedIdSet = useMemo(() => new Set(visiblePinnedIds), [visiblePinnedIds])
if (s._lineage_root_id && !map.has(s._lineage_root_id)) {
map.set(s._lineage_root_id, s)
}
}
return map
}, [sessions])
const pinnedSessions = useMemo(() => {
const seen = new Set<string>()
const out: SessionInfo[] = []
for (const pinId of pinnedSessionIds) {
const session = sessionByAnyId.get(pinId)
if (session && !seen.has(session.id)) {
seen.add(session.id)
out.push(session)
}
}
return out
}, [pinnedSessionIds, sessionByAnyId])
const pinnedRealIdSet = useMemo(() => new Set(pinnedSessions.map(s => s.id)), [pinnedSessions])
// Full-text search across *all* sessions (not just the loaded page) so 699
// sessions stay findable. Debounced; loaded sessions are matched instantly
// client-side and merged ahead of the server hits.
useEffect(() => {
if (!trimmedQuery) {
setServerMatches([])
return
}
let cancelled = false
const id = window.setTimeout(() => {
void searchSessions(trimmedQuery)
.then(res => {
if (!cancelled) {
setServerMatches(res.results)
}
})
.catch(() => undefined)
}, 200)
return () => {
cancelled = true
window.clearTimeout(id)
}
}, [trimmedQuery])
const searchResults = useMemo(() => {
if (!trimmedQuery) {
return []
}
const needle = trimmedQuery.toLowerCase()
const out = new Map<string, SessionInfo>()
for (const s of sortedSessions) {
if (`${s.title ?? ''} ${s.preview ?? ''} ${s.cwd ?? ''}`.toLowerCase().includes(needle)) {
out.set(s.id, s)
}
}
for (const match of serverMatches) {
if (out.has(match.session_id)) {
continue
}
const loaded = sessionByAnyId.get(match.session_id)
out.set(match.session_id, loaded ?? searchResultToSession(match))
}
return [...out.values()]
}, [trimmedQuery, sortedSessions, serverMatches, sessionByAnyId])
const pinnedSessions = useMemo(
() => visiblePinnedIds.map(id => sessionsById.get(id)!).filter(Boolean),
[visiblePinnedIds, sessionsById]
)
const unpinnedAgentSessions = useMemo(
() => sortedSessions.filter(s => !pinnedRealIdSet.has(s.id)),
[sortedSessions, pinnedRealIdSet]
() => sortedSessions.filter(s => !visiblePinnedIdSet.has(s.id)),
[sortedSessions, visiblePinnedIdSet]
)
const agentSessions = useMemo(
@@ -353,10 +236,7 @@ export function ChatSidebar({
return
}
// Sortable ids are live session ids; the pinned store is keyed by durable
// (lineage-root) ids, so translate before reordering.
const dragged = sessionByAnyId.get(String(active.id))
reorderPinnedSession(dragged ? sessionPinId(dragged) : String(active.id), newIndex)
reorderPinnedSession(String(active.id), newIndex)
}
const handleAgentDragEnd = ({ active, over }: DragEndEvent) => {
@@ -451,56 +331,6 @@ export function ChatSidebar({
</SidebarGroup>
{sidebarOpen && showSessionSections && (
<div className="shrink-0 pb-1 pt-1">
<div className="flex items-center gap-1.5 rounded-md border border-transparent bg-transparent px-2 transition-colors focus-within:border-(--ui-stroke-tertiary)">
<Codicon className="shrink-0 text-(--ui-text-tertiary)" name="search" size="0.75rem" />
<input
aria-label="Search sessions"
className="h-6 min-w-0 flex-1 bg-transparent text-[0.8125rem] text-foreground placeholder:text-(--ui-text-tertiary) focus:outline-none"
onChange={event => setSearchQuery(event.target.value)}
placeholder="Search sessions…"
type="text"
value={searchQuery}
/>
{searchQuery && (
<button
aria-label="Clear search"
className="grid size-4 shrink-0 cursor-pointer place-items-center rounded-sm text-(--ui-text-tertiary) hover:bg-(--ui-control-active-background) hover:text-foreground"
onClick={() => setSearchQuery('')}
type="button"
>
<Codicon name="close" size="0.75rem" />
</button>
)}
</div>
</div>
)}
{sidebarOpen && showSessionSections && trimmedQuery && (
<SidebarSessionsSection
activeSessionId={activeSidebarSessionId}
contentClassName="flex min-h-0 flex-1 flex-col gap-px overflow-y-auto overscroll-contain pb-1.75"
emptyState={
<div className="grid min-h-24 place-items-center rounded-lg px-2 text-center text-xs text-(--ui-text-tertiary)">
No sessions match {trimmedQuery}.
</div>
}
label="Results"
labelMeta={String(searchResults.length)}
onArchiveSession={onArchiveSession}
onDeleteSession={onDeleteSession}
onResumeSession={onResumeSession}
onToggle={() => undefined}
onTogglePin={pinSession}
open
pinned={false}
rootClassName="min-h-0 flex-1 p-0"
sessions={searchResults}
workingSessionIdSet={workingSessionIdSet}
/>
)}
{sidebarOpen && showSessionSections && !trimmedQuery && (
<SidebarSessionsSection
activeSessionId={activeSidebarSessionId}
contentClassName="flex min-h-10 shrink-0 flex-col gap-px rounded-lg pb-2 pt-1"
@@ -522,7 +352,7 @@ export function ChatSidebar({
/>
)}
{sidebarOpen && showSessionSections && !trimmedQuery && (
{sidebarOpen && showSessionSections && (
<SidebarSessionsSection
activeSessionId={activeSidebarSessionId}
contentClassName="flex min-h-0 flex-1 flex-col gap-px overflow-y-auto overscroll-contain pb-1.75"
@@ -706,7 +536,7 @@ function SidebarSessionsSection({
isWorking: workingSessionIdSet.has(session.id),
onArchive: () => onArchiveSession(session.id),
onDelete: () => onDeleteSession(session.id),
onPin: () => onTogglePin(sessionPinId(session)),
onPin: () => onTogglePin(session.id),
onResume: () => onResumeSession(session.id),
session
}
@@ -5,7 +5,6 @@ import { type FC, useCallback, useMemo, useRef } from 'react'
import type { SessionInfo } from '@/hermes'
import { cn } from '@/lib/utils'
import { sessionPinId } from '@/store/session'
import { SidebarSessionRow } from './session-row'
@@ -78,7 +77,7 @@ export const VirtualSessionList: FC<VirtualSessionListProps> = ({
isWorking: workingSessionIdSet.has(session.id),
onArchive: () => onArchiveSession(session.id),
onDelete: () => onDeleteSession(session.id),
onPin: () => onTogglePin(sessionPinId(session)),
onPin: () => onTogglePin(session.id),
onResume: () => onResumeSession(session.id)
}
+370 -6
View File
@@ -3,29 +3,37 @@ import {
IconBookmark,
IconBookmarkFilled,
IconDownload,
IconLoader2,
IconRefresh,
IconSparkles,
IconTrash
} from '@tabler/icons-react'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import {
getActionStatus,
getAuxiliaryModels,
getGlobalModelInfo,
getGlobalModelOptions,
getLogs,
getStatus,
getUsageAnalytics,
restartGateway,
searchSessions,
setModelAssignment,
updateHermes
} from '@/hermes'
import type {
ActionStatusResponse,
AnalyticsResponse,
AuxiliaryModelsResponse,
ModelOptionProvider,
SessionInfo,
SessionSearchResult as SessionSearchApiResult,
StatusResponse
} from '@/hermes'
import { sessionTitle } from '@/lib/chat-runtime'
import { Activity, AlertCircle, BarChart3, Pin } from '@/lib/icons'
import { Activity, AlertCircle, BarChart3, Cpu, Pin } from '@/lib/icons'
import { exportSession } from '@/lib/session-export'
import { cn } from '@/lib/utils'
import { upsertDesktopActionTask } from '@/store/activity'
@@ -39,9 +47,30 @@ import { OverlayMain, OverlayNavItem, OverlaySidebar, OverlaySplitLayout } from
import { OverlayView } from '../overlays/overlay-view'
import { ARTIFACTS_ROUTE, MESSAGING_ROUTE, NEW_CHAT_ROUTE, SETTINGS_ROUTE, SKILLS_ROUTE } from '../routes'
export type CommandCenterSection = 'sessions' | 'system' | 'usage'
export type CommandCenterSection = 'models' | 'sessions' | 'system' | 'usage'
const SECTIONS = ['sessions', 'system', 'usage'] as const satisfies readonly CommandCenterSection[]
const SECTIONS = ['sessions', 'system', 'models', 'usage'] as const satisfies readonly CommandCenterSection[]
// Mirrors `_AUX_TASK_SLOTS` in hermes_cli/web_server.py. Friendly labels and
// hints make the assignments panel readable; raw task keys (vision, mcp, …)
// are opaque to most users.
interface AuxTaskMeta {
hint: string
key: string
label: string
}
const AUX_TASKS: readonly AuxTaskMeta[] = [
{ key: 'vision', label: 'Vision', hint: 'Image analysis' },
{ key: 'web_extract', label: 'Web extract', hint: 'Page summarization' },
{ key: 'compression', label: 'Compression', hint: 'Context compaction' },
{ key: 'session_search', label: 'Session search', hint: 'Recall queries' },
{ key: 'skills_hub', label: 'Skills hub', hint: 'Skill search' },
{ key: 'approval', label: 'Approval', hint: 'Smart auto-approve' },
{ key: 'mcp', label: 'MCP', hint: 'MCP tool routing' },
{ key: 'title_generation', label: 'Title gen', hint: 'Session titles' },
{ key: 'curator', label: 'Curator', hint: 'Skill-usage review' }
]
const USAGE_PERIODS = [7, 30, 90] as const
type UsagePeriod = (typeof USAGE_PERIODS)[number]
@@ -50,6 +79,7 @@ interface CommandCenterViewProps {
initialSection?: CommandCenterSection
onClose: () => void
onDeleteSession: (sessionId: string) => Promise<void>
onMainModelChanged?: (provider: string, model: string) => void
onNavigateRoute: (path: string) => void
onOpenSession: (sessionId: string) => void
}
@@ -57,12 +87,14 @@ interface CommandCenterViewProps {
const SECTION_LABELS: Record<CommandCenterSection, string> = {
sessions: 'Sessions',
system: 'System',
models: 'Models',
usage: 'Usage'
}
const SECTION_DESCRIPTIONS: Record<CommandCenterSection, string> = {
sessions: 'Search and manage sessions',
system: 'Status, logs, and system actions',
models: 'Global and auxiliary model controls',
usage: 'Token, cost, and skill activity over time'
}
@@ -83,7 +115,7 @@ interface SectionSearchEntry {
const NAVIGATION_SEARCH_ENTRIES: readonly NavigationSearchEntry[] = [
{ id: 'nav-new-chat', route: NEW_CHAT_ROUTE, title: 'New session', detail: 'Start a fresh session' },
{ id: 'nav-settings', route: SETTINGS_ROUTE, title: 'Settings', detail: 'Configure Hermes desktop' },
{ id: 'nav-skills', route: SKILLS_ROUTE, title: 'Skills & Tools', detail: 'Enable skills, toolsets, and providers' },
{ id: 'nav-skills', route: SKILLS_ROUTE, title: 'Skills', detail: 'Enable and inspect skills' },
{
id: 'nav-messaging',
route: MESSAGING_ROUTE,
@@ -96,6 +128,7 @@ const NAVIGATION_SEARCH_ENTRIES: readonly NavigationSearchEntry[] = [
const SECTION_SEARCH_ENTRIES: readonly SectionSearchEntry[] = [
{ id: 'section-sessions', section: 'sessions', title: 'Sessions panel', detail: 'Search, pin, and manage sessions' },
{ id: 'section-system', section: 'system', title: 'System panel', detail: 'Gateway status, logs, restart/update' },
{ id: 'section-models', section: 'models', title: 'Models panel', detail: 'Main and auxiliary model assignments' },
{ id: 'section-usage', section: 'usage', title: 'Usage panel', detail: 'Token, cost, and skill activity' }
]
@@ -183,6 +216,7 @@ export function CommandCenterView({
initialSection,
onClose,
onDeleteSession,
onMainModelChanged,
onNavigateRoute,
onOpenSession
}: CommandCenterViewProps) {
@@ -199,6 +233,16 @@ export function CommandCenterView({
const [systemLoading, setSystemLoading] = useState(false)
const [systemError, setSystemError] = useState('')
const [systemAction, setSystemAction] = useState<ActionStatusResponse | null>(null)
const [modelsLoading, setModelsLoading] = useState(false)
const [modelsError, setModelsError] = useState('')
const [mainModel, setMainModel] = useState<{ model: string; provider: string } | null>(null)
const [providers, setProviders] = useState<ModelOptionProvider[]>([])
const [selectedProvider, setSelectedProvider] = useState('')
const [selectedModel, setSelectedModel] = useState('')
const [auxiliary, setAuxiliary] = useState<AuxiliaryModelsResponse | null>(null)
const [applyingModel, setApplyingModel] = useState(false)
const [editingAuxTask, setEditingAuxTask] = useState<null | string>(null)
const [auxDraft, setAuxDraft] = useState<{ model: string; provider: string }>({ model: '', provider: '' })
const [usagePeriod, setUsagePeriod] = useState<UsagePeriod>(30)
const [usage, setUsage] = useState<AnalyticsResponse | null>(null)
const [usageLoading, setUsageLoading] = useState(false)
@@ -221,6 +265,11 @@ export function CommandCenterView({
[sessions]
)
const selectedProviderModels = useMemo(
() => providers.find(provider => provider.slug === selectedProvider)?.models ?? [],
[providers, selectedProvider]
)
const searchProviders = useMemo<readonly CommandCenterSearchProvider[]>(
() => [
{
@@ -293,6 +342,29 @@ export function CommandCenterView({
}
}, [])
const refreshModels = useCallback(async () => {
setModelsLoading(true)
setModelsError('')
try {
const [modelInfo, modelOptions, auxiliaryModels] = await Promise.all([
getGlobalModelInfo(),
getGlobalModelOptions(),
getAuxiliaryModels()
])
setMainModel({ model: modelInfo.model, provider: modelInfo.provider })
setProviders(modelOptions.providers || [])
setSelectedProvider(prev => prev || modelInfo.provider)
setSelectedModel(prev => prev || modelInfo.model)
setAuxiliary(auxiliaryModels)
} catch (error) {
setModelsError(error instanceof Error ? error.message : String(error))
} finally {
setModelsLoading(false)
}
}, [])
const refreshUsage = useCallback(async (days: UsagePeriod) => {
const requestId = usageRequestRef.current + 1
usageRequestRef.current = requestId
@@ -358,12 +430,28 @@ export function CommandCenterView({
}
}, [refreshSystem, section, status, systemLoading])
useEffect(() => {
if (section === 'models' && !mainModel && !modelsLoading) {
void refreshModels()
}
}, [mainModel, modelsLoading, refreshModels, section])
useEffect(() => {
if (section === 'usage') {
void refreshUsage(usagePeriod)
}
}, [refreshUsage, section, usagePeriod])
useEffect(() => {
if (!selectedProviderModels.length) {
return
}
if (!selectedProviderModels.includes(selectedModel)) {
setSelectedModel(selectedProviderModels[0])
}
}, [selectedModel, selectedProviderModels])
const showGlobalSearchResults = debouncedQuery.length > 0
const hasGlobalSearchResults = searchGroups.length > 0
const sessionListHasResults = filteredSessions.length > 0
@@ -409,6 +497,128 @@ export function CommandCenterView({
[refreshSystem]
)
const applyMainModel = useCallback(async () => {
if (!selectedProvider || !selectedModel) {
return
}
setApplyingModel(true)
setModelsError('')
try {
const result = await setModelAssignment({
model: selectedModel,
provider: selectedProvider,
scope: 'main'
})
const provider = result.provider || selectedProvider
const model = result.model || selectedModel
setMainModel({ provider, model })
onMainModelChanged?.(provider, model)
await refreshModels()
} catch (error) {
setModelsError(error instanceof Error ? error.message : String(error))
} finally {
setApplyingModel(false)
}
}, [onMainModelChanged, refreshModels, selectedModel, selectedProvider])
const setAuxiliaryToMain = useCallback(
async (task: string) => {
if (!mainModel) {
return
}
setApplyingModel(true)
setModelsError('')
try {
await setModelAssignment({
model: mainModel.model,
provider: mainModel.provider,
scope: 'auxiliary',
task
})
await refreshModels()
} catch (error) {
setModelsError(error instanceof Error ? error.message : String(error))
} finally {
setApplyingModel(false)
}
},
[mainModel, refreshModels]
)
const applyAuxiliaryDraft = useCallback(
async (task: string) => {
if (!auxDraft.provider || !auxDraft.model) {
return
}
setApplyingModel(true)
setModelsError('')
try {
await setModelAssignment({
model: auxDraft.model,
provider: auxDraft.provider,
scope: 'auxiliary',
task
})
setEditingAuxTask(null)
await refreshModels()
} catch (error) {
setModelsError(error instanceof Error ? error.message : String(error))
} finally {
setApplyingModel(false)
}
},
[auxDraft, refreshModels]
)
const beginAuxiliaryEdit = useCallback(
(task: string) => {
const current = auxiliary?.tasks.find(entry => entry.task === task)
const initialProvider =
current?.provider && current.provider !== 'auto' ? current.provider : (mainModel?.provider ?? '')
const initialModel = current?.model || mainModel?.model || ''
setAuxDraft({ provider: initialProvider, model: initialModel })
setEditingAuxTask(task)
},
[auxiliary, mainModel]
)
const auxDraftProviderModels = useMemo(
() => providers.find(provider => provider.slug === auxDraft.provider)?.models ?? [],
[auxDraft.provider, providers]
)
const resetAuxiliaryModels = useCallback(async () => {
if (!mainModel) {
return
}
setApplyingModel(true)
setModelsError('')
try {
await setModelAssignment({
model: mainModel.model,
provider: mainModel.provider,
scope: 'auxiliary',
task: '__reset__'
})
await refreshModels()
} catch (error) {
setModelsError(error instanceof Error ? error.message : String(error))
} finally {
setApplyingModel(false)
}
}, [mainModel, refreshModels])
const handleSearchSelect = useCallback(
(result: CommandCenterSearchResult) => {
if (result.kind === 'route') {
@@ -448,7 +658,7 @@ export function CommandCenterView({
{SECTIONS.map(value => (
<OverlayNavItem
active={section === value}
icon={value === 'sessions' ? Pin : value === 'system' ? Activity : BarChart3}
icon={value === 'sessions' ? Pin : value === 'system' ? Activity : value === 'models' ? Cpu : BarChart3}
key={value}
label={SECTION_LABELS[value]}
onClick={() => setSection(value)}
@@ -474,6 +684,12 @@ export function CommandCenterView({
{usageLoading ? 'Refreshing...' : 'Refresh'}
</OverlayActionButton>
)}
{section === 'models' && (
<OverlayActionButton disabled={modelsLoading} onClick={() => void refreshModels()}>
<IconRefresh className={cn('mr-1.5 size-3.5', modelsLoading && 'animate-spin')} />
{modelsLoading ? 'Refreshing...' : 'Refresh'}
</OverlayActionButton>
)}
</header>
{showGlobalSearchResults ? (
@@ -628,7 +844,7 @@ export function CommandCenterView({
period={usagePeriod}
usage={usage}
/>
) : (
) : section === 'system' ? (
<div className="grid min-h-0 flex-1 grid-rows-[auto_minmax(0,1fr)] gap-3">
<OverlayCard className="p-3 text-sm">
{status ? (
@@ -686,6 +902,154 @@ export function CommandCenterView({
</pre>
</OverlayCard>
</div>
) : (
<div className="grid min-h-0 flex-1 grid-rows-[auto_auto_minmax(0,1fr)] gap-3">
<OverlayCard className="p-3">
{mainModel ? (
<>
<div className="text-sm font-medium text-foreground">Main model</div>
<div className="text-xs text-muted-foreground">
{mainModel.provider} / {mainModel.model}
</div>
</>
) : (
<div className="text-xs text-muted-foreground">Loading model state...</div>
)}
</OverlayCard>
<OverlayCard className="p-3">
<div className="mb-2 text-xs font-medium text-muted-foreground">Set global main model</div>
<div className="flex flex-wrap items-center gap-2">
<select
className="h-8 min-w-36 rounded-md border border-border bg-background px-2 text-xs text-foreground"
onChange={event => setSelectedProvider(event.target.value)}
value={selectedProvider}
>
{(providers.length ? providers : [{ name: '—', slug: '', models: [] }]).map(provider => (
<option key={provider.slug || 'none'} value={provider.slug}>
{provider.name}
</option>
))}
</select>
<select
className="h-8 min-w-58 rounded-md border border-border bg-background px-2 text-xs text-foreground"
onChange={event => setSelectedModel(event.target.value)}
value={selectedModel}
>
{(selectedProviderModels.length ? selectedProviderModels : ['']).map(model => (
<option key={model || 'none'} value={model}>
{model || 'No models available'}
</option>
))}
</select>
<OverlayActionButton
disabled={!selectedProvider || !selectedModel || applyingModel}
onClick={() => void applyMainModel()}
>
{applyingModel ? (
<IconLoader2 className="mr-1.5 size-3.5 animate-spin" />
) : (
<IconSparkles className="mr-1.5 size-3.5" />
)}
{applyingModel ? 'Applying...' : 'Apply'}
</OverlayActionButton>
</div>
{modelsError && <div className="mt-2 text-xs text-destructive">{modelsError}</div>}
</OverlayCard>
<OverlayCard className="min-h-0 overflow-auto p-2">
<div className="mb-2 flex items-center justify-between">
<span className="text-xs font-medium text-muted-foreground">Auxiliary assignments</span>
<OverlayActionButton
disabled={!mainModel || applyingModel}
onClick={() => void resetAuxiliaryModels()}
tone="subtle"
>
Reset all
</OverlayActionButton>
</div>
<div className="grid gap-1.5">
{AUX_TASKS.map(meta => {
const current = auxiliary?.tasks.find(entry => entry.task === meta.key)
const isAuto = !current || !current.provider || current.provider === 'auto'
const isEditing = editingAuxTask === meta.key
return (
<OverlayCard className="px-2 py-1.5" key={meta.key}>
<div className="flex items-center gap-2">
<div className="min-w-0 flex-1">
<div className="flex items-baseline gap-2">
<span className="text-xs font-medium text-foreground">{meta.label}</span>
<span className="text-[0.62rem] text-muted-foreground/70">{meta.hint}</span>
</div>
<div className="truncate font-mono text-[0.62rem] text-muted-foreground">
{isAuto
? 'auto · use main model'
: `${current.provider} · ${current.model || '(provider default)'}`}
</div>
</div>
{!isEditing && (
<>
<OverlayActionButton
disabled={!mainModel || applyingModel}
onClick={() => void setAuxiliaryToMain(meta.key)}
tone="subtle"
>
Set to main
</OverlayActionButton>
<OverlayActionButton
disabled={!providers.length || applyingModel}
onClick={() => beginAuxiliaryEdit(meta.key)}
>
Change
</OverlayActionButton>
</>
)}
</div>
{isEditing && (
<div className="mt-2 flex flex-wrap items-center gap-2 border-t border-border/40 pt-2">
<select
className="h-7 min-w-28 rounded-md border border-border bg-background px-2 text-[0.7rem] text-foreground"
onChange={event =>
setAuxDraft(prev => ({ ...prev, provider: event.target.value, model: '' }))
}
value={auxDraft.provider}
>
{(providers.length ? providers : [{ name: '—', slug: '', models: [] }]).map(provider => (
<option key={provider.slug || 'none'} value={provider.slug}>
{provider.name}
</option>
))}
</select>
<select
className="h-7 min-w-44 rounded-md border border-border bg-background px-2 text-[0.7rem] text-foreground"
onChange={event => setAuxDraft(prev => ({ ...prev, model: event.target.value }))}
value={auxDraft.model}
>
{(auxDraftProviderModels.length ? auxDraftProviderModels : ['']).map(model => (
<option key={model || 'none'} value={model}>
{model || 'No models available'}
</option>
))}
</select>
<OverlayActionButton
disabled={!auxDraft.provider || !auxDraft.model || applyingModel}
onClick={() => void applyAuxiliaryDraft(meta.key)}
>
{applyingModel ? 'Applying...' : 'Apply'}
</OverlayActionButton>
<OverlayActionButton onClick={() => setEditingAuxTask(null)} tone="subtle">
Cancel
</OverlayActionButton>
</div>
)}
</OverlayCard>
)
})}
</div>
</OverlayCard>
</div>
)}
</OverlayMain>
</OverlaySplitLayout>
+10 -16
View File
@@ -32,8 +32,6 @@ import {
$freshDraftReady,
$gatewayState,
$selectedStoredSessionId,
$sessions,
sessionPinId,
setAwaitingResponse,
setBusy,
setCurrentBranch,
@@ -226,14 +224,10 @@ export function DesktopController() {
return
}
// Pin on the durable lineage-root id so the pin survives auto-compression.
const session = $sessions.get().find(s => s.id === sessionId || s._lineage_root_id === sessionId)
const pinId = session ? sessionPinId(session) : sessionId
if ($pinnedSessionIds.get().includes(pinId)) {
unpinSession(pinId)
if ($pinnedSessionIds.get().includes(sessionId)) {
unpinSession(sessionId)
} else {
pinSession(pinId)
pinSession(sessionId)
}
}, [])
@@ -537,13 +531,6 @@ export function DesktopController() {
void refreshCurrentModel()
void queryClient.invalidateQueries({ queryKey: ['model-options'] })
}}
onMainModelChanged={(provider, model) => {
setCurrentProvider(provider)
setCurrentModel(model)
updateModelOptionsCache(provider, model, true)
void refreshCurrentModel()
void queryClient.invalidateQueries({ queryKey: ['model-options'] })
}}
/>
</Suspense>
)}
@@ -554,6 +541,13 @@ export function DesktopController() {
initialSection={commandCenterInitialSection}
onClose={closeOverlayToPreviousRoute}
onDeleteSession={removeSession}
onMainModelChanged={(provider, model) => {
setCurrentProvider(provider)
setCurrentModel(model)
updateModelOptionsCache(provider, model, true)
void refreshCurrentModel()
void queryClient.invalidateQueries({ queryKey: ['model-options'] })
}}
onNavigateRoute={path => navigate(path)}
onOpenSession={sessionId => navigate(sessionRoute(sessionId))}
/>
@@ -18,7 +18,6 @@ import type { ConfigFieldSchema, HermesConfigRecord } from '@/types/hermes'
import { CONTROL_TEXT, EMPTY_SELECT_VALUE, FIELD_DESCRIPTIONS, FIELD_LABELS, SECTIONS } from './constants'
import { enumOptionsFor, getNested, includesQuery, prettyName, setNested } from './helpers'
import { ModelSettings } from './model-settings'
import { EmptyState, ListRow, LoadingState, SettingsContent } from './primitives'
import type { SearchProps } from './types'
@@ -168,12 +167,10 @@ export function ConfigSettings({
query,
activeSectionId,
onConfigSaved,
onMainModelChanged,
importInputRef
}: SearchProps & {
activeSectionId: string
onConfigSaved?: () => void
onMainModelChanged?: (provider: string, model: string) => void
importInputRef: React.RefObject<HTMLInputElement | null>
}) {
const [config, setConfig] = useState<HermesConfigRecord | null>(null)
@@ -325,11 +322,6 @@ export function ConfigSettings({
return (
<SettingsContent>
{activeSectionId === 'model' && !query.trim() && (
<div className="mb-6">
<ModelSettings onMainModelChanged={onMainModelChanged} />
</div>
)}
{query.trim() && (
<div className="mb-4 text-xs text-muted-foreground">
{fields.length} result{fields.length === 1 ? '' : 's'}
+21 -5
View File
@@ -141,7 +141,13 @@ export const FIELD_LABELS: Record<string, string> = {
'delegation.max_iterations': 'Subagent Turn Limit',
'delegation.max_concurrent_children': 'Parallel Subagents',
'delegation.child_timeout_seconds': 'Subagent Timeout',
'delegation.reasoning_effort': 'Subagent Reasoning Effort'
'delegation.reasoning_effort': 'Subagent Reasoning Effort',
'auxiliary.vision.provider': 'Vision Provider',
'auxiliary.vision.model': 'Vision Model',
'auxiliary.compression.provider': 'Compression Provider',
'auxiliary.compression.model': 'Compression Model',
'auxiliary.title_generation.provider': 'Title Provider',
'auxiliary.title_generation.model': 'Title Model'
}
export const FIELD_DESCRIPTIONS: Record<string, string> = {
@@ -177,7 +183,7 @@ export const SECTIONS: DesktopConfigSection[] = [
id: 'model',
label: 'Model',
icon: Sparkles,
keys: ['model_context_length', 'fallback_providers']
keys: ['model', 'model_context_length', 'fallback_providers']
},
{
id: 'chat',
@@ -281,7 +287,13 @@ export const SECTIONS: DesktopConfigSection[] = [
'delegation.max_iterations',
'delegation.max_concurrent_children',
'delegation.child_timeout_seconds',
'delegation.reasoning_effort'
'delegation.reasoning_effort',
'auxiliary.vision.provider',
'auxiliary.vision.model',
'auxiliary.compression.provider',
'auxiliary.compression.model',
'auxiliary.title_generation.provider',
'auxiliary.title_generation.model'
]
}
]
@@ -299,11 +311,15 @@ export const MODE_OPTIONS: ModeOption[] = [
{ id: 'system', label: 'System', description: 'Follow OS appearance', icon: Monitor }
]
export const SEARCH_PLACEHOLDER: Record<'about' | 'config' | 'gateway' | 'keys' | 'mcp' | 'sessions', string> = {
export const SEARCH_PLACEHOLDER: Record<
'about' | 'config' | 'gateway' | 'keys' | 'mcp' | 'sessions' | 'tools',
string
> = {
about: 'About Hermes Desktop',
config: 'Search settings...',
gateway: 'Gateway connection...',
keys: 'Search API keys...',
mcp: 'Search MCP servers...',
sessions: 'Search archived sessions...'
sessions: 'Search archived sessions...',
tools: 'Search skills and tools...'
}
+15 -5
View File
@@ -3,7 +3,7 @@ import { useEffect, useRef, useState } from 'react'
import { getHermesConfigDefaults, getHermesConfigRecord, saveHermesConfig } from '@/hermes'
import { triggerHaptic } from '@/lib/haptics'
import { Archive, Globe, Info, KeyRound, Wrench } from '@/lib/icons'
import { Archive, Globe, Info, KeyRound, Package, Wrench } from '@/lib/icons'
import { notifyError } from '@/store/notifications'
import { useRouteEnumParam } from '../hooks/use-route-enum-param'
@@ -20,6 +20,7 @@ import { GatewaySettings } from './gateway-settings'
import { KeysSettings } from './keys-settings'
import { McpSettings } from './mcp-settings'
import { SessionsSettings } from './sessions-settings'
import { ToolsSettings } from './tools-settings'
import type { SettingsPageProps, SettingsQueryKey, SettingsView as SettingsViewId } from './types'
const SETTINGS_VIEWS: readonly SettingsViewId[] = [
@@ -28,10 +29,11 @@ const SETTINGS_VIEWS: readonly SettingsViewId[] = [
'keys',
'mcp',
'sessions',
'tools',
'about'
]
export function SettingsView({ gateway, onClose, onConfigSaved, onMainModelChanged }: SettingsPageProps) {
export function SettingsView({ gateway, onClose, onConfigSaved }: SettingsPageProps) {
const [activeView, setActiveView] = useRouteEnumParam('tab', SETTINGS_VIEWS, 'config:model' as SettingsViewId)
const [queries, setQueries] = useState<Record<SettingsQueryKey, string>>({
@@ -40,7 +42,8 @@ export function SettingsView({ gateway, onClose, onConfigSaved, onMainModelChang
gateway: '',
keys: '',
mcp: '',
sessions: ''
sessions: '',
tools: ''
})
const searchInputRef = useRef<HTMLInputElement>(null)
@@ -137,6 +140,12 @@ export function SettingsView({ gateway, onClose, onConfigSaved, onMainModelChang
label="API Keys"
onClick={() => setActiveView('keys')}
/>
<OverlayNavItem
active={activeView === 'tools'}
icon={Package}
label="Skills & Tools"
onClick={() => setActiveView('tools')}
/>
<OverlayNavItem
active={activeView === 'mcp'}
icon={Wrench}
@@ -194,15 +203,16 @@ export function SettingsView({ gateway, onClose, onConfigSaved, onMainModelChang
activeSectionId={activeView.slice('config:'.length)}
importInputRef={importInputRef}
onConfigSaved={onConfigSaved}
onMainModelChanged={onMainModelChanged}
query={queries.config}
/>
) : activeView === 'keys' ? (
<KeysSettings query={queries.keys} />
) : activeView === 'mcp' ? (
<McpSettings gateway={gateway} onConfigSaved={onConfigSaved} query={queries.mcp} />
) : (
) : activeView === 'sessions' ? (
<SessionsSettings query={queries.sessions} />
) : (
<ToolsSettings query={queries.tools} />
)}
</OverlayMain>
</OverlaySplitLayout>
@@ -1,70 +0,0 @@
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
const getGlobalModelInfo = vi.fn()
const getGlobalModelOptions = vi.fn()
const getAuxiliaryModels = vi.fn()
const setModelAssignment = vi.fn()
vi.mock('@/hermes', () => ({
getGlobalModelInfo: () => getGlobalModelInfo(),
getGlobalModelOptions: () => getGlobalModelOptions(),
getAuxiliaryModels: () => getAuxiliaryModels(),
setModelAssignment: (body: unknown) => setModelAssignment(body)
}))
beforeEach(() => {
getGlobalModelInfo.mockResolvedValue({ provider: 'nous', model: 'hermes-4' })
getGlobalModelOptions.mockResolvedValue({
providers: [{ name: 'Nous', slug: 'nous', models: ['hermes-4', 'hermes-4-mini'] }]
})
getAuxiliaryModels.mockResolvedValue({
main: { provider: 'nous', model: 'hermes-4' },
tasks: [{ task: 'vision', provider: 'auto', model: '', base_url: '' }]
})
setModelAssignment.mockResolvedValue({ provider: 'nous', model: 'hermes-4', gateway_tools: [] })
})
afterEach(() => {
cleanup()
vi.clearAllMocks()
})
async function renderModelSettings() {
const { ModelSettings } = await import('./model-settings')
return render(<ModelSettings />)
}
describe('ModelSettings', () => {
it('loads and shows the current main model', async () => {
await renderModelSettings()
await waitFor(() => expect(getGlobalModelInfo).toHaveBeenCalled())
expect(screen.getByText('nous / hermes-4')).toBeTruthy()
})
it('renders the auxiliary task rows', async () => {
await renderModelSettings()
expect(await screen.findByText('Vision')).toBeTruthy()
expect(screen.getAllByText('auto · use main model').length).toBeGreaterThan(0)
})
it('assigns an auxiliary task to the main model via setModelAssignment', async () => {
await renderModelSettings()
// One "Set to main" button per task slot; the first is Vision.
const setToMainButtons = await screen.findAllByRole('button', { name: 'Set to main' })
fireEvent.click(setToMainButtons[0])
await waitFor(() =>
expect(setModelAssignment).toHaveBeenCalledWith({
model: 'hermes-4',
provider: 'nous',
scope: 'auxiliary',
task: 'vision'
})
)
})
})
@@ -1,358 +0,0 @@
import { useCallback, useEffect, useMemo, useState } from 'react'
import { Button } from '@/components/ui/button'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue
} from '@/components/ui/select'
import { getAuxiliaryModels, getGlobalModelInfo, getGlobalModelOptions, setModelAssignment } from '@/hermes'
import type { AuxiliaryModelsResponse, ModelOptionProvider } from '@/hermes'
import { Cpu, Loader2, Sparkles } from '@/lib/icons'
import { cn } from '@/lib/utils'
import { CONTROL_TEXT } from './constants'
import { ListRow, LoadingState, Pill, SectionHeading } from './primitives'
// Mirrors `_AUX_TASK_SLOTS` in hermes_cli/web_server.py. Friendly labels and
// hints make the assignments readable; raw task keys (vision, mcp, …) are
// opaque to most users.
interface AuxTaskMeta {
hint: string
key: string
label: string
}
const AUX_TASKS: readonly AuxTaskMeta[] = [
{ key: 'vision', label: 'Vision', hint: 'Image analysis' },
{ key: 'web_extract', label: 'Web extract', hint: 'Page summarization' },
{ key: 'compression', label: 'Compression', hint: 'Context compaction' },
{ key: 'session_search', label: 'Session search', hint: 'Recall queries' },
{ key: 'skills_hub', label: 'Skills hub', hint: 'Skill search' },
{ key: 'approval', label: 'Approval', hint: 'Smart auto-approve' },
{ key: 'mcp', label: 'MCP', hint: 'MCP tool routing' },
{ key: 'title_generation', label: 'Title gen', hint: 'Session titles' },
{ key: 'curator', label: 'Curator', hint: 'Skill-usage review' }
]
const NO_PROVIDERS: readonly ModelOptionProvider[] = [{ name: '—', slug: '', models: [] }]
interface ModelSettingsProps {
/** Notified after the main model is applied, so live UI stores can sync. */
onMainModelChanged?: (provider: string, model: string) => void
}
export function ModelSettings({ onMainModelChanged }: ModelSettingsProps) {
const [loading, setLoading] = useState(true)
const [error, setError] = useState('')
const [mainModel, setMainModel] = useState<{ model: string; provider: string } | null>(null)
const [providers, setProviders] = useState<ModelOptionProvider[]>([])
const [selectedProvider, setSelectedProvider] = useState('')
const [selectedModel, setSelectedModel] = useState('')
const [auxiliary, setAuxiliary] = useState<AuxiliaryModelsResponse | null>(null)
const [applying, setApplying] = useState(false)
const [editingAuxTask, setEditingAuxTask] = useState<null | string>(null)
const [auxDraft, setAuxDraft] = useState<{ model: string; provider: string }>({ model: '', provider: '' })
const refresh = useCallback(async () => {
setLoading(true)
setError('')
try {
const [modelInfo, modelOptions, auxiliaryModels] = await Promise.all([
getGlobalModelInfo(),
getGlobalModelOptions(),
getAuxiliaryModels()
])
setMainModel({ model: modelInfo.model, provider: modelInfo.provider })
setProviders(modelOptions.providers || [])
setSelectedProvider(prev => prev || modelInfo.provider)
setSelectedModel(prev => prev || modelInfo.model)
setAuxiliary(auxiliaryModels)
} catch (err) {
setError(err instanceof Error ? err.message : String(err))
} finally {
setLoading(false)
}
}, [])
useEffect(() => {
void refresh()
}, [refresh])
const providerOptions = providers.length ? providers : NO_PROVIDERS
const selectedProviderModels = useMemo(
() => providers.find(provider => provider.slug === selectedProvider)?.models ?? [],
[providers, selectedProvider]
)
const auxDraftProviderModels = useMemo(
() => providers.find(provider => provider.slug === auxDraft.provider)?.models ?? [],
[auxDraft.provider, providers]
)
const applyMainModel = useCallback(async () => {
if (!selectedProvider || !selectedModel) {
return
}
setApplying(true)
setError('')
try {
const result = await setModelAssignment({ model: selectedModel, provider: selectedProvider, scope: 'main' })
const provider = result.provider || selectedProvider
const model = result.model || selectedModel
setMainModel({ provider, model })
onMainModelChanged?.(provider, model)
await refresh()
} catch (err) {
setError(err instanceof Error ? err.message : String(err))
} finally {
setApplying(false)
}
}, [onMainModelChanged, refresh, selectedModel, selectedProvider])
const setAuxiliaryToMain = useCallback(
async (task: string) => {
if (!mainModel) {
return
}
setApplying(true)
setError('')
try {
await setModelAssignment({ model: mainModel.model, provider: mainModel.provider, scope: 'auxiliary', task })
await refresh()
} catch (err) {
setError(err instanceof Error ? err.message : String(err))
} finally {
setApplying(false)
}
},
[mainModel, refresh]
)
const applyAuxiliaryDraft = useCallback(
async (task: string) => {
if (!auxDraft.provider || !auxDraft.model) {
return
}
setApplying(true)
setError('')
try {
await setModelAssignment({ model: auxDraft.model, provider: auxDraft.provider, scope: 'auxiliary', task })
setEditingAuxTask(null)
await refresh()
} catch (err) {
setError(err instanceof Error ? err.message : String(err))
} finally {
setApplying(false)
}
},
[auxDraft, refresh]
)
const beginAuxiliaryEdit = useCallback(
(task: string) => {
const current = auxiliary?.tasks.find(entry => entry.task === task)
const initialProvider =
current?.provider && current.provider !== 'auto' ? current.provider : (mainModel?.provider ?? '')
const initialModel = current?.model || mainModel?.model || ''
setAuxDraft({ provider: initialProvider, model: initialModel })
setEditingAuxTask(task)
},
[auxiliary, mainModel]
)
const resetAuxiliaryModels = useCallback(async () => {
if (!mainModel) {
return
}
setApplying(true)
setError('')
try {
await setModelAssignment({
model: mainModel.model,
provider: mainModel.provider,
scope: 'auxiliary',
task: '__reset__'
})
await refresh()
} catch (err) {
setError(err instanceof Error ? err.message : String(err))
} finally {
setApplying(false)
}
}, [mainModel, refresh])
if (loading && !mainModel) {
return <LoadingState label="Loading model configuration..." />
}
return (
<div className="grid gap-6">
<section>
<SectionHeading
icon={Sparkles}
meta={mainModel ? `${mainModel.provider} / ${mainModel.model}` : undefined}
title="Main model"
/>
<p className="mb-3 text-xs text-muted-foreground">
Applies to new sessions. Use the model picker in the composer to hot-swap the active chat.
</p>
<div className="flex flex-wrap items-center gap-2">
<Select onValueChange={setSelectedProvider} value={selectedProvider}>
<SelectTrigger className={cn('min-w-40', CONTROL_TEXT)}>
<SelectValue placeholder="Provider" />
</SelectTrigger>
<SelectContent>
{providerOptions.map(provider => (
<SelectItem key={provider.slug || 'none'} value={provider.slug || 'none'}>
{provider.name}
</SelectItem>
))}
</SelectContent>
</Select>
<Select onValueChange={setSelectedModel} value={selectedModel}>
<SelectTrigger className={cn('min-w-60', CONTROL_TEXT)}>
<SelectValue placeholder="Model" />
</SelectTrigger>
<SelectContent>
{(selectedProviderModels.length ? selectedProviderModels : []).map(model => (
<SelectItem key={model} value={model}>
{model}
</SelectItem>
))}
</SelectContent>
</Select>
<Button disabled={!selectedProvider || !selectedModel || applying} onClick={() => void applyMainModel()} size="sm">
{applying ? <Loader2 className="size-3.5 animate-spin" /> : <Sparkles className="size-3.5" />}
{applying ? 'Applying...' : 'Apply'}
</Button>
</div>
{error && <div className="mt-2 text-xs text-destructive">{error}</div>}
</section>
<section>
<div className="mb-2.5 flex items-center justify-between">
<SectionHeading icon={Cpu} title="Auxiliary models" />
<Button
disabled={!mainModel || applying}
onClick={() => void resetAuxiliaryModels()}
size="sm"
variant="outline"
>
Reset all to main
</Button>
</div>
<p className="mb-2 text-xs text-muted-foreground">
Helper tasks run on the main model by default. Assign a dedicated model to any task to override.
</p>
<div className="divide-y divide-border/40">
{AUX_TASKS.map(meta => {
const current = auxiliary?.tasks.find(entry => entry.task === meta.key)
const isAuto = !current || !current.provider || current.provider === 'auto'
const isEditing = editingAuxTask === meta.key
return (
<ListRow
action={
!isEditing && (
<div className="flex shrink-0 items-center gap-1.5">
<Button
disabled={!mainModel || applying}
onClick={() => void setAuxiliaryToMain(meta.key)}
size="sm"
variant="ghost"
>
Set to main
</Button>
<Button
disabled={!providers.length || applying}
onClick={() => beginAuxiliaryEdit(meta.key)}
size="sm"
variant="outline"
>
Change
</Button>
</div>
)
}
below={
isEditing && (
<div className="mt-2 flex flex-wrap items-center gap-2 border-t border-border/40 pt-2">
<Select
onValueChange={value => setAuxDraft(prev => ({ ...prev, provider: value, model: '' }))}
value={auxDraft.provider}
>
<SelectTrigger className={cn('min-w-32', CONTROL_TEXT)}>
<SelectValue placeholder="Provider" />
</SelectTrigger>
<SelectContent>
{providerOptions.map(provider => (
<SelectItem key={provider.slug || 'none'} value={provider.slug || 'none'}>
{provider.name}
</SelectItem>
))}
</SelectContent>
</Select>
<Select
onValueChange={value => setAuxDraft(prev => ({ ...prev, model: value }))}
value={auxDraft.model}
>
<SelectTrigger className={cn('min-w-48', CONTROL_TEXT)}>
<SelectValue placeholder="Model" />
</SelectTrigger>
<SelectContent>
{(auxDraftProviderModels.length ? auxDraftProviderModels : []).map(model => (
<SelectItem key={model} value={model}>
{model}
</SelectItem>
))}
</SelectContent>
</Select>
<Button
disabled={!auxDraft.provider || !auxDraft.model || applying}
onClick={() => void applyAuxiliaryDraft(meta.key)}
size="sm"
>
{applying ? 'Applying...' : 'Apply'}
</Button>
<Button onClick={() => setEditingAuxTask(null)} size="sm" variant="ghost">
Cancel
</Button>
</div>
)
}
description={
<span className="font-mono text-[0.68rem]">
{isAuto ? 'auto · use main model' : `${current.provider} · ${current.model || '(provider default)'}`}
</span>
}
key={meta.key}
title={
<span className="flex items-baseline gap-2">
{meta.label}
<Pill>{meta.hint}</Pill>
</span>
}
/>
)
})}
</div>
</section>
</div>
)
}
@@ -1,24 +1,16 @@
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
import { MemoryRouter } from 'react-router-dom'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
const getSkills = vi.fn()
const getToolsets = vi.fn()
const toggleSkill = vi.fn()
const toggleToolset = vi.fn()
const getToolsetConfig = vi.fn()
const selectToolsetProvider = vi.fn()
vi.mock('@/hermes', () => ({
getSkills: () => getSkills(),
getToolsets: () => getToolsets(),
toggleSkill: (name: string, enabled: boolean) => toggleSkill(name, enabled),
toggleToolset: (name: string, enabled: boolean) => toggleToolset(name, enabled),
getToolsetConfig: (name: string) => getToolsetConfig(name),
selectToolsetProvider: (toolset: string, provider: string) => selectToolsetProvider(toolset, provider),
deleteEnvVar: vi.fn(),
revealEnvVar: vi.fn(),
setEnvVar: vi.fn()
toggleToolset: (name: string, enabled: boolean) => toggleToolset(name, enabled)
}))
// Notifications hit nanostores/timers we don't care about here.
@@ -40,21 +32,10 @@ function toolset(overrides: Record<string, unknown> = {}) {
}
}
function renderSkills() {
return import('./index').then(({ SkillsView }) =>
render(
<MemoryRouter initialEntries={['/skills?tab=toolsets']}>
<SkillsView />
</MemoryRouter>
)
)
}
beforeEach(() => {
getSkills.mockResolvedValue([])
getToolsets.mockResolvedValue([toolset()])
toggleToolset.mockResolvedValue({ ok: true, name: 'web', enabled: false })
getToolsetConfig.mockResolvedValue({ has_category: false, active_provider: null, providers: [] })
})
afterEach(() => {
@@ -62,9 +43,10 @@ afterEach(() => {
vi.clearAllMocks()
})
describe('SkillsView toolset management', () => {
describe('ToolsSettings toolset toggle', () => {
it('renders a switch for each toolset and toggles it off', async () => {
await renderSkills()
const { ToolsSettings } = await import('./tools-settings')
render(<ToolsSettings query="" />)
const sw = await screen.findByRole('switch', { name: 'Toggle Web Search toolset' })
expect(sw.getAttribute('aria-checked')).toBe('true')
@@ -75,18 +57,10 @@ describe('SkillsView toolset management', () => {
})
it('keeps the configured pill alongside the switch', async () => {
await renderSkills()
const { ToolsSettings } = await import('./tools-settings')
render(<ToolsSettings query="" />)
await screen.findByRole('switch', { name: 'Toggle Web Search toolset' })
expect(screen.getByText('Configured')).toBeTruthy()
})
it('expands the provider config panel when the configured pill is clicked', async () => {
await renderSkills()
const configureBtn = await screen.findByRole('button', { name: 'Configure Web Search' })
fireEvent.click(configureBtn)
await waitFor(() => expect(getToolsetConfig).toHaveBeenCalledWith('web'))
})
})
@@ -0,0 +1,229 @@
import { useCallback, useEffect, useMemo, useState } from 'react'
import { Switch } from '@/components/ui/switch'
import { getSkills, getToolsets, toggleSkill, toggleToolset } from '@/hermes'
import { Brain, Wrench } from '@/lib/icons'
import { notify, notifyError } from '@/store/notifications'
import type { SkillInfo, ToolsetInfo } from '@/types/hermes'
import { asText, includesQuery, prettyName, toolNames } from './helpers'
import { ListRow, LoadingState, Pill, SectionHeading, SettingsContent } from './primitives'
import { ToolsetConfigPanel } from './toolset-config-panel'
import type { SearchProps } from './types'
export function ToolsSettings({ query }: SearchProps) {
const [skills, setSkills] = useState<SkillInfo[] | null>(null)
const [toolsets, setToolsets] = useState<ToolsetInfo[] | null>(null)
const [savingSkill, setSavingSkill] = useState<string | null>(null)
const [savingToolset, setSavingToolset] = useState<string | null>(null)
const [expandedToolset, setExpandedToolset] = useState<string | null>(null)
useEffect(() => {
let cancelled = false
Promise.all([getSkills(), getToolsets()])
.then(([s, t]) => {
if (cancelled) {
return
}
setSkills(s)
setToolsets(t)
})
.catch(err => notifyError(err, 'Capabilities failed to load'))
return () => void (cancelled = true)
}, [])
const refreshToolsets = useCallback(() => {
getToolsets()
.then(setToolsets)
.catch(err => notifyError(err, 'Toolsets failed to refresh'))
}, [])
const filteredSkills = useMemo(() => {
if (!skills) {
return []
}
const q = query.trim().toLowerCase()
return skills
.filter(s => !q || includesQuery(s.name, q) || includesQuery(s.description, q) || includesQuery(s.category, q))
.sort(
(a, b) => asText(a.category).localeCompare(asText(b.category)) || asText(a.name).localeCompare(asText(b.name))
)
}, [query, skills])
const filteredToolsets = useMemo(() => {
if (!toolsets) {
return []
}
const q = query.trim().toLowerCase()
return toolsets
.filter(t => {
if (!q) {
return true
}
return (
includesQuery(t.name, q) ||
includesQuery(t.label, q) ||
includesQuery(t.description, q) ||
toolNames(t).some(n => includesQuery(n, q))
)
})
.sort((a, b) => asText(a.label || a.name).localeCompare(asText(b.label || b.name)))
}, [query, toolsets])
const skillGroups = useMemo(() => {
const groups = new Map<string, SkillInfo[]>()
for (const skill of filteredSkills) {
const cat = asText(skill.category) || 'other'
groups.set(cat, [...(groups.get(cat) ?? []), skill])
}
return Array.from(groups).sort(([a], [b]) => a.localeCompare(b))
}, [filteredSkills])
async function handleToggleSkill(skill: SkillInfo, enabled: boolean) {
setSavingSkill(skill.name)
try {
await toggleSkill(skill.name, enabled)
setSkills(c => c?.map(s => (s.name === skill.name ? { ...s, enabled } : s)) ?? c)
notify({
kind: 'success',
title: enabled ? 'Skill enabled' : 'Skill disabled',
message: `${skill.name} applies to new sessions.`
})
} catch (err) {
notifyError(err, `Failed to update ${skill.name}`)
} finally {
setSavingSkill(null)
}
}
async function handleToggleToolset(toolset: ToolsetInfo, enabled: boolean) {
setSavingToolset(toolset.name)
try {
await toggleToolset(toolset.name, enabled)
setToolsets(c => c?.map(t => (t.name === toolset.name ? { ...t, enabled, available: enabled } : t)) ?? c)
notify({
kind: 'success',
title: enabled ? 'Toolset enabled' : 'Toolset disabled',
message: `${asText(toolset.label || toolset.name)} applies to new sessions.`
})
} catch (err) {
notifyError(err, `Failed to update ${asText(toolset.label || toolset.name)}`)
} finally {
setSavingToolset(null)
}
}
if (!skills || !toolsets) {
return <LoadingState label="Loading skills and toolsets..." />
}
return (
<SettingsContent>
<div className="mb-6">
<SectionHeading icon={Brain} meta={`${filteredSkills.filter(s => s.enabled).length} enabled`} title="Skills" />
{skillGroups.map(([category, list]) => (
<div className="mt-4 first:mt-0" key={category}>
<div className="mb-1 text-[0.68rem] font-semibold uppercase tracking-[0.12em] text-muted-foreground">
{prettyName(category)}
</div>
<div className="divide-y divide-border/40">
{list.map(skill => (
<ListRow
action={
<Switch
checked={skill.enabled}
disabled={savingSkill === skill.name}
onCheckedChange={c => void handleToggleSkill(skill, c)}
/>
}
description={asText(skill.description)}
key={asText(skill.name)}
title={asText(skill.name)}
/>
))}
</div>
</div>
))}
</div>
<div className="mb-6">
<SectionHeading
icon={Wrench}
meta={`${filteredToolsets.filter(t => t.enabled).length} enabled`}
title="Toolsets"
/>
<div className="divide-y divide-border/40">
{filteredToolsets.map(toolset => {
const tools = toolNames(toolset)
const label = asText(toolset.label || toolset.name)
const expanded = expandedToolset === toolset.name
return (
<ListRow
action={
<div className="flex shrink-0 items-center gap-1.5">
<button
aria-expanded={expanded}
aria-label={`Configure ${label}`}
className="cursor-pointer rounded-full outline-none focus-visible:ring-2 focus-visible:ring-ring/50"
onClick={() => setExpandedToolset(c => (c === toolset.name ? null : toolset.name))}
type="button"
>
<Pill tone={toolset.configured ? 'primary' : 'muted'}>
{toolset.configured ? 'Configured' : 'Needs keys'}
</Pill>
</button>
<Switch
aria-label={`Toggle ${label} toolset`}
checked={toolset.enabled}
disabled={savingToolset === toolset.name}
onCheckedChange={c => void handleToggleToolset(toolset, c)}
/>
</div>
}
below={
<>
{tools.length > 0 && (
<div className="mt-3 flex flex-wrap gap-1">
{tools.slice(0, 10).map(t => (
<span
className="rounded-md bg-muted px-1.5 py-0.5 font-mono text-[0.64rem] text-muted-foreground"
key={t}
>
{t}
</span>
))}
{tools.length > 10 && (
<span className="rounded-md bg-muted px-1.5 py-0.5 text-[0.64rem] text-muted-foreground">
+{tools.length - 10} more
</span>
)}
</div>
)}
{expanded && (
<ToolsetConfigPanel onConfiguredChange={refreshToolsets} toolset={toolset.name} />
)}
</>
}
description={asText(toolset.description)}
key={asText(toolset.name) || label}
title={label}
/>
)
})}
</div>
</div>
</SettingsContent>
)
}
@@ -26,7 +26,6 @@ function config(overrides: Partial<ToolsetConfig> = {}): ToolsetConfig {
return {
name: 'tts',
has_category: true,
active_provider: null,
providers: [
{
name: 'Microsoft Edge TTS',
@@ -34,8 +33,7 @@ function config(overrides: Partial<ToolsetConfig> = {}): ToolsetConfig {
tag: 'No API key needed',
env_vars: [],
post_setup: null,
requires_nous_auth: false,
is_active: false
requires_nous_auth: false
},
{
name: 'ElevenLabs',
@@ -45,8 +43,7 @@ function config(overrides: Partial<ToolsetConfig> = {}): ToolsetConfig {
{ key: 'ELEVENLABS_API_KEY', prompt: 'ElevenLabs API key', url: 'https://x', default: null, is_set: false }
],
post_setup: null,
requires_nous_auth: false,
is_active: false
requires_nous_auth: false
}
],
...overrides
@@ -102,54 +99,4 @@ describe('ToolsetConfigPanel', () => {
await waitFor(() => expect(setEnvVar).toHaveBeenCalledWith('ELEVENLABS_API_KEY', 'sk-test-123'))
})
it('expands the active provider on load, not just the first configured one', async () => {
// ElevenLabs is the active provider per config, even though the keyless
// Edge TTS provider sorts first and is also "configured". The panel must
// honor is_active and expand ElevenLabs (so its API-key field renders)
// rather than defaulting to the first keyless provider. Regression test
// for the GUI showing the wrong provider selected after relaunch.
getToolsetConfig.mockResolvedValue(
config({
active_provider: 'ElevenLabs',
providers: [
{
name: 'Microsoft Edge TTS',
badge: 'free',
tag: 'No API key needed',
env_vars: [],
post_setup: null,
requires_nous_auth: false,
is_active: false
},
{
name: 'ElevenLabs',
badge: 'paid',
tag: 'Most natural voices',
env_vars: [
{
key: 'ELEVENLABS_API_KEY',
prompt: 'ElevenLabs API key',
url: 'https://x',
default: null,
is_set: true
}
],
post_setup: null,
requires_nous_auth: false,
is_active: true
}
]
})
)
const { ToolsetConfigPanel } = await import('./toolset-config-panel')
render(<ToolsetConfigPanel onConfiguredChange={vi.fn()} toolset="tts" />)
// The active provider's env-var field only renders when it's the expanded
// one — so finding it proves ElevenLabs (not Edge TTS) was auto-expanded.
expect(await screen.findByText('ELEVENLABS_API_KEY')).toBeTruthy()
// No provider selection was triggered — this is purely reflecting state.
expect(selectToolsetProvider).not.toHaveBeenCalled()
})
})
@@ -195,23 +195,16 @@ export function ToolsetConfigPanel({ toolset, onConfiguredChange }: ToolsetConfi
const providers = useMemo(() => cfg?.providers ?? [], [cfg])
// Default the expanded provider to the one actually active in config
// (`is_active` / `cfg.active_provider`, mirroring the CLI picker), then the
// first fully-configured provider, else the first provider. Without this the
// panel highlighted the first keyless provider (e.g. Nous Portal) even when
// the user had already selected another (e.g. DuckDuckGo).
// Default the expanded provider to the first one that is fully configured,
// else the first provider.
useEffect(() => {
if (activeProvider || providers.length === 0) {
return
}
const selected =
providers.find(p => p.is_active) ??
(cfg?.active_provider ? providers.find(p => p.name === cfg.active_provider) : undefined) ??
providers.find(p => providerConfigured(p, envState)) ??
providers[0]
setActiveProvider(selected.name)
}, [activeProvider, providers, envState, cfg])
const configured = providers.find(p => providerConfigured(p, envState))
setActiveProvider((configured ?? providers[0]).name)
}, [activeProvider, providers, envState])
async function handleSelect(provider: ToolProvider) {
setActiveProvider(provider.name)
+2 -3
View File
@@ -4,15 +4,14 @@ import type { HermesGateway } from '@/hermes'
import type { IconComponent } from '@/lib/icons'
import type { EnvVarInfo } from '@/types/hermes'
export type SettingsView = 'about' | 'gateway' | 'keys' | 'mcp' | 'sessions' | `config:${string}`
export type SettingsQueryKey = 'about' | 'config' | 'gateway' | 'keys' | 'mcp' | 'sessions'
export type SettingsView = 'about' | 'gateway' | 'keys' | 'mcp' | 'sessions' | 'tools' | `config:${string}`
export type SettingsQueryKey = 'about' | 'config' | 'gateway' | 'keys' | 'mcp' | 'sessions' | 'tools'
export type EnvPatch = Partial<Pick<EnvVarInfo, 'is_set' | 'redacted_value'>>
export interface SettingsPageProps {
gateway?: HermesGateway | null
onClose: () => void
onConfigSaved?: () => void
onMainModelChanged?: (provider: string, model: string) => void
}
export interface SearchProps {
@@ -4,7 +4,7 @@ import { useLocation, useNavigate } from 'react-router-dom'
import { type CommandCenterSection } from '@/app/command-center'
import { AGENTS_ROUTE, appViewForPath, COMMAND_CENTER_ROUTE, NEW_CHAT_ROUTE } from '@/app/routes'
const SECTIONS = ['sessions', 'system', 'usage'] as const
const SECTIONS = ['models', 'sessions', 'system'] as const
const OVERLAY_VIEWS = new Set(['settings', 'command-center', 'agents'])
export function useOverlayRouting() {
+6 -50
View File
@@ -6,7 +6,7 @@ import { Button } from '@/components/ui/button'
import { Codicon } from '@/components/ui/codicon'
import { Switch } from '@/components/ui/switch'
import { TextTab, TextTabMeta } from '@/components/ui/text-tab'
import { getSkills, getToolsets, toggleSkill, toggleToolset } from '@/hermes'
import { getSkills, getToolsets, toggleSkill } from '@/hermes'
import { cn } from '@/lib/utils'
import { notify, notifyError } from '@/store/notifications'
import type { SkillInfo, ToolsetInfo } from '@/types/hermes'
@@ -14,7 +14,6 @@ import type { SkillInfo, ToolsetInfo } from '@/types/hermes'
import { useRouteEnumParam } from '../hooks/use-route-enum-param'
import { PageSearchShell } from '../page-search-shell'
import { asText, includesQuery, prettyName, toolNames } from '../settings/helpers'
import { ToolsetConfigPanel } from '../settings/toolset-config-panel'
import type { SetStatusbarItemGroup } from '../shell/statusbar-controls'
const SKILLS_MODES = ['skills', 'toolsets'] as const
@@ -74,8 +73,6 @@ export function SkillsView({ setStatusbarItemGroup: _setStatusbarItemGroup, ...p
const [activeCategory, setActiveCategory] = useState<string | null>(null)
const [refreshing, setRefreshing] = useState(false)
const [savingSkill, setSavingSkill] = useState<string | null>(null)
const [savingToolset, setSavingToolset] = useState<string | null>(null)
const [expandedToolset, setExpandedToolset] = useState<string | null>(null)
const refreshCapabilities = useCallback(async () => {
setRefreshing(true)
@@ -91,12 +88,6 @@ export function SkillsView({ setStatusbarItemGroup: _setStatusbarItemGroup, ...p
}
}, [])
const refreshToolsets = useCallback(() => {
getToolsets()
.then(setToolsets)
.catch(err => notifyError(err, 'Toolsets failed to refresh'))
}, [])
useEffect(() => {
void refreshCapabilities()
}, [refreshCapabilities])
@@ -157,26 +148,6 @@ export function SkillsView({ setStatusbarItemGroup: _setStatusbarItemGroup, ...p
}
}
async function handleToggleToolset(toolset: ToolsetInfo, enabled: boolean) {
setSavingToolset(toolset.name)
try {
await toggleToolset(toolset.name, enabled)
setToolsets(current =>
current?.map(row => (row.name === toolset.name ? { ...row, enabled, available: enabled } : row)) ?? current
)
notify({
kind: 'success',
title: enabled ? 'Toolset enabled' : 'Toolset disabled',
message: `${asText(toolset.label || toolset.name)} applies to new sessions.`
})
} catch (err) {
notifyError(err, `Failed to update ${asText(toolset.label || toolset.name)}`)
} finally {
setSavingToolset(null)
}
}
return (
<PageSearchShell
{...props}
@@ -277,30 +248,16 @@ export function SkillsView({ setStatusbarItemGroup: _setStatusbarItemGroup, ...p
{visibleToolsets.map(toolset => {
const tools = toolNames(toolset)
const label = asText(toolset.label || toolset.name)
const expanded = expandedToolset === toolset.name
return (
<div className="px-0 py-2.5" key={toolset.name}>
<div className="flex items-center justify-between gap-2">
<div className="truncate text-sm font-medium">{label}</div>
<div className="flex shrink-0 items-center gap-1.5">
<button
aria-expanded={expanded}
aria-label={`Configure ${label}`}
className="cursor-pointer rounded-full outline-none focus-visible:ring-2 focus-visible:ring-ring/50"
onClick={() => setExpandedToolset(current => (current === toolset.name ? null : toolset.name))}
type="button"
>
<StatusPill active={toolset.configured}>
{toolset.configured ? 'Configured' : 'Needs keys'}
</StatusPill>
</button>
<Switch
aria-label={`Toggle ${label} toolset`}
checked={toolset.enabled}
disabled={savingToolset === toolset.name}
onCheckedChange={checked => void handleToggleToolset(toolset, checked)}
/>
<div className="flex items-center gap-1.5">
<StatusPill active={toolset.enabled}>{toolset.enabled ? 'Enabled' : 'Disabled'}</StatusPill>
<StatusPill active={toolset.configured}>
{toolset.configured ? 'Configured' : 'Needs keys'}
</StatusPill>
</div>
</div>
<p className="mt-1 text-xs text-muted-foreground">
@@ -318,7 +275,6 @@ export function SkillsView({ setStatusbarItemGroup: _setStatusbarItemGroup, ...p
))}
</div>
)}
{expanded && <ToolsetConfigPanel onConfiguredChange={refreshToolsets} toolset={toolset.name} />}
</div>
)
})}
@@ -62,7 +62,9 @@ function formatStageName(name: string): string {
if (name.length <= 3) return name
return name
.split('-')
.map((word, i) => (i === 0 ? word.charAt(0).toUpperCase() + word.slice(1) : word))
.map((word, i) =>
i === 0 ? word.charAt(0).toUpperCase() + word.slice(1) : word
)
.join(' ')
}
@@ -114,10 +116,17 @@ function StageRow({ descriptor, result, isCurrent, now }: StageRowProps) {
state === 'failed' && 'bg-destructive/10'
)}
>
<div className="flex h-5 w-5 flex-shrink-0 items-center justify-center">{icon}</div>
<div className="flex h-5 w-5 flex-shrink-0 items-center justify-center">
{icon}
</div>
<div className="min-w-0 flex-1">
<div className="flex items-baseline justify-between gap-2">
<span className={cn('truncate text-sm font-medium', state === 'pending' && 'text-muted-foreground')}>
<span
className={cn(
'truncate text-sm font-medium',
state === 'pending' && 'text-muted-foreground'
)}
>
{formatStageName(descriptor.name)}
</span>
<span className="flex-shrink-0 text-xs tabular-nums text-muted-foreground">
@@ -126,7 +135,9 @@ function StageRow({ descriptor, result, isCurrent, now }: StageRowProps) {
{state === 'failed' ? STATE_LABEL[state] : null}
</span>
</div>
{reason && state !== 'pending' && <p className="mt-0.5 truncate text-xs text-muted-foreground">{reason}</p>}
{reason && state !== 'pending' && (
<p className="mt-0.5 truncate text-xs text-muted-foreground">{reason}</p>
)}
</div>
</li>
)
@@ -169,7 +180,7 @@ function applyEvent(state: DesktopBootstrapState, ev: DesktopBootstrapEvent): De
durationMs: ev.durationMs ?? null,
// Stamp the start time on the running transition so the UI can show
// a live elapsed timer; preserve it across repeated running events.
startedAt: ev.state === 'running' ? (prev?.startedAt ?? Date.now()) : (prev?.startedAt ?? null),
startedAt: ev.state === 'running' ? prev?.startedAt ?? Date.now() : prev?.startedAt ?? null,
json: ev.json ?? null,
error: ev.error ?? null
}
@@ -206,7 +217,6 @@ export function DesktopInstallOverlay({ enabled = true }: DesktopInstallOverlayP
const [state, setState] = useState<DesktopBootstrapState>(EMPTY_STATE)
const [logOpen, setLogOpen] = useState(false)
const [copied, setCopied] = useState(false)
const [cancelling, setCancelling] = useState(false)
const [now, setNow] = useState(() => Date.now())
const logEndRef = useRef<HTMLDivElement | null>(null)
@@ -283,8 +293,8 @@ export function DesktopInstallOverlay({ enabled = true }: DesktopInstallOverlayP
<div className="w-full max-w-xl rounded-xl border bg-card p-8 shadow-xl">
<h2 className="text-2xl font-semibold tracking-tight">Hermes needs a one-time install</h2>
<p className="mt-2 text-sm text-muted-foreground">
Automated first-launch install isn{'\u2019'}t available on {platformLabel} yet. Open Terminal and run the
command below, then relaunch this app. Subsequent launches will skip this step.
Automated first-launch install isn{'\u2019'}t available on {platformLabel} yet. Open Terminal and
run the command below, then relaunch this app. Subsequent launches will skip this step.
</p>
<div className="mt-4">
@@ -318,7 +328,11 @@ export function DesktopInstallOverlay({ enabled = true }: DesktopInstallOverlayP
<span className="text-xs text-muted-foreground">
Will install to <code className="rounded bg-muted/50 px-1 py-0.5 font-mono">{ups.activeRoot}</code>
</span>
<Button variant="default" size="sm" onClick={() => window.location.reload()}>
<Button
variant="default"
size="sm"
onClick={() => window.location.reload()}
>
I{'\u2019'}ve run it -- retry
</Button>
</div>
@@ -348,7 +362,7 @@ export function DesktopInstallOverlay({ enabled = true }: DesktopInstallOverlayP
</h2>
<p className="mt-1.5 text-sm text-muted-foreground">
{failed
? 'One of the install steps failed. On Windows, this can happen if another Hermes CLI or desktop instance is running. Stop any running Hermes instances, then retry. Check the details below or the desktop log for the full transcript.'
? 'One of the install steps failed. Check the details below or the desktop log for the full transcript.'
: 'This is a one-time setup. The Hermes installer is downloading dependencies and configuring your machine. ' +
'Subsequent launches will skip this step.'}
</p>
@@ -368,7 +382,10 @@ export function DesktopInstallOverlay({ enabled = true }: DesktopInstallOverlayP
</div>
<div className="h-1.5 w-full overflow-hidden rounded-full bg-muted">
<div
className={cn('h-full transition-all duration-300', failed ? 'bg-destructive' : 'bg-primary')}
className={cn(
'h-full transition-all duration-300',
failed ? 'bg-destructive' : 'bg-primary'
)}
style={{ width: `${progressPct}%` }}
/>
</div>
@@ -414,18 +431,14 @@ export function DesktopInstallOverlay({ enabled = true }: DesktopInstallOverlayP
>
{logOpen ? <ChevronDown className="h-3.5 w-3.5" /> : <ChevronRight className="h-3.5 w-3.5" />}
<span>{logOpen ? 'Hide installer output' : 'Show installer output'}</span>
<span className="ml-1 tabular-nums">
({state.log.length} line{state.log.length === 1 ? '' : 's'})
</span>
<span className="ml-1 tabular-nums">({state.log.length} line{state.log.length === 1 ? '' : 's'})</span>
</button>
{logOpen && (
<div
className={cn(
'mt-2 overflow-auto rounded-md border bg-muted/30 p-2 font-mono text-[11px] leading-relaxed',
failed ? 'max-h-96' : 'max-h-64'
)}
>
<div className={cn(
'mt-2 overflow-auto rounded-md border bg-muted/30 p-2 font-mono text-[11px] leading-relaxed',
failed ? 'max-h-96' : 'max-h-64'
)}>
{state.log.length === 0 ? (
<div className="text-muted-foreground">No output yet.</div>
) : (
@@ -444,38 +457,12 @@ export function DesktopInstallOverlay({ enabled = true }: DesktopInstallOverlayP
</div>
</div>
{/* Active footer: let the user actually cancel a running install. */}
{state.active && !failed && (
<div className="flex-shrink-0 border-t bg-card p-4">
<div className="flex items-center justify-end">
<Button
disabled={cancelling}
onClick={async () => {
setCancelling(true)
try {
await window.hermesDesktop?.cancelBootstrap?.()
} catch {
// ignore -- the failed/cancelled event will surface the result
}
}}
size="sm"
variant="ghost"
>
{cancelling ? <Loader2 className="h-4 w-4 animate-spin" /> : null}
{cancelling ? 'Cancelling...' : 'Cancel install'}
</Button>
</div>
</div>
)}
{/* Footer -- always visible, never scrolls; only renders on failure */}
{failed && (
<div className="flex-shrink-0 border-t bg-card p-4">
<div className="flex items-center justify-between gap-2">
<span className="text-xs text-muted-foreground">
Full transcript saved to{' '}
<code className="rounded bg-muted/50 px-1 py-0.5 font-mono">%LOCALAPPDATA%\hermes\logs\</code>
Full transcript saved to <code className="rounded bg-muted/50 px-1 py-0.5 font-mono">%LOCALAPPDATA%\hermes\logs\</code>
</span>
<div className="flex gap-2">
<Button
+7 -2
View File
@@ -48,7 +48,6 @@ declare global {
getBootstrapState: () => Promise<DesktopBootstrapState>
resetBootstrap: () => Promise<{ ok: boolean }>
repairBootstrap: () => Promise<{ ok: boolean }>
cancelBootstrap: () => Promise<{ ok: boolean; cancelled: boolean }>
onBootstrapEvent: (callback: (payload: DesktopBootstrapEvent) => void) => () => void
getVersion: () => Promise<DesktopVersionInfo>
updates: {
@@ -195,7 +194,12 @@ export interface DesktopBootstrapStageDescriptor {
needs_user_input?: boolean
}
export type DesktopBootstrapStageState = 'pending' | 'running' | 'succeeded' | 'skipped' | 'failed'
export type DesktopBootstrapStageState =
| 'pending'
| 'running'
| 'succeeded'
| 'skipped'
| 'failed'
export interface DesktopBootstrapStageResult {
state: DesktopBootstrapStageState
@@ -244,6 +248,7 @@ export type DesktopBootstrapEvent =
docsUrl: string
}
export interface HermesApiRequest {
path: string
method?: string
+2 -3
View File
@@ -114,11 +114,10 @@ export class HermesGateway extends JsonRpcGatewayClient {
export async function listSessions(
limit = 40,
minMessages = 0,
archived: 'exclude' | 'include' | 'only' = 'exclude',
order: 'created' | 'recent' = 'recent'
archived: 'exclude' | 'include' | 'only' = 'exclude'
): Promise<PaginatedSessions> {
const result = await window.hermesDesktop.api<PaginatedSessions>({
path: `/api/sessions?limit=${limit}&offset=0&min_messages=${Math.max(0, minMessages)}&archived=${archived}&order=${order}`
path: `/api/sessions?limit=${limit}&offset=0&min_messages=${Math.max(0, minMessages)}&archived=${archived}`
})
return {
-36
View File
@@ -1,36 +0,0 @@
import { describe, expect, it } from 'vitest'
import type { SessionInfo } from '@/types/hermes'
import { sessionPinId } from './session'
const session = (over: Partial<SessionInfo>): SessionInfo => ({
archived: false,
cwd: null,
ended_at: null,
id: 'live',
input_tokens: 0,
is_active: false,
last_active: 0,
message_count: 0,
model: null,
output_tokens: 0,
preview: null,
source: null,
started_at: 0,
title: null,
tool_call_count: 0,
...over
})
describe('sessionPinId', () => {
it('uses the live id when there is no compression lineage', () => {
expect(sessionPinId(session({ id: 'abc' }))).toBe('abc')
})
it('uses the lineage root so a pin survives compression', () => {
// After auto-compression the entry surfaces under a fresh tip id but keeps
// the original root — pinning on the root keeps the pin stable.
expect(sessionPinId(session({ id: 'tip', _lineage_root_id: 'root' }))).toBe('root')
})
})
-6
View File
@@ -16,12 +16,6 @@ function updateAtom<T>(store: AppAtom<T>, next: Updater<T>) {
store.set(typeof next === 'function' ? (next as (current: T) => T)(store.get()) : next)
}
/** Durable id for pinning. Auto-compression rotates a conversation's session
* id (root -> continuation tip), so pins keyed on the live id evaporate. The
* lineage root is stable across every compression, so we pin on that. */
export const sessionPinId = (session: Pick<SessionInfo, '_lineage_root_id' | 'id'>): string =>
session._lineage_root_id ?? session.id
export const $connection = atom<HermesConnection | null>(null)
export const $gatewayState = atom('idle')
export const $sessions = atom<SessionInfo[]>([])
-77
View File
@@ -1,77 +0,0 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { DesktopUpdateStatus } from '@/global'
const storage = new Map<string, string>()
vi.mock('@/lib/storage', () => ({
persistString: (key: string, value: null | string) => {
if (value === null) {
storage.delete(key)
} else {
storage.set(key, value)
}
},
storedString: (key: string) => storage.get(key) ?? null
}))
const notifySpy = vi.fn()
const dismissSpy = vi.fn()
vi.mock('@/store/notifications', () => ({
notify: (...args: unknown[]) => notifySpy(...args),
dismissNotification: (...args: unknown[]) => dismissSpy(...args)
}))
const { maybeNotifyUpdateAvailable } = await import('./updates')
const status = (over: Partial<DesktopUpdateStatus> = {}): DesktopUpdateStatus => ({
supported: true,
behind: 3,
targetSha: 'sha-a',
fetchedAt: 0,
...over
})
const lastToast = () => notifySpy.mock.calls.at(-1)?.[0] as { onDismiss: () => void }
describe('maybeNotifyUpdateAvailable', () => {
beforeEach(() => {
storage.clear()
notifySpy.mockClear()
vi.useRealTimers()
})
it('shows when an update is available and not snoozed', () => {
maybeNotifyUpdateAvailable(status())
expect(notifySpy).toHaveBeenCalledTimes(1)
})
it('stays quiet for new commits once the toast was closed', () => {
maybeNotifyUpdateAvailable(status())
lastToast().onDismiss() // user closes it → cooldown starts
notifySpy.mockClear()
// A different commit lands while still within the cooldown window.
maybeNotifyUpdateAvailable(status({ targetSha: 'sha-b', behind: 9 }))
expect(notifySpy).not.toHaveBeenCalled()
})
it('re-shows once the cooldown elapses', () => {
vi.useFakeTimers()
vi.setSystemTime(0)
maybeNotifyUpdateAvailable(status())
lastToast().onDismiss()
notifySpy.mockClear()
vi.setSystemTime(25 * 60 * 60 * 1000) // > 24h cooldown
maybeNotifyUpdateAvailable(status({ targetSha: 'sha-b' }))
expect(notifySpy).toHaveBeenCalledTimes(1)
})
it('does nothing when already up to date', () => {
maybeNotifyUpdateAvailable(status({ behind: 0 }))
expect(notifySpy).not.toHaveBeenCalled()
})
})
+18 -25
View File
@@ -48,22 +48,7 @@ export const setUpdateOverlayOpen = (open: boolean) => $updateOverlayOpen.set(op
export const resetUpdateApplyState = () => $updateApply.set(IDLE)
const UPDATE_TOAST_ID = 'desktop-update-available'
// Time-based snooze instead of per-sha dismissal: this repo lands ~100 commits
// a day, so a "don't show this exact sha again" guard re-popped the toast on
// every new commit. We instead suppress the toast for a cooldown window that
// (re)starts whenever the user closes it.
const UPDATE_TOAST_SNOOZE_KEY = 'hermes:update-toast-snooze-until'
const UPDATE_TOAST_COOLDOWN_MS = 24 * 60 * 60 * 1000
function snoozeUpdateToast(): void {
persistString(UPDATE_TOAST_SNOOZE_KEY, String(Date.now() + UPDATE_TOAST_COOLDOWN_MS))
}
function isUpdateToastSnoozed(): boolean {
const until = Number(storedString(UPDATE_TOAST_SNOOZE_KEY) || 0)
return Number.isFinite(until) && Date.now() < until
}
const UPDATE_TOAST_DISMISSED_KEY = 'hermes:update-toast-dismissed-sha'
// Must match tui_gateway's DESKTOP_BACKEND_CONTRACT that this build was written
// against. The backend reports its own value in session runtime info; a lower
@@ -89,18 +74,25 @@ export function reportBackendContract(contract: number | undefined): void {
durationMs: 0,
id: SKEW_TOAST_ID,
kind: 'warning',
message: 'Your Hermes backend is older than this desktop build and may not work correctly. Update to align them.',
message:
'Your Hermes backend is older than this desktop build and may not work correctly. Update to align them.',
title: 'Backend out of date'
})
}
function markToastDismissed(sha: string | undefined) {
if (sha) {
persistString(UPDATE_TOAST_DISMISSED_KEY, sha)
}
}
/**
* Fire a toast when an update is available, at most once per cooldown window.
* Closing the toast — dismissing it or opening the updates window from it —
* (re)starts the cooldown, so a busy upstream branch doesn't re-spam the user
* on every new commit. The snooze is persisted, so it survives relaunches too.
* Fire a one-shot toast the first time we see a particular target commit so
* users don't have to notice the status-bar version pill turning colors.
* Dismissal is remembered per-target-sha so the toast doesn't keep popping
* back for the same update across restarts.
*/
export function maybeNotifyUpdateAvailable(status: DesktopUpdateStatus | null) {
function maybeNotifyUpdateAvailable(status: DesktopUpdateStatus | null) {
if (!status || status.supported === false || status.error || !status.targetSha) {
return
}
@@ -109,7 +101,7 @@ export function maybeNotifyUpdateAvailable(status: DesktopUpdateStatus | null) {
return
}
if (isUpdateToastSnoozed()) {
if (storedString(UPDATE_TOAST_DISMISSED_KEY) === status.targetSha) {
return
}
@@ -118,12 +110,13 @@ export function maybeNotifyUpdateAvailable(status: DesktopUpdateStatus | null) {
}
const behind = status.behind ?? 0
const targetSha = status.targetSha
notify({
action: {
label: "See what's new",
onClick: () => {
snoozeUpdateToast()
markToastDismissed(targetSha)
openUpdatesWindow()
}
},
@@ -131,7 +124,7 @@ export function maybeNotifyUpdateAvailable(status: DesktopUpdateStatus | null) {
id: UPDATE_TOAST_ID,
kind: 'info',
message: `${behind} new change${behind === 1 ? '' : 's'} available.`,
onDismiss: () => snoozeUpdateToast(),
onDismiss: () => markToastDismissed(targetSha),
title: 'Update ready'
})
}
-9
View File
@@ -244,10 +244,6 @@ export interface SessionInfo {
cwd?: null | string
ended_at: null | number
id: string
/** Original root id of a compression chain, when this entry is a projected
* continuation tip. Stable across compressions — used as the durable id for
* pins so a pinned conversation survives auto-compression. */
_lineage_root_id?: null | string
input_tokens: number
is_active: boolean
last_active: number
@@ -475,17 +471,12 @@ export interface ToolProvider {
env_vars: ToolEnvVar[]
post_setup: string | null
requires_nous_auth: boolean
/** True when this is the provider currently written to config (mirrors the
* CLI `hermes tools` active-provider detection). */
is_active: boolean
}
export interface ToolsetConfig {
name: string
has_category: boolean
providers: ToolProvider[]
/** Name of the currently active provider, or null if none is configured. */
active_provider: string | null
}
export interface SessionSearchResult {
-64
View File
@@ -872,17 +872,6 @@ _cleanup_done = False
# Weak reference to the active AIAgent for memory provider shutdown at exit
_active_agent_ref = None
_deferred_agent_startup_done = False
# Set True once the TUI's prompt_toolkit app starts (which enables focus
# reporting + mouse tracking). Gates the on-exit terminal reset so non-TUI
# one-shot CLI runs — which also register _run_cleanup via atexit — don't emit
# escape codes for modes they never enabled (#36823).
_tui_input_modes_active = False
def _mark_tui_input_modes_active() -> None:
"""Record that the TUI app started, so _run_cleanup resets input modes."""
global _tui_input_modes_active
_tui_input_modes_active = True
def _prepare_deferred_agent_startup() -> None:
@@ -938,12 +927,6 @@ def _run_cleanup():
return
_cleanup_done = True
# Reset terminal input modes first, before the slower resource teardown
# below (MCP / browser / memory shutdown can take seconds). On Ctrl+C the
# user's terminal becomes usable immediately, and a later step raising
# can't skip the reset (#36823). No-op unless the TUI actually ran.
_reset_terminal_input_modes_on_exit()
try:
_cleanup_all_terminals()
except Exception:
@@ -989,50 +972,6 @@ def _run_cleanup():
pass
def _reset_terminal_input_modes_on_exit() -> None:
"""Best-effort: disable focus reporting + mouse tracking on TUI exit so they
don't leak into the next shell session sharing the tab.
prompt_toolkit restores these on a clean teardown, but Ctrl+C, SIGTERM /
SIGHUP and crashes can bypass its unwind, leaving the modes enabled. The
terminal then emits raw ``ESC[I`` / ``ESC[O`` focus events and fragmented
SGR mouse reports as visible text in whatever runs next in the same tab
(#36823). Called from ``_run_cleanup`` (atexit-registered + invoked on the
normal / EOF / interrupt exit paths) this covers normal quit, Ctrl+C and
SIGTERM/SIGHUP. ``kill -9`` is uncatchable, and the kanban worker's
``os._exit(0)`` path bypasses ``atexit``; neither runs this but both are
non-TTY / non-TUI, so there is nothing to reset there.
Gated on ``_tui_input_modes_active`` so one-shot non-TUI CLI runs (which
share ``_run_cleanup`` via ``atexit``) never emit these codes. Writes to the
controlling terminal directly: by exit, prompt_toolkit's own output is torn
down, so ``sys.stdout`` is the real fd; falls back to ``/dev/tty`` when
stdout is redirected away from the terminal.
"""
global _tui_input_modes_active
if not _tui_input_modes_active:
return
# About to disable the modes — clear the flag so a re-armed _run_cleanup (or
# a long-lived process that reuses it) doesn't re-emit them.
_tui_input_modes_active = False
# Prefer stdout when it's the terminal; otherwise the TUI may have driven
# /dev/tty while stdout was redirected — reset there instead of nowhere.
try:
stream = sys.stdout
if stream is not None and stream.isatty():
stream.write(_TERMINAL_INPUT_MODE_RESET_SEQ)
stream.flush()
return
except Exception:
pass
try:
with open("/dev/tty", "w", encoding="ascii") as tty:
tty.write(_TERMINAL_INPUT_MODE_RESET_SEQ)
tty.flush()
except Exception:
pass
# =============================================================================
# Git Worktree Isolation (#652)
# =============================================================================
@@ -15196,9 +15135,6 @@ class HermesCLI:
pass # No running loop -- nothing to patch
except Exception:
pass
# The app enables focus reporting + mouse tracking; record that
# so _run_cleanup resets them on exit (#36823).
_mark_tui_input_modes_active()
app.run()
except (EOFError, KeyboardInterrupt, BrokenPipeError):
pass
+4 -11
View File
@@ -361,17 +361,10 @@ class StreamingConfig:
# fall back to edit-based when not.
# "draft" — explicitly request native drafts; falls back to edit when
# the platform/chat doesn't support them.
# "edit" — progressive editMessageText only (legacy behaviour).
# "edit" — progressive editMessageText only (legacy/default
# behaviour).
# "off" — disable streaming entirely.
#
# Default is "auto": prefer native draft streaming on platforms that
# support it (Telegram DMs via sendMessageDraft, Bot API 9.5+) and fall
# back to edit-based streaming everywhere else. This is safe as a global
# default because adapters without draft support (Discord, Slack, Matrix,
# …) report supports_draft_streaming() == False and transparently use the
# edit path — so "auto" never regresses non-Telegram platforms, it only
# upgrades the chats that can render the smoother native preview.
transport: str = "auto"
transport: str = "edit"
edit_interval: float = DEFAULT_STREAMING_EDIT_INTERVAL
buffer_threshold: int = DEFAULT_STREAMING_BUFFER_THRESHOLD
cursor: str = DEFAULT_STREAMING_CURSOR
@@ -400,7 +393,7 @@ class StreamingConfig:
return cls()
return cls(
enabled=_coerce_bool(data.get("enabled"), False),
transport=data.get("transport", "auto"),
transport=data.get("transport", "edit"),
edit_interval=_coerce_float(
data.get("edit_interval"), DEFAULT_STREAMING_EDIT_INTERVAL,
),
-78
View File
@@ -1916,84 +1916,6 @@ class BasePlatformAdapter(ABC):
f"{type(self).__name__} does not implement send_draft"
)
# ── Structured stream-event rendering ────────────────────────────────
#
# These methods let an adapter decide *how* to present each structured
# streaming event (see gateway/stream_events.py). The default
# implementations reproduce the historical behavior exactly: assistant
# text/commentary/segment events delegate to the stream consumer, and
# tool events render the same "emoji tool_name: preview" chrome the
# gateway has always produced. Adapters override these to be more native
# to their platform (e.g. Telegram streaming a MarkdownV2 ```bash``` block
# as a draft; iMessage eating tool chrome it cannot format).
#
# The contract is presentation-only: nothing rendered here is persisted to
# conversation history. History is owned by the agent; what an adapter
# chooses to "eat" must never change the bytes the agent stored.
def render_message_event(self, event: Any, sink: Any) -> None:
"""Render a MessageChunk / MessageStop / Commentary onto the sink.
Default: map onto the stream consumer's existing primitives, preserving
today's behavior 1:1. ``sink`` is a GatewayStreamConsumer.
"""
from gateway.stream_events import MessageChunk, MessageStop, Commentary
if isinstance(event, MessageChunk):
if event.text:
sink.on_delta(event.text)
elif isinstance(event, MessageStop):
# An intermediate stop (text → tool → text) is a segment break;
# the terminal stop is signalled by the gateway via finish(),
# not here, so we only break segments on non-final stops.
if not event.final:
sink.on_segment_break()
elif isinstance(event, Commentary):
if event.text:
sink.on_commentary(event.text)
def format_tool_event(self, event: Any, *, mode: str = "all",
preview_max_len: int = 40) -> Optional[str]:
"""Return the rendered chrome for a ToolCallChunk, or None to eat it.
Reproduces the gateway's historical tool-progress formatting: an emoji
for the tool, the tool name, and a short argument preview (or the full
args dict in ``verbose`` mode). Adapters that cannot render tool chrome
(no message editing, plain-text only) should override to return None so
the event is dropped rather than spamming separate bubbles.
``mode`` is the resolved tool-progress mode ("all" / "new" / "verbose");
``preview_max_len`` mirrors the ``tool_preview_length`` config (0 means
"no cap" in verbose mode).
"""
from gateway.stream_events import ToolCallChunk
if not isinstance(event, ToolCallChunk):
return None
from agent.display import get_tool_emoji
emoji = get_tool_emoji(event.tool_name, default="⚙️")
if mode == "verbose":
if event.args:
import json
args_str = json.dumps(event.args, ensure_ascii=False, default=str)
if preview_max_len > 0 and len(args_str) > preview_max_len:
args_str = args_str[:preview_max_len - 3] + "..."
return f"{emoji} {event.tool_name}({list(event.args.keys())})\n{args_str}"
if event.preview:
return f"{emoji} {event.tool_name}: \"{event.preview}\""
return f"{emoji} {event.tool_name}..."
# "all" / "new": short preview, capped (default 40 to keep gateway
# progress bubbles compact — they persist as permanent messages).
preview = event.preview
if preview:
cap = preview_max_len if preview_max_len > 0 else 40
if len(preview) > cap:
preview = preview[:cap - 3] + "..."
return f"{emoji} {event.tool_name}: \"{preview}\""
return f"{emoji} {event.tool_name}..."
@property
def has_fatal_error(self) -> bool:
return self._fatal_error_message is not None
+23 -47
View File
@@ -2521,55 +2521,31 @@ class TelegramAdapter(BasePlatformAdapter):
text = content if len(content) <= self.MAX_MESSAGE_LENGTH else \
self.truncate_message(content, self.MAX_MESSAGE_LENGTH, len_fn=utf16_len)[0]
kwargs: Dict[str, Any] = {
"chat_id": int(chat_id),
"draft_id": int(draft_id),
"text": text,
}
thread_id = self._metadata_thread_id(metadata)
if thread_id is not None:
kwargs["message_thread_id"] = thread_id
# Apply the same MarkdownV2 conversion the regular ``send`` path uses
# so the animated draft preview renders with identical formatting to
# the final message. Without this, the draft streams as raw text and
# the final ``sendMessage`` (which DOES use MarkdownV2) snaps into
# formatted output, producing a jarring visual shift at the end of the
# response. We try MarkdownV2 first and fall back to plain text if a
# malformed escape would be rejected — mirroring the (True, False)
# retry the streaming send loop uses — so a single bad token never
# kills draft streaming for the whole response.
for use_markdown in (True, False):
kwargs: Dict[str, Any] = {
"chat_id": int(chat_id),
"draft_id": int(draft_id),
"text": self.format_message(text) if use_markdown else text,
}
if use_markdown:
kwargs["parse_mode"] = ParseMode.MARKDOWN_V2
if thread_id is not None:
kwargs["message_thread_id"] = thread_id
try:
ok = await self._bot.send_message_draft(**kwargs)
if ok:
# Drafts have no message_id; we report success without one
# so the caller knows the animation frame landed.
return SendResult(success=True, message_id=None)
return SendResult(success=False, error="draft_rejected")
except Exception as e:
# A MarkdownV2 parse failure (BadRequest "can't parse entities")
# is recoverable: retry once as plain text. Any other failure
# (chat doesn't allow drafts, transient hiccup) — or a failure
# on the plain-text attempt — propagates to the caller, which
# treats it as "fall back to edit-based for this response".
if use_markdown and self._is_bad_request_error(e):
logger.debug(
"[%s] sendMessageDraft MarkdownV2 rejected, retrying "
"as plain text (chat=%s draft_id=%s): %s",
self.name, chat_id, draft_id, e,
)
continue
logger.debug(
"[%s] sendMessageDraft failed (chat=%s draft_id=%s): %s",
self.name, chat_id, draft_id, e,
)
return SendResult(success=False, error=str(e))
return SendResult(success=False, error="draft_rejected")
try:
ok = await self._bot.send_message_draft(**kwargs)
if ok:
# Drafts have no message_id; we report success without one
# so the caller knows the animation frame landed.
return SendResult(success=True, message_id=None)
return SendResult(success=False, error="draft_rejected")
except Exception as e:
# Most likely: BadRequest because this bot/chat doesn't allow
# drafts, or a transient server hiccup. The caller treats any
# failure as "fall back to edit-based for this response".
logger.debug(
"[%s] sendMessageDraft failed (chat=%s draft_id=%s): %s",
self.name, chat_id, draft_id, e,
)
return SendResult(success=False, error=str(e))
async def _send_message_with_thread_fallback(self, **kwargs):
"""Send a Telegram message, retrying once without message_thread_id
-132
View File
@@ -1,132 +0,0 @@
"""Adapter-driven dispatch of structured stream events to a delivery sink.
``GatewayEventDispatcher`` is the seam Tobi asked for: the agent emits typed
events (gateway/stream_events.py), and the *adapter* decides how each one is
delivered. The dispatcher holds an adapter + the stream consumer (sink) + the
resolved per-channel presentation settings (tool-progress mode, preview length)
and routes each event through the adapter's render hooks.
Message/commentary/segment events flow into the consumer (native draft on
Telegram DMs, edit-in-place elsewhere). Tool events are formatted by the
adapter which may return None to *eat* the event on platforms that can't
render tool chrome and the rendered line is enqueued onto the same tool
progress queue the gateway already drains, so the two no longer race through
independent code paths.
This module deliberately has no platform knowledge and no asyncio: it is a thin
synchronous router callable from the agent's worker thread, exactly like the
callbacks it replaces.
"""
from __future__ import annotations
import logging
from typing import Any, Callable, Optional
from gateway.stream_events import (
Commentary,
GatewayNotice,
LongToolHint,
MessageChunk,
MessageStop,
StreamEvent,
ToolCallChunk,
ToolCallFinished,
)
logger = logging.getLogger("gateway.stream_events")
class GatewayEventDispatcher:
"""Route typed stream events through an adapter onto a delivery sink.
Parameters
----------
adapter:
The platform adapter. Provides ``render_message_event`` and
``format_tool_event`` (BasePlatformAdapter defaults reproduce today's
behavior; adapters may override for native rendering).
sink:
The GatewayStreamConsumer for assistant-text delivery. May be None
when streaming is disabled, in which case message events are dropped
(the final response still goes out via the normal send path).
enqueue_tool_line:
Callback that places a rendered tool-progress line onto the gateway's
progress queue (the same queue ``send_progress_messages`` drains). May
be None when tool progress is disabled for this channel.
tool_mode:
Resolved tool-progress mode for this channel ("all" / "new" / "verbose"
/ "off").
preview_max_len:
Resolved ``tool_preview_length`` (0 = no cap in verbose mode).
on_long_tool / on_notice:
Optional hooks for LongToolHint / GatewayNotice events, letting the
gateway own the "should I surface this here?" decision.
"""
def __init__(
self,
adapter: Any,
sink: Any = None,
*,
enqueue_tool_line: Optional[Callable[[Any], None]] = None,
tool_mode: str = "all",
preview_max_len: int = 40,
on_long_tool: Optional[Callable[[LongToolHint], None]] = None,
on_notice: Optional[Callable[[GatewayNotice], None]] = None,
) -> None:
self.adapter = adapter
self.sink = sink
self._enqueue_tool_line = enqueue_tool_line
self.tool_mode = tool_mode or "all"
self.preview_max_len = preview_max_len
self._on_long_tool = on_long_tool
self._on_notice = on_notice
# "new" mode dedup — only report when the tool changes.
self._last_tool: Optional[str] = None
def dispatch(self, event: StreamEvent) -> None:
"""Route a single event. Never raises into the agent's worker thread."""
try:
self._dispatch(event)
except Exception: # presentation must never break the agent loop
logger.debug("stream-event dispatch error", exc_info=True)
def _dispatch(self, event: StreamEvent) -> None:
if isinstance(event, (MessageChunk, MessageStop, Commentary)):
if self.sink is not None:
self.adapter.render_message_event(event, self.sink)
return
if isinstance(event, ToolCallChunk):
if self.tool_mode == "off" or self._enqueue_tool_line is None:
return
# "new" mode: only emit when the tool changes.
if self.tool_mode == "new" and event.tool_name == self._last_tool:
return
self._last_tool = event.tool_name
line = self.adapter.format_tool_event(
event, mode=self.tool_mode, preview_max_len=self.preview_max_len,
)
# None == adapter chose to eat this event (can't render tool chrome).
if line:
self._enqueue_tool_line(line)
return
if isinstance(event, ToolCallFinished):
# Default: no chrome on completion (matches today — the gateway only
# rendered "started" events). Completion drives onboarding hints.
return
if isinstance(event, LongToolHint):
if self._on_long_tool is not None:
self._on_long_tool(event)
return
if isinstance(event, GatewayNotice):
if self._on_notice is not None:
self._on_notice(event)
return
__all__ = ["GatewayEventDispatcher"]
-171
View File
@@ -1,171 +0,0 @@
"""Structured streaming events — the agent→gateway delivery contract.
Historically the agent drove gateway delivery through a fan of loosely-typed
callbacks (``stream_delta_callback(text)``, ``tool_progress_callback(event_type,
tool_name, preview, args)``, ``interim_assistant_callback(text)`` ) and each
gateway callback decided *both* what to render and how to send it. That
coupling is why tool-progress bubbles and the streaming draft raced each other
on Telegram, and why tool-call formatting lived agent-side even though only the
gateway knows what a given platform can render.
This module defines a small, typed event vocabulary that names *what happened*
without prescribing *how it is delivered*. The gateway's stream consumer
(``GatewayStreamConsumer``) is the single sink; the platform adapter decides how
to render each event (Telegram can stream a MarkdownV2 ```bash``` block as a
native draft; iMessage has no rich formatting and may collapse or drop tool
chrome). Separation of concerns: smart agent emits structured data, smart
gateway decides delivery.
These are intentionally plain frozen dataclasses no behavior, no platform
knowledge, no I/O. They are cheap to construct on the agent's worker thread and
safe to hand across the thread/async boundary into the consumer queue.
Design constraints (see hermes-agent-dev skill message-flow + cache
invariants):
* Events describe *transport*, never *context*. Nothing here is persisted to
conversation history; what the gateway chooses to "eat" (e.g. tool chrome on
a platform that can't render it) must never diverge from the bytes stored in
the agent's message history. History is owned by the agent; these events are
a presentation-layer stream only.
* Backward compatible by construction. The gateway adapts its existing
callbacks into these events at the boundary; adapters that don't opt into
event-native rendering get identical behavior via the base-class default.
"""
from __future__ import annotations
from dataclasses import dataclass, field
from typing import Any, Dict, Optional, Union
# ── Message (assistant text) events ──────────────────────────────────────────
@dataclass(frozen=True)
class MessageChunk:
"""A delta of streamed assistant text.
``text`` is the incremental content as it arrives from the model. The
consumer accumulates chunks and progressively renders them (native draft on
Telegram DMs, edit-in-place elsewhere). Reasoning/think-block content is
filtered upstream and never arrives as a MessageChunk.
"""
text: str
@dataclass(frozen=True)
class MessageStop:
"""The current assistant message segment is complete.
Emitted when a contiguous run of assistant text ends either the whole
response finished, or a tool boundary interrupts the text so the next
segment should render as a fresh message *below* any tool chrome.
``final`` is True only for the terminal stop of the whole turn; an
intermediate stop (text tool call more text) carries ``final=False`` so
the consumer finalizes the current bubble and prepares a new segment without
treating the turn as done.
"""
final: bool = False
@dataclass(frozen=True)
class Commentary:
"""A complete interim assistant message emitted between tool iterations.
Example: the model says "I'll inspect the repo first." before issuing a tool
call. Unlike a MessageChunk this is already-complete text (not a delta); the
consumer renders it as its own message so it reads as a distinct beat.
"""
text: str
# ── Tool-call events ─────────────────────────────────────────────────────────
@dataclass(frozen=True)
class ToolCallChunk:
"""A tool invocation has started (or its in-progress state changed).
Carries the raw facts about the call name, a short argument ``preview``,
and the full ``args`` dict and lets the *gateway* decide presentation
(emoji, truncation, verbose vs compact, or eat it entirely on platforms that
don't show tool chrome). Previously the agent's gateway callback baked the
emoji + preview formatting in; that decision now belongs to the adapter.
"""
tool_name: str
preview: Optional[str] = None
args: Optional[Dict[str, Any]] = None
# Monotonic per-turn index, so the consumer can correlate a finish with its
# start and so "new"-mode dedup (only report when the tool changes) works
# without the consumer tracking call order itself.
index: int = 0
@dataclass(frozen=True)
class ToolCallFinished:
"""A tool invocation completed.
``duration`` is wall-clock seconds. ``ok`` reflects whether the tool
returned without raising. The gateway uses this to clear/settle a progress
bubble and to drive one-time onboarding hints (e.g. suggest /verbose after a
long tool run). No tool *output* travels here output is the agent's
concern and is persisted to history, not streamed as presentation.
"""
tool_name: str
duration: float = 0.0
ok: bool = True
index: int = 0
# ── Gateway control / lifecycle events ───────────────────────────────────────
@dataclass(frozen=True)
class LongToolHint:
"""One-shot onboarding nudge when a tool runs longer than the threshold.
The gateway gates this on platform capability (the /verbose command must be
usable) and on the user not having seen the hint before. Modeled as an
event so the *gateway* owns the "should I surface this here?" decision rather
than the agent.
"""
tool_name: str = ""
duration: float = 0.0
@dataclass(frozen=True)
class GatewayNotice:
"""A gateway-originated control message (restart, online, long-run notice).
``kind`` is a stable string the adapter can switch on
(``"restart"`` / ``"online"`` / ``"long_run"`` / ). ``text`` is the
human-readable default the base class renders when an adapter has no
platform-specific treatment.
"""
kind: str
text: str = ""
extra: Dict[str, Any] = field(default_factory=dict)
# Union of every event the consumer's dispatcher accepts. Kept explicit (rather
# than a marker base class) so a missing ``case`` in an exhaustive match is a
# visible type error rather than a silent fall-through.
StreamEvent = Union[
MessageChunk,
MessageStop,
Commentary,
ToolCallChunk,
ToolCallFinished,
LongToolHint,
GatewayNotice,
]
__all__ = [
"MessageChunk",
"MessageStop",
"Commentary",
"ToolCallChunk",
"ToolCallFinished",
"LongToolHint",
"GatewayNotice",
"StreamEvent",
]
+2 -61
View File
@@ -1345,27 +1345,7 @@ DEFAULT_CONFIG = {
# responses and content messages are never touched. Default 0
# (disabled) preserves prior behavior.
"ephemeral_system_ttl": 0,
# Per-platform display/streaming overrides. Each key is a gateway
# platform ("telegram", "discord", "slack", …) mapping to a dict of
# display settings that override the global value for that platform
# only. A setting left unset here falls through to the global default.
#
# Shipped defaults encode the streaming experience that works best
# per platform:
# - Telegram has native animated draft streaming (sendMessageDraft),
# which is smooth, so streaming is on by default there.
# - Discord/Slack/etc. only have edit-based streaming (repeated
# editMessage), which flickers and is noticeably jankier, so
# streaming is off by default there.
# These are gap-fillers: a user who explicitly sets, e.g.,
# display.platforms.discord.streaming: true keeps their value
# (config deep-merge has user values win over defaults). The global
# streaming.enabled master switch still gates everything — these
# per-platform flags only take effect once streaming is enabled.
"platforms": {
"telegram": {"streaming": True},
"discord": {"streaming": False},
},
"platforms": {}, # Per-platform display overrides: {"telegram": {"tool_progress": "all"}, "slack": {"tool_progress": "off"}}
# Gateway runtime-metadata footer appended to the FINAL message of a turn
# (disabled by default to keep replies minimal). When enabled, renders
# e.g. `model · 68% · ~/projects/hermes`. Per-platform overrides go under
@@ -2057,45 +2037,6 @@ DEFAULT_CONFIG = {
"trust_recent_files_seconds": 600,
},
# Real-time token streaming to messaging platforms (Telegram, Discord,
# Slack, etc.). Read at the top level by the gateway; absent this block the
# gateway falls back to these same defaults, so adding it here only makes
# the feature discoverable in config.yaml — it does not change behavior.
#
# Disabled by default: streaming costs extra edit/draft API calls per
# response. Set ``enabled: true`` and restart the gateway to turn it on.
"streaming": {
# Master switch. When false, each response is delivered as a single
# final message (no progressive updates).
"enabled": False,
# Transport selection:
# "auto" — prefer native draft streaming where the platform
# supports it (Telegram DMs via sendMessageDraft,
# Bot API 9.5+) and fall back to edit-based elsewhere.
# Safe global default: platforms without draft support
# (Discord, Slack, Matrix, Telegram groups) transparently
# use the edit path, so "auto" only upgrades chats that
# can render the smoother native preview.
# "draft" — explicitly request native drafts; falls back to edit
# when the platform/chat doesn't support them.
# "edit" — progressive editMessageText only (legacy behavior).
# "off" — disable streaming entirely (same as enabled: false).
"transport": "auto",
# Minimum seconds between progressive edits — tuned for Telegram's
# ~1 edit/s flood envelope.
"edit_interval": 0.8,
# Flush the buffer to the platform once this many characters have
# accumulated, so short replies feel near-instant.
"buffer_threshold": 24,
# Cursor glyph appended to the in-progress message while streaming.
"cursor": " \u2589",
# When >0, the final edit for a long-running streamed response is
# delivered as a fresh message if the preview has been visible at
# least this many seconds, so the platform timestamp reflects
# completion time. Telegram only; other platforms ignore it.
"fresh_final_after_seconds": 60.0,
},
# Session storage — controls automatic cleanup of ~/.hermes/state.db.
# state.db accumulates every session, message, tool call, and FTS5 index
# entry forever. Without auto-pruning, a heavy user (gateway + cron)
@@ -3815,7 +3756,7 @@ _KNOWN_ROOT_KEYS = {
"fallback_providers", "credential_pool_strategies", "toolsets",
"agent", "terminal", "display", "compression", "delegation",
"auxiliary", "custom_providers", "context", "memory", "gateway",
"sessions", "streaming",
"sessions",
}
# Valid fields inside a custom_providers list entry
+21 -129
View File
@@ -24,7 +24,7 @@ from fastapi.responses import JSONResponse, RedirectResponse, Response
from hermes_cli.dashboard_auth import list_providers
from hermes_cli.dashboard_auth.audit import AuditEvent, audit_log
from hermes_cli.dashboard_auth.base import ProviderError, RefreshExpiredError
from hermes_cli.dashboard_auth.base import ProviderError
from hermes_cli.dashboard_auth.cookies import read_session_cookies
from hermes_cli.dashboard_auth.public_paths import PUBLIC_API_PATHS
@@ -185,94 +185,44 @@ async def gated_auth_middleware(
return await call_next(request)
at, _rt = read_session_cookies(request)
if not at and not _rt:
# Neither token present — no session at all. Nothing to verify or
# refresh; force login.
if not at:
return _unauth_response(request, reason="no_cookie")
# Try every registered provider's verify_session in turn. Providers
# MUST return None for tokens they don't recognise (not raise). This
# lets multiple providers stack — the first one that recognises a
# token wins.
#
# When the access-token cookie is absent but a refresh-token cookie is
# present, skip verification and go straight to the refresh path below.
# This is the COMMON expiry case, not an edge case: the access-token
# cookie is set with ``Max-Age = access_token_expires_in`` (~15 min), so
# the browser EVICTS it the moment the token lapses, while the
# refresh-token cookie lives for 30 days. From that point the browser
# sends only ``hermes_session_rt``. If we bailed on ``not at`` here we'd
# bounce the user to /login on every expiry despite holding a perfectly
# good refresh token — defeating the whole transparent-refresh feature.
session = None
if at:
for provider in list_providers():
try:
session = provider.verify_session(access_token=at)
except ProviderError as e:
_log.warning(
"dashboard-auth: provider %r unreachable during verify: %s",
provider.name, e,
)
audit_log(
AuditEvent.SESSION_VERIFY_FAILURE,
provider=provider.name,
reason="provider_unreachable",
ip=_client_ip(request),
)
return JSONResponse(
{"detail": f"Auth provider {provider.name!r} unreachable"},
status_code=503,
)
if session is not None:
break
if session is None:
# Access token is expired/invalid. Before forcing re-login, try to
# rotate it using the refresh token (if the session cookie carries
# one). On success we re-set the rotated cookies on the response and
# serve the request transparently; on RefreshExpiredError (RT dead /
# revoked / reuse-detected) we fall through to clear-and-relogin.
refreshed = _attempt_refresh(request, refresh_token=_rt)
if refreshed is not None:
new_session, refreshing_provider = refreshed
request.state.session = new_session
response = await call_next(request)
# Persist the ROTATED tokens. Portal rotates the refresh token on
# every refresh and runs reuse-detection, so writing the new RT
# back is mandatory: a stale RT cookie would replay a rotated
# token on the next refresh and (outside Portal's grace) revoke
# the whole session. Bind cookie Secure/Path to the request shape.
from hermes_cli.dashboard_auth.cookies import (
detect_https,
set_session_cookies,
)
from hermes_cli.dashboard_auth.prefix import prefix_from_request
set_session_cookies(
response,
access_token=new_session.access_token,
refresh_token=new_session.refresh_token,
access_token_expires_in=_expires_in_seconds(new_session),
use_https=detect_https(request),
prefix=prefix_from_request(request),
for provider in list_providers():
try:
session = provider.verify_session(access_token=at)
except ProviderError as e:
_log.warning(
"dashboard-auth: provider %r unreachable during verify: %s",
provider.name, e,
)
audit_log(
AuditEvent.REFRESH_SUCCESS,
provider=refreshing_provider,
user_id=new_session.user_id,
AuditEvent.SESSION_VERIFY_FAILURE,
provider=provider.name,
reason="provider_unreachable",
ip=_client_ip(request),
)
return response
return JSONResponse(
{"detail": f"Auth provider {provider.name!r} unreachable"},
status_code=503,
)
if session is not None:
break
if session is None:
audit_log(
AuditEvent.SESSION_VERIFY_FAILURE,
reason="no_provider_recognises",
ip=_client_ip(request),
)
response = _unauth_response(request, reason="invalid_or_expired_session")
# Clear the dead cookies so the browser doesn't keep sending them.
# Refresh already failed (or there was no RT), so the only correct
# Clear the dead cookie so the browser doesn't keep sending it.
# Contract v1: no refresh token to retry with, so the only correct
# next step is full re-auth via /login. Importing locally avoids a
# cycle with cookies → middleware at module load. Pass the active
# prefix so the deletion's Path matches the set-Path (otherwise
@@ -284,61 +234,3 @@ async def gated_auth_middleware(
request.state.session = session
return await call_next(request)
def _expires_in_seconds(session) -> int:
"""Seconds until the access token's ``exp``, floored at 60.
Mirrors the auth-route's ``max(60, exp - now)`` so the access-token
cookie's Max-Age tracks the token lifetime even on a slightly skewed
clock. ``time`` imported locally to keep the module's import surface
minimal.
"""
import time
return max(60, int(session.expires_at) - int(time.time()))
def _attempt_refresh(request: Request, *, refresh_token):
"""Try to rotate an expired session via the refresh token.
Returns ``(new_session, provider_name)`` on success, or ``None`` if
there's no RT or every provider's ``refresh_session`` failed with
``RefreshExpiredError`` (dead/revoked/reuse-detected RT force re-login).
A ``ProviderError`` (Portal unreachable) is NOT swallowed into a re-login
here re-raising would 500 the request; instead we log and return None so
the caller forces a clean re-login, which is the safer UX than a hard
error on a transient network blip during the narrow refresh window.
"""
if not refresh_token:
return None
for provider in list_providers():
try:
new_session = provider.refresh_session(refresh_token=refresh_token)
except RefreshExpiredError:
# This provider owns the RT but it's dead — stop trying others
# (an RT belongs to exactly one provider) and force re-login.
audit_log(
AuditEvent.REFRESH_FAILURE,
provider=provider.name,
reason="refresh_expired",
ip=_client_ip(request),
)
return None
except ProviderError as e:
_log.warning(
"dashboard-auth: provider %r unreachable during refresh: %s",
provider.name, e,
)
audit_log(
AuditEvent.REFRESH_FAILURE,
provider=provider.name,
reason="provider_unreachable",
ip=_client_ip(request),
)
return None
if new_session is not None:
return new_session, provider.name
return None
+9 -56
View File
@@ -7479,23 +7479,8 @@ def _update_via_zip(args):
# individually so update does not silently strip working capabilities.
print("→ Updating Python dependencies...")
from hermes_cli.managed_uv import ensure_uv, rebuild_venv, update_managed_uv
# Keep managed uv current — runs `uv self update` if we already have one.
update_managed_uv()
uv_bin, fresh_bootstrap = ensure_uv()
# First-time managed uv install on an existing checkout: the old venv
# may point to a Python without FTS5. Rebuild it so the new managed
# uv provides a fresh interpreter with FTS5 guaranteed.
if fresh_bootstrap and uv_bin:
if not rebuild_venv(uv_bin, PROJECT_ROOT / "venv"):
print("✗ Failed to rebuild venv with managed uv. Re-run `hermes update` or install manually.")
sys.exit(1)
pip_cmd = [sys.executable, "-m", "pip"]
if not uv_bin:
uv_bin = _ensure_uv_for_termux(pip_cmd)
uv_bin = shutil.which("uv") or _ensure_uv_for_termux(pip_cmd)
if uv_bin:
uv_env = {**os.environ, "VIRTUAL_ENV": str(PROJECT_ROOT / "venv")}
if _is_termux_env(uv_env):
@@ -8595,27 +8580,16 @@ def _install_psutil_android_compat(
def _ensure_uv_for_termux(pip_cmd: list[str]) -> str | None:
"""Best-effort uv bootstrap on Termux for faster update installs.
The normal path (``ensure_uv()`` in managed_uv) installs the managed
standalone uv into ``$HERMES_HOME/bin/uv``, but on Termux the official
installer may not work (glibc vs bionic). Fall back to ``pip install uv``
which gets a Termux-compatible binary.
"""
from hermes_cli.managed_uv import resolve_uv
existing = resolve_uv()
if existing:
return existing
if not _is_termux_env():
return None
"""Best-effort uv bootstrap on Termux for faster update installs."""
uv_bin = shutil.which("uv")
if uv_bin or not _is_termux_env():
return uv_bin
try:
print(" → Termux detected: trying to install uv for faster dependency updates...")
subprocess.run(pip_cmd + ["install", "uv"], cwd=PROJECT_ROOT, check=False)
except Exception:
pass
# After pip install, check managed path first, then PATH
return resolve_uv() or shutil.which("uv")
return shutil.which("uv")
def _update_node_dependencies() -> None:
@@ -9263,12 +9237,7 @@ def _cmd_update_pip(args):
print(f"→ Current version: {__version__}")
print("→ Checking PyPI for updates...")
from hermes_cli.managed_uv import ensure_uv, update_managed_uv
# Keep managed uv current before using it.
update_managed_uv()
uv, _fresh_bootstrap = ensure_uv()
uv = shutil.which("uv")
in_venv = sys.prefix != sys.base_prefix
# pipx-managed installs live under .../pipx/venvs/<name>/...
pipx_managed = "pipx" in sys.prefix.split(os.sep)
@@ -9283,8 +9252,7 @@ def _cmd_update_pip(args):
if is_uv_tool_install():
if not uv:
print("✗ Detected a uv-tool install but managed uv install failed.")
print(" Install uv manually: https://docs.astral.sh/uv/getting-started/installation/")
print("✗ Detected a uv-tool install but `uv` is not on PATH; install uv and retry.")
sys.exit(1)
cmd = [uv, "tool", "upgrade", "hermes-agent"]
elif pipx_managed and pipx:
@@ -9680,23 +9648,8 @@ def _cmd_update_impl(args, gateway_mode: bool):
# breaks on this machine, keep base deps and reinstall the remaining extras
# individually so update does not silently strip working capabilities.
print("→ Updating Python dependencies...")
from hermes_cli.managed_uv import ensure_uv, rebuild_venv, update_managed_uv
# Keep managed uv current — runs `uv self update` if we already have one.
update_managed_uv()
uv_bin, fresh_bootstrap = ensure_uv()
# First-time managed uv install on an existing checkout: the old venv
# may point to a Python without FTS5. Rebuild it so the new managed
# uv provides a fresh interpreter with FTS5 guaranteed.
if fresh_bootstrap and uv_bin:
if not rebuild_venv(uv_bin, PROJECT_ROOT / "venv"):
print("✗ Failed to rebuild venv with managed uv. Re-run `hermes update` or install manually.")
sys.exit(1)
pip_cmd = [sys.executable, "-m", "pip"]
if not uv_bin:
uv_bin = _ensure_uv_for_termux(pip_cmd)
uv_bin = shutil.which("uv") or _ensure_uv_for_termux(pip_cmd)
install_group = "all"
if uv_bin:
-229
View File
@@ -1,229 +0,0 @@
"""Managed uv — one path, no guessing.
Hermes owns its own uv binary at ``$HERMES_HOME/bin/uv`` (or ``uv.exe`` on
Windows). Every code path that needs uv resolves it from that single location.
If the binary is missing, ``ensure_uv()`` bootstraps it via the official
standalone installer with ``UV_UNMANAGED_INSTALL`` / ``UV_INSTALL_DIR`` pointed
at ``$HERMES_HOME/bin`` so the installer writes directly there no PATH
probing, no conda guards, no multi-location resolution chains.
When ``ensure_uv()`` bootstraps uv for the first time (i.e. there was no
managed uv before), it returns ``(path, True)`` instead of just ``path``.
Callers in the update path use that signal to nuke and recreate the venv
with the now-current managed uv, guaranteeing a Python with FTS5.
"""
from __future__ import annotations
import logging
import os
import platform
import shutil
import subprocess
import tempfile
from pathlib import Path
from typing import Optional, Tuple
from hermes_constants import get_hermes_home
logger = logging.getLogger(__name__)
# ---------------------------------------------------------------------------
# Public helpers
# ---------------------------------------------------------------------------
def managed_uv_path() -> Path:
"""Return the path where Hermes keeps *its* uv binary.
``$HERMES_HOME/bin/uv`` on POSIX, ``$HERMES_HOME\\bin\\uv.exe`` on
Windows. The directory may not exist yet callers should use
``ensure_uv()`` to bootstrap it.
"""
home = get_hermes_home()
if platform.system() == "Windows":
return home / "bin" / "uv.exe"
return home / "bin" / "uv"
def resolve_uv() -> Optional[str]:
"""Return the managed uv path if it exists, else ``None``.
No side effects pure lookup.
"""
p = managed_uv_path()
if p.is_file() and os.access(p, os.X_OK):
return str(p)
return None
def ensure_uv() -> Tuple[Optional[str], bool]:
"""Return the managed uv path, installing it first if necessary.
Returns ``(path, freshly_bootstrapped)`` where *freshly_bootstrapped* is
``True`` when we just installed managed uv for the first time (there was
no managed uv before this call). Callers can use that signal to rebuild
the venv so Python is guaranteed to have FTS5.
On failure returns ``(None, False)`` (never raises) so callers can fall
back to pip gracefully.
"""
existing = resolve_uv()
if existing:
return (existing, False)
target = managed_uv_path()
target.parent.mkdir(parents=True, exist_ok=True)
print(f" → Installing managed uv into {target.parent} ...")
try:
_install_uv(target)
except Exception as exc:
logger.warning("Managed uv install failed: %s", exc)
print(f" ✗ Failed to install managed uv: {exc}")
return (None, False)
# Verify
result = resolve_uv()
if result:
version = subprocess.run(
[result, "--version"],
capture_output=True,
text=True,
check=False,
).stdout.strip()
print(f" ✓ Managed uv installed ({version})")
else:
print(" ✗ Managed uv install appeared to succeed but binary not found")
return (result, result is not None)
def rebuild_venv(uv_bin: str, venv_dir: Path, python_version: str = "3.11") -> bool:
"""Nuke and recreate the venv with managed uv.
Called when managed uv is first bootstrapped on an existing install the
old venv may point to a Python without FTS5, so we rebuild it with a
fresh interpreter from the current managed uv. Returns ``True`` on
success.
"""
if venv_dir.exists():
print(f" → Rebuilding venv (old Python may lack FTS5)...")
shutil.rmtree(venv_dir, ignore_errors=True)
result = subprocess.run(
[uv_bin, "venv", str(venv_dir), "--python", python_version],
capture_output=True,
text=True,
check=False,
)
if result.returncode == 0:
venv_python = venv_dir / ("Scripts" if platform.system() == "Windows" else "bin") / "python"
py_ver = subprocess.run(
[str(venv_python), "--version"],
capture_output=True,
text=True,
check=False,
).stdout.strip()
print(f" ✓ venv rebuilt ({py_ver})")
return True
else:
logger.warning("venv rebuild failed: %s", result.stderr)
print(f" ✗ venv rebuild failed: {result.stderr.strip()}")
return False
def update_managed_uv() -> Optional[str]:
"""Run ``uv self update`` on the managed uv binary.
Call this during ``hermes update`` so the managed copy stays current.
Returns the managed path if a managed uv is present (always even when
``uv self update`` fails, the old binary still works). Returns ``None``
only when no managed uv exists yet (``ensure_uv()`` handles that case).
"""
existing = resolve_uv()
if not existing:
# Not installed yet — ensure_uv() will handle that elsewhere.
return None
result = subprocess.run(
[existing, "self", "update"],
capture_output=True,
text=True,
check=False,
)
if result.returncode == 0:
version = subprocess.run(
[existing, "--version"],
capture_output=True,
text=True,
check=False,
).stdout.strip()
print(f" ✓ Managed uv updated ({version})")
else:
# Non-fatal — old uv still works fine.
logger.debug("uv self update failed (rc=%d): %s", result.returncode, result.stderr)
return existing
# ---------------------------------------------------------------------------
# Installer internals
# ---------------------------------------------------------------------------
def _install_uv(target: Path) -> None:
"""Bootstrap uv into *target* using the official standalone installer.
Uses ``UV_UNMANAGED_INSTALL`` (POSIX) or ``UV_INSTALL_DIR`` (Windows)
so the astral installer writes the binary directly into
``$HERMES_HOME/bin/`` instead of ``~/.local/bin/``.
"""
system = platform.system()
env = {
**os.environ,
# Tell the astral installer to drop the binary in our dir, not
# ~/.local/bin. UV_UNMANAGED_INSTALL is the POSIX env var; Windows
# uses UV_INSTALL_DIR.
"UV_UNMANAGED_INSTALL": str(target.parent),
"UV_INSTALL_DIR": str(target.parent),
}
if system == "Windows":
_install_uv_windows(env)
else:
_install_uv_posix(env)
def _install_uv_posix(env: dict[str, str]) -> None:
"""Download + sh the POSIX installer (two-stage to avoid curl|sh pitfalls)."""
with tempfile.NamedTemporaryFile(suffix=".sh", delete=False) as f:
installer_path = f.name
try:
subprocess.run(
["curl", "-LsSf", "https://astral.sh/uv/install.sh", "-o", installer_path],
check=True,
capture_output=True,
)
subprocess.run(
["sh", installer_path],
env=env,
check=True,
capture_output=True,
)
finally:
try:
os.unlink(installer_path)
except OSError:
pass
def _install_uv_windows(env: dict[str, str]) -> None:
"""Invoke the PowerShell installer."""
cmd = (
'irm https://astral.sh/uv/install.ps1 | iex'
)
subprocess.run(
["powershell", "-ExecutionPolicy", "Bypass", "-c", cmd],
env=env,
check=True,
capture_output=True,
)
-23
View File
@@ -2106,32 +2106,9 @@ def provider_model_ids(provider: Optional[str], *, force_refresh: bool = False)
if api_key:
base_raw = os.getenv("OPENAI_BASE_URL", "").strip().rstrip("/")
base = base_raw or "https://api.openai.com/v1"
# Custom OpenAI-compatible endpoints (proxies, gateways, self-hosted)
# may serve a small curated catalog — use the live list verbatim so
# discovery works. But the canonical api.openai.com /v1/models dump
# is 120+ entries of embeddings, whisper, tts, dall-e, moderation and
# legacy chat models — none of which belong in the agent model picker.
# For the default endpoint, intersect the live list with our curated
# agentic catalog so ``/model`` matches what ``hermes model`` shows.
is_default_openai = base.rstrip("/") in (
"https://api.openai.com/v1",
"https://api.openai.com",
)
try:
live = fetch_api_models(api_key, base)
if live:
if is_default_openai:
live_lower = {m.lower() for m in live}
curated = list(_PROVIDER_MODELS.get(normalized, []))
# Keep curated order; only surface curated models the
# account actually has access to.
filtered = [m for m in curated if m.lower() in live_lower]
if filtered:
return filtered
# Account serves none of the curated models (rare —
# e.g. org without GPT-5 access). Fall back to curated
# so the picker still offers sane defaults.
return curated or live
return live
except Exception:
pass
+1
View File
@@ -47,6 +47,7 @@ HERMES_OVERLAYS: Dict[str, HermesOverlay] = {
"openrouter": HermesOverlay(
transport="openai_chat",
is_aggregator=True,
extra_env_vars=("OPENAI_API_KEY",),
base_url_env_var="OPENROUTER_BASE_URL",
),
"nous": HermesOverlay(
-136
View File
@@ -1363,7 +1363,6 @@ async def get_sessions(
offset: int = 0,
min_messages: int = 0,
archived: str = "exclude",
order: str = "created",
):
"""List sessions.
@@ -1371,22 +1370,12 @@ async def get_sessions(
``exclude`` (default) hides them, ``only`` returns just the archived ones
(used by the desktop "Archived sessions" settings panel), and ``include``
returns both.
``order`` controls pagination order: ``created`` (default, by original
start time) or ``recent`` (by latest activity across the compression
chain). ``recent`` keeps a long-running conversation on the first page
after it auto-compresses into a fresh continuation id.
"""
if archived not in ("exclude", "only", "include"):
raise HTTPException(
status_code=400,
detail="archived must be one of: exclude, only, include",
)
if order not in ("created", "recent"):
raise HTTPException(
status_code=400,
detail="order must be one of: created, recent",
)
try:
from hermes_state import SessionDB
db = SessionDB()
@@ -1400,7 +1389,6 @@ async def get_sessions(
min_message_count=min_message_count,
include_archived=include_archived,
archived_only=archived_only,
order_by_last_active=order == "recent",
)
total = db.session_count(
min_message_count=min_message_count,
@@ -3925,117 +3913,6 @@ def _session_latest_descendant(session_id: str):
finally:
db.close()
# CRITICAL — every literal-path route below MUST be declared BEFORE the
# templated ``/api/sessions/{session_id}`` family that follows. FastAPI/
# Starlette match routes in registration order, and the ``{session_id}``
# pattern is unconstrained — it would otherwise swallow e.g.
# ``DELETE /api/sessions/empty``, ``POST /api/sessions/bulk-delete``, or
# ``GET /api/sessions/stats`` as "operate on the session with id
# 'empty'" / "'bulk-delete'" / "'stats'", which would 404 (or worse,
# succeed and delete the wrong row). Same story as the older
# ``/api/sessions/search`` endpoint up at line ~1191. If you split or
# reorder this block, move every route in it together.
class BulkDeleteSessions(BaseModel):
ids: List[str]
@app.post("/api/sessions/bulk-delete")
async def bulk_delete_sessions_endpoint(body: BulkDeleteSessions):
"""Delete every session in ``body.ids`` in a single DB transaction.
Backs the dashboard's bulk-select-and-delete flow on the sessions
page. POST (not DELETE) because most HTTP clients refuse to send a
request body on DELETE and a body is the natural shape for a list
of IDs Starlette accepts both, but POSTing a list keeps proxies,
curl, and the browser ``fetch`` API consistent.
Per-row contract matches :meth:`SessionDB.delete_sessions`:
* Unknown IDs are silently skipped (the response ``deleted`` count
reflects what really happened, not the input length). This is
deliberate UI selection state can race against another tab's
delete, and we'd rather succeed-on-the-rest than fail-the-whole-
batch.
* Children of every deleted parent are orphaned, not cascade-
deleted.
* Active and archived sessions ARE deleted when explicitly
selected unlike ``DELETE /api/sessions/empty``, the user
hand-picked the rows so we trust the selection.
* Like the other session-delete endpoints, this does NOT pass a
``sessions_dir`` through; on-disk transcript / request-dump
cleanup runs at the CLI/agent layer on the next prune pass.
The response carries the actual deleted count, so the dashboard
can surface it in a toast. The IDs that were removed are not
echoed back because the client already knows what it asked to
delete (unknown IDs are silently skipped see contract above)
and can prune its in-memory list directly from the request.
"""
# Enforce a hard cap so a runaway/typo'd selection can't lock the
# DB writer for an extended window. The dashboard pages 20 rows
# at a time; 500 covers a "select all on every page in a
# reasonable scrollback" worst case without opening the door to
# multi-thousand-row transactions.
if len(body.ids) > 500:
raise HTTPException(
status_code=400,
detail="ids must contain at most 500 entries",
)
from hermes_state import SessionDB
db = SessionDB()
try:
deleted = db.delete_sessions(body.ids)
return {"ok": True, "deleted": deleted}
finally:
db.close()
@app.get("/api/sessions/empty/count")
async def count_empty_sessions_endpoint():
"""Return the number of empty, ended, non-archived sessions.
Drives the dashboard's "Delete empty (N)" button — when N is 0 the
UI hides the affordance so users aren't presented with a button
that does nothing. Cheap, single-COUNT query.
"""
from hermes_state import SessionDB
db = SessionDB()
try:
return {"count": db.count_empty_sessions()}
finally:
db.close()
@app.delete("/api/sessions/empty")
async def delete_empty_sessions_endpoint():
"""Delete every empty (``message_count == 0``), ended,
non-archived session in a single transaction.
Safety contract mirrors :meth:`SessionDB.delete_empty_sessions`:
* Active sessions are skipped (``ended_at IS NULL``) so a live
agent isn't yanked mid-handshake.
* Archived sessions are skipped the user explicitly chose to
keep those rows.
* Children of deleted parents are orphaned, not cascade-deleted.
Like the single-session ``DELETE /api/sessions/{id}`` endpoint
below, this doesn't pass a ``sessions_dir`` through — the on-disk
transcript / request-dump cleanup is wired at the CLI/agent layer
but the web server historically leaves file cleanup to the next
prune-on-startup pass. Matching that pre-existing trade-off keeps
the two delete endpoints' DB-vs-disk behaviour consistent.
"""
from hermes_state import SessionDB
db = SessionDB()
try:
deleted = db.delete_empty_sessions()
return {"ok": True, "deleted": deleted}
finally:
db.close()
@app.get("/api/sessions/stats")
async def get_session_stats():
"""Session-store statistics for the Sessions page (mirrors `hermes sessions stats`).
@@ -4068,7 +3945,6 @@ async def get_session_stats():
finally:
db.close()
@app.get("/api/sessions/{session_id}")
async def get_session_detail(session_id: str):
from hermes_state import SessionDB
@@ -5916,7 +5792,6 @@ async def get_toolset_config(name: str):
from hermes_cli.tools_config import (
TOOL_CATEGORIES,
_get_effective_configurable_toolsets,
_is_provider_active,
_visible_providers,
)
from hermes_cli.config import get_env_value
@@ -5928,7 +5803,6 @@ async def get_toolset_config(name: str):
config = load_config()
cat = TOOL_CATEGORIES.get(name)
providers = []
active_provider = None
if cat:
for prov in _visible_providers(cat, config, force_fresh=True):
env_vars = [
@@ -5941,13 +5815,6 @@ async def get_toolset_config(name: str):
}
for e in prov.get("env_vars", [])
]
# Surface the same active-provider determination the CLI picker
# uses (``_is_provider_active``) so the GUI highlights the provider
# actually written to config (e.g. web.backend), not just the first
# keyless one in the list.
is_active = _is_provider_active(prov, config, force_fresh=True)
if is_active and active_provider is None:
active_provider = prov["name"]
providers.append({
"name": prov["name"],
"badge": prov.get("badge", ""),
@@ -5955,13 +5822,11 @@ async def get_toolset_config(name: str):
"env_vars": env_vars,
"post_setup": prov.get("post_setup"),
"requires_nous_auth": bool(prov.get("requires_nous_auth")),
"is_active": is_active,
})
return {
"name": name,
"has_category": cat is not None,
"providers": providers,
"active_provider": active_provider,
}
@@ -6857,7 +6722,6 @@ def mount_spa(application: FastAPI):
_BUILTIN_DASHBOARD_THEMES = [
{"name": "default", "label": "Hermes Teal", "description": "Classic dark teal — the canonical Hermes look"},
{"name": "default-large", "label": "Hermes Teal (Large)", "description": "Hermes Teal with bigger fonts and roomier spacing"},
{"name": "nous-blue", "label": "Nous Blue", "description": "Light mode — vivid Nous-blue accents on cream canvas"},
{"name": "midnight", "label": "Midnight", "description": "Deep blue-violet with cool accents"},
{"name": "ember", "label": "Ember", "description": "Warm crimson and bronze — forge vibes"},
{"name": "mono", "label": "Mono", "description": "Clean grayscale — minimal and focused"},
+6 -174
View File
@@ -452,8 +452,12 @@ class SessionDB:
self._fts_unavailable_warned = True
logger.warning(
"SQLite FTS5 unavailable for %s; full-text session search "
"disabled. Run `hermes update` to rebuild the venv with a "
"current Python. (underlying error: %s)",
"disabled. This usually means Hermes is running on an "
"unsupported install (e.g. a pip-installed or pip-managed "
"Python whose bundled SQLite lacks FTS5) rather than a "
"mainline install. Some features may be missing or behave "
"differently. Install the supported way: "
"https://hermes-agent.nousresearch.com (underlying error: %s)",
self.db_path,
exc,
)
@@ -3182,178 +3186,6 @@ class SessionDB:
self._remove_session_files(sessions_dir, session_id)
return deleted
def delete_sessions(
self,
session_ids: List[str],
sessions_dir: Optional[Path] = None,
) -> int:
"""Delete every session in *session_ids* in a single transaction.
Backs the dashboard's bulk-select-then-delete flow on the
sessions page (``POST /api/sessions/bulk-delete``). Mirrors the
single-session :meth:`delete_session` contract per row:
* Unknown IDs are silently skipped (no 404) selection state
in the UI can race against another tab's delete, and we'd
rather succeed-on-the-rest than fail-the-whole-batch.
* Children of every deleted ID are orphaned
(``parent_session_id NULL``), never cascade-deleted, so a
branch / subagent transcript survives an inadvertent parent
delete.
* Messages and the session row both go in one
``_execute_write`` call so a partial failure can't leave the
DB in a "messages gone but session row still there" state.
* On-disk transcript / ``request_dump_*`` files are cleaned up
outside the DB transaction when *sessions_dir* is provided,
matching :meth:`prune_sessions` and
:meth:`delete_empty_sessions`.
Returns the count of sessions that actually existed and were
deleted (may be less than ``len(session_ids)`` if some IDs were
already gone).
"""
if not session_ids:
return 0
# Dedup + drop any non-string entries up-front. Avoids
# double-counting in the WHERE-IN list and protects against
# callers that pass a list with stray ``None`` values.
unique_ids = list({sid for sid in session_ids if isinstance(sid, str) and sid})
if not unique_ids:
return 0
removed_ids: list[str] = []
def _do(conn):
placeholders = ",".join("?" * len(unique_ids))
# First, filter to IDs that actually exist — we want to
# return the real deleted count, not the input length.
cursor = conn.execute(
f"SELECT id FROM sessions WHERE id IN ({placeholders})",
unique_ids,
)
existing = [row["id"] for row in cursor.fetchall()]
if not existing:
return 0
existing_placeholders = ",".join("?" * len(existing))
# Orphan children whose parent is in the kill list so the
# FK constraint stays satisfied. Pin children whose parent
# is itself in the kill list rather than NULL-ing parents
# of survivors — the IN list on ``parent_session_id`` does
# exactly this.
conn.execute(
f"UPDATE sessions SET parent_session_id = NULL "
f"WHERE parent_session_id IN ({existing_placeholders})",
existing,
)
conn.execute(
f"DELETE FROM messages WHERE session_id IN ({existing_placeholders})",
existing,
)
conn.execute(
f"DELETE FROM sessions WHERE id IN ({existing_placeholders})",
existing,
)
removed_ids.extend(existing)
return len(existing)
count = self._execute_write(_do)
for sid in removed_ids:
self._remove_session_files(sessions_dir, sid)
return count
def count_empty_sessions(self) -> int:
"""Return the count of empty, non-active, non-archived sessions.
"Empty" = ``message_count = 0`` AND the session has ended
(``ended_at IS NOT NULL``) AND is not archived. The ``ended_at``
guard matches the safety contract used by :meth:`prune_sessions`:
only ended sessions are candidates for bulk deletion, so a freshly
spawned session whose first message hasn't landed yet — or one
held open by the live agent is never sniped out from under
the runtime.
Backs the ``GET /api/sessions/empty/count`` endpoint that lets the
web dashboard hide its "Delete empty" button when there's nothing
to clean up, and pre-populate the confirm dialog with the actual
count.
"""
with self._lock:
cursor = self._conn.execute(
"SELECT COUNT(*) FROM sessions "
"WHERE message_count = 0 "
"AND ended_at IS NOT NULL "
"AND archived = 0"
)
return cursor.fetchone()[0]
def delete_empty_sessions(
self,
sessions_dir: Optional[Path] = None,
) -> int:
"""Delete every empty, ended, non-archived session.
Mirrors :meth:`prune_sessions`' transactional shape:
* Selects candidate IDs first (``message_count = 0`` AND
``ended_at IS NOT NULL`` AND ``archived = 0``) so we never
touch a live session or one the user deliberately archived.
* Orphans any child whose parent is in the kill list children
of an empty parent are kept and re-parented to ``NULL`` rather
than cascade-deleted, matching ``delete_session`` /
``prune_sessions`` semantics so branch/subagent transcripts
survive an inadvertent parent cleanup.
* Deletes the rows in a single ``_execute_write`` callback so
the operation is atomic a partial failure (e.g. SIGKILL
mid-loop) doesn't leave the DB in a "messages-deleted but
session-row-still-there" half-state.
* Cleans up on-disk transcript files (``.json`` / ``.jsonl`` /
``request_dump_*``) outside the DB transaction when
``sessions_dir`` is provided. Empty sessions don't typically
have transcript files, but the gateway can leave a stub
``request_dump_*`` if it crashed before the first reply
so we still sweep, matching ``prune_sessions``.
Returns the number of sessions deleted.
"""
removed_ids: list[str] = []
def _do(conn):
cursor = conn.execute(
"SELECT id FROM sessions "
"WHERE message_count = 0 "
"AND ended_at IS NOT NULL "
"AND archived = 0"
)
session_ids = {row["id"] for row in cursor.fetchall()}
if not session_ids:
return 0
placeholders = ",".join("?" * len(session_ids))
conn.execute(
f"UPDATE sessions SET parent_session_id = NULL "
f"WHERE parent_session_id IN ({placeholders})",
list(session_ids),
)
for sid in session_ids:
# DELETE FROM messages is paranoia — by construction
# these rows have ``message_count = 0`` — but if a
# bookkeeping bug ever lets the counter drift below the
# real row count, we still leave a clean FK state.
conn.execute(
"DELETE FROM messages WHERE session_id = ?", (sid,)
)
conn.execute("DELETE FROM sessions WHERE id = ?", (sid,))
removed_ids.append(sid)
return len(session_ids)
count = self._execute_write(_do)
for sid in removed_ids:
self._remove_session_files(sessions_dir, sid)
return count
def prune_sessions(
self,
older_than_days: int = 90,
+22 -109
View File
@@ -36,13 +36,8 @@ Key contract points encoded here:
- scope is ``agent_dashboard:access`` only (no OIDC scopes).
- tokens are RS256 JWTs verified against ``/.well-known/jwks.json``;
JWKS is cached for 5 minutes.
- the dashboard auth-code grant issues a 24h rotating refresh token
(Portal NAS PR #293). ``refresh_session`` posts ``grant_type=refresh_token``
to rotate the access token; ``complete_login`` and ``refresh_session``
both populate ``Session.refresh_token`` with the (rotating) value the
middleware persists back to the HttpOnly cookie. On a dead/expired/
reuse-detected refresh token Portal returns 400 ``RefreshExpiredError``
middleware redirects to ``/auth/login``.
- V1 has NO refresh tokens ``refresh_session`` always raises
``RefreshExpiredError`` so the middleware redirects to ``/auth/login``.
- audience claim is the bare ``client_id`` (no ``hermes-cli:`` prefix).
- tolerant ``oauth_contract_version`` check: missing warn + proceed;
present and ``!= 1`` refuse.
@@ -54,11 +49,11 @@ of cookie names; this provider just hands back ``{"code_verifier": …,
"state": }`` and the route serializes those into the ``hermes_session_pkce``
cookie.
Refresh-token rotation: Portal rotates the refresh token on every
successful refresh and runs reuse-detection (replaying a rotated token
outside Portal's 60s grace revokes the whole session). The host
middleware therefore MUST persist the rotated ``Session.refresh_token``
back to the cookie on every refresh.
Forward compatibility: if a future Portal contract starts issuing refresh
tokens, ``complete_login`` already captures the value forward-compatibly
(populates ``Session.refresh_token``). Wiring the RT cookie back into the
middleware's near-expiry refresh path lives in the host application, not
here.
Skip reasons:
The plugin exposes a module-level ``LAST_SKIP_REASON`` that the gate's
@@ -234,94 +229,12 @@ class NousDashboardAuthProvider(DashboardAuthProvider):
except httpx.RequestError as exc:
raise ProviderError(f"Portal token endpoint unreachable: {exc}") from exc
# The dashboard auth-code grant now issues a rotating refresh token
# (24h session, reuse-detected) — Portal NAS PR #293. A 400 here means
# the code/PKCE/redirect_uri failed, surfaced as InvalidCodeError.
return self._token_response_to_session(
response, bad_request_exc=InvalidCodeError
)
def refresh_session(self, *, refresh_token: str) -> Session:
"""Rotate the access token using the refresh token.
Posts ``grant_type=refresh_token`` to Portal's token endpoint. The
refresh token is sent in the ``X-Refresh-Token`` header (not the body)
so it never lands in Portal's request-body access logs — mirroring the
device-flow CLI convention; Portal reconciles header vs. body and
rejects conflicts.
Portal rotates the refresh token on every successful refresh, so the
returned ``Session.refresh_token`` is a NEW value the caller MUST
persist (replacing the old cookie). Failing to persist it means the
next refresh replays a rotated token and outside Portal's 60s grace
trips reuse-detection and revokes the whole session.
Raises ``RefreshExpiredError`` on a 400 (expired / revoked / reuse-
detected), so the middleware clears cookies and forces re-login.
Raises ``ProviderError`` if Portal is unreachable.
"""
if not refresh_token:
# No RT to present — treat as a dead session so middleware
# forces a clean re-login rather than emitting a malformed POST.
raise RefreshExpiredError("no refresh token present in session")
try:
response = httpx.post(
self._token_url,
# The refresh token goes in BOTH the body and the
# ``x-nous-refresh-token`` header. Portal's token endpoint
# requires ``refresh_token`` in the body (its request schema
# rejects a header-only request as ``invalid_request``), and
# additionally reconciles the header against the body — sending
# both lets Portal keep the value out of body-access-logs while
# still satisfying the schema. The header name must match
# Portal's ``REFRESH_TOKEN_HEADER`` exactly (``x-nous-refresh-
# token``); any other name is silently ignored. (Verified
# against the NAS #293 preview deploy: header-only → 400
# invalid_request; body → accepted.)
data={
"grant_type": "refresh_token",
"client_id": self._client_id,
"refresh_token": refresh_token,
},
headers={
"Accept": "application/json",
"x-nous-refresh-token": refresh_token,
},
timeout=_TOKEN_ENDPOINT_TIMEOUT_SEC,
)
except httpx.RequestError as exc:
raise ProviderError(
f"Portal token endpoint unreachable: {exc}"
) from exc
# A 400 on refresh means the RT is expired / revoked / reuse-detected;
# surface as RefreshExpiredError so middleware forces re-login.
return self._token_response_to_session(
response, bad_request_exc=RefreshExpiredError
)
def _token_response_to_session(
self,
response: httpx.Response,
*,
bad_request_exc: type[Exception],
) -> Session:
"""Translate a Portal ``/api/oauth/token`` response into a Session.
Shared by ``complete_login`` (auth-code grant) and ``refresh_session``
(refresh grant). ``bad_request_exc`` is the exception type raised on a
400 ``InvalidCodeError`` for the auth-code path, ``RefreshExpiredError``
for the refresh path so the middleware's distinct handling
(400-on-callback vs. force-relogin) is preserved.
"""
if response.status_code == 400:
# Contract: invalid_code / invalid_grant / redirect_uri_mismatch
# (auth-code) and expired / revoked / reuse-detected (refresh) all
# Contract: invalid_code, invalid_grant, redirect_uri_mismatch all
# surface as 400 with an OAuth-shaped JSON error envelope.
body = self._parse_json_body(response)
error_code = body.get("error", "invalid_request")
raise bad_request_exc(f"Portal rejected token request: {error_code}")
raise InvalidCodeError(f"Portal rejected code: {error_code}")
if response.status_code != 200:
raise ProviderError(
f"Portal token endpoint returned {response.status_code}: "
@@ -338,14 +251,21 @@ class NousDashboardAuthProvider(DashboardAuthProvider):
raise ProviderError(f"unexpected token_type={token_type!r}")
claims = self._verify_jwt(access_token)
# The dashboard grant issues a rotating refresh token; capture it so
# the caller can persist it. Empty string if Portal omitted it (the
# session then behaves as access-token-only until expiry).
# Contract V1: no refresh token expected. If a future Portal ever
# adds one, capture it forward-compatibly.
refresh_token = payload.get("refresh_token") or ""
if not isinstance(refresh_token, str):
refresh_token = ""
return self._session_from_claims(access_token, refresh_token, claims)
def refresh_session(self, *, refresh_token: str) -> Session:
# Contract V1 has no refresh tokens — always force re-auth. If a
# future Portal contract starts issuing them, this method needs to
# be re-implemented; until then it's an unconditional refusal.
raise RefreshExpiredError(
"Nous Portal does not issue refresh tokens in OAuth contract v1; "
"user must re-authenticate via /auth/login."
)
def verify_session(self, *, access_token: str) -> Optional[Session]:
# Contract: returns None on expiry/invalidity (middleware then
@@ -364,16 +284,9 @@ class NousDashboardAuthProvider(DashboardAuthProvider):
return self._session_from_claims(access_token, "", claims)
def revoke_session(self, *, refresh_token: str) -> None:
# Portal exposes no public refresh-token revocation grant on its token
# endpoint (revocation is driven from the authenticated /sessions UI,
# keyed by sessionId + userId, not by the RT value). So logout is
# client-side cookie clearing; the server-side refresh session simply
# expires within its 24h TTL. Best-effort no-op, must not raise.
#
# If Portal later adds a token-endpoint revoke grant (e.g.
# grant_type=... + X-Refresh-Token), implement it here so logout
# invalidates the RT server-side immediately rather than waiting out
# the TTL.
# Contract V1: no refresh tokens to revoke, and no Portal revocation
# endpoint documented for dashboard tokens. Logout is purely
# client-side cookie clearing; this is a best-effort no-op.
_ = refresh_token
return None
@@ -1,7 +1,7 @@
{
"name": "example",
"label": "Example",
"description": "Test-only dashboard plugin fixture — installed by tests that need a stable plugin API endpoint to verify auth + static-asset behaviour",
"description": "Example dashboard plugin — used by test suite for auth coverage",
"icon": "Sparkles",
"version": "1.0.0",
"tab": {
@@ -0,0 +1,17 @@
"""Example dashboard plugin — backend API routes.
Mounted at /api/plugins/example/ by the dashboard plugin system.
This minimal plugin exists so the test suite has a stable, side-effect-free
GET endpoint to verify that plugin API routes work with auth.
"""
from fastapi import APIRouter
router = APIRouter()
@router.get("/hello")
async def hello():
"""Simple greeting endpoint to demonstrate plugin API routes."""
return {"message": "Hello from the example plugin!", "plugin": "example", "version": "1.0.0"}
+77 -50
View File
@@ -289,57 +289,79 @@ function Install-AgentBrowser {
# ============================================================================
function Install-Uv {
# Hermes owns its own uv at $HermesHome\bin\uv.exe. Always install there —
# no PATH probing, no conda guards, no multi-location resolution chains.
# The runtime update path (hermes_cli/managed_uv.py) looks in the same
# place, so install.ps1 and `hermes update` stay in sync.
$managedUv = Join-Path $HermesHome "bin\uv.exe"
if (Test-Path $managedUv) {
$script:UvCmd = $managedUv
$version = & $managedUv --version
Write-Success "Managed uv found ($version)"
Write-Info "Checking for uv package manager..."
# Check if uv is already available
if (Get-Command uv -ErrorAction SilentlyContinue) {
$version = uv --version
$script:UvCmd = "uv"
Write-Success "uv found ($version)"
return $true
}
Write-Info "Installing managed uv into $HermesHome\bin ..."
New-Item -ItemType Directory -Path (Join-Path $HermesHome "bin") -Force | Out-Null
# UV_INSTALL_DIR tells the astral installer to place the binary
# directly into $HermesHome\bin instead of ~/.local/bin.
# Check common install locations
$uvPaths = @(
"$env:USERPROFILE\.local\bin\uv.exe",
"$env:USERPROFILE\.cargo\bin\uv.exe"
)
foreach ($uvPath in $uvPaths) {
if (Test-Path $uvPath) {
$script:UvCmd = $uvPath
$version = & $uvPath --version
Write-Success "uv found at $uvPath ($version)"
return $true
}
}
# Install uv
Write-Info "Installing uv (fast Python package manager)..."
# Capture EAP outside the try block so the catch's restore call always
# has a meaningful value -- if the assignment lived inside try and the
# try body threw before reaching it, the catch would see $prevEAP
# unset and leave EAP at whatever the previous protected call set.
$prevEAP = $ErrorActionPreference
$prevUVInstallDir = $env:UV_INSTALL_DIR
try {
# Relax ErrorActionPreference around the nested astral installer.
# The astral installer (a separate `powershell -c "irm ... | iex"`)
# writes download progress to stderr. With $ErrorActionPreference
# = "Stop" set at the top of this script, PowerShell wraps stderr
# lines from native commands (which `powershell -c` is, from our
# perspective) as ErrorRecord objects when captured via 2>&1, then
# throws a terminating exception on the first one -- even though
# uv installs successfully and the child exits 0. Same fix
# pattern Test-Python uses for `uv python install`; verify success
# via Test-Path on the expected binary afterwards, which is more
# reliable than exit-code/stderr signal anyway.
$ErrorActionPreference = "Continue"
$env:UV_INSTALL_DIR = Join-Path $HermesHome "bin"
powershell -ExecutionPolicy ByPass -c "irm https://astral.sh/uv/install.ps1 | iex" 2>&1 | Out-Null
$ErrorActionPreference = $prevEAP
# Restore UV_INSTALL_DIR — don't leak it into subsequent stages.
if ($null -eq $prevUVInstallDir) {
Remove-Item Env:UV_INSTALL_DIR -ErrorAction SilentlyContinue
} else {
$env:UV_INSTALL_DIR = $prevUVInstallDir
# Find the installed binary
$uvExe = "$env:USERPROFILE\.local\bin\uv.exe"
if (-not (Test-Path $uvExe)) {
$uvExe = "$env:USERPROFILE\.cargo\bin\uv.exe"
}
if (Test-Path $managedUv) {
$script:UvCmd = $managedUv
$version = & $managedUv --version
Write-Success "Managed uv installed ($version)"
if (-not (Test-Path $uvExe)) {
# Refresh PATH and try again
$env:Path = [Environment]::GetEnvironmentVariable("Path", "User") + ";" + [Environment]::GetEnvironmentVariable("Path", "Machine")
if (Get-Command uv -ErrorAction SilentlyContinue) {
$uvExe = (Get-Command uv).Source
}
}
if (Test-Path $uvExe) {
$script:UvCmd = $uvExe
$version = & $uvExe --version
Write-Success "uv installed ($version)"
return $true
}
Write-Err "uv installed but not found at $managedUv"
Write-Info "Install manually: https://docs.astral.sh/uv/getting-started/installation/"
Write-Err "uv installed but not found on PATH"
Write-Info "Try restarting your terminal and re-running"
return $false
} catch {
# Restore EAP in case the try block threw before the assignment
if ($prevEAP) { $ErrorActionPreference = $prevEAP }
# Restore UV_INSTALL_DIR on error too.
if ($null -eq $prevUVInstallDir) {
Remove-Item Env:UV_INSTALL_DIR -ErrorAction SilentlyContinue
} else {
$env:UV_INSTALL_DIR = $prevUVInstallDir
}
Write-Err "Failed to install uv: $_"
Write-Info "Install manually: https://docs.astral.sh/uv/getting-started/installation/"
return $false
@@ -363,9 +385,11 @@ function Sync-EnvPath {
# in a fresh powershell process, so $script:UvCmd set by Install-Uv in a
# prior process is not visible here. Later stages (Test-Python,
# Install-Venv, Install-Dependencies, Install-PlatformSdks) call this
# at the top to populate $script:UvCmd from the managed location.
# Throws if uv is not findable the caller's stage then surfaces a
# clean error via the stage-driver's try/catch.
# at the top to populate $script:UvCmd from PATH or known install paths.
# Throws if uv is not findable -- the caller's stage then surfaces a
# clean error via the stage-driver's try/catch. Fast path is a single
# Get-Command call when uv is on PATH (the common case after Stage-Uv
# ran path-modifying installs in a sibling process).
function Resolve-UvCmd {
# Already resolved (default invocation path: Install-Uv ran earlier
# in the same process and set $script:UvCmd).
@@ -380,15 +404,9 @@ function Resolve-UvCmd {
# Stale; fall through to re-discover.
}
# Check the managed location first — this is where Install-Uv puts it.
$managedUv = Join-Path $HermesHome "bin\uv.exe"
if (Test-Path $managedUv) {
$script:UvCmd = $managedUv
return
}
# Fall back to PATH (covers edge cases where the installer ran in a
# sibling process and HERMES_HOME wasn't propagated).
# Try PATH first (covers `winget install astral.uv`, manual installs,
# and the post-Install-Uv state where uv.exe lives in
# %USERPROFILE%\.local\bin which the installer added to PATH).
if (Get-Command uv -ErrorAction SilentlyContinue) {
$script:UvCmd = "uv"
return
@@ -402,7 +420,16 @@ function Resolve-UvCmd {
return
}
throw "uv is not installed. Run install.ps1 -Stage uv first."
# Check the well-known install locations the astral.sh installer drops
# uv into. Mirrors the probe order Install-Uv uses.
foreach ($uvPath in @("$env:USERPROFILE\.local\bin\uv.exe", "$env:USERPROFILE\.cargo\bin\uv.exe")) {
if (Test-Path $uvPath) {
$script:UvCmd = $uvPath
return
}
}
throw "uv is not installed or not on PATH. Run install.ps1 -Stage uv first."
}
function Test-Python {
+138 -17
View File
@@ -475,22 +475,39 @@ install_uv() {
return 0
fi
# Hermes owns its own uv at $HERMES_HOME/bin/uv. Always install there —
# no PATH probing, no conda guards, no multi-location resolution chains.
# The runtime update path (hermes_cli/managed_uv.py) looks in the same
# place, so install.sh and `hermes update` stay in sync.
local _managed_uv="$HERMES_HOME/bin/uv"
log_info "Checking for uv package manager..."
if [ -x "$_managed_uv" ]; then
UV_CMD="$_managed_uv"
# Check common locations for uv
if command -v uv &> /dev/null; then
UV_CMD="uv"
UV_VERSION=$($UV_CMD --version 2>/dev/null)
log_success "Managed uv found ($UV_VERSION)"
log_success "uv found ($UV_VERSION)"
return 0
fi
log_info "Installing managed uv into $HERMES_HOME/bin ..."
mkdir -p "$HERMES_HOME/bin"
# Check ~/.local/bin (default uv install location) even if not on PATH yet
if [ -x "$HOME/.local/bin/uv" ]; then
UV_CMD="$HOME/.local/bin/uv"
UV_VERSION=$($UV_CMD --version 2>/dev/null)
log_success "uv found at ~/.local/bin ($UV_VERSION)"
return 0
fi
# Check ~/.cargo/bin (alternative uv install location)
if [ -x "$HOME/.cargo/bin/uv" ]; then
UV_CMD="$HOME/.cargo/bin/uv"
UV_VERSION=$($UV_CMD --version 2>/dev/null)
log_success "uv found at ~/.cargo/bin ($UV_VERSION)"
return 0
fi
# Install uv
log_info "Installing uv (fast Python package manager)..."
# Capture installer output so a failure shows the user WHY (network,
# glibc mismatch on old distros, missing curl, ~/.local/bin not
# writable, disk full, corp proxy / TLS interception, etc.) instead
# of the previous "✗ Failed to install uv" with zero diagnostic.
#
# Two-stage: download the installer, then run it. Piping
# `curl | sh` masks curl failures (sh exits 0 on empty stdin)
# and conflates network errors with installer errors.
@@ -505,22 +522,26 @@ install_uv() {
rm -f "$_uv_install_log" "$_uv_installer"
exit 1
fi
# UV_UNMANAGED_INSTALL tells the astral installer to place the binary
# directly into $HERMES_HOME/bin instead of ~/.local/bin.
if UV_UNMANAGED_INSTALL="$HERMES_HOME/bin" sh "$_uv_installer" >>"$_uv_install_log" 2>&1; then
if sh "$_uv_installer" >>"$_uv_install_log" 2>&1; then
rm -f "$_uv_installer"
if [ -x "$_managed_uv" ]; then
UV_CMD="$_managed_uv"
# uv installs to ~/.local/bin by default
if [ -x "$HOME/.local/bin/uv" ]; then
UV_CMD="$HOME/.local/bin/uv"
elif [ -x "$HOME/.cargo/bin/uv" ]; then
UV_CMD="$HOME/.cargo/bin/uv"
elif command -v uv &> /dev/null; then
UV_CMD="uv"
else
log_error "uv installer reported success but binary not found at $_managed_uv"
log_error "uv installer reported success but binary not found on PATH"
log_info "Installer output:"
sed 's/^/ /' "$_uv_install_log" >&2
log_info "Try adding ~/.local/bin to your PATH and re-running"
rm -f "$_uv_install_log"
exit 1
fi
rm -f "$_uv_install_log"
UV_VERSION=$($UV_CMD --version 2>/dev/null)
log_success "Managed uv installed ($UV_VERSION)"
log_success "uv installed ($UV_VERSION)"
else
log_error "Failed to install uv"
log_info "Installer output:"
@@ -558,6 +579,7 @@ check_python() {
if PYTHON_PATH="$("$UV_CMD" python find "$PYTHON_VERSION" 2>/dev/null)"; then
PYTHON_FOUND_VERSION="$("$PYTHON_PATH" --version 2>/dev/null)"
log_success "Python found: $PYTHON_FOUND_VERSION"
ensure_fts5
return 0
fi
@@ -567,6 +589,7 @@ check_python() {
PYTHON_PATH="$("$UV_CMD" python find "$PYTHON_VERSION")"
PYTHON_FOUND_VERSION="$("$PYTHON_PATH" --version 2>/dev/null)"
log_success "Python installed: $PYTHON_FOUND_VERSION"
ensure_fts5
else
log_error "Failed to install Python $PYTHON_VERSION"
log_info "Install Python $PYTHON_VERSION manually, then re-run this script"
@@ -574,6 +597,104 @@ check_python() {
fi
}
# Probe whether $1 (a python executable) links a SQLite with the FTS5
# module compiled in. Hermes' session store (hermes_state.py) creates FTS5
# virtual tables for full-text session search; a SQLite without FTS5 makes
# the bundled-python path unusable for that feature. Returns 0 if FTS5 works.
_python_has_fts5() {
"$1" - <<'PY' 2>/dev/null
import sqlite3, sys
try:
sqlite3.connect(":memory:").execute("CREATE VIRTUAL TABLE t USING fts5(x)")
except Exception:
sys.exit(1)
PY
}
# Reinstall $PYTHON_VERSION with the current uv and re-resolve PYTHON_PATH.
# Returns 0 if the resulting interpreter ships FTS5.
_reinstall_python_with_fts5() {
local uv_bin="$1"
"$uv_bin" python install "$PYTHON_VERSION" --reinstall >/dev/null 2>&1 || return 1
PYTHON_PATH="$("$uv_bin" python find "$PYTHON_VERSION" 2>/dev/null)"
PYTHON_FOUND_VERSION="$("$PYTHON_PATH" --version 2>/dev/null)"
[ -n "${PYTHON_PATH:-}" ] && _python_has_fts5 "$PYTHON_PATH"
}
_warn_no_fts5() {
# Could not obtain an FTS5-capable interpreter (offline, pinned env, etc.).
# Install proceeds — Hermes degrades gracefully and disables only full-text
# session search — but warn so it isn't a silent gap.
log_warn "Could not obtain an FTS5-capable Python. Hermes will run, but"
log_warn "full-text session search will be disabled until FTS5 is present."
}
# Guarantee the resolved uv-managed interpreter ships FTS5. uv's Python
# distributions only gained FTS5 in mid-2025 (python-build-standalone #694),
# but WHICH builds a given uv can install is baked into the uv binary's
# download manifest — so a stale uv (e.g. `pip install uv==0.7.20`) only knows
# about pre-FTS5 builds, and even `uv python install --reinstall` just pulls the
# same FTS5-less interpreter. A plain reinstall with an old uv is therefore a
# no-op for FTS5. To actually fix everyone's install, we escalate uv itself:
#
# 1. reinstall with the current $UV_CMD (handles a stale *interpreter* under
# an already-current uv)
# 2. if still no FTS5, bring uv up to date (`uv self update`) and reinstall —
# this is what fixes a stale standalone uv
# 3. if uv can't self-update (pip/apt/brew-managed uv refuses), install a
# fresh standalone uv via the official installer into a temp dir and use
# THAT to reinstall — this fixes package-manager-managed stale uv
#
# Pythons live in uv's shared store, so a fresh uv's --reinstall overwrites the
# stale interpreter in place and the installer's later `uv python find` resolves
# to it. Keeps session search working without bundling a second SQLite or asking
# the user to do anything.
ensure_fts5() {
[ -n "${PYTHON_PATH:-}" ] || return 0
if _python_has_fts5 "$PYTHON_PATH"; then
return 0
fi
# Termux / non-uv installs have nothing to escalate.
[ -n "${UV_CMD:-}" ] || { _warn_no_fts5; return 0; }
log_warn "Resolved Python's SQLite lacks the FTS5 module (session search needs it)."
log_info "Reinstalling a current Python $PYTHON_VERSION with FTS5 via uv..."
if _reinstall_python_with_fts5 "$UV_CMD"; then
log_success "FTS5 available ($PYTHON_FOUND_VERSION)"
return 0
fi
# Still no FTS5 — the uv binary itself is too old to know about FTS5-capable
# Python builds. Try to update uv in place.
log_info "uv is too old to provide an FTS5-capable Python — updating uv..."
if "$UV_CMD" self update >/dev/null 2>&1; then
if _reinstall_python_with_fts5 "$UV_CMD"; then
log_success "FTS5 available ($PYTHON_FOUND_VERSION)"
return 0
fi
fi
# `uv self update` is unavailable on externally-managed uv (pip/apt/brew),
# which is exactly the case the user hit (`pip install uv==0.7.20`). Install
# a fresh standalone uv into a temp dir and use it just for the reinstall.
log_info "Installing an up-to-date standalone uv to obtain an FTS5 Python..."
local _tmp_uv_dir _fresh_uv
_tmp_uv_dir="$(mktemp -d 2>/dev/null || echo "/tmp/hermes-fresh-uv.$$")"
mkdir -p "$_tmp_uv_dir"
if curl -LsSf https://astral.sh/uv/install.sh 2>/dev/null \
| env UV_INSTALL_DIR="$_tmp_uv_dir" UV_UNMANAGED_INSTALL="$_tmp_uv_dir" sh >/dev/null 2>&1; then
_fresh_uv="$_tmp_uv_dir/uv"
if [ -x "$_fresh_uv" ] && _reinstall_python_with_fts5 "$_fresh_uv"; then
log_success "FTS5 available ($PYTHON_FOUND_VERSION)"
rm -rf "$_tmp_uv_dir"
return 0
fi
fi
rm -rf "$_tmp_uv_dir"
_warn_no_fts5
}
# Best-effort automatic git provisioning, mirroring install.ps1's Install-Git
# (which downloads PortableGit on Windows). git is required to clone the repo,
# and a fresh "normie" machine with no developer tools won't have it. Returns 0
@@ -0,0 +1,209 @@
---
name: dynamic-workflow
description: Orchestrate large fan-out work as a plan-in-code "workflow" so the agent's context holds only the final verified answer, not the exhaust of hundreds of intermediate steps. Use for codebase-wide sweeps, large migrations, multi-angle research, and any task too big for one context window where the split strategy is known enough to script. Includes the adversarial-convergence verification recipe (independent attempts + refuters, keep only surviving claims).
version: 1.0.0
author: Hermes Agent + Teknium
license: MIT
metadata:
hermes:
tags: [orchestration, fan-out, subagents, delegation, verification, migration, audit, research]
category: autonomous-ai-agents
related_skills: []
when_to_use:
- A task is too big for one context window AND you can describe the split (per-file, per-endpoint, per-source, per-record)
- You want orchestration codified as a re-runnable script, not improvised turn-by-turn
- Quality matters more than token economy: you want independent attempts cross-checked / refuted before you trust the answer
- Codebase-wide bug/security sweep, 100+ file migration, multi-angle research with sources cross-checked
when_not_to_use:
- Small bounded task (<~10 units) — just call the tool directly or do it inline
- Tight serial dependency (B needs A's output) — orchestration overhead is wasted
- You need it to survive the user sending a new message — see "The synchronous trap" below; use cron/kanban instead
---
# Dynamic Workflow — plan-in-code fan-out with verification
This is Hermes's answer to Claude Code's "dynamic workflows" (run hundreds of
parallel subagents in one session). The mechanic worth copying is NOT "more
subagents" — it is **moving the plan, the loop, and the intermediate results
OUT of the context window and INTO a script.** Normally the agent IS the
orchestrator: every intermediate result piles into context, which is exactly
what caps you at a handful of agents. A workflow keeps only the *final verified
answer* in context; the script holds everything else.
> This skill is self-contained, but it builds on standard fan-out hygiene —
> chunk inputs to ~50-70KB per child, route structured output to files (not the
> `summary` field, which truncates under load), use delimiter-separated lines
> over JSON wrappers, and remember that a "stalled" child often completed its
> write anyway (check the filesystem before retrying). If your install has a
> `delegate-task-output-patterns` skill, load it for the detailed thresholds;
> the rules above are the load-bearing subset.
## The two orchestration-script layers (pick the right one — they are NOT interchangeable)
Hermes has no JS runtime. The "orchestration script" is one of two layers, and
the split is enforced by a real capability boundary, not a style preference:
| | Layer A: `execute_code` (Python script) | Layer B: `delegate_task` batch |
|---|---|---|
| Use for | DETERMINISTIC fan-out — fetch N URLs, parse N files, run N shell commands, template N outputs | LLM-JUDGMENT fan-out — classify, review, decide, write, refute, audit per item |
| The script holds | the loop + branching + intermediate vars (real Python) | n/a — you call it once with a `tasks=[...]` array; each task is its own isolated agent |
| Tools available inside | `web_search, web_extract, read_file, write_file, search_files, terminal, patch` ONLY (the `SANDBOX_ALLOWED_TOOLS` set) | configured child toolsets, subject to delegate restrictions (leaf children are stripped of `delegate_task`, `clarify`, `memory`, `send_message`, `execute_code` — see `DELEGATE_BLOCKED_TOOLS`) |
| Can it call `delegate_task`? | **NO.** `delegate_task` is NOT in `SANDBOX_ALLOWED_TOOLS`. Do not write a script that imports it — it will fail. | itself, if `role='orchestrator'` and `max_spawn_depth>=2` |
| Concurrency | you control it in Python (`ThreadPoolExecutor`, batches) | `delegation.max_concurrent_children` (default 3; raise in config.yaml) |
| Cost shape | cheap — most steps are tool calls, no per-item LLM unless you call `web_search`/aux | one model call tree PER child task — multiplies linearly, can be very expensive |
**Rule of thumb:** do the deterministic part in Layer A first (inline, in a
script), then fan out ONLY the irreducibly-LLM step via Layer B. This is
Pattern 1 from `delegate-task-output-patterns`, applied at workflow scale.
Mixing them: a Layer-A script can write a manifest file, and you (the parent)
then read that manifest and issue a single Layer-B `delegate_task` batch.
## The synchronous trap (READ THIS — it is the #1 way a "workflow" disappoints)
`delegate_task` runs **synchronously inside the parent turn**. If the user sends
a new message, hits /stop, or /new, every in-flight child is **cancelled and its
work discarded** (status `interrupted`). It does NOT run in the background, and
it does NOT survive the turn. There is no cache-resume of a half-finished fan-out.
So a "workflow" in Hermes is one of:
1. **Foreground workflow (default):** Layer A and/or one Layer-B batch, completed
within a single turn. Good for minutes-long fan-out (dozens of units). The
user waits. This is what you build 90% of the time.
2. **Durable workflow (hours/days, survives interruption):** use the **kanban
swarm** (the SQLite-backed multi-agent kernel that ships with Hermes —
`hermes_cli/kanban_swarm.py` + the kanban plugin; if your install has a
`kanban-multiagent` skill, load it for the workflow). It
writes a task graph (root → parallel workers → verifier → synthesizer) into
the SQLite kanban kernel with a JSON blackboard. State persists across turns
and restarts. This is the ONLY path that matches Claude Code's "runs into
hours and days, resumes where it left off." Reach for it when the foreground
path would time out or when the user must be able to walk away.
Never promise "background, resumable, hundreds of agents over days" from a plain
`delegate_task` call. For a durable multi-agent workflow *graph*, the kanban
swarm is the right fit. For simpler durable/out-of-turn cases there are lighter
options too: a `cronjob` one-shot or scheduled job, or a managed
`terminal(background=True, notify_on_complete=True)` process — both survive the
turn without standing up a full task graph.
## Workflow recipe (foreground)
1. **Decompose into independent units.** What is the unit — a file? an endpoint?
a source? a record? Each unit must be answerable WITHOUT the others' output
(else it's serial, not fan-out — see when_not_to_use).
2. **Deterministic pre-pass (Layer A).** In one `execute_code` script, gather the
manifest: list the files, extract the candidate sites, fetch the raw sources,
compute anything regex/parse can compute. Write a manifest to a **unique
per-run** directory — `/tmp/wf_<name>_<uuid>/manifest.jsonl` (one unit per
line), never a bare `/tmp/wf_<name>/` that a prior interrupted run could have
left stale outputs in. This is the "plan in code." Print the unit count and
the run dir, and stop.
3. **Size the fan-out** against `delegate-task-output-patterns`: chunk so each
child handles ~8-12 mechanical file edits OR ~2000-3000 lines of reading OR
~50-70KB of corpus. Look at the LARGEST unit, not the average. One
`delegate_task(tasks=[...])` call is bounded by
`delegation.max_concurrent_children` (default 3) — it does NOT queue hundreds
of tasks internally. For larger fan-out, issue bounded waves yourself (loop:
one batch, collect, next batch) or have the user raise the config
intentionally.
4. **LLM-judgment fan-out (Layer B).** Issue ONE `delegate_task` with a `tasks=[]`
array, one task per chunk. Each task: reads its slice from the manifest,
emits delimiter-separated lines to `/tmp/wf_<name>_<uuid>/out_<i>.csv`, prints a
status word, stops. Do NOT depend on the `summary` field for content.
5. **Synthesize on the parent.** Read the out_*.csv files yourself — verify the
file count and freshness (each was written this run) so a stale or missing
output from an interrupted child isn't silently read as success — then merge
and present. The cross-cutting "whole picture" step stays on the parent — only
the per-unit work fanned out.
## The novel mechanic worth building: adversarial convergence
This is the part Hermes did NOT already have and the real reason to bother.
Claude Code's quality claim ("independent agents try to refute each other's
findings; only surviving claims surface; iterate until they converge") maps
cleanly onto `delegate_task` batch mode:
### Recipe: N independent attempts + M refuters
For a finding-quality task (security audit, "is this code path actually
vulnerable?", "does this migration preserve behavior?", a high-stakes plan):
1. **Independent attempts (round 1).** Fan out the SAME question to N children
(N=2-4) with DIFFERENT framings/angles in each `context`, so they don't
collapse to the same reasoning. Each writes its claims to
`/tmp/wf_<name>/attempt_<i>.md` as a list of discrete, individually-checkable
claims (one claim per line — atomicity is what makes refutation possible).
2. **Collect + dedupe (parent or Layer A).** Merge all claims into a single
numbered list. Identical claims from independent attempts = higher prior;
note the agreement count per claim.
3. **Refutation round (round 2).** Fan out a refuter batch: each refuter gets the
claim list and is told "your job is to BREAK these claims — for each, find the
counter-evidence (the auth check that DOES exist, the test that DOES cover it,
the edge case the claim ignores). Output `claim_idx|survives|counter_evidence`."
Give refuters the codebase/sources, not the original attempts' reasoning.
4. **Keep only survivors.** A claim surfaces to the user only if it survived
refutation (no refuter produced valid counter-evidence). Filtered claims are
dropped, with a one-line note of why if the user asked for completeness.
5. **Converge (optional).** If round 2 surfaced NEW claims (refuters often find
adjacent issues), feed them back through one more refutation round. Stop when
a round produces no new surviving claims — that's convergence. Cap at 3 rounds
to bound cost.
This gives you the "more trustworthy than a single pass" property without a
runtime — it's just two `delegate_task` batches and a merge, structured so
disagreement is visible and unsupported claims die before they reach the user.
### Why atomic claims matter
A refuter cannot break "the auth layer has problems." It CAN break "endpoint
`POST /api/users/:id/role` in src/routes/users.ts:142 has no role check." Force
attempts to emit specific, located, individually-falsifiable claims or the
refutation round is theater.
## Cost discipline (this is the thing that bites)
A workflow can consume dramatically more tokens than a normal turn — that is
inherent, not a bug. Two real multipliers:
- **Each Layer-B child is a full agent tree.** 20 children ≈ 20× the model calls.
`delegation.max_concurrent_children` only bounds *concurrency*, not *total*.
- **Hermes aux/subagent model defaults to main-model-first.** Children inherit
the parent's (often expensive reasoning) model. `delegate_task` does NOT expose
a per-task `model` or `profile` field — its per-task keys are
`{goal, context, toolsets, role}`. To run the fan-out cheaper you either route
delegation globally via `delegation` config (model/provider applied to all
children), or — for genuinely model/profile-scoped work — use cron, the kanban
swarm, or a separate Hermes process. The cleanest lever for mechanical fan-out
is still Layer A: do the deterministic part in a script with no per-item LLM at
all.
Always: start on a SCOPED slice (one directory, 20 records, 10 endpoints), prove
the recipe end-to-end, report the token cost, THEN offer to run it at full scale.
Never silently fan out hundreds of children — surface the cost first and let the
user say go.
## Pitfalls
- **Writing `delegate_task` inside an `execute_code` script.** It's not in
`SANDBOX_ALLOWED_TOOLS`; the import/stub won't exist. Layer A is deterministic
tools only. Fan out LLM judgment from the parent turn, not from inside a script.
- **Promising background/resumable from `delegate_task`.** It's synchronous and
turn-scoped. Durable = kanban swarm.
- **Trusting `summary` fields for content.** Route structured output to files
(Pattern 2 in delegate-task-output-patterns).
- **Non-atomic claims in the verify recipe.** Unfalsifiable claims survive
refutation by default and pollute the output. Force located, specific claims.
- **Same framing in all "independent" attempts.** They collapse to one answer and
the cross-check is worthless. Vary the angle in each child's context.
- **Fanning out a serial task.** If unit B needs unit A's output, parallelism
produces wrong/empty results. Re-check independence before fanning out.
## Verification before you call it done
- Did the deterministic pre-pass actually run, and does the manifest line-count
match the expected unit count? (`wc -l /tmp/wf_<name>/manifest.jsonl`)
- Did every fan-out child write its output file? (`ls /tmp/wf_<name>/out_*.csv`) —
remember stalled children often completed anyway (Pattern 6).
- For the verify recipe: can you point to the refuter counter-evidence for every
DROPPED claim, and confirm every SURFACED claim went through refutation?
- Did you report token cost on the scoped run before offering full scale?
@@ -1,212 +0,0 @@
"""Regression tests for GitHub #36823 — the TUI must reset terminal input
modes on exit so focus-reporting / mouse-tracking escape sequences don't leak
into the next shell session sharing the tab.
prompt_toolkit restores these on a clean teardown, but Ctrl+C, SIGTERM/SIGHUP
and crashes can bypass its unwind. ``_run_cleanup`` (the once-only cleanup that
runs on every catchable exit path, including ``atexit``) now emits the disable
sequence as its first step via ``_reset_terminal_input_modes_on_exit`` gated
on ``_tui_input_modes_active`` so non-TUI one-shot CLI runs (which share
``_run_cleanup`` via ``atexit``) don't emit codes for modes they never set.
"""
import unittest
from unittest.mock import mock_open, patch
def _import_cli():
import hermes_cli.config as config_mod
if not hasattr(config_mod, "save_env_value_secure"):
config_mod.save_env_value_secure = lambda key, value: {
"success": True,
"stored_as": key,
"validated": False,
}
import cli as cli_mod
return cli_mod
class _FakeStream:
def __init__(self, isatty: bool = True):
self._isatty = isatty
self.written: list[str] = []
self.flushed = 0
def isatty(self) -> bool:
return self._isatty
def write(self, s: str) -> int:
self.written.append(s)
return len(s)
def flush(self) -> None:
self.flushed += 1
class TestResetTerminalInputModes(unittest.TestCase):
def test_emits_reset_seq_on_tty_when_tui_ran(self):
cli_mod = _import_cli()
fake = _FakeStream(isatty=True)
with (
patch.object(cli_mod, "_tui_input_modes_active", True),
patch.object(cli_mod.sys, "stdout", fake),
):
cli_mod._reset_terminal_input_modes_on_exit()
written = "".join(fake.written)
self.assertEqual(written, cli_mod._TERMINAL_INPUT_MODE_RESET_SEQ)
self.assertGreaterEqual(fake.flushed, 1)
# The focus-reporting disable is the specific leak the issue reports.
self.assertIn("\x1b[?1004l", written)
def test_noop_when_tui_never_ran(self):
"""Non-TUI one-shot CLI runs share _run_cleanup via atexit — they must
not emit terminal escape codes they never needed (review finding #1)."""
cli_mod = _import_cli()
fake = _FakeStream(isatty=True)
with (
patch.object(cli_mod, "_tui_input_modes_active", False),
patch.object(cli_mod.sys, "stdout", fake),
# Guard: must not touch the real /dev/tty either.
patch("builtins.open", mock_open()) as m_open,
):
cli_mod._reset_terminal_input_modes_on_exit()
self.assertEqual(fake.written, [])
m_open.assert_not_called()
def test_noop_when_not_a_tty_and_no_dev_tty(self):
"""stdout redirected and /dev/tty unavailable → nothing written, no raise."""
cli_mod = _import_cli()
fake = _FakeStream(isatty=False)
with (
patch.object(cli_mod, "_tui_input_modes_active", True),
patch.object(cli_mod.sys, "stdout", fake),
patch("builtins.open", side_effect=OSError("no /dev/tty")),
):
cli_mod._reset_terminal_input_modes_on_exit()
self.assertEqual(fake.written, [], "must not pollute the redirected stream")
def test_falls_back_to_dev_tty_when_stdout_redirected(self):
"""When stdout isn't the terminal, reset via /dev/tty (issue's own
suggestion) so a TUI that drove /dev/tty still gets cleaned up."""
cli_mod = _import_cli()
fake = _FakeStream(isatty=False)
m_open = mock_open()
with (
patch.object(cli_mod, "_tui_input_modes_active", True),
patch.object(cli_mod.sys, "stdout", fake),
patch("builtins.open", m_open),
):
cli_mod._reset_terminal_input_modes_on_exit()
self.assertEqual(fake.written, [])
m_open.assert_called_once_with("/dev/tty", "w", encoding="ascii")
m_open().write.assert_called_once_with(cli_mod._TERMINAL_INPUT_MODE_RESET_SEQ)
def test_swallows_stdout_errors(self):
cli_mod = _import_cli()
class _Boom:
def isatty(self):
raise OSError("stdout closed")
with (
patch.object(cli_mod, "_tui_input_modes_active", True),
patch.object(cli_mod.sys, "stdout", _Boom()),
patch("builtins.open", side_effect=OSError("no /dev/tty")),
):
# Cleanup runs at process teardown — it must never raise.
cli_mod._reset_terminal_input_modes_on_exit()
def test_mark_tui_input_modes_active_sets_flag(self):
cli_mod = _import_cli()
original = cli_mod._tui_input_modes_active
cli_mod._tui_input_modes_active = False
try:
cli_mod._mark_tui_input_modes_active()
self.assertTrue(cli_mod._tui_input_modes_active)
finally:
cli_mod._tui_input_modes_active = original
def test_flag_cleared_after_reset(self):
"""Once the modes are disabled they are no longer active — the flag must
flip back so a re-armed cleanup doesn't re-emit the sequence."""
cli_mod = _import_cli()
fake = _FakeStream(isatty=True)
original = cli_mod._tui_input_modes_active
cli_mod._tui_input_modes_active = True
try:
with patch.object(cli_mod.sys, "stdout", fake):
cli_mod._reset_terminal_input_modes_on_exit()
self.assertIn("\x1b[?1004l", "".join(fake.written))
self.assertFalse(
cli_mod._tui_input_modes_active, "flag must clear after reset"
)
finally:
cli_mod._tui_input_modes_active = original
class TestRunCleanupWiring(unittest.TestCase):
"""_run_cleanup must call the reset, as its first step, on every invocation
even if a later cleanup step raises."""
def _run_cleanup_isolated(self, cli_mod, **extra_patches):
"""Invoke _run_cleanup with heavy/real teardown steps stubbed out so the
test is hermetic (review finding #5)."""
original_done = cli_mod._cleanup_done
cli_mod._cleanup_done = False
patches = {
"_cleanup_all_terminals": lambda: None,
"_cleanup_all_browsers": lambda: None,
}
try:
with (
patch.object(
cli_mod, "_reset_terminal_input_modes_on_exit"
) as mock_reset,
patch.object(
cli_mod, "_cleanup_all_terminals", patches["_cleanup_all_terminals"]
),
patch.object(
cli_mod, "_cleanup_all_browsers", patches["_cleanup_all_browsers"]
),
patch("tools.mcp_tool.shutdown_mcp_servers", lambda *a, **k: None),
patch(
"agent.auxiliary_client.shutdown_cached_clients",
lambda *a, **k: None,
),
patch("hermes_cli.plugins.invoke_hook", lambda *a, **k: None),
):
if extra_patches.get("terminals_raise"):
with patch.object(
cli_mod,
"_cleanup_all_terminals",
side_effect=RuntimeError("boom"),
):
cli_mod._run_cleanup()
else:
cli_mod._run_cleanup()
return mock_reset
finally:
cli_mod._cleanup_done = original_done
def test_run_cleanup_calls_reset(self):
cli_mod = _import_cli()
mock_reset = self._run_cleanup_isolated(cli_mod)
mock_reset.assert_called_once()
def test_reset_runs_even_when_a_cleanup_step_raises(self):
"""The reset is the first step, so a failing teardown step can't skip
it covering the Ctrl+C / crash paths the issue is about."""
cli_mod = _import_cli()
mock_reset = self._run_cleanup_isolated(cli_mod, terminals_raise=True)
mock_reset.assert_called_once()
if __name__ == "__main__":
unittest.main()
@@ -1,24 +0,0 @@
"""Example dashboard plugin — backend API routes (test fixture).
This plugin lives under ``tests/fixtures/plugins/`` so it is NOT shipped as
part of the bundled-plugins set; a stock hermes-agent install does not see
an "Example" tab in its sidebar. The ``_install_example_plugin`` pytest
fixture in ``tests/hermes_cli/test_web_server.py`` copies this directory
into ``$HERMES_HOME/plugins/example-dashboard/`` and forces the dashboard
plugin discovery cache to rescan, so tests that need a stable, side-effect-
free GET endpoint to verify plugin API auth + static-asset behaviour can
hit ``/api/plugins/example/hello`` (and ``/dashboard-plugins/example/
manifest.json``) without depending on any production-facing plugin.
Mounted at /api/plugins/example/ by the dashboard plugin system.
"""
from fastapi import APIRouter
router = APIRouter()
@router.get("/hello")
async def hello():
"""Simple greeting endpoint to demonstrate plugin API routes."""
return {"message": "Hello from the example plugin!", "plugin": "example", "version": "1.0.0"}
+2 -5
View File
@@ -164,12 +164,9 @@ class TestSessionResetPolicy:
class TestStreamingConfig:
def test_defaults_to_auto_transport(self):
# "auto" prefers native draft streaming where the platform supports
# it (Telegram DMs) and falls back to edit-based everywhere else, so
# it is safe as the global out-of-the-box default.
def test_defaults_to_edit_transport(self):
restored = StreamingConfig.from_dict({"enabled": "true"})
assert restored.transport == "auto"
assert restored.transport == "edit"
def test_from_dict_coerces_quoted_false_enabled(self):
restored = StreamingConfig.from_dict({"enabled": "false"})
@@ -1,65 +0,0 @@
"""Per-platform streaming defaults + dashboard exposure.
Streaming is smooth on Telegram (native sendMessageDraft) but flickers on
edit-only platforms like Discord. The shipped defaults encode that:
display.platforms.telegram.streaming=true, .discord.streaming=false. These are
gap-fillers (user values win via deep-merge) and, because the dashboard schema
is generated from DEFAULT_CONFIG, they automatically appear as editable toggles
in the web UI.
"""
from __future__ import annotations
def test_default_per_platform_streaming_flags():
from hermes_cli.config import DEFAULT_CONFIG
plats = DEFAULT_CONFIG["display"]["platforms"]
assert plats["telegram"]["streaming"] is True
assert plats["discord"]["streaming"] is False
def test_resolver_telegram_on_discord_off_when_global_enabled():
"""With global streaming on, the per-platform defaults make Telegram stream
and Discord not matching the platforms' actual streaming quality."""
from hermes_cli.config import DEFAULT_CONFIG
from gateway.display_config import resolve_display_setting
cfg = dict(DEFAULT_CONFIG)
cfg["streaming"] = {"enabled": True, "transport": "auto"}
def streams(plat):
ov = resolve_display_setting(cfg, plat, "streaming")
# global enabled; None override = follow global (True)
return True if ov is None else bool(ov)
assert streams("telegram") is True
assert streams("discord") is False
# A platform with no default entry follows the global switch.
assert streams("slack") is True
def test_user_override_wins_over_default():
"""A user who explicitly enables Discord streaming keeps their value — the
default false must not clobber it (config deep-merge: user wins)."""
from hermes_cli.config import DEFAULT_CONFIG, _deep_merge
user = {"display": {"platforms": {"discord": {"streaming": True}}}}
merged = _deep_merge(dict(DEFAULT_CONFIG), user)
assert merged["display"]["platforms"]["discord"]["streaming"] is True
# Partial override must not wipe the sibling telegram default.
assert merged["display"]["platforms"]["telegram"]["streaming"] is True
def test_dashboard_schema_exposes_per_platform_streaming():
"""Because the web settings schema is built from DEFAULT_CONFIG, the
per-platform streaming toggles surface in the dashboard automatically."""
import pytest
pytest.importorskip("fastapi") # web_server requires fastapi/uvicorn
from hermes_cli.web_server import CONFIG_SCHEMA
assert "display.platforms.telegram.streaming" in CONFIG_SCHEMA
assert "display.platforms.discord.streaming" in CONFIG_SCHEMA
assert CONFIG_SCHEMA["display.platforms.discord.streaming"]["type"] == "boolean"
# Global streaming controls are exposed too.
assert "streaming.enabled" in CONFIG_SCHEMA
assert "streaming.transport" in CONFIG_SCHEMA
-182
View File
@@ -1,182 +0,0 @@
"""Structured stream-event protocol + dispatcher behavior.
Covers the agentgateway delivery contract introduced to decouple *what
happened* (typed events) from *how it's delivered* (adapter decides). The
default BasePlatformAdapter rendering must reproduce today's behavior exactly;
an adapter may override format_tool_event to eat tool chrome on platforms that
can't render it.
"""
from __future__ import annotations
from unittest.mock import MagicMock
from gateway.stream_dispatch import GatewayEventDispatcher
from gateway.stream_events import (
Commentary,
GatewayNotice,
LongToolHint,
MessageChunk,
MessageStop,
ToolCallChunk,
ToolCallFinished,
)
def _base_adapter():
"""A real BasePlatformAdapter instance (abstractmethods cleared) so we
exercise the genuine default render hooks, not a mock."""
from gateway.platforms.base import BasePlatformAdapter
Concrete = type("Concrete", (BasePlatformAdapter,), {})
Concrete.__abstractmethods__ = frozenset()
return Concrete.__new__(Concrete)
class _FakeSink:
def __init__(self):
self.deltas = []
self.commentary = []
self.segment_breaks = 0
def on_delta(self, text):
self.deltas.append(text)
def on_commentary(self, text):
self.commentary.append(text)
def on_segment_break(self):
self.segment_breaks += 1
# ── Message events → sink ────────────────────────────────────────────────────
def test_message_chunk_flows_to_sink_on_delta():
sink = _FakeSink()
d = GatewayEventDispatcher(_base_adapter(), sink)
d.dispatch(MessageChunk("hello "))
d.dispatch(MessageChunk("world"))
assert sink.deltas == ["hello ", "world"]
def test_intermediate_message_stop_breaks_segment_but_final_does_not():
sink = _FakeSink()
d = GatewayEventDispatcher(_base_adapter(), sink)
d.dispatch(MessageStop(final=False))
d.dispatch(MessageStop(final=True))
assert sink.segment_breaks == 1 # only the non-final stop breaks
def test_commentary_flows_to_sink():
sink = _FakeSink()
d = GatewayEventDispatcher(_base_adapter(), sink)
d.dispatch(Commentary("I'll inspect the repo first."))
assert sink.commentary == ["I'll inspect the repo first."]
def test_message_events_dropped_when_no_sink():
# streaming disabled → no sink → message events are no-ops, no crash.
d = GatewayEventDispatcher(_base_adapter(), sink=None)
d.dispatch(MessageChunk("x")) # must not raise
# ── Tool events → progress queue, formatted by adapter ───────────────────────
def test_tool_call_chunk_renders_default_chrome():
lines = []
d = GatewayEventDispatcher(
_base_adapter(), _FakeSink(),
enqueue_tool_line=lines.append, tool_mode="all",
)
d.dispatch(ToolCallChunk(tool_name="terminal", preview="ls -la"))
assert len(lines) == 1
assert "terminal" in lines[0]
assert "ls -la" in lines[0]
def test_tool_preview_truncated_to_cap():
lines = []
d = GatewayEventDispatcher(
_base_adapter(), _FakeSink(),
enqueue_tool_line=lines.append, tool_mode="all", preview_max_len=10,
)
d.dispatch(ToolCallChunk(tool_name="x", preview="0123456789ABCDEF"))
# capped at 10 → 7 chars + "..." (then wrapped in quotes by the renderer)
assert '"0123456..."' in lines[0]
assert "89ABCDEF" not in lines[0]
def test_new_mode_dedups_same_tool():
lines = []
d = GatewayEventDispatcher(
_base_adapter(), _FakeSink(),
enqueue_tool_line=lines.append, tool_mode="new",
)
d.dispatch(ToolCallChunk(tool_name="terminal", preview="a"))
d.dispatch(ToolCallChunk(tool_name="terminal", preview="b")) # deduped
d.dispatch(ToolCallChunk(tool_name="read_file", preview="c"))
assert len(lines) == 2 # terminal once, read_file once
def test_off_mode_emits_nothing():
lines = []
d = GatewayEventDispatcher(
_base_adapter(), _FakeSink(),
enqueue_tool_line=lines.append, tool_mode="off",
)
d.dispatch(ToolCallChunk(tool_name="terminal", preview="ls"))
assert lines == []
def test_adapter_can_eat_tool_chrome():
"""An adapter that returns None from format_tool_event drops the event —
the 'iMessage can't render tool chrome' case."""
adapter = _base_adapter()
adapter.format_tool_event = lambda event, **kw: None # eat everything
lines = []
d = GatewayEventDispatcher(
adapter, _FakeSink(), enqueue_tool_line=lines.append, tool_mode="all",
)
d.dispatch(ToolCallChunk(tool_name="terminal", preview="ls"))
assert lines == [] # eaten
def test_tool_finished_emits_no_chrome():
lines = []
d = GatewayEventDispatcher(
_base_adapter(), _FakeSink(),
enqueue_tool_line=lines.append, tool_mode="all",
)
d.dispatch(ToolCallFinished(tool_name="terminal", duration=2.0, ok=True))
assert lines == []
# ── Control events → gateway-owned hooks ─────────────────────────────────────
def test_long_tool_hint_routes_to_hook():
seen = []
d = GatewayEventDispatcher(
_base_adapter(), _FakeSink(), on_long_tool=seen.append,
)
d.dispatch(LongToolHint(tool_name="terminal", duration=45.0))
assert len(seen) == 1
assert seen[0].tool_name == "terminal"
def test_gateway_notice_routes_to_hook():
seen = []
d = GatewayEventDispatcher(
_base_adapter(), _FakeSink(), on_notice=seen.append,
)
d.dispatch(GatewayNotice(kind="restart", text="Gateway restarted"))
assert seen[0].kind == "restart"
def test_dispatch_swallows_render_errors():
"""A render error must never propagate into the agent worker thread."""
adapter = _base_adapter()
def _boom(event, sink):
raise RuntimeError("render blew up")
adapter.render_message_event = _boom
d = GatewayEventDispatcher(adapter, _FakeSink())
d.dispatch(MessageChunk("x")) # must not raise
@@ -1,114 +0,0 @@
"""TelegramAdapter.send_draft MarkdownV2 formatting parity.
Bot API 9.5 ``sendMessageDraft`` powers the animated streaming preview in
DMs. The regular ``send`` path renders with MarkdownV2, so the draft must
too otherwise the live preview streams as raw text and the final
``sendMessage`` snaps into formatted output, producing a jarring visual
shift at the end of the response (reported by an external user, May 2026).
These tests pin:
1. The happy path passes ``parse_mode=MARKDOWN_V2`` with format_message'd
text (formatting parity with the final message).
2. A MarkdownV2 BadRequest triggers a single plain-text retry rather than
killing draft streaming for the whole response.
3. A non-BadRequest failure propagates so the caller falls back to edit.
"""
import sys
from unittest.mock import AsyncMock, MagicMock
import pytest
from gateway.config import PlatformConfig
def _ensure_telegram_mock():
if "telegram" in sys.modules and hasattr(sys.modules["telegram"], "__file__"):
return
mod = MagicMock()
mod.error.NetworkError = type("NetworkError", (OSError,), {})
mod.error.TimedOut = type("TimedOut", (OSError,), {})
mod.error.BadRequest = type("BadRequest", (Exception,), {})
for name in ("telegram", "telegram.ext", "telegram.constants", "telegram.request"):
sys.modules.setdefault(name, mod)
sys.modules.setdefault("telegram.error", mod.error)
_ensure_telegram_mock()
from gateway.platforms import telegram as tg_mod # noqa: E402
from gateway.platforms.telegram import TelegramAdapter # noqa: E402
def _make_adapter() -> TelegramAdapter:
adapter = TelegramAdapter(PlatformConfig(enabled=True, token="***"))
adapter._bot = MagicMock()
adapter._bot.send_message_draft = AsyncMock(return_value=True)
return adapter
@pytest.mark.asyncio
async def test_send_draft_passes_markdownv2_parse_mode():
"""Happy path: draft is sent with parse_mode set and format_message'd text."""
adapter = _make_adapter()
# Make format_message observable and deterministic.
adapter.format_message = lambda c: f"FMT::{c}"
result = await adapter.send_draft("123", 7, "**bold** body")
assert result.success is True
adapter._bot.send_message_draft.assert_awaited_once()
kwargs = adapter._bot.send_message_draft.await_args.kwargs
assert kwargs["text"] == "FMT::**bold** body"
assert kwargs["parse_mode"] is tg_mod.ParseMode.MARKDOWN_V2
assert kwargs["chat_id"] == 123
assert kwargs["draft_id"] == 7
@pytest.mark.asyncio
async def test_send_draft_falls_back_to_plain_text_on_markdownv2_error():
"""A MarkdownV2 BadRequest retries once as plain text (no parse_mode),
instead of aborting draft streaming for the whole response."""
adapter = _make_adapter()
adapter.format_message = lambda content: f"FMT::{content}"
# Resolve the BadRequest type the adapter checks via _is_bad_request_error.
from telegram.error import BadRequest # type: ignore
calls = []
async def _draft(**kwargs):
calls.append(kwargs)
if "parse_mode" in kwargs:
raise BadRequest("can't parse entities")
return True
adapter._bot.send_message_draft = AsyncMock(side_effect=_draft)
result = await adapter.send_draft("123", 9, "weird _text")
assert result.success is True
# First attempt: MarkdownV2; second attempt: plain text, no parse_mode.
assert len(calls) == 2
assert "parse_mode" in calls[0]
assert "parse_mode" not in calls[1]
assert calls[1]["text"] == "weird _text" # raw, unformatted
@pytest.mark.asyncio
async def test_send_draft_non_badrequest_propagates_without_retry():
"""A non-BadRequest failure (e.g. drafts not allowed) returns failure
immediately so the caller falls back to the edit transport."""
adapter = _make_adapter()
adapter.format_message = lambda c: f"FMT::{c}"
calls = []
async def _draft(**kwargs):
calls.append(kwargs)
raise RuntimeError("drafts disabled for this chat")
adapter._bot.send_message_draft = AsyncMock(side_effect=_draft)
result = await adapter.send_draft("123", 11, "hi")
assert result.success is False
assert len(calls) == 1 # no plain-text retry on non-BadRequest
-36
View File
@@ -39,42 +39,6 @@ def mock_args():
return SimpleNamespace()
# ---------------------------------------------------------------------------
# Managed-uv compatibility for tests that patch shutil.which
# ---------------------------------------------------------------------------
# The production code now uses ``ensure_uv()`` / ``update_managed_uv()``
# instead of ``shutil.which("uv")``. Many tests in this file patch
# ``shutil.which`` to control whether uv is "available" — these autouse
# fixtures make the managed_uv functions delegate to the patched
# ``shutil.which`` so the existing test setup keeps working without
# per-test changes.
@pytest.fixture(autouse=True)
def _patch_managed_uv(request):
"""Make managed_uv helpers follow shutil.which mocking in tests."""
import shutil
# resolve_uv delegates to shutil.which("uv") so that test patches
# on shutil.which flow through naturally.
def _fake_resolve_uv():
return shutil.which("uv")
def _fake_ensure_uv():
path = shutil.which("uv")
return (path, False) # never freshly bootstrapped in tests
def _fake_update_managed_uv():
return None # never actually self-update in tests
def _fake_rebuild_venv(*args, **kwargs):
return True # no-op in tests
with patch("hermes_cli.managed_uv.resolve_uv", side_effect=_fake_resolve_uv), \
patch("hermes_cli.managed_uv.ensure_uv", side_effect=_fake_ensure_uv), \
patch("hermes_cli.managed_uv.update_managed_uv", side_effect=_fake_update_managed_uv), \
patch("hermes_cli.managed_uv.rebuild_venv", side_effect=_fake_rebuild_venv):
yield
class TestCmdUpdatePip:
"""Regression tests for pip-install update flows."""
@@ -192,95 +192,6 @@ class TestApi401Envelope:
assert "next=" not in body["login_url"]
class TestTransparentRefreshOnAccessTokenEviction:
"""Regression: an expired access token whose cookie the browser has
ALREADY EVICTED must still transparently refresh via the RT cookie
not bounce to /login.
This is the common-path expiry bug, not an edge case. The access-token
cookie is set with ``Max-Age = access_token_expires_in`` (~15 min), so
the browser deletes ``hermes_session_at`` the instant the token lapses,
while ``hermes_session_rt`` lives for 30 days. From that moment the
browser sends ONLY the refresh-token cookie. The original gate bailed at
``if not at: return _unauth_response(...)`` bouncing the user to
/login on every single expiry despite holding a perfectly good refresh
token, defeating the entire transparent-refresh feature. The fix lets a
request carrying only the RT flow into the refresh path.
Discrimination: under the pre-fix code, scenario 1 (AT cookie absent,
RT present) returned 401/302 to login with NO rotated cookies and NO
REFRESH_SUCCESS the refresh code never ran. With the fix it returns
200 and rotates both cookies.
"""
def _build_rt_only_app(self):
"""Gate over the real app with a Stub provider whose RT is live
(default_ttl>0 so refresh succeeds). Mint a valid signed RT
directly (the stub's refresh_session only checks the RT's
signature + exp), then send ONLY that RT cookie.
"""
import time as _t
from tests.hermes_cli.conftest_dashboard_auth import _sign
clear_providers()
provider = StubAuthProvider(default_ttl=900)
register_provider(provider)
valid_rt = _sign(
{"sub": "stub-user-1", "kind": "refresh", "exp": int(_t.time()) + 30 * 86400}
)
return provider, valid_rt
def test_at_evicted_rt_present_refreshes_transparently(self, gated_app):
provider, valid_rt = self._build_rt_only_app()
# Browser sends ONLY the RT cookie — the AT cookie has aged out.
gated_app.cookies.clear()
gated_app.cookies.set(SESSION_RT_COOKIE, valid_rt)
r = gated_app.get("/api/sessions", follow_redirects=False)
# Transparent refresh — request served, NOT bounced.
assert r.status_code == 200, (
f"expected 200 (transparent refresh) got {r.status_code} "
f"— the AT-evicted/RT-present case bounced to login"
)
# Both cookies rotated onto the response.
set_cookies = r.headers.get_list("set-cookie")
assert any(
c.startswith(SESSION_AT_COOKIE) or f"-{SESSION_AT_COOKIE}" in c
for c in set_cookies
), f"no rotated AT cookie in {set_cookies!r}"
assert any(
c.startswith(SESSION_RT_COOKIE) or f"-{SESSION_RT_COOKIE}" in c
for c in set_cookies
), f"no rotated RT cookie in {set_cookies!r}"
def test_no_cookies_at_all_still_bounces(self, gated_app):
"""Guard the fix didn't over-reach: a request with NEITHER cookie
must still 401 to login (nothing to verify or refresh)."""
self._build_rt_only_app()
gated_app.cookies.clear()
r = gated_app.get("/api/sessions")
assert r.status_code == 401
assert r.json()["error"] == "unauthenticated"
def test_dead_rt_only_bounces_to_login(self, gated_app):
"""An RT-only request whose RT is dead/expired must bounce (the
refresh raises RefreshExpiredError clear + relogin), not 500."""
clear_providers()
# default_ttl=0 → the stub treats the minted RT as born-expired,
# so refresh_session raises RefreshExpiredError.
provider = StubAuthProvider(default_ttl=0)
register_provider(provider)
gated_app.cookies.clear()
# A syntactically-real but expired RT (signed with exp<=now).
import time as _t
from tests.hermes_cli.conftest_dashboard_auth import _sign
dead_rt = _sign({"sub": "u", "kind": "refresh", "exp": int(_t.time()) - 1})
gated_app.cookies.set(SESSION_RT_COOKIE, dead_rt)
r = gated_app.get("/api/sessions")
assert r.status_code == 401
assert r.json()["error"] == "session_expired"
class TestHtmlRedirectNext:
def test_deep_html_path_redirects_with_next(self, gated_app):
r = gated_app.get("/sessions", follow_redirects=False)
-206
View File
@@ -1,206 +0,0 @@
"""Tests for hermes_cli.managed_uv — one path, no guessing."""
from __future__ import annotations
import os
import stat
from pathlib import Path
from unittest.mock import MagicMock, patch
import pytest
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _make_executable(path: Path) -> None:
"""Create a minimal fake uv binary at *path*."""
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text("#!/bin/sh\necho uv 0.1.2\n")
path.chmod(path.stat().st_mode | stat.S_IEXEC)
# ---------------------------------------------------------------------------
# managed_uv_path
# ---------------------------------------------------------------------------
class TestManagedUvPath:
def test_posix(self, tmp_path):
with patch("hermes_cli.managed_uv.get_hermes_home", return_value=tmp_path), \
patch("hermes_cli.managed_uv.platform.system", return_value="Linux"):
from hermes_cli.managed_uv import managed_uv_path
assert managed_uv_path() == tmp_path / "bin" / "uv"
def test_windows(self, tmp_path):
with patch("hermes_cli.managed_uv.get_hermes_home", return_value=tmp_path), \
patch("hermes_cli.managed_uv.platform.system", return_value="Windows"):
from hermes_cli.managed_uv import managed_uv_path
assert managed_uv_path() == tmp_path / "bin" / "uv.exe"
# ---------------------------------------------------------------------------
# resolve_uv
# ---------------------------------------------------------------------------
class TestResolveUv:
def test_missing_returns_none(self, tmp_path):
with patch("hermes_cli.managed_uv.get_hermes_home", return_value=tmp_path):
from hermes_cli.managed_uv import resolve_uv
assert resolve_uv() is None
def test_existing_executable(self, tmp_path):
_make_executable(tmp_path / "bin" / "uv")
with patch("hermes_cli.managed_uv.get_hermes_home", return_value=tmp_path):
from hermes_cli.managed_uv import resolve_uv
result = resolve_uv()
assert result == str(tmp_path / "bin" / "uv")
def test_non_executable_file_returns_none(self, tmp_path):
uv = tmp_path / "bin" / "uv"
uv.parent.mkdir(parents=True)
uv.write_text("not a binary")
# Ensure no execute bit
uv.chmod(0o644)
with patch("hermes_cli.managed_uv.get_hermes_home", return_value=tmp_path):
from hermes_cli.managed_uv import resolve_uv
assert resolve_uv() is None
# ---------------------------------------------------------------------------
# ensure_uv
# ---------------------------------------------------------------------------
class TestEnsureUv:
def test_already_installed_no_bootstrap(self, tmp_path):
_make_executable(tmp_path / "bin" / "uv")
with patch("hermes_cli.managed_uv.get_hermes_home", return_value=tmp_path):
from hermes_cli.managed_uv import ensure_uv
path, fresh = ensure_uv()
assert path == str(tmp_path / "bin" / "uv")
assert fresh is False
def test_installs_if_missing_sets_bootstrap_flag(self, tmp_path):
with patch("hermes_cli.managed_uv.get_hermes_home", return_value=tmp_path), \
patch("hermes_cli.managed_uv._install_uv") as mock_install:
# Simulate the installer creating the binary
def fake_install(target):
_make_executable(target)
mock_install.side_effect = fake_install
from hermes_cli.managed_uv import ensure_uv
path, fresh = ensure_uv()
assert path == str(tmp_path / "bin" / "uv")
assert fresh is True
mock_install.assert_called_once()
def test_install_failure_returns_none_false(self, tmp_path):
with patch("hermes_cli.managed_uv.get_hermes_home", return_value=tmp_path), \
patch("hermes_cli.managed_uv._install_uv", side_effect=RuntimeError("network down")):
from hermes_cli.managed_uv import ensure_uv
path, fresh = ensure_uv()
assert path is None
assert fresh is False
# ---------------------------------------------------------------------------
# rebuild_venv
# ---------------------------------------------------------------------------
class TestRebuildVenv:
def test_removes_old_venv_and_creates_new(self, tmp_path):
venv_dir = tmp_path / "venv"
venv_dir.mkdir()
(venv_dir / "old_file").write_text("stale")
uv_bin = str(tmp_path / "bin" / "uv")
def fake_run(cmd, **kwargs):
m = MagicMock(returncode=0)
if cmd[1] == "venv":
# Simulate uv creating the venv dir
venv_dir.mkdir(exist_ok=True)
bin_dir = venv_dir / "bin"
bin_dir.mkdir(parents=True, exist_ok=True)
(bin_dir / "python").write_text("#!/bin/sh\necho Python 3.11.0")
elif "--version" in cmd:
m.stdout = "Python 3.11.0"
return m
with patch("hermes_cli.managed_uv.subprocess.run", side_effect=fake_run), \
patch("hermes_cli.managed_uv.shutil.rmtree") as mock_rmtree:
from hermes_cli.managed_uv import rebuild_venv
result = rebuild_venv(uv_bin, venv_dir)
assert result is True
mock_rmtree.assert_called_once_with(venv_dir, ignore_errors=True)
def test_rebuild_failure_returns_false(self, tmp_path):
venv_dir = tmp_path / "venv"
uv_bin = str(tmp_path / "bin" / "uv")
with patch("hermes_cli.managed_uv.subprocess.run") as mock_run, \
patch("hermes_cli.managed_uv.shutil.rmtree"):
mock_run.return_value = MagicMock(returncode=1, stderr="nope")
from hermes_cli.managed_uv import rebuild_venv
result = rebuild_venv(uv_bin, venv_dir)
assert result is False
# ---------------------------------------------------------------------------
# update_managed_uv
# ---------------------------------------------------------------------------
class TestUpdateManagedUv:
def test_no_uv_returns_none(self, tmp_path):
with patch("hermes_cli.managed_uv.get_hermes_home", return_value=tmp_path):
from hermes_cli.managed_uv import update_managed_uv
assert update_managed_uv() is None
def test_self_update_success(self, tmp_path):
_make_executable(tmp_path / "bin" / "uv")
with patch("hermes_cli.managed_uv.get_hermes_home", return_value=tmp_path), \
patch("hermes_cli.managed_uv.subprocess.run") as mock_run:
# uv self update succeeds
mock_run.return_value = MagicMock(returncode=0, stdout="uv 0.2.0")
from hermes_cli.managed_uv import update_managed_uv
result = update_managed_uv()
assert result == str(tmp_path / "bin" / "uv")
# First call is self update, second is --version
assert mock_run.call_count == 2
assert mock_run.call_args_list[0][0][0] == [str(tmp_path / "bin" / "uv"), "self", "update"]
def test_self_update_failure_non_fatal(self, tmp_path):
_make_executable(tmp_path / "bin" / "uv")
with patch("hermes_cli.managed_uv.get_hermes_home", return_value=tmp_path), \
patch("hermes_cli.managed_uv.subprocess.run") as mock_run:
mock_run.return_value = MagicMock(returncode=1, stderr="nope")
from hermes_cli.managed_uv import update_managed_uv
result = update_managed_uv()
# Still returns the path — failure is non-fatal
assert result == str(tmp_path / "bin" / "uv")
# ---------------------------------------------------------------------------
# _install_uv internals
# ---------------------------------------------------------------------------
class TestInstallUvInternals:
def test_posix_sets_uv_unmanaged_install(self, tmp_path):
target = tmp_path / "bin" / "uv"
with patch("hermes_cli.managed_uv.platform.system", return_value="Linux"), \
patch("hermes_cli.managed_uv._install_uv_posix") as mock_posix:
from hermes_cli.managed_uv import _install_uv
_install_uv(target)
mock_posix.assert_called_once()
call_env = mock_posix.call_args[0][0]
assert call_env["UV_UNMANAGED_INSTALL"] == str(tmp_path / "bin")
def test_windows_sets_uv_install_dir(self, tmp_path):
target = tmp_path / "bin" / "uv.exe"
with patch("hermes_cli.managed_uv.platform.system", return_value="Windows"), \
patch("hermes_cli.managed_uv._install_uv_windows") as mock_windows:
from hermes_cli.managed_uv import _install_uv
_install_uv(target)
mock_windows.assert_called_once()
call_env = mock_windows.call_args[0][0]
assert call_env["UV_INSTALL_DIR"] == str(tmp_path / "bin")
@@ -1,96 +0,0 @@
"""Regression tests for two OpenAI/OpenRouter model-picker bugs.
Bug 1 OpenAI picker dumped the raw ``/v1/models`` catalog
``provider_model_ids("openai")`` hit ``api.openai.com/v1/models`` and
returned the full 120+ entry catalog (embeddings, whisper, tts, dall-e,
moderation, gpt-3.5, ). The ``hermes model`` CLI shows only the curated
agentic list. The picker now intersects the live default-endpoint catalog
with the curated list (preserving curated order) so both surfaces match.
Custom OpenAI-compatible endpoints (proxies, gateways) keep the live list
verbatim so discovery still works.
Bug 2 OpenRouter appeared authenticated whenever OPENAI_API_KEY was set
OpenRouter's HermesOverlay carried ``extra_env_vars=("OPENAI_API_KEY",)``.
``list_authenticated_providers`` reads ``extra_env_vars`` to decide whether
a provider has credentials, so any OpenAI user saw a phantom OpenRouter
row. The overlay entry is removed; runtime credential resolution still
falls back to OPENAI_API_KEY for explicitly-selected OpenRouter (handled
in runtime_provider.py, independent of the overlay).
"""
import os
from unittest.mock import patch
import pytest
from hermes_cli import models as M
from hermes_cli.providers import HERMES_OVERLAYS
# --- Bug 2: overlay no longer lists OPENAI_API_KEY --------------------------
def test_openrouter_overlay_does_not_list_openai_api_key():
overlay = HERMES_OVERLAYS["openrouter"]
assert "OPENAI_API_KEY" not in overlay.extra_env_vars
# --- Bug 1: default OpenAI endpoint filters to curated agentic models -------
def test_default_openai_endpoint_filters_to_curated(monkeypatch):
"""The 126-model /v1/models dump is intersected with the curated list."""
monkeypatch.setenv("OPENAI_API_KEY", "sk-fake")
monkeypatch.delenv("OPENAI_BASE_URL", raising=False)
curated = M._PROVIDER_MODELS["openai-api"]
# Live catalog: every curated model PLUS a pile of non-agentic junk.
live = list(curated) + [
"text-embedding-3-large", "whisper-1", "tts-1", "dall-e-3",
"gpt-3.5-turbo", "davinci-002", "omni-moderation-latest",
]
with patch.object(M, "fetch_api_models", return_value=live):
result = M.provider_model_ids("openai-api", force_refresh=True)
# Only curated models survive, in curated order, no junk.
assert result == list(curated)
for m in result:
assert m in curated
def test_default_openai_endpoint_intersects_account_access(monkeypatch):
"""Curated models the account can't access are dropped (intersection)."""
monkeypatch.setenv("OPENAI_API_KEY", "sk-fake")
monkeypatch.delenv("OPENAI_BASE_URL", raising=False)
curated = M._PROVIDER_MODELS["openai-api"]
# Account only serves the first two curated models.
live = list(curated[:2]) + ["text-embedding-3-large", "whisper-1"]
with patch.object(M, "fetch_api_models", return_value=live):
result = M.provider_model_ids("openai-api", force_refresh=True)
assert result == list(curated[:2])
def test_default_openai_endpoint_falls_back_when_no_curated_access(monkeypatch):
"""If the account serves none of the curated models, fall back to curated."""
monkeypatch.setenv("OPENAI_API_KEY", "sk-fake")
monkeypatch.delenv("OPENAI_BASE_URL", raising=False)
curated = M._PROVIDER_MODELS["openai-api"]
live = ["text-embedding-3-large", "whisper-1", "tts-1"] # all junk
with patch.object(M, "fetch_api_models", return_value=live):
result = M.provider_model_ids("openai-api", force_refresh=True)
# No curated overlap -> serve the curated defaults so the picker isn't empty.
assert result == list(curated)
def test_custom_openai_compatible_endpoint_keeps_live_list(monkeypatch):
"""Custom OPENAI_BASE_URL endpoints keep the live catalog verbatim."""
monkeypatch.setenv("OPENAI_API_KEY", "sk-fake")
monkeypatch.setenv("OPENAI_BASE_URL", "https://my-proxy.example.com/v1")
live = ["custom-model-a", "custom-model-b", "some-embedding-model"]
with patch.object(M, "fetch_api_models", return_value=live):
result = M.provider_model_ids("openai-api", force_refresh=True)
assert result == live
-36
View File
@@ -1,7 +1,6 @@
from pathlib import Path
from subprocess import CalledProcessError
from types import SimpleNamespace
from unittest.mock import patch
import pytest
@@ -9,41 +8,6 @@ from hermes_cli import config as hermes_config
from hermes_cli import main as hermes_main
# ---------------------------------------------------------------------------
# Managed-uv compatibility for tests that patch shutil.which
# ---------------------------------------------------------------------------
# The production code now uses ``ensure_uv()`` / ``update_managed_uv()``
# instead of ``shutil.which("uv")``. Many tests in this file patch
# ``shutil.which`` to control whether uv is "available" — these autouse
# fixtures make the managed_uv functions delegate to the patched
# ``shutil.which`` so the existing test setup keeps working without
# per-test changes.
@pytest.fixture(autouse=True)
def _patch_managed_uv(request):
"""Make managed_uv helpers follow shutil.which mocking in tests."""
import shutil
# resolve_uv delegates to shutil.which("uv") so that test patches
# on shutil.which flow through naturally.
def _fake_resolve_uv():
return shutil.which("uv")
def _fake_ensure_uv():
path = shutil.which("uv")
return (path, False) # never freshly bootstrapped in tests
def _fake_update_managed_uv():
return None # never actually self-update in tests
def _fake_rebuild_venv(*args, **kwargs):
return True # no-op in tests
with patch("hermes_cli.managed_uv.resolve_uv", side_effect=_fake_resolve_uv), \
patch("hermes_cli.managed_uv.ensure_uv", side_effect=_fake_ensure_uv), \
patch("hermes_cli.managed_uv.update_managed_uv", side_effect=_fake_update_managed_uv), \
patch("hermes_cli.managed_uv.rebuild_venv", side_effect=_fake_rebuild_venv):
yield
def test_stash_local_changes_if_needed_returns_none_when_tree_clean(monkeypatch, tmp_path):
calls = []
-36
View File
@@ -20,42 +20,6 @@ from unittest.mock import patch
import pytest
# ---------------------------------------------------------------------------
# Managed-uv compatibility for tests that patch shutil.which
# ---------------------------------------------------------------------------
# The production code now uses ``ensure_uv()`` / ``update_managed_uv()``
# instead of ``shutil.which("uv")``. Many tests in this file patch
# ``shutil.which`` to control whether uv is "available" — these autouse
# fixtures make the managed_uv functions delegate to the patched
# ``shutil.which`` so the existing test setup keeps working without
# per-test changes.
@pytest.fixture(autouse=True)
def _patch_managed_uv(request):
"""Make managed_uv helpers follow shutil.which mocking in tests."""
import shutil
# resolve_uv delegates to shutil.which("uv") so that test patches
# on shutil.which flow through naturally.
def _fake_resolve_uv():
return shutil.which("uv")
def _fake_ensure_uv():
path = shutil.which("uv")
return (path, False) # never freshly bootstrapped in tests
def _fake_update_managed_uv():
return None # never actually self-update in tests
def _fake_rebuild_venv(*args, **kwargs):
return True # no-op in tests
with patch("hermes_cli.managed_uv.resolve_uv", side_effect=_fake_resolve_uv), \
patch("hermes_cli.managed_uv.ensure_uv", side_effect=_fake_ensure_uv), \
patch("hermes_cli.managed_uv.update_managed_uv", side_effect=_fake_update_managed_uv), \
patch("hermes_cli.managed_uv.rebuild_venv", side_effect=_fake_rebuild_venv):
yield
# ---------------------------------------------------------------------------
# is_uv_tool_install
# ---------------------------------------------------------------------------
+7 -464
View File
@@ -2,7 +2,6 @@
import os
import json
import shutil
from pathlib import Path
from unittest.mock import patch, MagicMock
@@ -15,97 +14,6 @@ from hermes_cli.config import (
)
# ---------------------------------------------------------------------------
# Shared fixtures
# ---------------------------------------------------------------------------
# Path to the test-only example-dashboard plugin. Lives under
# tests/fixtures/ so the bundled-plugins directory stays clean — stock
# installs no longer ship a dummy "Example" sidebar tab. Tests that
# depend on its routes opt in via the `_install_example_plugin` fixture
# below.
_EXAMPLE_PLUGIN_FIXTURE = (
Path(__file__).resolve().parent.parent / "fixtures" / "plugins" / "example-dashboard"
)
@pytest.fixture
def _install_example_plugin(_isolate_hermes_home):
"""Drop the example-dashboard fixture into the per-test HERMES_HOME
user-plugins directory and force the web_server's dashboard plugin
cache + API mount to rediscover it.
The plugin used to live under ``<repo>/plugins/example-dashboard/``
and was loaded for every install, putting an "Example" tab in every
user's sidebar. It is now a tests-only fixture: any test that needs
``/api/plugins/example/hello`` or ``/dashboard-plugins/example/...``
requests this fixture so the plugin appears only for that test's
isolated ``HERMES_HOME``.
The user-plugin source is preferred over a transient
``HERMES_BUNDLED_PLUGINS`` override because the bundled dir is
resolved per-call (other tests in the suite implicitly rely on the
real bundled plugins kanban, hermes-achievements, model providers
being available, and globally swapping that root would yank them
all). User plugins are first in the discovery search order, so
laying down the fixture here is enough.
"""
from hermes_constants import get_hermes_home
from hermes_cli import web_server
user_plugins_dir = get_hermes_home() / "plugins"
user_plugins_dir.mkdir(parents=True, exist_ok=True)
dst = user_plugins_dir / "example-dashboard"
if dst.exists():
shutil.rmtree(dst)
shutil.copytree(_EXAMPLE_PLUGIN_FIXTURE, dst)
# Snapshot the existing routes BEFORE mounting so we can:
# 1. Identify the routes the mount call appends.
# 2. Restore the original list on teardown — otherwise leftover
# ``/api/plugins/example/*`` routes leak into subsequent tests
# and start serving requests against a torn-down HERMES_HOME.
app = web_server.app
original_routes = list(app.router.routes)
# Bust the module-level cache and re-discover so the example plugin
# shows up in `_get_dashboard_plugins()`. `_mount_plugin_api_routes`
# imports the plugin's `plugin_api.py` and ``include_router``s its
# FastAPI router under ``/api/plugins/example/*``. The static-asset
# route at ``/dashboard-plugins/<name>/<path>`` reads the plugins
# list dynamically per request, so the rescan alone is enough for
# the static-asset tests; the API auth tests additionally need the
# route reorder below.
web_server._dashboard_plugins_cache = None
web_server._get_dashboard_plugins(force_rescan=True)
web_server._mount_plugin_api_routes()
# ``include_router`` appends the new routes to the END of
# ``app.router.routes``. That works fine at import time — the SPA
# catch-all ``mount_spa(app)`` registers AFTER the initial mount
# call — but when we mount mid-flight the catch-all is already in
# place, so the new ``/api/plugins/example/*`` route loses the
# match-order race and we get a 404. Move the newly-appended routes
# to the front of the list so FastAPI matches them first. They're
# path-prefixed to ``/api/plugins/example/`` and can't shadow
# anything else.
new_routes = [r for r in app.router.routes if r not in original_routes]
for route in new_routes:
app.router.routes.remove(route)
for offset, route in enumerate(new_routes):
app.router.routes.insert(offset, route)
try:
yield
finally:
# Restore the original route list — drops the example plugin's
# routes so the next test sees a clean app — and clear the
# cache for the same reason.
app.router.routes[:] = original_routes
web_server._dashboard_plugins_cache = None
# ---------------------------------------------------------------------------
# reload_env tests
# ---------------------------------------------------------------------------
@@ -385,49 +293,6 @@ class TestWebServerEndpoints:
resp = self.client.get("/api/sessions?archived=bogus")
assert resp.status_code == 400
def test_get_sessions_rejects_unknown_order_value(self):
resp = self.client.get("/api/sessions?order=sideways")
assert resp.status_code == 400
def test_get_sessions_order_recent_surfaces_compression_tip(self):
"""A long-running conversation that auto-compresses must stay on the
first page by recency, listed under its live continuation id."""
import time as _time
from hermes_state import SessionDB
db = SessionDB()
try:
old = _time.time() - 86_400
# Old conversation that later compresses into a fresh continuation.
# The continuation must start at/after the parent's ended_at to be
# recognised as a compression tip (not a sub-agent/branch).
db.create_session(session_id="root-old", source="cli")
db.append_message(session_id="root-old", role="user", content="kickoff")
db.end_session("root-old", "compression")
db._conn.execute(
"UPDATE sessions SET started_at = ?, ended_at = ? WHERE id = ?",
(old, old + 10, "root-old"),
)
db.create_session(session_id="tip-new", source="cli", parent_session_id="root-old")
db._conn.execute("UPDATE sessions SET started_at = ? WHERE id = ?", (old + 10, "tip-new"))
db.append_message(session_id="tip-new", role="user", content="continued just now")
# A brand-new unrelated session started after the root but before now.
db.create_session(session_id="mid", source="cli")
db._conn.execute("UPDATE sessions SET started_at = ? WHERE id = ?", (_time.time() - 3600, "mid"))
db.append_message(session_id="mid", role="user", content="hello")
db._conn.commit()
finally:
db.close()
rows = self.client.get("/api/sessions?order=recent&limit=5").json()["sessions"]
ids = [r["id"] for r in rows]
# The compressed conversation surfaces under its live tip id...
assert "tip-new" in ids
# ...carrying the durable lineage root so the desktop can match pins.
tip = next(r for r in rows if r["id"] == "tip-new")
assert tip.get("_lineage_root_id") == "root-old"
def test_get_sessions_archived_is_boolean(self):
from hermes_state import SessionDB
@@ -1658,52 +1523,13 @@ class TestNewEndpoints:
assert data["has_category"] is True
assert isinstance(data["providers"], list)
assert data["providers"], "tts always has at least the built-in providers"
# active_provider is part of the contract so the GUI can highlight the
# provider actually written to config (else it falls back to the first
# keyless one). It's either None or the name of one listed provider.
assert "active_provider" in data
names = {p["name"] for p in data["providers"]}
assert data["active_provider"] is None or data["active_provider"] in names
for prov in data["providers"]:
assert "name" in prov
assert "is_active" in prov
assert "env_vars" in prov
assert isinstance(prov["env_vars"], list)
for ev in prov["env_vars"]:
assert "key" in ev
assert "is_set" in ev
# active_provider summarizes the first provider flagged is_active
# (some catalogs list two rows backed by the same config value, e.g.
# Firecrawl cloud + self-hosted both map to web.backend=firecrawl).
active = [p["name"] for p in data["providers"] if p["is_active"]]
if active:
assert data["active_provider"] == active[0]
else:
assert data["active_provider"] is None
def test_get_toolset_config_reflects_selected_provider(self):
"""Selecting a provider is reflected in the next /config read.
Regression: the GUI's provider panel highlighted the first keyless
provider on relaunch because /config never reported which provider was
actually active. After selecting one, is_active / active_provider must
point at it.
"""
sel = self.client.put(
"/api/tools/toolsets/web/provider",
json={"provider": "Firecrawl Self-Hosted"},
)
assert sel.status_code == 200
resp = self.client.get("/api/tools/toolsets/web/config")
assert resp.status_code == 200
data = resp.json()
assert data["active_provider"] == "Firecrawl Self-Hosted"
active = [p["name"] for p in data["providers"] if p["is_active"]]
# The first active row is what the GUI highlights; it must be the
# selected provider.
assert active, "expected at least one provider flagged active"
assert active[0] == "Firecrawl Self-Hosted"
def test_get_toolset_config_no_category_toolset(self):
"""A toolset without a TOOL_CATEGORIES entry returns has_category False."""
@@ -2643,284 +2469,12 @@ class TestNormaliseThemeExtensions:
assert r["componentStyles"]["card"] == {"opacity": "0.8", "zIndex": "5"}
class TestBulkDeleteSessionsEndpoint:
"""Tests for ``POST /api/sessions/bulk-delete`` — backs the
dashboard's "Delete N selected" flow on the sessions page.
Locks in four things:
1. Route-ordering: ``/api/sessions/bulk-delete`` must shadow the
templated ``/api/sessions/{session_id}`` route below it (see
the block comment in ``hermes_cli/web_server.py``).
2. Behaviour parity with :meth:`SessionDB.delete_sessions` real
deleted count, archive/active sessions deleted on explicit
selection.
3. The 500-ID payload cap is enforced.
4. Auth gating (issue #19533 contract).
"""
@pytest.fixture(autouse=True)
def _setup_test_client(self, monkeypatch, _isolate_hermes_home):
try:
from starlette.testclient import TestClient
except ImportError:
pytest.skip("fastapi/starlette not installed")
import hermes_state
from hermes_constants import get_hermes_home
from hermes_cli.web_server import app, _SESSION_HEADER_NAME, _SESSION_TOKEN
monkeypatch.setattr(
hermes_state, "DEFAULT_DB_PATH", get_hermes_home() / "state.db"
)
self.client = TestClient(app)
self.auth_client = TestClient(app)
self.auth_client.headers[_SESSION_HEADER_NAME] = _SESSION_TOKEN
def _seed(self, ids):
from hermes_state import SessionDB
db = SessionDB()
try:
for sid in ids:
db.create_session(session_id=sid, source="cli")
finally:
db.close()
def test_requires_auth(self):
resp = self.client.post("/api/sessions/bulk-delete", json={"ids": ["x"]})
assert resp.status_code == 401
def test_deletes_listed_sessions_only(self):
from hermes_state import SessionDB
self._seed(["a", "b", "c"])
resp = self.auth_client.post(
"/api/sessions/bulk-delete", json={"ids": ["a", "b"]}
)
assert resp.status_code == 200
assert resp.json() == {"ok": True, "deleted": 2}
db = SessionDB()
try:
assert db.get_session("a") is None
assert db.get_session("b") is None
assert db.get_session("c") is not None
finally:
db.close()
def test_unknown_ids_silently_skipped(self):
"""The endpoint never 404s on a missing ID — it returns the
real deleted count so a UI selection that raced against
another tab still resolves cleanly."""
self._seed(["real"])
resp = self.auth_client.post(
"/api/sessions/bulk-delete",
json={"ids": ["real", "ghost1", "ghost2"]},
)
assert resp.status_code == 200
assert resp.json() == {"ok": True, "deleted": 1}
def test_empty_list_is_noop(self):
"""``ids: []`` returns ``deleted: 0`` (200, not 400) — the UI
treats an empty selection as a no-op rather than an error."""
resp = self.auth_client.post(
"/api/sessions/bulk-delete", json={"ids": []}
)
assert resp.status_code == 200
assert resp.json() == {"ok": True, "deleted": 0}
def test_payload_cap_enforced(self):
"""501 IDs returns 400 — a hard cap stops a runaway selection
from holding the SQLite writer for an extended window."""
resp = self.auth_client.post(
"/api/sessions/bulk-delete",
json={"ids": [f"s{i}" for i in range(501)]},
)
assert resp.status_code == 400
# 500 exactly still succeeds (no rows actually present, so
# deleted=0 — but it's not the cap path).
resp = self.auth_client.post(
"/api/sessions/bulk-delete",
json={"ids": [f"s{i}" for i in range(500)]},
)
assert resp.status_code == 200
def test_route_order_not_shadowed_by_session_id(self):
"""Pin the route-ordering contract: ``POST /api/sessions/bulk-delete``
must hit the bulk handler, not be re-interpreted via the
templated ``/api/sessions/{session_id}`` family. Concretely the
response carries our ``ok`` + ``deleted`` keys."""
resp = self.auth_client.post(
"/api/sessions/bulk-delete", json={"ids": []}
)
assert resp.status_code == 200
body = resp.json()
assert body.get("ok") is True
assert "deleted" in body, (
"If this assertion fails, /api/sessions/bulk-delete is "
"being shadowed by /api/sessions/{session_id} — check "
"registration order in hermes_cli/web_server.py."
)
class TestDeleteEmptySessionsEndpoint:
"""Tests for ``GET /api/sessions/empty/count`` and
``DELETE /api/sessions/empty`` the bulk-delete endpoints backing
the dashboard's "Delete empty" button.
Locks in three things the implementation has to get right:
1. Route-ordering: the literal ``/api/sessions/empty[/count]`` paths
must shadow the templated ``/api/sessions/{session_id}`` route
above them. A regression here would route ``DELETE /api/sessions/
empty`` to the single-session handler with ``session_id="empty"``
(which 404s instead of bulk-deleting).
2. Behaviour parity with :meth:`SessionDB.delete_empty_sessions`:
active sessions and archived sessions are both preserved.
3. Auth gating: both routes require the session token like every
other ``/api/*`` endpoint (issue #19533 contract).
"""
@pytest.fixture(autouse=True)
def _setup_test_client(self, monkeypatch, _isolate_hermes_home):
try:
from starlette.testclient import TestClient
except ImportError:
pytest.skip("fastapi/starlette not installed")
import hermes_state
from hermes_constants import get_hermes_home
from hermes_cli.web_server import app, _SESSION_HEADER_NAME, _SESSION_TOKEN
# Pin the SessionDB to the isolated HERMES_HOME so each test
# starts with a clean state.db.
monkeypatch.setattr(
hermes_state, "DEFAULT_DB_PATH", get_hermes_home() / "state.db"
)
self.client = TestClient(app)
self.auth_client = TestClient(app)
self.auth_client.headers[_SESSION_HEADER_NAME] = _SESSION_TOKEN
def _seed(self):
"""Build the standard test corpus:
* ``empty1`` / ``empty2`` ended, no messages should delete
* ``hasmsg`` ended, has one message must survive
* ``live`` un-ended, empty must survive (active)
* ``archived`` ended, empty, archived must survive
"""
from hermes_state import SessionDB
db = SessionDB()
try:
db.create_session(session_id="empty1", source="cli")
db.end_session("empty1", end_reason="done")
db.create_session(session_id="empty2", source="cli")
db.end_session("empty2", end_reason="done")
db.create_session(session_id="hasmsg", source="cli")
db.append_message("hasmsg", role="user", content="hello")
db.end_session("hasmsg", end_reason="done")
db.create_session(session_id="live", source="cli")
db.create_session(session_id="archived", source="cli")
db.end_session("archived", end_reason="done")
db.set_session_archived("archived", True)
finally:
db.close()
def test_count_endpoint_requires_auth(self):
"""GET /api/sessions/empty/count must 401 without the session token."""
resp = self.client.get("/api/sessions/empty/count")
assert resp.status_code == 401
def test_delete_endpoint_requires_auth(self):
"""DELETE /api/sessions/empty must 401 without the session token.
Regression guard for issue #19533 — the bulk-delete is a strictly
destructive primitive, the middleware must gate it even if a
future refactor introduces a non-auth path."""
resp = self.client.delete("/api/sessions/empty")
assert resp.status_code == 401
def test_count_returns_only_empty_ended_unarchived(self):
"""With the standard corpus, the count is exactly 2 — only
``empty1`` and ``empty2`` qualify (``hasmsg`` has a message,
``live`` is active, ``archived`` is archived)."""
self._seed()
resp = self.auth_client.get("/api/sessions/empty/count")
assert resp.status_code == 200
assert resp.json() == {"count": 2}
def test_delete_returns_count_and_removes_only_empties(self):
"""DELETE returns the deleted count and removes only the
empty-ended-unarchived rows same shape contract as the
DB-level method's unit tests."""
from hermes_state import SessionDB
self._seed()
resp = self.auth_client.delete("/api/sessions/empty")
assert resp.status_code == 200
assert resp.json() == {"ok": True, "deleted": 2}
db = SessionDB()
try:
assert db.get_session("empty1") is None
assert db.get_session("empty2") is None
# Survivors: hasmsg has a message, live is active, archived
# is archived. All three must still be there.
assert db.get_session("hasmsg") is not None
assert db.get_session("live") is not None
assert db.get_session("archived") is not None
# And the count endpoint now reports 0.
assert db.count_empty_sessions() == 0
finally:
db.close()
def test_delete_with_no_empties_returns_zero(self):
"""No empty sessions → endpoint returns ``deleted: 0`` (200,
not 404). The dashboard relies on this no-op path to surface
a "Nothing to clean up" toast instead of an error."""
resp = self.auth_client.delete("/api/sessions/empty")
assert resp.status_code == 200
assert resp.json() == {"ok": True, "deleted": 0}
def test_route_order_empty_not_shadowed_by_session_id(self):
"""Pin the route-ordering contract: ``DELETE /api/sessions/empty``
must hit the bulk handler, not the templated single-session
handler (which would 404 because no session has id 'empty').
Concretely: a request against the bulk path on an EMPTY corpus
returns ``{ok: True, deleted: 0}``. If the templated route were
winning, we'd see 404 ("Session not found") instead.
"""
resp = self.auth_client.delete("/api/sessions/empty")
assert resp.status_code == 200
body = resp.json()
assert "deleted" in body, (
"If this assertion fails, the literal /api/sessions/empty "
"route is being shadowed by the templated /api/sessions/"
"{session_id} route — check registration order in "
"hermes_cli/web_server.py."
)
class TestPluginAPIAuth:
"""Tests that plugin API routes require the session token (issue #19533)."""
@pytest.fixture(autouse=True)
def _setup_test_client(self, monkeypatch, _isolate_hermes_home, _install_example_plugin):
"""Create a TestClient without the session token header.
Pulls in ``_install_example_plugin`` so ``test_plugin_route_allows_auth``
has the ``/api/plugins/example/hello`` endpoint available the
example plugin is no longer a bundled plugin, so the fixture
installs it into the per-test ``HERMES_HOME``.
"""
def _setup_test_client(self, monkeypatch, _isolate_hermes_home):
"""Create a TestClient without the session token header."""
try:
from starlette.testclient import TestClient
except ImportError:
@@ -2945,12 +2499,10 @@ class TestPluginAPIAuth:
def test_plugin_route_allows_auth(self):
"""Plugin API routes should work with a valid session token.
Uses ``/api/plugins/example/hello`` from the example-dashboard
test fixture (installed into HERMES_HOME by the class-level
``_install_example_plugin`` fixture) a stable, side-effect-free
GET that's only loaded for tests. With a valid token the handler
should run (200); without one the middleware should 401 before
the handler is reached.
Use ``/api/plugins/example/hello`` from the example-dashboard plugin
a stable, side-effect-free GET that's always loaded in tests. With a
valid token the handler should run (200); without one the middleware
should 401 before the handler is reached.
"""
# Without auth: middleware blocks before reaching the handler.
resp = self.client.get("/api/plugins/example/hello")
@@ -3502,16 +3054,7 @@ class TestDashboardPluginStaticAssetAllowlist:
"""
@pytest.fixture(autouse=True)
def _setup_test_client(self, monkeypatch, _isolate_hermes_home, _install_example_plugin):
"""Create a TestClient and install the example-dashboard fixture.
The static-asset allowlist tests need a plugin to point at
they verify that ``/dashboard-plugins/example/manifest.json``
is served while ``plugin_api.py`` and ``__pycache__/*.pyc``
from the same directory are not. Since the example plugin is
no longer bundled, ``_install_example_plugin`` lays it down in
the per-test ``HERMES_HOME`` user-plugins dir.
"""
def _setup_test_client(self, monkeypatch, _isolate_hermes_home):
try:
from starlette.testclient import TestClient
except ImportError:
@@ -542,12 +542,7 @@ class TestCompleteLogin:
def test_happy_path_returns_session(self, provider, rsa_keypair):
access_token = _mint_token(rsa_keypair)
mock_resp = self._mock_post(
200,
{
"access_token": access_token,
"token_type": "Bearer",
"refresh_token": "rt_initial_value",
},
200, {"access_token": access_token, "token_type": "Bearer"}
)
with patch("plugins.dashboard_auth.nous.httpx.post", return_value=mock_resp):
session = provider.complete_login(
@@ -560,29 +555,11 @@ class TestCompleteLogin:
assert session.user_id == "usr_abc"
assert session.provider == "nous"
assert session.access_token == access_token
# The dashboard auth-code grant now issues a refresh token (NAS #293);
# complete_login must surface it so the middleware persists it.
assert session.refresh_token == "rt_initial_value"
assert session.refresh_token == "" # contract V1
assert session.org_id == "org_xyz"
assert session.email == ""
assert session.display_name == ""
def test_happy_path_tolerates_missing_refresh_token(self, provider, rsa_keypair):
# If Portal omits refresh_token (older deploy), the session is still
# valid as access-token-only; refresh_token defaults to "".
access_token = _mint_token(rsa_keypair)
mock_resp = self._mock_post(
200, {"access_token": access_token, "token_type": "Bearer"}
)
with patch("plugins.dashboard_auth.nous.httpx.post", return_value=mock_resp):
session = provider.complete_login(
code="abc",
state="state-val",
code_verifier="vfy",
redirect_uri="https://hermes.fly.dev/auth/callback",
)
assert session.refresh_token == ""
def test_400_raises_invalid_code(self, provider):
mock_resp = self._mock_post(400, {"error": "invalid_grant"})
with patch("plugins.dashboard_auth.nous.httpx.post", return_value=mock_resp):
@@ -753,90 +730,24 @@ class TestVerifySession:
# ---------------------------------------------------------------------------
# refresh_session + revoke_session
# refresh_session + revoke_session (V1 contract: trivial)
# ---------------------------------------------------------------------------
class TestRefreshAndRevoke:
@pytest.fixture
def provider(self, rsa_keypair):
p = nous_plugin.NousDashboardAuthProvider(
client_id="agent:inst123", portal_url="https://portal.example.com"
def provider(self):
return nous_plugin.NousDashboardAuthProvider(
client_id="agent:inst1", portal_url="https://portal.example.com"
)
_patched_jwks(p, rsa_keypair)
return p
def _mock_post(self, status_code, body, *, ctype="application/json"):
resp = MagicMock(spec=httpx.Response)
resp.status_code = status_code
if isinstance(body, dict):
resp.text = json.dumps(body)
resp.json = MagicMock(return_value=body)
else:
resp.text = body
resp.json = MagicMock(side_effect=ValueError("not json"))
resp.headers = {"content-type": ctype}
return resp
def test_refresh_always_raises(self, provider):
with pytest.raises(RefreshExpiredError):
provider.refresh_session(refresh_token="anything")
def test_refresh_happy_path_returns_rotated_session(self, provider, rsa_keypair):
# Portal returns a fresh access token AND a rotated refresh token.
access_token = _mint_token(rsa_keypair)
mock_resp = self._mock_post(
200,
{
"access_token": access_token,
"token_type": "Bearer",
"refresh_token": "rt_rotated_value",
},
)
with patch(
"plugins.dashboard_auth.nous.httpx.post", return_value=mock_resp
) as mock_post:
session = provider.refresh_session(refresh_token="rt_old_value")
assert isinstance(session, Session)
assert session.access_token == access_token
# The ROTATED refresh token must be surfaced so the middleware can
# persist it back to the cookie.
assert session.refresh_token == "rt_rotated_value"
assert session.provider == "nous"
# Posts grant_type=refresh_token with the RT in BOTH the body (Portal's
# schema requires it there) and the X-Refresh-Token header (log
# redaction). Verified against the live preview deploy.
_, kwargs = mock_post.call_args
assert kwargs["data"]["grant_type"] == "refresh_token"
assert kwargs["data"]["client_id"] == "agent:inst123"
assert kwargs["data"]["refresh_token"] == "rt_old_value"
assert kwargs["headers"]["x-nous-refresh-token"] == "rt_old_value"
def test_refresh_400_raises_refresh_expired(self, provider):
# Expired / revoked / reuse-detected RT → Portal 400 → force re-login.
mock_resp = self._mock_post(400, {"error": "invalid_grant"})
with patch("plugins.dashboard_auth.nous.httpx.post", return_value=mock_resp):
with pytest.raises(RefreshExpiredError, match="invalid_grant"):
provider.refresh_session(refresh_token="rt_dead")
def test_refresh_empty_token_raises_refresh_expired_without_network(self, provider):
# No RT present — fail fast as a dead session, never hit the network.
with patch("plugins.dashboard_auth.nous.httpx.post") as mock_post:
with pytest.raises(RefreshExpiredError):
provider.refresh_session(refresh_token="")
mock_post.assert_not_called()
def test_refresh_network_error_raises_provider_error(self, provider):
with patch(
"plugins.dashboard_auth.nous.httpx.post",
side_effect=httpx.RequestError("boom"),
):
with pytest.raises(ProviderError, match="unreachable"):
provider.refresh_session(refresh_token="rt_x")
def test_refresh_500_raises_provider_error(self, provider):
mock_resp = self._mock_post(500, "oops", ctype="text/plain")
with patch("plugins.dashboard_auth.nous.httpx.post", return_value=mock_resp):
with pytest.raises(ProviderError):
provider.refresh_session(refresh_token="rt_x")
def test_refresh_raises_even_with_empty_token(self, provider):
with pytest.raises(RefreshExpiredError):
provider.refresh_session(refresh_token="")
def test_revoke_is_noop(self, provider):
# Must not raise; returns None implicitly.
-237
View File
@@ -1523,243 +1523,6 @@ class TestDeleteSessionOrphansChildren:
assert grandchild["parent_session_id"] == "child"
class TestBulkDeleteSessions:
"""``delete_sessions(ids)`` — the bulk-delete primitive backing the
sessions-page "Delete N selected" button. Per-row contract matches
:meth:`SessionDB.delete_session` (children orphaned, not cascade-
deleted), but applied across the whole list in one transaction.
Invariants this class locks in:
1. Returns the real deleted count (existing intersection), not
just ``len(session_ids)`` selection state in the UI can race
against another tab's delete.
2. Unknown IDs are silently skipped, never raise.
3. ``message_count > 0`` sessions are deleted too unlike
``delete_empty_sessions``, the user explicitly picked them, so
we trust the selection.
4. Live (un-ended) and archived sessions ARE deleted on explicit
selection (no bulk-sweep safety guards apply when the user
hand-picks the row).
5. Children of any deleted parent are orphaned, even when the
parent is mid-list.
6. ``[]`` / ``None``-laden lists are safe no-ops.
"""
def test_deletes_listed_sessions(self, db):
db.create_session(session_id="a", source="cli")
db.append_message("a", role="user", content="hi")
db.create_session(session_id="b", source="cli")
db.create_session(session_id="c", source="cli")
deleted = db.delete_sessions(["a", "b"])
assert deleted == 2
assert db.get_session("a") is None
assert db.get_session("b") is None
# Unlisted survives.
assert db.get_session("c") is not None
def test_returns_real_count_skipping_unknown_ids(self, db):
"""Unknown IDs are silently skipped — the return value reflects
what was *actually* deleted, so the UI can show an accurate
toast even if the selection raced against another tab."""
db.create_session(session_id="real", source="cli")
deleted = db.delete_sessions(["real", "ghost1", "ghost2"])
assert deleted == 1
assert db.get_session("real") is None
def test_empty_list_is_noop(self, db):
"""``[]`` returns 0 without touching the DB. Guards against a
bulk endpoint with an empty payload triggering an
unconditional 'wipe everything' if the caller forgets the
WHERE clause."""
db.create_session(session_id="keep", source="cli")
assert db.delete_sessions([]) == 0
assert db.get_session("keep") is not None
def test_drops_non_string_entries(self, db):
"""Stray ``None`` / empty strings in the input list are
filtered out before hitting SQL. Callers may pull selection IDs
from a Set-like that occasionally contains noise; we don't want
a SQL parameter-type error to fail the whole batch."""
db.create_session(session_id="real", source="cli")
# noinspection PyTypeChecker
deleted = db.delete_sessions(["real", None, "", "ghost"]) # type: ignore[list-item]
assert deleted == 1
assert db.get_session("real") is None
def test_dedupes_duplicate_ids(self, db):
"""The same ID listed twice counts as one deletion. Defends
against a hand-crafted POST body or a UI bug that double-adds
the same selection."""
db.create_session(session_id="real", source="cli")
deleted = db.delete_sessions(["real", "real"])
assert deleted == 1
def test_orphans_children_of_deleted_parents(self, db):
"""Bulk-deleting a parent leaves its children alive but
re-parented to NULL. Same contract as the single-session
:meth:`delete_session` path."""
db.create_session(session_id="parent", source="cli")
db.create_session(
session_id="child", source="cli", parent_session_id="parent"
)
deleted = db.delete_sessions(["parent"])
assert deleted == 1
child = db.get_session("child")
assert child is not None
assert child["parent_session_id"] is None
def test_deletes_archived_and_active_when_selected(self, db):
"""Unlike the safety-gated ``delete_empty_sessions`` sweep,
explicit bulk-select trusts the user archived sessions and
un-ended live sessions are both deleted when in the list.
Otherwise the selection UI would silently 'leak' rows the user
thought they'd removed."""
db.create_session(session_id="archived", source="cli")
db.end_session("archived", end_reason="done")
db.set_session_archived("archived", True)
db.create_session(session_id="live", source="cli")
deleted = db.delete_sessions(["archived", "live"])
assert deleted == 2
assert db.get_session("archived") is None
assert db.get_session("live") is None
def test_cleans_up_transcript_files(self, db, tmp_path):
"""When ``sessions_dir`` is provided, on-disk transcripts are
swept as part of the bulk operation mirrors the per-row
:meth:`delete_session(sessions_dir=...)` behaviour so the
bulk-delete CLI / web flows don't leak files."""
db.create_session(session_id="s1", source="cli")
db.create_session(session_id="s2", source="cli")
(tmp_path / "s1.jsonl").write_text("")
(tmp_path / "s2.json").write_text("{}")
deleted = db.delete_sessions(["s1", "s2"], sessions_dir=tmp_path)
assert deleted == 2
assert not (tmp_path / "s1.jsonl").exists()
assert not (tmp_path / "s2.json").exists()
class TestDeleteEmptySessions:
"""``delete_empty_sessions`` sweeps every ended, non-archived session
whose ``message_count`` is 0. Backs the dashboard's "Delete empty"
button see ``SessionsPage.tsx`` + ``DELETE /api/sessions/empty``
in ``hermes_cli/web_server.py``.
Invariants this class locks in:
1. Only ``message_count = 0`` rows are touched.
2. Active (un-ended) sessions are skipped even if they're empty —
the agent might be mid-handshake, and yanking the row would
race the live runtime.
3. Archived sessions are skipped the user already filed them away.
4. Children of a deleted parent are orphaned (parent_session_id
NULL) rather than cascade-deleted, matching the
``delete_session`` / ``prune_sessions`` contract.
5. The pre-DB count matches the post-DB delete return value.
"""
def test_count_and_delete_empties_only(self, db):
# Two empty + ended sessions → both should be in the kill list.
db.create_session(session_id="empty1", source="cli")
db.end_session("empty1", end_reason="done")
db.create_session(session_id="empty2", source="cli")
db.end_session("empty2", end_reason="done")
# One non-empty + ended session → must survive.
db.create_session(session_id="hasmsg", source="cli")
db.append_message("hasmsg", role="user", content="Hello")
db.end_session("hasmsg", end_reason="done")
assert db.count_empty_sessions() == 2
deleted = db.delete_empty_sessions()
assert deleted == 2
assert db.get_session("empty1") is None
assert db.get_session("empty2") is None
assert db.get_session("hasmsg") is not None
assert db.count_empty_sessions() == 0
def test_skips_active_empty_sessions(self, db):
"""A live (un-ended) empty session is what you get during the
race between session-create and the first message landing. The
sweep must not delete it that would yank a session out from
under the agent before its first reply persists."""
db.create_session(session_id="live", source="cli")
# Deliberately no end_session() — session is "active".
assert db.count_empty_sessions() == 0
assert db.delete_empty_sessions() == 0
assert db.get_session("live") is not None
def test_skips_archived_empty_sessions(self, db):
"""Archived = soft-hidden by the user. They explicitly chose to
keep the row around (even though it's empty), so the bulk sweep
must not surprise them by deleting it. Restoring an archived
session is one click; resurrecting one we deleted is impossible."""
db.create_session(session_id="archived_empty", source="cli")
db.end_session("archived_empty", end_reason="done")
db.set_session_archived("archived_empty", True)
assert db.count_empty_sessions() == 0
assert db.delete_empty_sessions() == 0
assert db.get_session("archived_empty") is not None
def test_returns_zero_when_nothing_to_delete(self, db):
"""No-op path: no candidate rows → return 0, no error."""
db.create_session(session_id="hasmsg", source="cli")
db.append_message("hasmsg", role="user", content="Hello")
db.end_session("hasmsg", end_reason="done")
assert db.count_empty_sessions() == 0
assert db.delete_empty_sessions() == 0
assert db.get_session("hasmsg") is not None
def test_orphans_children_of_deleted_empty_parent(self, db):
"""Even an empty parent can have a child (e.g. a branch session
spawned before the parent received any messages). The sweep
must orphan that child, not cascade-delete it same contract
as ``delete_session`` and ``prune_sessions``."""
db.create_session(session_id="empty_parent", source="cli")
db.end_session("empty_parent", end_reason="done")
db.create_session(
session_id="child", source="cli", parent_session_id="empty_parent"
)
db.append_message("child", role="user", content="something")
db.end_session("child", end_reason="done")
deleted = db.delete_empty_sessions()
assert deleted == 1
assert db.get_session("empty_parent") is None
child = db.get_session("child")
assert child is not None
assert child["parent_session_id"] is None
def test_cleans_up_on_disk_transcript_files(self, db, tmp_path):
"""When ``sessions_dir`` is provided, transcript files left
behind by a crashed gateway (``request_dump_*.json``) are swept
too. Empty sessions rarely have ``{id}.json`` / ``.jsonl``
transcripts, but the request-dump path is real the gateway
writes one before the first reply lands, so a crash mid-reply
produces an empty session with a non-empty dump file."""
db.create_session(session_id="empty_with_dump", source="cli")
db.end_session("empty_with_dump", end_reason="done")
dump = tmp_path / "request_dump_empty_with_dump_0.json"
dump.write_text("{}")
transcript = tmp_path / "empty_with_dump.jsonl"
transcript.write_text("")
deleted = db.delete_empty_sessions(sessions_dir=tmp_path)
assert deleted == 1
assert not dump.exists()
assert not transcript.exists()
# =========================================================================
# Schema and WAL mode
# =========================================================================
+5 -22
View File
@@ -933,27 +933,8 @@ def test_session_create_does_not_persist_empty_row(monkeypatch):
server._sessions.pop(sid, None)
def test_ensure_session_db_row_persists_explicit_cwd(monkeypatch, tmp_path):
"""An explicitly chosen workspace is persisted as the session cwd."""
created = []
class _FakeDB:
def create_session(self, key, source=None, model=None, cwd=None):
created.append({"key": key, "source": source, "model": model, "cwd": cwd})
monkeypatch.setattr(server, "_get_db", lambda: _FakeDB())
monkeypatch.setattr(server, "_resolve_model", lambda: "test-model")
server._ensure_session_db_row({"session_key": "k1", "cwd": str(tmp_path), "explicit_cwd": True})
assert created == [
{"key": "k1", "source": "tui", "model": "test-model", "cwd": str(tmp_path)}
]
def test_ensure_session_db_row_defaults_to_no_workspace(monkeypatch, tmp_path):
"""Without an explicit workspace, cwd is left null so the session groups
under "No workspace" rather than the gateway's launch directory."""
def test_ensure_session_db_row_persists_with_cwd(monkeypatch, tmp_path):
"""First prompt persists the row (INSERT OR IGNORE) capturing cwd up front."""
created = []
class _FakeDB:
@@ -965,7 +946,9 @@ def test_ensure_session_db_row_defaults_to_no_workspace(monkeypatch, tmp_path):
server._ensure_session_db_row({"session_key": "k1", "cwd": str(tmp_path)})
assert created == [{"key": "k1", "source": "tui", "model": "test-model", "cwd": None}]
assert created == [
{"key": "k1", "source": "tui", "model": "test-model", "cwd": str(tmp_path)}
]
def test_session_title_clears_pending_after_persist(monkeypatch):
+3 -22
View File
@@ -708,14 +708,8 @@ def _ensure_session_db_row(session: dict) -> None:
Called from prompt.submit so a row only exists once the user actually sends
a message abandoned drafts never leave an empty "Untitled" session behind.
Uses INSERT OR IGNORE under the hood, so re-calls (and the AIAgent's own
lazy create) are no-ops.
Only an *explicitly chosen* workspace is persisted as the session's cwd.
The agent still runs in the auto-detected directory (session["cwd"]), but
we don't stamp that onto the row — otherwise every session the user never
picked a folder for gets grouped under whatever directory the desktop
happened to launch in (e.g. "desktop"). Leaving it null groups them under
"No workspace", which is the desired default.
lazy create) are no-ops. Captures cwd up front so workspace grouping works
without waiting for a separate cwd update.
"""
key = session.get("session_key")
if not key:
@@ -728,7 +722,7 @@ def _ensure_session_db_row(session: dict) -> None:
key,
source="tui",
model=_resolve_model(),
cwd=_session_cwd(session) if session.get("explicit_cwd") else None,
cwd=_session_cwd(session),
)
except Exception:
logger.debug("failed to persist desktop session row", exc_info=True)
@@ -739,9 +733,6 @@ def _set_session_cwd(session: dict, cwd: str) -> str:
if not os.path.isdir(resolved):
raise ValueError(f"working directory does not exist: {cwd}")
session["cwd"] = resolved
# An explicit user choice — persist it as the workspace (and let a later
# lazy row creation persist it too, not the launch-dir fallback).
session["explicit_cwd"] = True
_register_session_cwd(session)
db = _get_db()
if db is not None:
@@ -2755,15 +2746,6 @@ def _(rid, params: dict) -> dict:
cols = int(params.get("cols", 80))
history = _coerce_seed_history(params.get("messages"))
title = str(params.get("title") or "").strip()
# Did the client pick a workspace, or are we falling back to the gateway's
# launch directory? Only an explicit choice is persisted as the session's
# workspace (see _ensure_session_db_row); otherwise it lands in "No
# workspace" instead of whatever folder the desktop launched in.
raw_cwd = str(params.get("cwd") or "").strip()
try:
explicit_cwd = bool(raw_cwd) and os.path.isdir(os.path.abspath(os.path.expanduser(raw_cwd)))
except Exception:
explicit_cwd = False
_enable_gateway_prompts()
ready = threading.Event()
@@ -2777,7 +2759,6 @@ def _(rid, params: dict) -> dict:
"cols": cols,
"created_at": now,
"edit_snapshots": {},
"explicit_cwd": explicit_cwd,
"history": history,
"history_lock": threading.Lock(),
"history_version": 0,
+8 -52
View File
@@ -11,27 +11,11 @@ import fillerBgUrl from "@nous-research/ui/assets/filler-bg0.webp";
* and the warm vignette both read theme-switchable CSS custom properties so
* `ThemeProvider` can repaint the stack without remounting.
*
* z-1 bg = `var(--background-base)`, mix-blend-mode driven by
* `--component-backdrop-bg-blend-mode` (default `difference`).
* Both LENS_0-style dark themes and the LENS_5I-style Nous Blue
* light theme keep `difference` here the canvas is flipped by
* the z-200 FG inversion layer, not by changing this blend mode.
* The CSS var is exposed as a hook so future presets can override
* it (e.g. `multiply` to paint the bg as-is before inversion)
* without touching this component.
* z-1 bg = `var(--background-base)`, mix-blend-mode: difference
* z-2 bundled filler-bg WebP, inverted, opacity 0.033, difference
* z-99 warm top-left vignette (`var(--warm-glow)`), opacity 0.22, lighten
* z-200 FG inversion = `var(--foreground)` (opaque white in LENS_5I,
* alpha-0 in LENS_0), mix-blend-mode: difference. This is the
* layer that flips the dashboard into "light mode" for inverted
* themes; for normal dark themes its alpha is 0 so it's a no-op.
* Deliberately placed above every UI overlay z-index (modals,
* tooltips, and dropUp dropdowns all sit at z-[100]) so portaled
* elements get inverted along with the rest of the page instead
* of painting with pre-inversion colors on top of the lens.
* z-201 noise grain (SVG, ~55% opacity × `--noise-opacity-mul`,
* color-dodge) gated on GPU tier. Sits above the inversion
* layer by design so the grain is not flipped.
* z-101 noise grain (SVG, ~55% opacity × `--noise-opacity-mul`,
* color-dodge) gated on GPU tier
*
* `useGpuTier` returns 0 when WebGL is unavailable, the renderer is a
* software rasterizer (SwiftShader/llvmpipe), or the user has
@@ -47,13 +31,10 @@ export function Backdrop() {
<div
aria-hidden
className="pointer-events-none fixed inset-0 z-[1]"
style={
{
backgroundColor: "var(--background-base)",
mixBlendMode:
"var(--component-backdrop-bg-blend-mode, difference)",
} as unknown as React.CSSProperties
}
style={{
backgroundColor: "var(--background-base)",
mixBlendMode: "difference",
}}
/>
<div
@@ -94,35 +75,10 @@ export function Backdrop() {
}}
/>
{/* Foreground inversion layer. Source-of-truth: LENS_5I.Lens.fgOpacity
+ fgBlend: 'difference' in `design-language/src/ui/components/
overlays/lens.ts`. With `--foreground-alpha: 0` (LENS_0 dark default)
the layer is fully transparent and contributes nothing; with
alpha 1 + opaque white it inverts the entire stack below it,
producing the LENS_5I "light mode" look without altering any
downstream component code.
z-200 (not 100) so it sits above every portaled UI overlay
sidebar tooltips, dropUp dropdowns, and modal dialogs all use
z-[100], which is what the DS Lens picks too; portals append
at the end of <body>, so equal z-index + later DOM order means
they'd paint on top of the inversion and skip the flip. Inlined
z-index for the same reason the DS does it Tailwind's JIT
scan sometimes drops non-default z utilities. */}
<div
aria-hidden
className="pointer-events-none fixed inset-0"
style={{
backgroundColor: "var(--foreground)",
mixBlendMode: "difference",
zIndex: 200,
}}
/>
{gpuTier > 0 && (
<div
aria-hidden
className="pointer-events-none fixed inset-0 z-[201]"
className="pointer-events-none fixed inset-0 z-[101]"
style={{
backgroundImage:
"url(\"data:image/svg+xml,%3Csvg viewBox='0 0 512 512' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.85' numOctaves='4' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' fill='%23eaeaea' filter='url(%23n)' opacity='0.6'/%3E%3C/svg%3E\")",
+1 -1
View File
@@ -86,7 +86,7 @@ export function ModelInfoCard({
{hasCaps && (
<div className="flex flex-wrap items-center gap-1.5 pt-0.5">
{caps.supports_tools && (
<span className="inline-flex items-center gap-1 bg-success/10 px-2 py-0.5 text-xs font-medium text-success">
<span className="inline-flex items-center gap-1 bg-emerald-500/10 px-2 py-0.5 text-xs font-medium text-emerald-600 dark:text-emerald-400">
<Wrench className="h-2.5 w-2.5" /> Tools
</span>
)}
-273
View File
@@ -1,273 +0,0 @@
import { useCallback } from "react";
import { Input } from "@nous-research/ui/ui/components/input";
import { Label } from "@nous-research/ui/ui/components/label";
import { Select, SelectOption } from "@nous-research/ui/ui/components/select";
import { Button } from "@nous-research/ui/ui/components/button";
import { useI18n } from "@/i18n";
import {
buildScheduleString,
DEFAULT_SCHEDULE_STATE,
type IntervalUnit,
type ScheduleBuilderState,
type ScheduleMode,
type Weekday,
WEEKDAY_INDEXES,
} from "@/lib/schedule";
/**
* Human-readable schedule picker for cron job create/edit flows.
*
* Replaces the raw "type a cron expression" input that lived inline in
* ``CronPage``. The picker still emits a single backend-compatible
* schedule string (see ``cron/jobs.py::parse_schedule``), but the user
* fills out shape-appropriate inputs (time picker, weekday toggles,
* datetime-local field) per mode.
*
* Architecture:
*
* - The component is fully controlled. Parent owns the
* ``ScheduleBuilderState`` and the derived schedule string (built
* via ``buildScheduleString`` in render).
* - Mode-specific state slots (``timeOfDay``, ``weekdays``, ...) are
* preserved across mode switches so flipping back to a previous mode
* doesn't erase the user's work.
* - The "Custom" mode is an escape hatch surfacing it as a normal
* option (instead of hiding it behind an "advanced" toggle) keeps
* power-user workflows discoverable without making everyone scroll
* past it.
*/
export function ScheduleBuilder({ onChange, value }: ScheduleBuilderProps) {
const { t } = useI18n();
const cronStrings = t.cron;
const modeStrings = cronStrings.scheduleModes;
const update = useCallback(
(patch: Partial<ScheduleBuilderState>) => {
onChange({ ...value, ...patch });
},
[onChange, value],
);
const toggleWeekday = useCallback(
(day: Weekday) => {
const present = value.weekdays.includes(day);
update({
weekdays: present
? value.weekdays.filter((d) => d !== day)
: [...value.weekdays, day],
});
},
[update, value.weekdays],
);
return (
<div className="grid gap-3">
<div className="grid gap-2">
<Label htmlFor="cron-schedule-mode">
{cronStrings.scheduleMode ?? "Schedule"}
</Label>
<Select
id="cron-schedule-mode"
value={value.mode}
onValueChange={(v) => update({ mode: v as ScheduleMode })}
>
<SelectOption value="interval">{modeStrings.interval}</SelectOption>
<SelectOption value="daily">{modeStrings.daily}</SelectOption>
<SelectOption value="weekly">{modeStrings.weekly}</SelectOption>
<SelectOption value="monthly">{modeStrings.monthly}</SelectOption>
<SelectOption value="once">{modeStrings.once}</SelectOption>
<SelectOption value="custom">{modeStrings.custom}</SelectOption>
</Select>
</div>
{value.mode === "interval" && (
<div className="grid grid-cols-[1fr_1.4fr] gap-3">
<div className="grid gap-2">
<Label htmlFor="cron-interval-value">
{modeStrings.intervalEvery}
</Label>
<Input
id="cron-interval-value"
type="number"
min={1}
max={9999}
value={String(value.intervalValue)}
onChange={(e) => {
const n = parseInt(e.target.value, 10);
update({
intervalValue: Number.isFinite(n) && n > 0 ? n : 1,
});
}}
/>
</div>
<div className="grid gap-2">
<Label htmlFor="cron-interval-unit">{modeStrings.intervalUnit}</Label>
<Select
id="cron-interval-unit"
value={value.intervalUnit}
onValueChange={(v) => update({ intervalUnit: v as IntervalUnit })}
>
<SelectOption value="minutes">
{modeStrings.unitMinutes}
</SelectOption>
<SelectOption value="hours">{modeStrings.unitHours}</SelectOption>
<SelectOption value="days">{modeStrings.unitDays}</SelectOption>
</Select>
</div>
</div>
)}
{value.mode === "daily" && (
<TimeOfDayField
id="cron-daily-time"
label={modeStrings.timeOfDay}
value={value.timeOfDay}
onChange={(timeOfDay) => update({ timeOfDay })}
/>
)}
{value.mode === "weekly" && (
<>
<div className="grid gap-2">
<Label>{modeStrings.weekdays}</Label>
<div
className="flex flex-wrap gap-1.5"
role="group"
aria-label={modeStrings.weekdays}
>
{WEEKDAY_INDEXES.map((d) => {
const isOn = value.weekdays.includes(d);
return (
<Button
key={d}
type="button"
size="sm"
outlined={!isOn}
aria-pressed={isOn}
onClick={() => toggleWeekday(d)}
className="min-w-[2.5rem] font-mono-ui text-xs uppercase"
>
{modeStrings.weekdaysShort[d]}
</Button>
);
})}
</div>
</div>
<TimeOfDayField
id="cron-weekly-time"
label={modeStrings.timeOfDay}
value={value.timeOfDay}
onChange={(timeOfDay) => update({ timeOfDay })}
/>
</>
)}
{value.mode === "monthly" && (
<div className="grid grid-cols-[1fr_1fr] gap-3">
<div className="grid gap-2">
<Label htmlFor="cron-month-day">{modeStrings.dayOfMonth}</Label>
<Input
id="cron-month-day"
type="number"
min={1}
max={31}
value={String(value.dayOfMonth)}
onChange={(e) => {
const n = parseInt(e.target.value, 10);
update({
dayOfMonth:
Number.isFinite(n) && n >= 1 && n <= 31 ? n : 1,
});
}}
/>
</div>
<TimeOfDayField
id="cron-monthly-time"
label={modeStrings.timeOfDay}
value={value.timeOfDay}
onChange={(timeOfDay) => update({ timeOfDay })}
/>
</div>
)}
{value.mode === "once" && (
<div className="grid gap-2">
<Label htmlFor="cron-once-at">{modeStrings.onceAt}</Label>
{/* Native datetime-local emits the exact "YYYY-MM-DDTHH:MM"
shape ``parse_schedule`` accepts on the backend. */}
<input
id="cron-once-at"
type="datetime-local"
className="flex h-9 w-full border border-border bg-background/40 px-3 py-2 text-sm font-courier shadow-sm placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-foreground/30 focus-visible:border-foreground/25"
value={value.onceAt}
onChange={(e) => update({ onceAt: e.target.value })}
/>
</div>
)}
{value.mode === "custom" && (
<div className="grid gap-2">
<Label htmlFor="cron-custom-expr">{modeStrings.customLabel}</Label>
<Input
id="cron-custom-expr"
placeholder={modeStrings.customPlaceholder}
value={value.custom}
onChange={(e) => update({ custom: e.target.value })}
className="font-mono-ui"
/>
<p className="text-xs text-muted-foreground">
{modeStrings.customHint}
</p>
</div>
)}
{/* Inline preview of what we'll send to the backend. Helps users
eyeball the result before hitting Create, and keeps the
schedule grammar discoverable for the custom mode. */}
<p className="text-xs text-muted-foreground">
<span className="opacity-70">{modeStrings.preview}: </span>
<span className="font-mono-ui text-foreground">
{buildScheduleString(value) || modeStrings.previewEmpty}
</span>
</p>
</div>
);
}
function TimeOfDayField({
id,
label,
onChange,
value,
}: TimeOfDayFieldProps) {
return (
<div className="grid gap-2">
<Label htmlFor={id}>{label}</Label>
{/* Native time picker is the right tool for "HH:MM" saves us
two separate hour/minute selects, respects user locale's
AM/PM preference, and round-trips with ``buildScheduleString``
without parsing. */}
<input
id={id}
type="time"
className="flex h-9 w-full border border-border bg-background/40 px-3 py-2 text-sm font-courier shadow-sm placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-foreground/30 focus-visible:border-foreground/25"
value={value}
onChange={(e) => onChange(e.target.value)}
/>
</div>
);
}
export { DEFAULT_SCHEDULE_STATE };
interface ScheduleBuilderProps {
onChange: (state: ScheduleBuilderState) => void;
value: ScheduleBuilderState;
}
interface TimeOfDayFieldProps {
id: string;
label: string;
onChange: (value: string) => void;
value: string;
}
+4 -14
View File
@@ -208,25 +208,15 @@ function ThemeSwitcherOptions({
}
function ThemeSwatch({ theme }: { theme: DashboardTheme }) {
// Inverted themes (Nous Blue / future lens themes) author their palette
// pre-inversion — `#FFAC02` reads as `#0053FD` blue once the foreground-
// difference layer flips the page. The picker can't replay that math
// cheaply, so themes opt-in to an explicit `swatchColors` triplet that
// mirrors the on-screen result. Falls back to the raw palette hexes for
// every other theme so existing dark-theme swatches are untouched.
const [c1, c2, c3] = theme.swatchColors ?? [
theme.palette.background.hex,
theme.palette.midground.hex,
theme.palette.warmGlow,
];
const { background, midground, warmGlow } = theme.palette;
return (
<div
aria-hidden
className="flex h-4 w-9 shrink-0 overflow-hidden border border-current/20"
>
<span className="flex-1" style={{ background: c1 }} />
<span className="flex-1" style={{ background: c2 }} />
<span className="flex-1" style={{ background: c3 }} />
<span className="flex-1" style={{ background: background.hex }} />
<span className="flex-1" style={{ background: midground.hex }} />
<span className="flex-1" style={{ background: warmGlow }} />
</div>
);
}
+1 -1
View File
@@ -220,7 +220,7 @@ function colorizeDiff(diff: string): React.ReactNode {
function diffLineClass(line: string): string {
if (line.startsWith("+") && !line.startsWith("+++"))
return "text-success";
return "text-emerald-500 dark:text-emerald-400";
if (line.startsWith("-") && !line.startsWith("---"))
return "text-destructive";
if (line.startsWith("@@")) return "text-primary";
-51
View File
@@ -141,22 +141,6 @@ export const af: Translations = {
"Dit verwyder die gesprek en al sy boodskappe permanent. Dit kan nie ongedaan gemaak word nie.",
sessionDeleted: "Sessie geskrap",
failedToDelete: "Kon nie sessie skrap nie",
deleteEmpty: "Skrap leë",
deleteEmptyConfirmTitle: "Skrap leë sessies?",
deleteEmptyConfirmMessage:
"Dit verwyder permanent {count} sessies wat geen boodskappe het nie. Aktiewe en geargiveerde sessies word oorgeslaan. Dit kan nie ongedaan gemaak word nie.",
emptySessionsDeleted: "{count} leë sessies geskrap",
failedToDeleteEmpty: "Kon nie leë sessies skrap nie",
selectSession: "Kies sessie",
selectAllOnPage: "Kies alles op hierdie bladsy",
clearSelection: "Maak keuse skoon",
selectedCount: "{count} gekies",
deleteSelected: "Skrap {count}",
deleteSelectedConfirmTitle: "Skrap {count} sessies?",
deleteSelectedConfirmMessage:
"Dit verwyder {count} gekose sessies en al hul boodskappe permanent. Dit kan nie ongedaan gemaak word nie.",
selectedSessionsDeleted: "{count} sessies geskrap",
failedToDeleteSelected: "Kon nie gekose sessies skrap nie",
resumeInChat: "Hervat in Klets",
previousPage: "Vorige bladsy",
nextPage: "Volgende bladsy",
@@ -227,41 +211,6 @@ export const af: Translations = {
promptPlaceholder: "Wat moet die agent met elke uitvoering doen?",
schedule: "Skedule (cron-uitdrukking)",
schedulePlaceholder: "0 9 * * *",
scheduleMode: "Skedule",
scheduleModes: {
interval: "Herhalende interval",
daily: "Daagliks",
weekly: "Weekliks",
monthly: "Maandeliks",
once: "Een keer",
custom: "Pasgemaak (cron-uitdrukking)",
intervalEvery: "Elke",
intervalUnit: "Eenheid",
unitMinutes: "minute",
unitHours: "ure",
unitDays: "dae",
timeOfDay: "Tyd van die dag",
weekdays: "Dae van die week",
weekdaysShort: ["Son", "Maa", "Din", "Woe", "Don", "Vry", "Sat"],
dayOfMonth: "Dag van die maand",
onceAt: "Hardloop op",
customLabel: "Cron-uitdrukking",
customPlaceholder: "0 9 * * *",
customHint:
"Cron-uitdrukking met vyf velde (minuut, uur, dag, maand, weekdag).",
preview: "Word gestuur as",
previewEmpty: "(onvolledig)",
},
scheduleDescribe: {
none: "—",
everyMinutes: "Elke {n} min",
everyHours: "Elke {n} u",
everyDays: "Elke {n} d",
dailyAt: "Daagliks om {time}",
weeklyAt: "Weekliks op {days} om {time}",
monthlyAt: "Maandeliks op die {day} om {time}",
onceAt: "Een keer op {time}",
},
deliverTo: "Lewer aan",
scheduledJobs: "Geskeduleerde Take",
noJobs: "Geen cron-take gekonfigureer nie. Skep een hierbo.",
-51
View File
@@ -141,22 +141,6 @@ export const de: Translations = {
"Dies entfernt die Unterhaltung und alle Nachrichten dauerhaft. Dies kann nicht rückgängig gemacht werden.",
sessionDeleted: "Sitzung gelöscht",
failedToDelete: "Sitzung konnte nicht gelöscht werden",
deleteEmpty: "Leere löschen",
deleteEmptyConfirmTitle: "Leere Sitzungen löschen?",
deleteEmptyConfirmMessage:
"Dies entfernt dauerhaft {count} Sitzungen ohne Nachrichten. Aktive und archivierte Sitzungen werden übersprungen. Dies kann nicht rückgängig gemacht werden.",
emptySessionsDeleted: "{count} leere Sitzungen gelöscht",
failedToDeleteEmpty: "Leere Sitzungen konnten nicht gelöscht werden",
selectSession: "Sitzung auswählen",
selectAllOnPage: "Alle auf dieser Seite auswählen",
clearSelection: "Auswahl aufheben",
selectedCount: "{count} ausgewählt",
deleteSelected: "{count} löschen",
deleteSelectedConfirmTitle: "{count} Sitzungen löschen?",
deleteSelectedConfirmMessage:
"Dies entfernt {count} ausgewählte Sitzungen und alle zugehörigen Nachrichten dauerhaft. Dies kann nicht rückgängig gemacht werden.",
selectedSessionsDeleted: "{count} Sitzungen gelöscht",
failedToDeleteSelected: "Ausgewählte Sitzungen konnten nicht gelöscht werden",
resumeInChat: "Im Chat fortsetzen",
previousPage: "Vorherige Seite",
nextPage: "Nächste Seite",
@@ -227,41 +211,6 @@ export const de: Translations = {
promptPlaceholder: "Was soll der Agent bei jedem Lauf tun?",
schedule: "Zeitplan (Cron-Ausdruck)",
schedulePlaceholder: "0 9 * * *",
scheduleMode: "Zeitplan",
scheduleModes: {
interval: "Wiederkehrendes Intervall",
daily: "Täglich",
weekly: "Wöchentlich",
monthly: "Monatlich",
once: "Einmalig",
custom: "Benutzerdefiniert (Cron-Ausdruck)",
intervalEvery: "Alle",
intervalUnit: "Einheit",
unitMinutes: "Minuten",
unitHours: "Stunden",
unitDays: "Tage",
timeOfDay: "Uhrzeit",
weekdays: "Wochentage",
weekdaysShort: ["So", "Mo", "Di", "Mi", "Do", "Fr", "Sa"],
dayOfMonth: "Tag des Monats",
onceAt: "Ausführen am",
customLabel: "Cron-Ausdruck",
customPlaceholder: "0 9 * * *",
customHint:
"Cron-Ausdruck mit fünf Feldern (Minute, Stunde, Tag, Monat, Wochentag).",
preview: "Wird gesendet als",
previewEmpty: "(unvollständig)",
},
scheduleDescribe: {
none: "—",
everyMinutes: "Alle {n} Min.",
everyHours: "Alle {n} Std.",
everyDays: "Alle {n} Tage",
dailyAt: "Täglich um {time}",
weeklyAt: "Wöchentlich am {days} um {time}",
monthlyAt: "Monatlich am {day} um {time}",
onceAt: "Einmal am {time}",
},
deliverTo: "Zustellen an",
scheduledJobs: "Geplante Aufgaben",
noJobs: "Keine Cron-Aufgaben konfiguriert. Erstelle oben eine.",
-51
View File
@@ -141,22 +141,6 @@ export const en: Translations = {
"This permanently removes the conversation and all of its messages. This cannot be undone.",
sessionDeleted: "Session deleted",
failedToDelete: "Failed to delete session",
deleteEmpty: "Delete empty",
deleteEmptyConfirmTitle: "Delete empty sessions?",
deleteEmptyConfirmMessage:
"This permanently removes {count} sessions that have no messages. Active and archived sessions are skipped. This cannot be undone.",
emptySessionsDeleted: "{count} empty sessions deleted",
failedToDeleteEmpty: "Failed to delete empty sessions",
selectSession: "Select session",
selectAllOnPage: "Select all on this page",
clearSelection: "Clear selection",
selectedCount: "{count} selected",
deleteSelected: "Delete {count}",
deleteSelectedConfirmTitle: "Delete {count} sessions?",
deleteSelectedConfirmMessage:
"This permanently removes {count} selected sessions and all their messages. This cannot be undone.",
selectedSessionsDeleted: "{count} sessions deleted",
failedToDeleteSelected: "Failed to delete selected sessions",
resumeInChat: "Resume in Chat",
previousPage: "Previous page",
nextPage: "Next page",
@@ -227,41 +211,6 @@ export const en: Translations = {
promptPlaceholder: "What should the agent do on each run?",
schedule: "Schedule (cron expression)",
schedulePlaceholder: "0 9 * * *",
scheduleMode: "Schedule",
scheduleModes: {
interval: "Every interval",
daily: "Daily",
weekly: "Weekly",
monthly: "Monthly",
once: "Once",
custom: "Custom (cron expression)",
intervalEvery: "Every",
intervalUnit: "Unit",
unitMinutes: "minutes",
unitHours: "hours",
unitDays: "days",
timeOfDay: "Time of day",
weekdays: "Days of week",
weekdaysShort: ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"],
dayOfMonth: "Day of month",
onceAt: "Run at",
customLabel: "Cron expression",
customPlaceholder: "0 9 * * *",
customHint:
"Five-field cron expression (minute, hour, day, month, weekday).",
preview: "Sends as",
previewEmpty: "(incomplete)",
},
scheduleDescribe: {
none: "—",
everyMinutes: "Every {n} min",
everyHours: "Every {n} h",
everyDays: "Every {n} d",
dailyAt: "Daily at {time}",
weeklyAt: "Weekly on {days} at {time}",
monthlyAt: "Monthly on the {day} at {time}",
onceAt: "Once at {time}",
},
deliverTo: "Deliver to",
scheduledJobs: "Scheduled Jobs",
noJobs: "No cron jobs configured. Create one above.",
-51
View File
@@ -141,22 +141,6 @@ export const es: Translations = {
"Esto elimina permanentemente la conversación y todos sus mensajes. No se puede deshacer.",
sessionDeleted: "Sesión eliminada",
failedToDelete: "No se pudo eliminar la sesión",
deleteEmpty: "Eliminar vacías",
deleteEmptyConfirmTitle: "¿Eliminar sesiones vacías?",
deleteEmptyConfirmMessage:
"Esto elimina permanentemente {count} sesiones que no tienen mensajes. Se omiten las sesiones activas y archivadas. Esta acción no se puede deshacer.",
emptySessionsDeleted: "{count} sesiones vacías eliminadas",
failedToDeleteEmpty: "No se pudieron eliminar las sesiones vacías",
selectSession: "Seleccionar sesión",
selectAllOnPage: "Seleccionar todas en esta página",
clearSelection: "Limpiar selección",
selectedCount: "{count} seleccionadas",
deleteSelected: "Eliminar {count}",
deleteSelectedConfirmTitle: "¿Eliminar {count} sesiones?",
deleteSelectedConfirmMessage:
"Esto elimina permanentemente {count} sesiones seleccionadas y todos sus mensajes. No se puede deshacer.",
selectedSessionsDeleted: "{count} sesiones eliminadas",
failedToDeleteSelected: "No se pudieron eliminar las sesiones seleccionadas",
resumeInChat: "Reanudar en el chat",
previousPage: "Página anterior",
nextPage: "Página siguiente",
@@ -227,41 +211,6 @@ export const es: Translations = {
promptPlaceholder: "¿Qué debe hacer el agente en cada ejecución?",
schedule: "Programación (expresión cron)",
schedulePlaceholder: "0 9 * * *",
scheduleMode: "Programación",
scheduleModes: {
interval: "Cada intervalo",
daily: "Diariamente",
weekly: "Semanalmente",
monthly: "Mensualmente",
once: "Una vez",
custom: "Personalizado (expresión cron)",
intervalEvery: "Cada",
intervalUnit: "Unidad",
unitMinutes: "minutos",
unitHours: "horas",
unitDays: "días",
timeOfDay: "Hora del día",
weekdays: "Días de la semana",
weekdaysShort: ["Dom", "Lun", "Mar", "Mié", "Jue", "Vie", "Sáb"],
dayOfMonth: "Día del mes",
onceAt: "Ejecutar el",
customLabel: "Expresión cron",
customPlaceholder: "0 9 * * *",
customHint:
"Expresión cron de cinco campos (minuto, hora, día, mes, día de la semana).",
preview: "Se envía como",
previewEmpty: "(incompleta)",
},
scheduleDescribe: {
none: "—",
everyMinutes: "Cada {n} min",
everyHours: "Cada {n} h",
everyDays: "Cada {n} d",
dailyAt: "Diariamente a las {time}",
weeklyAt: "Semanalmente los {days} a las {time}",
monthlyAt: "Mensualmente el {day} a las {time}",
onceAt: "Una vez el {time}",
},
deliverTo: "Entregar a",
scheduledJobs: "Tareas programadas",
noJobs: "No hay tareas cron configuradas. Crea una arriba.",
-51
View File
@@ -141,22 +141,6 @@ export const fr: Translations = {
"Cela supprime définitivement la conversation et tous ses messages. Cette action est irréversible.",
sessionDeleted: "Session supprimée",
failedToDelete: "Échec de la suppression de la session",
deleteEmpty: "Supprimer les vides",
deleteEmptyConfirmTitle: "Supprimer les sessions vides ?",
deleteEmptyConfirmMessage:
"Cela supprime définitivement {count} sessions sans messages. Les sessions actives et archivées sont ignorées. Cette action est irréversible.",
emptySessionsDeleted: "{count} sessions vides supprimées",
failedToDeleteEmpty: "Échec de la suppression des sessions vides",
selectSession: "Sélectionner la session",
selectAllOnPage: "Tout sélectionner sur cette page",
clearSelection: "Effacer la sélection",
selectedCount: "{count} sélectionnée(s)",
deleteSelected: "Supprimer {count}",
deleteSelectedConfirmTitle: "Supprimer {count} sessions ?",
deleteSelectedConfirmMessage:
"Cela supprime définitivement {count} sessions sélectionnées et tous leurs messages. Cette action est irréversible.",
selectedSessionsDeleted: "{count} sessions supprimées",
failedToDeleteSelected: "Échec de la suppression des sessions sélectionnées",
resumeInChat: "Reprendre dans le chat",
previousPage: "Page précédente",
nextPage: "Page suivante",
@@ -227,41 +211,6 @@ export const fr: Translations = {
promptPlaceholder: "Que doit faire l'agent à chaque exécution ?",
schedule: "Planning (expression cron)",
schedulePlaceholder: "0 9 * * *",
scheduleMode: "Planification",
scheduleModes: {
interval: "Intervalle récurrent",
daily: "Quotidien",
weekly: "Hebdomadaire",
monthly: "Mensuel",
once: "Une fois",
custom: "Personnalisé (expression cron)",
intervalEvery: "Toutes les",
intervalUnit: "Unité",
unitMinutes: "minutes",
unitHours: "heures",
unitDays: "jours",
timeOfDay: "Heure de la journée",
weekdays: "Jours de la semaine",
weekdaysShort: ["Dim", "Lun", "Mar", "Mer", "Jeu", "Ven", "Sam"],
dayOfMonth: "Jour du mois",
onceAt: "Exécuter le",
customLabel: "Expression cron",
customPlaceholder: "0 9 * * *",
customHint:
"Expression cron à cinq champs (minute, heure, jour, mois, jour de la semaine).",
preview: "Envoyé sous la forme",
previewEmpty: "(incomplet)",
},
scheduleDescribe: {
none: "—",
everyMinutes: "Toutes les {n} min",
everyHours: "Toutes les {n} h",
everyDays: "Tous les {n} j",
dailyAt: "Tous les jours à {time}",
weeklyAt: "Chaque {days} à {time}",
monthlyAt: "Le {day} de chaque mois à {time}",
onceAt: "Une fois le {time}",
},
deliverTo: "Livrer à",
scheduledJobs: "Tâches planifiées",
noJobs: "Aucune tâche cron configurée. Créez-en une ci-dessus.",
-59
View File
@@ -141,22 +141,6 @@ export const ga: Translations = {
"Baineann sé seo an comhrá agus a chuid teachtaireachtaí ar fad go buan. Ní féidir é seo a chealú.",
sessionDeleted: "Seisiún scriosta",
failedToDelete: "Theip ar scriosadh an tseisiúin",
deleteEmpty: "Scrios folamh",
deleteEmptyConfirmTitle: "Scrios seisiúin fholmha?",
deleteEmptyConfirmMessage:
"Baintear {count} seisiúin gan teachtaireachtaí ar bhealach buan. Ní scriostar seisiúin ghníomhacha agus seisiúin chartlainne. Ní féidir é seo a chealú.",
emptySessionsDeleted: "{count} seisiúin fholmha scriosta",
failedToDeleteEmpty: "Theip ar scriosadh na seisiún folmha",
selectSession: "Roghnaigh seisiún",
selectAllOnPage: "Roghnaigh gach ceann ar an leathanach seo",
clearSelection: "Glan an rogha",
selectedCount: "{count} roghnaithe",
deleteSelected: "Scrios {count}",
deleteSelectedConfirmTitle: "Scrios {count} seisiún?",
deleteSelectedConfirmMessage:
"Bainfear {count} seisiún roghnaithe agus a dteachtaireachtaí go léir go buan. Ní féidir é seo a chur ar ais.",
selectedSessionsDeleted: "Scriosadh {count} seisiún",
failedToDeleteSelected: "Theip ar scriosadh na seisiún roghnaithe",
resumeInChat: "Lean ar aghaidh sa chomhrá",
previousPage: "Leathanach roimhe seo",
nextPage: "An chéad leathanach eile",
@@ -227,49 +211,6 @@ export const ga: Translations = {
promptPlaceholder: "Cad ba chóir don agent a dhéanamh ag gach rith?",
schedule: "Sceideal (slonn cron)",
schedulePlaceholder: "0 9 * * *",
scheduleMode: "Sceideal",
scheduleModes: {
interval: "Eatramh athfhillteach",
daily: "Go laethúil",
weekly: "Go seachtainiúil",
monthly: "Go míosúil",
once: "Uair amháin",
custom: "Saincheaptha (slonn cron)",
intervalEvery: "Gach",
intervalUnit: "Aonad",
unitMinutes: "nóiméad",
unitHours: "uair",
unitDays: "lá",
timeOfDay: "Am an lae",
weekdays: "Laethanta na seachtaine",
weekdaysShort: [
"Domh",
"Luan",
"Máirt",
"Céad",
"Déar",
"Aoine",
"Sath",
],
dayOfMonth: "Lá den mhí",
onceAt: "Rith ag",
customLabel: "Slonn cron",
customPlaceholder: "0 9 * * *",
customHint:
"Slonn cron cúig réimse (nóiméad, uair, lá, mí, lá den tseachtain).",
preview: "Seoltar mar",
previewEmpty: "(neamhiomlán)",
},
scheduleDescribe: {
none: "—",
everyMinutes: "Gach {n} nóim",
everyHours: "Gach {n} u",
everyDays: "Gach {n} lá",
dailyAt: "Go laethúil ag {time}",
weeklyAt: "Gach {days} ag {time}",
monthlyAt: "An {day} de gach mí ag {time}",
onceAt: "Uair amháin ag {time}",
},
deliverTo: "Seachadadh chuig",
scheduledJobs: "Poist sceidealta",
noJobs: "Níl poist cron cumraithe. Cruthaigh ceann thuas.",
-51
View File
@@ -141,22 +141,6 @@ export const hu: Translations = {
"Ez véglegesen eltávolítja a beszélgetést és minden üzenetét. A művelet nem vonható vissza.",
sessionDeleted: "Munkamenet törölve",
failedToDelete: "Nem sikerült törölni a munkamenetet",
deleteEmpty: "Üresek törlése",
deleteEmptyConfirmTitle: "Üres munkamenetek törlése?",
deleteEmptyConfirmMessage:
"Ez véglegesen eltávolít {count} olyan munkamenetet, amely nem tartalmaz üzenetet. Az aktív és archivált munkameneteket kihagyja. Ez nem vonható vissza.",
emptySessionsDeleted: "{count} üres munkamenet törölve",
failedToDeleteEmpty: "Nem sikerült törölni az üres munkameneteket",
selectSession: "Munkamenet kijelölése",
selectAllOnPage: "Az oldalon mindegyik kijelölése",
clearSelection: "Kijelölés törlése",
selectedCount: "{count} kijelölve",
deleteSelected: "{count} törlése",
deleteSelectedConfirmTitle: "{count} munkamenet törlése?",
deleteSelectedConfirmMessage:
"Ez véglegesen eltávolítja a kijelölt {count} munkamenetet és minden üzenetüket. A művelet nem vonható vissza.",
selectedSessionsDeleted: "{count} munkamenet törölve",
failedToDeleteSelected: "Nem sikerült törölni a kijelölt munkameneteket",
resumeInChat: "Folytatás a csevegésben",
previousPage: "Előző oldal",
nextPage: "Következő oldal",
@@ -227,41 +211,6 @@ export const hu: Translations = {
promptPlaceholder: "Mit tegyen az ügynök minden futtatáskor?",
schedule: "Ütemezés (cron-kifejezés)",
schedulePlaceholder: "0 9 * * *",
scheduleMode: "Ütemezés",
scheduleModes: {
interval: "Ismétlődő intervallum",
daily: "Naponta",
weekly: "Hetente",
monthly: "Havonta",
once: "Egyszer",
custom: "Egyéni (cron kifejezés)",
intervalEvery: "Minden",
intervalUnit: "Egység",
unitMinutes: "perc",
unitHours: "óra",
unitDays: "nap",
timeOfDay: "Napszak",
weekdays: "Hét napjai",
weekdaysShort: ["V", "H", "K", "Sze", "Cs", "P", "Szo"],
dayOfMonth: "Hónap napja",
onceAt: "Futtatás ekkor",
customLabel: "Cron kifejezés",
customPlaceholder: "0 9 * * *",
customHint:
"Öt mezős cron kifejezés (perc, óra, nap, hónap, hét napja).",
preview: "Elküldve mint",
previewEmpty: "(hiányos)",
},
scheduleDescribe: {
none: "—",
everyMinutes: "{n} percenként",
everyHours: "{n} óránként",
everyDays: "{n} naponta",
dailyAt: "Naponta {time}-kor",
weeklyAt: "Hetente {days} {time}-kor",
monthlyAt: "Havonta {day} {time}-kor",
onceAt: "Egyszer {time}-kor",
},
deliverTo: "Kézbesítés ide",
scheduledJobs: "Ütemezett feladatok",
noJobs: "Nincs beállított cron-feladat. Hozzon létre egyet fent.",
-51
View File
@@ -141,22 +141,6 @@ export const it: Translations = {
"Questa operazione rimuove definitivamente la conversazione e tutti i suoi messaggi. Non può essere annullata.",
sessionDeleted: "Sessione eliminata",
failedToDelete: "Eliminazione della sessione non riuscita",
deleteEmpty: "Elimina vuote",
deleteEmptyConfirmTitle: "Eliminare le sessioni vuote?",
deleteEmptyConfirmMessage:
"Questa azione rimuove in modo permanente {count} sessioni senza messaggi. Le sessioni attive e archiviate vengono ignorate. L'azione non può essere annullata.",
emptySessionsDeleted: "{count} sessioni vuote eliminate",
failedToDeleteEmpty: "Impossibile eliminare le sessioni vuote",
selectSession: "Seleziona sessione",
selectAllOnPage: "Seleziona tutte in questa pagina",
clearSelection: "Annulla selezione",
selectedCount: "{count} selezionate",
deleteSelected: "Elimina {count}",
deleteSelectedConfirmTitle: "Eliminare {count} sessioni?",
deleteSelectedConfirmMessage:
"Verranno eliminate definitivamente {count} sessioni selezionate e tutti i loro messaggi. L'operazione non può essere annullata.",
selectedSessionsDeleted: "{count} sessioni eliminate",
failedToDeleteSelected: "Impossibile eliminare le sessioni selezionate",
resumeInChat: "Riprendi nella chat",
previousPage: "Pagina precedente",
nextPage: "Pagina successiva",
@@ -227,41 +211,6 @@ export const it: Translations = {
promptPlaceholder: "Cosa deve fare l'agente a ogni esecuzione?",
schedule: "Pianificazione (espressione cron)",
schedulePlaceholder: "0 9 * * *",
scheduleMode: "Pianificazione",
scheduleModes: {
interval: "Intervallo ricorrente",
daily: "Giornaliero",
weekly: "Settimanale",
monthly: "Mensile",
once: "Una volta",
custom: "Personalizzato (espressione cron)",
intervalEvery: "Ogni",
intervalUnit: "Unità",
unitMinutes: "minuti",
unitHours: "ore",
unitDays: "giorni",
timeOfDay: "Ora del giorno",
weekdays: "Giorni della settimana",
weekdaysShort: ["Dom", "Lun", "Mar", "Mer", "Gio", "Ven", "Sab"],
dayOfMonth: "Giorno del mese",
onceAt: "Esegui il",
customLabel: "Espressione cron",
customPlaceholder: "0 9 * * *",
customHint:
"Espressione cron a cinque campi (minuto, ora, giorno, mese, giorno della settimana).",
preview: "Inviato come",
previewEmpty: "(incompleta)",
},
scheduleDescribe: {
none: "—",
everyMinutes: "Ogni {n} min",
everyHours: "Ogni {n} h",
everyDays: "Ogni {n} g",
dailyAt: "Tutti i giorni alle {time}",
weeklyAt: "Ogni {days} alle {time}",
monthlyAt: "Il {day} di ogni mese alle {time}",
onceAt: "Una volta il {time}",
},
deliverTo: "Consegna a",
scheduledJobs: "Attività pianificate",
noJobs: "Nessuna attività cron configurata. Creane una sopra.",
-50
View File
@@ -141,22 +141,6 @@ export const ja: Translations = {
"会話とそのすべてのメッセージが完全に削除されます。この操作は取り消せません。",
sessionDeleted: "セッションを削除しました",
failedToDelete: "セッションの削除に失敗しました",
deleteEmpty: "空を削除",
deleteEmptyConfirmTitle: "空のセッションを削除しますか?",
deleteEmptyConfirmMessage:
"メッセージのない {count} 件のセッションを完全に削除します。アクティブおよびアーカイブされたセッションはスキップされます。この操作は元に戻せません。",
emptySessionsDeleted: "{count} 件の空のセッションを削除しました",
failedToDeleteEmpty: "空のセッションの削除に失敗しました",
selectSession: "セッションを選択",
selectAllOnPage: "このページの全てを選択",
clearSelection: "選択を解除",
selectedCount: "{count}件選択中",
deleteSelected: "{count}件削除",
deleteSelectedConfirmTitle: "{count}件のセッションを削除しますか?",
deleteSelectedConfirmMessage:
"選択した{count}件のセッションとそのすべてのメッセージが完全に削除されます。この操作は取り消せません。",
selectedSessionsDeleted: "{count}件のセッションを削除しました",
failedToDeleteSelected: "選択したセッションの削除に失敗しました",
resumeInChat: "チャットで再開",
previousPage: "前のページ",
nextPage: "次のページ",
@@ -227,40 +211,6 @@ export const ja: Translations = {
promptPlaceholder: "実行ごとにエージェントが行う内容は?",
schedule: "スケジュール (cron 式)",
schedulePlaceholder: "0 9 * * *",
scheduleMode: "スケジュール",
scheduleModes: {
interval: "繰り返し間隔",
daily: "毎日",
weekly: "毎週",
monthly: "毎月",
once: "1回のみ",
custom: "カスタム(cron式)",
intervalEvery: "実行間隔",
intervalUnit: "単位",
unitMinutes: "分",
unitHours: "時間",
unitDays: "日",
timeOfDay: "時刻",
weekdays: "曜日",
weekdaysShort: ["日", "月", "火", "水", "木", "金", "土"],
dayOfMonth: "日付",
onceAt: "実行日時",
customLabel: "cron式",
customPlaceholder: "0 9 * * *",
customHint: "5フィールドのcron式(分、時、日、月、曜日)。",
preview: "送信形式",
previewEmpty: "(未入力)",
},
scheduleDescribe: {
none: "—",
everyMinutes: "{n}分ごと",
everyHours: "{n}時間ごと",
everyDays: "{n}日ごと",
dailyAt: "毎日 {time}",
weeklyAt: "毎週 {days} {time}",
monthlyAt: "毎月{day} {time}",
onceAt: "{time} に1回",
},
deliverTo: "配信先",
scheduledJobs: "スケジュール済みジョブ",
noJobs: "Cron ジョブが設定されていません。上で作成してください。",
-50
View File
@@ -141,22 +141,6 @@ export const ko: Translations = {
"이 작업은 대화와 모든 메시지를 영구적으로 제거합니다. 되돌릴 수 없습니다.",
sessionDeleted: "세션이 삭제되었습니다",
failedToDelete: "세션 삭제에 실패했습니다",
deleteEmpty: "빈 세션 삭제",
deleteEmptyConfirmTitle: "빈 세션을 삭제하시겠습니까?",
deleteEmptyConfirmMessage:
"메시지가 없는 {count}개의 세션을 영구적으로 삭제합니다. 활성 및 보관된 세션은 건너뜁니다. 이 작업은 되돌릴 수 없습니다.",
emptySessionsDeleted: "빈 세션 {count}개 삭제됨",
failedToDeleteEmpty: "빈 세션 삭제에 실패했습니다",
selectSession: "세션 선택",
selectAllOnPage: "이 페이지 전체 선택",
clearSelection: "선택 해제",
selectedCount: "{count}개 선택됨",
deleteSelected: "{count}개 삭제",
deleteSelectedConfirmTitle: "{count}개 세션을 삭제하시겠습니까?",
deleteSelectedConfirmMessage:
"선택한 {count}개 세션과 모든 메시지가 영구적으로 제거됩니다. 이 작업은 취소할 수 없습니다.",
selectedSessionsDeleted: "{count}개 세션이 삭제되었습니다",
failedToDeleteSelected: "선택한 세션 삭제에 실패했습니다",
resumeInChat: "채팅에서 다시 시작",
previousPage: "이전 페이지",
nextPage: "다음 페이지",
@@ -227,40 +211,6 @@ export const ko: Translations = {
promptPlaceholder: "에이전트가 매 실행 시 무엇을 해야 합니까?",
schedule: "스케줄 (cron 표현식)",
schedulePlaceholder: "0 9 * * *",
scheduleMode: "일정",
scheduleModes: {
interval: "반복 간격",
daily: "매일",
weekly: "매주",
monthly: "매월",
once: "한 번",
custom: "사용자 지정 (cron 표현식)",
intervalEvery: "실행 간격",
intervalUnit: "단위",
unitMinutes: "분",
unitHours: "시간",
unitDays: "일",
timeOfDay: "시각",
weekdays: "요일",
weekdaysShort: ["일", "월", "화", "수", "목", "금", "토"],
dayOfMonth: "날짜",
onceAt: "실행 시각",
customLabel: "cron 표현식",
customPlaceholder: "0 9 * * *",
customHint: "5개 필드의 cron 표현식 (분, 시, 일, 월, 요일).",
preview: "전송 형식",
previewEmpty: "(미완성)",
},
scheduleDescribe: {
none: "—",
everyMinutes: "{n}분마다",
everyHours: "{n}시간마다",
everyDays: "{n}일마다",
dailyAt: "매일 {time}",
weeklyAt: "매주 {days} {time}",
monthlyAt: "매월 {day} {time}",
onceAt: "{time}에 한 번",
},
deliverTo: "전달 대상",
scheduledJobs: "예약된 작업",
noJobs: "구성된 cron 작업이 없습니다. 위에서 하나 만드세요.",
-51
View File
@@ -141,22 +141,6 @@ export const pt: Translations = {
"Esta ação remove permanentemente a conversa e todas as suas mensagens. Não é possível anular.",
sessionDeleted: "Sessão eliminada",
failedToDelete: "Falha ao eliminar a sessão",
deleteEmpty: "Eliminar vazias",
deleteEmptyConfirmTitle: "Eliminar sessões vazias?",
deleteEmptyConfirmMessage:
"Isto remove permanentemente {count} sessões sem mensagens. As sessões ativas e arquivadas são ignoradas. Esta ação não pode ser desfeita.",
emptySessionsDeleted: "{count} sessões vazias eliminadas",
failedToDeleteEmpty: "Falha ao eliminar sessões vazias",
selectSession: "Selecionar sessão",
selectAllOnPage: "Selecionar todas nesta página",
clearSelection: "Limpar seleção",
selectedCount: "{count} selecionadas",
deleteSelected: "Eliminar {count}",
deleteSelectedConfirmTitle: "Eliminar {count} sessões?",
deleteSelectedConfirmMessage:
"Isto remove permanentemente {count} sessões selecionadas e todas as suas mensagens. Não pode ser desfeito.",
selectedSessionsDeleted: "{count} sessões eliminadas",
failedToDeleteSelected: "Falha ao eliminar as sessões selecionadas",
resumeInChat: "Retomar no Chat",
previousPage: "Página anterior",
nextPage: "Página seguinte",
@@ -227,41 +211,6 @@ export const pt: Translations = {
promptPlaceholder: "O que deve o agente fazer em cada execução?",
schedule: "Agendamento (expressão cron)",
schedulePlaceholder: "0 9 * * *",
scheduleMode: "Agendamento",
scheduleModes: {
interval: "Intervalo recorrente",
daily: "Diariamente",
weekly: "Semanalmente",
monthly: "Mensalmente",
once: "Uma vez",
custom: "Personalizado (expressão cron)",
intervalEvery: "A cada",
intervalUnit: "Unidade",
unitMinutes: "minutos",
unitHours: "horas",
unitDays: "dias",
timeOfDay: "Hora do dia",
weekdays: "Dias da semana",
weekdaysShort: ["Dom", "Seg", "Ter", "Qua", "Qui", "Sex", "Sáb"],
dayOfMonth: "Dia do mês",
onceAt: "Executar em",
customLabel: "Expressão cron",
customPlaceholder: "0 9 * * *",
customHint:
"Expressão cron de cinco campos (minuto, hora, dia, mês, dia da semana).",
preview: "Enviado como",
previewEmpty: "(incompleta)",
},
scheduleDescribe: {
none: "—",
everyMinutes: "A cada {n} min",
everyHours: "A cada {n} h",
everyDays: "A cada {n} d",
dailyAt: "Diariamente às {time}",
weeklyAt: "Semanalmente {days} às {time}",
monthlyAt: "Mensalmente no dia {day} às {time}",
onceAt: "Uma vez em {time}",
},
deliverTo: "Entregar a",
scheduledJobs: "Tarefas agendadas",
noJobs: "Sem tarefas cron configuradas. Crie uma acima.",
-51
View File
@@ -141,22 +141,6 @@ export const ru: Translations = {
"Это безвозвратно удалит разговор и все его сообщения. Действие нельзя отменить.",
sessionDeleted: "Сессия удалена",
failedToDelete: "Не удалось удалить сессию",
deleteEmpty: "Удалить пустые",
deleteEmptyConfirmTitle: "Удалить пустые сессии?",
deleteEmptyConfirmMessage:
"Это безвозвратно удалит {count} сессий без сообщений. Активные и архивные сессии будут пропущены. Это действие нельзя отменить.",
emptySessionsDeleted: "Удалено пустых сессий: {count}",
failedToDeleteEmpty: "Не удалось удалить пустые сессии",
selectSession: "Выбрать сессию",
selectAllOnPage: "Выбрать все на этой странице",
clearSelection: "Снять выделение",
selectedCount: "Выбрано: {count}",
deleteSelected: "Удалить {count}",
deleteSelectedConfirmTitle: "Удалить {count} сессий?",
deleteSelectedConfirmMessage:
"Это безвозвратно удалит {count} выбранных сессий и все их сообщения. Это действие нельзя отменить.",
selectedSessionsDeleted: "Удалено сессий: {count}",
failedToDeleteSelected: "Не удалось удалить выбранные сессии",
resumeInChat: "Продолжить в чате",
previousPage: "Предыдущая страница",
nextPage: "Следующая страница",
@@ -227,41 +211,6 @@ export const ru: Translations = {
promptPlaceholder: "Что должен делать агент при каждом запуске?",
schedule: "Расписание (cron-выражение)",
schedulePlaceholder: "0 9 * * *",
scheduleMode: "Расписание",
scheduleModes: {
interval: "Повторяющийся интервал",
daily: "Ежедневно",
weekly: "Еженедельно",
monthly: "Ежемесячно",
once: "Один раз",
custom: "Произвольное (cron-выражение)",
intervalEvery: "Каждые",
intervalUnit: "Единицы",
unitMinutes: "минут",
unitHours: "часов",
unitDays: "дней",
timeOfDay: "Время суток",
weekdays: "Дни недели",
weekdaysShort: ["Вс", "Пн", "Вт", "Ср", "Чт", "Пт", "Сб"],
dayOfMonth: "День месяца",
onceAt: "Выполнить в",
customLabel: "Cron-выражение",
customPlaceholder: "0 9 * * *",
customHint:
"Cron-выражение из пяти полей (минута, час, день, месяц, день недели).",
preview: "Отправляется как",
previewEmpty: "(не заполнено)",
},
scheduleDescribe: {
none: "—",
everyMinutes: "Каждые {n} мин",
everyHours: "Каждые {n} ч",
everyDays: "Каждые {n} дн",
dailyAt: "Ежедневно в {time}",
weeklyAt: "Еженедельно в {days} в {time}",
monthlyAt: "Ежемесячно {day} числа в {time}",
onceAt: "Один раз {time}",
},
deliverTo: "Доставить в",
scheduledJobs: "Запланированные задачи",
noJobs: "Cron-задачи не настроены. Создайте задачу выше.",
-51
View File
@@ -141,22 +141,6 @@ export const tr: Translations = {
"Bu, konuşmayı ve tüm mesajlarını kalıcı olarak siler. Bu işlem geri alınamaz.",
sessionDeleted: "Oturum silindi",
failedToDelete: "Oturum silinemedi",
deleteEmpty: "Boşları sil",
deleteEmptyConfirmTitle: "Boş oturumlar silinsin mi?",
deleteEmptyConfirmMessage:
"Bu işlem, mesaj içermeyen {count} oturumu kalıcı olarak siler. Aktif ve arşivlenmiş oturumlar atlanır. Bu işlem geri alınamaz.",
emptySessionsDeleted: "{count} boş oturum silindi",
failedToDeleteEmpty: "Boş oturumlar silinemedi",
selectSession: "Oturumu seç",
selectAllOnPage: "Bu sayfadakilerin tümünü seç",
clearSelection: "Seçimi temizle",
selectedCount: "{count} seçildi",
deleteSelected: "{count} sil",
deleteSelectedConfirmTitle: "{count} oturum silinsin mi?",
deleteSelectedConfirmMessage:
"Bu, seçilen {count} oturumu ve tüm mesajlarını kalıcı olarak siler. Bu işlem geri alınamaz.",
selectedSessionsDeleted: "{count} oturum silindi",
failedToDeleteSelected: "Seçilen oturumlar silinemedi",
resumeInChat: "Sohbette Devam Et",
previousPage: "Önceki sayfa",
nextPage: "Sonraki sayfa",
@@ -227,41 +211,6 @@ export const tr: Translations = {
promptPlaceholder: "Agent her çalıştırmada ne yapmalı?",
schedule: "Zamanlama (cron ifadesi)",
schedulePlaceholder: "0 9 * * *",
scheduleMode: "Zamanlama",
scheduleModes: {
interval: "Tekrarlanan aralık",
daily: "Günlük",
weekly: "Haftalık",
monthly: "Aylık",
once: "Bir kez",
custom: "Özel (cron ifadesi)",
intervalEvery: "Her",
intervalUnit: "Birim",
unitMinutes: "dakika",
unitHours: "saat",
unitDays: "gün",
timeOfDay: "Günün saati",
weekdays: "Haftanın günleri",
weekdaysShort: ["Paz", "Pzt", "Sal", "Çar", "Per", "Cum", "Cmt"],
dayOfMonth: "Ayın günü",
onceAt: "Çalıştırma zamanı",
customLabel: "Cron ifadesi",
customPlaceholder: "0 9 * * *",
customHint:
"Beş alanlı cron ifadesi (dakika, saat, gün, ay, haftanın günü).",
preview: "Gönderilecek olan",
previewEmpty: "(eksik)",
},
scheduleDescribe: {
none: "—",
everyMinutes: "Her {n} dk",
everyHours: "Her {n} sa",
everyDays: "Her {n} gün",
dailyAt: "Her gün {time}",
weeklyAt: "Her hafta {days} {time}",
monthlyAt: "Her ayın {day} günü {time}",
onceAt: "{time} bir kez",
},
deliverTo: "Şuraya teslim et",
scheduledJobs: "Zamanlanmış Görevler",
noJobs: "Yapılandırılmış cron görevi yok. Yukarıdan bir tane oluşturun.",
-48
View File
@@ -158,20 +158,6 @@ export interface Translations {
confirmDeleteMessage: string;
sessionDeleted: string;
failedToDelete: string;
deleteEmpty: string;
deleteEmptyConfirmTitle: string;
deleteEmptyConfirmMessage: string;
emptySessionsDeleted: string;
failedToDeleteEmpty: string;
selectSession: string;
selectAllOnPage: string;
clearSelection: string;
selectedCount: string;
deleteSelected: string;
deleteSelectedConfirmTitle: string;
deleteSelectedConfirmMessage: string;
selectedSessionsDeleted: string;
failedToDeleteSelected: string;
resumeInChat: string;
previousPage: string;
nextPage: string;
@@ -245,40 +231,6 @@ export interface Translations {
promptPlaceholder: string;
schedule: string;
schedulePlaceholder: string;
scheduleMode: string;
scheduleModes: {
interval: string;
daily: string;
weekly: string;
monthly: string;
once: string;
custom: string;
intervalEvery: string;
intervalUnit: string;
unitMinutes: string;
unitHours: string;
unitDays: string;
timeOfDay: string;
weekdays: string;
weekdaysShort: [string, string, string, string, string, string, string];
dayOfMonth: string;
onceAt: string;
customLabel: string;
customPlaceholder: string;
customHint: string;
preview: string;
previewEmpty: string;
};
scheduleDescribe: {
none: string;
everyMinutes: string;
everyHours: string;
everyDays: string;
dailyAt: string;
weeklyAt: string;
monthlyAt: string;
onceAt: string;
};
deliverTo: string;
scheduledJobs: string;
noJobs: string;
-51
View File
@@ -141,22 +141,6 @@ export const uk: Translations = {
"Це назавжди видалить розмову та всі її повідомлення. Цю дію не можна скасувати.",
sessionDeleted: "Сесію видалено",
failedToDelete: "Не вдалося видалити сесію",
deleteEmpty: "Видалити порожні",
deleteEmptyConfirmTitle: "Видалити порожні сесії?",
deleteEmptyConfirmMessage:
"Це остаточно видалить {count} сесій без повідомлень. Активні та архівні сесії пропускаються. Цю дію неможливо скасувати.",
emptySessionsDeleted: "Видалено порожніх сесій: {count}",
failedToDeleteEmpty: "Не вдалося видалити порожні сесії",
selectSession: "Вибрати сесію",
selectAllOnPage: "Вибрати всі на цій сторінці",
clearSelection: "Скинути вибір",
selectedCount: "Вибрано: {count}",
deleteSelected: "Видалити {count}",
deleteSelectedConfirmTitle: "Видалити {count} сесій?",
deleteSelectedConfirmMessage:
"Це назавжди видалить {count} вибраних сесій і всі їхні повідомлення. Цю дію неможливо скасувати.",
selectedSessionsDeleted: "Видалено сесій: {count}",
failedToDeleteSelected: "Не вдалося видалити вибрані сесії",
resumeInChat: "Продовжити в чаті",
previousPage: "Попередня сторінка",
nextPage: "Наступна сторінка",
@@ -227,41 +211,6 @@ export const uk: Translations = {
promptPlaceholder: "Що агент має робити при кожному запуску?",
schedule: "Розклад (cron-вираз)",
schedulePlaceholder: "0 9 * * *",
scheduleMode: "Розклад",
scheduleModes: {
interval: "Повторюваний інтервал",
daily: "Щодня",
weekly: "Щотижня",
monthly: "Щомісяця",
once: "Один раз",
custom: "Користувацьке (cron-вираз)",
intervalEvery: "Кожні",
intervalUnit: "Одиниці",
unitMinutes: "хвилин",
unitHours: "годин",
unitDays: "днів",
timeOfDay: "Час доби",
weekdays: "Дні тижня",
weekdaysShort: ["Нд", "Пн", "Вт", "Ср", "Чт", "Пт", "Сб"],
dayOfMonth: "День місяця",
onceAt: "Виконати о",
customLabel: "Cron-вираз",
customPlaceholder: "0 9 * * *",
customHint:
"П'ятиполевий cron-вираз (хвилина, година, день, місяць, день тижня).",
preview: "Надсилається як",
previewEmpty: "(не заповнено)",
},
scheduleDescribe: {
none: "—",
everyMinutes: "Кожні {n} хв",
everyHours: "Кожні {n} год",
everyDays: "Кожні {n} дн",
dailyAt: "Щодня о {time}",
weeklyAt: "Щотижня у {days} о {time}",
monthlyAt: "Щомісяця {day} числа о {time}",
onceAt: "Один раз {time}",
},
deliverTo: "Надіслати на",
scheduledJobs: "Заплановані завдання",
noJobs: "Cron-завдань не налаштовано. Створіть одне вище.",
-50
View File
@@ -141,22 +141,6 @@ export const zhHant: Translations = {
"此操作將永久移除對話及其所有訊息,無法復原。",
sessionDeleted: "工作階段已刪除",
failedToDelete: "刪除工作階段失敗",
deleteEmpty: "刪除空工作階段",
deleteEmptyConfirmTitle: "刪除空工作階段?",
deleteEmptyConfirmMessage:
"這將永久刪除 {count} 個沒有訊息的工作階段。活動中與已封存的工作階段將被略過。此動作無法復原。",
emptySessionsDeleted: "已刪除 {count} 個空工作階段",
failedToDeleteEmpty: "刪除空工作階段失敗",
selectSession: "選擇工作階段",
selectAllOnPage: "全選本頁",
clearSelection: "清除選擇",
selectedCount: "已選擇 {count} 個",
deleteSelected: "刪除 {count} 個",
deleteSelectedConfirmTitle: "刪除 {count} 個工作階段?",
deleteSelectedConfirmMessage:
"此操作將永久刪除所選的 {count} 個工作階段及其所有訊息。無法復原。",
selectedSessionsDeleted: "已刪除 {count} 個工作階段",
failedToDeleteSelected: "刪除所選工作階段失敗",
resumeInChat: "在對話中繼續",
previousPage: "上一頁",
nextPage: "下一頁",
@@ -227,40 +211,6 @@ export const zhHant: Translations = {
promptPlaceholder: "代理每次執行時應做什麼?",
schedule: "排程(cron 運算式)",
schedulePlaceholder: "0 9 * * *",
scheduleMode: "排程",
scheduleModes: {
interval: "重複間隔",
daily: "每日",
weekly: "每週",
monthly: "每月",
once: "僅一次",
custom: "自訂(cron 運算式)",
intervalEvery: "每",
intervalUnit: "單位",
unitMinutes: "分鐘",
unitHours: "小時",
unitDays: "天",
timeOfDay: "時間",
weekdays: "星期",
weekdaysShort: ["日", "一", "二", "三", "四", "五", "六"],
dayOfMonth: "日期",
onceAt: "執行時間",
customLabel: "cron 運算式",
customPlaceholder: "0 9 * * *",
customHint: "五欄位 cron 運算式(分、時、日、月、星期)。",
preview: "傳送為",
previewEmpty: "(未完成)",
},
scheduleDescribe: {
none: "—",
everyMinutes: "每 {n} 分鐘",
everyHours: "每 {n} 小時",
everyDays: "每 {n} 天",
dailyAt: "每天 {time}",
weeklyAt: "每週 {days} {time}",
monthlyAt: "每月{day} {time}",
onceAt: "{time} 執行一次",
},
deliverTo: "傳送至",
scheduledJobs: "已排程任務",
noJobs: "尚未設定排程任務。請於上方建立。",
-50
View File
@@ -139,22 +139,6 @@ export const zh: Translations = {
confirmDeleteMessage: "此操作将永久删除对话及其所有消息,无法恢复。",
sessionDeleted: "会话已删除",
failedToDelete: "删除会话失败",
deleteEmpty: "删除空会话",
deleteEmptyConfirmTitle: "删除空会话?",
deleteEmptyConfirmMessage:
"这将永久删除 {count} 个没有消息的会话。活动和已归档的会话将被跳过。此操作无法撤销。",
emptySessionsDeleted: "已删除 {count} 个空会话",
failedToDeleteEmpty: "删除空会话失败",
selectSession: "选择会话",
selectAllOnPage: "全选本页",
clearSelection: "清除选择",
selectedCount: "已选择 {count} 个",
deleteSelected: "删除 {count} 个",
deleteSelectedConfirmTitle: "删除 {count} 个会话?",
deleteSelectedConfirmMessage:
"此操作将永久删除所选的 {count} 个会话及其所有消息。无法撤销。",
selectedSessionsDeleted: "已删除 {count} 个会话",
failedToDeleteSelected: "删除所选会话失败",
resumeInChat: "在对话中继续",
previousPage: "上一页",
nextPage: "下一页",
@@ -224,40 +208,6 @@ export const zh: Translations = {
promptPlaceholder: "代理每次运行时应执行什么操作?",
schedule: "调度表达式(cron",
schedulePlaceholder: "0 9 * * *",
scheduleMode: "调度",
scheduleModes: {
interval: "重复间隔",
daily: "每天",
weekly: "每周",
monthly: "每月",
once: "仅一次",
custom: "自定义(cron 表达式)",
intervalEvery: "每",
intervalUnit: "单位",
unitMinutes: "分钟",
unitHours: "小时",
unitDays: "天",
timeOfDay: "时间",
weekdays: "星期",
weekdaysShort: ["日", "一", "二", "三", "四", "五", "六"],
dayOfMonth: "日期",
onceAt: "执行时间",
customLabel: "cron 表达式",
customPlaceholder: "0 9 * * *",
customHint: "五字段 cron 表达式(分、时、日、月、星期)。",
preview: "发送为",
previewEmpty: "(未完成)",
},
scheduleDescribe: {
none: "—",
everyMinutes: "每 {n} 分钟",
everyHours: "每 {n} 小时",
everyDays: "每 {n} 天",
dailyAt: "每天 {time}",
weeklyAt: "每周 {days} {time}",
monthlyAt: "每月{day} {time}",
onceAt: "{time} 执行一次",
},
deliverTo: "投递至",
scheduledJobs: "已调度任务",
noJobs: "暂无定时任务。在上方创建一个。",
-9
View File
@@ -83,15 +83,6 @@
--theme-radius: 0.5rem;
--theme-spacing-mul: 1;
--theme-density: comfortable;
/* Data-series accents consumed by Analytics + Models pages for the
input-vs-output token visualisations (chart bars, table values,
legend swatches). Defaults are tuned for the Hermes-teal LENS_0
look: cream input + emerald-400 output read as warm/cool against
the dark canvas. Themes override via ThemeProvider, which emits
these as `--series-input-token` / `--series-output-token`. */
--series-input-token: #ffe6cb;
--series-output-token: #34d399;
}
/* Theme tokens cascade into the document root so every descendant inherits
-12
View File
@@ -238,18 +238,6 @@ export const api = {
fetchJSON<{ ok: boolean }>(`/api/sessions/${encodeURIComponent(id)}`, {
method: "DELETE",
}),
getEmptySessionsCount: () =>
fetchJSON<{ count: number }>("/api/sessions/empty/count"),
deleteEmptySessions: () =>
fetchJSON<{ ok: boolean; deleted: number }>("/api/sessions/empty", {
method: "DELETE",
}),
bulkDeleteSessions: (ids: string[]) =>
fetchJSON<{ ok: boolean; deleted: number }>("/api/sessions/bulk-delete", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ ids }),
}),
renameSession: (id: string, title: string) =>
fetchJSON<{ ok: boolean; title: string }>(
`/api/sessions/${encodeURIComponent(id)}`,
-382
View File
@@ -1,382 +0,0 @@
/**
* Schedule builder helpers for the cron page.
*
* The hermes-agent backend (cron/jobs.py::parse_schedule) accepts a
* surprisingly broad set of string formats:
*
* - Duration (one-shot): "30m", "2h", "1d"
* - Interval (recurring): "every 30m", "every 2h", "every 1d"
* - Cron expression (5-field): "0 9 * * *", "30 14 * * 1,3,5"
* - ISO timestamp (one-shot): "2026-02-03T14:00:00"
*
* Power users can hand-type any of those, but for everyone else the
* dashboard now offers a human-readable picker. This module is the
* pure logic layer behind that picker:
*
* - {@link buildScheduleString} turns the picker's structured state
* into one of the strings above.
* - {@link describeSchedule} goes the other way: takes the structured
* schedule shape the API returns (``CronJob.schedule``) and produces
* a human-readable sentence for the job list. It recognises common
* cron-expression shapes (daily/weekly/monthly) so users don't have
* to parse "30 14 * * 1,3,5" by eye.
*
* Kept dependency-free and locale-string-driven so it tree-shakes
* cleanly and is testable in isolation if we ever wire up vitest here.
*/
/** Picker modes each renders a different set of inputs in the UI but
* all funnel through {@link buildScheduleString} to a backend-compatible
* string. ``custom`` is the escape hatch for power users who still want
* to type a raw cron expression. */
export type ScheduleMode =
| "interval"
| "daily"
| "weekly"
| "monthly"
| "once"
| "custom";
/** Unit used by interval mode. Backend parses ``m``/``h``/``d`` suffixes. */
export type IntervalUnit = "minutes" | "hours" | "days";
/** Cron weekday convention: Sunday = 0 .. Saturday = 6. Matches what
* croniter expects on the backend (no need to remap on submit). */
export const WEEKDAY_INDEXES = [0, 1, 2, 3, 4, 5, 6] as const;
export type Weekday = (typeof WEEKDAY_INDEXES)[number];
export interface ScheduleBuilderState {
/** Index of which "custom" radio is selected. */
mode: ScheduleMode;
/** Interval mode: positive integer, paired with ``intervalUnit``. */
intervalValue: number;
intervalUnit: IntervalUnit;
/** Daily/weekly/monthly mode: "HH:MM" 24h format from <input type=time>. */
timeOfDay: string;
/** Weekly mode: 0..6, Sunday-first. Empty means "every day", which is
* still valid we send "*" for the day-of-week cron field. */
weekdays: Weekday[];
/** Monthly mode: 1..31 (no support for "last day of month" sugar the
* croniter ``L`` extension isn't enabled in the parse_schedule regex). */
dayOfMonth: number;
/** Once mode: ``YYYY-MM-DDTHH:MM`` from <input type=datetime-local>. */
onceAt: string;
/** Custom mode: raw user-typed cron expression. Stored separately so
* flipping between modes doesn't erase the user's work. */
custom: string;
}
/** Default state "every 30 minutes" is the most-common-cron-pattern
* starting point and avoids forcing the user to pick everything from
* scratch. */
export const DEFAULT_SCHEDULE_STATE: ScheduleBuilderState = {
mode: "interval",
intervalValue: 30,
intervalUnit: "minutes",
timeOfDay: "09:00",
weekdays: [1, 2, 3, 4, 5],
dayOfMonth: 1,
onceAt: "",
custom: "",
};
const UNIT_SUFFIX: Record<IntervalUnit, string> = {
minutes: "m",
hours: "h",
days: "d",
};
/** Build the schedule string from picker state. Returns ``""`` when the
* state is incomplete enough that the backend would 400 the caller
* uses that to disable the Submit button.
*
* Why we lean on the broad parse_schedule grammar instead of always
* emitting cron expressions: interval syntax ("every 30m") survives a
* backend without ``croniter`` installed and renders more readably in
* the job list. We only emit raw cron when the picker truly needs the
* cron field expressiveness (specific weekdays, specific day-of-month). */
export function buildScheduleString(state: ScheduleBuilderState): string {
switch (state.mode) {
case "interval": {
const n = Math.floor(state.intervalValue);
if (!Number.isFinite(n) || n < 1) return "";
return `every ${n}${UNIT_SUFFIX[state.intervalUnit]}`;
}
case "daily": {
const parsed = parseTimeOfDay(state.timeOfDay);
if (!parsed) return "";
return `${parsed.minute} ${parsed.hour} * * *`;
}
case "weekly": {
const parsed = parseTimeOfDay(state.timeOfDay);
if (!parsed) return "";
// Empty weekday selection → "*" (every day) rather than a backend
// 400. The Daily mode is the cleaner choice for that, but if the
// user toggles all days off in Weekly mode we still emit a valid
// expression instead of breaking the submit.
const days =
state.weekdays.length === 0
? "*"
: [...state.weekdays].sort((a, b) => a - b).join(",");
return `${parsed.minute} ${parsed.hour} * * ${days}`;
}
case "monthly": {
const parsed = parseTimeOfDay(state.timeOfDay);
if (!parsed) return "";
const dom = Math.floor(state.dayOfMonth);
if (!Number.isFinite(dom) || dom < 1 || dom > 31) return "";
return `${parsed.minute} ${parsed.hour} ${dom} * *`;
}
case "once": {
const v = state.onceAt.trim();
if (!v) return "";
// <input type=datetime-local> already emits the
// "YYYY-MM-DDTHH:MM" shape that fromisoformat() accepts directly.
// Append ":00" so the backend's regex hits the "T" branch and
// the seconds component lines up with isoformat() output.
return v.length === 16 ? `${v}:00` : v;
}
case "custom":
return state.custom.trim();
}
}
function parseTimeOfDay(value: string): { hour: number; minute: number } | null {
if (!value || !/^\d{1,2}:\d{2}$/.test(value)) return null;
const [hh, mm] = value.split(":");
const hour = parseInt(hh, 10);
const minute = parseInt(mm, 10);
if (
!Number.isFinite(hour) ||
!Number.isFinite(minute) ||
hour < 0 ||
hour > 23 ||
minute < 0 ||
minute > 59
) {
return null;
}
return { hour, minute };
}
/** Translation surface the human-readable describer needs. Passing it
* in (instead of importing ``useI18n``) keeps the helper pure and
* testable; the CronPage threads ``t.cron.scheduleDescribe`` through. */
export interface ScheduleDescribeStrings {
/** Display when no schedule can be resolved (e.g. legacy/blank job). */
none: string;
/** "Every {n} minute(s)" — caller pluralises via {n}. */
everyMinutes: string;
everyHours: string;
everyDays: string;
/** "Daily at {time}" */
dailyAt: string;
/** "Weekly on {days} at {time}" */
weeklyAt: string;
/** "Monthly on the {day} at {time}" */
monthlyAt: string;
/** "Once at {time}" */
onceAt: string;
/** Weekday short names indexed 0..6 (Sunday-first). */
weekdaysShort: [string, string, string, string, string, string, string];
/** Ordinal suffix builder, e.g. "1st", "22nd". For locales that
* don't use English ordinals, just return ``String(day)``. */
ordinal: (day: number) => string;
}
/** Schedule shape stored on a ``CronJob`` row (see api.ts). */
export interface ScheduleLike {
kind?: string;
expr?: string;
minutes?: number;
run_at?: string;
display?: string;
}
/** Human-readable description of a stored schedule.
*
* Prefers a structured render over the raw ``display`` string so cron
* expressions like ``30 14 * * 1,3,5`` show up as "Weekly on Mon, Wed,
* Fri at 14:30" instead of the raw five-field gibberish. Falls back to
* ``display`` / ``expr`` / ``none`` in that order if we can't make sense
* of the schedule (e.g. exotic cron with ranges, step values, or @reboot
* macros that we'd misrepresent if we tried to "humanize"). */
export function describeSchedule(
schedule: ScheduleLike | undefined,
fallbackDisplay: string | undefined,
strings: ScheduleDescribeStrings,
): string {
if (!schedule) return fallbackDisplay || strings.none;
if (schedule.kind === "interval" && typeof schedule.minutes === "number") {
return describeInterval(schedule.minutes, strings);
}
if (schedule.kind === "once" && schedule.run_at) {
return strings.onceAt.replace(
"{time}",
formatIsoLocal(schedule.run_at, false),
);
}
if (schedule.kind === "cron" && schedule.expr) {
const cronDesc = describeCronExpression(schedule.expr, strings);
if (cronDesc) return cronDesc;
}
// Try the raw expression as a last attempt — for legacy jobs stored
// without ``kind``, the ``schedule_display`` field often *is* the cron
// expression.
if (fallbackDisplay) {
const cronDesc = describeCronExpression(fallbackDisplay, strings);
if (cronDesc) return cronDesc;
return fallbackDisplay;
}
if (schedule.display) return schedule.display;
if (schedule.expr) return schedule.expr;
return strings.none;
}
function describeInterval(
minutes: number,
strings: ScheduleDescribeStrings,
): string {
if (minutes <= 0) return strings.none;
if (minutes % 1440 === 0) {
return strings.everyDays.replace("{n}", String(minutes / 1440));
}
if (minutes % 60 === 0) {
return strings.everyHours.replace("{n}", String(minutes / 60));
}
return strings.everyMinutes.replace("{n}", String(minutes));
}
/** Recognise the common, well-shaped cron patterns and return a
* human sentence for them. Returns ``null`` when the expression has any
* ranges, steps, or other complexity that would be misleading to
* "humanize" caller falls back to displaying the raw expression so
* the user sees what's actually scheduled.
*
* Strictly 5-field only: the backend ``parse_schedule`` also accepts the
* 6-field ``minute hour dom month dow year`` form, but humanising those
* by destructuring only the first five fields would silently drop the
* year and mislead the user (e.g. ``0 9 * * * 2099`` would read as
* "Daily at 09:00"). 6+ field expressions intentionally fall through to
* the raw-string fallback in {@link describeSchedule}. */
function describeCronExpression(
expr: string,
strings: ScheduleDescribeStrings,
): string | null {
const parts = expr.trim().split(/\s+/);
if (parts.length !== 5) return null;
const [minField, hourField, domField, monField, dowField] = parts;
const month = monField === "*";
if (!month) return null; // we don't try to humanize per-month rules
const isLiteralOrList = (f: string) =>
/^\d+(,\d+)*$/.test(f) || /^\*$/.test(f);
if (!isLiteralOrList(minField) || !isLiteralOrList(hourField)) return null;
if (!isLiteralOrList(domField) || !isLiteralOrList(dowField)) return null;
// Star minutes/hours would mean "every minute" / "every hour" — we'd
// need a step-value handler ("*/15") to describe that cleanly, and
// that path is power-user territory. Bail to raw display.
if (minField === "*" || hourField === "*") return null;
const minutes = minField.split(",").map((n) => parseInt(n, 10));
const hours = hourField.split(",").map((n) => parseInt(n, 10));
if (minutes.length !== 1 || hours.length !== 1) return null;
if (
!Number.isFinite(minutes[0]) ||
!Number.isFinite(hours[0]) ||
hours[0] < 0 ||
hours[0] > 23 ||
minutes[0] < 0 ||
minutes[0] > 59
) {
return null;
}
const time = `${pad2(hours[0])}:${pad2(minutes[0])}`;
const domAll = domField === "*";
const dowAll = dowField === "*";
if (domAll && dowAll) {
return strings.dailyAt.replace("{time}", time);
}
if (domAll && !dowAll) {
const days = dowField
.split(",")
.map((n) => parseInt(n, 10))
.filter((n) => Number.isFinite(n) && n >= 0 && n <= 6) as Weekday[];
if (days.length === 0) return null;
const labels = days
.map((d) => strings.weekdaysShort[d])
.filter(Boolean)
.join(", ");
return strings.weeklyAt
.replace("{days}", labels)
.replace("{time}", time);
}
if (!domAll && dowAll) {
const dom = parseInt(domField, 10);
if (!Number.isFinite(dom) || dom < 1 || dom > 31) return null;
return strings.monthlyAt
.replace("{day}", strings.ordinal(dom))
.replace("{time}", time);
}
// Both day-of-month AND day-of-week set is unusual and cron's
// OR-semantics for that combo are confusing — fall back to raw.
return null;
}
function pad2(n: number): string {
return n < 10 ? `0${n}` : String(n);
}
/** Format an ISO date for inline display. Drops the seconds + TZ
* suffix so the cron list stays compact. Falls back to the raw string
* if Date parsing fails. */
function formatIsoLocal(iso: string, includeSeconds: boolean): string {
const d = new Date(iso);
if (Number.isNaN(d.getTime())) return iso;
const yyyy = d.getFullYear();
const mm = pad2(d.getMonth() + 1);
const dd = pad2(d.getDate());
const hh = pad2(d.getHours());
const mi = pad2(d.getMinutes());
if (includeSeconds) {
return `${yyyy}-${mm}-${dd} ${hh}:${mi}:${pad2(d.getSeconds())}`;
}
return `${yyyy}-${mm}-${dd} ${hh}:${mi}`;
}
/** Convenience: build an English ordinal suffix ("1st", "2nd", "23rd").
* Most non-English locales should just return ``String(day)`` from
* their ``ordinal`` override. */
export function englishOrdinal(day: number): string {
const d = Math.floor(day);
if (!Number.isFinite(d) || d < 1) return String(day);
const lastTwo = d % 100;
if (lastTwo >= 11 && lastTwo <= 13) return `${d}th`;
switch (d % 10) {
case 1:
return `${d}st`;
case 2:
return `${d}nd`;
case 3:
return `${d}rd`;
default:
return `${d}th`;
}
}
+36 -39
View File
@@ -21,6 +21,7 @@ import { Button } from "@nous-research/ui/ui/components/button";
import { Spinner } from "@nous-research/ui/ui/components/spinner";
import { Stats } from "@nous-research/ui/ui/components/stats";
import { Card, CardContent, CardHeader, CardTitle } from "@nous-research/ui/ui/components/card";
import { Badge } from "@nous-research/ui/ui/components/badge";
import { usePageHeader } from "@/contexts/usePageHeader";
import { useI18n } from "@/i18n";
import { PluginSlot } from "@/plugins";
@@ -147,17 +148,11 @@ function TokenBarChart({ daily }: { daily: AnalyticsDailyEntry[] }) {
</div>
<div className="flex items-center gap-4 font-mondwest normal-case text-xs text-muted-foreground">
<div className="flex items-center gap-1.5">
<div
className="h-2.5 w-2.5"
style={{ backgroundColor: "var(--series-input-token)" }}
/>
<div className="h-2.5 w-2.5 bg-[#ffe6cb]" />
{t.analytics.input}
</div>
<div className="flex items-center gap-1.5">
<div
className="h-2.5 w-2.5"
style={{ backgroundColor: "var(--series-output-token)" }}
/>
<div className="h-2.5 w-2.5 bg-emerald-500" />
{t.analytics.output}
</div>
</div>
@@ -197,19 +192,13 @@ function TokenBarChart({ daily }: { daily: AnalyticsDailyEntry[] }) {
</div>
<div
className="w-full"
style={{
backgroundColor:
"color-mix(in srgb, var(--series-input-token) 70%, transparent)",
height: Math.max(inputH, total > 0 ? 1 : 0),
}}
className="w-full bg-[#ffe6cb]/70"
style={{ height: Math.max(inputH, total > 0 ? 1 : 0) }}
/>
<div
className="w-full"
className="w-full bg-emerald-500/70"
style={{
backgroundColor:
"color-mix(in srgb, var(--series-output-token) 70%, transparent)",
height: Math.max(outputH, d.output_tokens > 0 ? 1 : 0),
}}
/>
@@ -272,12 +261,12 @@ function DailyTable({ daily }: { daily: AnalyticsDailyEntry[] }) {
{d.sessions}
</td>
<td className="text-right py-2 px-4">
<span style={{ color: "var(--series-input-token)" }}>
<span className="text-[#ffe6cb]">
{formatTokens(d.input_tokens)}
</span>
</td>
<td className="text-right py-2 pl-4">
<span style={{ color: "var(--series-output-token)" }}>
<span className="text-emerald-400">
{formatTokens(d.output_tokens)}
</span>
</td>
@@ -330,11 +319,11 @@ function ModelTable({ models }: { models: AnalyticsModelEntry[] }) {
{m.sessions}
</td>
<td className="text-right py-2 pl-4">
<span style={{ color: "var(--series-input-token)" }}>
<span className="text-[#ffe6cb]">
{formatTokens(m.input_tokens)}
</span>
{" / "}
<span style={{ color: "var(--series-output-token)" }}>
<span className="text-emerald-400">
{formatTokens(m.output_tokens)}
</span>
</td>
@@ -438,24 +427,14 @@ export default function AnalyticsPage() {
}, [days, showTokens]);
useLayoutEffect(() => {
// Period selector + refresh both live in afterTitle so the controls
// sit immediately next to the page title instead of being pinned to
// the far-right `end` slot. The active period is conveyed by the
// filled (non-outlined) button — no redundant period badge.
const periodLabel =
PERIODS.find((p) => p.days === days)?.label ?? `${days}d`;
setAfterTitle(
showTokens === false ? null : (
<div className="flex flex-wrap items-center gap-1.5">
{PERIODS.map((p) => (
<Button
key={p.label}
type="button"
size="sm"
outlined={days !== p.days}
onClick={() => setDays(p.days)}
>
{p.label}
</Button>
))}
<span className="flex items-center gap-1.5">
<Badge tone="secondary" className="text-xs">
{periodLabel}
</Badge>
{showTokens !== false && (
<Button
type="button"
ghost
@@ -467,10 +446,28 @@ export default function AnalyticsPage() {
>
{loading ? <Spinner /> : <RefreshCw />}
</Button>
)}
</span>,
);
setEnd(
showTokens === false ? null : (
<div className="flex w-full min-w-0 flex-wrap items-center justify-start gap-2 sm:justify-end sm:gap-2">
<div className="flex flex-wrap items-center gap-1.5">
{PERIODS.map((p) => (
<Button
key={p.label}
type="button"
size="sm"
outlined={days !== p.days}
onClick={() => setDays(p.days)}
>
{p.label}
</Button>
))}
</div>
</div>
),
);
setEnd(null);
return () => {
setAfterTitle(null);
setEnd(null);
+46 -84
View File
@@ -8,17 +8,6 @@ import { H2 } from "@nous-research/ui/ui/components/typography/h2";
import { api } from "@/lib/api";
import type { CronJob, ProfileInfo } from "@/lib/api";
import { DeleteConfirmDialog } from "@/components/DeleteConfirmDialog";
import {
DEFAULT_SCHEDULE_STATE,
ScheduleBuilder,
} from "@/components/ScheduleBuilder";
import {
buildScheduleString,
describeSchedule,
englishOrdinal,
type ScheduleBuilderState,
type ScheduleDescribeStrings,
} from "@/lib/schedule";
import { useToast } from "@nous-research/ui/hooks/use-toast";
import { useConfirmDelete } from "@nous-research/ui/hooks/use-confirm-delete";
import { useModalBehavior } from "@/hooks/useModalBehavior";
@@ -68,20 +57,12 @@ function getJobTitle(job: CronJob): string {
return job.id || "Cron job";
}
function getJobScheduleDisplay(
job: CronJob,
strings: ScheduleDescribeStrings,
): string {
// Prefer a structured render so cron expressions like
// ``30 14 * * 1,3,5`` surface as "Weekly on Mon, Wed, Fri at 14:30"
// in the list instead of the raw five-field gibberish. Falls back
// through the existing chain (``schedule_display`` from the backend,
// then the structured ``display`` field, then the raw ``expr``) so
// legacy job rows still render *something* meaningful.
return describeSchedule(
job.schedule,
asText(job.schedule_display) || asText(job.schedule?.display),
strings,
function getJobScheduleDisplay(job: CronJob): string {
return (
asText(job.schedule_display) ||
asText(job.schedule?.display) ||
asText(job.schedule?.expr) ||
"—"
);
}
@@ -121,35 +102,13 @@ export default function CronPage() {
const [selectedProfile, setSelectedProfile] = useState("all");
const [loading, setLoading] = useState(true);
const { toast, showToast } = useToast();
const { t, locale } = useI18n();
const { t } = useI18n();
const { setEnd } = usePageHeader();
// Translation surface for the human-readable schedule describer.
// English ordinals are a special case ("1st", "2nd", "23rd"); every
// other locale falls back to the plain numeric form, which avoids
// shipping incorrect grammar (e.g. naive "1th"/"2th" suffixes that
// don't exist in most languages).
//
// Built inline (not memoized) — the cron page renders a small job
// list, this is single-digit microseconds, and a useMemo here would
// just add boilerplate.
const scheduleDescribeStrings: ScheduleDescribeStrings = {
...t.cron.scheduleDescribe,
weekdaysShort: t.cron.scheduleModes.weekdaysShort,
ordinal: locale === "en" ? englishOrdinal : (n: number) => String(n),
};
// New job modal state
const [createModalOpen, setCreateModalOpen] = useState(false);
const [prompt, setPrompt] = useState("");
// The schedule is now constructed via the ScheduleBuilder; we keep
// the full builder state so flipping between modes during edit
// doesn't erase the user's intermediate inputs. The actual string
// sent to the backend is derived via ``buildScheduleString`` at
// submit time.
const [scheduleState, setScheduleState] = useState<ScheduleBuilderState>(
DEFAULT_SCHEDULE_STATE,
);
const [schedule, setSchedule] = useState("");
const [name, setName] = useState("");
const closeCreateModal = useCallback(() => setCreateModalOpen(false), []);
const createModalRef = useModalBehavior({
@@ -202,10 +161,8 @@ export default function CronPage() {
loadJobs();
}, [loadJobs]);
const scheduleString = buildScheduleString(scheduleState);
const handleCreate = async () => {
if (!prompt.trim() || !scheduleString) {
if (!prompt.trim() || !schedule.trim()) {
showToast(`${t.cron.prompt} & ${t.cron.schedule} required`, "error");
return;
}
@@ -214,7 +171,7 @@ export default function CronPage() {
await api.createCronJob(
{
prompt: prompt.trim(),
schedule: scheduleString,
schedule: schedule.trim(),
name: name.trim() || undefined,
deliver,
},
@@ -222,7 +179,7 @@ export default function CronPage() {
);
showToast(t.common.create + " ✓", "success");
setPrompt("");
setScheduleState(DEFAULT_SCHEDULE_STATE);
setSchedule("");
setName("");
setDeliver("local");
setCreateModalOpen(false);
@@ -435,34 +392,41 @@ export default function CronPage() {
/>
</div>
<ScheduleBuilder
value={scheduleState}
onChange={setScheduleState}
/>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div className="grid gap-2">
<Label htmlFor="cron-schedule">{t.cron.schedule}</Label>
<Input
id="cron-schedule"
placeholder={t.cron.schedulePlaceholder}
value={schedule}
onChange={(e) => setSchedule(e.target.value)}
/>
</div>
<div className="grid gap-2">
<Label htmlFor="cron-deliver">{t.cron.deliverTo}</Label>
<Select
id="cron-deliver"
value={deliver}
onValueChange={(v) => setDeliver(v)}
>
<SelectOption value="local">
{t.cron.delivery.local}
</SelectOption>
<SelectOption value="telegram">
{t.cron.delivery.telegram}
</SelectOption>
<SelectOption value="discord">
{t.cron.delivery.discord}
</SelectOption>
<SelectOption value="slack">
{t.cron.delivery.slack}
</SelectOption>
<SelectOption value="email">
{t.cron.delivery.email}
</SelectOption>
</Select>
<div className="grid gap-2">
<Label htmlFor="cron-deliver">{t.cron.deliverTo}</Label>
<Select
id="cron-deliver"
value={deliver}
onValueChange={(v) => setDeliver(v)}
>
<SelectOption value="local">
{t.cron.delivery.local}
</SelectOption>
<SelectOption value="telegram">
{t.cron.delivery.telegram}
</SelectOption>
<SelectOption value="discord">
{t.cron.delivery.discord}
</SelectOption>
<SelectOption value="slack">
{t.cron.delivery.slack}
</SelectOption>
<SelectOption value="email">
{t.cron.delivery.email}
</SelectOption>
</Select>
</div>
</div>
<div className="flex justify-end">
@@ -653,9 +617,7 @@ export default function CronPage() {
</p>
)}
<div className="flex items-center gap-4 text-xs text-muted-foreground">
<span className="font-mono-ui">
{getJobScheduleDisplay(job, scheduleDescribeStrings)}
</span>
<span className="font-mono">{getJobScheduleDisplay(job)}</span>
<span>
{t.cron.last}: {formatTime(job.last_run_at)}
</span>
+33 -39
View File
@@ -95,17 +95,11 @@ function TokenBar({
const total = input + output + cacheRead + reasoning;
if (total === 0) return null;
// Segments carry a CSS color value (hex or `var(--token)`) rather than
// a Tailwind class so the input/output series can pick up the active
// theme's `--series-*-token` vars — see `themes/types.ts`
// `ThemeSeriesColors`. The /60/70 fade on the bar is applied via
// color-mix on the same value so themes don't need to ship two
// separate hex literals.
const segments: Array<{ color: string; label: string; value: number }> = [
{ value: cacheRead, color: "#60a5fa", label: "Cache Read" }, // tailwind blue-400
{ value: reasoning, color: "#c084fc", label: "Reasoning" }, // tailwind purple-400
{ value: input, color: "var(--series-input-token)", label: "Input" },
{ value: output, color: "var(--series-output-token)", label: "Output" },
const segments = [
{ value: cacheRead, color: "bg-blue-400/60", dotColor: "bg-blue-400", label: "Cache Read" },
{ value: reasoning, color: "bg-purple-400/60", dotColor: "bg-purple-400", label: "Reasoning" },
{ value: input, color: "bg-[#ffe6cb]/70", dotColor: "bg-[#ffe6cb]", label: "Input" },
{ value: output, color: "bg-emerald-500/70", dotColor: "bg-emerald-500", label: "Output" },
].filter((s) => s.value > 0);
return (
@@ -115,11 +109,8 @@ function TokenBar({
{segments.map((s, i) => (
<div
key={i}
className="relative flex items-center transition-all duration-300"
style={{
backgroundColor: `color-mix(in srgb, ${s.color} 70%, transparent)`,
width: `${(s.value / total) * 100}%`,
}}
className={`${s.color} relative flex items-center transition-all duration-300`}
style={{ width: `${(s.value / total) * 100}%` }}
>
{/* Stepped fill pattern overlay */}
<div
@@ -137,10 +128,7 @@ function TokenBar({
<div className="flex flex-wrap gap-x-3 gap-y-0.5 text-xs text-text-secondary">
{segments.map((s, i) => (
<span key={i} className="flex items-center gap-1">
<span
className="inline-block h-1.5 w-1.5 rounded-full"
style={{ backgroundColor: s.color }}
/>
<span className={`inline-block h-1.5 w-1.5 rounded-full ${s.dotColor}`} />
{s.label} {formatTokens(s.value)}
</span>
))}
@@ -164,7 +152,7 @@ function CapabilityBadges({
return (
<div className="flex flex-wrap items-center gap-1.5">
{capabilities.supports_tools && (
<span className="inline-flex items-center gap-1 bg-success/10 px-1.5 py-0.5 text-xs font-medium text-success">
<span className="inline-flex items-center gap-1 bg-emerald-500/10 px-1.5 py-0.5 text-xs font-medium text-emerald-600 dark:text-emerald-400">
<Wrench className="h-2.5 w-2.5" /> Tools
</span>
)}
@@ -830,24 +818,13 @@ export default function ModelsPage() {
}, []);
useLayoutEffect(() => {
// Period selector + refresh both live in afterTitle so the controls
// sit immediately next to the page title instead of being pinned to
// the far-right `end` slot. The active period is conveyed by the
// filled (non-outlined) button — no redundant period badge.
const periodLabel =
PERIODS.find((p) => p.days === days)?.label ?? `${days}d`;
setAfterTitle(
<div className="flex flex-wrap items-center gap-1.5">
{PERIODS.map((p) => (
<Button
key={p.label}
type="button"
size="sm"
outlined={days !== p.days}
onClick={() => setDays(p.days)}
className="uppercase"
>
{p.label}
</Button>
))}
<span className="flex items-center gap-1.5">
<Badge tone="secondary" className="text-xs">
{periodLabel}
</Badge>
<Button
type="button"
ghost
@@ -859,9 +836,26 @@ export default function ModelsPage() {
>
{loading ? <Spinner /> : <RefreshCw />}
</Button>
</span>,
);
setEnd(
<div className="flex w-full min-w-0 flex-wrap items-center justify-start gap-2 sm:justify-end sm:gap-2">
<div className="flex flex-wrap items-center gap-1.5">
{PERIODS.map((p) => (
<Button
key={p.label}
type="button"
size="sm"
outlined={days !== p.days}
onClick={() => setDays(p.days)}
className="uppercase"
>
{p.label}
</Button>
))}
</div>
</div>,
);
setEnd(null);
return () => {
setAfterTitle(null);
setEnd(null);
+23 -384
View File
@@ -23,7 +23,6 @@ import {
Hash,
X,
Play,
Eraser,
Download,
Pencil,
Check,
@@ -42,7 +41,6 @@ import { Markdown } from "@/components/Markdown";
import { PlatformsCard } from "@/components/PlatformsCard";
import { Toast } from "@nous-research/ui/ui/components/toast";
import { Button } from "@nous-research/ui/ui/components/button";
import { Checkbox } from "@nous-research/ui/ui/components/checkbox";
import { ListItem } from "@nous-research/ui/ui/components/list-item";
import { Segmented } from "@nous-research/ui/ui/components/segmented";
import { Spinner } from "@nous-research/ui/ui/components/spinner";
@@ -275,14 +273,22 @@ function SessionRow({
snippet,
searchQuery,
isExpanded,
isSelected,
onToggle,
onSelectClick,
onDelete,
onRename,
onExport,
resumeInChatEnabled,
}: SessionRowProps) {
}: {
session: SessionInfo;
snippet?: string;
searchQuery?: string;
isExpanded: boolean;
onToggle: () => void;
onDelete: () => void;
onRename: (id: string, title: string) => Promise<void>;
onExport: (id: string) => void;
resumeInChatEnabled: boolean;
}) {
const [messages, setMessages] = useState<SessionMessage[] | null>(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
@@ -394,44 +400,18 @@ function SessionRow({
</>
);
// Selected rows get a stronger left-edge accent + tinted background so the
// selection state is unambiguous even when scrolling past the bulk-action
// bar at the top. Beat the is_active styling — explicit user selection
// takes priority over "this session is live".
const containerClasses = isSelected
? "border-primary/40 bg-primary/[0.06]"
: session.is_active
? "border-success/30 bg-success/[0.03]"
: "border-border";
// Clicking the checkbox must NOT toggle row expansion; selection and
// expansion are independent gestures. We bind ``onClick`` directly on
// the Checkbox (which Radix forwards to its underlying ``<button
// role=checkbox>``) so the event carries the real ``shiftKey`` state
// for range-select AND so keyboard activation (Space on the focused
// checkbox) toggles selection via the same code path — the browser
// synthesises a click on <button> for Space, so one handler covers
// mouse + keyboard cleanly.
const handleSelectClick = (e: React.MouseEvent) => {
e.stopPropagation();
onSelectClick(e);
};
return (
<div
className={`max-w-full min-w-0 overflow-hidden border transition-colors ${containerClasses}`}
className={`max-w-full min-w-0 overflow-hidden border transition-colors ${
session.is_active
? "border-success/30 bg-success/[0.03]"
: "border-border"
}`}
>
<div
className="flex cursor-pointer items-start gap-3 p-3 transition-colors hover:bg-secondary/30"
onClick={onToggle}
>
<span className="flex shrink-0 items-center pt-0.5">
<Checkbox
checked={isSelected}
onClick={handleSelectClick}
aria-label={t.sessions.selectSession}
/>
</span>
<div className={`shrink-0 pt-0.5 ${sourceInfo.color}`}>
<SourceIcon className="h-4 w-4" />
</div>
@@ -626,30 +606,6 @@ export default function SessionsPage() {
const [status, setStatus] = useState<StatusResponse | null>(null);
const [overviewSessions, setOverviewSessions] = useState<SessionInfo[]>([]);
const [view, setView] = useState<SessionsView>("overview");
// Count of empty (no-message, ended, non-archived) sessions across the
// entire DB, populated by /api/sessions/empty/count. Used to:
// • hide the "Delete empty" button when there's nothing to clean up
// • show "(N)" alongside the label
// • surface the count in the confirm dialog body
// Refreshed on mount, after single-session deletes, and after the bulk
// delete itself — none of those code paths can update the global empty
// count from local state alone (per-page list != global DB count).
const [emptyCount, setEmptyCount] = useState(0);
const [deleteEmptyOpen, setDeleteEmptyOpen] = useState(false);
const [deletingEmpty, setDeletingEmpty] = useState(false);
// Bulk-select-then-delete state. ``selectedIds`` is a Set so per-row
// checkbox toggles and ``has()`` lookups are O(1); we wrap mutations
// in a fresh Set so React notices the change (mutating in place
// wouldn't trigger a re-render).
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set());
// Index of the last row whose checkbox was clicked WITHOUT shift,
// resolved against the currently visible (post-search) ``filtered``
// list. Used as the anchor for shift-click range select — matches the
// Gmail / Notion / file-explorer convention. ``null`` means "no
// anchor yet", in which case shift-click degrades to a plain toggle.
const lastClickedIndexRef = useRef<number | null>(null);
const [deleteSelectedOpen, setDeleteSelectedOpen] = useState(false);
const [deletingSelected, setDeletingSelected] = useState(false);
const [stats, setStats] = useState<SessionStoreStats | null>(null);
const [pruneOpen, setPruneOpen] = useState(false);
const [pruneDays, setPruneDays] = useState("90");
@@ -660,18 +616,6 @@ export default function SessionsPage() {
const { activeAction, actionStatus, dismissLog } = useSystemActions();
const resumeInChatEnabled = isDashboardEmbeddedChatEnabled();
const refreshEmptyCount = useCallback(() => {
api
.getEmptySessionsCount()
.then((r) => setEmptyCount(r.count))
.catch(() => {});
}, []);
const clearSelection = useCallback(() => {
setSelectedIds(new Set());
lastClickedIndexRef.current = null;
}, []);
useLayoutEffect(() => {
if (loading) {
setAfterTitle(null);
@@ -729,8 +673,7 @@ export default function SessionsPage() {
useEffect(() => {
loadSessions(page);
refreshEmptyCount();
}, [loadSessions, page, refreshEmptyCount]);
}, [loadSessions, page]);
useEffect(() => {
const loadOverview = () => {
@@ -753,36 +696,6 @@ export default function SessionsPage() {
if (el) el.scrollTop = el.scrollHeight;
}, [actionStatus?.lines]);
// Wrapped setters that ALSO clear the bulk selection. The user's
// mental model is "I'm selecting what I can see" — carrying a
// selection across a page change, search input, or view switch
// would arm invisible rows for deletion, which is the exact footgun
// the confirm dialog can't catch. Doing this at the call sites
// instead of in a ``useEffect`` keeps us out of the
// react-hooks/set-state-in-effect lint trap and the cascading
// re-render it warns about.
const goToPage = useCallback(
(p: number) => {
setPage(p);
clearSelection();
},
[clearSelection],
);
const updateSearch = useCallback(
(value: string) => {
setSearch(value);
clearSelection();
},
[clearSelection],
);
const switchView = useCallback(
(next: SessionsView) => {
setView(next);
clearSelection();
},
[clearSelection],
);
// Debounced FTS search
useEffect(() => {
if (debounceRef.current) clearTimeout(debounceRef.current);
@@ -815,18 +728,6 @@ export default function SessionsPage() {
setSessions((prev) => prev.filter((s) => s.id !== id));
setTotal((prev) => prev - 1);
if (expandedId === id) setExpandedId(null);
// Drop the deleted ID from any active bulk-select set — it
// can't bulk-delete a row that's already gone.
setSelectedIds((prev) => {
if (!prev.has(id)) return prev;
const next = new Set(prev);
next.delete(id);
return next;
});
// A single-session delete might have been an empty one — re-fetch
// the global empty count so the button hides itself / its badge
// ticks down without waiting for the next page navigation.
refreshEmptyCount();
showToast(t.sessions.sessionDeleted, "success");
loadStats();
} catch {
@@ -836,7 +737,6 @@ export default function SessionsPage() {
},
[
expandedId,
refreshEmptyCount,
showToast,
loadStats,
t.sessions.sessionDeleted,
@@ -845,140 +745,6 @@ export default function SessionsPage() {
),
});
/** Toggle one row's selection. When ``event.shiftKey`` is true AND we
* have a previous anchor, every row between the anchor and the
* current index (inclusive) is set to the current row's NEW state
* matches Gmail/Notion/file-explorer semantics. ``visibleList`` must
* be the currently rendered list (post-search), since indices are
* resolved against what the user is actually looking at.
*/
const handleSelectClick = useCallback(
(event: React.MouseEvent, index: number, visibleList: SessionInfo[]) => {
const id = visibleList[index]?.id;
if (!id) return;
setSelectedIds((prev) => {
const next = new Set(prev);
const wasSelected = next.has(id);
const willSelect = !wasSelected;
const anchor = lastClickedIndexRef.current;
// Shift-click extends the selection from the anchor to here.
// Skip if there's no anchor or the anchor is outside the
// visible list — in those cases fall through to a plain toggle
// (the click also resets the anchor below).
if (event.shiftKey && anchor !== null && anchor < visibleList.length) {
const [lo, hi] =
anchor <= index ? [anchor, index] : [index, anchor];
for (let i = lo; i <= hi; i++) {
const rowId = visibleList[i]?.id;
if (!rowId) continue;
if (willSelect) next.add(rowId);
else next.delete(rowId);
}
} else if (willSelect) {
next.add(id);
} else {
next.delete(id);
}
return next;
});
// Always update the anchor to the most recent click — even when
// it was a shift-click that extended a range, the user's next
// shift-click should anchor from here, not from two steps back.
lastClickedIndexRef.current = index;
},
[],
);
const selectAllOnPage = useCallback((visibleList: SessionInfo[]) => {
setSelectedIds((prev) => {
const next = new Set(prev);
for (const s of visibleList) next.add(s.id);
return next;
});
}, []);
const handleDeleteSelected = useCallback(async () => {
const ids = Array.from(selectedIds);
if (ids.length === 0) {
setDeleteSelectedOpen(false);
return;
}
setDeletingSelected(true);
try {
const resp = await api.bulkDeleteSessions(ids);
showToast(
t.sessions.selectedSessionsDeleted.replace(
"{count}",
String(resp.deleted),
),
"success",
);
setDeleteSelectedOpen(false);
// Drop deleted rows out of the visible list immediately rather
// than waiting for the reload. The reload still runs so total /
// pagination stays correct, and so any rows the reload pulls in
// from later pages render in place.
const deletedSet = new Set(ids);
setSessions((prev) => prev.filter((s) => !deletedSet.has(s.id)));
setTotal((prev) => Math.max(0, prev - resp.deleted));
if (expandedId && deletedSet.has(expandedId)) setExpandedId(null);
clearSelection();
loadSessions(page);
refreshEmptyCount();
} catch {
showToast(t.sessions.failedToDeleteSelected, "error");
} finally {
setDeletingSelected(false);
}
}, [
clearSelection,
expandedId,
loadSessions,
page,
refreshEmptyCount,
selectedIds,
showToast,
t.sessions.failedToDeleteSelected,
t.sessions.selectedSessionsDeleted,
]);
const handleDeleteEmpty = useCallback(async () => {
setDeletingEmpty(true);
try {
const resp = await api.deleteEmptySessions();
// Show count in the toast so users get confirmation of the actual
// number removed (which may differ slightly from `emptyCount` if a
// session entered/left the "empty" set between the count fetch and
// the delete — e.g. an active session just ended without sending
// any messages).
showToast(
t.sessions.emptySessionsDeleted.replace(
"{count}",
String(resp.deleted),
),
"success",
);
setDeleteEmptyOpen(false);
// Reload the current page so any newly-vanished empty sessions
// drop out of the visible list, and re-fetch the empty count so
// the button hides itself.
loadSessions(page);
refreshEmptyCount();
} catch {
showToast(t.sessions.failedToDeleteEmpty, "error");
} finally {
setDeletingEmpty(false);
}
}, [
loadSessions,
page,
refreshEmptyCount,
showToast,
t.sessions.emptySessionsDeleted,
t.sessions.failedToDeleteEmpty,
]);
const handleRename = useCallback(
async (id: string, title: string) => {
try {
@@ -1132,33 +898,6 @@ export default function SessionsPage() {
loading={sessionDelete.isDeleting}
/>
<DeleteConfirmDialog
open={deleteEmptyOpen}
onCancel={() => setDeleteEmptyOpen(false)}
onConfirm={handleDeleteEmpty}
title={t.sessions.deleteEmptyConfirmTitle}
description={t.sessions.deleteEmptyConfirmMessage.replace(
"{count}",
String(emptyCount),
)}
loading={deletingEmpty}
/>
<DeleteConfirmDialog
open={deleteSelectedOpen}
onCancel={() => setDeleteSelectedOpen(false)}
onConfirm={handleDeleteSelected}
title={t.sessions.deleteSelectedConfirmTitle.replace(
"{count}",
String(selectedIds.size),
)}
description={t.sessions.deleteSelectedConfirmMessage.replace(
"{count}",
String(selectedIds.size),
)}
loading={deletingSelected}
/>
<Dialog
open={pruneOpen}
onOpenChange={(open) => {
@@ -1345,7 +1084,7 @@ export default function SessionsPage() {
className="w-fit shrink-0"
size="md"
value={view}
onChange={switchView}
onChange={setView}
options={[
{ value: "overview", label: t.sessions.overview },
{ value: "list", label: t.sessions.history },
@@ -1363,7 +1102,7 @@ export default function SessionsPage() {
<Input
placeholder={t.sessions.searchPlaceholder}
value={search}
onChange={(e) => updateSearch(e.target.value)}
onChange={(e) => setSearch(e.target.value)}
className="h-8 py-0 pr-7 pl-8 text-xs leading-none"
/>
{search && (
@@ -1371,7 +1110,7 @@ export default function SessionsPage() {
ghost
size="xs"
className="absolute right-1.5 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
onClick={() => updateSearch("")}
onClick={() => setSearch("")}
aria-label={t.common.clear}
>
<X />
@@ -1379,23 +1118,6 @@ export default function SessionsPage() {
)}
</div>
)}
{showList && emptyCount > 0 && !isSearching && (
<Button
outlined
destructive
size="sm"
className="shrink-0"
onClick={() => setDeleteEmptyOpen(true)}
aria-label={t.sessions.deleteEmpty}
title={t.sessions.deleteEmpty}
>
<Eraser className="h-3.5 w-3.5" />
<span className="font-mondwest normal-case text-xs">
{t.sessions.deleteEmpty} ({emptyCount})
</span>
</Button>
)}
</div>
{showPagination && (
@@ -1404,77 +1126,12 @@ export default function SessionsPage() {
className="shrink-0 sm:ml-auto"
page={page}
total={total}
onPageChange={goToPage}
onPageChange={setPage}
/>
)}
</div>
) : null}
{showList && selectedIds.size > 0 && (
<div
className="flex flex-wrap items-center gap-2 border border-primary/30 bg-primary/[0.06] px-3 py-2"
role="region"
aria-label={t.sessions.selectedCount.replace(
"{count}",
String(selectedIds.size),
)}
>
<span className="font-mondwest normal-case text-xs text-primary tabular-nums">
{t.sessions.selectedCount.replace(
"{count}",
String(selectedIds.size),
)}
</span>
{filtered.some((s) => !selectedIds.has(s.id)) && (
<Button
ghost
size="sm"
onClick={() => selectAllOnPage(filtered)}
aria-label={t.sessions.selectAllOnPage}
title={t.sessions.selectAllOnPage}
>
<span className="font-mondwest normal-case text-xs">
{t.sessions.selectAllOnPage}
</span>
</Button>
)}
<Button
ghost
size="sm"
onClick={clearSelection}
aria-label={t.sessions.clearSelection}
title={t.sessions.clearSelection}
>
<span className="font-mondwest normal-case text-xs">
{t.sessions.clearSelection}
</span>
</Button>
<Button
outlined
destructive
size="sm"
className="ml-auto"
onClick={() => setDeleteSelectedOpen(true)}
aria-label={t.sessions.deleteSelected.replace(
"{count}",
String(selectedIds.size),
)}
title={t.sessions.deleteSelected.replace(
"{count}",
String(selectedIds.size),
)}
>
<Trash2 className="h-3.5 w-3.5" />
<span className="font-mondwest normal-case text-xs">
{t.sessions.deleteSelected.replace(
"{count}",
String(selectedIds.size),
)}
</span>
</Button>
</div>
)}
{showList ? (
filtered.length === 0 ? (
<div className="flex flex-col items-center justify-center py-16 text-muted-foreground">
@@ -1491,20 +1148,16 @@ export default function SessionsPage() {
) : (
<>
<div className="flex min-w-0 flex-col gap-1.5">
{filtered.map((s, index) => (
{filtered.map((s) => (
<SessionRow
key={s.id}
session={s}
snippet={snippetMap.get(s.id)}
searchQuery={search || undefined}
isExpanded={expandedId === s.id}
isSelected={selectedIds.has(s.id)}
onToggle={() =>
setExpandedId((prev) => (prev === s.id ? null : s.id))
}
onSelectClick={(event) =>
handleSelectClick(event, index, filtered)
}
onDelete={() => sessionDelete.requestDelete(s.id)}
onRename={handleRename}
onExport={handleExport}
@@ -1517,7 +1170,7 @@ export default function SessionsPage() {
<SessionsPagination
page={page}
total={total}
onPageChange={goToPage}
onPageChange={setPage}
/>
)}
</>
@@ -1585,20 +1238,6 @@ export default function SessionsPage() {
);
}
interface SessionRowProps {
isExpanded: boolean;
isSelected: boolean;
onDelete: () => void;
onExport: (id: string) => void;
onRename: (id: string, title: string) => Promise<void>;
onSelectClick: (event: React.MouseEvent) => void;
onToggle: () => void;
resumeInChatEnabled: boolean;
searchQuery?: string;
session: SessionInfo;
snippet?: string;
}
interface SessionsPaginationProps {
className?: string;
compact?: boolean;
+30 -20
View File
@@ -1,5 +1,4 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { Link } from "react-router-dom";
import {
Activity,
Brain,
@@ -22,12 +21,12 @@ import {
} from "lucide-react";
import { Badge } from "@nous-research/ui/ui/components/badge";
import { Button } from "@nous-research/ui/ui/components/button";
import { Select, SelectOption } from "@nous-research/ui/ui/components/select";
import { Spinner } from "@nous-research/ui/ui/components/spinner";
import { H2 } from "@nous-research/ui/ui/components/typography/h2";
import { Card, CardContent } from "@nous-research/ui/ui/components/card";
import { Input } from "@nous-research/ui/ui/components/input";
import { Label } from "@nous-research/ui/ui/components/label";
import { Select, SelectOption } from "@nous-research/ui/ui/components/select";
import { Toast } from "@nous-research/ui/ui/components/toast";
import { useToast } from "@nous-research/ui/hooks/use-toast";
import { useConfirmDelete } from "@nous-research/ui/hooks/use-confirm-delete";
@@ -237,9 +236,16 @@ export default function SystemPage() {
};
// ── Memory ─────────────────────────────────────────────────────────
// Memory provider selection lives on the /plugins page now (see the
// read-only display + link below); the dropdown was intentionally
// dropped from this card during the admin-panel refresh.
const setMemoryProvider = async (provider: string) => {
try {
await api.setMemoryProvider(provider);
showToast(`Memory provider: ${provider || "built-in only"}`, "success");
loadAll();
} catch (e) {
showToast(`Failed to set provider: ${e}`, "error");
}
};
const memoryReset = useConfirmDelete({
onDelete: useCallback(
async (target: string) => {
@@ -742,22 +748,26 @@ export default function SystemPage() {
</H2>
<Card>
<CardContent className="flex flex-col gap-4 py-4">
<div className="flex flex-wrap items-center gap-x-3 gap-y-1 text-xs text-muted-foreground">
<span>
External provider:{" "}
<span className="font-mono text-foreground">
{memory?.active || "built-in only"}
</span>
</span>
<Link to="/plugins" className="underline">
Change in Plugins
</Link>
<span className="ml-auto">
New credentials:{" "}
<span className="font-mono">hermes memory setup</span>
</span>
<div className="grid gap-2 max-w-sm">
<Label htmlFor="mem-provider">External provider</Label>
<Select
id="mem-provider"
value={memory?.active || ""}
onValueChange={setMemoryProvider}
>
<SelectOption value="">Built-in only</SelectOption>
{(memory?.providers ?? []).map((p) => (
<SelectOption key={p.name} value={p.name}>
{p.name}
{p.configured ? " (configured)" : ""}
</SelectOption>
))}
</Select>
<p className="text-xs text-muted-foreground">
Set up a new provider's credentials with{" "}
<span className="font-mono">hermes memory setup</span>.
</p>
</div>
<div className="flex flex-wrap items-center gap-3 border-t border-border pt-3">
<span className="text-xs text-muted-foreground">
Built-in files MEMORY.md:{" "}
+4 -67
View File
@@ -19,7 +19,6 @@ import type {
ThemeLayoutVariant,
ThemeListEntry,
ThemePalette,
ThemeSeriesColors,
ThemeTypography,
} from "./types";
import { api } from "@/lib/api";
@@ -28,21 +27,6 @@ import { api } from "@/lib/api";
* a visible flash of the default palette on theme-overridden installs. */
const STORAGE_KEY = "hermes-dashboard-theme";
/** Renames of built-in theme keys we've shipped previously. Without this,
* users who saved one of the old names in localStorage (or had it
* persisted server-side) would silently fall back to `defaultTheme`
* because the lookup in `resolveTheme` no longer finds the stale key.
* Keep entries here until enough release cycles have passed that we can
* reasonably assume nobody still has the old value persisted. */
const THEME_NAME_ALIASES: Record<string, string> = {
// Renamed during the LENS_5I port + Nous-blue rebrand.
"lens-5i": "nous-blue",
};
function migrateThemeName(name: string): string {
return THEME_NAME_ALIASES[name] ?? name;
}
/** Tracks fontUrls we've already injected so multiple theme switches don't
* pile up <link> tags. Keyed by URL. */
const INJECTED_FONT_URLS = new Set<string>();
@@ -142,30 +126,6 @@ function overrideVars(
return out;
}
/** Map data-series accents to their CSS vars. Themes omit either field to
* inherit the `:root` default from `index.css`; when omitted we also
* proactively clear any leftover value from a previous theme so switches
* don't carry stale colors. */
const SERIES_KEY_TO_VAR: Record<keyof ThemeSeriesColors, string> = {
inputTokenAccent: "--series-input-token",
outputTokenAccent: "--series-output-token",
};
const ALL_SERIES_VARS = Object.values(SERIES_KEY_TO_VAR);
function seriesColorVars(
series: ThemeSeriesColors | undefined,
): Record<string, string> {
if (!series) return {};
const out: Record<string, string> = {};
for (const [key, value] of Object.entries(series)) {
if (!value) continue;
const cssVar = SERIES_KEY_TO_VAR[key as keyof ThemeSeriesColors];
if (cssVar) out[cssVar] = value;
}
return out;
}
// ---------------------------------------------------------------------------
// Asset + component-style + layout variant vars
// ---------------------------------------------------------------------------
@@ -308,12 +268,6 @@ function applyTheme(theme: DashboardTheme) {
for (const cssVar of ALL_OVERRIDE_VARS) {
root.style.removeProperty(cssVar);
}
// Same clear-then-set for series colors so a theme that defines them
// (e.g. Nous Blue) doesn't leave its values behind when the user
// switches to a theme that inherits the `:root` defaults.
for (const cssVar of ALL_SERIES_VARS) {
root.style.removeProperty(cssVar);
}
// Clear dynamic (asset/component) vars from the previous theme so the
// new one starts clean — otherwise stale notched clip-paths, hero URLs,
// etc. would bleed across theme switches.
@@ -333,7 +287,6 @@ function applyTheme(theme: DashboardTheme) {
...typographyVars(theme.typography),
...layoutVars(theme.layout),
...overrideVars(theme.colorOverrides),
...seriesColorVars(theme.seriesColors),
...assetMap,
...componentMap,
};
@@ -360,14 +313,7 @@ export function ThemeProvider({ children }: { children: ReactNode }) {
/** Name of the currently active theme (built-in id or user YAML name). */
const [themeName, setThemeName] = useState<string>(() => {
if (typeof window === "undefined") return "default";
const stored = window.localStorage.getItem(STORAGE_KEY) ?? "default";
const migrated = migrateThemeName(stored);
// Write the migrated name back so future reads converge on the new
// key and we eventually retire the alias entry.
if (migrated !== stored) {
window.localStorage.setItem(STORAGE_KEY, migrated);
}
return migrated;
return window.localStorage.getItem(STORAGE_KEY) ?? "default";
});
/** All selectable themes (shown in the picker). Starts with just the
@@ -431,18 +377,9 @@ export function ThemeProvider({ children }: { children: ReactNode }) {
}
if (Object.keys(defs).length > 0) setUserThemeDefs(defs);
}
if (resp.active) {
const migratedActive = migrateThemeName(resp.active);
if (migratedActive !== themeName) {
setThemeName(migratedActive);
window.localStorage.setItem(STORAGE_KEY, migratedActive);
}
// If the server is still persisting the stale key, push the
// migrated value back so it converges too — otherwise every
// future page load would re-trigger this branch.
if (migratedActive !== resp.active) {
api.setTheme(migratedActive).catch(() => {});
}
if (resp.active && resp.active !== themeName) {
setThemeName(resp.active);
window.localStorage.setItem(STORAGE_KEY, resp.active);
}
})
.catch(() => {});
-95
View File
@@ -184,100 +184,6 @@ export const roseTheme: DashboardTheme = {
},
};
/**
* Nous Blue the inverted "light mode" Hermes look, ported from the
* LENS_5I overlay preset in `@nous-research/ui`.
*
* Unlike the other built-ins (which paint dark color directly on the
* canvas), this theme relies on `<Backdrop />`'s foreground inversion
* layer: an opaque white sheet at z-200 with `mix-blend-mode: difference`
* that flips the entire stack below it. Authoring colors stay dark
* (`#170d02` brown background, `#FFAC02` orange midground), and the
* inversion converts them to their visual complements at paint time
* the orange midground reads as #0053FD Nous-blue on screen, against a
* cream `#E8F2FD` canvas.
*
* Note on bg blend mode: the DS Lens uses `multiply` for LENS_5I because
* nousnet-web's <body> is white; hermes-agent's App root is `bg-black`,
* so we leave the bg layer's blend mode at the `difference` default
* `difference(#170d02, #000)` passes the bg through unchanged, and the
* subsequent FG-difference layer then inverts it to cream. Using
* `multiply` here would collapse the bg to pure black against the
* `bg-black` root and produce a plain-white canvas instead of the
* intended cream-blue.
*
* Source of truth for the palette: `design-language/src/ui/components/
* overlays/lens.ts` (LENS_5I export).
*/
export const nousBlueTheme: DashboardTheme = {
name: "nous-blue",
label: "Nous Blue",
description: "Light mode — vivid Nous-blue accents on cream canvas",
palette: {
background: { hex: "#170d02", alpha: 1 },
midground: { hex: "#FFAC02", alpha: 1 },
foreground: { hex: "#FFFFFF", alpha: 1 },
// Same warm-amber as nousnet-web's overlay glow; after the FG
// inversion it reads as a cool ultraviolet vignette in the top-left.
warmGlow: "rgba(255, 172, 2, 0.18)",
// Noise sits above the FG inversion and is NOT flipped, so a softer
// multiplier keeps it from speckling over the bright post-inversion
// canvas.
noiseOpacity: 0.4,
},
typography: DEFAULT_TYPOGRAPHY,
layout: DEFAULT_LAYOUT,
// Inverted page: the embedded terminal is below the FG layer too, so
// a `#000000` source paints as visual white — i.e. a proper light-mode
// terminal pane. xterm picks lighter palette colors against the "black"
// canvas, which then read as dark text on screen post-inversion.
terminalBackground: "#000000",
componentStyles: {
backdrop: {
// Lower than LENS_5I.Lens.fillerOpacity (0.06). The filler texture
// gets amplified post-inversion: small variations against the deep
// `#170d02` source bg are barely visible, but those same variations
// against the bright `#E8F2FD` post-inversion canvas read as a
// heavy cloud/marble pattern — especially on near-empty pages
// (loading spinners, blank states). 0.02 keeps subtle grain
// without overwhelming the canvas.
fillerOpacity: "0.02",
},
},
// Pre-invert absolute-hex tokens so they read as their familiar colors
// through the FG difference layer. e.g. source #04D3C9 (cyan) is what
// gets painted, and `255 - channel` flips it to #FB2C36 (red) on screen.
// Without these, the default destructive/success/warning tokens would
// appear as their unintuitive complements.
colorOverrides: {
destructive: "#04d3c9",
destructiveForeground: "#000000",
success: "#b5217f",
warning: "#0042c7",
},
// Pre-inverted data-series accents for the Analytics/Models token
// charts. The defaults (#ffe6cb cream + #34d399 emerald) would render
// through the FG difference layer as dark navy + hot-coral on the
// bright Nous-blue canvas — the coral is the "red" users see for
// Output values without these overrides. Source → on-screen:
// Input: #ffe6cb → #001934 (dark navy) ← unchanged
// Output: #ffac02 → #0053fd (vivid Nous-blue) ← brand accent
// Input keeps the cream source so it stays a neutral, low-contrast
// dark-blue against the cream canvas; output paints as the brand
// Nous-blue so the "primary" series in token-flow charts reads as
// the highlight color, matching the rest of the inverted UI chrome.
seriesColors: {
inputTokenAccent: "#ffe6cb",
outputTokenAccent: "#ffac02",
},
// Explicit picker swatch — the raw palette hex (`#170d02`, `#FFAC02`,
// amber rgba) doesn't reflect what users see after the FG inversion,
// so we paint the post-inversion visual triplet directly:
// white → vivid Nous-blue → cream/light-blue
// matching the actual on-screen rendering of the theme.
swatchColors: ["#FFFFFF", "#0053FD", "#E8F2FD"],
};
/**
* Same look as ``defaultTheme`` but with a larger root font size, looser
* line-height, and ``spacious`` density so every rem-based size in the
@@ -302,7 +208,6 @@ export const defaultLargeTheme: DashboardTheme = {
export const BUILTIN_THEMES: Record<string, DashboardTheme> = {
default: defaultTheme,
"default-large": defaultLargeTheme,
"nous-blue": nousBlueTheme,
midnight: midnightTheme,
ember: emberTheme,
mono: monoTheme,
-28
View File
@@ -119,25 +119,6 @@ export interface ThemeComponentStyles {
page?: Record<string, string>;
}
/** Data-series accent colors for chart + table visualisations (Analytics,
* Models, etc.). Themes provide hex strings; the provider emits them as
* `--series-input-token` / `--series-output-token` CSS vars consumed
* inline by pages that render input-vs-output token flows. Themes can
* omit either field to inherit the default token defined in
* `index.css` (Hermes-teal `#ffe6cb` for input, `#34d399` for output).
*
* Inverted-lens themes (e.g. Nous Blue) must pre-invert these hex
* values so they read as their intended visual color after the FG
* difference layer flips them (`out = 255 channel`). E.g. to make
* output paint as Nous-blue `#0053FD` on screen, set
* `outputTokenAccent: "#FFAC02"` the difference math reverses it. */
export interface ThemeSeriesColors {
/** Input-tokens series accent (Analytics chart bars + table values). */
inputTokenAccent?: string;
/** Output-tokens series accent. */
outputTokenAccent?: string;
}
/** Optional hex overrides keyed by shadcn-compat token name (without the
* `--color-` prefix). Any key set here wins over the DS cascade. */
export interface ThemeColorOverrides {
@@ -181,15 +162,6 @@ export interface DashboardTheme {
/** Per-component CSS-var overrides. See `ThemeComponentStyles`. */
componentStyles?: ThemeComponentStyles;
colorOverrides?: ThemeColorOverrides;
/** Data-series accent colors for Analytics/Models token charts.
* See `ThemeSeriesColors` for inversion-aware values. */
seriesColors?: ThemeSeriesColors;
/** Explicit 3-color swatch override for the theme picker. Use when the
* palette's raw hex values don't reflect what users see on screen
* e.g. inverted "lens" themes whose foreground-difference layer flips
* the authored colors to their visual complements. Order matches the
* default swatch cells: [background, midground, warmGlow]. */
swatchColors?: [string, string, string];
/** Background color for the embedded terminal pane (xterm.js).
* Hex string. Defaults to `"#000000"` when absent. */
terminalBackground?: string;

Some files were not shown because too many files have changed in this diff Show More