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:
@@ -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
48
docker/entrypoint.sh
Executable 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 "$@"
|
||||
98
docs/workspace-chat-session-routing.md
Normal file
98
docs/workspace-chat-session-routing.md
Normal 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.
|
||||
50
e2e/chat-flicker-duplicate.spec.ts
Normal file
50
e2e/chat-flicker-duplicate.spec.ts
Normal 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 })
|
||||
})
|
||||
})
|
||||
53
e2e/chat-thinking-state.spec.ts
Normal file
53
e2e/chat-thinking-state.spec.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
103
e2e/conductor-mobile-rendering.spec.ts
Normal file
103
e2e/conductor-mobile-rendering.spec.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
@@ -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
24
pnpm-lock.yaml
generated
@@ -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
5
pnpm-workspace.yaml
Normal file
@@ -0,0 +1,5 @@
|
||||
allowBuilds:
|
||||
electron: true
|
||||
electron-winstaller: true
|
||||
esbuild: true
|
||||
unrs-resolver: true
|
||||
@@ -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}
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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)
|
||||
|
||||
63
src/components/update-center-notifier.test.tsx
Normal file
63
src/components/update-center-notifier.test.tsx
Normal 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()
|
||||
})
|
||||
})
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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])
|
||||
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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
49
src/router.test.ts
Normal 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')
|
||||
})
|
||||
})
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
142
src/routes/api/-swarm-runtime-reset.test.ts
Normal file
142
src/routes/api/-swarm-runtime-reset.test.ts
Normal 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')
|
||||
})
|
||||
})
|
||||
@@ -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,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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.
|
||||
}
|
||||
|
||||
16
src/routes/api/config-patch.ts
Normal file
16
src/routes/api/config-patch.ts
Normal 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,
|
||||
},
|
||||
},
|
||||
})
|
||||
19
src/routes/api/hermes-config.ts
Normal file
19
src/routes/api/hermes-config.ts
Normal 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,
|
||||
},
|
||||
},
|
||||
})
|
||||
@@ -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)
|
||||
|
||||
@@ -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(
|
||||
{
|
||||
|
||||
@@ -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 })
|
||||
|
||||
@@ -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({
|
||||
|
||||
67
src/routes/api/swarm-runtime.reset.ts
Normal file
67
src/routes/api/swarm-runtime.reset.ts
Normal 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 })
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
@@ -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
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
|
||||
|
||||
@@ -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?.()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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() {
|
||||
|
||||
46
src/server/claude-agent.test.ts
Normal file
46
src/server/claude-agent.test.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
@@ -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('.'))
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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')
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -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')
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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')
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -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([
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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' }])
|
||||
})
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
103
src/server/swarm-runtime-reset.ts
Normal file
103
src/server/swarm-runtime-reset.ts
Normal 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))
|
||||
}
|
||||
@@ -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({
|
||||
|
||||
56
src/server/workspace-state-dir.test.ts
Normal file
56
src/server/workspace-state-dir.test.ts
Normal 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')
|
||||
})
|
||||
})
|
||||
25
src/server/workspace-state-dir.ts
Normal file
25
src/server/workspace-state-dir.ts
Normal 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'))
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user