fix: batch remaining workspace bugfix slices (#483)

* chore: preserve pnpm approved builds

* fix(router): support runtime basepath override for reverse-proxy hosting

The TanStack router was created without a `basepath` option, so the same
built bundle could not be hosted under a path prefix (e.g. behind a
reverse proxy that mounts the app at `/workspaces/<id>/`). Hard refreshes
appeared to work because SSR runs at the proxy-stripped path, but
client-side navigation to dynamic routes such as `/chat/$sessionKey`
silently fell through to the catch-all `/$` route — rendering the
"404 — Not Found" page from inside the SPA.

Read an optional `window.__HERMES_WORKSPACE_BASEPATH__` global and pass
it through to `createRouter`. When unset, behavior is unchanged
(`basepath: '/'`). The value is normalized so callers can pass either
`/workspaces/abc`, `workspaces/abc`, or `/workspaces/abc/` without
upsetting TanStack's pathname matching.

This lets hosting layers inject a tiny inline script before the bundle
loads to mount the app at any path, without rebuilding.

* fix(chat): prevent stale thinking state after page refresh (closes #449)

Root cause: sessionStorage 'waiting' flags persisted across page refreshes
even for completed conversations. The Zustand store restored these stale
entries on mount, and the active-run API check cleared them async —
but there was a visible render window where the UI showed 'thinking'.

Fix:
1. Added activeRunCheckDone state that gates the waitingForResponse memo.
   While the active-run API check is pending, stale restored state is
   not trusted — the thinking indicator stays hidden until verification.
2. Added onCheckComplete callback to useActiveRunCheck hook that fires
   after the API check finishes (success or error), unblocking the gate.
3. Added a useEffect that detects restored stale waiting state and sets
   pendingVerifySessionKeyRef so the gate only applies to the key that
   needs verification — not to genuine active streams.

Test: e2e/chat-thinking-state.spec.ts injects a stale sessionStorage
entry before page load, then verifies no thinking indicator appears
and the stale entry is cleaned up by the API check.

* fix(chat): eliminate duplicate messages flicker on stream completion (closes #441)

Root cause: onDone handler used queryClient.invalidateQueries() which
triggers an async refetch. During the refetch window, mergeHistoryMessages
ran with stale cache data + realtime buffer, producing visible duplicates
(extra user message + blank line) for 1-2 seconds until refetch completed.

Fix: Directly merge realtime buffer into history cache via setQueryData(),
then clear buffer synchronously. Background refetch runs after for
consistency but doesn't block rendering.

* fix: restore hermes-config and config-patch API routes

The Aurora rename migration (efcb7d14) renamed hermes-config.ts to
claude-config.ts, but the frontend and routeTree.gen.ts still reference
the original paths. This caused all /api/hermes-config and /api/config-patch
requests to fall through to the SPA HTML fallback, breaking config saves
from the settings dialog and provider wizard with 'Failed to save' errors.

Restored by creating thin route files that delegate to the existing
handleHermesConfigGet/handleHermesConfigPatch handlers from
src/server/hermes-config-route.ts.

Fixes the settings dialog (hermes-config GET/PATCH) and provider wizard
(config-patch POST) config save flows.

* fix(server): add essential env vars to terminal session

* fix(swarm): worker card shows stale state after task completes

deriveWorkerState derived the badge from currentTask title substring
matching and markCheckpointResult never cleared currentTask on terminal
checkpoints, so a finished worker's card stayed permanently 'working'.

- swarm-dispatch.ts: clear currentTask on terminal checkpoint
  (checkpointStatus !== 'in_progress'), matching conductor-stop's reset
- operational-worker-card.tsx: deriveWorkerState reads authoritative
  checkpointStatus/state first, title heuristic only while in_progress
- swarm2-screen.tsx: pass checkpointStatus/state into the card

* fix(portable-history): replay authenticated portable chat history

* fix(config): keep legacy claude-config shim on shared handlers

* fix: harden splash hydration and docker uid mapping

* fix: keep seen update notes dismissed

* feat: consolidate workspace state under configurable state directory (closes #439)

Adds HERMES_WORKSPACE_STATE_DIR env var support, consolidating 5
scattered state files under a single configurable directory.

Changes:
- New src/server/workspace-state-dir.ts with getStateDir() utility
  honoring HERMES_WORKSPACE_STATE_DIR → HERMES_HOME/workspace →
  CLAUDE_HOME/workspace → ~/.hermes/workspace (fallback chain)
- Updated gateway-capabilities.ts (workspace-overrides.json)
- Updated mcp-presets-store.ts (mcp-presets.json)
- Updated mcp-hub-sources-store.ts (mcp-hub-sources.json)
- Updated mcp-tools-cache.ts (cache/mcp-tools.json)
- Updated knowledge-config.ts (knowledge-config.json)
- Removed 5 duplicated hermesHome() functions, replaced with shared
  getStateDir() import

Test: 6 vitest unit tests covering all env var priority combinations
(cherry picked from commit d6bebe0614b0c7b9015bac5e35d315a8450ac146)

* fix(conductor): surface native-swarm progress and harden worker startup

* feat(chat): safely render HTML message markup

* fix(chat): surface installed skills in slash autocomplete

* fix: add swarm runtime reset endpoint

* fix(conductor): mobile rendering — add overflow-y-auto, mobile bottom padding, OfficeView responsive height, tabbar fix

* fix(send-stream): preserve runs on client disconnect

* fix(profiles): skip profiles/default duplicate card

* fix: accept HERMES_AGENT_PATH override

* fix(profiles): allow disabling sticky active_profile writes

* fix: preserve workspace chat session routing

* fix(portable-history): skip replay when gateway session continuity is available

---------

Co-authored-by: Hermes Agent <hermes-agent@local.invalid>
Co-authored-by: jack <jack@hijak.dev>
Co-authored-by: Waylon Kenning <waylonkenning@Waylons-MacBook-Pro.local>
Co-authored-by: Michael Rodriguez <michael@rivercity-industries.com>
Co-authored-by: Vu Tran <baysao@gmail.com>
Co-authored-by: iltaek <iltaekkwon@gmail.com>
Co-authored-by: Aurora release bot <release@outsourc-e.com>
Co-authored-by: jonathanmalkin <jonathan.d.malkin@gmail.com>
Co-authored-by: KT-Hermes <ktadmin@kt-bot2.tekeis.net>
This commit is contained in:
Eric
2026-05-19 16:27:10 -04:00
committed by GitHub
parent e1470084d2
commit 4f75b5835c
62 changed files with 1937 additions and 569 deletions

View File

@@ -9,6 +9,7 @@
# Or pull pre-built:
# docker pull ghcr.io/outsourc-e/hermes-workspace:latest
#
FROM tianon/gosu:1.19-bookworm AS gosu_source
# ─── build stage ─────────────────────────────────────────────────────────
FROM node:22-slim AS build
RUN corepack enable && apt-get update && apt-get install -y --no-install-recommends ca-certificates && rm -rf /var/lib/apt/lists/*
@@ -32,6 +33,8 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
&& rm -rf /var/lib/apt/lists/* \
&& groupadd -r workspace && useradd -r -g workspace -u 10010 -m workspace
COPY --from=gosu_source /gosu /usr/local/bin/gosu
WORKDIR /app
# Copy build artefacts + runtime deps.
@@ -44,8 +47,8 @@ COPY --from=build --chown=workspace:workspace /app/node_modules ./node_modules
COPY --from=build --chown=workspace:workspace /app/package.json ./package.json
COPY --from=build --chown=workspace:workspace /app/server-entry.js ./server-entry.js
COPY --from=build --chown=workspace:workspace /app/skills ./skills
COPY --chown=workspace:workspace docker/entrypoint.sh /usr/local/bin/docker-entrypoint.sh
USER workspace
ENV NODE_ENV=production \
PORT=3000 \
HOST=0.0.0.0 \
@@ -55,5 +58,5 @@ EXPOSE 3000
HEALTHCHECK --interval=30s --timeout=5s --start-period=20s --retries=3 \
CMD curl -fsS http://127.0.0.1:3000/ >/dev/null || exit 1
ENTRYPOINT ["/usr/bin/tini", "--"]
ENTRYPOINT ["/usr/bin/tini", "--", "/usr/local/bin/docker-entrypoint.sh"]
CMD ["node", "--max-old-space-size=2048", "server-entry.js"]

48
docker/entrypoint.sh Executable file
View File

@@ -0,0 +1,48 @@
#!/bin/bash
set -e
WORKSPACE_USER=workspace
WORKSPACE_GROUP=workspace
WORKSPACE_HOME="$(getent passwd "$WORKSPACE_USER" | cut -d: -f6)"
TARGET_UID="${HERMES_UID:-}"
TARGET_GID="${HERMES_GID:-}"
fix_owner_if_needed() {
local path="$1"
if [ ! -e "$path" ]; then
return
fi
local actual_uid
actual_uid=$(id -u "$WORKSPACE_USER")
local current_uid
current_uid=$(stat -c %u "$path" 2>/dev/null || true)
if [ -n "$current_uid" ] && [ "$current_uid" != "$actual_uid" ]; then
chown -R "$WORKSPACE_USER:$WORKSPACE_GROUP" "$path" 2>/dev/null || \
echo "Warning: chown failed for $path (rootless container or restricted mount?) — continuing anyway"
fi
}
if [ "$(id -u)" = "0" ]; then
current_uid=$(id -u "$WORKSPACE_USER")
current_gid=$(id -g "$WORKSPACE_USER")
if [ -n "$TARGET_GID" ] && [ "$TARGET_GID" != "$current_gid" ]; then
echo "Changing workspace GID to $TARGET_GID"
groupmod -o -g "$TARGET_GID" "$WORKSPACE_GROUP" 2>/dev/null || true
fi
if [ -n "$TARGET_UID" ] && [ "$TARGET_UID" != "$current_uid" ]; then
echo "Changing workspace UID to $TARGET_UID"
usermod -o -u "$TARGET_UID" "$WORKSPACE_USER"
fi
mkdir -p "$WORKSPACE_HOME/.hermes" /workspace
fix_owner_if_needed "$WORKSPACE_HOME"
fix_owner_if_needed /workspace
echo "Dropping root privileges"
exec gosu "$WORKSPACE_USER:$WORKSPACE_GROUP" "$0" "$@"
fi
exec "$@"

View File

@@ -0,0 +1,98 @@
# Workspace Chat Session Routing
## Purpose
Hermes Workspace supports a portable chat path through OpenAI-compatible `/v1/chat/completions`. In this mode, the browser route alone is not enough to preserve conversational context: Workspace must forward a stable server-side session identifier to the Hermes Agent gateway.
This document records the routing contract and the failure mode that caused related turns and attachments to be stored as separate `api-*` sessions.
## Routing Contract
There are two distinct header layers:
| Layer | Headers | Purpose |
| --- | --- | --- |
| Workspace UI route resolution | `X-Hermes-Session-Key`, `X-Hermes-Friendly-Id` | Tells the browser which Workspace chat route/friendly ID is resolved for the visible conversation. |
| Hermes Agent gateway continuation | `X-Hermes-Session-Id`, `X-Claude-Session-Id` | Tells the gateway which server-side Hermes session should receive the next chat completion request. |
Do not conflate these. A response can correctly resolve a Workspace route while the next gateway request still loses server-side context if `X-Hermes-Session-Id` is missing.
## Portable OpenAI-Compatible Flow
1. `src/routes/api/send-stream.ts` receives `sessionKey`, `friendlyId`, `message`, `history`, and optional `attachments` from the UI.
2. It resolves a persistent Workspace `sessionKey`.
3. It builds OpenAI-compatible messages, including multimodal image parts when attachments are present.
4. It calls `openaiChat(..., { sessionId: portableSessionKey })`.
5. `src/server/openai-compat-api.ts` forwards that session ID to the gateway via:
- `X-Hermes-Session-Id`
- `X-Claude-Session-Id` as a legacy/back-compat alias.
6. Hermes Agent uses the provided session ID for continuity instead of deriving a fresh deterministic `api-*` session from the request payload.
## Failure Mode
The bug was coupling session-continuity headers to bearer-token presence:
```ts
if (options.sessionId && bearer) {
headers['X-Hermes-Session-Id'] = options.sessionId
headers['X-Claude-Session-Id'] = options.sessionId
}
```
That made routing depend on auth configuration. If a bearer token was unavailable or not used, Workspace still had a local session key, but the gateway never received it. The gateway then derived sessions such as `api-*` from request content, which could split related turns and attachment-only/image requests across separate API sessions.
## Correct Behavior
Session routing is independent of whether a bearer token is configured. If the gateway requires auth, its auth check enforces the bearer token separately.
```ts
const bearer = getBearerToken()
if (bearer) {
headers['Authorization'] = `Bearer ${bearer}`
}
if (options.sessionId) {
headers['X-Hermes-Session-Id'] = options.sessionId
headers['X-Claude-Session-Id'] = options.sessionId
}
```
## Regression Coverage
`src/server/openai-compat-api.test.ts` should cover both cases:
- session headers are sent when a bearer token is present
- session headers are still sent when no bearer token is present
`src/server/chat-backends.ts` should forward `options.sessionId` into `openaiChat(...)` for both streaming and non-streaming OpenAI-compatible calls.
## Manual Verification Recipe
1. Run the targeted test:
```bash
pnpm vitest run src/server/openai-compat-api.test.ts
```
2. Build production assets:
```bash
pnpm build
```
3. Restart Workspace where deployed:
```bash
systemctl --user restart hermes-workspace.service
systemctl --user is-active hermes-workspace.service
```
4. Send two `/api/send-stream` turns with the same `sessionKey` and a unique token in the first prompt.
5. Search session history for that token. Both turns should appear under the same `session_id` equal to the supplied Workspace session key, not separate `api-*` sessions.
6. Send an image attachment with the same `sessionKey`; session history should show `[screenshot]` in that same session.
## Operational Notes
- Keep credentials redacted when inspecting `.env`, service files, or built bundles.
- In zero-fork deployments, Workspace commonly talks to Hermes Agent gateway on `127.0.0.1:8642` and Dashboard on `127.0.0.1:9119`.
- A successful `/health` probe means the gateway is reachable; it does not prove session continuity is wired correctly. Verify the actual chat path.

View File

@@ -0,0 +1,50 @@
import { test, expect } from '@playwright/test'
test.describe('Chat UI flicker #441', () => {
test('chat messages should not contain duplicates after stream completion', async ({ page }) => {
// Navigate to the chat page
await page.goto('/chat')
await page.waitForLoadState('load')
// Dismiss the "Hermes updated" modal if present
const continueBtn = page.getByRole('button', { name: 'Continue' })
if (await continueBtn.isVisible({ timeout: 5000 }).catch(() => false)) {
await continueBtn.click()
}
// Wait for sessions to load in the sidebar
await page.waitForTimeout(3000)
// Click on an existing session from the sidebar
const sessionLink = page.locator('a[href*="/chat/20"]').first()
if (await sessionLink.isVisible({ timeout: 10000 }).catch(() => false)) {
await sessionLink.click()
}
// Wait for the session to load and messages to render
await page.waitForTimeout(5000)
// Look for message-like elements. The chat uses data attributes
// Try a few approaches to find message bubbles
const messageElements = page.locator('.message, [role="listitem"], [data-message-id], [class*="message"]')
const msgCount = await messageElements.count()
if (msgCount > 0) {
console.log(`Found ${msgCount} message elements`)
}
// VERIFY: Page rendered without error — no error states visible
const errorState = page.getByRole('alert')
const hasError = await errorState.isVisible({ timeout: 1000 }).catch(() => false)
expect(hasError).toBe(false)
// VERIFY: No "generating" or "thinking" state showing
const producingState = page.locator('text=/generating|waiting for response|Generating/i')
const producingCount = await producingState.count()
expect(producingCount).toBe(0)
// VERIFY: The chat input is visible (page is functional)
const chatInput = page.locator('textarea, [contenteditable="true"]').first()
await expect(chatInput).toBeVisible({ timeout: 5000 })
})
})

View File

@@ -0,0 +1,53 @@
import { test, expect } from '@playwright/test'
test.describe('Chat thinking state #449', () => {
test('should not show stale thinking state after page refresh for completed session', async ({ page }) => {
// This test simulates the exact bug scenario described in Issue #449:
// User had a conversation, the stream completed (clearing waiting state),
// page refreshes, and the assistant briefly shows "thinking" state.
// Use an existing session that has completed messages
const SESSION_PATH = '/chat/20260515_150106_4be3a000'
// Inject a stale waiting entry for THIS session before the page loads
await page.addInitScript((sessionKey) => {
window.sessionStorage.setItem(
`claude_waiting_${sessionKey}`,
JSON.stringify({
since: Date.now() - 30000, // 30s ago — within the 120s TTL
runId: 'stale-run-id',
}),
)
}, SESSION_PATH.replace('/chat/', ''))
// Navigate directly to the session
await page.goto(SESSION_PATH)
await page.waitForLoadState('load')
// Dismiss the "Hermes updated" modal if present
const continueBtn = page.getByRole('button', { name: 'Continue' })
if (await continueBtn.isVisible({ timeout: 5000 }).catch(() => false)) {
await continueBtn.click()
}
// Wait for app rehydration, Zustand store init, sessionStorage restore,
// and the active-run API check to complete
await page.waitForTimeout(5000)
// VERIFY: No thinking indicator is visible after page refresh.
// The stale sessionStorage entry should have been cleared by the
// active-run API check, and the fix gates thinking on that check.
const thinkingIndicator = page.locator(
'[data-testid="thinking-indicator"], [aria-label="Assistant thinking"], .thinking-indicator, [data-thinking="true"]',
)
const thinkingCount = await thinkingIndicator.count()
expect(thinkingCount).toBe(0)
// VERIFY: The stale sessionStorage entry was cleaned up
const staleKey = SESSION_PATH.replace('/chat/', '')
const hasStaleEntry = await page.evaluate((key) => {
return window.sessionStorage.getItem(`claude_waiting_${key}`) !== null
}, staleKey)
expect(hasStaleEntry).toBe(false)
})
})

View File

@@ -0,0 +1,103 @@
import { test, expect } from '@playwright/test'
const BASE = process.env.HERMES_WORKSPACE_URL || 'http://localhost:3002'
test.describe('Conductor mobile rendering', () => {
test.use({
viewport: { width: 375, height: 667 }, // iPhone SE
})
test('conductor home page renders without clipping on mobile', async ({ page }) => {
await page.goto(`${BASE}/conductor`)
await page.waitForTimeout(2000)
// Check that the main container is present
const main = page.locator('main')
await expect(main.first()).toBeVisible()
// Verify the page is scrollable — bottom content should be reachable
const scrollHeight = await page.evaluate(() => document.documentElement.scrollHeight)
const clientHeight = await page.evaluate(() => document.documentElement.clientHeight)
expect(scrollHeight).toBeGreaterThanOrEqual(clientHeight)
// Check that the Conductor badge or title is visible
const pageText = await page.locator('body').innerText()
expect(pageText).toContain('Conductor')
// Scroll to the very bottom
await page.evaluate(() => window.scrollTo(0, document.documentElement.scrollHeight))
await page.waitForTimeout(500)
// Verify no content is cut off — the last visible element should not be flush
// with the bottom of the viewport
const bottomElement = await page.evaluate(() => {
const body = document.body
const bodyRect = body.getBoundingClientRect()
return bodyRect.bottom
})
// body bottom should be within the document (not clipped off-screen)
expect(bottomElement).toBeGreaterThan(0)
})
test('conductor page has no horizontal overflow on mobile', async ({ page }) => {
await page.goto(`${BASE}/conductor`)
await page.waitForTimeout(2000)
// Check for horizontal overflow
const hasHorizontalOverflow = await page.evaluate(() => {
return document.documentElement.scrollWidth > document.documentElement.clientWidth
})
expect(hasHorizontalOverflow).toBe(false)
})
test('conductor action buttons are present on mobile', async ({ page }) => {
await page.goto(`${BASE}/conductor`)
await page.waitForTimeout(2000)
// Check for action buttons — they should be visible and clickable
const buttons = page.locator('button')
const buttonCount = await buttons.count()
expect(buttonCount).toBeGreaterThan(0)
})
test('conductor main container has proper bottom padding on mobile', async ({ page }) => {
await page.goto(`${BASE}/conductor`)
await page.waitForTimeout(2000)
// Check the bottom padding of main elements
const bottomPadding = await page.evaluate(() => {
const mains = document.querySelectorAll('main')
if (mains.length === 0) return -1
// Get computed padding-bottom from the last main (the conductor one)
const style = window.getComputedStyle(mains[mains.length - 1])
return parseInt(style.paddingBottom, 10) || 0
})
// Bottom padding must exist (not 0) to prevent content from being flush with tab bar
expect(bottomPadding).toBeGreaterThanOrEqual(4)
})
test('conductor page body fills full viewport height without clipping at bottom', async ({ page }) => {
await page.goto(`${BASE}/conductor`)
await page.waitForTimeout(2000)
// Verify body fills the viewport and can scroll
const bodyHeight = await page.evaluate(() => document.body.scrollHeight)
const vpHeight = await page.evaluate(() => window.innerHeight)
expect(bodyHeight).toBeGreaterThanOrEqual(vpHeight * 0.5)
// Scroll to bottom — should not error
await page.evaluate(() => window.scrollTo(0, document.body.scrollHeight))
await page.waitForTimeout(300)
// The last visible element on the page should have bottom >= 0
const lastElBottom = await page.evaluate(() => {
const all = document.querySelectorAll('main > div, main > section')
const last = all[all.length - 1]
if (!last) return -1
const rect = last.getBoundingClientRect()
return rect.bottom
})
// The last content element must be visible (not above the fold or clipped)
expect(lastElBottom).toBeGreaterThan(0)
})
})

View File

@@ -63,6 +63,8 @@
"react-joyride": "^2.9.3",
"react-markdown": "^10.1.0",
"recharts": "^3.7.0",
"rehype-raw": "^7.0.0",
"rehype-sanitize": "^6.0.0",
"remark-breaks": "^4.0.0",
"remark-gfm": "^4.0.1",
"shiki": "^3.21.0",

24
pnpm-lock.yaml generated
View File

@@ -116,6 +116,12 @@ importers:
recharts:
specifier: ^3.7.0
version: 3.8.0(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react-is@18.3.1)(react@19.2.4)(redux@5.0.1)
rehype-raw:
specifier: ^7.0.0
version: 7.0.0
rehype-sanitize:
specifier: ^6.0.0
version: 6.0.0
remark-breaks:
specifier: ^4.0.0
version: 4.0.0
@@ -2408,6 +2414,7 @@ packages:
'@ungap/structured-clone@1.3.0':
resolution: {integrity: sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g==}
deprecated: Potential CWE-502 - Update to 1.3.1 or higher
'@unrs/resolver-binding-android-arm-eabi@1.11.1':
resolution: {integrity: sha512-ppLRUgHVaGRWUx0R0Ut06Mjo9gBaBkg3v/8AxusGLhsIotbBLuRk51rAzqLC8gq6NyyAojEXglNjzf6R948DNw==}
@@ -3846,6 +3853,9 @@ packages:
hast-util-raw@9.1.0:
resolution: {integrity: sha512-Y8/SBAHkZGoNkpzqqfCldijcuUKh7/su31kEBp67cFY09Wy0mTRgtsLYsiIxMJxlu0f6AA5SUTbDR8K0rxnbUw==}
hast-util-sanitize@5.0.2:
resolution: {integrity: sha512-3yTWghByc50aGS7JlGhk61SPenfE/p1oaFeNwkOOyrscaOkMGrcW9+Cy/QAIOBpZxP1yqDIzFMR0+Np0i0+usg==}
hast-util-to-estree@3.1.3:
resolution: {integrity: sha512-48+B/rJWAp0jamNbAAf9M7Uf//UVqAoMmgXhBdxTDJLGKY+LRnZ99qcG+Qjl5HfMpYNzS5v4EAwVEF34LeAj7w==}
@@ -5278,6 +5288,9 @@ packages:
rehype-recma@1.0.0:
resolution: {integrity: sha512-lqA4rGUf1JmacCNWWZx0Wv1dHqMwxzsDWYMTowuplHF3xH0N/MmrZ/G3BDZnzAkRmxDadujCjaKM2hqYdCBOGw==}
rehype-sanitize@6.0.0:
resolution: {integrity: sha512-CsnhKNsyI8Tub6L4sm5ZFsme4puGfc6pYylvXo1AeqaGbjOYyzNv3qZPwvs0oMJ39eryyeOdmxwUIo94IpEhqg==}
remark-breaks@4.0.0:
resolution: {integrity: sha512-IjEjJOkH4FuJvHZVIW0QCDWxcG96kCq7An/KVH2NfJe6rKZU2AsHeB3OEjPNRxi4QC34Xdx7I2KGYn6IpT7gxQ==}
@@ -10535,6 +10548,12 @@ snapshots:
web-namespaces: 2.0.1
zwitch: 2.0.4
hast-util-sanitize@5.0.2:
dependencies:
'@types/hast': 3.0.4
'@ungap/structured-clone': 1.3.0
unist-util-position: 5.0.0
hast-util-to-estree@3.1.3:
dependencies:
'@types/estree': 1.0.8
@@ -12345,6 +12364,11 @@ snapshots:
transitivePeerDependencies:
- supports-color
rehype-sanitize@6.0.0:
dependencies:
'@types/hast': 3.0.4
hast-util-sanitize: 5.0.2
remark-breaks@4.0.0:
dependencies:
'@types/mdast': 4.0.4

5
pnpm-workspace.yaml Normal file
View File

@@ -0,0 +1,5 @@
allowBuilds:
electron: true
electron-winstaller: true
esbuild: true
unrs-resolver: true

View File

@@ -1,6 +1,8 @@
import { marked } from 'marked'
import { createContext, memo, useContext, useId, useMemo, useRef } from 'react'
import ReactMarkdown from 'react-markdown'
import rehypeRaw from 'rehype-raw'
import rehypeSanitize from 'rehype-sanitize'
import remarkBreaks from 'remark-breaks'
import remarkGfm from 'remark-gfm'
import { CodeBlock } from './code-block'
@@ -214,6 +216,9 @@ const INITIAL_COMPONENTS: Partial<Components> = {
return <li className="leading-relaxed">{children}</li>
},
a: function AComponent({ children, href }) {
if (!href) {
return <span className="text-primary-950">{children}</span>
}
return (
<a
href={href}
@@ -225,6 +230,12 @@ const INITIAL_COMPONENTS: Partial<Components> = {
</a>
)
},
img: function ImgComponent({ src, alt, ...props }) {
if (!src) {
return null
}
return <img src={src} alt={alt ?? ''} {...props} />
},
blockquote: function BlockquoteComponent({ children }) {
return (
<blockquote className="border-l-2 border-primary-300 pl-4 text-primary-900 italic">
@@ -334,6 +345,101 @@ const INITIAL_COMPONENTS: Partial<Components> = {
},
}
const HTML_SANITIZE_SCHEMA = {
tagNames: [
'a',
'abbr',
'article',
'b',
'bdi',
'blockquote',
'br',
'caption',
'center',
'cite',
'code',
'col',
'colgroup',
'data',
'dd',
'del',
'details',
'dfn',
'div',
'dl',
'dt',
'em',
'figcaption',
'figure',
'footer',
'h1',
'h2',
'h3',
'h4',
'h5',
'h6',
'header',
'hgroup',
'hr',
'i',
'img',
'ins',
'kbd',
'li',
'main',
'mark',
'nav',
'ol',
'p',
'pre',
'q',
'rp',
'rt',
'ruby',
's',
'samp',
'section',
'small',
'span',
'strong',
'sub',
'summary',
'sup',
'table',
'tbody',
'td',
'tfoot',
'th',
'thead',
'time',
'tr',
'u',
'ul',
'var',
'wbr',
],
attributes: {
'*': ['className', 'class', 'title', 'lang', 'dir'],
a: ['href', 'target', 'rel', 'download'],
img: ['src', 'alt', 'width', 'height', 'loading'],
td: ['colspan', 'rowspan', 'headers'],
th: ['colspan', 'rowspan', 'headers', 'scope'],
col: ['span'],
colgroup: ['span'],
ol: ['start', 'type'],
li: ['value'],
details: ['open'],
time: ['datetime'],
data: ['value'],
del: ['datetime'],
ins: ['datetime'],
},
protocols: {
a: { href: ['http', 'https', 'mailto', 'tel'] },
img: { src: ['http', 'https', 'data'] },
},
}
const MemoizedMarkdownBlock = memo(
function MarkdownBlock({
content,
@@ -345,6 +451,7 @@ const MemoizedMarkdownBlock = memo(
return (
<ReactMarkdown
remarkPlugins={[remarkGfm, remarkBreaks]}
rehypePlugins={[rehypeRaw, [rehypeSanitize, HTML_SANITIZE_SCHEMA]]}
components={components}
>
{content}

View File

@@ -1,6 +1,6 @@
import { describe, expect, it } from 'vitest'
import { DEFAULT_SLASH_COMMANDS } from './slash-command-menu'
import { DEFAULT_SLASH_COMMANDS, mergeSlashCommands } from './slash-command-menu'
describe('DEFAULT_SLASH_COMMANDS', () => {
it('includes /plugins in the slash autocomplete list', () => {
@@ -43,3 +43,31 @@ describe('DEFAULT_SLASH_COMMANDS', () => {
}
})
})
describe('mergeSlashCommands', () => {
it('appends installed skills without replacing built-ins', () => {
const merged = mergeSlashCommands(DEFAULT_SLASH_COMMANDS, [
{
command: '/hermes-agent',
description: 'Complete guide to using and extending Hermes Agent',
},
])
expect(merged.map((entry) => entry.command)).toContain('/new')
expect(merged.map((entry) => entry.command)).toContain('/hermes-agent')
})
it('deduplicates by command label and keeps the first definition', () => {
const merged = mergeSlashCommands(DEFAULT_SLASH_COMMANDS, [
{
command: '/skills',
description: 'Conflicting duplicate that should be ignored',
},
])
expect(merged.filter((entry) => entry.command === '/skills')).toHaveLength(1)
expect(
merged.find((entry) => entry.command === '/skills')?.description,
).toBe('Browse and manage skills')
})
})

View File

@@ -22,6 +22,7 @@ export type SlashCommandMenuProps = {
open: boolean
query: string
onSelect: (command: SlashCommandDefinition) => void
commands?: Array<SlashCommandDefinition>
}
export type SlashCommandMenuHandle = {
@@ -41,8 +42,28 @@ export const DEFAULT_SLASH_COMMANDS: Array<SlashCommandDefinition> = [
{ command: '/help', description: 'Show available commands' },
]
export function mergeSlashCommands(
base: Array<SlashCommandDefinition>,
additions: Array<SlashCommandDefinition>,
): Array<SlashCommandDefinition> {
const merged: Array<SlashCommandDefinition> = []
const seen = new Set<string>()
for (const entry of [...base, ...additions]) {
const command = entry.command.trim()
if (!command || seen.has(command)) continue
seen.add(command)
merged.push({
command,
description: entry.description.trim() || 'Run command',
})
}
return merged
}
const SlashCommandMenu = forwardRef(function SlashCommandMenu(
{ open, query, onSelect }: SlashCommandMenuProps,
{ open, query, onSelect, commands = DEFAULT_SLASH_COMMANDS }: SlashCommandMenuProps,
ref: Ref<SlashCommandMenuHandle>,
) {
const [activeIndex, setActiveIndex] = useState(0)
@@ -50,16 +71,16 @@ const SlashCommandMenu = forwardRef(function SlashCommandMenu(
const filteredCommands = useMemo(() => {
const normalizedQuery = query.trim()
if (!normalizedQuery) return DEFAULT_SLASH_COMMANDS
if (!normalizedQuery) return commands
return DEFAULT_SLASH_COMMANDS.filter((item) =>
return commands.filter((item) =>
filter.contains(
item,
normalizedQuery,
(target) => `${target.command} ${target.description}`,
),
)
}, [filter, query])
}, [commands, filter, query])
useEffect(() => {
setActiveIndex(0)

View File

@@ -0,0 +1,63 @@
/** @vitest-environment jsdom */
import { beforeEach, describe, expect, it } from 'vitest'
import { __updateReleaseNotesStorageForTests } from './update-center-notifier'
const { NOTES_SEEN_KEY, storeNotes } = __updateReleaseNotesStorageForTests
const agentReleaseNotes = [
{
product: 'agent' as const,
label: 'Hermes Agent',
from: 'c23a87bc163b188abc7e40fbdccf07a9739231c3',
to: '4fdfdf67499c33015ed56e6e5910d8bdc00aa901',
commits: ['Merge pull request #25045 (4fdfdf674)'],
},
]
function installLocalStorage() {
const store = new Map<string, string>()
const storage = {
get length() {
return store.size
},
key(index: number) {
return Array.from(store.keys())[index] ?? null
},
getItem(key: string) {
return store.get(key) ?? null
},
setItem(key: string, value: string) {
store.set(key, value)
},
removeItem(key: string) {
store.delete(key)
},
clear() {
store.clear()
},
}
Object.defineProperty(globalThis, 'localStorage', {
configurable: true,
value: storage,
})
Object.defineProperty(window, 'localStorage', {
configurable: true,
value: storage,
})
}
beforeEach(() => {
installLocalStorage()
})
describe('update center release notes storage', () => {
it('does not reopen release notes already marked seen when status returns the same payload', () => {
const firstStored = storeNotes(agentReleaseNotes)
expect(firstStored).not.toBeNull()
localStorage.setItem(NOTES_SEEN_KEY, firstStored!.id)
expect(storeNotes(agentReleaseNotes)).toBeNull()
})
})

View File

@@ -108,6 +108,7 @@ function storeNotes(sections: Array<ReleaseNoteSection>): Notes | null {
localStorage.removeItem(NOTES_SEEN_KEY)
}
localStorage.setItem(NOTES_KEY, JSON.stringify(notes))
if (localStorage.getItem(NOTES_SEEN_KEY) === id) return null
return notes
}
@@ -520,3 +521,8 @@ function ReleaseNotes({
</AnimatePresence>
)
}
export const __updateReleaseNotesStorageForTests = {
NOTES_SEEN_KEY,
storeNotes,
}

View File

@@ -119,10 +119,6 @@ function ContextAlertModalComponent({
emphasis
/>
)}
<Recommendation
icon="🗜"
text="Enable auto-compaction in Settings Config to automatically manage context"
/>
<Recommendation
icon="📋"
text="Summarize important details before starting a new chat"

View File

@@ -495,7 +495,11 @@ export function UsageMeter({ visible = true }: { visible?: boolean }) {
} catch (err) {
const errorMessage = err instanceof Error ? err.message : String(err)
setError(errorMessage)
toast('Failed to fetch usage data', { type: 'error' })
const silent =
/unauthorized/i.test(errorMessage) || /not found/i.test(errorMessage)
if (!silent) {
toast('Failed to fetch usage data', { type: 'error' })
}
}
}, [statusSessionKey])

View File

@@ -375,7 +375,7 @@ export function WorkspaceShell({ children }: WorkspaceShellProps) {
'h-full min-h-0 min-w-0 overflow-x-hidden bg-[var(--theme-bg)] relative',
isOnChatRoute ? 'overflow-hidden' : 'overflow-y-auto',
isMobile && !isOnChatRoute
? 'pb-[calc(var(--tabbar-h,0px)+0.5rem)]'
? 'pb-[calc(var(--tabbar-h,80px)+0.5rem)]'
: !isMobile &&
!isChromeFreeSurface &&
!isOnChatRoute &&
@@ -454,6 +454,7 @@ export function WorkspaceShell({ children }: WorkspaceShellProps) {
</div>
{!isChromeFreeSurface ? <MobileHamburgerMenu /> : null}
{!isChromeFreeSurface ? <MobileTabBar /> : null}
{!isChromeFreeSurface && !isMobile && !isOnChatRoute && settings.showSystemMetricsFooter ? (
<SystemMetricsFooter leftOffsetPx={sidebarCollapsed ? 48 : 300} />
) : null}

View File

@@ -88,6 +88,7 @@ import { Route as ApiLocalProvidersRouteImport } from './routes/api/local-provid
import { Route as ApiIntegrationsRouteImport } from './routes/api/integrations'
import { Route as ApiHistoryRouteImport } from './routes/api/history'
import { Route as ApiHermesTasksRouteImport } from './routes/api/hermes-tasks'
import { Route as ApiHermesConfigRouteImport } from './routes/api/hermes-config'
import { Route as ApiGatewayStatusRouteImport } from './routes/api/gateway-status'
import { Route as ApiGatewayReprobeRouteImport } from './routes/api/gateway-reprobe'
import { Route as ApiFilesRouteImport } from './routes/api/files'
@@ -96,6 +97,7 @@ import { Route as ApiCrewStatusRouteImport } from './routes/api/crew-status'
import { Route as ApiContextUsageRouteImport } from './routes/api/context-usage'
import { Route as ApiConnectionStatusRouteImport } from './routes/api/connection-status'
import { Route as ApiConnectionSettingsRouteImport } from './routes/api/connection-settings'
import { Route as ApiConfigPatchRouteImport } from './routes/api/config-patch'
import { Route as ApiConductorStopRouteImport } from './routes/api/conductor-stop'
import { Route as ApiConductorSpawnRouteImport } from './routes/api/conductor-spawn'
import { Route as ApiClaudeUpdateRouteImport } from './routes/api/claude-update'
@@ -110,6 +112,7 @@ import { Route as ApiArtifactsRouteImport } from './routes/api/artifacts'
import { Route as ApiUpdateWorkspaceRouteImport } from './routes/api/update/workspace'
import { Route as ApiUpdateStatusRouteImport } from './routes/api/update/status'
import { Route as ApiUpdateAgentRouteImport } from './routes/api/update/agent'
import { Route as ApiSwarmRuntimeResetRouteImport } from './routes/api/swarm-runtime.reset'
import { Route as ApiSwarmMemorySearchRouteImport } from './routes/api/swarm-memory/search'
import { Route as ApiSkillsUninstallRouteImport } from './routes/api/skills/uninstall'
import { Route as ApiSkillsToggleRouteImport } from './routes/api/skills/toggle'
@@ -552,6 +555,11 @@ const ApiHermesTasksRoute = ApiHermesTasksRouteImport.update({
path: '/api/hermes-tasks',
getParentRoute: () => rootRouteImport,
} as any)
const ApiHermesConfigRoute = ApiHermesConfigRouteImport.update({
id: '/api/hermes-config',
path: '/api/hermes-config',
getParentRoute: () => rootRouteImport,
} as any)
const ApiGatewayStatusRoute = ApiGatewayStatusRouteImport.update({
id: '/api/gateway-status',
path: '/api/gateway-status',
@@ -592,6 +600,11 @@ const ApiConnectionSettingsRoute = ApiConnectionSettingsRouteImport.update({
path: '/api/connection-settings',
getParentRoute: () => rootRouteImport,
} as any)
const ApiConfigPatchRoute = ApiConfigPatchRouteImport.update({
id: '/api/config-patch',
path: '/api/config-patch',
getParentRoute: () => rootRouteImport,
} as any)
const ApiConductorStopRoute = ApiConductorStopRouteImport.update({
id: '/api/conductor-stop',
path: '/api/conductor-stop',
@@ -662,6 +675,11 @@ const ApiUpdateAgentRoute = ApiUpdateAgentRouteImport.update({
path: '/api/update/agent',
getParentRoute: () => rootRouteImport,
} as any)
const ApiSwarmRuntimeResetRoute = ApiSwarmRuntimeResetRouteImport.update({
id: '/reset',
path: '/reset',
getParentRoute: () => ApiSwarmRuntimeRoute,
} as any)
const ApiSwarmMemorySearchRoute = ApiSwarmMemorySearchRouteImport.update({
id: '/search',
path: '/search',
@@ -927,6 +945,7 @@ export interface FileRoutesByFullPath {
'/api/claude-update': typeof ApiClaudeUpdateRoute
'/api/conductor-spawn': typeof ApiConductorSpawnRoute
'/api/conductor-stop': typeof ApiConductorStopRoute
'/api/config-patch': typeof ApiConfigPatchRoute
'/api/connection-settings': typeof ApiConnectionSettingsRoute
'/api/connection-status': typeof ApiConnectionStatusRoute
'/api/context-usage': typeof ApiContextUsageRoute
@@ -935,6 +954,7 @@ export interface FileRoutesByFullPath {
'/api/files': typeof ApiFilesRoute
'/api/gateway-reprobe': typeof ApiGatewayReprobeRoute
'/api/gateway-status': typeof ApiGatewayStatusRoute
'/api/hermes-config': typeof ApiHermesConfigRoute
'/api/hermes-tasks': typeof ApiHermesTasksRouteWithChildren
'/api/history': typeof ApiHistoryRoute
'/api/integrations': typeof ApiIntegrationsRoute
@@ -974,7 +994,7 @@ export interface FileRoutesByFullPath {
'/api/swarm-project': typeof ApiSwarmProjectRoute
'/api/swarm-reports': typeof ApiSwarmReportsRoute
'/api/swarm-roster': typeof ApiSwarmRosterRoute
'/api/swarm-runtime': typeof ApiSwarmRuntimeRoute
'/api/swarm-runtime': typeof ApiSwarmRuntimeRouteWithChildren
'/api/swarm-tmux-scroll': typeof ApiSwarmTmuxScrollRoute
'/api/swarm-tmux-start': typeof ApiSwarmTmuxStartRoute
'/api/swarm-tmux-stop': typeof ApiSwarmTmuxStopRoute
@@ -1031,6 +1051,7 @@ export interface FileRoutesByFullPath {
'/api/skills/toggle': typeof ApiSkillsToggleRoute
'/api/skills/uninstall': typeof ApiSkillsUninstallRoute
'/api/swarm-memory/search': typeof ApiSwarmMemorySearchRoute
'/api/swarm-runtime/reset': typeof ApiSwarmRuntimeResetRoute
'/api/update/agent': typeof ApiUpdateAgentRoute
'/api/update/status': typeof ApiUpdateStatusRoute
'/api/update/workspace': typeof ApiUpdateWorkspaceRoute
@@ -1074,6 +1095,7 @@ export interface FileRoutesByTo {
'/api/claude-update': typeof ApiClaudeUpdateRoute
'/api/conductor-spawn': typeof ApiConductorSpawnRoute
'/api/conductor-stop': typeof ApiConductorStopRoute
'/api/config-patch': typeof ApiConfigPatchRoute
'/api/connection-settings': typeof ApiConnectionSettingsRoute
'/api/connection-status': typeof ApiConnectionStatusRoute
'/api/context-usage': typeof ApiContextUsageRoute
@@ -1082,6 +1104,7 @@ export interface FileRoutesByTo {
'/api/files': typeof ApiFilesRoute
'/api/gateway-reprobe': typeof ApiGatewayReprobeRoute
'/api/gateway-status': typeof ApiGatewayStatusRoute
'/api/hermes-config': typeof ApiHermesConfigRoute
'/api/hermes-tasks': typeof ApiHermesTasksRouteWithChildren
'/api/history': typeof ApiHistoryRoute
'/api/integrations': typeof ApiIntegrationsRoute
@@ -1121,7 +1144,7 @@ export interface FileRoutesByTo {
'/api/swarm-project': typeof ApiSwarmProjectRoute
'/api/swarm-reports': typeof ApiSwarmReportsRoute
'/api/swarm-roster': typeof ApiSwarmRosterRoute
'/api/swarm-runtime': typeof ApiSwarmRuntimeRoute
'/api/swarm-runtime': typeof ApiSwarmRuntimeRouteWithChildren
'/api/swarm-tmux-scroll': typeof ApiSwarmTmuxScrollRoute
'/api/swarm-tmux-start': typeof ApiSwarmTmuxStartRoute
'/api/swarm-tmux-stop': typeof ApiSwarmTmuxStopRoute
@@ -1178,6 +1201,7 @@ export interface FileRoutesByTo {
'/api/skills/toggle': typeof ApiSkillsToggleRoute
'/api/skills/uninstall': typeof ApiSkillsUninstallRoute
'/api/swarm-memory/search': typeof ApiSwarmMemorySearchRoute
'/api/swarm-runtime/reset': typeof ApiSwarmRuntimeResetRoute
'/api/update/agent': typeof ApiUpdateAgentRoute
'/api/update/status': typeof ApiUpdateStatusRoute
'/api/update/workspace': typeof ApiUpdateWorkspaceRoute
@@ -1223,6 +1247,7 @@ export interface FileRoutesById {
'/api/claude-update': typeof ApiClaudeUpdateRoute
'/api/conductor-spawn': typeof ApiConductorSpawnRoute
'/api/conductor-stop': typeof ApiConductorStopRoute
'/api/config-patch': typeof ApiConfigPatchRoute
'/api/connection-settings': typeof ApiConnectionSettingsRoute
'/api/connection-status': typeof ApiConnectionStatusRoute
'/api/context-usage': typeof ApiContextUsageRoute
@@ -1231,6 +1256,7 @@ export interface FileRoutesById {
'/api/files': typeof ApiFilesRoute
'/api/gateway-reprobe': typeof ApiGatewayReprobeRoute
'/api/gateway-status': typeof ApiGatewayStatusRoute
'/api/hermes-config': typeof ApiHermesConfigRoute
'/api/hermes-tasks': typeof ApiHermesTasksRouteWithChildren
'/api/history': typeof ApiHistoryRoute
'/api/integrations': typeof ApiIntegrationsRoute
@@ -1270,7 +1296,7 @@ export interface FileRoutesById {
'/api/swarm-project': typeof ApiSwarmProjectRoute
'/api/swarm-reports': typeof ApiSwarmReportsRoute
'/api/swarm-roster': typeof ApiSwarmRosterRoute
'/api/swarm-runtime': typeof ApiSwarmRuntimeRoute
'/api/swarm-runtime': typeof ApiSwarmRuntimeRouteWithChildren
'/api/swarm-tmux-scroll': typeof ApiSwarmTmuxScrollRoute
'/api/swarm-tmux-start': typeof ApiSwarmTmuxStartRoute
'/api/swarm-tmux-stop': typeof ApiSwarmTmuxStopRoute
@@ -1327,6 +1353,7 @@ export interface FileRoutesById {
'/api/skills/toggle': typeof ApiSkillsToggleRoute
'/api/skills/uninstall': typeof ApiSkillsUninstallRoute
'/api/swarm-memory/search': typeof ApiSwarmMemorySearchRoute
'/api/swarm-runtime/reset': typeof ApiSwarmRuntimeResetRoute
'/api/update/agent': typeof ApiUpdateAgentRoute
'/api/update/status': typeof ApiUpdateStatusRoute
'/api/update/workspace': typeof ApiUpdateWorkspaceRoute
@@ -1373,6 +1400,7 @@ export interface FileRouteTypes {
| '/api/claude-update'
| '/api/conductor-spawn'
| '/api/conductor-stop'
| '/api/config-patch'
| '/api/connection-settings'
| '/api/connection-status'
| '/api/context-usage'
@@ -1381,6 +1409,7 @@ export interface FileRouteTypes {
| '/api/files'
| '/api/gateway-reprobe'
| '/api/gateway-status'
| '/api/hermes-config'
| '/api/hermes-tasks'
| '/api/history'
| '/api/integrations'
@@ -1477,6 +1506,7 @@ export interface FileRouteTypes {
| '/api/skills/toggle'
| '/api/skills/uninstall'
| '/api/swarm-memory/search'
| '/api/swarm-runtime/reset'
| '/api/update/agent'
| '/api/update/status'
| '/api/update/workspace'
@@ -1520,6 +1550,7 @@ export interface FileRouteTypes {
| '/api/claude-update'
| '/api/conductor-spawn'
| '/api/conductor-stop'
| '/api/config-patch'
| '/api/connection-settings'
| '/api/connection-status'
| '/api/context-usage'
@@ -1528,6 +1559,7 @@ export interface FileRouteTypes {
| '/api/files'
| '/api/gateway-reprobe'
| '/api/gateway-status'
| '/api/hermes-config'
| '/api/hermes-tasks'
| '/api/history'
| '/api/integrations'
@@ -1624,6 +1656,7 @@ export interface FileRouteTypes {
| '/api/skills/toggle'
| '/api/skills/uninstall'
| '/api/swarm-memory/search'
| '/api/swarm-runtime/reset'
| '/api/update/agent'
| '/api/update/status'
| '/api/update/workspace'
@@ -1668,6 +1701,7 @@ export interface FileRouteTypes {
| '/api/claude-update'
| '/api/conductor-spawn'
| '/api/conductor-stop'
| '/api/config-patch'
| '/api/connection-settings'
| '/api/connection-status'
| '/api/context-usage'
@@ -1676,6 +1710,7 @@ export interface FileRouteTypes {
| '/api/files'
| '/api/gateway-reprobe'
| '/api/gateway-status'
| '/api/hermes-config'
| '/api/hermes-tasks'
| '/api/history'
| '/api/integrations'
@@ -1772,6 +1807,7 @@ export interface FileRouteTypes {
| '/api/skills/toggle'
| '/api/skills/uninstall'
| '/api/swarm-memory/search'
| '/api/swarm-runtime/reset'
| '/api/update/agent'
| '/api/update/status'
| '/api/update/workspace'
@@ -1817,6 +1853,7 @@ export interface RootRouteChildren {
ApiClaudeUpdateRoute: typeof ApiClaudeUpdateRoute
ApiConductorSpawnRoute: typeof ApiConductorSpawnRoute
ApiConductorStopRoute: typeof ApiConductorStopRoute
ApiConfigPatchRoute: typeof ApiConfigPatchRoute
ApiConnectionSettingsRoute: typeof ApiConnectionSettingsRoute
ApiConnectionStatusRoute: typeof ApiConnectionStatusRoute
ApiContextUsageRoute: typeof ApiContextUsageRoute
@@ -1825,6 +1862,7 @@ export interface RootRouteChildren {
ApiFilesRoute: typeof ApiFilesRoute
ApiGatewayReprobeRoute: typeof ApiGatewayReprobeRoute
ApiGatewayStatusRoute: typeof ApiGatewayStatusRoute
ApiHermesConfigRoute: typeof ApiHermesConfigRoute
ApiHermesTasksRoute: typeof ApiHermesTasksRouteWithChildren
ApiHistoryRoute: typeof ApiHistoryRoute
ApiIntegrationsRoute: typeof ApiIntegrationsRoute
@@ -1864,7 +1902,7 @@ export interface RootRouteChildren {
ApiSwarmProjectRoute: typeof ApiSwarmProjectRoute
ApiSwarmReportsRoute: typeof ApiSwarmReportsRoute
ApiSwarmRosterRoute: typeof ApiSwarmRosterRoute
ApiSwarmRuntimeRoute: typeof ApiSwarmRuntimeRoute
ApiSwarmRuntimeRoute: typeof ApiSwarmRuntimeRouteWithChildren
ApiSwarmTmuxScrollRoute: typeof ApiSwarmTmuxScrollRoute
ApiSwarmTmuxStartRoute: typeof ApiSwarmTmuxStartRoute
ApiSwarmTmuxStopRoute: typeof ApiSwarmTmuxStopRoute
@@ -2457,6 +2495,13 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof ApiHermesTasksRouteImport
parentRoute: typeof rootRouteImport
}
'/api/hermes-config': {
id: '/api/hermes-config'
path: '/api/hermes-config'
fullPath: '/api/hermes-config'
preLoaderRoute: typeof ApiHermesConfigRouteImport
parentRoute: typeof rootRouteImport
}
'/api/gateway-status': {
id: '/api/gateway-status'
path: '/api/gateway-status'
@@ -2513,6 +2558,13 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof ApiConnectionSettingsRouteImport
parentRoute: typeof rootRouteImport
}
'/api/config-patch': {
id: '/api/config-patch'
path: '/api/config-patch'
fullPath: '/api/config-patch'
preLoaderRoute: typeof ApiConfigPatchRouteImport
parentRoute: typeof rootRouteImport
}
'/api/conductor-stop': {
id: '/api/conductor-stop'
path: '/api/conductor-stop'
@@ -2611,6 +2663,13 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof ApiUpdateAgentRouteImport
parentRoute: typeof rootRouteImport
}
'/api/swarm-runtime/reset': {
id: '/api/swarm-runtime/reset'
path: '/reset'
fullPath: '/api/swarm-runtime/reset'
preLoaderRoute: typeof ApiSwarmRuntimeResetRouteImport
parentRoute: typeof ApiSwarmRuntimeRoute
}
'/api/swarm-memory/search': {
id: '/api/swarm-memory/search'
path: '/search'
@@ -3112,6 +3171,18 @@ const ApiSwarmMemoryRouteWithChildren = ApiSwarmMemoryRoute._addFileChildren(
ApiSwarmMemoryRouteChildren,
)
interface ApiSwarmRuntimeRouteChildren {
ApiSwarmRuntimeResetRoute: typeof ApiSwarmRuntimeResetRoute
}
const ApiSwarmRuntimeRouteChildren: ApiSwarmRuntimeRouteChildren = {
ApiSwarmRuntimeResetRoute: ApiSwarmRuntimeResetRoute,
}
const ApiSwarmRuntimeRouteWithChildren = ApiSwarmRuntimeRoute._addFileChildren(
ApiSwarmRuntimeRouteChildren,
)
interface ApiHermesworldReservationsRouteChildren {
ApiHermesworldReservationsConfirmRoute: typeof ApiHermesworldReservationsConfirmRoute
}
@@ -3162,6 +3233,7 @@ const rootRouteChildren: RootRouteChildren = {
ApiClaudeUpdateRoute: ApiClaudeUpdateRoute,
ApiConductorSpawnRoute: ApiConductorSpawnRoute,
ApiConductorStopRoute: ApiConductorStopRoute,
ApiConfigPatchRoute: ApiConfigPatchRoute,
ApiConnectionSettingsRoute: ApiConnectionSettingsRoute,
ApiConnectionStatusRoute: ApiConnectionStatusRoute,
ApiContextUsageRoute: ApiContextUsageRoute,
@@ -3170,6 +3242,7 @@ const rootRouteChildren: RootRouteChildren = {
ApiFilesRoute: ApiFilesRoute,
ApiGatewayReprobeRoute: ApiGatewayReprobeRoute,
ApiGatewayStatusRoute: ApiGatewayStatusRoute,
ApiHermesConfigRoute: ApiHermesConfigRoute,
ApiHermesTasksRoute: ApiHermesTasksRouteWithChildren,
ApiHistoryRoute: ApiHistoryRoute,
ApiIntegrationsRoute: ApiIntegrationsRoute,
@@ -3209,7 +3282,7 @@ const rootRouteChildren: RootRouteChildren = {
ApiSwarmProjectRoute: ApiSwarmProjectRoute,
ApiSwarmReportsRoute: ApiSwarmReportsRoute,
ApiSwarmRosterRoute: ApiSwarmRosterRoute,
ApiSwarmRuntimeRoute: ApiSwarmRuntimeRoute,
ApiSwarmRuntimeRoute: ApiSwarmRuntimeRouteWithChildren,
ApiSwarmTmuxScrollRoute: ApiSwarmTmuxScrollRoute,
ApiSwarmTmuxStartRoute: ApiSwarmTmuxStartRoute,
ApiSwarmTmuxStopRoute: ApiSwarmTmuxStopRoute,

49
src/router.test.ts Normal file
View File

@@ -0,0 +1,49 @@
// @vitest-environment jsdom
import { afterEach, describe, expect, it } from 'vitest'
import { resolveRouterBasepath } from './router'
function setBasepathGlobal(value: unknown) {
;(window as unknown as Record<string, unknown>).__HERMES_WORKSPACE_BASEPATH__ =
value
}
function clearBasepathGlobal() {
delete (window as unknown as Record<string, unknown>)
.__HERMES_WORKSPACE_BASEPATH__
}
afterEach(() => {
clearBasepathGlobal()
})
describe('resolveRouterBasepath', () => {
it('returns "/" when no global override is set', () => {
clearBasepathGlobal()
expect(resolveRouterBasepath()).toBe('/')
})
it('returns "/" when the global is not a string', () => {
setBasepathGlobal(42)
expect(resolveRouterBasepath()).toBe('/')
})
it('returns "/" when the global is an empty or whitespace string', () => {
setBasepathGlobal(' ')
expect(resolveRouterBasepath()).toBe('/')
})
it('normalizes a valid prefix with a leading slash and no trailing slash', () => {
setBasepathGlobal('/workspaces/abc/')
expect(resolveRouterBasepath()).toBe('/workspaces/abc')
})
it('adds a leading slash if one is missing', () => {
setBasepathGlobal('workspaces/abc')
expect(resolveRouterBasepath()).toBe('/workspaces/abc')
})
it('collapses multiple trailing slashes', () => {
setBasepathGlobal('/workspaces/abc////')
expect(resolveRouterBasepath()).toBe('/workspaces/abc')
})
})

View File

@@ -3,11 +3,39 @@ import { createRouter } from '@tanstack/react-router'
// Import the generated route tree
import { routeTree } from './routeTree.gen'
declare global {
interface Window {
/**
* Optional runtime override for the TanStack router basepath. Allows the
* same built artifact to be hosted under a path prefix by reverse proxies
* or container orchestrators (e.g. mounted at `/workspaces/<id>/`) without
* a rebuild. Set this on `window` before the app bundle executes — for
* example via an inline `<script>` injected by the proxy.
*/
__HERMES_WORKSPACE_BASEPATH__?: string
}
}
export function resolveRouterBasepath(): string {
if (typeof window === 'undefined') return '/'
const value = window.__HERMES_WORKSPACE_BASEPATH__
if (typeof value !== 'string') return '/'
const trimmed = value.trim()
if (!trimmed) return '/'
// Normalize to leading slash and no trailing slash so TanStack's internal
// pathname matching produces stable results (`basepath: ''` and trailing
// slashes both cause subtle mismatches in route resolution).
const withLeadingSlash = trimmed.startsWith('/') ? trimmed : `/${trimmed}`
const withoutTrailing = withLeadingSlash.replace(/\/+$/, '')
return withoutTrailing.length > 0 ? withoutTrailing : '/'
}
// Create a new router instance
export const getRouter = () => {
const router = createRouter({
routeTree,
context: {},
basepath: resolveRouterBasepath(),
scrollRestoration: true,
defaultPreloadStaleTime: 0,

View File

@@ -416,12 +416,14 @@ function RootDocument({ children }: { children: React.ReactNode }) {
/>
</head>
<body>
<div id="splash-screen" aria-hidden="true" style={{ display: 'none' }} />
<script
dangerouslySetInnerHTML={{
__html: wrapInlineScript(`
(function(){
if (document.getElementById('splash-screen')) return;
if (location.pathname === '/hermes-world' || location.pathname.indexOf('/hermes-world/') === 0 || location.pathname === '/world' || location.pathname.indexOf('/world/') === 0) return;
var d = document.getElementById('splash-screen');
if (!d) return;
var bg = '#031A1A', txt = '#F8F1E3', muted = '#9CB2AE', accent = '#FFAC02';
try {
var theme = localStorage.getItem('${THEME_STORAGE_KEY}') || '${DEFAULT_THEME}';
@@ -467,14 +469,11 @@ function RootDocument({ children }: { children: React.ReactNode }) {
var quips = ["Consulting the oracle...","Loading ancient knowledge...","Warming up the messenger...","Calibrating tool chain...","Summoning your agent...","Preparing the workspace...","Bridging realms...","Initializing agent runtime..."];
var quip = quips[Math.floor(Math.random() * quips.length)];
var d = document.createElement('div');
d.id = 'splash-screen';
d.style.cssText = 'position:fixed;inset:0;z-index:99999;display:flex;flex-direction:column;align-items:center;justify-content:center;background:'+bg+';transition:opacity 0.5s ease;';
d.innerHTML = '<img src="/claude-avatar.webp" alt="Hermes Agent" style="width:80px;height:80px;margin-bottom:20px;border-radius:16px;filter:drop-shadow(0 8px 32px color-mix(in srgb,'+accent+' 45%, transparent))" />'
+ '<img src="'+(isDark ? '/claude-banner.png' : '/claude-banner-light.png')+'" alt="Hermes Workspace" style="width:280px;height:auto;margin-bottom:8px;filter:drop-shadow(0 4px 16px '+(isDark ? 'rgba(0,0,0,0.5)' : 'rgba(0,0,0,0.1)')+')" />'
+ '<div style="font:400 14px/1 system-ui,-apple-system,sans-serif;letter-spacing:0.04em;color:'+muted+'">Workspace</div>'
+ '<div style="margin-top:28px;width:140px;height:3px;background:'+(isDark ? 'rgba(255,255,255,0.08)' : 'rgba(0,0,0,0.08)')+';border-radius:3px;overflow:hidden;position:relative"><div id=splash-bar style="width:0%;height:100%;background:'+accent+';border-radius:3px;transition:width 0.4s ease"></div></div>';
document.body.prepend(d);
var bar = document.getElementById('splash-bar');
if (bar) {
@@ -491,7 +490,10 @@ function RootDocument({ children }: { children: React.ReactNode }) {
if (bar) bar.style.width = '100%';
setTimeout(function(){
el.style.opacity = '0';
setTimeout(function(){ el.remove(); }, 500);
setTimeout(function(){
el.innerHTML = '';
el.style.cssText = 'display:none';
}, 500);
}, 300);
};
// Fallback: always dismiss after 5s

View File

@@ -0,0 +1,142 @@
import * as fs from 'node:fs'
import * as os from 'node:os'
import * as path from 'node:path'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
vi.mock('@tanstack/react-router', () => ({
createFileRoute: (_path: string) => (opts: any) => opts,
}))
vi.mock('../../server/auth-middleware', () => ({
isAuthenticated: () => true,
}))
vi.mock('../../server/rate-limit', () => ({
requireJsonContentType: () => null,
}))
let tmpHome = ''
const originalEnv: Record<string, string | undefined> = {}
function setEnv(key: string, value: string | undefined) {
if (!(key in originalEnv)) originalEnv[key] = process.env[key]
if (value === undefined) delete process.env[key]
else process.env[key] = value
}
function writeRuntime(workerId: string, runtime: Record<string, unknown>) {
const profilePath = path.join(tmpHome, 'profiles', workerId)
fs.mkdirSync(profilePath, { recursive: true })
fs.writeFileSync(path.join(profilePath, 'runtime.json'), JSON.stringify(runtime, null, 2) + '\n', 'utf-8')
}
beforeEach(() => {
tmpHome = fs.mkdtempSync(path.join(os.tmpdir(), 'swarm-runtime-reset-'))
setEnv('HERMES_HOME', tmpHome)
setEnv('CLAUDE_HOME', undefined)
vi.resetModules()
})
afterEach(() => {
for (const [key, value] of Object.entries(originalEnv)) {
if (value === undefined) delete process.env[key]
else process.env[key] = value
}
for (const key of Object.keys(originalEnv)) delete originalEnv[key]
fs.rmSync(tmpHome, { recursive: true, force: true })
})
async function loadHandlers() {
const mod = await import('./swarm-runtime.reset')
return (mod as any).Route.server.handlers
}
describe('/api/swarm-runtime/reset', () => {
it('resets the selected semantic worker runtimes', async () => {
writeRuntime('augur', {
workerId: 'augur',
state: 'blocked',
phase: 'stalled',
currentTask: 'Need operator input',
currentMissionId: 'mission-1',
extraField: 'keep-me',
})
writeRuntime('consul', {
workerId: 'consul',
state: 'executing',
phase: 'running',
currentTask: 'still working',
})
const handlers = await loadHandlers()
const res = await handlers.POST({
request: new Request('http://localhost/api/swarm-runtime/reset', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ workerIds: ['augur'], actor: 'test-suite', reason: 'manual cleanup' }),
}),
})
const body = await res.json()
expect(res.status).toBe(200)
expect(body.ok).toBe(true)
expect(body.workerIds).toEqual(['augur'])
expect(body.resetCount).toBe(1)
const augurRuntime = JSON.parse(fs.readFileSync(path.join(tmpHome, 'profiles', 'augur', 'runtime.json'), 'utf-8'))
const consulRuntime = JSON.parse(fs.readFileSync(path.join(tmpHome, 'profiles', 'consul', 'runtime.json'), 'utf-8'))
expect(augurRuntime.state).toBe('idle')
expect(augurRuntime.phase).toBe('cancelled')
expect(augurRuntime.currentTask).toBeNull()
expect(augurRuntime.currentMissionId).toBeNull()
expect(augurRuntime.extraField).toBe('keep-me')
expect(augurRuntime.cancelledBy).toBe('test-suite')
expect(consulRuntime.state).toBe('executing')
})
it('resets all worker profiles but skips the synthetic workspace profile', async () => {
writeRuntime('builder', { workerId: 'builder', state: 'blocked', phase: 'stalled' })
writeRuntime('reviewer', { workerId: 'reviewer', state: 'executing', phase: 'running' })
writeRuntime('workspace', { workerId: 'workspace', state: 'blocked', phase: 'stalled' })
const handlers = await loadHandlers()
const res = await handlers.POST({
request: new Request('http://localhost/api/swarm-runtime/reset', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({}),
}),
})
const body = await res.json()
expect(res.status).toBe(200)
expect(body.workerIds).toEqual(['builder', 'reviewer'])
const builderRuntime = JSON.parse(fs.readFileSync(path.join(tmpHome, 'profiles', 'builder', 'runtime.json'), 'utf-8'))
const reviewerRuntime = JSON.parse(fs.readFileSync(path.join(tmpHome, 'profiles', 'reviewer', 'runtime.json'), 'utf-8'))
const workspaceRuntime = JSON.parse(fs.readFileSync(path.join(tmpHome, 'profiles', 'workspace', 'runtime.json'), 'utf-8'))
expect(builderRuntime.state).toBe('idle')
expect(reviewerRuntime.state).toBe('idle')
expect(workspaceRuntime.state).toBe('blocked')
})
it('rejects unknown worker ids', async () => {
writeRuntime('builder', { workerId: 'builder', state: 'blocked', phase: 'stalled' })
const handlers = await loadHandlers()
const res = await handlers.POST({
request: new Request('http://localhost/api/swarm-runtime/reset', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ workerIds: ['ghost-worker'] }),
}),
})
const body = await res.json()
expect(res.status).toBe(400)
expect(body.ok).toBe(false)
expect(body.error).toContain('unknown worker ids')
})
})

View File

@@ -1,313 +1,21 @@
/**
* Hermes Config API — read/write ~/.hermes/config.yaml and ~/.hermes/.env
* Gives the web UI the same config power as `hermes setup`
* Legacy config route shim.
*
* The frontend still calls /api/claude-config in a few places, but the real
* implementation now lives in the shared Hermes config handlers.
*/
import fs from 'node:fs'
import path from 'node:path'
import os from 'node:os'
import { createFileRoute } from '@tanstack/react-router'
import YAML from 'yaml'
import { isAuthenticated } from '../../server/auth-middleware'
import {
ensureGatewayProbed,
getCapabilities,
} from '../../server/gateway-capabilities'
import { createCapabilityUnavailablePayload } from '@/lib/feature-gates'
type AuthResult = Response | true
const CLAUDE_HOME = process.env.HERMES_HOME ?? process.env.CLAUDE_HOME ?? path.join(os.homedir(), '.hermes')
const CONFIG_PATH = path.join(CLAUDE_HOME, 'config.yaml')
const ENV_PATH = path.join(CLAUDE_HOME, '.env')
// Known Hermes providers
const PROVIDERS = [
{ id: 'nous', name: 'Nous Portal', authType: 'oauth', envKeys: [] },
{ id: 'openai-codex', name: 'OpenAI Codex', authType: 'oauth', envKeys: [] },
{
id: 'anthropic',
name: 'Anthropic',
authType: 'api_key',
envKeys: ['ANTHROPIC_API_KEY'],
},
{
id: 'openrouter',
name: 'OpenRouter',
authType: 'api_key',
envKeys: ['OPENROUTER_API_KEY'],
},
{
id: 'zai',
name: 'Z.AI / GLM',
authType: 'api_key',
envKeys: ['GLM_API_KEY'],
},
{
id: 'kimi-coding',
name: 'Kimi / Moonshot',
authType: 'api_key',
envKeys: ['KIMI_API_KEY'],
},
{
id: 'minimax',
name: 'MiniMax',
authType: 'api_key',
envKeys: ['MINIMAX_API_KEY'],
},
{
id: 'minimax-cn',
name: 'MiniMax (China)',
authType: 'api_key',
envKeys: ['MINIMAX_CN_API_KEY'],
},
{
id: 'xiaomi',
name: 'Xiaomi MiMo',
authType: 'api_key',
envKeys: ['XIAOMI_API_KEY'],
},
{ id: 'ollama', name: 'Ollama (Local)', authType: 'none', envKeys: [] },
{
id: 'atomic-chat',
name: 'Atomic Chat (Local)',
authType: 'none',
envKeys: [],
},
{
id: 'custom',
name: 'Custom OpenAI-compatible',
authType: 'api_key',
envKeys: ['CUSTOM_API_KEY'],
},
]
function readConfig(): Record<string, unknown> {
try {
const raw = fs.readFileSync(CONFIG_PATH, 'utf-8')
const parsed = YAML.parse(raw)
return parsed && typeof parsed === 'object' && !Array.isArray(parsed)
? (parsed as Record<string, unknown>)
: {}
} catch {
return {}
}
}
function writeConfig(config: Record<string, unknown>): void {
fs.mkdirSync(CLAUDE_HOME, { recursive: true })
fs.writeFileSync(CONFIG_PATH, YAML.stringify(config), 'utf-8')
}
function readEnv(): Record<string, string> {
try {
const raw = fs.readFileSync(ENV_PATH, 'utf-8')
const env: Record<string, string> = {}
for (const line of raw.split('\n')) {
const trimmed = line.trim()
if (!trimmed || trimmed.startsWith('#')) continue
const eqIdx = trimmed.indexOf('=')
if (eqIdx > 0) {
const key = trimmed.slice(0, eqIdx).trim()
let value = trimmed.slice(eqIdx + 1).trim()
// Strip quotes
if (
(value.startsWith('"') && value.endsWith('"')) ||
(value.startsWith("'") && value.endsWith("'"))
) {
value = value.slice(1, -1)
}
env[key] = value
}
}
return env
} catch {
return {}
}
}
function writeEnv(env: Record<string, string>): void {
fs.mkdirSync(CLAUDE_HOME, { recursive: true })
const lines = Object.entries(env).map(([k, v]) => `${k}=${v}`)
fs.writeFileSync(ENV_PATH, lines.join('\n') + '\n', 'utf-8')
}
function maskKey(key: string): string {
if (!key || key.length < 8) return '***'
return key.slice(0, 4) + '...' + key.slice(-4)
}
function checkAuthStore(providerId: string): {
hasToken: boolean
source: string
maskedKey?: string
} {
// Check Claude auth store
const storePath = path.join(CLAUDE_HOME, 'auth-profiles.json')
try {
if (fs.existsSync(storePath)) {
const store = JSON.parse(fs.readFileSync(storePath, 'utf-8'))
const profiles = store?.profiles || {}
for (const [key, value] of Object.entries(profiles)) {
if (!key.startsWith(`${providerId}:`)) continue
if (typeof value !== 'object' || value === null) continue
const p = value as Record<string, unknown>
const token = String(p.token || p.key || p.access || '').trim()
if (token) {
return { hasToken: true, source: 'claude-auth-store', maskedKey: maskKey(token) }
}
}
}
} catch {}
return { hasToken: false, source: '' }
}
handleHermesConfigGet,
handleHermesConfigPatch,
} from '../../server/hermes-config-route'
export const Route = createFileRoute('/api/claude-config')({
server: {
handlers: {
GET: async ({ request }) => {
const authResult = isAuthenticated(request) as AuthResult
if (authResult !== true) return authResult
await ensureGatewayProbed()
if (!getCapabilities().config) {
return Response.json({
...createCapabilityUnavailablePayload('config'),
config: {},
providers: [],
activeProvider: '',
activeModel: '',
claudeHome: CLAUDE_HOME,
})
}
const config = readConfig()
const env = readEnv()
// Build provider status
const providerStatus = PROVIDERS.map((p) => {
const hasEnvKey =
p.envKeys.length === 0 || p.envKeys.some((k) => !!env[k])
const authStoreCheck = checkAuthStore(p.id)
const hasKey =
hasEnvKey || authStoreCheck.hasToken || p.authType === 'none'
const maskedKeys: Record<string, string> = {}
for (const k of p.envKeys) {
if (env[k]) maskedKeys[k] = maskKey(env[k])
}
if (authStoreCheck.hasToken && authStoreCheck.maskedKey) {
maskedKeys['auth-store'] = authStoreCheck.maskedKey
}
return {
...p,
configured: hasKey,
authSource: authStoreCheck.hasToken
? authStoreCheck.source
: hasEnvKey
? 'env'
: 'none',
maskedKeys,
}
})
// Get active provider/model from config
// Support both flat keys (model: "gpt-5.4", provider: "openai-codex")
// and legacy nested format (model: { default: "...", provider: "..." })
const modelField = config.model
let activeModel = ''
let activeProvider = ''
if (typeof modelField === 'string') {
activeModel = modelField
activeProvider = (config.provider as string) || ''
} else if (modelField && typeof modelField === 'object') {
const modelObj = modelField as Record<string, unknown>
activeModel = (modelObj.default as string) || ''
activeProvider =
(modelObj.provider as string) || (config.provider as string) || ''
}
return Response.json({
config,
providers: providerStatus,
activeProvider,
activeModel,
claudeHome: CLAUDE_HOME,
})
},
PATCH: async ({ request }) => {
const authResult = isAuthenticated(request) as AuthResult
if (authResult !== true) return authResult
await ensureGatewayProbed()
if (!getCapabilities().config) {
return new Response(
JSON.stringify(
createCapabilityUnavailablePayload('config', {
error: 'Configuration updates are unavailable on this backend.',
}),
),
{ status: 503, headers: { 'Content-Type': 'application/json' } },
)
}
const body = (await request.json()) as Record<string, unknown>
// Handle config updates
if (body.config && typeof body.config === 'object') {
const current = readConfig()
const updates = body.config as Record<string, unknown>
// Deep merge
function deepMerge(
target: Record<string, unknown>,
source: Record<string, unknown>,
) {
for (const [key, value] of Object.entries(source)) {
if (
value &&
typeof value === 'object' &&
!Array.isArray(value) &&
target[key] &&
typeof target[key] === 'object'
) {
deepMerge(
target[key] as Record<string, unknown>,
value as Record<string, unknown>,
)
} else {
target[key] = value
}
}
}
// Handle null values as explicit removals
for (const [key, value] of Object.entries(updates)) {
if (value === null) {
delete current[key]
delete updates[key]
}
}
deepMerge(current, updates)
writeConfig(current)
}
// Handle env var updates
if (body.env && typeof body.env === 'object') {
const currentEnv = readEnv()
const envUpdates = body.env as Record<string, string | null>
for (const [key, value] of Object.entries(envUpdates)) {
if (value === '' || value === null) {
delete currentEnv[key]
} else {
currentEnv[key] = value
}
}
writeEnv(currentEnv)
}
return Response.json({
ok: true,
message: 'Config updated. Restart Hermes Agent to apply changes.',
})
},
GET: handleHermesConfigGet,
PATCH: handleHermesConfigPatch,
POST: handleHermesConfigPatch,
},
},
})

View File

@@ -8,8 +8,12 @@ import { requireJsonContentType } from '../../server/rate-limit'
import { dashboardFetch, ensureGatewayProbed } from '../../server/gateway-capabilities'
import { sanitizeConductorMissionGoal } from '../../server/conductor-mission-sanitize'
import { getSwarmMission } from '../../server/swarm-missions'
import { dispatchSwarmAssignments } from './swarm-dispatch'
import { dispatchSwarmAssignments, readRuntimeCheckpointSnapshot, checkpointFromRuntimeSnapshot, runtimeCheckpointSignature } from './swarm-dispatch'
import type { SwarmMission } from '../../server/swarm-missions'
import { recordMissionCheckpoint } from '../../server/swarm-missions'
import { getSwarmProfilePath } from '../../server/swarm-foundation'
import { readWorkerMessages } from '../../server/swarm-chat-reader'
import { newestCheckpointFromMessages } from '../../server/swarm-checkpoints'
let cachedSkill: string | null = null
@@ -321,7 +325,50 @@ export const Route = createFileRoute('/api/conductor-spawn')({
const nativeMission = getSwarmMission(missionId)
if (nativeMission) {
return json({ ok: true, mode: 'native-swarm', mission: toNativeConductorMissionRecord(nativeMission, lines) })
// For active native missions, check worker runtime.json for fresh
// checkpoints that haven't been written back to the mission store yet.
// This bridges the gap between fire-and-forget dispatch (waitForCheckpoint=false)
// and the conductor UI polling for live status.
if (nativeMission.state === 'executing') {
for (const assignment of nativeMission.assignments) {
if (assignment.state === 'dispatched' && assignment.workerId) {
try {
const profilePath = getSwarmProfilePath(assignment.workerId)
// Check runtime.json first
const snapshot = readRuntimeCheckpointSnapshot(profilePath)
let checkpoint = checkpointFromRuntimeSnapshot(snapshot)
// Also check the worker's chat SQLite DB for checkpoint messages
// (tmux workers write checkpoints there)
if (!checkpoint || checkpoint.stateLabel === 'IN_PROGRESS') {
const chat = readWorkerMessages(profilePath, 50)
if (chat.ok) {
const msgCheckpoint = newestCheckpointFromMessages(chat.messages)
if (msgCheckpoint && msgCheckpoint.raw !== snapshot.checkpointRaw) {
checkpoint = msgCheckpoint
}
}
}
if (checkpoint && (checkpoint.stateLabel === 'DONE' || checkpoint.stateLabel === 'BLOCKED' || checkpoint.stateLabel === 'HANDOFF' || checkpoint.stateLabel === 'NEEDS_INPUT')) {
recordMissionCheckpoint({
missionId: nativeMission.id,
assignmentId: assignment.id,
workerId: assignment.workerId,
checkpoint,
source: 'conductor-poll',
})
}
} catch {
// runtime.json might not exist yet or be temporarily unreadable
}
}
}
}
// Re-read the mission from the store so the response reflects any
// checkpoints just synced via recordMissionCheckpoint above.
const updatedNative = getSwarmMission(missionId) ?? nativeMission
return json({ ok: true, mode: 'native-swarm', mission: toNativeConductorMissionRecord(updatedNative, lines) })
}
const capabilities = await ensureGatewayProbed()

View File

@@ -1,5 +1,3 @@
import { existsSync, readFileSync, writeFileSync } from 'node:fs'
import { join } from 'node:path'
import { createFileRoute } from '@tanstack/react-router'
import { json } from '@tanstack/react-start'
import { isAuthenticated } from '../../server/auth-middleware'
@@ -7,36 +5,7 @@ import { requireJsonContentType } from '../../server/rate-limit'
import { deleteSession } from '../../server/claude-api'
import { dashboardFetch, ensureGatewayProbed } from '../../server/gateway-capabilities'
import { cancelSwarmMission } from '../../server/swarm-missions'
import { getProfilesDir } from '../../server/claude-paths'
function resetNativeWorkerRuntime(workerId: string, missionId: string): boolean {
const runtimePath = join(getProfilesDir(), workerId, 'runtime.json')
let current: Record<string, unknown> = {}
if (existsSync(runtimePath)) {
try {
current = JSON.parse(readFileSync(runtimePath, 'utf8')) as Record<string, unknown>
} catch {
current = {}
}
}
const now = new Date().toISOString()
writeFileSync(runtimePath, JSON.stringify({
...current,
workerId,
state: 'idle',
phase: 'cancelled',
currentTask: null,
currentMissionId: null,
currentAssignmentId: null,
checkpointStatus: 'none',
needsHuman: false,
blockedReason: null,
lastCheckIn: now,
lastSummary: `Cancelled native Conductor mission ${missionId}`,
nextAction: 'Idle; ready for next Conductor or Swarm dispatch.',
}, null, 2) + '\n')
return true
}
import { resetSwarmWorkerRuntime } from '../../server/swarm-runtime-reset'
export const Route = createFileRoute('/api/conductor-stop')({
server: {
@@ -78,7 +47,10 @@ export const Route = createFileRoute('/api/conductor-stop')({
cancelledNativeMissions += 1
for (const workerId of Array.from(new Set(cancelled.mission.assignments.map((assignment) => assignment.workerId)))) {
try {
resetNativeWorkerRuntime(workerId, missionId)
resetSwarmWorkerRuntime(workerId, {
actor: 'conductor-stop',
reason: `Cancelled native Conductor mission ${missionId}`,
})
} catch {
// Runtime reset is best-effort; cancellation state is still durable.
}

View File

@@ -0,0 +1,16 @@
/**
* Config Patch API — handles provider/settings saves from the providers screen
* and provider wizard. Delegates to the same hermes-config-route handler.
*/
import { createFileRoute } from '@tanstack/react-router'
import {
handleHermesConfigPatch,
} from '../../server/hermes-config-route'
export const Route = createFileRoute('/api/config-patch')({
server: {
handlers: {
POST: handleHermesConfigPatch,
},
},
})

View File

@@ -0,0 +1,19 @@
/**
* Hermes Config API — proxy route for hermes-config-route handlers.
* Maps GET and PATCH/POST to the server-side config read/write logic.
*/
import { createFileRoute } from '@tanstack/react-router'
import {
handleHermesConfigGet,
handleHermesConfigPatch,
} from '../../server/hermes-config-route'
export const Route = createFileRoute('/api/hermes-config')({
server: {
handlers: {
GET: handleHermesConfigGet,
PATCH: handleHermesConfigPatch,
POST: handleHermesConfigPatch,
},
},
})

View File

@@ -390,6 +390,31 @@ export const Route = createFileRoute('/api/send-stream')({
streamClosed = true
}
const persistRunStarted = (
runId: string | undefined,
runSessionKey: string,
friendlyId: string,
) => {
if (!runId || persistedRunReady) return
activeRunSessionKey = runSessionKey
persistedRunReady = createPersistedRun({
runId,
sessionKey: runSessionKey,
friendlyId,
}).catch(() => null)
}
const persistActiveRun = (
write: (sessionKey: string, runId: string) => Promise<unknown>,
) => {
if (!activeRunId || !activeRunSessionKey) return
const runId = activeRunId
const runSessionKey = activeRunSessionKey
void (persistedRunReady ?? Promise.resolve())
.then(() => write(runSessionKey, runId))
.catch(() => null)
}
const stream = new ReadableStream({
async start(controller) {
let heartbeatTimer: ReturnType<typeof setInterval> | null = null
@@ -460,31 +485,6 @@ export const Route = createFileRoute('/api/send-stream')({
sendEvent('heartbeat', { timestamp: Date.now() })
}, 30_000)
const persistRunStarted = (
runId: string | undefined,
runSessionKey: string,
friendlyId: string,
) => {
if (!runId || persistedRunReady) return
activeRunSessionKey = runSessionKey
persistedRunReady = createPersistedRun({
runId,
sessionKey: runSessionKey,
friendlyId,
}).catch(() => null)
}
const persistActiveRun = (
write: (sessionKey: string, runId: string) => Promise<unknown>,
) => {
if (!activeRunId || !activeRunSessionKey) return
const runId = activeRunId
const runSessionKey = activeRunSessionKey
void (persistedRunReady ?? Promise.resolve())
.then(() => write(runSessionKey, runId))
.catch(() => null)
}
try {
if (chatMode === 'portable') {
const runId = crypto.randomUUID()
@@ -527,7 +527,7 @@ export const Route = createFileRoute('/api/send-stream')({
: []
// Load persisted history for this session, then append user message.
// When the gateway can bind portable chat to a server-side session
// via X-Claude-Session-Id, replaying the entire local transcript on
// via X-Hermes-Session-Id, replaying the entire local transcript on
// every turn duplicates prompt context and can trip model limits
// on otherwise simple tasks (#405).
const persistedMessages = getLocalMessages(portableSessionKey)

View File

@@ -159,45 +159,71 @@ export const Route = createFileRoute('/api/session-status')({
})
}
const session = await getSession(sessionKey)
const config = capabilities.config
? await getConfig()
: ({ model: '', provider: '' } as const)
try {
const session = await getSession(sessionKey)
const config = capabilities.config
? await getConfig()
: ({ model: '', provider: '' } as const)
const inputTokens = session.input_tokens ?? 0
const outputTokens = session.output_tokens ?? 0
const contextUsage = await readContextUsage(session.id)
const inputTokens = session.input_tokens ?? 0
const outputTokens = session.output_tokens ?? 0
const contextUsage = await readContextUsage(session.id)
return json({
ok: true,
payload: {
status: session.ended_at ? 'ended' : 'idle',
sessionKey: session.id,
sessionLabel: session.title ?? '',
model: session.model ?? config.model ?? '',
modelProvider: config.provider ?? '',
inputTokens,
outputTokens,
totalTokens: inputTokens + outputTokens,
contextPercent: contextUsage.contextPercent,
maxTokens: contextUsage.maxTokens,
usedTokens: contextUsage.usedTokens,
sessions: [
{
key: session.id,
agentId: session.id,
label: session.title ?? session.id,
model: session.model ?? config.model ?? '',
modelProvider: config.provider ?? '',
updatedAt: session.last_active ?? session.started_at ?? 0,
usage: {
input: inputTokens,
output: outputTokens,
return json({
ok: true,
payload: {
status: session.ended_at ? 'ended' : 'idle',
sessionKey: session.id,
sessionLabel: session.title ?? '',
model: session.model ?? config.model ?? '',
modelProvider: config.provider ?? '',
inputTokens,
outputTokens,
totalTokens: inputTokens + outputTokens,
contextPercent: contextUsage.contextPercent,
maxTokens: contextUsage.maxTokens,
usedTokens: contextUsage.usedTokens,
sessions: [
{
key: session.id,
agentId: session.id,
label: session.title ?? session.id,
model: session.model ?? config.model ?? '',
modelProvider: config.provider ?? '',
updatedAt: session.last_active ?? session.started_at ?? 0,
usage: {
input: inputTokens,
output: outputTokens,
},
},
},
],
},
})
],
},
})
} catch (sessionErr) {
const message =
sessionErr instanceof Error ? sessionErr.message : String(sessionErr)
if (!/not found|404/i.test(message)) {
throw sessionErr
}
const contextUsage = await readContextUsage(sessionKey)
return json({
ok: true,
payload: {
status: 'idle',
sessionKey,
sessionLabel: '',
model: contextUsage.model,
modelProvider: '',
inputTokens: 0,
outputTokens: 0,
totalTokens: 0,
contextPercent: contextUsage.contextPercent,
maxTokens: contextUsage.maxTokens,
usedTokens: contextUsage.usedTokens,
sessions: [],
},
})
}
} catch (err) {
return json(
{

View File

@@ -11,6 +11,7 @@ import { createOrUpdateMission, markMissionAssignmentDispatched, recordMissionCh
import { appendSwarmMemoryEvent, buildSwarmStartupSnapshot } from '../../server/swarm-memory'
import { rosterByWorkerId, type SwarmRosterWorker } from '../../server/swarm-roster'
import { publishSwarmCheckpointNotification } from '../../server/swarm-notifications'
import { ensureSwarmProfileConfig } from '../../server/swarm-profile-config'
const HERMES_BIN_CANDIDATES = [
process.env.HERMES_CLI_BIN,
@@ -502,10 +503,17 @@ function markDispatchResult(workerId: string, result: WorkerResult): void {
}
function markCheckpointResult(workerId: string, checkpoint: ParsedSwarmCheckpoint, notifySessionKey?: string | null): void {
// When the checkpoint reaches any terminal status (anything other than
// 'in_progress' — i.e. done/blocked/needs_input/handoff) the worker is no
// longer running this task, so clear currentTask the same way conductor-stop
// resets it. While still in_progress we omit the key entirely so
// writeRuntimePatch keeps the existing currentTask untouched.
const clearCurrentTask = checkpoint.checkpointStatus !== 'in_progress'
writeRuntimePatch(workerId, {
state: checkpoint.runtimeState,
phase: checkpoint.stateLabel.toLowerCase(),
checkpointStatus: checkpoint.checkpointStatus,
...(clearCurrentTask ? { currentTask: null } : {}),
lastCheckIn: new Date().toISOString(),
lastOutputAt: Date.now(),
lastSummary: checkpoint.result,
@@ -587,6 +595,7 @@ async function ensureLiveTmuxSession(workerId: string): Promise<{ ok: true; tmux
}
const profilePath = getProfilePath(workerId)
ensureSwarmProfileConfig(profilePath)
const cwd = resolveWorkerCwd(workerId)
const hermesBin = resolveHermesBin()
const launchCommand = buildHermesTmuxLaunchCommand({
@@ -621,7 +630,10 @@ async function ensureLiveTmuxSession(workerId: string): Promise<{ ok: true; tmux
}
const startupOutput = await captureTmuxPane(tmuxBin, sessionName)
if (startupOutput.includes('[Hermes worker exited with status')) {
// Match only at the start of a line so the echoed shell command's printf
// format string doesn't trigger a false positive startup-failure sentinel.
const exitedPattern = /(?:^|\n)\[Hermes worker exited with status/
if (exitedPattern.test(startupOutput)) {
const sanitizedOutput = redactStartupOutput(startupOutput).slice(-4_000)
const logsDir = join(profilePath, 'logs')
mkdirSync(logsDir, { recursive: true })

View File

@@ -1,10 +1,8 @@
import { existsSync, readFileSync, renameSync, writeFileSync } from 'node:fs'
import { join } from 'node:path'
import { createFileRoute } from '@tanstack/react-router'
import { json } from '@tanstack/react-start'
import { isAuthenticated } from '../../server/auth-middleware'
import { SWARM_MISSIONS_PATH, cancelSwarmAssignment, cancelSwarmMission, getSwarmMission, listSwarmMissions, listSwarmReports } from '../../server/swarm-missions'
import { getProfilesDir } from '../../server/claude-paths'
import { resetSwarmWorkerRuntime } from '../../server/swarm-runtime-reset'
type CancelPostBody = {
action?: unknown
@@ -20,41 +18,6 @@ function cleanString(value: unknown): string | null {
return typeof value === 'string' && value.trim() ? value.trim() : null
}
function resetWorkerRuntime(workerId: string, reason: string, actor: string): { workerId: string; ok: boolean; error?: string } {
if (!/^swarm\d+$/i.test(workerId)) return { workerId, ok: false, error: 'invalid worker id' }
const runtimePath = join(getProfilesDir(), workerId, 'runtime.json')
if (!existsSync(runtimePath)) return { workerId, ok: true }
try {
const raw = JSON.parse(readFileSync(runtimePath, 'utf-8')) as Record<string, unknown>
const next = {
...raw,
state: 'idle',
phase: 'cancelled',
currentTask: null,
currentMissionId: null,
currentAssignmentId: null,
checkpointStatus: 'none',
needsHuman: false,
blockedReason: null,
activeTool: null,
checkpointRaw: null,
orchestratorProcessedRaw: null,
lastSummary: `Cancelled by ${actor}: ${reason}`,
lastControlMessage: `Cancelled by ${actor}: ${reason}`,
nextAction: 'Idle. Do not continue cancelled Workspace swarm work unless explicitly re-dispatched.',
cancelledAt: new Date().toISOString(),
cancellationReason: reason,
cancelledBy: actor,
}
const tmp = `${runtimePath}.${process.pid}.${Date.now()}.tmp`
writeFileSync(tmp, JSON.stringify(next, null, 2) + '\n')
renameSync(tmp, runtimePath)
return { workerId, ok: true }
} catch (err) {
return { workerId, ok: false, error: err instanceof Error ? err.message : String(err) }
}
}
export const Route = createFileRoute('/api/swarm-missions')({
server: {
handlers: {
@@ -106,9 +69,9 @@ export const Route = createFileRoute('/api/swarm-missions')({
if (cancelledIds.has(assignment.id)) workerIds.add(assignment.workerId)
}
}
if (workerId && /^swarm\d+$/i.test(workerId)) workerIds.add(workerId)
if (workerId) workerIds.add(workerId)
const runtimeResets = body.resetWorkers !== false
? Array.from(workerIds).map((id) => resetWorkerRuntime(id, reason, actor))
? Array.from(workerIds).map((id) => resetSwarmWorkerRuntime(id, { actor, reason }))
: []
return json({

View File

@@ -0,0 +1,67 @@
import { createFileRoute } from '@tanstack/react-router'
import { json } from '@tanstack/react-start'
import { isAuthenticated } from '../../server/auth-middleware'
import { requireJsonContentType } from '../../server/rate-limit'
import { resetSwarmWorkerRuntimes, resolveResetTargetWorkerIds } from '../../server/swarm-runtime-reset'
type ResetBody = {
workerIds?: unknown
reason?: unknown
actor?: unknown
}
function cleanString(value: unknown): string | null {
return typeof value === 'string' && value.trim() ? value.trim() : null
}
export const Route = createFileRoute('/api/swarm-runtime/reset')({
server: {
handlers: {
POST: async ({ request }) => {
if (!isAuthenticated(request)) {
return json({ ok: false, error: 'Unauthorized' }, { status: 401 })
}
const csrfCheck = requireJsonContentType(request)
if (csrfCheck) return csrfCheck
let body: ResetBody
try {
body = (await request.json()) as ResetBody
} catch {
return json({ ok: false, error: 'Invalid JSON body' }, { status: 400 })
}
if (body.workerIds !== undefined && !Array.isArray(body.workerIds)) {
return json({ ok: false, error: 'workerIds must be an array of worker ids when provided' }, { status: 400 })
}
const actor = cleanString(body.actor) ?? 'swarm-runtime-reset'
const reason = cleanString(body.reason) ?? 'Swarm runtime reset from Workspace API'
const requestedWorkerIds = Array.isArray(body.workerIds)
? body.workerIds.filter((value): value is string => typeof value === 'string')
: undefined
const targets = resolveResetTargetWorkerIds(requestedWorkerIds)
if (!targets.ok || !targets.workerIds) {
return json({ ok: false, error: targets.error ?? 'Unable to resolve worker ids' }, { status: 400 })
}
const results = resetSwarmWorkerRuntimes(targets.workerIds, { actor, reason })
const resetCount = results.filter((result) => result.ok).length
const failureCount = results.length - resetCount
const status = failureCount > 0 ? 207 : 200
return json({
ok: failureCount === 0,
actor,
reason,
workerIds: targets.workerIds,
results,
resetCount,
failureCount,
resetAt: Date.now(),
}, { status })
},
},
},
})

View File

@@ -485,11 +485,29 @@ export function ChatScreen({
// resolvedSessionKey isn't available yet (defined below), so we track it via
// a ref that's updated once it resolves. The memo/callback read the ref.
const sessionKeyForWaiting = useRef<string | undefined>(undefined)
const [activeRunCheckDone, setActiveRunCheckDone] = useState(false)
// Track stale-restored sessions that need API verification before showing thinking.
// On page reload, sessionStorage may contain stale "waiting" flags from a
// previous session. We must not show the thinking indicator until the
// active-run API check confirms the run is genuinely active. (Issue #449)
const pendingVerifySessionKeyRef = useRef<string | undefined>(undefined)
const waitingForResponse = useMemo(() => {
const key = sessionKeyForWaiting.current
if (!key) return hasPendingSend() || hasPendingGeneration()
// If we restored waiting state from sessionStorage but haven't verified
// with the API yet, don't show thinking — it might be stale (Issue #449).
if (
storeWaiting.has(key) &&
pendingVerifySessionKeyRef.current === key &&
!activeRunCheckDone
) {
return false
}
return storeWaiting.has(key)
}, [storeWaiting])
}, [storeWaiting, activeRunCheckDone])
const setWaitingForResponse = useCallback((waiting: boolean) => {
const store = useChatStore.getState()
@@ -595,11 +613,30 @@ export function ChatScreen({
// Keep the waiting-state ref in sync with the resolved session key
sessionKeyForWaiting.current = resolvedSessionKey
// Detect stale restored waiting state from sessionStorage — we need API
// verification before showing thinking (Issue #449).
useEffect(() => {
const currentSessionKey = resolvedSessionKey
if (!currentSessionKey || isNewChat) return
const store = useChatStore.getState()
if (store.isSessionWaiting(currentSessionKey)) {
pendingVerifySessionKeyRef.current = currentSessionKey
setActiveRunCheckDone(false)
} else {
// No restored waiting state — no need to verify
pendingVerifySessionKeyRef.current = undefined
setActiveRunCheckDone(true)
}
}, [resolvedSessionKey, isNewChat])
// On remount, check if the server still has an active run for this session.
// If so, re-set waitingForResponse in the store so the UI shows the spinner.
useActiveRunCheck({
sessionKey: resolvedSessionKey ?? '',
enabled: !isNewChat && Boolean(resolvedSessionKey) && historyQuery.isSuccess,
onCheckComplete: useCallback(() => {
setActiveRunCheckDone(true)
}, []),
})
// Wire SSE realtime stream for instant message delivery

View File

@@ -34,13 +34,17 @@ import type {
SlashCommandDefinition,
SlashCommandMenuHandle,
} from '@/components/slash-command-menu'
import {
DEFAULT_SLASH_COMMANDS,
mergeSlashCommands,
SlashCommandMenu,
} from '@/components/slash-command-menu'
import {
PromptInput,
PromptInputAction,
PromptInputActions,
PromptInputTextarea,
} from '@/components/prompt-kit/prompt-input'
import { SlashCommandMenu } from '@/components/slash-command-menu'
import { useSettings } from '@/hooks/use-settings'
import { MOBILE_TAB_BAR_OFFSET } from '@/components/mobile-tab-bar'
import { useWorkspaceStore } from '@/stores/workspace-store'
@@ -211,6 +215,39 @@ type ClaudeAvailableModelsResponse = {
providers: Array<ClaudeProviderOption>
}
type InstalledSkillSummary = {
id: string
name: string
description: string
installed: boolean
enabled: boolean
}
async function fetchInstalledSkills(): Promise<Array<InstalledSkillSummary>> {
const response = await fetch('/api/skills?tab=installed&limit=120')
if (!response.ok) {
throw new Error(`Skills request failed (${response.status})`)
}
const payload = (await response.json()) as {
skills?: Array<Record<string, unknown>>
ok?: boolean
}
const skills = Array.isArray(payload.skills) ? payload.skills : []
return skills
.map((entry) => {
const id = readModelText(entry.id) || readModelText(entry.slug) || readModelText(entry.name)
if (!id) return null
const name = readModelText(entry.name) || id
const description = readModelText(entry.description)
const installed = entry.installed !== false
const enabled = entry.enabled !== false
return { id, name, description, installed, enabled }
})
.filter((entry): entry is InstalledSkillSummary => entry !== null)
}
async function fetchModels(): Promise<{
ok?: boolean
models?: Array<ModelCatalogEntry>
@@ -968,6 +1005,12 @@ function ChatComposerComponent({
retry: false,
staleTime: 15_000,
})
const installedSkillsQuery = useQuery({
queryKey: ['chat', 'composer', 'installed-skills'],
queryFn: fetchInstalledSkills,
retry: false,
staleTime: 60_000,
})
const workspaceContextQuery = useQuery({
queryKey: ['workspace', 'composer-context'],
queryFn: fetchWorkspaceContext,
@@ -1628,6 +1671,19 @@ function ChatComposerComponent({
const promptPlaceholder = isMobileViewport
? 'Message...'
: 'Ask anything... (↵ to send · ⇧↵ new line · ⌘⇧M switch model)'
const slashCommands = useMemo(
() =>
mergeSlashCommands(
DEFAULT_SLASH_COMMANDS,
(installedSkillsQuery.data ?? [])
.filter((skill) => skill.installed && skill.enabled)
.map((skill) => ({
command: `/${skill.id}`,
description: skill.description || `Run ${skill.name}`,
})),
),
[installedSkillsQuery.data],
)
const slashCommandQuery = useMemo(() => readSlashCommandQuery(value), [value])
const isSlashMenuOpen =
slashCommandQuery !== null && !disabled && !isSlashMenuDismissed
@@ -2076,6 +2132,7 @@ function ChatComposerComponent({
ref={slashMenuRef}
open={isSlashMenuOpen}
query={slashCommandQuery ?? ''}
commands={slashCommands}
onSelect={handleSelectSlashCommand}
/>

View File

@@ -1,4 +1,4 @@
import { useEffect, useRef } from 'react'
import { useCallback, useEffect, useRef } from 'react'
import { useChatStore } from '../../../stores/chat-store'
type ActiveRunStatus =
@@ -37,13 +37,17 @@ const ACTIVE_STATUSES: ReadonlySet<string> = new Set([
export function useActiveRunCheck({
sessionKey,
enabled,
onCheckComplete,
}: {
sessionKey: string
enabled: boolean
onCheckComplete?: () => void
}): void {
const hasCheckedRef = useRef(false)
const sessionKeyRef = useRef(sessionKey)
sessionKeyRef.current = sessionKey
const onCompleteRef = useRef(onCheckComplete)
onCompleteRef.current = onCheckComplete
useEffect(() => {
if (!enabled || !sessionKey || sessionKey === 'new') return
@@ -72,6 +76,8 @@ export function useActiveRunCheck({
}
} catch {
// Network error or abort — ignore
} finally {
onCompleteRef.current?.()
}
}

View File

@@ -324,31 +324,85 @@ export function useRealtimeChatHistory({
const prevCount =
(prevData?.messages as Array<unknown> | undefined)?.length ?? 0
// Refetch immediately — done event message is already in realtime store
queryClient.invalidateQueries({ queryKey: key }).then(() => {
clearCompletedStreaming()
// Issue #441 fix: Directly merge realtime buffer into history cache
// INSTEAD of invalidateQueries. The old approach caused a race:
// invalidateQueries → refetch (async) → merge runs with stale data
// → duplicates appear briefly → refetch completes → fixed.
//
// New approach: merge realtime messages into the cache synchronously,
// then clear the realtime buffer in the same tick. A background
// refetch runs after for consistency but doesn't block rendering.
const store = useChatStore.getState()
const realtimeMessages =
store.realtimeMessages.get(effectiveSessionKey) ?? []
const historyMessages = prevData?.messages as
| Array<unknown>
| undefined
// Check for compaction — significant message count drop
const newData =
queryClient.getQueryData<Record<string, unknown>>(key)
const newCount =
(newData?.messages as Array<unknown> | undefined)?.length ?? 0
if (
prevCount > 10 &&
newCount > 0 &&
newCount < prevCount * 0.6
) {
onCompactionEnd?.()
toast(
'Context compacted — older messages were summarized to free up space',
{
type: 'info',
icon: '🗜️',
duration: 8000,
if (realtimeMessages.length > 0 && Array.isArray(historyMessages)) {
// Deduplicate: remove any realtime messages already in history
const historyTexts = new Set(
historyMessages.map((m: unknown) => {
const raw = m as Record<string, unknown>
const content = raw.content ?? raw.text ?? ''
return `${raw.role ?? ''}:${JSON.stringify(content)}`
}),
)
const dedupedRealtime = realtimeMessages.filter((m: unknown) => {
const raw = m as Record<string, unknown>
const content = raw.content ?? raw.text ?? ''
const sig = `${raw.role ?? ''}:${JSON.stringify(content)}`
return !historyTexts.has(sig)
})
if (dedupedRealtime.length > 0) {
const merged = [...historyMessages, ...dedupedRealtime].sort(
(a: unknown, b: unknown) => {
const aTs = (a as Record<string, unknown>).createdAt as
| number
| undefined
const bTs = (b as Record<string, unknown>).createdAt as
| number
| undefined
if (typeof aTs === 'number' && typeof bTs === 'number')
return aTs - bTs
return 0
},
)
queryClient.setQueryData(key, {
...(prevData ?? {}),
messages: merged,
})
}
})
}
// Clear realtime buffer immediately — no more stale data in render
store.clearRealtimeBuffer(effectiveSessionKey)
clearCompletedStreaming()
// Background refetch for long-term consistency — doesn't block render
queryClient.invalidateQueries({ queryKey: key, refetchType: 'all' })
// Check for compaction — significant message count drop
const newData =
queryClient.getQueryData<Record<string, unknown>>(key)
const newCount =
(newData?.messages as Array<unknown> | undefined)?.length ?? 0
if (
prevCount > 10 &&
newCount > 0 &&
newCount < prevCount * 0.6
) {
onCompactionEnd?.()
toast(
'Context compacted — older messages were summarized to free up space',
{
type: 'info',
icon: '🗜️',
duration: 8000,
},
)
}
}
}
},

View File

@@ -1132,7 +1132,7 @@ export function Conductor() {
return (
<div className="flex min-h-dvh flex-col overflow-y-auto bg-[var(--theme-bg)] text-[var(--theme-text)]" style={THEME_STYLE}>
<main className="mx-auto flex min-h-0 w-full max-w-[720px] flex-1 flex-col px-4 py-4 pb-[calc(var(--tabbar-h,80px)+1rem)] md:px-6 md:py-8">
<main className="mx-auto flex min-h-0 w-full max-w-[720px] flex-1 flex-col px-4 py-4 pb-4 md:pb-[calc(var(--tabbar-h,80px)+1rem)] md:px-6 md:py-8">
<div className="space-y-6">
<button
type="button"
@@ -1293,7 +1293,7 @@ export function Conductor() {
return (
<div className="flex min-h-dvh flex-col overflow-y-auto bg-[var(--theme-bg)] text-[var(--theme-text)]" style={THEME_STYLE}>
<main className="mx-auto flex min-h-0 w-full max-w-[760px] flex-1 flex-col items-stretch justify-center px-4 py-4 pb-[calc(var(--tabbar-h,80px)+1rem)] md:px-6 md:py-6">
<main className="mx-auto flex min-h-0 w-full max-w-[760px] flex-1 flex-col items-stretch justify-center px-4 py-4 pb-4 md:pb-[calc(var(--tabbar-h,80px)+1rem)] md:px-6 md:py-6">
<div className="w-full space-y-6">
<div className="space-y-2 text-center">
<div className="relative flex items-center justify-center">
@@ -1831,8 +1831,8 @@ export function Conductor() {
if (phase === 'preview') {
return (
<div className="flex min-h-dvh flex-col bg-[var(--theme-bg)] text-[var(--theme-text)]" style={THEME_STYLE}>
<main className="mx-auto flex min-h-0 w-full max-w-[720px] flex-1 flex-col items-stretch justify-center px-4 py-4 pb-[calc(var(--tabbar-h,80px)+1rem)] md:px-6 md:py-8">
<div className="flex min-h-dvh flex-col overflow-y-auto bg-[var(--theme-bg)] text-[var(--theme-text)]" style={THEME_STYLE}>
<main className="mx-auto flex min-h-0 w-full max-w-[720px] flex-1 flex-col px-4 py-4 pb-4 md:pb-[calc(var(--tabbar-h,80px)+1rem)] md:px-6 md:py-8">
<div className="space-y-6">
<div className="space-y-2 text-center">
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-[var(--theme-accent)]">Mission Decomposition</p>
@@ -1891,8 +1891,8 @@ export function Conductor() {
if (phase === 'complete') {
return (
<div className="flex min-h-dvh flex-col bg-[var(--theme-bg)] text-[var(--theme-text)]" style={THEME_STYLE}>
<main className="mx-auto flex min-h-0 w-full max-w-[720px] flex-1 flex-col px-4 py-4 pb-[calc(var(--tabbar-h,80px)+1rem)] md:px-6 md:py-8">
<div className="flex min-h-dvh flex-col overflow-y-auto bg-[var(--theme-bg)] text-[var(--theme-text)]" style={THEME_STYLE}>
<main className="mx-auto flex min-h-0 w-full max-w-[720px] flex-1 flex-col px-4 py-4 pb-4 md:pb-[calc(var(--tabbar-h,80px)+1rem)] md:px-6 md:py-8">
<div className="space-y-6">
<div className="text-center">
<div className="inline-flex items-center gap-2 rounded-full border border-[var(--theme-border)] bg-[var(--theme-card)] px-4 py-2 text-xs font-semibold uppercase tracking-[0.24em] text-[var(--theme-muted)]">
@@ -2225,8 +2225,8 @@ export function Conductor() {
}
return (
<div className="flex min-h-dvh flex-col bg-[var(--theme-bg)] text-[var(--theme-text)]" style={THEME_STYLE}>
<main className="mx-auto flex min-h-0 w-full max-w-[720px] flex-1 flex-col justify-center px-4 py-4 pb-[calc(var(--tabbar-h,80px)+1rem)] md:px-6 md:py-8">
<div className="flex min-h-dvh flex-col overflow-y-auto bg-[var(--theme-bg)] text-[var(--theme-text)]" style={THEME_STYLE}>
<main className="mx-auto flex min-h-0 w-full max-w-[720px] flex-1 flex-col px-4 py-4 pb-4 md:pb-[calc(var(--tabbar-h,80px)+1rem)] md:px-6 md:py-8">
<div className="flex w-full flex-col gap-6">
<div className="text-center">
<div className="inline-flex items-center gap-2 rounded-full border border-[var(--theme-border)] bg-[var(--theme-card)] px-4 py-2 text-xs font-semibold uppercase tracking-[0.24em] text-[var(--theme-muted)]">
@@ -2315,7 +2315,7 @@ export function Conductor() {
</div>
</section>
)}
<section className="h-[360px] overflow-hidden rounded-3xl border border-[var(--theme-border)] bg-[var(--theme-card)] shadow-[0_24px_80px_var(--theme-shadow)]">
<section className="max-h-[clamp(200px,40vh,360px)] overflow-hidden rounded-3xl border border-[var(--theme-border)] bg-[var(--theme-card)] shadow-[0_24px_80px_var(--theme-shadow)]">
<OfficeView agentRows={officeAgentRows} missionRunning onViewOutput={() => {}} processType="parallel" companyName="Conductor Office" containerHeight={360} hideHeader />
</section>

View File

@@ -27,6 +27,16 @@ type ConductorMissionRecord = {
session_id?: string | null
lines?: unknown
exit_code?: number | null
// Native-swarm fields returned by the conductor-spawn GET handler
nativeSwarm?: boolean
updatedAt?: number
assignments?: Array<{
id?: string
workerId: string
task?: string
state?: string
checkpoint?: { stateLabel?: string; result?: string; nextAction?: string } | null
}>
}
type ConductorMissionResponse = {
@@ -1013,7 +1023,52 @@ export function useConductorGateway() {
refetchInterval: phase === 'decomposing' || phase === 'running' ? 2_500 : false,
})
const workers = sessionsQuery.data ?? []
const sessionWorkers = sessionsQuery.data ?? []
// For native-swarm missions, build virtual worker cards from the mission
// assignments so the UI shows progress instead of "Spawning workers..." forever.
const swarmAssignments = missionStatusQuery.data?.assignments
const isNativeSwarm = missionStatusQuery.data?.nativeSwarm === true
const virtualWorkers = useMemo<ConductorWorker[]>(() => {
if (!isNativeSwarm || !swarmAssignments || swarmAssignments.length === 0) return []
const missionUpdatedAt = new Date(missionStatusQuery.data?.updatedAt ?? Date.now()).toISOString()
return swarmAssignments.map((assignment, index) => {
const workerId = assignment.workerId
const state = assignment.state ?? 'dispatched'
const checkpoint = assignment.checkpoint
const isComplete = state === 'checkpointed' || state === 'done' || state === 'cancelled'
const isBlocked = state === 'blocked' || state === 'needs_input'
const personaNames = ['Nova', 'Pixel', 'Blaze', 'Echo', 'Sage', 'Drift', 'Flux', 'Volt']
const persona = personaNames[index % personaNames.length]
return {
key: workerId,
label: workerId,
model: 'native-swarm',
status: isComplete ? 'complete' : isBlocked ? 'stale' : 'running',
updatedAt: missionUpdatedAt,
displayName: `${persona} · ${state}`,
totalTokens: 0,
contextTokens: 0,
tokenUsageLabel: state,
raw: {
key: workerId,
label: workerId,
friendlyId: workerId,
status: isComplete ? 'completed' : 'running',
model: 'native-swarm',
lastMessage: null,
createdAt: missionStatusQuery.data?.updatedAt ?? Date.now(),
startedAt: missionStatusQuery.data?.updatedAt ?? Date.now(),
updatedAt: Date.now(),
} as GatewaySession,
}
})
}, [isNativeSwarm, swarmAssignments])
const workers = useMemo(() => {
if (sessionWorkers.length > 0) return sessionWorkers
return virtualWorkers
}, [sessionWorkers, virtualWorkers])
const activeWorkers = useMemo(() => workers.filter((worker) => worker.status === 'running' || worker.status === 'idle'), [workers])
const hasPersistedMission = initialMission !== null

View File

@@ -72,11 +72,33 @@ function roleFromId(id: string): string {
}
}
function deriveWorkerState(member: CrewMember, currentTask: string | null): WorkerState {
function deriveWorkerState(
member: CrewMember,
currentTask: string | null,
checkpointStatus?: string | null,
runtimeState?: string | null,
): WorkerState {
const status = getOnlineStatus(member)
if (status === 'offline') return 'offline'
// Authoritative runtime state takes precedence over the title heuristic.
// SwarmCheckpointStatus: 'none' | 'in_progress' | 'done' | 'blocked' | 'handoff' | 'needs_input'
// SwarmWorkerState: 'idle' | 'executing' | 'thinking' | 'writing' | 'waiting' | 'blocked' | 'syncing' | 'reviewing' | 'offline'
const cs = checkpointStatus ?? null
const rs = runtimeState ?? null
// Terminal-done: a finished worker renders as Idle (there is no 'done' WorkerState).
if (cs === 'done' || cs === 'handoff' || rs === 'idle') return 'idle'
// Blocked from either authoritative source.
if (cs === 'blocked' || rs === 'blocked') return 'error'
// Needs human input / waiting.
if (cs === 'needs_input' || rs === 'waiting') return 'waiting'
if (!currentTask) return 'idle'
// Safety: a set, non-in-progress checkpoint must never render as active.
if (cs && cs !== 'none' && cs !== 'in_progress') return 'idle'
const lc = currentTask.toLowerCase()
if (lc.includes('review')) return 'reviewing'
if (lc.includes('writ') || lc.includes('doc') || lc.includes('spec')) return 'writing'
@@ -191,6 +213,8 @@ const AVATAR_OPTIONS = ['','🤖','🧠','🛠️','📊','🧪','📝','⚙️'
export type OperationalWorkerCardProps = {
member: CrewMember
currentTask?: string | null
checkpointStatus?: string | null
runtimeState?: string | null
recentLines?: Array<string>
recentOutputAt?: number | null
recentSummary?: string | null
@@ -208,6 +232,8 @@ export type OperationalWorkerCardProps = {
export function OperationalWorkerCard({
member,
currentTask = null,
checkpointStatus = null,
runtimeState = null,
recentOutputAt = null,
recentSummary = null,
artifacts = [],
@@ -228,7 +254,7 @@ export function OperationalWorkerCard({
const [draftModel, setDraftModel] = useState('')
const [draftAvatar, setDraftAvatar] = useState('')
const [taskComposerOpen, setTaskComposerOpen] = useState(false)
const state = deriveWorkerState(member, currentTask)
const state = deriveWorkerState(member, currentTask, checkpointStatus, runtimeState)
const status = statusStyles(state)
const role = settings.role || member.role || roleFromId(member.id)
const displayName = settings.displayName || member.displayName || member.id

View File

@@ -153,6 +153,7 @@ type RuntimeEntry = {
lastResult?: string | null
blockedReason?: string | null
checkpointStatus?: string | null
state?: string | null
needsHuman?: boolean | null
assignedTaskCount?: number | null
cronJobCount?: number | null
@@ -839,6 +840,8 @@ function ControlPlaneStage({
cardRef={setWorkerRef(member.id)}
member={member}
currentTask={runtime?.currentTask ?? null}
checkpointStatus={runtime?.checkpointStatus ?? null}
runtimeState={runtime?.state ?? null}
recentLines={recentLines(runtime)}
recentOutputAt={runtime?.lastOutputAt ?? runtime?.lastSessionStartedAt ?? null}
recentSummary={runtime?.lastRealSummary ?? runtime?.lastRealResult ?? runtime?.lastSummary ?? runtime?.lastResult ?? runtime?.blockedReason ?? null}

View File

@@ -100,6 +100,32 @@ describe('profiles-browser', () => {
warnSpy.mockRestore()
})
it('skips sticky active_profile writes when HERMES_WORKSPACE_STICKY_PROFILE=0', async () => {
process.env.HERMES_WORKSPACE_STICKY_PROFILE = '0'
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
existsSync.mockImplementation((p: string) => {
if (p === path.join('/home/testuser', '.hermes', 'profiles', 'jarvis')) return true
if (p === path.join('/home/testuser', '.hermes', 'active_profile')) return true
return false
})
const mod = await loadMod()
mod.setActiveProfile('jarvis')
mod.setActiveProfile('default')
expect(writeFileSync).not.toHaveBeenCalledWith(
path.join('/home/testuser', '.hermes', 'active_profile'),
expect.anything(),
'utf-8',
)
expect(unlinkSync).not.toHaveBeenCalledWith(path.join('/home/testuser', '.hermes', 'active_profile'))
expect(warnSpy).toHaveBeenCalledTimes(1)
warnSpy.mockRestore()
delete process.env.HERMES_WORKSPACE_STICKY_PROFILE
})
it('clears active profile file when setting default', async () => {
existsSync.mockImplementation((p: string) => {
if (p === path.join('/home/testuser', '.hermes', 'active_profile')) return true
@@ -112,6 +138,45 @@ describe('profiles-browser', () => {
})
})
describe('renameProfile', () => {
it('skips sticky active_profile rewrites on rename when HERMES_WORKSPACE_STICKY_PROFILE=0', async () => {
process.env.HERMES_WORKSPACE_STICKY_PROFILE = '0'
const profilesRoot = path.join('/home/testuser', '.hermes', 'profiles')
const oldPath = path.join(profilesRoot, 'jarvis')
const newPath = path.join(profilesRoot, 'friday')
const configPath = path.join(newPath, 'config.yaml')
let renamedOnDisk = false
renameSync.mockImplementation(() => {
renamedOnDisk = true
})
existsSync.mockImplementation((p: string) => {
if (p === oldPath) return true
if (p === newPath) return renamedOnDisk
if (p === configPath) return renamedOnDisk
return false
})
readFileSync.mockImplementation((p: string) => {
if (p === path.join('/home/testuser', '.hermes', 'active_profile')) return 'jarvis\n'
if (p === configPath) return 'model: named-model\n'
return ''
})
const mod = await loadMod()
const renamed = mod.renameProfile('jarvis', 'friday')
expect(renameSync).toHaveBeenCalledWith(oldPath, newPath)
expect(writeFileSync).not.toHaveBeenCalledWith(
path.join('/home/testuser', '.hermes', 'active_profile'),
expect.anything(),
'utf-8',
)
expect(renamed.name).toBe('friday')
delete process.env.HERMES_WORKSPACE_STICKY_PROFILE
})
})
describe('updateProfileConfig', () => {
it('deep-merges nested objects instead of overwriting', async () => {
const root = path.join('/home/testuser', '.hermes')

View File

@@ -108,6 +108,7 @@ export async function sendChatUnified(
temperature: options.temperature,
signal: options.signal,
stream: false,
sessionId: options.sessionId,
})
}
@@ -134,6 +135,7 @@ export async function streamChatUnified(
temperature: options.temperature,
signal: options.signal,
stream: true,
sessionId: options.sessionId,
})
// Adapt StreamChunkType to plain string for legacy callers
async function* toStringStream() {

View File

@@ -0,0 +1,46 @@
import { mkdtempSync, mkdirSync, rmSync } from 'node:fs'
import { join } from 'node:path'
import { tmpdir } from 'node:os'
import { afterEach, describe, expect, it } from 'vitest'
import { resolveClaudeAgentDir } from './claude-agent'
const tempDirs: string[] = []
function createAgentDir(prefix: string): string {
const dir = mkdtempSync(join(tmpdir(), prefix))
mkdirSync(join(dir, 'webapi'))
tempDirs.push(dir)
return dir
}
afterEach(() => {
for (const dir of tempDirs.splice(0)) {
rmSync(dir, { recursive: true, force: true })
}
})
describe('resolveClaudeAgentDir', () => {
it('prefers HERMES_AGENT_PATH when it points to a valid hermes-agent checkout', () => {
const hermesAgentDir = createAgentDir('hermes-agent-')
const legacyAgentDir = createAgentDir('claude-agent-')
expect(
resolveClaudeAgentDir({
HERMES_AGENT_PATH: hermesAgentDir,
CLAUDE_AGENT_PATH: legacyAgentDir,
}),
).toBe(hermesAgentDir)
})
it('falls back to legacy CLAUDE_AGENT_PATH for backward compatibility', () => {
const legacyAgentDir = createAgentDir('claude-agent-')
expect(
resolveClaudeAgentDir({
CLAUDE_AGENT_PATH: legacyAgentDir,
}),
).toBe(legacyAgentDir)
})
})

View File

@@ -58,8 +58,9 @@ export function resolveClaudeAgentDir(
): string | null {
const candidates: Array<string> = []
if (env.CLAUDE_AGENT_PATH?.trim()) {
candidates.push(env.CLAUDE_AGENT_PATH.trim())
const explicitAgentPath = env.HERMES_AGENT_PATH?.trim() || env.CLAUDE_AGENT_PATH?.trim()
if (explicitAgentPath) {
candidates.push(explicitAgentPath)
}
const workspaceRoot = dirname(resolve('.'))

View File

@@ -19,18 +19,15 @@
import fs from 'node:fs'
import path from 'node:path'
import os from 'node:os'
import { getStateDir } from './workspace-state-dir'
type WorkspaceOverrides = {
claudeApiUrl?: string
claudeDashboardUrl?: string
}
function hermesHome(): string {
return process.env.HERMES_HOME ?? process.env.CLAUDE_HOME ?? path.join(os.homedir(), '.hermes')
}
function overridesPath(): string {
return path.join(hermesHome(), 'workspace-overrides.json')
return path.join(getStateDir(), 'workspace-overrides.json')
}
function readOverrides(): WorkspaceOverrides {

View File

@@ -1,6 +1,7 @@
import fs from 'node:fs'
import os from 'node:os'
import path from 'node:path'
import { getStateDir } from './workspace-state-dir'
export type KnowledgeBaseSource =
| { type: 'local'; path: string }
@@ -15,9 +16,7 @@ const DEFAULT_CONFIG: KnowledgeBaseConfig = {
}
function getConfigPath(): string {
const claudeHome =
process.env.HERMES_HOME ?? process.env.CLAUDE_HOME ?? path.join(os.homedir(), '.hermes')
return path.join(claudeHome, 'knowledge-config.json')
return path.join(getStateDir(), 'knowledge-config.json')
}
export function readKnowledgeBaseConfig(): KnowledgeBaseConfig {

View File

@@ -21,9 +21,9 @@ import {
unlinkSync,
writeSync,
} from 'node:fs'
import { homedir } from 'node:os'
import { dirname, join } from 'node:path'
import { randomBytes } from 'node:crypto'
import { getStateDir } from './workspace-state-dir'
// ---------------------------------------------------------------------------
// Types
@@ -99,12 +99,8 @@ const KNOWN_TOP_FIELDS = new Set(['version', 'sources'])
// Path helpers
// ---------------------------------------------------------------------------
function hermesHome(): string {
return process.env.HERMES_HOME?.trim() || process.env.CLAUDE_HOME?.trim() || join(homedir(), '.hermes')
}
export function hubSourcesFilePath(): string {
return join(hermesHome(), 'mcp-hub-sources.json')
return join(getStateDir(), 'mcp-hub-sources.json')
}
// ---------------------------------------------------------------------------

View File

@@ -22,12 +22,12 @@ import {
unlinkSync,
writeSync,
} from 'node:fs'
import { homedir } from 'node:os'
import { dirname, join, resolve as pathResolve } from 'node:path'
import { fileURLToPath } from 'node:url'
import { randomBytes } from 'node:crypto'
import type { McpClientInput } from '../types/mcp'
import { parseMcpServerInput } from './mcp-input-validate'
import { getStateDir } from './workspace-state-dir'
export interface McpPreset {
id: string
@@ -92,16 +92,8 @@ const TOP_KNOWN_FIELDS = new Set(['version', 'presets'])
let _cache: CacheEntry | null = null
function hermesHome(): string {
const override = process.env.HERMES_HOME?.trim()
if (override) return override
const claudeHome = process.env.CLAUDE_HOME?.trim()
if (claudeHome) return claudeHome
return join(homedir(), '.hermes')
}
export function presetsFilePath(): string {
return join(hermesHome(), 'mcp-presets.json')
return join(getStateDir(), 'mcp-presets.json')
}
/**

View File

@@ -22,9 +22,9 @@ import {
unlinkSync,
writeSync,
} from 'node:fs'
import { homedir } from 'node:os'
import { dirname, join } from 'node:path'
import { randomBytes } from 'node:crypto'
import { getStateDir } from './workspace-state-dir'
export interface CachedProbe {
status: 'connected' | 'failed' | 'unknown'
@@ -58,16 +58,8 @@ function getTtlMs(): number {
return DEFAULT_TTL_MS
}
function hermesHome(): string {
const override = process.env.HERMES_HOME?.trim()
if (override) return override
const claudeHome = process.env.CLAUDE_HOME?.trim()
if (claudeHome) return claudeHome
return join(homedir(), '.hermes')
}
export function cacheFilePath(): string {
return join(hermesHome(), 'cache', 'mcp-tools.json')
return join(getStateDir(), 'cache', 'mcp-tools.json')
}
// ---------------------------------------------------------------------------

View File

@@ -1,6 +1,6 @@
import { describe, expect, it } from 'vitest'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { parseOpenAIStream } from './openai-compat-api'
import { openaiChat, parseOpenAIStream } from './openai-compat-api'
function createStreamResponse(chunks: string[]): Response {
const encoder = new TextEncoder()
@@ -21,6 +21,60 @@ function createStreamResponse(chunks: string[]): Response {
)
}
const ORIGINAL_HOME = process.env.HOME
afterEach(() => {
vi.restoreAllMocks()
delete process.env.HERMES_API_TOKEN
delete process.env.CLAUDE_API_TOKEN
if (ORIGINAL_HOME === undefined) delete process.env.HOME
else process.env.HOME = ORIGINAL_HOME
})
describe('openaiChat', () => {
it('sends Hermes session continuity headers with authentication when available', async () => {
process.env.HERMES_API_TOKEN = 'test-token'
const fetchMock = vi.fn().mockResolvedValue(
new Response(
JSON.stringify({ choices: [{ message: { content: 'ok' } }] }),
{ status: 200, headers: { 'Content-Type': 'application/json' } },
),
)
vi.stubGlobal('fetch', fetchMock)
await openaiChat([{ role: 'user', content: 'hello' }], {
model: 'hermes-agent',
sessionId: 'workspace-session-1',
})
const headers = fetchMock.mock.calls[0]?.[1]?.headers as Record<string, string>
expect(headers.Authorization).toBe('Bearer test-token')
expect(headers['X-Hermes-Session-Id']).toBe('workspace-session-1')
expect(headers['X-Claude-Session-Id']).toBe('workspace-session-1')
})
it('sends Hermes session continuity headers even without a bearer token', async () => {
process.env.HOME = '/tmp/hermes-workspace-test-no-codex-auth'
const fetchMock = vi.fn().mockResolvedValue(
new Response(
JSON.stringify({ choices: [{ message: { content: 'ok' } }] }),
{ status: 200, headers: { 'Content-Type': 'application/json' } },
),
)
vi.stubGlobal('fetch', fetchMock)
await openaiChat([{ role: 'user', content: 'hello' }], {
model: 'hermes-agent',
sessionId: 'workspace-session-2',
})
const headers = fetchMock.mock.calls[0]?.[1]?.headers as Record<string, string>
expect(headers.Authorization).toBeUndefined()
expect(headers['X-Hermes-Session-Id']).toBe('workspace-session-2')
expect(headers['X-Claude-Session-Id']).toBe('workspace-session-2')
})
})
describe('parseOpenAIStream', () => {
it('passes through ordinary content chunks', async () => {
const response = createStreamResponse([

View File

@@ -283,9 +283,14 @@ export async function openaiChat(
if (bearer) {
headers['Authorization'] = `Bearer ${bearer}`
}
// Only send session header when authenticated — gateways without
// API_SERVER_KEY reject this header with an auth error.
if (options.sessionId && bearer) {
// Session continuity is part of request routing, not authentication.
// If the gateway requires auth, _check_auth has already validated the
// bearer above; when it does not, dropping these headers forces Hermes
// Agent to derive a fresh api-* session from each message payload.
if (options.sessionId) {
headers['X-Hermes-Session-Id'] = options.sessionId
// Back-compat for older/Claude-compatible adapters that still look for
// the pre-Hermes header name. Hermes Agent ignores this alias.
headers['X-Claude-Session-Id'] = options.sessionId
}

View File

@@ -6,7 +6,7 @@ import {
} from './portable-history'
describe('portable history replay', () => {
it('skips replay when the gateway can bind portable chat to a server session', () => {
it('skips replay when the Hermes gateway can continue the server-side session', () => {
expect(
shouldReplayPortableHistory({
bearerToken: 'token',
@@ -32,12 +32,12 @@ describe('portable history replay', () => {
).toEqual([{ role: 'assistant', content: 'old reply' }])
})
it('falls back to client-sent history when no persisted local session exists', () => {
it('falls back to client-sent history for direct local-provider requests when no persisted local session exists', () => {
expect(
selectPortableConversationHistory(
[],
[{ role: 'user', content: 'fallback' }],
{ bearerToken: '' },
{ localBaseUrl: 'http://127.0.0.1:11434', bearerToken: '' },
),
).toEqual([{ role: 'user', content: 'fallback' }])
})

View File

@@ -1,5 +1,3 @@
import { BEARER_TOKEN } from './gateway-capabilities'
export type PortableHistoryMessage = {
role: string
content: string
@@ -10,12 +8,15 @@ export function shouldReplayPortableHistory(options?: {
bearerToken?: string
}): boolean {
const localBaseUrl = options?.localBaseUrl?.trim() || ''
// Direct local-provider / custom-base-url requests remain stateless from the
// workspace perspective, so replay the transcript there.
if (localBaseUrl) return true
const bearerToken =
typeof options?.bearerToken === 'string' ? options.bearerToken : BEARER_TOKEN
return !bearerToken.trim()
// When portable chat targets the Hermes gateway, Workspace now forwards a
// stable X-Hermes-Session-Id / X-Claude-Session-Id for server-side session
// continuity. Replaying the full transcript on every turn would duplicate
// prompt context and can explode token usage.
return false
}
export function selectPortableConversationHistory(

View File

@@ -49,6 +49,35 @@ describe('listProfiles', () => {
expect(profiles.find((profile) => profile.name === 'jarvis')?.description).toBe('Named operator')
})
it('skips profiles/default so only the root-backed default card renders', () => {
const hermesRoot = path.join(tempHome, '.hermes')
const defaultDirRoot = path.join(hermesRoot, 'profiles', 'default')
const namedProfileRoot = path.join(hermesRoot, 'profiles', 'builder')
fs.mkdirSync(defaultDirRoot, { recursive: true })
fs.mkdirSync(namedProfileRoot, { recursive: true })
fs.writeFileSync(
path.join(hermesRoot, 'config.yaml'),
'model: root-model\nprovider: openai\ndescription: Root default\n',
'utf-8',
)
fs.writeFileSync(
path.join(namedProfileRoot, 'config.yaml'),
'model: named-model\nprovider: anthropic\ndescription: Named operator\n',
'utf-8',
)
const profiles = listProfiles()
const defaultProfiles = profiles.filter((profile) => profile.name === 'default')
expect(defaultProfiles).toHaveLength(1)
expect(defaultProfiles[0]?.path).toBe(hermesRoot)
expect(defaultProfiles[0]?.model).toBe('root-model')
expect(defaultProfiles[0]?.provider).toBe('openai')
expect(defaultProfiles[0]?.description).toBe('Root default')
expect(profiles.find((profile) => profile.name === 'builder')?.provider).toBe('anthropic')
})
it('reads and updates profile descriptions from config.yaml', () => {
const hermesRoot = path.join(tempHome, '.hermes')
const profileRoot = path.join(hermesRoot, 'profiles', 'builder')

View File

@@ -66,6 +66,10 @@ function getActiveProfilePath(): string {
return path.join(getClaudeRoot(), 'active_profile')
}
function stickyActiveProfileEnabled(): boolean {
return process.env.HERMES_WORKSPACE_STICKY_PROFILE !== '0'
}
/**
* Validate a profile name that will be *written* to disk. The 'default'
* profile is reserved — callers must not create or mutate it via the UI.
@@ -190,6 +194,7 @@ export function listProfiles(): Array<ProfileSummary> {
for (const entry of entries) {
const name = entry.name
if (name === 'default') continue
const profilePath = path.join(profilesRoot, name)
if (!entry.isDirectory()) {
if (!entry.isSymbolicLink()) continue
@@ -327,15 +332,19 @@ export function setActiveProfile(name: string): void {
if (!trimmed) throw new Error('Profile name is required')
// "default" means clear the active_profile file (revert to default)
if (trimmed === 'default') {
const activePath = getActiveProfilePath()
if (fs.existsSync(activePath)) fs.unlinkSync(activePath)
if (stickyActiveProfileEnabled()) {
const activePath = getActiveProfilePath()
if (fs.existsSync(activePath)) fs.unlinkSync(activePath)
}
return
}
const normalized = validateProfileName(trimmed)
const profilePath = path.join(getProfilesRoot(), normalized)
if (!fs.existsSync(profilePath)) throw new Error('Profile not found')
fs.mkdirSync(getClaudeRoot(), { recursive: true })
fs.writeFileSync(getActiveProfilePath(), `${normalized}\n`, 'utf-8')
if (stickyActiveProfileEnabled()) {
fs.mkdirSync(getClaudeRoot(), { recursive: true })
fs.writeFileSync(getActiveProfilePath(), `${normalized}\n`, 'utf-8')
}
console.warn(
`[profiles] Active profile set to "${normalized}". Restart the Hermes Agent gateway for this profile switch to take effect.`,
)
@@ -464,7 +473,7 @@ export function renameProfile(oldName: string, newName: string): ProfileDetail {
if (!fs.existsSync(fromPath)) throw new Error('Profile not found')
if (fs.existsSync(toPath)) throw new Error('Target profile already exists')
fs.renameSync(fromPath, toPath)
if (getActiveProfileName() === from) {
if (stickyActiveProfileEnabled() && getActiveProfileName() === from) {
fs.writeFileSync(getActiveProfilePath(), `${to}\n`, 'utf-8')
}
return readProfile(to)

View File

@@ -42,7 +42,11 @@ export function parseSwarmCheckpoint(text: string): ParsedSwarmCheckpoint | null
const lines = text.replace(/\r\n/g, '\n').split('\n')
for (const line of lines) {
const match = line.match(/^\s*([A-Z_ -]{3,24})\s*:\s*(.*)$/i)
// Handle both plain (STATE:) and bold markdown (**STATE:**) formats.
// Strip Markdown bold markers so the label always sits at start-of-value
// position regardless of formatting.
const cleanLine = line.replace(/\*\*/g, '')
const match = cleanLine.match(/^\s*([A-Z_ -]{3,24})\s*:\s*(.*)$/i)
const label = match ? normalizeLabel(match[1]) : null
if (label) {
current = label

View File

@@ -0,0 +1,103 @@
import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs'
import { join } from 'node:path'
import { getProfilesDir } from './claude-paths'
import { listSwarmWorkerIds } from './swarm-foundation'
export type SwarmRuntimeResetResult = {
workerId: string
ok: boolean
error?: string
}
export function listResettableSwarmWorkerIds(): Array<string> {
return listSwarmWorkerIds({ swarmOnly: true }).filter((workerId) => workerId !== 'workspace')
}
export function resolveResetTargetWorkerIds(workerIds?: Array<string> | null): {
ok: boolean
workerIds?: Array<string>
error?: string
} {
const available = new Set(listResettableSwarmWorkerIds())
if (!workerIds || workerIds.length === 0) {
return { ok: true, workerIds: Array.from(available).sort() }
}
const normalized = Array.from(
new Set(
workerIds
.map((value) => value.trim())
.filter((value) => value.length > 0),
),
)
if (normalized.length === 0) {
return { ok: false, error: 'workerIds must include at least one non-empty worker id' }
}
const unknown = normalized.filter((workerId) => !available.has(workerId))
if (unknown.length > 0) {
return { ok: false, error: `unknown worker ids: ${unknown.join(', ')}` }
}
return { ok: true, workerIds: normalized }
}
export function resetSwarmWorkerRuntime(workerId: string, input: { actor: string; reason: string }): SwarmRuntimeResetResult {
const available = new Set(listResettableSwarmWorkerIds())
if (!available.has(workerId)) {
return { workerId, ok: false, error: 'unknown worker id' }
}
const profilePath = join(getProfilesDir(), workerId)
const runtimePath = join(profilePath, 'runtime.json')
let current: Record<string, unknown> = {}
if (existsSync(runtimePath)) {
try {
current = JSON.parse(readFileSync(runtimePath, 'utf-8')) as Record<string, unknown>
} catch {
current = {}
}
}
try {
mkdirSync(profilePath, { recursive: true })
const now = new Date().toISOString()
const next = {
...current,
workerId,
state: 'idle',
phase: 'cancelled',
currentTask: null,
currentMissionId: null,
currentAssignmentId: null,
checkpointStatus: 'none',
needsHuman: false,
blockedReason: null,
activeTool: null,
checkpointRaw: null,
orchestratorProcessedRaw: null,
lastCheckIn: now,
lastSummary: `Reset by ${input.actor}: ${input.reason}`,
lastControlMessage: `Reset by ${input.actor}: ${input.reason}`,
nextAction: 'Idle. Ready for the next Swarm or Conductor dispatch.',
cancelledAt: now,
cancellationReason: input.reason,
cancelledBy: input.actor,
}
const tmp = `${runtimePath}.${process.pid}.${Date.now()}.tmp`
writeFileSync(tmp, JSON.stringify(next, null, 2) + '\n')
renameSync(tmp, runtimePath)
return { workerId, ok: true }
} catch (error) {
return {
workerId,
ok: false,
error: error instanceof Error ? error.message : String(error),
}
}
}
export function resetSwarmWorkerRuntimes(workerIds: Array<string>, input: { actor: string; reason: string }): Array<SwarmRuntimeResetResult> {
return workerIds.map((workerId) => resetSwarmWorkerRuntime(workerId, input))
}

View File

@@ -108,11 +108,11 @@ export function createTerminalSession(params: {
}
}
// Spawn Python PTY helper
const proc: ChildProcess = spawn(
'python3',
[PTY_HELPER, cwd, String(cols), String(rows), '--', ...command],
{
// Spawn shell directly on Windows, else use Python PTY helper for POSIX
let proc: ChildProcess
if (process.platform === 'win32') {
proc = spawn(command[0], command.slice(1), {
cwd,
env: {
...process.env,
...params.env,
@@ -122,8 +122,24 @@ export function createTerminalSession(params: {
LINES: String(rows),
} as Record<string, string>,
stdio: ['pipe', 'pipe', 'pipe'],
},
)
})
} else {
proc = spawn(
'python3',
[PTY_HELPER, cwd, String(cols), String(rows), '--', ...command],
{
env: {
...process.env,
...params.env,
TERM: 'xterm-256color',
COLORTERM: 'truecolor',
COLUMNS: String(cols),
LINES: String(rows),
} as Record<string, string>,
stdio: ['pipe', 'pipe', 'pipe'],
},
)
}
proc.stdout?.on('data', (data: Buffer) => {
pushEvent({

View File

@@ -0,0 +1,56 @@
import { describe, it, expect, beforeEach, afterEach } from 'vitest'
import { getStateDir } from './workspace-state-dir'
describe('getStateDir', () => {
const originalEnv = { ...process.env }
beforeEach(() => {
// Clear workspace-specific override for clean tests
delete process.env.HERMES_WORKSPACE_STATE_DIR
// Clear hermes home chain too
delete process.env.HERMES_HOME
delete process.env.CLAUDE_HOME
})
afterEach(() => {
process.env = { ...originalEnv }
})
it('returns HERMES_WORKSPACE_STATE_DIR when set', () => {
process.env.HERMES_WORKSPACE_STATE_DIR = '/custom/state/dir'
const result = getStateDir()
expect(result).toBe('/custom/state/dir')
})
it('uses HERMES_HOME/workspace when HERMES_WORKSPACE_STATE_DIR is not set', () => {
process.env.HERMES_HOME = '/custom/hermes'
const result = getStateDir()
expect(result).toBe('/custom/hermes/workspace')
})
it('falls back to CLAUDE_HOME/workspace when only CLAUDE_HOME is set', () => {
process.env.CLAUDE_HOME = '/claude/home'
const result = getStateDir()
expect(result).toBe('/claude/home/workspace')
})
it('prefers HERMES_HOME over CLAUDE_HOME', () => {
process.env.HERMES_HOME = '/hermes/home'
process.env.CLAUDE_HOME = '/claude/home'
const result = getStateDir()
expect(result).toBe('/hermes/home/workspace')
})
it('prefers HERMES_WORKSPACE_STATE_DIR over everything', () => {
process.env.HERMES_WORKSPACE_STATE_DIR = '/explicit/workspace'
process.env.HERMES_HOME = '/hermes/home'
const result = getStateDir()
expect(result).toBe('/explicit/workspace')
})
it('trims whitespace from env values', () => {
process.env.HERMES_WORKSPACE_STATE_DIR = ' /trimmed/path '
const result = getStateDir()
expect(result).toBe('/trimmed/path')
})
})

View File

@@ -0,0 +1,25 @@
import { homedir } from 'node:os'
import { join, resolve } from 'node:path'
/**
* Resolve the Hermes workspace state directory.
*
* Priority:
* 1. `HERMES_WORKSPACE_STATE_DIR` env var (explicit override)
* 2. `join(HERMES_HOME, 'workspace')` where HERMES_HOME respects
* `HERMES_HOME` → `CLAUDE_HOME` → `~/.hermes` (standard chain)
*
* The returned path is absolute and resolved. Callers should create the
* directory at startup if it doesn't exist.
*/
export function getStateDir(): string {
const explicit = process.env.HERMES_WORKSPACE_STATE_DIR?.trim()
if (explicit) return resolve(explicit)
const hermesHome =
process.env.HERMES_HOME?.trim() ??
process.env.CLAUDE_HOME?.trim() ??
join(homedir(), '.hermes')
return resolve(join(hermesHome, 'workspace'))
}

View File

@@ -5,7 +5,7 @@
@variant dark (&:where(.dark, .dark *));
:root {
--tabbar-h: 0px;
--tabbar-h: 80px;
/* Chat content max-width — controlled by Settings > Chat Display (see #89).
* Keep 900px default to match prior layout; 'wide' = 1200px, 'full' = 100%. */
--chat-content-max-width: 900px;

View File

@@ -19,16 +19,18 @@ import viteTsConfigPaths from 'vite-tsconfig-paths'
// ---------------------------------------------------------------------------
/** Resolve the hermes-agent directory using a priority-ordered fallback chain:
* 1. CLAUDE_AGENT_PATH env var (explicit override)
* 2. ../hermes-agent — sibling clone (standard README setup)
* 3. ../../hermes-agent — one level up (monorepo / nested workspace)
* 1. HERMES_AGENT_PATH env var (explicit override)
* 2. CLAUDE_AGENT_PATH env var (legacy override)
* 3. ../hermes-agent sibling clone (standard README setup)
* 4. ../../hermes-agent — one level up (monorepo / nested workspace)
* Returns null if none found.
*/
function resolveClaudeAgentDir(env: Record<string, string>): string | null {
const candidates: string[] = []
if (env.CLAUDE_AGENT_PATH?.trim()) {
candidates.push(env.CLAUDE_AGENT_PATH.trim())
const explicitAgentPath = env.HERMES_AGENT_PATH?.trim() || env.CLAUDE_AGENT_PATH?.trim()
if (explicitAgentPath) {
candidates.push(explicitAgentPath)
}
// Resolve relative to the workspace root (parent of hermes-workspace/)
@@ -155,7 +157,7 @@ const config = defineConfig(({ mode, command }) => {
'[hermes-agent] Could not find hermes-agent installation.\n' +
' Run the installer:\n' +
' curl -fsSL https://hermes-workspace.com/install.sh | bash\n' +
' Or set CLAUDE_AGENT_PATH in .env to point at your hermes-agent clone.',
' Or set HERMES_AGENT_PATH (or legacy CLAUDE_AGENT_PATH) in .env to point at your hermes-agent clone.',
)
return
}