chore: deep cleanup — remove all ClawSuite/OpenClaw bloat, dead screens, workspace daemon, gateway UI

Deleted:
- 30+ stale .md files (specs, audits, roadmaps)
- docs/archive/ directory
- data/specs/, data/agent-hub-architecture-report.md
- workspace-daemon/ (entire ClawSuite orchestration daemon)
- release/ (Electron builds)
- Screens: activity, costs, cron, tasks, gateway (kept approvals stub)
- Components: gateway-setup-wizard, openclaw-update-notifier, update-notifier,
  compaction-notifier, fallback-banner, exec-approval-toast, gateway-restart-overlay
- API routes: gateway/*, cloud/*, cron/*, browser/*, debug/*, events/*,
  workspace/* (orchestration), cli-agents, system-metrics, diagnostics, etc.
- Server: debug-analyzer, activity-stream, activity-events, workspace-proxy,
  exec-approval-store, browser-monitor, gateway-stream, cron
- Stores: mission-store, mission-event-store
- Hooks: use-agent-view, use-task-reminders

Stubbed (preserves chat functionality):
- approvals-store (no-op, chat-screen imports it)
- use-research-card (no-op)
- research-card component (returns null)
- gateway-restart-overlay (passthrough)

Rebranded:
- CONTRIBUTING.md, docker-compose.yml, vite.config.ts, test-redaction.ts
- Env vars: HERMES_WORKSPACE_DIR with OPENCLAW fallback

213 files changed, -56,503 lines. tsc clean.
This commit is contained in:
outsourc-e
2026-03-16 15:33:03 -04:00
parent d3a59225dc
commit 82c3f70975
213 changed files with 70 additions and 56503 deletions

View File

@@ -11,7 +11,7 @@ Thanks for your interest in contributing! Here's how to get started.
```bash
cp .env.example .env
# Edit .env with your gateway URL and token
# Hermes Workspace still uses the gateway token from: openclaw config get gateway.auth.token
# Hermes Workspace still uses the gateway token from: hermes gateway token
```
5. **Run dev server:** `npm run dev`
6. **Make your changes** on a feature branch
@@ -42,7 +42,7 @@ npm run build
**First-time setup:**
- Copy `.env.example` to `.env`
- Set `CLAWDBOT_GATEWAY_URL` (default: `ws://127.0.0.1:18789`)
- Set `CLAWDBOT_GATEWAY_TOKEN` (find with `openclaw config get gateway.auth.token`)
- Set `HERMES_GATEWAY_TOKEN` (find with `hermes gateway token`)
- See [README.md](README.md#environment-setup) for detailed environment variable documentation
## Guidelines

View File

@@ -6,8 +6,8 @@ services:
environment:
- CLAWDBOT_GATEWAY_URL=${CLAWDBOT_GATEWAY_URL:-ws://host.docker.internal:18789}
- CLAWDBOT_GATEWAY_TOKEN=${CLAWDBOT_GATEWAY_TOKEN:-}
- HERMES_ALLOWED_HOSTS=${HERMES_ALLOWED_HOSTS:-${CLAWSUITE_ALLOWED_HOSTS:-}}
- HERMES_PASSWORD=${HERMES_PASSWORD:-${CLAWSUITE_PASSWORD:-}}
- HERMES_ALLOWED_HOSTS=${HERMES_ALLOWED_HOSTS:-${HERMES_ALLOWED_HOSTS:-}}
- HERMES_PASSWORD=${HERMES_PASSWORD:-${HERMES_PASSWORD:-}}
restart: unless-stopped
extra_hosts:
- 'host.docker.internal:host-gateway'

View File

@@ -1,429 +0,0 @@
# Agent Hub / Mission Control — Code Audit Report
**Date:** 2026-03-04
**Auditor:** Aurora (subagent)
**Scope:** Mission lifecycle, session persistence, report viewing, refresh/polling, UX gaps
---
## Executive Summary
The Agent Hub's mission system has a **fundamental architectural split** that causes most reported bugs: missions are managed through **two completely independent systems** that don't talk to each other.
1. **The sidebar Agent View** (`use-agent-view.ts` + `agent-view-panel.tsx`) — polls `/api/sessions` via gateway, tracks all subagent sessions in real-time, classifies them as active/queued/history.
2. **The Mission Control hub** (`agent-hub-layout.tsx` + `mission-checkpoint.ts`) — manages missions via localStorage + per-agent SSE streams, with its own session spawning, task dispatch, and report generation.
These two systems share almost no state. A mission started from the hub creates gateway sessions, but the hub tracks them in React state + localStorage — not through the gateway's session metadata. When you navigate away, React state dies. When you come back, the hub doesn't know how to reconstruct the mission from gateway sessions.
---
## Finding 1: Missions Disappear When Navigating Away
**Severity: P0**
**Root Cause:** Mission runtime state is held entirely in React component state within `AgentHubLayout`.
### Evidence
In `agent-hub-layout.tsx`, all mission state lives in `useState` hooks:
```typescript
const [missionActive, setMissionActive] = useState(false)
const [missionGoal, setMissionGoal] = useState('')
const [activeMissionName, setActiveMissionName] = useState('')
const [missionTasks, setMissionTasks] = useState<Array<HubTask>>([])
const [agentSessionMap, setAgentSessionMap] = useState<Record<string, string>>(...)
const [missionState, setMissionState] = useState<'running' | 'paused' | 'stopped'>('stopped')
```
When the user navigates away from `/agent-swarm`, `AgentHubLayout` unmounts. All of this state is destroyed. When the user returns, the component remounts with fresh default state — `missionActive: false`, `missionState: 'stopped'`, empty tasks, etc.
### What IS Persisted
There is a checkpoint system in `mission-checkpoint.ts`:
```typescript
// localStorage keys:
const CURRENT_KEY = 'clawsuite:mission-checkpoint'
const HISTORY_KEY = 'clawsuite:mission-history'
```
And `agent-hub-layout.tsx` does call `saveMissionCheckpoint()` at various points — task status updates, mission stop. It also reads the checkpoint on mount:
```typescript
const [restoreCheckpoint, setRestoreCheckpoint] = useState<MissionCheckpoint | null>(() => {
const cp = loadMissionCheckpoint()
return cp?.status === 'running' ? cp : null
})
```
**But the restore banner is never rendered.** The variables are declared and then silenced:
```typescript
void restoreCheckpoint
void restoreDismissed
```
So the checkpoint is saved to localStorage, but the restore UI was never implemented. The user sees nothing when they return.
### What's Also Persisted
Agent session mappings are stored in localStorage:
```typescript
// key: 'clawsuite:hub-agent-sessions'
// value: { [agentId]: { sessionKey, model? } }
```
This is read on mount, but since `missionActive` defaults to `false` and no restore flow exists, these sessions are orphaned.
### Fix Recommendation
1. **Implement the restore flow.** When `AgentHubLayout` mounts and finds a `running` checkpoint in localStorage:
- Set `missionActive = true`, `missionState = 'running'`
- Restore `missionTasks`, `team`, `agentSessionMap` from the checkpoint
- Re-open SSE streams to the existing sessions
- Show a "Mission in progress — reconnecting..." banner
2. **Move critical mission state to Zustand with `persist` middleware** (like `useAgentViewStore` already does). This survives navigation without relying on localStorage checkpoint sync.
---
## Finding 2: Completed Missions Don't Appear in Missions Tab
**Severity: P0**
**Root Cause:** The Missions tab's `HistoryView` looks for two things, and the data pipeline for both is fragile.
### How HistoryView Works
In `agent-hub-layout.tsx`, the `HistoryView` component:
1. **Fetches `/api/sessions`** and filters for sessions where `label.startsWith('Mission:')`:
```typescript
const missionSessions = (data.sessions ?? [])
.filter((s) => {
const label = readString(s.label)
return label.startsWith('Mission:')
})
```
2. **Reads `loadMissionHistory()` from localStorage** — the archived checkpoints.
### Why Completed Missions Vanish
**Problem A: Gateway session lifecycle.** When a mission completes, `stopMissionAndCleanup()` is called:
```typescript
const stopMissionAndCleanup = useCallback((reason: 'aborted' | 'completed' = 'aborted') => {
// ...
Object.values(agentSessionMap).forEach((sessionKey) => {
// ABORT the chat
fetch('/api/chat-abort', { ... })
// DELETE the session
fetch(`/api/sessions?sessionKey=${encodeURIComponent(sessionKey)}`, {
method: 'DELETE',
}).catch(() => {})
})
// ...
})
```
**The sessions are DELETED from the gateway on completion.** So when `HistoryView` fetches `/api/sessions`, the completed mission's sessions no longer exist. They were just destroyed.
**Problem B: The checkpoint is archived, but...** `archiveMissionToHistory()` IS called before cleanup, which writes to `clawsuite:mission-history` in localStorage. And `HistoryView` DOES read this. So local checkpoints should appear.
BUT — if `stopMissionAndCleanup` is called via the auto-completion path (SSE `done` events), there's a timing issue. The `buildMissionCompletionSnapshot` is called, but it captures from `agentOutputLinesRef` which may not have the final output yet. The snapshot is stored in `missionCompletionSnapshotRef` — but is it actually saved to localStorage history?
Looking at the `missionState` transition effect — when `missionState` goes from `'running'` to `'stopped'`, the component needs to detect this and archive. But if the component has already unmounted (user navigated away), this effect never runs.
**Problem C: Old missions showing (7d ago).** These are gateway sessions that were created with `Mission:` labels but were never deleted (perhaps from a crash or the user closing the browser before cleanup). They persist in the gateway's session store. Recently completed ones are gone because `stopMissionAndCleanup` explicitly deletes them.
### Fix Recommendation
1. **Don't delete gateway sessions on mission completion.** Instead, patch their status to `completed` via `sessions.patch`. This preserves them for the history view.
2. **Add a `completedAt` timestamp** to the session metadata when completing.
3. **Archive to localStorage immediately in `stopMissionAndCleanup`** before deleting sessions, ensuring the checkpoint has all output data.
4. **Consider a dedicated `/api/mission-history` endpoint** that stores mission reports server-side rather than relying on localStorage.
---
## Finding 3: Can't View Final Report After Completion
**Severity: P1**
**Root Cause:** Reports are generated in-memory and stored in localStorage, but the viewing flow is incomplete.
### How Reports Work
When a mission completes, `generateMissionReport()` creates a markdown report from:
- Mission goal, team, tasks
- Per-agent output (from `agentOutputLinesRef`)
- Detected artifacts (code blocks, URLs, tables)
- Token count and cost estimate
This report is saved via `saveStoredMissionReport()` to:
```typescript
const MISSION_REPORTS_STORAGE_KEY = 'clawsuite-mission-reports'
const MAX_MISSION_REPORTS = 10
```
### The Problem
The report generation happens in a `useEffect` that watches `missionState` transitions. When the state goes from `running` → `stopped`, it reads `missionCompletionSnapshotRef.current`, generates a report, and saves it.
But there are multiple failure modes:
1. **Snapshot is null.** If the mission auto-completes via the SSE safety net (`setTimeout` 6000ms), and the ref was set from a stale closure, the snapshot may be incomplete or null.
2. **Component unmount race.** If the user navigates away while the 5000ms/6000ms auto-completion timer is pending, the callback still fires but `setMissionState` on an unmounted component is a no-op. The report is never generated.
3. **`HistoryView` shows checkpoints, not reports.** Even if the report is saved to `clawsuite-mission-reports`, the `HistoryView` component shows:
- Local checkpoints from `clawsuite:mission-history` (label, team, tasks — but NOT the full report)
- Gateway sessions with `Mission:` prefix (which are deleted on completion)
There's no UI in `HistoryView` that reads from `clawsuite-mission-reports` and shows the full markdown report.
4. **`selectedReport` state exists** in `AgentHubLayout` — there's state for viewing a report (`selectedReport`, `completionReportVisible`, `completionReport`), but the `HistoryView` component doesn't use these. They're used by report modals that are triggered from the completion flow, not from the history tab.
### Fix Recommendation
1. **Add a "View Report" button to each item in `HistoryView`** that loads from `clawsuite-mission-reports` by matching `missionId`.
2. **Store the report markdown inside the checkpoint** (`MissionCheckpoint.report?: string`) so history items are self-contained.
3. **Generate the report synchronously in `stopMissionAndCleanup`** before any async cleanup, to avoid timing issues.
---
## Finding 4: Sidebar Agent View vs Hub Are Disconnected
**Severity: P1**
**Root Cause:** Two independent polling/display systems with no shared state.
### The Sidebar (`use-agent-view.ts` + `agent-view-panel.tsx`)
- Polls `/api/sessions` every 5s via `fetchSessions()`
- Filters sessions with `isAgentSession()` — includes anything with `subagent:` in the key
- Classifies into active/queued/history based on status
- Displays in the right sidebar panel
### The Hub (`agent-hub-layout.tsx`)
- Manages its own `agentSessionMap` (agentId → sessionKey)
- Opens SSE streams per agent
- Tracks status via `agentSessionStatus` React state
- Has its own task board, artifacts, output lines, etc.
### Consequences
- A mission running in the hub appears in the sidebar as individual agents, but there's no "mission" concept in the sidebar.
- The sidebar's "History" section (`historyAgents`) shows completed/failed sessions, but these are individual agent sessions — not grouped as a mission.
- Killing an agent from the sidebar doesn't notify the hub's mission state.
- The hub creates sessions labeled `Mission: {agentName}`, but the sidebar doesn't give these special treatment.
### Fix Recommendation
Create a shared `MissionStore` (Zustand with persist) that both the sidebar and hub can read from:
```typescript
type MissionStore = {
activeMission: {
id: string
goal: string
team: TeamMember[]
tasks: HubTask[]
agentSessions: Record<string, string>
state: 'running' | 'paused' | 'stopped'
startedAt: number
} | null
missionHistory: StoredMissionReport[]
}
```
---
## Finding 5: 68% Mission Failure Rate
**Severity: P1**
**Root Cause:** Multiple factors contribute to high failure rates.
### Analysis
1. **Session deletion = "failed" in the sidebar.** When `stopMissionAndCleanup` deletes sessions, any that haven't reached `complete` status are classified as `failed` by `use-agent-view.ts`:
```typescript
if (['failed', 'error', 'cancelled', 'canceled', 'killed'].includes(status))
return 'failed'
```
Deleting a session while it's running or idle causes the gateway to mark it with an error/cancelled stop reason.
2. **Staleness heuristic misclassification.** In `agent-swarm-store.ts`:
```typescript
if (hasTokens && staleness > 30_000) return 'complete'
```
But in `use-agent-view.ts`:
```typescript
if (tokens > 0 && staleness > 30_000) return 'complete'
```
Sessions that are actually still running but haven't sent an update in 30s get marked as "complete" prematurely.
3. **No error recovery.** When a session fails (model error, rate limit, etc.), the hub detects it via SSE `error` events but has no retry mechanism. The task stays in `in_progress` and the mission eventually times out.
### Fix Recommendation
1. Stop deleting sessions on mission completion — patch status instead.
2. Increase the staleness threshold to 120s for "complete" heuristic.
3. Add retry logic: if an agent session errors, auto-spawn a replacement session for the same tasks.
---
## Finding 6: All Missions Show "1 Agent"
**Severity: P2**
**Root Cause:** The sidebar Agent View treats each session independently.
The sidebar (`agent-view-panel.tsx`) shows agents individually — there's no mission grouping. When it says "1 agent", it means 1 active session, not "1 agent in the mission." If a mission spawned 3 agents, the sidebar would show 3 separate agent cards.
The "1 agent" display is likely because:
1. Only 1 agent was actually spawned per mission (sequential processing)
2. Or other agent sessions completed/failed quickly and moved to history
The hub does support multi-agent teams, but sequential processing dispatches one agent at a time:
```typescript
const [processType, setProcessType] = useState<'sequential' | 'hierarchical' | 'parallel'>('parallel')
```
Default is `parallel` but the actual dispatch logic may serialize. Would need to trace the dispatch flow deeper.
---
## Finding 7: Usage & Cost Shows $0.00
**Severity: P2**
**Root Cause:** Cost tracking uses a rough heuristic, and the data source is unreliable.
In `agent-hub-layout.tsx`:
```typescript
const ROUGH_COST_PER_1K_TOKENS_USD = 0.01
function estimateMissionCost(tokenCount: number): number {
return Number(((tokenCount / 1000) * ROUGH_COST_PER_1K_TOKENS_USD).toFixed(2))
}
```
Token count is tracked via SSE chunks:
```typescript
setMissionTokenCount((current) => current + Math.ceil(text.length / 4))
```
This is a rough char/4 estimate. If SSE streams aren't connected (sessions failed to spawn, or component unmounted), `missionTokenCount` stays at 0, giving $0.00 cost.
In the sidebar, `formatCost` sums `agent.estimatedCost` which comes from:
```typescript
function readEstimatedCost(session, status, tokenCount): number {
// Falls back to: tokenCount * 0.000004
}
```
If the gateway doesn't report cost (most don't for OAuth sessions), and token count is low or zero, cost shows as $0.00.
### Fix Recommendation
1. Use the gateway's actual token counts from session metadata instead of character-count heuristics.
2. If using OAuth providers (free), display "Free (OAuth)" instead of $0.00 to avoid confusion.
---
## Finding 8: Refresh/Polling Architecture
**Severity: P2**
### Current Setup
- **Sidebar:** `REFRESH_INTERVAL_MS = 5000` — polls `/api/sessions` every 5s
- **Hub:** SSE streams per agent for real-time updates, plus:
- Gateway status: 15s polling
- Session status: 10s polling via `useQuery` in `AgentsScreen`
- Approvals: 8s polling
- **No SSE for mission-level events.** The hub opens per-agent SSE streams but there's no centralized event that says "mission completed."
### Race Conditions
1. **SSE `done` event → mission auto-complete.** Uses a `setTimeout(5000)` delay to let output flush. If 2 agents finish within 5s of each other, both `done` events fire, but only the second one triggers auto-complete. The first agent's final output may not be captured in the snapshot.
2. **Component unmount during timeout.** If user navigates away during the 5000ms window, `setMissionState` is called on an unmounted component. React ignores this silently, so the mission never transitions to `stopped` and the report is never generated.
### Fix Recommendation
1. Move auto-completion logic out of the component — use a Zustand store action or a service worker.
2. Use `AbortController` for cleanup on unmount, and persist mission state to localStorage before the component unmounts.
3. Add an `onbeforeunload` handler to save checkpoint if mission is active.
---
## Architecture Recommendations
### 1. Unified Mission Store (Zustand + persist)
Replace the 15+ `useState` hooks in `AgentHubLayout` with a single Zustand store:
```typescript
const useMissionStore = create(persist(
(set, get) => ({
activeMission: null as ActiveMission | null,
missionHistory: [] as MissionReport[],
startMission: (goal, team, tasks) => { ... },
completeMission: () => { ... },
abortMission: () => { ... },
updateTaskStatus: (taskId, status) => { ... },
}),
{ name: 'clawsuite:mission-store' }
))
```
Benefits:
- Survives navigation (persist middleware)
- Shared between sidebar and hub
- Single source of truth
- Testable
### 2. Don't Delete Sessions on Completion
Patch session status to `completed` with metadata. This preserves history in the gateway and lets HistoryView find them.
### 3. Mission Report Storage
Store reports as part of the mission checkpoint, not in a separate localStorage key. Include the full markdown report in `MissionCheckpoint` so HistoryView can render it directly.
### 4. Implement the Restore Banner
The code is half-written — `restoreCheckpoint` state is loaded from localStorage but the UI is `void`-silenced. Wire it up to show a "Resume mission?" banner with the mission goal and team info.
### 5. Add `beforeunload` Guard
```typescript
useEffect(() => {
const handler = (e: BeforeUnloadEvent) => {
if (missionActive) {
saveMissionCheckpoint(currentCheckpoint)
e.preventDefault()
}
}
window.addEventListener('beforeunload', handler)
return () => window.removeEventListener('beforeunload', handler)
}, [missionActive])
```
---
## Summary Table
| # | Finding | Severity | Files |
|---|---------|----------|-------|
| 1 | Missions disappear on navigation — React state only, restore UI voided | **P0** | `agent-hub-layout.tsx`, `mission-checkpoint.ts` |
| 2 | Completed missions vanish — sessions deleted on completion | **P0** | `agent-hub-layout.tsx` (`stopMissionAndCleanup`) |
| 3 | Can't view final report — report stored but no UI to access from history | **P1** | `agent-hub-layout.tsx` (`HistoryView`, `selectedReport`) |
| 4 | Sidebar and Hub disconnected — two independent tracking systems | **P1** | `use-agent-view.ts`, `agent-hub-layout.tsx` |
| 5 | 68% failure rate — session deletion + staleness heuristic | **P1** | `agent-hub-layout.tsx`, `agent-swarm-store.ts`, `use-agent-view.ts` |
| 6 | "1 agent" display — sidebar doesn't group by mission | **P2** | `agent-view-panel.tsx` |
| 7 | $0.00 cost — heuristic token counting, OAuth shows as free | **P2** | `agent-hub-layout.tsx` |
| 8 | Race conditions in auto-completion timers | **P2** | `agent-hub-layout.tsx` (SSE `done` handler) |
---
*End of audit. No code changes made.*

View File

@@ -182,7 +182,7 @@ if (redactedObject.gatewayUrl === 'ws://127.0.0.1:18789') {
console.log('\n🔒 Testing folder name extraction...\n')
const pathTests = [
{ input: '/Users/eric/projects/clawsuite', expected: 'clawsuite' },
{ input: '/Users/eric/projects/hermes-workspace', expected: 'hermes-workspace' },
{ input: 'C:\\Users\\Eric\\Desktop\\project', expected: 'project' },
{ input: null, expected: 'Not set' },
{ input: '', expected: 'Not set' },

View File

@@ -1,70 +0,0 @@
'use client'
/**
* CompactionNotifier — gateway-driven compaction detection.
*
* Listens to the /api/chat-events SSE stream for gateway `compaction` events
* (stream="compaction", phase="start"|"end") — the exact same signal the
* Hermes control UI uses. No polling, no heuristics.
*
* Shows a toast on any screen:
* - phase "start" → amber "Compacting context…" (dismissible)
* - phase "end" → green "Context compacted" (auto-dismisses)
*/
import { useEffect, useRef } from 'react'
import { toast } from '@/components/ui/toast'
export function CompactionNotifier() {
const startToastIdRef = useRef<boolean>(false)
useEffect(() => {
let es: EventSource | null = null
let active = true
function connect() {
if (!active) return
es = new EventSource('/api/chat-events')
es.addEventListener('compaction', (e: MessageEvent) => {
if (!active) return
try {
const data = JSON.parse(e.data) as { phase?: string; sessionKey?: string }
if (data.phase === 'start') {
startToastIdRef.current = true
toast('🗜️ Compacting context… older messages will be summarized', {
type: 'info',
duration: 30_000,
})
} else if (data.phase === 'end') {
startToastIdRef.current = false
toast('✅ Context compacted — session history summarized', {
type: 'success',
duration: 5_000, // matches gateway's Wp=5000 auto-clear
})
}
} catch {
/* ignore malformed event */
}
})
es.addEventListener('error', () => {
// SSE error — the main chat-events connection handles reconnect;
// just close this instance and let it reconnect naturally.
es?.close()
es = null
})
}
connect()
return () => {
active = false
es?.close()
es = null
}
}, [])
return null
}

View File

@@ -1,324 +0,0 @@
'use client'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import {
fetchGatewayApprovals,
resolveGatewayApproval,
type GatewayApprovalEntry,
} from '@/lib/gateway-api'
import { cn } from '@/lib/utils'
import { toast as showToast } from '@/components/ui/toast'
/**
* ExecApprovalToast — Global overlay for exec approval requests.
*
* - Polls gateway every 3s for pending approvals
* - Shows stacked cards (up to 3 visible, count badge for overflow)
* - Each card has a 30s countdown → auto-deny on timeout
* - Approve (green) / Deny (red) buttons
* - Fixed position, z-index 9999, always on top
* - Matches dark theme
*/
type EnrichedApproval = GatewayApprovalEntry & {
timeoutMs?: number
timeoutAt?: number
expiresAt?: number
deadline?: number
/** Locally-computed deadline for auto-deny (ms since epoch) */
_localDeadline?: number
}
const DEFAULT_TIMEOUT_MS = 30_000
function approvalText(approval: GatewayApprovalEntry): string {
if (typeof approval.action === 'string' && approval.action.trim().length > 0) return approval.action
if (typeof approval.tool === 'string' && approval.tool.trim().length > 0) return approval.tool
if (approval.input !== undefined) {
try {
return JSON.stringify(approval.input)
} catch {
return 'Approval requested'
}
}
return 'Approval requested'
}
function approvalAgent(approval: GatewayApprovalEntry): string {
return approval.agentName ?? approval.sessionKey ?? 'Agent'
}
function approvalContext(approval: GatewayApprovalEntry): string | null {
if (typeof approval.context === 'string' && approval.context.trim().length > 0) {
return approval.context.trim()
}
return null
}
function toDeadline(approval: EnrichedApproval): number {
if (typeof approval.timeoutAt === 'number' && Number.isFinite(approval.timeoutAt)) return approval.timeoutAt
if (typeof approval.expiresAt === 'number' && Number.isFinite(approval.expiresAt)) return approval.expiresAt
if (typeof approval.deadline === 'number' && Number.isFinite(approval.deadline)) return approval.deadline
if (typeof approval.timeoutMs === 'number' && Number.isFinite(approval.timeoutMs)) {
const requested = approval.requestedAt ?? Date.now()
return requested + Math.max(0, approval.timeoutMs)
}
// Fallback: use local deadline or default 30s from when we first saw it
if (approval._localDeadline) return approval._localDeadline
return (approval.requestedAt ?? Date.now()) + DEFAULT_TIMEOUT_MS
}
function formatCountdown(ms: number): string {
const total = Math.max(0, Math.ceil(ms / 1000))
if (total <= 0) return '0s'
if (total < 60) return `${total}s`
const minutes = Math.floor(total / 60)
const seconds = total % 60
return `${minutes}:${seconds.toString().padStart(2, '0')}`
}
function riskLevel(text: string): 'low' | 'medium' | 'high' {
const lower = text.toLowerCase()
if (/(rm\s+-rf|drop\s+table|truncate|sudo|chown|chmod\s+777|delete\s+all|force)/.test(lower)) return 'high'
if (/(write|edit|patch|install|deploy|execute|run|kill|terminate|delete|update)/.test(lower)) return 'medium'
return 'low'
}
const RISK_BADGE: Record<string, { bg: string; text: string; label: string }> = {
low: { bg: 'bg-emerald-100 dark:bg-emerald-900/30', text: 'text-emerald-700 dark:text-emerald-300', label: 'Low Risk' },
medium: { bg: 'bg-amber-100 dark:bg-amber-900/30', text: 'text-amber-700 dark:text-amber-300', label: 'Med Risk' },
high: { bg: 'bg-red-100 dark:bg-red-900/30', text: 'text-red-700 dark:text-red-300', label: 'High Risk' },
}
export function ExecApprovalToast() {
const [gatewayPending, setGatewayPending] = useState<EnrichedApproval[]>([])
const [resolving, setResolving] = useState<Record<string, 'approve' | 'deny'>>({})
const [now, setNow] = useState(Date.now())
const [dismissed, setDismissed] = useState<Set<string>>(new Set())
const seenRef = useRef<Map<string, number>>(new Map())
const refresh = useCallback(async () => {
try {
const response = await fetchGatewayApprovals()
const rows = (response.pending ?? response.approvals ?? []) as EnrichedApproval[]
const pending = rows.filter((entry) => (entry.status ?? 'pending') === 'pending')
// Assign local deadlines for approvals we haven't seen before
const seenMap = seenRef.current
const enriched = pending.map((entry) => {
if (!seenMap.has(entry.id)) {
const deadline = (entry.requestedAt ?? Date.now()) + DEFAULT_TIMEOUT_MS
seenMap.set(entry.id, deadline)
}
return { ...entry, _localDeadline: seenMap.get(entry.id) }
})
setGatewayPending(enriched)
} catch {
// silently ignore fetch errors
}
}, [])
// Poll gateway + tick countdown
useEffect(() => {
void refresh()
const poll = window.setInterval(() => void refresh(), 3_000)
const ticker = window.setInterval(() => setNow(Date.now()), 1_000)
return () => {
window.clearInterval(poll)
window.clearInterval(ticker)
}
}, [refresh])
// Auto-deny expired approvals
useEffect(() => {
for (const approval of gatewayPending) {
const deadline = toDeadline(approval)
const remaining = deadline - now
if (remaining <= 0 && !resolving[approval.id] && !dismissed.has(approval.id)) {
// Auto-deny
void resolveGatewayApproval(approval.id, 'deny').then(() => {
showToast(`Auto-denied: ${approvalText(approval).slice(0, 60)}`, { type: 'warning' })
void refresh()
})
setDismissed((prev) => new Set(prev).add(approval.id))
}
}
}, [gatewayPending, now, resolving, dismissed, refresh])
// Filter out dismissed/resolved items
const visibleApprovals = useMemo(() => {
return gatewayPending
.filter((entry) => !dismissed.has(entry.id))
.sort((a, b) => (a.requestedAt ?? 0) - (b.requestedAt ?? 0))
}, [gatewayPending, dismissed])
const pendingCount = visibleApprovals.length
const displayedApprovals = visibleApprovals.slice(0, 3)
const overflowCount = Math.max(0, pendingCount - 3)
async function handleResolve(id: string, action: 'approve' | 'deny') {
setResolving((prev) => ({ ...prev, [id]: action }))
try {
const result = await resolveGatewayApproval(id, action)
if (result.ok) {
showToast(
action === 'approve' ? 'Approved ✓' : 'Denied ✕',
{ type: action === 'approve' ? 'success' : 'error' },
)
} else {
showToast('Failed to resolve approval', { type: 'error' })
}
setDismissed((prev) => new Set(prev).add(id))
await refresh()
} finally {
setResolving((prev) => {
const next = { ...prev }
delete next[id]
return next
})
}
}
if (pendingCount === 0) return null
return (
<div className="pointer-events-none fixed bottom-4 right-4 z-[9999] flex w-[min(440px,calc(100vw-2rem))] flex-col gap-2">
{/* Overflow badge */}
{overflowCount > 0 && (
<div className="pointer-events-auto self-end rounded-full bg-amber-500 px-3 py-1 text-xs font-bold text-white shadow-lg">
+{overflowCount} more pending
</div>
)}
{/* Stacked approval cards (newest at bottom / closest to user) */}
{displayedApprovals.map((approval, index) => {
const isTop = index === displayedApprovals.length - 1
const deadline = toDeadline(approval)
const remaining = Math.max(0, deadline - now)
const totalTimeout = DEFAULT_TIMEOUT_MS
const progressPct = Math.max(0, Math.min(100, (remaining / totalTimeout) * 100))
const countdown = formatCountdown(remaining)
const isUrgent = remaining < 10_000
const text = approvalText(approval)
const agent = approvalAgent(approval)
const context = approvalContext(approval)
const risk = riskLevel(text + ' ' + (context ?? ''))
const riskBadge = RISK_BADGE[risk]
const busy = resolving[approval.id]
return (
<div
key={approval.id}
className={cn(
'pointer-events-auto overflow-hidden rounded-2xl border shadow-2xl backdrop-blur transition-all duration-300',
isTop
? 'border-amber-300 bg-white/98 dark:border-amber-800/60 dark:bg-neutral-950/98'
: 'border-neutral-200 bg-white/90 opacity-80 dark:border-neutral-800 dark:bg-neutral-950/90',
isUrgent && isTop && 'ring-2 ring-red-400/50 border-red-300 dark:border-red-800/60',
)}
style={{
// Slight scale-down for stacked cards behind the front one
transform: !isTop ? `scale(${0.96 - index * 0.02})` : undefined,
}}
>
{/* Countdown progress bar */}
<div className="h-[3px] w-full bg-neutral-100 dark:bg-neutral-800">
<div
className={cn(
'h-full transition-all duration-1000 ease-linear rounded-r-full',
isUrgent ? 'bg-red-500' : 'bg-amber-400',
)}
style={{ width: `${progressPct}%` }}
/>
</div>
<div className="p-4">
{/* Header row: badge + agent + countdown */}
<div className="mb-2 flex items-center justify-between gap-2">
<div className="flex items-center gap-2">
<span className="rounded-full bg-amber-100 px-2 py-0.5 text-[10px] font-bold uppercase tracking-wide text-amber-700 dark:bg-amber-900/40 dark:text-amber-300">
Exec Approval
</span>
<span className={cn('rounded-full px-1.5 py-0.5 text-[9px] font-semibold uppercase', riskBadge.bg, riskBadge.text)}>
{riskBadge.label}
</span>
</div>
<div className="flex items-center gap-2">
<span className={cn(
'font-mono text-[11px] font-bold tabular-nums',
isUrgent ? 'text-red-500 animate-pulse' : 'text-neutral-500 dark:text-neutral-400',
)}>
{countdown}
</span>
{pendingCount > 1 && (
<span className="rounded-full bg-neutral-100 px-1.5 py-0.5 text-[9px] font-bold text-neutral-600 dark:bg-neutral-800 dark:text-neutral-400">
{pendingCount}
</span>
)}
</div>
</div>
{/* Agent name */}
<p className="mb-1 text-[11px] font-semibold text-neutral-600 dark:text-neutral-400">
{agent}
</p>
{/* Command text */}
<p className="line-clamp-3 font-mono text-xs font-semibold leading-relaxed text-neutral-900 dark:text-neutral-100">
{text}
</p>
{/* Working directory / context */}
{context && (
<p className="mt-1 line-clamp-1 font-mono text-[10px] text-neutral-500 dark:text-neutral-500">
📁 {context}
</p>
)}
{/* Action buttons */}
<div className="mt-3 flex items-center gap-2">
<button
type="button"
onClick={() => void handleResolve(approval.id, 'approve')}
disabled={Boolean(busy)}
className={cn(
'flex-1 rounded-lg bg-emerald-600 py-2 text-xs font-bold text-white transition-all hover:bg-emerald-700 active:scale-[0.98]',
busy && 'cursor-not-allowed opacity-60',
)}
>
{busy === 'approve' ? (
<span className="flex items-center justify-center gap-1.5">
<span className="size-3 animate-spin rounded-full border-2 border-white/30 border-t-white" />
Approving
</span>
) : (
'✓ Approve'
)}
</button>
<button
type="button"
onClick={() => void handleResolve(approval.id, 'deny')}
disabled={Boolean(busy)}
className={cn(
'flex-1 rounded-lg border border-red-300 bg-white py-2 text-xs font-bold text-red-600 transition-all hover:bg-red-50 active:scale-[0.98] dark:border-red-800/50 dark:bg-neutral-900 dark:text-red-400 dark:hover:bg-red-950/30',
busy && 'cursor-not-allowed opacity-60',
)}
>
{busy === 'deny' ? (
<span className="flex items-center justify-center gap-1.5">
<span className="size-3 animate-spin rounded-full border-2 border-red-300 border-t-red-600" />
Denying
</span>
) : (
'✕ Deny'
)}
</button>
</div>
</div>
</div>
)
})}
</div>
)
}

View File

@@ -1,73 +0,0 @@
'use client'
/**
* FallbackBanner — gateway-driven model fallback notification.
*
* Listens to the /api/chat-events SSE stream for gateway `fallback` events
* (phase="fallback"|"fallback_cleared"). Shows a toast when the gateway
* switches to a fallback model, and another when the primary is restored.
*
* Returns null (no DOM) — same pattern as compaction-notifier.tsx.
*/
import { useEffect } from 'react'
import { toast } from '@/components/ui/toast'
export function FallbackBanner() {
useEffect(() => {
let es: EventSource | null = null
let active = true
function connect() {
if (!active) return
es = new EventSource('/api/chat-events')
es.addEventListener('fallback', (e: MessageEvent) => {
if (!active) return
try {
const data = JSON.parse(e.data) as {
phase?: string
selectedModel?: string
activeModel?: string
previousModel?: string
reason?: string
attempts?: number
sessionKey?: string
}
if (data.phase === 'fallback') {
const model = data.activeModel ?? data.selectedModel ?? 'fallback model'
const reason = data.reason ?? 'primary model unavailable'
toast(`⚠️ Model switched to ${model}${reason}`, {
type: 'warning',
duration: 30_000,
})
} else if (data.phase === 'fallback_cleared') {
toast('✅ Primary model restored', {
type: 'success',
duration: 8_000,
})
}
} catch {
/* ignore malformed event */
}
})
es.addEventListener('error', () => {
// SSE error — just close and let it reconnect naturally
es?.close()
es = null
})
}
connect()
return () => {
active = false
es?.close()
es = null
}
}, [])
return null
}

View File

@@ -1,417 +1,10 @@
'use client'
import {
createContext,
useCallback,
useContext,
useEffect,
useRef,
useState,
} from 'react'
import { HugeiconsIcon } from '@hugeicons/react'
import {
Alert02Icon,
RefreshIcon,
Tick02Icon,
} from '@hugeicons/core-free-icons'
import { Button } from '@/components/ui/button'
import { BrailleSpinner } from '@/components/ui/braille-spinner'
import { cn } from '@/lib/utils'
// ── Types ─────────────────────────────────────────────────────────
type RestartPhase =
| 'idle'
| 'restarting'
| 'ready'
| 'error'
type GatewayRestartContextValue = {
/** Whether a restart is currently in progress */
isRestarting: boolean
/**
* Trigger a provider config save + gateway restart.
* @param saveProvider async fn that applies the config change before restart
*/
triggerRestart: (saveProvider?: () => Promise<void>) => Promise<void>
}
type ProviderRestartConfirmState = {
open: boolean
pendingSave: (() => Promise<void>) | null
}
// ── Constants ─────────────────────────────────────────────────────
const HEALTH_POLL_INTERVAL_MS = 2_000
const RESTART_TIMEOUT_MS = 30_000
// ── Context ───────────────────────────────────────────────────────
const GatewayRestartContext = createContext<GatewayRestartContextValue>({
isRestarting: false,
triggerRestart: async () => {},
})
// ── Health polling ────────────────────────────────────────────────
async function pingGateway(): Promise<boolean> {
try {
const res = await fetch('/api/ping', {
signal: AbortSignal.timeout(3_000),
})
if (!res.ok) return false
const data = (await res.json()) as { ok?: boolean }
return Boolean(data.ok)
} catch {
return false
// Stub — gateway restart overlay is not used in Hermes Workspace
export function useGatewayRestart() {
return {
triggerRestart: async (fn: () => Promise<void>) => { await fn() },
}
}
async function callGatewayRestart(): Promise<void> {
const res = await fetch('/api/gateway-restart', {
method: 'POST',
headers: { 'content-type': 'application/json' },
signal: AbortSignal.timeout(10_000),
})
if (!res.ok) {
const body = (await res.json().catch(() => ({}))) as { error?: string }
throw new Error(body.error || 'Hermes restart request failed')
}
}
// ── Provider ──────────────────────────────────────────────────────
export function GatewayRestartProvider({
children,
}: {
children: React.ReactNode
}) {
const [phase, setPhase] = useState<RestartPhase>('idle')
const [errorMsg, setErrorMsg] = useState('')
const [confirmState, setConfirmState] = useState<ProviderRestartConfirmState>(
{ open: false, pendingSave: null },
)
const pollTimerRef = useRef<ReturnType<typeof setInterval> | null>(null)
const timeoutTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
function clearTimers() {
if (pollTimerRef.current) {
clearInterval(pollTimerRef.current)
pollTimerRef.current = null
}
if (timeoutTimerRef.current) {
clearTimeout(timeoutTimerRef.current)
timeoutTimerRef.current = null
}
}
// Start polling for gateway recovery
function startRecoveryPolling() {
clearTimers()
timeoutTimerRef.current = setTimeout(() => {
clearTimers()
setPhase('error')
setErrorMsg('Hermes did not come back within 30 seconds.')
}, RESTART_TIMEOUT_MS)
pollTimerRef.current = setInterval(() => {
void (async () => {
const healthy = await pingGateway()
if (healthy) {
clearTimers()
setPhase('ready')
// Dispatch event so other components can re-subscribe to SSE / refetch
if (typeof window !== 'undefined') {
window.dispatchEvent(new CustomEvent('gateway:restarted'))
window.dispatchEvent(new CustomEvent('gateway:health-restored'))
}
// Auto-dismiss after 2.5s
setTimeout(() => setPhase('idle'), 2_500)
}
})()
}, HEALTH_POLL_INTERVAL_MS)
}
// Core restart flow (called after user confirms)
async function executeRestart(saveProvider?: () => Promise<void>) {
setPhase('restarting')
setErrorMsg('')
try {
// Step 1: apply the config change
if (saveProvider) {
await saveProvider()
}
// Step 2: fire gateway restart
await callGatewayRestart()
} catch (err) {
// Connection drop is expected — gateway is restarting
const msg = err instanceof Error ? err.message : String(err)
const isExpected =
msg.includes('closed') ||
msg.includes('Failed to fetch') ||
msg.includes('timed out') ||
msg.includes('NetworkError')
if (!isExpected) {
setPhase('error')
setErrorMsg(msg)
return
}
}
// Step 3: poll until health check passes
startRecoveryPolling()
}
const triggerRestart = useCallback(
async (saveProvider?: () => Promise<void>) => {
// Show the confirmation dialog; actual restart happens on confirm
setConfirmState({ open: true, pendingSave: saveProvider ?? null })
},
[],
)
function handleConfirm() {
const { pendingSave } = confirmState
setConfirmState({ open: false, pendingSave: null })
void executeRestart(pendingSave ?? undefined)
}
function handleCancel() {
setConfirmState({ open: false, pendingSave: null })
}
function handleRetry() {
setPhase('restarting')
setErrorMsg('')
startRecoveryPolling()
}
// Cleanup on unmount
useEffect(() => {
return () => clearTimers()
}, [])
const isRestarting = phase === 'restarting'
return (
<GatewayRestartContext.Provider value={{ isRestarting, triggerRestart }}>
{children}
{/* Confirm dialog */}
{confirmState.open ? (
<ProviderRestartConfirmDialog
onConfirm={handleConfirm}
onCancel={handleCancel}
/>
) : null}
{/* Restart overlay */}
{phase !== 'idle' ? (
<GatewayRestartOverlayView
phase={phase}
errorMsg={errorMsg}
onRetry={handleRetry}
onDismiss={() => setPhase('idle')}
/>
) : null}
</GatewayRestartContext.Provider>
)
}
// ── Hook ──────────────────────────────────────────────────────────
/**
* Returns a function to trigger the provider add/remove restart flow.
* Shows a confirm dialog first, then restarts the gateway and polls for recovery.
*/
export function useGatewayRestart(): GatewayRestartContextValue {
return useContext(GatewayRestartContext)
}
// ── Confirm Dialog ────────────────────────────────────────────────
function ProviderRestartConfirmDialog({
onConfirm,
onCancel,
}: {
onConfirm: () => void
onCancel: () => void
}) {
return (
<div
className="fixed inset-0 z-[9990] flex items-center justify-center p-4"
role="dialog"
aria-modal="true"
aria-labelledby="provider-restart-title"
aria-describedby="provider-restart-desc"
>
{/* Backdrop */}
<div
className="absolute inset-0 bg-primary-950/30 backdrop-blur-sm"
onClick={onCancel}
aria-hidden="true"
/>
{/* Panel */}
<div className="relative z-10 w-[min(420px,92vw)] rounded-2xl border border-primary-200 bg-primary-50 p-5 shadow-xl dark:border-neutral-700 dark:bg-neutral-900">
<div className="flex items-start gap-3">
<span className="inline-flex size-9 shrink-0 items-center justify-center rounded-xl border border-amber-200 bg-amber-50 dark:border-amber-700/40 dark:bg-amber-900/30">
<HugeiconsIcon
icon={RefreshIcon}
size={18}
strokeWidth={1.5}
className="text-amber-600 dark:text-amber-400"
/>
</span>
<div className="flex-1 min-w-0">
<h2
id="provider-restart-title"
className="text-sm font-semibold text-primary-900 dark:text-neutral-100"
>
Hermes restart required
</h2>
<p
id="provider-restart-desc"
className="mt-1 text-sm text-primary-600 text-pretty dark:text-neutral-400"
>
Adding or removing a provider requires a Hermes restart. Active
sessions will be paused briefly. Continue?
</p>
</div>
</div>
<div className="mt-4 flex items-center justify-end gap-2">
<Button variant="outline" size="sm" onClick={onCancel}>
Cancel
</Button>
<Button size="sm" onClick={onConfirm}>
Restart & Apply
</Button>
</div>
</div>
</div>
)
}
// ── Overlay View ──────────────────────────────────────────────────
function GatewayRestartOverlayView({
phase,
errorMsg,
onRetry,
onDismiss,
}: {
phase: Exclude<RestartPhase, 'idle'>
errorMsg: string
onRetry: () => void
onDismiss: () => void
}) {
return (
<div
className={cn(
'fixed inset-0 z-[9980] flex items-center justify-center p-4',
'bg-primary-950/60 backdrop-blur-md',
'transition-opacity duration-200 opacity-100',
)}
role="status"
aria-live="polite"
aria-label={
phase === 'restarting'
? 'Hermes restarting'
: phase === 'ready'
? 'Hermes ready'
: 'Hermes restart failed'
}
>
<div className="flex flex-col items-center gap-4 rounded-2xl border border-primary-200/60 bg-primary-50/95 px-8 py-8 shadow-2xl backdrop-blur-xl dark:border-neutral-700/60 dark:bg-neutral-900/95">
{phase === 'restarting' ? (
<>
<BrailleSpinner
preset="claw"
size={32}
className="text-accent-500"
label="Hermes restarting"
/>
<div className="text-center">
<p className="text-base font-semibold text-primary-900 dark:text-neutral-100">
Hermes restarting
</p>
<p className="mt-1 text-sm text-primary-500 dark:text-neutral-400">
Applying provider changes. Active sessions are paused.
</p>
</div>
<ReconnectingBadge />
</>
) : phase === 'ready' ? (
<>
<span className="inline-flex size-12 items-center justify-center rounded-full border border-green-200 bg-green-50 dark:border-green-700/40 dark:bg-green-900/30">
<HugeiconsIcon
icon={Tick02Icon}
size={24}
strokeWidth={1.5}
className="text-green-600 dark:text-green-400"
/>
</span>
<div className="text-center">
<p className="text-base font-semibold text-primary-900 dark:text-neutral-100">
Hermes ready
</p>
<p className="mt-1 text-sm text-primary-500 dark:text-neutral-400">
Provider changes applied. Resuming
</p>
</div>
</>
) : (
<>
<span className="inline-flex size-12 items-center justify-center rounded-full border border-red-200 bg-red-50 dark:border-red-700/40 dark:bg-red-900/30">
<HugeiconsIcon
icon={Alert02Icon}
size={24}
strokeWidth={1.5}
className="text-red-600 dark:text-red-400"
/>
</span>
<div className="text-center">
<p className="text-base font-semibold text-primary-900 dark:text-neutral-100">
Hermes restart failed
</p>
<p className="mt-1 text-sm text-primary-500 dark:text-neutral-400 text-pretty">
{errorMsg || 'Hermes did not come back in time.'}
</p>
</div>
<div className="flex items-center gap-2">
<Button size="sm" onClick={onRetry}>
<HugeiconsIcon
icon={RefreshIcon}
size={16}
strokeWidth={1.5}
/>
Retry
</Button>
<Button variant="outline" size="sm" onClick={onDismiss}>
Dismiss
</Button>
</div>
</>
)}
</div>
</div>
)
}
// Small "Reconnecting..." badge shown in overlay during restart
function ReconnectingBadge() {
return (
<div className="flex items-center gap-1.5 rounded-full border border-primary-200 bg-primary-100 px-3 py-1 dark:border-neutral-700 dark:bg-neutral-800">
<span className="size-1.5 animate-pulse rounded-full bg-accent-500" />
<span className="text-xs font-medium text-primary-600 dark:text-neutral-400">
Reconnecting
</span>
</div>
)
export function GatewayRestartProvider({ children }: { children: React.ReactNode }) {
return <>{children}</>
}

View File

@@ -115,16 +115,6 @@ export function SearchModal() {
navigate({ to: '/memory' })
},
},
{
id: 'qa-jobs',
emoji: '⏰',
label: 'Jobs',
description: 'Open scheduled jobs and run history',
onSelect: () => {
closeModal()
navigate({ to: '/jobs' })
},
},
{
id: 'qa-files',
emoji: '📁',
@@ -265,9 +255,7 @@ export function SearchModal() {
icon={
entry.id === 'qa-logs'
? ListViewIcon
: entry.id === 'qa-jobs'
? Clock01Icon
: FlashIcon
: FlashIcon
}
size={20}
strokeWidth={1.5}

View File

@@ -1,397 +0,0 @@
import { useEffect, useMemo, useState } from 'react'
import { useQuery, useQueryClient } from '@tanstack/react-query'
import { AnimatePresence, motion } from 'motion/react'
import { HugeiconsIcon } from '@hugeicons/react'
import {
Cancel01Icon,
Tick01Icon,
Loading03Icon,
SparklesIcon,
} from '@hugeicons/core-free-icons'
import { cn } from '@/lib/utils'
type CommitEntry = {
hash: string
subject: string
date: string
}
type UpdateCheckResult = {
updateAvailable: boolean
localVersion: string
remoteVersion: string
localCommit: string
remoteCommit: string
localDate: string
remoteDate: string
behindBy: number
changelog: Array<CommitEntry>
}
type UpdatePhase =
| 'idle'
| 'pulling'
| 'installing'
| 'restarting'
| 'done'
| 'error'
const DISMISS_KEY = 'hermes-update-dismissed'
const CHECK_INTERVAL_MS = 15 * 60 * 1000
const PHASE_LABELS: Record<UpdatePhase, string> = {
idle: '',
pulling: 'Pulling latest changes...',
installing: 'Installing dependencies...',
restarting: 'Restarting...',
done: 'Update complete!',
error: 'Update failed',
}
function commitTypeIcon(subject: string): string {
const lower = subject.toLowerCase()
if (lower.startsWith('feat')) return '✨'
if (lower.startsWith('fix')) return '🐛'
if (lower.startsWith('perf')) return '⚡'
if (lower.startsWith('refactor')) return '♻️'
if (lower.startsWith('docs')) return '📝'
if (lower.startsWith('style')) return '💄'
if (lower.startsWith('chore')) return '🔧'
if (lower.startsWith('security') || lower.startsWith('sec')) return '🔒'
return '📦'
}
function cleanSubject(subject: string): string {
// Strip conventional commit prefix like "feat: " or "fix(scope): "
return subject.replace(/^[a-z]+(\([^)]*\))?:\s*/i, '')
}
function relativeTime(dateStr: string): string {
if (!dateStr) return ''
const diff = Date.now() - new Date(dateStr).getTime()
const mins = Math.floor(diff / 60_000)
if (mins < 1) return 'just now'
if (mins < 60) return `${mins}m ago`
const hrs = Math.floor(mins / 60)
if (hrs < 24) return `${hrs}h ago`
const days = Math.floor(hrs / 24)
return `${days}d ago`
}
export function UpdateNotifier() {
const queryClient = useQueryClient()
const [dismissed, setDismissed] = useState<string | null>(null)
const [visible, setVisible] = useState(false)
const [expanded, setExpanded] = useState(false)
const [phase, setPhase] = useState<UpdatePhase>('idle')
const [progress, setProgress] = useState(0)
const [errorMsg, setErrorMsg] = useState('')
useEffect(() => {
setDismissed(localStorage.getItem(DISMISS_KEY))
}, [])
const { data } = useQuery<UpdateCheckResult>({
queryKey: ['update-check'],
queryFn: async () => {
const res = await fetch('/api/update-check')
if (!res.ok) throw new Error('update check failed')
return res.json() as Promise<UpdateCheckResult>
},
refetchInterval: CHECK_INTERVAL_MS,
staleTime: CHECK_INTERVAL_MS,
retry: false,
})
useEffect(() => {
if (!data?.updateAvailable) {
setVisible(false)
return
}
if (dismissed === data.remoteCommit) {
setVisible(false)
return
}
setVisible(true)
}, [data, dismissed])
// Categorize changelog
const changelogSummary = useMemo(() => {
if (!data?.changelog) return { features: 0, fixes: 0, other: 0 }
let features = 0
let fixes = 0
let other = 0
for (const c of data.changelog) {
const lower = c.subject.toLowerCase()
if (lower.startsWith('feat')) features++
else if (lower.startsWith('fix')) fixes++
else other++
}
return { features, fixes, other }
}, [data?.changelog])
function handleDismiss() {
if (data?.remoteCommit) {
localStorage.setItem(DISMISS_KEY, data.remoteCommit)
setDismissed(data.remoteCommit)
}
setVisible(false)
setExpanded(false)
setPhase('idle')
}
async function handleInstall() {
setPhase('pulling')
setProgress(0)
setErrorMsg('')
// Simulate phased progress
const progressTimer = setInterval(() => {
setProgress((p) => Math.min(p + 2, 90))
}, 300)
try {
// Phase 1: pulling
setProgress(10)
await new Promise((r) => setTimeout(r, 500))
setPhase('installing')
setProgress(30)
const res = await fetch('/api/update-check', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({}),
})
const result = (await res.json()) as { ok: boolean; output: string }
clearInterval(progressTimer)
if (result.ok) {
setPhase('restarting')
setProgress(90)
setExpanded(true) // show changelog on success
await new Promise((r) => setTimeout(r, 800))
setPhase('done')
setProgress(100)
void queryClient.invalidateQueries({ queryKey: ['update-check'] })
// Auto-reload after showing patch notes
setTimeout(() => window.location.reload(), 3000)
} else {
setPhase('error')
setErrorMsg(result.output?.slice(0, 300) || 'Unknown error')
setProgress(0)
}
} catch (err) {
clearInterval(progressTimer)
setPhase('error')
setErrorMsg(err instanceof Error ? err.message : 'Update failed')
setProgress(0)
}
}
const isUpdating =
phase === 'pulling' || phase === 'installing' || phase === 'restarting'
return (
<AnimatePresence>
{visible && data && (
<motion.div
initial={{ opacity: 0, y: -50, scale: 0.95 }}
animate={{ opacity: 1, y: 0, scale: 1 }}
exit={{ opacity: 0, y: -50, scale: 0.95 }}
transition={{ duration: 0.35, ease: [0.23, 1, 0.32, 1] }}
className={cn(
'fixed left-1/2 -translate-x-1/2 z-[9999] top-[calc(var(--titlebar-h,0px)+1rem)]',
'flex flex-col rounded-2xl overflow-hidden',
'bg-primary-950 text-white',
'shadow-2xl shadow-black/40',
'border border-primary-800/60',
expanded ? 'max-w-lg w-[95vw]' : 'max-w-md w-[90vw]',
'transition-all duration-300',
)}
>
{/* Progress bar */}
{isUpdating && (
<motion.div
className="h-0.5 bg-accent-500 origin-left"
initial={{ scaleX: 0 }}
animate={{ scaleX: progress / 100 }}
transition={{ duration: 0.3 }}
/>
)}
{phase === 'done' && <div className="h-0.5 bg-green-500" />}
{/* Header */}
<div className="flex items-center gap-3 px-5 py-3.5">
<div
className={cn(
'flex items-center justify-center size-9 rounded-xl shrink-0',
phase === 'done'
? 'bg-green-500/20'
: phase === 'error'
? 'bg-red-500/20'
: 'bg-accent-500/20',
)}
>
{isUpdating ? (
<HugeiconsIcon
icon={Loading03Icon}
size={18}
strokeWidth={2}
className="animate-spin text-accent-400"
/>
) : phase === 'done' ? (
<HugeiconsIcon
icon={Tick01Icon}
size={18}
strokeWidth={2}
className="text-green-400"
/>
) : (
<HugeiconsIcon
icon={SparklesIcon}
size={18}
strokeWidth={2}
className="text-accent-400"
/>
)}
</div>
<div className="flex-1 min-w-0">
<p className="text-sm font-semibold">
{phase === 'idle' || phase === 'error'
? 'Hermes Workspace Update'
: PHASE_LABELS[phase]}
</p>
<p className="text-xs text-primary-400 truncate">
{phase === 'error'
? errorMsg
: phase === 'done'
? `Updated to ${data.remoteVersion} · reloading...`
: isUpdating
? PHASE_LABELS[phase]
: `${data.behindBy} update${data.behindBy !== 1 ? 's' : ''} available · ${data.localVersion}`}
</p>
</div>
<div className="flex items-center gap-2 shrink-0">
{!expanded && phase === 'idle' && (
<button
type="button"
onClick={() => setExpanded(true)}
className="text-xs text-primary-400 hover:text-primary-200 transition-colors"
>
What&apos;s new?
</button>
)}
{(phase === 'idle' || phase === 'error') && (
<button
type="button"
onClick={handleInstall}
className={cn(
'rounded-lg px-4 py-1.5 text-xs font-semibold transition-all',
'bg-accent-500 hover:bg-accent-400 text-white',
)}
>
{phase === 'error' ? 'Retry' : 'Install'}
</button>
)}
{!isUpdating && phase !== 'done' && (
<button
type="button"
onClick={handleDismiss}
className="rounded-full p-1 text-primary-500 hover:text-primary-300 transition-colors"
aria-label="Dismiss"
>
<HugeiconsIcon
icon={Cancel01Icon}
size={14}
strokeWidth={2}
/>
</button>
)}
</div>
</div>
{/* Changelog */}
<AnimatePresence>
{expanded && (
<motion.div
initial={{ height: 0, opacity: 0 }}
animate={{ height: 'auto', opacity: 1 }}
exit={{ height: 0, opacity: 0 }}
transition={{ duration: 0.25 }}
className="overflow-hidden"
>
<div className="border-t border-primary-800/60">
{data.changelog.length > 0 ? (
<>
{/* Summary pills */}
<div className="flex items-center gap-2 px-5 pt-3 pb-2">
{changelogSummary.features > 0 && (
<span className="inline-flex items-center gap-1 rounded-full bg-accent-500/15 px-2.5 py-0.5 text-[11px] font-medium text-accent-400">
{changelogSummary.features} feature
{changelogSummary.features !== 1 ? 's' : ''}
</span>
)}
{changelogSummary.fixes > 0 && (
<span className="inline-flex items-center gap-1 rounded-full bg-blue-500/15 px-2.5 py-0.5 text-[11px] font-medium text-blue-400">
🐛 {changelogSummary.fixes} fix
{changelogSummary.fixes !== 1 ? 'es' : ''}
</span>
)}
{changelogSummary.other > 0 && (
<span className="inline-flex items-center gap-1 rounded-full bg-primary-700/40 px-2.5 py-0.5 text-[11px] font-medium text-primary-400">
📦 {changelogSummary.other} other
</span>
)}
</div>
{/* Commit list */}
<div className="max-h-48 overflow-y-auto px-5 pb-4 space-y-1">
{data.changelog.map((commit) => (
<div
key={commit.hash}
className="flex items-start gap-2.5 py-1.5 group"
>
<span className="text-sm leading-5 shrink-0">
{commitTypeIcon(commit.subject)}
</span>
<div className="flex-1 min-w-0">
<p className="text-xs text-primary-200 leading-5 truncate">
{cleanSubject(commit.subject)}
</p>
</div>
<div className="flex items-center gap-2 shrink-0">
<code className="text-[10px] text-primary-500 font-mono">
{commit.hash}
</code>
<span className="text-[10px] text-primary-600">
{relativeTime(commit.date)}
</span>
</div>
</div>
))}
</div>
</>
) : (
<div className="px-5 py-4 text-center">
<p className="text-xs text-primary-400">
{data.behindBy} new update
{data.behindBy !== 1 ? 's' : ''} available with bug
fixes and improvements.
</p>
<p className="text-[10px] text-primary-500 mt-1">
Click Install to update to the latest version.
</p>
</div>
)}
</div>
</motion.div>
)}
</AnimatePresence>
</motion.div>
)}
</AnimatePresence>
)
}

View File

@@ -1,763 +0,0 @@
import { useEffect, useMemo, useRef, useState } from 'react'
import { create } from 'zustand'
import { persist } from 'zustand/middleware'
import type {
GatewaySession,
GatewaySessionStatusResponse,
} from '@/lib/gateway-api'
import { fetchSessions } from '@/lib/gateway-api'
import { assignPersona } from '@/lib/agent-personas'
import { useMissionStore } from '@/stores/mission-store'
export type AgentModel = string
export type ActiveAgent = {
id: string
name: string
task: string
model: AgentModel
status: string
progress: number
startedAtMs: number
tokenCount: number
estimatedCost: number
isLive: boolean
}
export type QueuePriority = 'high' | 'normal' | 'low'
export type QueuedAgentTask = {
id: string
name: string
description: string
priority: QueuePriority
}
export type AgentHistoryStatus = 'success' | 'failed'
export type AgentHistoryItem = {
id: string
name: string
description: string
model: AgentModel
status: AgentHistoryStatus
runtimeSeconds: number
tokenCount: number
cost: number
}
type AgentViewState = {
isOpen: boolean
queueOpen: boolean
historyOpen: boolean
setOpen: (isOpen: boolean) => void
toggleOpen: () => void
setQueueOpen: (isOpen: boolean) => void
setHistoryOpen: (isOpen: boolean) => void
}
const PANEL_WIDTH_PX = 320
const MIN_DESKTOP_WIDTH = 1024
const AUTO_OPEN_WIDTH = 1440
const REFRESH_INTERVAL_MS = 5000
function createDemoActiveAgents(): Array<ActiveAgent> {
const now = Date.now()
return [
{
id: 'demo-dashboard-infra',
name: '🎨 Roger — Frontend Developer',
task: 'Building dashboard widget grid with responsive layout',
model: 'gpt-5.3-codex',
status: 'running',
progress: 67,
startedAtMs: now - 204_000,
tokenCount: 38_240,
estimatedCost: 0.218,
isLive: false,
},
{
id: 'demo-skills-browser',
name: '🏗️ Sally — Backend Architect',
task: 'Creating API routes for skills marketplace',
model: 'gpt-5.3-codex',
status: 'thinking',
progress: 42,
startedAtMs: now - 131_000,
tokenCount: 21_915,
estimatedCost: 0.131,
isLive: false,
},
{
id: 'demo-terminal-integration',
name: '🔍 Ada — QA Engineer',
task: 'Running integration tests on terminal panel',
model: 'gpt-5.3-codex',
status: 'running',
progress: 85,
startedAtMs: now - 242_000,
tokenCount: 47_609,
estimatedCost: 0.286,
isLive: false,
},
]
}
function createDemoQueue(): Array<QueuedAgentTask> {
return [
{
id: 'demo-queue-1',
name: 'release-notes',
description: 'Drafting release notes and migration checklist',
priority: 'high',
},
{
id: 'demo-queue-2',
name: 'theme-pass',
description: 'Applying dark theme polish to diagnostics screens',
priority: 'normal',
},
]
}
function createDemoHistory(): Array<AgentHistoryItem> {
return [
{
id: 'demo-history-1',
name: 'api-telemetry',
description: 'Instrumented API telemetry dashboard',
model: 'gpt-5-codex',
status: 'success',
runtimeSeconds: 452,
tokenCount: 62_430,
cost: 0.348,
},
{
id: 'demo-history-2',
name: 'auth-hardening',
description: 'Added auth guardrails for session endpoints',
model: 'claude-3-5-sonnet',
status: 'success',
runtimeSeconds: 311,
tokenCount: 48_920,
cost: 0.284,
},
]
}
function inferInitialOpenState(): boolean {
if (typeof window === 'undefined') return true
return window.innerWidth >= AUTO_OPEN_WIDTH
}
function readString(value: unknown): string {
if (typeof value !== 'string') return ''
return value.trim()
}
function readNumber(value: unknown): number {
if (typeof value !== 'number' || !Number.isFinite(value)) return 0
return value
}
function readTimestamp(value: unknown): number | null {
if (typeof value === 'number' && Number.isFinite(value)) {
return value < 1_000_000_000_000 ? value * 1000 : value
}
if (typeof value === 'string') {
const parsed = Date.parse(value)
if (!Number.isNaN(parsed)) return parsed
}
return null
}
function readSessionKey(session: GatewaySession): string {
const key = readString(session.key)
if (key.length > 0) return key
const friendly = readString(session.friendlyId)
if (friendly.length > 0) return friendly
return ''
}
function readSessionName(session: GatewaySession): string {
// Assign persona based on session key + task for named agent display
const key = readSessionKey(session)
const taskText =
readString(session.task) ||
readString(session.initialMessage) ||
readString(session.label)
if (key.length > 0) {
const persona = assignPersona(key, taskText)
return `${persona.emoji} ${persona.name}${persona.role}`
}
const label = readString(session.label)
if (label.length > 0) return label
const title = readString(session.title)
if (title.length > 0) return title
const derived = readString(session.derivedTitle)
if (derived.length > 0) return derived
const friendly = readString(session.friendlyId)
if (friendly.length > 0) return friendly
return 'session'
}
function isAgentSession(session: GatewaySession): boolean {
const key = readSessionKey(session).toLowerCase()
// Always exclude main sessions
if (key === 'main' || key.includes(':main')) return false
const friendlyId = readString(session.friendlyId).toLowerCase()
if (friendlyId === 'main') return false
// Always exclude cron jobs
if (key.includes('cron')) return false
const kind = readString(session.kind).toLowerCase()
if (kind === 'cron') return false
// Everything else is an agent session — inclusive by default (#37)
return true
}
function readTaskText(session: GatewaySession): string {
const explicitTask = readString(session.task)
if (explicitTask.length > 0) return explicitTask
const initialMessage = readString(session.initialMessage)
if (initialMessage.length > 0) return initialMessage
const lastMessage = session.lastMessage
if (lastMessage && typeof lastMessage === 'object') {
const directText = readString((lastMessage as { text?: unknown }).text)
if (directText.length > 0) return directText
const content = (lastMessage as { content?: unknown }).content
if (Array.isArray(content)) {
const text = content
.map(function mapPart(part) {
if (!part || typeof part !== 'object') return ''
return readString((part as { text?: unknown }).text)
})
.join(' ')
.trim()
if (text.length > 0) return text
}
}
return 'Agent session in progress'
}
function readTokenCount(
session: GatewaySession,
status: GatewaySessionStatusResponse | null,
): number {
const statusTokenCount = readNumber(status?.tokenCount)
if (statusTokenCount > 0) return statusTokenCount
const statusTotalTokens = readNumber(status?.totalTokens)
if (statusTotalTokens > 0) return statusTotalTokens
const statusUsageTokens = readNumber(status?.usage?.tokens)
if (statusUsageTokens > 0) return statusUsageTokens
const statusUsageTotal = readNumber(status?.usage?.totalTokens)
if (statusUsageTotal > 0) return statusUsageTotal
const sessionTokenCount = readNumber(session.tokenCount)
if (sessionTokenCount > 0) return sessionTokenCount
const sessionTotalTokens = readNumber(session.totalTokens)
if (sessionTotalTokens > 0) return sessionTotalTokens
const sessionUsageTokens = readNumber(session.usage?.tokens)
if (sessionUsageTokens > 0) return sessionUsageTokens
return readNumber(session.usage?.totalTokens)
}
function readEstimatedCost(
session: GatewaySession,
status: GatewaySessionStatusResponse | null,
tokenCount: number,
): number {
const statusCost = readNumber(status?.usage?.cost)
if (statusCost > 0) return statusCost
const sessionCost = readNumber(session.cost)
if (sessionCost > 0) return sessionCost
const usageCost = readNumber(session.usage?.cost)
if (usageCost > 0) return usageCost
return Number((tokenCount * 0.000004).toFixed(3))
}
function readProgress(
session: GatewaySession,
status: GatewaySessionStatusResponse | null,
): number {
const statusProgress = readNumber(status?.progress)
if (statusProgress > 0)
return Math.max(1, Math.min(99, Math.round(statusProgress)))
const sessionProgress = readNumber(session.progress)
if (sessionProgress > 0)
return Math.max(1, Math.min(99, Math.round(sessionProgress)))
const sessionStatus = readStatus(session, status)
if (isQueuedStatus(sessionStatus)) return 5
if (isFailedStatus(sessionStatus)) return 100
if (isCompletedStatus(sessionStatus)) return 100
return 35
}
function readStatus(
session: GatewaySession,
status: GatewaySessionStatusResponse | null,
): string {
const statusText = readString(status?.status)
if (statusText.length > 0) return statusText.toLowerCase()
const sessionStatus = readString(session.status)
if (sessionStatus.length > 0) return sessionStatus.toLowerCase()
// Heuristic: detect completion from staleness when gateway has no explicit status
const updatedAt = readTimestamp(session.updatedAt)
if (updatedAt) {
const staleness = Date.now() - updatedAt
const tokens =
readNumber(session.totalTokens) || readNumber(session.tokenCount)
if (tokens > 0 && staleness > 120_000) return 'complete'
if (tokens === 0 && staleness > 120_000) return 'idle'
}
return 'running'
}
function readModel(
session: GatewaySession,
status: GatewaySessionStatusResponse | null,
): string {
const statusModel = readString(status?.model)
if (statusModel.length > 0) return statusModel
const sessionModel = readString(session.model)
if (sessionModel.length > 0) return sessionModel
return 'unknown'
}
function readStartTimeMs(session: GatewaySession): number {
const startedAt = readTimestamp(session.startedAt)
if (startedAt) return startedAt
const createdAt = readTimestamp(session.createdAt)
if (createdAt) return createdAt
const updatedAt = readTimestamp(session.updatedAt)
if (updatedAt) return updatedAt
return Date.now()
}
function isQueuedStatus(status: string): boolean {
return ['queued', 'pending', 'waiting'].includes(status)
}
function isRunningStatus(status: string): boolean {
return [
'running',
'active',
'started',
'streaming',
'processing',
'in_progress',
'thinking',
].includes(status)
}
function isCompletedStatus(status: string): boolean {
return ['complete', 'completed', 'success', 'succeeded', 'done'].includes(
status,
)
}
function isFailedStatus(status: string): boolean {
return ['failed', 'error', 'cancelled', 'canceled', 'killed'].includes(status)
}
function mapSessionToActiveAgent(
session: GatewaySession,
status: GatewaySessionStatusResponse | null,
): ActiveAgent {
const tokenCount = readTokenCount(session, status)
return {
id: readSessionKey(session) || crypto.randomUUID(),
name: readSessionName(session),
task: readTaskText(session),
model: readModel(session, status),
status: readStatus(session, status),
progress: readProgress(session, status),
startedAtMs: readStartTimeMs(session),
tokenCount,
estimatedCost: readEstimatedCost(session, status, tokenCount),
isLive: true,
}
}
function mapSessionToQueuedTask(session: GatewaySession): QueuedAgentTask {
return {
id: readSessionKey(session) || `queued-${crypto.randomUUID()}`,
name: readSessionName(session),
description: readTaskText(session),
priority: 'normal',
}
}
function mapSessionToHistoryItem(
session: GatewaySession,
status: GatewaySessionStatusResponse | null,
): AgentHistoryItem {
const startMs = readStartTimeMs(session)
const endMs = readTimestamp(session.updatedAt) ?? Date.now()
const tokenCount = readTokenCount(session, status)
const statusText = readStatus(session, status)
return {
id: readSessionKey(session) || `history-${crypto.randomUUID()}`,
name: readSessionName(session),
description: readTaskText(session),
model: readModel(session, status),
status: isFailedStatus(statusText) ? 'failed' : 'success',
runtimeSeconds: Math.max(1, Math.floor((endMs - startMs) / 1000)),
tokenCount,
cost: readEstimatedCost(session, status, tokenCount),
}
}
export const useAgentViewStore = create<AgentViewState>()(
persist(
(set) => ({
isOpen: inferInitialOpenState(),
queueOpen: true,
historyOpen: false,
setOpen: function setOpen(isOpen) {
set({ isOpen })
},
toggleOpen: function toggleOpen() {
set((state) => ({ isOpen: !state.isOpen }))
},
setQueueOpen: function setQueueOpen(isOpen) {
set({ queueOpen: isOpen })
},
setHistoryOpen: function setHistoryOpen(isOpen) {
set({ historyOpen: isOpen })
},
}),
{
name: 'agent-view-state',
},
),
)
export type AgentViewResult = {
isOpen: boolean
queueOpen: boolean
historyOpen: boolean
isDesktop: boolean
shouldAutoOpen: boolean
panelVisible: boolean
showFloatingToggle: boolean
panelWidth: number
panelOffset: number
nowMs: number
lastRefreshedMs: number
activeAgents: Array<ActiveAgent>
missionActiveAgents: Array<ActiveAgent>
nonMissionActiveAgents: Array<ActiveAgent>
queuedAgents: Array<QueuedAgentTask>
historyAgents: Array<AgentHistoryItem>
activeMissionName: string
activeMissionState: string | null
activeCount: number
isLoading: boolean
isDemoMode: boolean
isLiveConnected: boolean
errorMessage: string | null
setOpen: (isOpen: boolean) => void
toggleOpen: () => void
setQueueOpen: (isOpen: boolean) => void
setHistoryOpen: (isOpen: boolean) => void
killAgent: (agentId: string) => void
cancelQueueTask: (taskId: string) => void
}
export function useAgentView(): AgentViewResult {
const isOpen = useAgentViewStore((state) => state.isOpen)
const queueOpen = useAgentViewStore((state) => state.queueOpen)
const historyOpen = useAgentViewStore((state) => state.historyOpen)
const setOpen = useAgentViewStore((state) => state.setOpen)
const toggleOpen = useAgentViewStore((state) => state.toggleOpen)
const setQueueOpen = useAgentViewStore((state) => state.setQueueOpen)
const setHistoryOpen = useAgentViewStore((state) => state.setHistoryOpen)
const activeMission = useMissionStore((state) => state.activeMission)
const missionSessionMap = useMissionStore((state) => state.agentSessionMap)
const [viewportWidth, setViewportWidth] = useState(() => {
if (typeof window === 'undefined') return AUTO_OPEN_WIDTH
return window.innerWidth
})
const [nowMs, setNowMs] = useState(() => Date.now())
const [lastRefreshedMs, setLastRefreshedMs] = useState(() => Date.now())
const [activeAgents, setActiveAgents] = useState<Array<ActiveAgent>>([])
const [queuedAgents, setQueuedAgents] = useState<Array<QueuedAgentTask>>([])
const [historyAgents, setHistoryAgents] = useState<Array<AgentHistoryItem>>(
[],
)
const [isLoading, setIsLoading] = useState(true)
const [isDemoMode, setIsDemoMode] = useState(false)
const [isLiveConnected, setIsLiveConnected] = useState(false)
const [errorMessage, setErrorMessage] = useState<string | null>(null)
const previousAutoOpenRef = useRef(false)
useEffect(() => {
function handleResize() {
setViewportWidth(window.innerWidth)
}
handleResize()
window.addEventListener('resize', handleResize)
return function cleanupResize() {
window.removeEventListener('resize', handleResize)
}
}, [])
useEffect(() => {
const timer = window.setInterval(() => {
setNowMs(Date.now())
}, 5000) // Every 5s instead of 1s to reduce re-renders
return function cleanupTimer() {
window.clearInterval(timer)
}
}, [])
useEffect(() => {
let isDisposed = false
async function refresh() {
try {
const sessionsPayload = await fetchSessions()
const sessions = Array.isArray(sessionsPayload.sessions)
? sessionsPayload.sessions
: []
// Skip per-session status fetch — /api/sessions/:key/status route
// doesn't exist, causing 404 spam. Use session data directly.
const statusEntries = sessions.map(function loadStatus(session) {
const key = readSessionKey(session)
return [key, null] as const
})
const statusMap = new Map<string, GatewaySessionStatusResponse | null>(
statusEntries,
)
const nextActiveAgents: Array<ActiveAgent> = []
const nextQueuedAgents: Array<QueuedAgentTask> = []
const nextHistoryAgents: Array<AgentHistoryItem> = []
sessions.forEach(function classifySession(session) {
if (!isAgentSession(session)) return
const key = readSessionKey(session)
const status = key ? (statusMap.get(key) ?? null) : null
const statusText = readStatus(session, status)
if (isQueuedStatus(statusText)) {
nextQueuedAgents.push(mapSessionToQueuedTask(session))
return
}
if (isCompletedStatus(statusText) || isFailedStatus(statusText)) {
nextHistoryAgents.push(mapSessionToHistoryItem(session, status))
return
}
if (isRunningStatus(statusText) || statusText.length === 0) {
nextActiveAgents.push(mapSessionToActiveAgent(session, status))
}
})
if (isDisposed) return
setActiveAgents(nextActiveAgents)
setQueuedAgents(nextQueuedAgents)
setHistoryAgents(nextHistoryAgents.slice(0, 10))
setIsDemoMode(false)
setIsLiveConnected(true)
setErrorMessage(null)
} catch (error) {
if (isDisposed) return
setActiveAgents(createDemoActiveAgents())
setQueuedAgents(createDemoQueue())
setHistoryAgents(createDemoHistory())
setIsDemoMode(true)
setIsLiveConnected(false)
setErrorMessage(
error instanceof Error ? error.message : 'Gateway unavailable',
)
} finally {
if (!isDisposed) {
setLastRefreshedMs(Date.now())
setIsLoading(false)
}
}
}
void refresh()
const refreshTimer = window.setInterval(() => {
void refresh()
}, REFRESH_INTERVAL_MS)
return function cleanupRefresh() {
isDisposed = true
window.clearInterval(refreshTimer)
}
}, [])
const shouldAutoOpen = viewportWidth >= AUTO_OPEN_WIDTH
useEffect(() => {
const isCrossingToLargeDesktop =
shouldAutoOpen && previousAutoOpenRef.current !== shouldAutoOpen
previousAutoOpenRef.current = shouldAutoOpen
if (isCrossingToLargeDesktop) {
setOpen(true)
}
}, [setOpen, shouldAutoOpen])
const isDesktop = viewportWidth >= MIN_DESKTOP_WIDTH
const panelVisible = isDesktop && isOpen
const showFloatingToggle = isDesktop && !isOpen
const panelOffset = panelVisible ? PANEL_WIDTH_PX : 0
const missionSessionKeys = useMemo(
() => new Set(Object.values(missionSessionMap)),
[missionSessionMap],
)
const missionActiveAgents = useMemo(
() =>
activeMission
? activeAgents.filter((agent) => missionSessionKeys.has(agent.id))
: [],
[activeAgents, activeMission, missionSessionKeys],
)
const nonMissionActiveAgents = useMemo(
() =>
activeMission
? activeAgents.filter((agent) => !missionSessionKeys.has(agent.id))
: activeAgents,
[activeAgents, activeMission, missionSessionKeys],
)
function killAgent(agentId: string) {
setActiveAgents((previous) => {
const killedAgent = previous.find((agent) => agent.id === agentId)
if (!killedAgent) return previous
const runtimeSeconds = Math.max(
1,
Math.floor((Date.now() - killedAgent.startedAtMs) / 1000),
)
const historyEntry: AgentHistoryItem = {
id: `history-${crypto.randomUUID()}`,
name: killedAgent.name,
description: killedAgent.task,
model: killedAgent.model,
status: 'failed',
runtimeSeconds,
tokenCount: killedAgent.tokenCount,
cost: killedAgent.estimatedCost,
}
setHistoryAgents((current) => [historyEntry, ...current].slice(0, 10))
return previous.filter((agent) => agent.id !== agentId)
})
}
function cancelQueueTask(taskId: string) {
setQueuedAgents((previous) => previous.filter((task) => task.id !== taskId))
}
return useMemo(
() => ({
isOpen,
queueOpen,
historyOpen,
isDesktop,
shouldAutoOpen,
panelVisible,
showFloatingToggle,
panelWidth: PANEL_WIDTH_PX,
panelOffset,
nowMs,
lastRefreshedMs,
activeAgents,
missionActiveAgents,
nonMissionActiveAgents,
queuedAgents,
historyAgents,
activeMissionName: activeMission?.name || '',
activeMissionState: activeMission?.state ?? null,
activeCount: activeAgents.length,
isLoading,
isDemoMode,
isLiveConnected,
errorMessage,
setOpen,
toggleOpen,
setQueueOpen,
setHistoryOpen,
killAgent,
cancelQueueTask,
}),
[
activeAgents,
activeMission,
missionActiveAgents,
nonMissionActiveAgents,
cancelQueueTask,
errorMessage,
historyAgents,
historyOpen,
isDemoMode,
isDesktop,
isLiveConnected,
isLoading,
isOpen,
killAgent,
lastRefreshedMs,
nowMs,
panelOffset,
panelVisible,
queueOpen,
queuedAgents,
setHistoryOpen,
setOpen,
setQueueOpen,
shouldAutoOpen,
showFloatingToggle,
toggleOpen,
],
)
}
export function formatRuntime(totalSeconds: number): string {
const minutes = Math.floor(totalSeconds / 60)
const seconds = totalSeconds % 60
return `${minutes}m ${String(seconds).padStart(2, '0')}s`
}
export function formatCost(cost: number): string {
return `$${cost.toFixed(3)}`
}

View File

@@ -1,381 +1,36 @@
import type { Dispatch, SetStateAction } from 'react'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { useGatewayChatStore } from '../stores/gateway-chat-store'
import {
CHAT_STREAM_DONE_EVENT,
CHAT_TOOL_CALL_EVENT,
CHAT_TOOL_RESULT_EVENT,
} from './use-gateway-chat-stream'
// Stub — research card is a ClawSuite feature, not used in Hermes Workspace
const EMPTY_TOOL_CALLS: never[] = []
const researchTimelineCache = new Map<
string,
{ steps: ResearchStep[]; collapsed: boolean }
>()
// Dev helper: trigger a fake research card demo from the browser console.
// Usage: __triggerResearchDemo() or __triggerResearchDemo('agent:main:main')
if (typeof window !== 'undefined' && import.meta.env.DEV) {
;(window as any).__triggerResearchDemo = (sessionKey = 'agent:main:main') => {
const tools = [
{ name: 'memory_search', args: '{"query":"test"}', delay: 0, duration: 800 },
{ name: 'Read', args: '{"path":"MEMORY.md"}', delay: 200, duration: 1200 },
{ name: 'exec', args: '{"command":"git log --oneline -5"}', delay: 500, duration: 2000 },
{ name: 'web_search', args: '{"query":"hermes agent latest"}', delay: 1000, duration: 1500 },
{ name: 'Edit', args: '{"path":"src/app.tsx"}', delay: 1500, duration: 900 },
]
tools.forEach((tool, i) => {
const toolCallId = `demo-${i}-${Date.now()}`
setTimeout(() => {
window.dispatchEvent(new CustomEvent(CHAT_TOOL_CALL_EVENT, {
detail: { sessionKey, toolCallId, name: tool.name, args: tool.args, phase: 'calling' }
}))
}, tool.delay)
setTimeout(() => {
window.dispatchEvent(new CustomEvent(CHAT_TOOL_RESULT_EVENT, {
detail: { sessionKey, toolCallId, name: tool.name, phase: 'done' }
}))
}, tool.delay + tool.duration)
})
}
}
export type ResearchStep = {
export interface ResearchStep {
id: string
toolName: string
name: string
label: string
status: 'running' | 'done' | 'error'
startedAt: number
durationMs?: number
args: string
delay: number
duration: number
durationMs: number
status: 'pending' | 'running' | 'done'
}
export type UseResearchCardResult = {
steps: ResearchStep[]
export interface UseResearchCardResult {
isVisible: boolean
isActive: boolean
totalDurationMs: number
currentStep: number
steps: ResearchStep[]
collapsed: boolean
setCollapsed: Dispatch<SetStateAction<boolean>>
setCollapsed: (collapsed: boolean) => void
totalDurationMs: number
dismiss: () => void
}
type UseResearchCardOptions = {
sessionKey?: string
isStreaming?: boolean
resetKey?: string | number
}
type ToolEventDetail = {
sessionKey?: string
toolCallId?: string
name?: string
phase?: string
args?: unknown
}
function basename(path: string): string {
if (!path) return ''
const normalized = path.replace(/\\/g, '/')
const parts = normalized.split('/')
return parts[parts.length - 1] || path
}
function extractFileTarget(args: unknown): string {
if (!args) return ''
if (typeof args === 'string') {
try {
const parsed = JSON.parse(args) as unknown
return extractFileTarget(parsed)
} catch {
// Not JSON — try regex
const patterns = [
/"(?:path|file_path|file|filepath)"\s*:\s*"([^"]+)"/i,
/path=([^\s,]+)/i,
]
for (const pattern of patterns) {
const match = pattern.exec(args)
if (match?.[1]) return basename(match[1])
}
return ''
}
}
if (typeof args === 'object' && args !== null) {
const record = args as Record<string, unknown>
for (const key of ['path', 'filePath', 'file_path', 'filepath', 'filename', 'file', 'target_file']) {
const val = record[key]
if (typeof val === 'string' && val.trim()) return basename(val.trim())
}
}
return ''
}
function buildToolLabel(toolName: string, args: unknown): string {
const fileTarget = extractFileTarget(args)
switch (toolName) {
case 'exec':
return 'Running command'
case 'Read':
case 'read':
return fileTarget ? `Reading ${fileTarget}` : 'Reading file'
case 'Write':
case 'write':
return fileTarget ? `Writing ${fileTarget}` : 'Writing file'
case 'Edit':
case 'edit':
return fileTarget ? `Editing ${fileTarget}` : 'Editing file'
case 'web_search':
return 'Searching the web'
case 'web_fetch':
return 'Fetching page'
case 'sessions_spawn':
return 'Spawning agent'
case 'sessions_send':
return 'Steering agent'
case 'memory_search':
return 'Searching memory'
case 'browser':
return 'Controlling browser'
case 'image':
return 'Analyzing image'
default:
return toolName
.replace(/[_-]+/g, ' ')
.replace(/\b\w/g, (char) => char.toUpperCase())
}
}
/**
* Research card hook that reads directly from the same gateway chat
* store selector path used by the thinking bubble.
*/
export function useResearchCard({
sessionKey,
isStreaming = false,
resetKey,
}: UseResearchCardOptions = {}) {
const effectiveSessionKey = sessionKey || 'main'
const timelineKey = `${effectiveSessionKey}:${String(resetKey ?? 'default')}`
const streamingToolCalls = useGatewayChatStore(
(state) => state.streamingState.get(effectiveSessionKey)?.toolCalls ?? EMPTY_TOOL_CALLS,
)
const [steps, setSteps] = useState<ResearchStep[]>(
() => researchTimelineCache.get(timelineKey)?.steps ?? [],
)
const [collapsed, setCollapsed] = useState(
() => researchTimelineCache.get(timelineKey)?.collapsed ?? false,
)
const [now, setNow] = useState(() => Date.now())
const seenToolIdsRef = useRef<Set<string>>(new Set())
const stepsRef = useRef(steps)
useEffect(() => {
stepsRef.current = steps
}, [steps])
const writeTimelineSnapshot = useCallback(
(nextSteps: ResearchStep[], nextCollapsed: boolean) => {
researchTimelineCache.set(timelineKey, {
steps: nextSteps,
collapsed: nextCollapsed,
})
},
[timelineKey],
)
useEffect(() => {
const cached = researchTimelineCache.get(timelineKey)
const nextSteps = cached?.steps ?? []
const nextCollapsed = cached?.collapsed ?? false
setSteps(nextSteps)
setCollapsed(nextCollapsed)
seenToolIdsRef.current = new Set(nextSteps.map((step) => step.id))
}, [timelineKey])
useEffect(() => {
writeTimelineSnapshot(steps, collapsed)
}, [collapsed, steps, writeTimelineSnapshot])
const upsertStep = useCallback(
(
toolId: string,
toolName: string,
args: unknown,
status: ResearchStep['status'],
currentTime = Date.now(),
) => {
setNow(currentTime)
setSteps((prevSteps) => {
const existingIndex = prevSteps.findIndex((step) => step.id === toolId)
if (existingIndex >= 0) {
const existing = prevSteps[existingIndex]
const nextDuration =
status === 'running' ? undefined : currentTime - existing.startedAt
const nextLabel = buildToolLabel(toolName, args)
if (
existing.toolName === toolName &&
existing.label === nextLabel &&
existing.status === status &&
existing.durationMs === nextDuration
) {
return prevSteps
}
const nextSteps = [...prevSteps]
nextSteps[existingIndex] = {
...existing,
toolName,
label: nextLabel,
status,
durationMs: nextDuration,
}
return nextSteps
}
if (seenToolIdsRef.current.has(toolId)) return prevSteps
seenToolIdsRef.current.add(toolId)
return [
...prevSteps,
{
id: toolId,
toolName,
label: buildToolLabel(toolName, args),
status,
startedAt: currentTime,
durationMs: status === 'running' ? undefined : 0,
},
]
})
},
[],
)
// Auto-collapse when streaming ends
useEffect(() => {
if (!isStreaming && steps.length > 0) {
setCollapsed(true)
}
}, [isStreaming, steps.length])
// Tick timer for duration display
useEffect(() => {
if (!steps.some((step) => step.status === 'running')) return
setNow(Date.now())
const intervalId = window.setInterval(() => setNow(Date.now()), 1000)
return () => window.clearInterval(intervalId)
}, [steps])
// Mirror the active tool-call array from the store into a persistent
// timeline so completed steps still render after streaming state clears.
useEffect(() => {
if (streamingToolCalls.length === 0) return
const currentTime = Date.now()
for (const toolCall of streamingToolCalls) {
const isDone = toolCall.phase === 'done' || toolCall.phase === 'result'
const isError = toolCall.phase === 'error'
const nextStatus: ResearchStep['status'] = isError
? 'error'
: isDone
? 'done'
: 'running'
upsertStep(
toolCall.id,
toolCall.name,
toolCall.args,
nextStatus,
currentTime,
)
}
setCollapsed(false)
}, [streamingToolCalls, upsertStep])
// Track tool activity directly from SSE tool events so quick runs still
// populate the timeline even if streamingState is cleared before a render.
useEffect(() => {
if (typeof window === 'undefined') return
const handleToolCall = (event: Event) => {
const detail = (event as CustomEvent<ToolEventDetail>).detail
if (detail.sessionKey !== effectiveSessionKey) return
const toolId = detail.toolCallId?.trim()
const toolName = detail.name?.trim()
if (!toolId || !toolName) return
upsertStep(toolId, toolName, detail.args, 'running')
setCollapsed(false)
}
const handleToolResult = (event: Event) => {
const detail = (event as CustomEvent<ToolEventDetail>).detail
if (detail.sessionKey !== effectiveSessionKey) return
const toolId = detail.toolCallId?.trim()
const toolName = detail.name?.trim()
if (!toolId || !toolName) return
upsertStep(
toolId,
toolName,
detail.args,
detail.phase === 'error' ? 'error' : 'done',
)
setCollapsed(false)
}
const handleStreamDone = (event: Event) => {
const detail = (event as CustomEvent<{ sessionKey?: string }>).detail
if (detail.sessionKey !== effectiveSessionKey) return
setCollapsed((current) => (stepsRef.current.length > 0 ? true : current))
}
window.addEventListener(CHAT_TOOL_CALL_EVENT, handleToolCall as EventListener)
window.addEventListener(
CHAT_TOOL_RESULT_EVENT,
handleToolResult as EventListener,
)
window.addEventListener(
CHAT_STREAM_DONE_EVENT,
handleStreamDone as EventListener,
)
return () => {
window.removeEventListener(
CHAT_TOOL_CALL_EVENT,
handleToolCall as EventListener,
)
window.removeEventListener(
CHAT_TOOL_RESULT_EVENT,
handleToolResult as EventListener,
)
window.removeEventListener(
CHAT_STREAM_DONE_EVENT,
handleStreamDone as EventListener,
)
}
}, [effectiveSessionKey, upsertStep])
const totalDurationMs = useMemo(() => {
if (steps.length === 0) return 0
const startedAt = Math.min(...steps.map((step) => step.startedAt))
const endedAt = Math.max(
...steps.map((step) =>
step.startedAt + (step.durationMs ?? (isStreaming ? now - step.startedAt : 0)),
),
)
return Math.max(0, endedAt - startedAt)
}, [isStreaming, now, steps])
const isActive = steps.some((step) => step.status === 'running')
export function useResearchCard(_opts?: unknown): UseResearchCardResult {
return {
steps,
isActive,
totalDurationMs,
collapsed,
setCollapsed,
isVisible: false,
isActive: false,
currentStep: 0,
steps: [],
collapsed: true,
setCollapsed: () => {},
totalDurationMs: 0,
dismiss: () => {},
}
}

View File

@@ -1,136 +0,0 @@
import { useEffect, useRef } from 'react'
import { useTaskStore, type Task } from '@/stores/task-store'
/** Tracks which reminders/auto-alerts have already fired this session */
const firedReminders = new Set<string>()
const firedAutoAlerts = new Set<string>()
function showNotification(title: string, body: string) {
// Try browser Notification API first
if (
typeof Notification !== 'undefined' &&
Notification.permission === 'granted'
) {
new Notification(title, { body, icon: '/favicon.ico' })
return
}
// Fallback: dispatch custom event for in-app toast
window.dispatchEvent(
new CustomEvent('hermes:toast', {
detail: { title, body, type: 'warning' },
}),
)
}
function isOverdue(task: Task): boolean {
if (!task.dueDate || task.status === 'done') return false
return new Date(task.dueDate).getTime() < Date.now()
}
function isDueSoon(task: Task): boolean {
if (!task.dueDate || task.status === 'done') return false
const diff = new Date(task.dueDate).getTime() - Date.now()
return diff > 0 && diff < 24 * 60 * 60 * 1000
}
export function useTaskReminders() {
const intervalRef = useRef<ReturnType<typeof setInterval>>(null)
useEffect(() => {
// Request notification permission on mount
if (
typeof window !== 'undefined' &&
typeof Notification !== 'undefined' &&
Notification.permission === 'default'
) {
void Notification.requestPermission()
}
function check() {
const { tasks, updateTask } = useTaskStore.getState()
const now = Date.now()
for (const task of tasks) {
if (task.status === 'done') continue
// 1. Explicit reminder — fires once, then clears
if (task.reminder && !firedReminders.has(task.id)) {
const reminderTime = new Date(task.reminder).getTime()
if (reminderTime <= now) {
firedReminders.add(task.id)
showNotification(
`⏰ Reminder: ${task.title}`,
task.description || `${task.priority} · ${task.status}`,
)
// Clear the reminder so it doesn't fire again after page reload
updateTask(task.id, { reminder: undefined })
// Persist to API
void fetch(`/api/tasks/${task.id}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ reminder: null }),
})
}
}
// 2. Overdue notification
if (isOverdue(task) && !firedAutoAlerts.has(`overdue-${task.id}`)) {
firedAutoAlerts.add(`overdue-${task.id}`)
showNotification(
`🚨 Overdue: ${task.title}`,
`Was due ${new Date(task.dueDate!).toLocaleDateString()}`,
)
}
// 3. Due soon (within 24h)
if (isDueSoon(task) && !firedAutoAlerts.has(`duesoon-${task.id}`)) {
firedAutoAlerts.add(`duesoon-${task.id}`)
showNotification(
`⚠️ Due soon: ${task.title}`,
`Due ${new Date(task.dueDate!).toLocaleDateString()}`,
)
}
// 4. Auto-priority reminders (P0 every 4h, P1 daily if stale)
if (task.priority === 'P0' && !task.reminder) {
const key = `p0-${task.id}-${Math.floor(now / (4 * 60 * 60 * 1000))}`
if (!firedAutoAlerts.has(key)) {
firedAutoAlerts.add(key)
showNotification(
`🔴 P0 Active: ${task.title}`,
`Priority task needs attention`,
)
}
}
if (
task.priority === 'P1' &&
task.status === 'in_progress' &&
!task.reminder
) {
const updated = new Date(task.updatedAt).getTime()
const twoDays = 2 * 24 * 60 * 60 * 1000
if (now - updated > twoDays) {
const key = `p1-stale-${task.id}-${Math.floor(now / (24 * 60 * 60 * 1000))}`
if (!firedAutoAlerts.has(key)) {
firedAutoAlerts.add(key)
showNotification(
`🟡 P1 Stale: ${task.title}`,
`In progress for ${Math.floor((now - updated) / (24 * 60 * 60 * 1000))} days`,
)
}
}
}
}
}
// Run immediately then every 60s
check()
intervalRef.current = setInterval(check, 60_000)
return () => {
if (intervalRef.current) clearInterval(intervalRef.current)
}
}, [])
}

File diff suppressed because it is too large Load Diff

View File

@@ -6,16 +6,10 @@ import { SearchModal } from '@/components/search/search-modal'
import { TerminalShortcutListener } from '@/components/terminal-shortcut-listener'
import { GlobalShortcutListener } from '@/components/global-shortcut-listener'
import { WorkspaceShell } from '@/components/workspace-shell'
import { useTaskReminders } from '@/hooks/use-task-reminders'
import { UpdateNotifier } from '@/components/update-notifier'
import { MobilePromptTrigger } from '@/components/mobile-prompt/MobilePromptTrigger'
import { Toaster } from '@/components/ui/toast'
import { OnboardingTour } from '@/components/onboarding/onboarding-tour'
import { KeyboardShortcutsModal } from '@/components/keyboard-shortcuts-modal'
import { CompactionNotifier } from '@/components/compaction-notifier'
import { FallbackBanner } from '@/components/fallback-banner'
import { GatewayRestartProvider } from '@/components/gateway-restart-overlay'
import { ExecApprovalToast } from '@/components/exec-approval-toast'
import { initializeSettingsAppearance } from '@/hooks/use-settings'
import { HermesOnboarding } from '@/components/onboarding/hermes-onboarding'
@@ -199,11 +193,6 @@ export const Route = createRootRoute({
const queryClient = new QueryClient()
function TaskReminderRunner() {
useTaskReminders()
return null
}
function RootLayout() {
// Unregister any existing service workers — they cause stale asset issues
// after Docker image updates and behind reverse proxies (Pangolin, Cloudflare, etc.)
@@ -229,22 +218,15 @@ function RootLayout() {
return (
<QueryClientProvider client={queryClient}>
<GatewayRestartProvider>
<HermesOnboarding />
<CompactionNotifier />
<FallbackBanner />
<GlobalShortcutListener />
<TerminalShortcutListener />
<TaskReminderRunner />
<UpdateNotifier />
<MobilePromptTrigger />
<Toaster />
<ExecApprovalToast />
<WorkspaceShell />
<SearchModal />
<OnboardingTour />
<KeyboardShortcutsModal />
</GatewayRestartProvider>
</QueryClientProvider>
)
}

View File

@@ -1,7 +0,0 @@
/**
* Diagnostics bundle API endpoint — stub
*/
// This file is a placeholder. The diagnostics functionality
// is handled by /api/debug/status endpoint instead.
export {}

View File

@@ -1,16 +0,0 @@
import { createFileRoute } from '@tanstack/react-router'
import { json } from '@tanstack/react-start'
import { isAuthenticated } from '../../server/auth-middleware'
export const Route = createFileRoute('/api/agent-activity')({
server: {
handlers: {
GET: async ({ request }) => {
if (!isAuthenticated(request)) {
return json({ ok: false, error: 'Unauthorized' }, { status: 401 })
}
return json({ events: [], ok: true })
},
},
},
})

View File

@@ -1,220 +0,0 @@
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 {
launchBrowser,
closeBrowser,
navigate,
typeText,
pressKey,
goBack,
goForward,
refresh,
scrollPage,
getScreenshot,
getPageContent,
cdpMouseClick,
} from '../../server/browser-session'
import {
startProxy,
stopProxy,
getProxyUrl,
getCurrentTarget,
} from '../../server/browser-proxy'
import { startBrowserStream } from '../../server/browser-stream'
export const Route = createFileRoute('/api/browser')({
server: {
handlers: {
GET: async ({ request }) => {
if (!isAuthenticated(request)) {
return json({ ok: false, error: 'Unauthorized' }, { status: 401 })
}
const url = new URL(request.url)
const action = url.searchParams.get('action') || 'status'
if (action === 'status' || action === 'proxy-status') {
try {
return json({
ok: true,
proxyUrl: getProxyUrl(),
target: getCurrentTarget(),
})
} catch (err) {
return json(
{
ok: false,
error: err instanceof Error ? err.message : String(err),
},
{ status: 500 },
)
}
}
return json(
{ error: `Unsupported GET action: ${action}` },
{ status: 400 },
)
},
POST: async ({ request }) => {
try {
if (!isAuthenticated(request)) {
return json({ ok: false, error: 'Unauthorized' }, { status: 401 })
}
const csrfCheck = requireJsonContentType(request)
if (csrfCheck) return csrfCheck
const body = (await request.json().catch(() => ({}))) as Record<
string,
unknown
>
const action =
typeof body.action === 'string' ? body.action.trim() : ''
switch (action) {
case 'launch': {
const state = await launchBrowser()
return json({ ok: true, ...state })
}
case 'close': {
await closeBrowser()
return json({ ok: true, running: false })
}
case 'navigate': {
const url = typeof body.url === 'string' ? body.url.trim() : ''
if (!url)
return json({ error: 'url is required' }, { status: 400 })
const state = await navigate(url)
return json({ ok: true, ...state })
}
case 'click': {
const xRaw = typeof body.x === 'number' ? body.x : 0
const yRaw = typeof body.y === 'number' ? body.y : 0
const x = Math.max(0, Math.min(10_000, Math.floor(xRaw)))
const y = Math.max(0, Math.min(10_000, Math.floor(yRaw)))
const state = await cdpMouseClick(x, y)
return json({ ok: true, ...state })
}
case 'type': {
const text =
typeof body.text === 'string' ? body.text.slice(0, 20_000) : ''
const submit = body.submit === true
const state = await typeText(text, submit)
return json({ ok: true, ...state })
}
case 'press': {
const key = typeof body.key === 'string' ? body.key : ''
if (!key)
return json({ error: 'key is required' }, { status: 400 })
const state = await pressKey(key)
return json({ ok: true, ...state })
}
case 'back': {
const state = await goBack()
return json({ ok: true, ...state })
}
case 'forward': {
const state = await goForward()
return json({ ok: true, ...state })
}
case 'refresh': {
const state = await refresh()
return json({ ok: true, ...state })
}
case 'scroll': {
const direction = body.direction === 'up' ? 'up' : 'down'
const amountRaw =
typeof body.amount === 'number' ? body.amount : 400
const amount = Math.max(10, Math.min(5000, Math.floor(amountRaw)))
const state = await scrollPage(direction, amount)
return json({ ok: true, ...state })
}
case 'screenshot': {
const state = await getScreenshot()
return json({ ok: true, ...state })
}
case 'content': {
const content = await getPageContent()
return json({ ok: true, ...content })
}
// Proxy mode — iframe-based browsing
case 'proxy-start': {
const result = await startProxy()
return json({ ok: true, ...result })
}
case 'proxy-stop': {
await stopProxy()
return json({ ok: true })
}
case 'proxy-navigate': {
const url = typeof body.url === 'string' ? body.url.trim() : ''
if (!url) return json({ error: 'url required' }, { status: 400 })
let normalizedUrl = url
if (!normalizedUrl.match(/^https?:\/\//))
normalizedUrl = `https://${normalizedUrl}`
// Navigate the proxy
const proxyUrl = getProxyUrl()
await fetch(
`${proxyUrl}/__proxy__/navigate?url=${encodeURIComponent(normalizedUrl)}`,
)
return json({
ok: true,
proxyUrl,
iframeSrc: `${proxyUrl}/?url=${encodeURIComponent(normalizedUrl)}`,
url: normalizedUrl,
})
}
case 'proxy-status': {
return json({
ok: true,
proxyUrl: getProxyUrl(),
target: getCurrentTarget(),
})
}
case 'stream-start': {
const result = await startBrowserStream()
return json({
ok: true,
wsUrl: `ws://localhost:${result.port}`,
...result,
})
}
default:
return json(
{ error: `Unknown action: ${action}` },
{ status: 400 },
)
}
} catch (err) {
return json(
{
ok: false,
error: err instanceof Error ? err.message : String(err),
},
{ status: 500 },
)
}
},
},
},
})

View File

@@ -1,82 +0,0 @@
import { createFileRoute } from '@tanstack/react-router'
import { json } from '@tanstack/react-start'
import { isAuthenticated } from '../../../server/auth-middleware'
import { gatewayRpc } from '../../../server/gateway'
import { requireJsonContentType } from '../../../server/rate-limit'
const BROWSER_NAVIGATE_METHODS = [
'browser', // Current Hermes API — single method with action param
'browser.navigate',
'browser_navigate',
'browser.go',
'browser_go',
]
function readErrorMessage(error: unknown): string {
if (error instanceof Error) return error.message || 'Browser navigate failed'
if (typeof error === 'string' && error.trim()) return error
return 'Browser navigate failed'
}
async function callBrowserNavigate(params: { url: string }): Promise<unknown> {
let lastError: unknown = null
for (const method of BROWSER_NAVIGATE_METHODS) {
try {
const rpcParams = method === 'browser'
? { action: 'navigate', ...params }
: params
return await gatewayRpc(method, rpcParams)
} catch (error) {
lastError = error
}
}
throw lastError instanceof Error
? lastError
: new Error('Gateway browser navigate request failed')
}
function normalizeUrl(input: string): string {
const trimmed = input.trim()
if (!trimmed) return ''
if (/^[a-zA-Z][a-zA-Z\d+\-.]*:\/\//.test(trimmed)) return trimmed
return `https://${trimmed}`
}
export const Route = createFileRoute('/api/browser/navigate')({
server: {
handlers: {
POST: async ({ request }) => {
if (!isAuthenticated(request)) {
return json({ ok: false, error: 'Unauthorized' }, { status: 401 })
}
const csrfCheck = requireJsonContentType(request)
if (csrfCheck) return csrfCheck
const body = (await request.json().catch(() => ({}))) as Record<
string,
unknown
>
const rawUrl = typeof body.url === 'string' ? body.url : ''
const url = normalizeUrl(rawUrl)
if (!url) {
return json({ ok: false, error: 'url is required' }, { status: 400 })
}
try {
const payload = await callBrowserNavigate({ url })
return json({ ok: true, url, payload })
} catch (error) {
return json(
{
ok: false,
error: readErrorMessage(error),
},
{ status: 500 },
)
}
},
},
},
})

View File

@@ -1,19 +0,0 @@
import { createFileRoute } from '@tanstack/react-router'
import { json } from '@tanstack/react-start'
import { isAuthenticated } from '../../server/auth-middleware'
import { requireJsonContentType } from '../../server/rate-limit'
export const Route = createFileRoute('/api/chat-abort')({
server: {
handlers: {
POST: async ({ request }) => {
if (!isAuthenticated(request)) {
return json({ ok: false, error: 'Unauthorized' }, { status: 401 })
}
const csrfCheck = requireJsonContentType(request)
if (csrfCheck) return csrfCheck
return json({ ok: true })
},
},
},
})

View File

@@ -1,43 +0,0 @@
import { createFileRoute } from '@tanstack/react-router'
import { json } from '@tanstack/react-start'
import { isAuthenticated } from '../../server/auth-middleware'
export const Route = createFileRoute('/api/cli-agents/$pid/kill')({
server: {
handlers: {
POST: async ({ request, params }) => {
if (!isAuthenticated(request)) {
return json({ ok: false, error: 'Unauthorized' }, { status: 401 })
}
const pid = Number(params.pid)
if (!Number.isFinite(pid) || !Number.isInteger(pid) || pid <= 0) {
return json({ ok: false, error: 'Invalid pid' }, { status: 400 })
}
try {
process.kill(pid, 'SIGTERM')
return json({ ok: true })
} catch (error) {
if (
typeof error === 'object' &&
error !== null &&
'code' in error &&
error.code === 'ESRCH'
) {
// Process already exited; treat as success.
return json({ ok: true })
}
return json(
{
ok: false,
error: error instanceof Error ? error.message : String(error),
},
{ status: 500 },
)
}
},
},
},
})

View File

@@ -1,321 +0,0 @@
import { execFile } from 'node:child_process'
import { promisify } from 'node:util'
import { createFileRoute } from '@tanstack/react-router'
import { json } from '@tanstack/react-start'
import { isAuthenticated } from '../../server/auth-middleware'
const execFileAsync = promisify(execFile)
const NAME_ADJECTIVES = [
'amber',
'brisk',
'calm',
'daring',
'ember',
'frost',
'golden',
'harbor',
'ivory',
'jade',
'lunar',
'misty',
'nova',
'quiet',
'raven',
'solar',
'swift',
'tidal',
'vivid',
'wild',
]
const NAME_NOUNS = [
'anchor',
'bloom',
'canyon',
'drift',
'engine',
'falcon',
'forge',
'glade',
'harbor',
'isle',
'journey',
'knoll',
'meadow',
'nexus',
'orbit',
'peak',
'ridge',
'signal',
'summit',
'trail',
]
type CliAgentStatus = 'running' | 'finished'
type AgentProcess = {
pid: number
stat: string
command: string
}
type CliAgent = {
pid: number
name: string
task: string
runtimeSeconds: number
status: CliAgentStatus
}
function parsePsAuxOutput(output: string): Array<AgentProcess> {
const lines = output.split('\n')
const entries: Array<AgentProcess> = []
for (const line of lines.slice(1)) {
const trimmed = line.trim()
if (!trimmed) continue
const columns = trimmed.split(/\s+/)
if (columns.length < 11) continue
const pid = Number.parseInt(columns[1] ?? '', 10)
if (!Number.isFinite(pid)) continue
const stat = columns[7] ?? ''
const command = columns.slice(10).join(' ')
if (!command) continue
if (!command.toLowerCase().includes('codex')) continue
entries.push({ pid, stat, command })
}
return entries
}
function parseElapsedToSeconds(elapsed: string): number {
const value = elapsed.trim()
if (!value) return 0
let daySeconds = 0
let clock = value
if (value.includes('-')) {
const [daysPart, timePart] = value.split('-', 2)
const days = Number.parseInt(daysPart, 10)
if (Number.isFinite(days) && days > 0) {
daySeconds = days * 24 * 60 * 60
}
clock = timePart
}
const segments = clock.split(':').map(function parseSegment(segment) {
return Number.parseInt(segment, 10)
})
if (
segments.some(function hasInvalid(valuePart) {
return !Number.isFinite(valuePart)
})
) {
return daySeconds
}
if (segments.length === 3) {
const [hours, minutes, seconds] = segments
return daySeconds + hours * 60 * 60 + minutes * 60 + seconds
}
if (segments.length === 2) {
const [minutes, seconds] = segments
return daySeconds + minutes * 60 + seconds
}
if (segments.length === 1) {
return daySeconds + segments[0]
}
return daySeconds
}
async function readRuntimeByPid(
pids: Array<number>,
): Promise<Map<number, number>> {
const runtimeByPid = new Map<number, number>()
if (!pids.length) return runtimeByPid
try {
const { stdout } = await execFileAsync('ps', [
'-o',
'pid=,etime=',
'-p',
pids.join(','),
])
for (const line of stdout.split('\n')) {
const trimmed = line.trim()
if (!trimmed) continue
const match = trimmed.match(/^(\d+)\s+(\S+)$/)
if (!match) continue
const pid = Number.parseInt(match[1], 10)
const runtimeSeconds = parseElapsedToSeconds(match[2])
if (Number.isFinite(pid) && Number.isFinite(runtimeSeconds)) {
runtimeByPid.set(pid, runtimeSeconds)
}
}
} catch {
return runtimeByPid
}
return runtimeByPid
}
function stripQuotes(value: string): string {
const trimmed = value.trim()
const hasDoubleQuotes = trimmed.startsWith('"') && trimmed.endsWith('"')
const hasSingleQuotes = trimmed.startsWith("'") && trimmed.endsWith("'")
if (hasDoubleQuotes || hasSingleQuotes) {
return trimmed.slice(1, -1)
}
return trimmed
}
function normalizeTask(value: string): string {
const normalized = value.trim().replace(/\s+/g, ' ')
return normalized || 'No task description'
}
function tokenizeCommand(command: string): Array<string> {
const tokens = command.match(/"[^"]*"|'[^']*'|\S+/g)
return tokens ?? []
}
function extractTaskFromCommand(command: string): string {
const tokens = tokenizeCommand(command)
if (!tokens.length) return 'No task description'
const codexIndex = tokens.findIndex(function isCodexToken(token) {
return stripQuotes(token).toLowerCase().includes('codex')
})
const args = codexIndex >= 0 ? tokens.slice(codexIndex + 1) : tokens
for (let i = 0; i < args.length; i += 1) {
const current = stripQuotes(args[i] ?? '')
if (!current) continue
if (current.startsWith('--task=')) {
return normalizeTask(current.slice('--task='.length))
}
if (current.startsWith('--prompt=')) {
return normalizeTask(current.slice('--prompt='.length))
}
if (
current === '--task' ||
current === '--prompt' ||
current === '-t' ||
current === '-p'
) {
const next = stripQuotes(args[i + 1] ?? '')
if (next) return normalizeTask(next)
}
}
const meaningfulParts: Array<string> = []
for (const arg of args) {
const part = stripQuotes(arg)
if (!part) continue
if (part.startsWith('-')) continue
meaningfulParts.push(part)
}
if (!meaningfulParts.length) {
return 'No task description'
}
return normalizeTask(meaningfulParts.join(' '))
}
function hashPid(pid: number): number {
let value = pid | 0
value = Math.imul(value ^ 0x45d9f3b, 0x45d9f3b)
value ^= value >>> 16
return value >>> 0
}
function createAgentName(pid: number): string {
const hash = hashPid(pid)
const adjective = NAME_ADJECTIVES[hash % NAME_ADJECTIVES.length]
const nounIndex =
Math.floor(hash / NAME_ADJECTIVES.length) % NAME_NOUNS.length
const noun = NAME_NOUNS[nounIndex]
return `${adjective}-${noun}`
}
function resolveStatus(stat: string): CliAgentStatus {
if (stat.includes('Z') || stat.includes('T') || stat.includes('X')) {
return 'finished'
}
return 'running'
}
function toCliAgent(
processEntry: AgentProcess,
runtimeByPid: Map<number, number>,
): CliAgent {
return {
pid: processEntry.pid,
name: createAgentName(processEntry.pid),
task: extractTaskFromCommand(processEntry.command),
runtimeSeconds: runtimeByPid.get(processEntry.pid) ?? 0,
status: resolveStatus(processEntry.stat),
}
}
export const Route = createFileRoute('/api/cli-agents')({
server: {
handlers: {
GET: async function getCliAgents({ request }: { request: Request }) {
if (!isAuthenticated(request)) {
return json({ ok: false, error: 'Unauthorized' }, { status: 401 })
}
try {
const { stdout } = await execFileAsync('ps', ['aux'])
const processes = parsePsAuxOutput(stdout)
const runtimeByPid = await readRuntimeByPid(
processes.map(function getPid(entry) {
return entry.pid
}),
)
const agents = processes
.map(function mapToAgent(entry) {
return toCliAgent(entry, runtimeByPid)
})
.sort(function sortAgents(a, b) {
if (a.runtimeSeconds !== b.runtimeSeconds) {
return b.runtimeSeconds - a.runtimeSeconds
}
return a.pid - b.pid
})
return json({ agents })
} catch (error) {
return json(
{
agents: [],
error: error instanceof Error ? error.message : String(error),
},
{ status: 500 },
)
}
},
},
},
})

View File

@@ -1,45 +0,0 @@
import { createFileRoute } from '@tanstack/react-router'
import { json } from '@tanstack/react-start'
import { z } from 'zod'
import { provisionCloudInstance } from '@/lib/cloud-store'
import type { CloudProvisionRequest } from '@/lib/cloud-types'
import { isAuthenticated } from '@/server/auth-middleware'
import { requireJsonContentType } from '@/server/rate-limit'
const ProvisionSchema = z.object({
email: z.string().email(),
plan: z.enum(['free', 'pro', 'team']),
polarSubscriptionId: z.string().min(1).optional(),
}) satisfies z.ZodType<CloudProvisionRequest>
export const Route = createFileRoute('/api/cloud/provision')({
server: {
handlers: {
POST: async ({ request }) => {
if (!isAuthenticated(request)) {
return json({ ok: false, error: 'Unauthorized' }, { status: 401 })
}
const csrfCheck = requireJsonContentType(request)
if (csrfCheck) return csrfCheck
const raw = await request.json().catch(() => ({}))
const parsed = ProvisionSchema.safeParse(raw)
if (!parsed.success) {
return json({ ok: false, error: 'Invalid provision request' }, { status: 400 })
}
const instance = await provisionCloudInstance(parsed.data)
return json({
gatewayUrl: instance.gatewayUrl,
token: instance.token,
plan: instance.plan,
expiresAt: instance.expiresAt,
})
},
},
},
})

View File

@@ -1,40 +0,0 @@
import { createFileRoute } from '@tanstack/react-router'
import { json } from '@tanstack/react-start'
import { getCloudInstanceByEmail } from '@/lib/cloud-store'
import { isAuthenticated } from '@/server/auth-middleware'
export const Route = createFileRoute('/api/cloud/status')({
server: {
handlers: {
GET: async ({ request }) => {
if (!isAuthenticated(request)) {
return json({ ok: false, error: 'Unauthorized' }, { status: 401 })
}
const url = new URL(request.url)
const email = url.searchParams.get('email')?.trim()
if (!email) {
return json({ ok: false, error: 'Missing email query parameter' }, { status: 400 })
}
const instance = getCloudInstanceByEmail(email)
if (!instance) {
return json({ ok: false, error: 'Cloud instance not found' }, { status: 404 })
}
return json({
id: instance.id,
email: instance.email,
plan: instance.plan,
status: instance.status,
gatewayUrl: instance.gatewayUrl,
createdAt: instance.createdAt,
expiresAt: instance.expiresAt,
polarSubscriptionId: instance.polarSubscriptionId,
})
},
},
},
})

View File

@@ -1,177 +0,0 @@
import { createHmac, timingSafeEqual } from 'node:crypto'
import { createFileRoute } from '@tanstack/react-router'
import { json } from '@tanstack/react-start'
import {
provisionCloudInstance,
suspendCloudInstance,
updateCloudInstance,
} from '@/lib/cloud-store'
import type { CloudPlan, PolarWebhookEvent } from '@/lib/cloud-types'
import { requireJsonContentType } from '@/server/rate-limit'
const POLAR_PRODUCT_PLAN_MAP: Record<string, CloudPlan> = {
'bd502a21-b846-40a0-8763-b301849b1df5': 'free',
'0cf1fed8-898c-4062-beeb-e38f0cd5bb21': 'pro', // $20/mo
'3e482285-2d66-438e-bca2-fd99bc15cc20': 'pro', // $200/yr
'fb2836ac-2f70-4b2c-9ad6-850b26ffa799': 'team', // $50/mo
'ba38b3ed-5d85-4268-873f-8293402ab697': 'team', // $500/yr
}
function normalizePlan(plan: unknown): CloudPlan {
return plan === 'team' || plan === 'free' ? plan : 'pro'
}
function planFromProductId(productId: unknown): CloudPlan | undefined {
if (typeof productId === 'string') return POLAR_PRODUCT_PLAN_MAP[productId]
return undefined
}
function timingSafeMatch(left: string, right: string): boolean {
const leftBuffer = Buffer.from(left)
const rightBuffer = Buffer.from(right)
if (leftBuffer.length !== rightBuffer.length) {
return false
}
return timingSafeEqual(leftBuffer, rightBuffer)
}
function isValidPolarSignature(rawBody: string, signature: string, secret: string): boolean {
const expectedHex = createHmac('sha256', secret).update(rawBody).digest('hex')
const expectedBase64 = createHmac('sha256', secret).update(rawBody).digest('base64')
const normalized = signature.trim()
const candidates = normalized
.split(',')
.map((value) => value.trim())
.flatMap((value) => {
if (value.startsWith('sha256=')) return [value.slice('sha256='.length)]
if (value.startsWith('v1=')) return [value.slice('v1='.length)]
return [value]
})
return candidates.some(
(candidate) =>
timingSafeMatch(candidate, expectedHex) ||
timingSafeMatch(candidate, expectedBase64),
)
}
function extractEmail(event: PolarWebhookEvent): string | undefined {
return (
event.data?.subscription?.customer?.email ??
event.data?.customer?.email ??
event.data?.email
)
}
function extractSubscriptionId(event: PolarWebhookEvent): string | undefined {
return event.data?.subscription?.id ?? event.data?.id
}
function extractPlan(event: PolarWebhookEvent): CloudPlan {
// First try to resolve from product ID (most reliable)
const productId =
event.data?.subscription?.productId ??
event.data?.subscription?.product_id ??
event.data?.productId ??
event.data?.product_id
const planFromProduct = planFromProductId(productId)
if (planFromProduct) return planFromProduct
// Fallback to metadata
const metadataPlan =
event.data?.subscription?.metadata?.plan ??
event.data?.metadata?.plan ??
event.data?.plan
return normalizePlan(metadataPlan)
}
function extractExpiry(event: PolarWebhookEvent): string | undefined {
return (
event.data?.subscription?.currentPeriodEnd ??
event.data?.subscription?.current_period_end ??
event.data?.currentPeriodEnd ??
event.data?.current_period_end
)
}
export const Route = createFileRoute('/api/cloud/webhook')({
server: {
handlers: {
POST: async ({ request }) => {
const csrfCheck = requireJsonContentType(request)
if (csrfCheck) return csrfCheck
const signature = request.headers.get('X-Polar-Signature')
const secret = process.env.POLAR_WEBHOOK_SECRET
if (!signature || !secret) {
return json({ ok: false, error: 'Webhook secret or signature missing' }, { status: 403 })
}
const rawBody = await request.text()
if (!isValidPolarSignature(rawBody, signature, secret)) {
return json({ ok: false, error: 'Invalid webhook signature' }, { status: 403 })
}
let event: PolarWebhookEvent
try {
event = JSON.parse(rawBody) as PolarWebhookEvent
} catch {
return json({ ok: false, error: 'Invalid webhook payload' }, { status: 400 })
}
const email = extractEmail(event)
const polarSubscriptionId = extractSubscriptionId(event)
const plan = extractPlan(event)
const expiresAt = extractExpiry(event)
if (event.type === 'subscription.created') {
if (!email) {
return json({ ok: false, error: 'Missing customer email' }, { status: 400 })
}
await provisionCloudInstance({
email,
plan,
polarSubscriptionId,
expiresAt,
})
}
if (event.type === 'subscription.canceled') {
const suspended = suspendCloudInstance({ email, polarSubscriptionId })
if (!suspended) {
return json({ ok: false, error: 'Cloud instance not found' }, { status: 404 })
}
}
if (event.type === 'subscription.updated' && email) {
const existing =
updateCloudInstance(email, {
plan,
expiresAt,
polarSubscriptionId,
status: event.data?.subscription?.status === 'canceled' ? 'suspended' : 'active',
}) ??
await provisionCloudInstance({
email,
plan,
polarSubscriptionId,
expiresAt,
})
if (!existing) {
return json({ ok: false, error: 'Unable to update cloud instance' }, { status: 500 })
}
}
return json({ ok: true })
},
},
},
})

View File

@@ -1,34 +0,0 @@
import { createFileRoute } from '@tanstack/react-router'
import { json } from '@tanstack/react-start'
import { isAuthenticated } from '../../server/auth-middleware'
const HERMES_API_URL = process.env.HERMES_API_URL || 'http://127.0.0.1:8642'
export const Route = createFileRoute('/api/config-get')({
server: {
handlers: {
GET: async ({ request }) => {
if (!isAuthenticated(request)) {
return json({ ok: false, error: 'Unauthorized' }, { status: 401 })
}
try {
const response = await fetch(`${HERMES_API_URL}/api/config`)
if (!response.ok) {
const body = await response.text().catch(() => '')
throw new Error(body || `Hermes config request failed (${response.status})`)
}
const result = (await response.json()) as { defaultModel?: string }
return json({ ok: true, payload: result })
} catch (err) {
return json(
{
ok: false,
error: err instanceof Error ? err.message : String(err),
},
{ status: 500 },
)
}
},
},
},
})

View File

@@ -1,22 +0,0 @@
import { createFileRoute } from '@tanstack/react-router'
import { json } from '@tanstack/react-start'
import { isAuthenticated } from '../../server/auth-middleware'
import { requireJsonContentType } from '../../server/rate-limit'
export const Route = createFileRoute('/api/config-patch')({
server: {
handlers: {
POST: async ({ request }) => {
if (!isAuthenticated(request)) {
return json({ ok: false, error: 'Unauthorized' }, { status: 401 })
}
const csrfCheck = requireJsonContentType(request)
if (csrfCheck) return csrfCheck
return json(
{ ok: false, error: 'Config updates are not available in Hermes Workspace.' },
{ status: 501 },
)
},
},
},
})

View File

@@ -1,22 +0,0 @@
import { createFileRoute } from '@tanstack/react-router'
import { json } from '@tanstack/react-start'
import { isAuthenticated } from '@/server/auth-middleware'
export const Route = createFileRoute('/api/cost')({
server: {
handlers: {
GET: async ({ request }) => {
if (!isAuthenticated(request)) {
return json({ ok: false, error: 'Unauthorized' }, { status: 401 })
}
return json({
ok: true,
cost: {
timeseries: [],
total: 0,
},
})
},
},
},
})

View File

@@ -1,33 +0,0 @@
import { createFileRoute } from '@tanstack/react-router'
import { json } from '@tanstack/react-start'
import { isAuthenticated } from '@/server/auth-middleware'
import { gatewayCronRpc, normalizeCronJobs } from '@/server/cron'
export const Route = createFileRoute('/api/cron')({
server: {
handlers: {
GET: async ({ request }) => {
if (!isAuthenticated(request)) {
return json({ ok: false, error: 'Unauthorized' }, { status: 401 })
}
try {
const payload = await gatewayCronRpc(
['cron.list', 'cron.jobs.list', 'scheduler.jobs.list'],
{ includeDisabled: true },
)
return json({
jobs: normalizeCronJobs(payload),
})
} catch (err) {
return json(
{
error: err instanceof Error ? err.message : String(err),
},
{ status: 500 },
)
}
},
},
},
})

View File

@@ -1,45 +0,0 @@
import { createFileRoute } from '@tanstack/react-router'
import { json } from '@tanstack/react-start'
import { isAuthenticated } from '../../../server/auth-middleware'
import { gatewayCronRpc } from '@/server/cron'
import { requireJsonContentType } from '../../../server/rate-limit'
export const Route = createFileRoute('/api/cron/delete')({
server: {
handlers: {
POST: async ({ request }) => {
try {
if (!isAuthenticated(request)) {
return json({ ok: false, error: 'Unauthorized' }, { status: 401 })
}
const csrfCheck = requireJsonContentType(request)
if (csrfCheck) return csrfCheck
const body = (await request.json().catch(() => ({}))) as Record<
string,
unknown
>
const jobId = typeof body.jobId === 'string' ? body.jobId.trim() : ''
if (!jobId) {
return json({ error: 'jobId is required' }, { status: 400 })
}
const payload = await gatewayCronRpc(
['cron.remove'],
{ jobId },
)
return json({ ok: true, payload })
} catch (err) {
return json(
{
ok: false,
error: err instanceof Error ? err.message : String(err),
},
{ status: 500 },
)
}
},
},
},
})

View File

@@ -1,33 +0,0 @@
import { createFileRoute } from '@tanstack/react-router'
import { json } from '@tanstack/react-start'
import { isAuthenticated } from '../../../server/auth-middleware'
import { gatewayCronRpc, normalizeCronJobs } from '@/server/cron'
export const Route = createFileRoute('/api/cron/list')({
server: {
handlers: {
GET: async ({ request }) => {
if (!isAuthenticated(request)) {
return json({ ok: false, error: 'Unauthorized' }, { status: 401 })
}
try {
const payload = await gatewayCronRpc(
['cron.list', 'cron.jobs.list', 'scheduler.jobs.list'],
{ includeDisabled: true },
)
return json({
jobs: normalizeCronJobs(payload),
})
} catch (err) {
return json(
{
error: err instanceof Error ? err.message : String(err),
},
{ status: 500 },
)
}
},
},
},
})

View File

@@ -1,51 +0,0 @@
import { createFileRoute } from '@tanstack/react-router'
import { json } from '@tanstack/react-start'
import { isAuthenticated } from '../../../server/auth-middleware'
import { gatewayCronRpc } from '@/server/cron'
import { requireJsonContentType } from '../../../server/rate-limit'
export const Route = createFileRoute('/api/cron/run-if-due')({
server: {
handlers: {
POST: async ({ request }) => {
try {
if (!isAuthenticated(request)) {
return json({ ok: false, error: 'Unauthorized' }, { status: 401 })
}
const csrfCheck = requireJsonContentType(request)
if (csrfCheck) return csrfCheck
const body = (await request.json().catch(() => ({}))) as Record<
string,
unknown
>
const jobId = typeof body.jobId === 'string' ? body.jobId.trim() : ''
if (!jobId) {
return json({ error: 'jobId is required' }, { status: 400 })
}
const payload = await gatewayCronRpc(
[
'cron.runIfDue',
'cron.run_if_due',
'scheduler.runIfDue',
'scheduler.jobs.runIfDue',
],
{ jobId },
)
return json({ ok: true, payload })
} catch (err) {
return json(
{
ok: false,
error: err instanceof Error ? err.message : String(err),
},
{ status: 500 },
)
}
},
},
},
})

View File

@@ -1,48 +0,0 @@
import { createFileRoute } from '@tanstack/react-router'
import { json } from '@tanstack/react-start'
import { isAuthenticated } from '../../../server/auth-middleware'
import { gatewayCronRpc } from '@/server/cron'
import { requireJsonContentType } from '../../../server/rate-limit'
export const Route = createFileRoute('/api/cron/run')({
server: {
handlers: {
POST: async ({ request }) => {
try {
if (!isAuthenticated(request)) {
return json({ ok: false, error: 'Unauthorized' }, { status: 401 })
}
const csrfCheck = requireJsonContentType(request)
if (csrfCheck) return csrfCheck
const body = (await request.json().catch(() => ({}))) as Record<
string,
unknown
>
const jobId = typeof body.jobId === 'string' ? body.jobId.trim() : ''
if (!jobId) {
return json({ error: 'jobId is required' }, { status: 400 })
}
const payload = await gatewayCronRpc(
['cron.run'],
{
jobId,
},
)
return json({ ok: true, payload })
} catch (err) {
return json(
{
ok: false,
error: err instanceof Error ? err.message : String(err),
},
{ status: 500 },
)
}
},
},
},
})

View File

@@ -1,51 +0,0 @@
import { createFileRoute } from '@tanstack/react-router'
import { json } from '@tanstack/react-start'
import { isAuthenticated } from '@/server/auth-middleware'
import { gatewayCronRpc, normalizeCronRuns } from '@/server/cron'
export const Route = createFileRoute('/api/cron/runs/$jobId')({
server: {
handlers: {
GET: async ({ request }) => {
if (!isAuthenticated(request)) {
return json({ ok: false, error: 'Unauthorized' }, { status: 401 })
}
try {
const url = new URL(request.url)
const limitRaw = Number(url.searchParams.get('limit') ?? '20')
const limit = Number.isFinite(limitRaw)
? Math.max(1, Math.min(100, Math.round(limitRaw)))
: 20
const segments = url.pathname.split('/')
const maybeJobId = decodeURIComponent(
segments[segments.length - 1] ?? '',
)
const jobId = maybeJobId.trim()
if (!jobId) {
return json({ error: 'jobId is required' }, { status: 400 })
}
const payload = await gatewayCronRpc(
['cron.runs', 'cron.jobs.runs', 'scheduler.runs'],
{
jobId,
limit,
},
)
return json({
runs: normalizeCronRuns(payload).slice(0, limit),
})
} catch (err) {
return json(
{
error: err instanceof Error ? err.message : String(err),
},
{ status: 500 },
)
}
},
},
},
})

View File

@@ -1,51 +0,0 @@
import { createFileRoute } from '@tanstack/react-router'
import { json } from '@tanstack/react-start'
import { isAuthenticated } from '../../../server/auth-middleware'
import { gatewayCronRpc, normalizeCronBool } from '@/server/cron'
import { requireJsonContentType } from '../../../server/rate-limit'
export const Route = createFileRoute('/api/cron/toggle')({
server: {
handlers: {
POST: async ({ request }) => {
try {
if (!isAuthenticated(request)) {
return json({ ok: false, error: 'Unauthorized' }, { status: 401 })
}
const csrfCheck = requireJsonContentType(request)
if (csrfCheck) return csrfCheck
const body = (await request.json().catch(() => ({}))) as Record<
string,
unknown
>
const jobId = typeof body.jobId === 'string' ? body.jobId.trim() : ''
if (!jobId) {
return json({ error: 'jobId is required' }, { status: 400 })
}
const enabled = normalizeCronBool(body.enabled, true)
const payload = await gatewayCronRpc(
['cron.update'],
{
jobId,
patch: { enabled },
},
)
return json({ ok: true, payload, enabled })
} catch (err) {
return json(
{
ok: false,
error: err instanceof Error ? err.message : String(err),
},
{ status: 500 },
)
}
},
},
},
})

View File

@@ -1,164 +0,0 @@
import { createFileRoute } from '@tanstack/react-router'
import { json } from '@tanstack/react-start'
import { isAuthenticated } from '../../../server/auth-middleware'
import { gatewayCronRpc, normalizeCronBool } from '@/server/cron'
import { requireJsonContentType } from '../../../server/rate-limit'
function readString(value: unknown): string {
if (typeof value !== 'string') return ''
return value.trim()
}
function asRecord(value: unknown): Record<string, unknown> {
if (!value || typeof value !== 'object' || Array.isArray(value)) {
return {}
}
return value as Record<string, unknown>
}
function resolvePayloadJobId(payload: unknown): string | undefined {
const row = asRecord(payload)
const candidates = [row.jobId, row.id, row.key]
for (const candidate of candidates) {
if (typeof candidate === 'string' && candidate.trim().length > 0) {
return candidate.trim()
}
}
return undefined
}
function buildSchedulePayload(schedule: string) {
const trimmed = schedule.trim()
const everyMatch = trimmed.match(
/^every\s+(\d+)\s*(m|min|minute|minutes|h|hr|hour|hours|d|day|days)?$/i,
)
if (everyMatch?.[1]) {
const unitToken = (everyMatch[2] ?? 'm').toLowerCase()
const unit =
unitToken.startsWith('h')
? 'hours'
: unitToken.startsWith('d')
? 'days'
: 'minutes'
return {
kind: 'every',
interval: Number(everyMatch[1]),
unit,
}
}
if (trimmed.startsWith('at ')) {
return {
kind: 'at',
at: trimmed.slice(3).trim(),
}
}
if (/^\d{4}-\d{2}-\d{2}/.test(trimmed)) {
return {
kind: 'at',
at: trimmed,
}
}
return {
kind: 'cron',
expr: trimmed,
}
}
function buildUpsertParams(
body: Record<string, unknown>,
jobId: string,
enabled: boolean,
) {
const name = readString(body.name)
const schedule = readString(body.schedule)
const payload = body.payload
const deliveryConfig = body.deliveryConfig
if (!jobId) {
// cron.add format
return {
name,
schedule: buildSchedulePayload(schedule),
payload: payload || { kind: 'systemEvent', text: name },
delivery: deliveryConfig || undefined,
sessionTarget: 'main',
enabled,
}
}
// cron.update format
return {
jobId,
patch: {
name,
schedule: buildSchedulePayload(schedule),
payload: payload || undefined,
delivery: deliveryConfig || undefined,
enabled,
},
}
}
export const Route = createFileRoute('/api/cron/upsert')({
server: {
handlers: {
POST: async ({ request }) => {
try {
if (!isAuthenticated(request)) {
return json({ ok: false, error: 'Unauthorized' }, { status: 401 })
}
const csrfCheck = requireJsonContentType(request)
if (csrfCheck) return csrfCheck
const body = (await request.json().catch(() => ({}))) as Record<
string,
unknown
>
const jobId = readString(body.jobId || body.id || body.key)
const name = readString(body.name)
const schedule = readString(
body.schedule || body.cron || body.expression,
)
if (!name) {
return json({ error: 'name is required' }, { status: 400 })
}
if (!schedule) {
return json({ error: 'schedule is required' }, { status: 400 })
}
const enabled = normalizeCronBool(body.enabled, true)
const methods = jobId
? ['cron.update']
: ['cron.add']
const payload = await gatewayCronRpc(
methods,
buildUpsertParams(body, jobId, enabled),
)
const resolvedJobId =
resolvePayloadJobId(payload) ?? (jobId || undefined)
return json({
ok: true,
payload,
jobId: resolvedJobId,
})
} catch (err) {
return json(
{
ok: false,
error: err instanceof Error ? err.message : String(err),
},
{ status: 500 },
)
}
},
},
},
})

View File

@@ -1,55 +0,0 @@
import { createFileRoute } from '@tanstack/react-router'
import { json } from '@tanstack/react-start'
import { isAuthenticated } from '../../server/auth-middleware'
import { analyzeError, readGatewayLogs } from '../../server/debug-analyzer'
import {
getClientIp,
rateLimit,
rateLimitResponse,
requireJsonContentType,
safeErrorMessage,
} from '../../server/rate-limit'
export const Route = createFileRoute('/api/debug-analyze')({
server: {
handlers: {
POST: async ({ request }) => {
if (!isAuthenticated(request)) {
return json({ ok: false, error: 'Unauthorized' }, { status: 401 })
}
const csrfCheck = requireJsonContentType(request)
if (csrfCheck) return csrfCheck
const ip = getClientIp(request)
if (!rateLimit(`debug:${ip}`, 10, 60_000)) {
return rateLimitResponse()
}
try {
const body = (await request.json().catch(() => ({}))) as Record<
string,
unknown
>
const terminalOutput =
typeof body.terminalOutput === 'string' ? body.terminalOutput : ''
const logContent = await readGatewayLogs()
const analysis = await analyzeError(terminalOutput, logContent)
return json(analysis)
} catch (error) {
if (import.meta.env.DEV) console.error(
'[/api/debug-analyze] Error:',
error instanceof Error ? error.message : String(error),
)
return json(
{
summary: 'Debug analysis request failed.',
rootCause: safeErrorMessage(error),
suggestedCommands: [],
},
{ status: 500 },
)
}
},
},
},
})

View File

@@ -1,45 +0,0 @@
import { createFileRoute } from '@tanstack/react-router'
import { json } from '@tanstack/react-start'
import {
getActivityStreamDiagnostics,
reconnectActivityStream,
sanitizeText,
} from '../../../server/activity-stream'
import { isAuthenticated } from '../../../server/auth-middleware'
import { requireJsonContentType } from '../../../server/rate-limit'
function toErrorMessage(error: unknown): string {
if (error instanceof Error) return sanitizeText(error.message)
return sanitizeText(String(error))
}
export const Route = createFileRoute('/api/debug/reconnect')({
server: {
handlers: {
POST: async ({ request }) => {
if (!isAuthenticated(request)) {
return json({ error: 'Unauthorized' }, { status: 401 })
}
const csrfCheck = requireJsonContentType(request)
if (csrfCheck) return csrfCheck
try {
await reconnectActivityStream()
return json({
ok: true,
state: getActivityStreamDiagnostics().status,
})
} catch (error) {
return json(
{
ok: false,
state: getActivityStreamDiagnostics().status,
error: toErrorMessage(error),
},
{ status: 503 },
)
}
},
},
},
})

View File

@@ -1,63 +0,0 @@
import { createFileRoute } from '@tanstack/react-router'
import { json } from '@tanstack/react-start'
import {
ensureActivityStreamStarted,
getActivityStreamDiagnostics,
sanitizeText,
} from '../../../server/activity-stream'
import { isAuthenticated } from '../../../server/auth-middleware'
const DEFAULT_GATEWAY_URL = 'ws://127.0.0.1:18789'
function readGatewayUrl(): string {
return process.env.CLAWDBOT_GATEWAY_URL?.trim() || DEFAULT_GATEWAY_URL
}
function stripAuthorityAuth(value: string): string {
if (!value.includes('@')) return value
const parts = value.split('@')
const lastPart = parts[parts.length - 1]
return lastPart || value
}
function maskGatewayUrl(rawUrl: string): string {
const sanitizedUrl = sanitizeText(rawUrl)
try {
const parsed = new URL(sanitizedUrl)
const safeHost = stripAuthorityAuth(parsed.host)
return `${parsed.protocol}//${safeHost}`
} catch {
const hostMatch = sanitizedUrl.match(/^([a-z]+:\/\/)?([^/]+)/i)
if (!hostMatch) return 'Unavailable'
const protocol = hostMatch[1] || ''
const authority = hostMatch[2] || ''
const safeAuthority = stripAuthorityAuth(authority)
return `${protocol}${safeAuthority}` || 'Unavailable'
}
}
export const Route = createFileRoute('/api/debug/status')({
server: {
handlers: {
GET: async ({ request }) => {
if (!isAuthenticated(request)) {
return json({ error: 'Unauthorized' }, { status: 401 })
}
void ensureActivityStreamStarted().catch(function ignoreStartError() {
// endpoint still returns diagnostics while disconnected
})
const diagnostics = getActivityStreamDiagnostics()
return json({
state: diagnostics.status,
gatewayUrl: maskGatewayUrl(readGatewayUrl()),
connectedSinceMs: diagnostics.connectedSinceMs,
lastDisconnectedAtMs: diagnostics.lastDisconnectedAtMs,
nowMs: Date.now(),
})
},
},
},
})

View File

@@ -1,94 +0,0 @@
import { createFileRoute } from '@tanstack/react-router'
import {
ensureActivityStreamStarted,
getActivityStreamStatus,
} from '../../server/activity-stream'
import { offEvent, onEvent } from '../../server/activity-events'
import type { ActivityEvent } from '../../types/activity-event'
import { isAuthenticated } from '@/server/auth-middleware'
const HEARTBEAT_INTERVAL_MS = 20_000
export const Route = createFileRoute('/api/events')({
server: {
handlers: {
GET: async ({ request }) => {
if (!isAuthenticated(request)) {
return new Response(JSON.stringify({ ok: false, error: 'Unauthorized' }), {
status: 401,
headers: { 'Content-Type': 'application/json' },
})
}
void ensureActivityStreamStarted().catch(function ignoreStartError() {
// stream stays available even when gateway is offline
})
const encoder = new TextEncoder()
let cleanupStream = function noCleanupYet() {}
const stream = new ReadableStream({
start(controller) {
let closed = false
let heartbeatTimer: ReturnType<typeof setInterval> | null = null
function sendEvent(eventName: string, payload: unknown) {
if (closed) return
const chunk = `event: ${eventName}\ndata: ${JSON.stringify(payload)}\n\n`
controller.enqueue(encoder.encode(chunk))
}
const listener = function onActivityEvent(event: ActivityEvent) {
sendEvent('activity', event)
}
function cleanup() {
if (closed) return
closed = true
if (heartbeatTimer) {
clearInterval(heartbeatTimer)
heartbeatTimer = null
}
offEvent(listener)
request.signal.removeEventListener('abort', onAbort)
try {
controller.close()
} catch {
// stream already closed
}
}
function onAbort() {
cleanup()
}
onEvent(listener)
sendEvent('ready', {
connected: getActivityStreamStatus() === 'connected',
})
heartbeatTimer = setInterval(function sendHeartbeat() {
if (closed) return
controller.enqueue(encoder.encode(': keep-alive\n\n'))
}, HEARTBEAT_INTERVAL_MS)
request.signal.addEventListener('abort', onAbort, { once: true })
cleanupStream = cleanup
},
cancel() {
cleanupStream()
},
})
return new Response(stream, {
headers: {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache',
Connection: 'keep-alive',
'X-Accel-Buffering': 'no',
},
})
},
},
},
})

View File

@@ -1,42 +0,0 @@
import { createFileRoute } from '@tanstack/react-router'
import { json } from '@tanstack/react-start'
import { isAuthenticated } from '../../../server/auth-middleware'
import { getRecentEvents } from '../../../server/activity-events'
import {
ensureActivityStreamStarted,
getActivityStreamStatus,
} from '../../../server/activity-stream'
const DEFAULT_RECENT_COUNT = 50
const MAX_RECENT_COUNT = 100
export const Route = createFileRoute('/api/events/recent')({
server: {
handlers: {
GET: async ({ request }) => {
if (!isAuthenticated(request)) {
return json({ ok: false, error: 'Unauthorized' }, { status: 401 })
}
void ensureActivityStreamStarted().catch(function ignoreStartError() {
// recent endpoint still returns buffered data while disconnected
})
const url = new URL(request.url)
const countParam = Number.parseInt(
url.searchParams.get('count') ?? '',
10,
)
const count = Number.isFinite(countParam)
? Math.max(1, Math.min(MAX_RECENT_COUNT, countParam))
: DEFAULT_RECENT_COUNT
return json({
events: getRecentEvents(count),
connected: getActivityStreamStatus() === 'connected',
})
},
},
},
})

View File

@@ -21,7 +21,7 @@ const execFileAsync = promisify(execFile)
const WORKSPACE_ROOT = (
process.env.HERMES_WORKSPACE_DIR ||
process.env.OPENCLAW_WORKSPACE_DIR ||
process.env.HERMES_WORKSPACE_DIR || process.env.OPENCLAW_WORKSPACE_DIR ||
path.join(os.homedir(), '.hermes')
).trim()

View File

@@ -1,16 +0,0 @@
import { createFileRoute } from '@tanstack/react-router'
import { json } from '@tanstack/react-start'
import { isAuthenticated } from '@/server/auth-middleware'
export const Route = createFileRoute('/api/gateway/channels')({
server: {
handlers: {
GET: async ({ request }) => {
if (!isAuthenticated(request)) {
return json({ ok: false, error: 'Unauthorized' }, { status: 401 })
}
return json({ ok: true, data: { channels: [], statuses: [] } })
},
},
},
})

View File

@@ -1,16 +0,0 @@
import { createFileRoute } from '@tanstack/react-router'
import { json } from '@tanstack/react-start'
import { isAuthenticated } from '@/server/auth-middleware'
export const Route = createFileRoute('/api/gateway/nodes')({
server: {
handlers: {
GET: async ({ request }) => {
if (!isAuthenticated(request)) {
return json({ ok: false, error: 'Unauthorized' }, { status: 401 })
}
return json({ ok: true, data: { nodes: [] } })
},
},
},
})

View File

@@ -1,16 +0,0 @@
import { createFileRoute } from '@tanstack/react-router'
import { json } from '@tanstack/react-start'
import { isAuthenticated } from '@/server/auth-middleware'
export const Route = createFileRoute('/api/gateway/sessions')({
server: {
handlers: {
GET: async ({ request }) => {
if (!isAuthenticated(request)) {
return json({ ok: false, error: 'Unauthorized' }, { status: 401 })
}
return json({ ok: true, data: { sessions: [] } })
},
},
},
})

View File

@@ -1,16 +0,0 @@
import { createFileRoute } from '@tanstack/react-router'
import { json } from '@tanstack/react-start'
import { isAuthenticated } from '../../../server/auth-middleware'
export const Route = createFileRoute('/api/gateway/status')({
server: {
handlers: {
GET: async ({ request }) => {
if (!isAuthenticated(request)) {
return json({ ok: false, error: 'Unauthorized' }, { status: 401 })
}
return json({ connected: false, ok: true })
},
},
},
})

View File

@@ -1,16 +0,0 @@
import { createFileRoute } from '@tanstack/react-router'
import { json } from '@tanstack/react-start'
import { isAuthenticated } from '@/server/auth-middleware'
export const Route = createFileRoute('/api/gateway/usage')({
server: {
handlers: {
GET: async ({ request }) => {
if (!isAuthenticated(request)) {
return json({ ok: false, error: 'Unauthorized' }, { status: 401 })
}
return json({ ok: true, data: { usage: null, cost: null } })
},
},
},
})

View File

@@ -1,317 +0,0 @@
import { createFileRoute } from '@tanstack/react-router'
import { access, readFile } from 'node:fs/promises'
import { spawn } from 'node:child_process'
import { homedir } from 'node:os'
import { join } from 'node:path'
import net from 'node:net'
import { requireLocalOrAuth } from '@/server/auth-middleware'
const DEFAULT_GATEWAY_PORT = 18789
const DEFAULT_GATEWAY_URL = `ws://127.0.0.1:${DEFAULT_GATEWAY_PORT}`
const HEARTBEAT_INTERVAL_MS = 20_000
const GATEWAY_START_TIMEOUT_MS = 45_000
const TOKEN_WAIT_TIMEOUT_MS = 20_000
const POLL_INTERVAL_MS = 750
type SetupStatus = 'checking' | 'installing' | 'starting' | 'ready' | 'error'
type SetupEvent = {
status: SetupStatus
message: string
url?: string
token?: string
}
type HermesConfig = {
gateway?: {
port?: number
auth?: {
token?: string
}
}
}
function wait(ms: number) {
return new Promise((resolve) => setTimeout(resolve, ms))
}
function formatCommandError(command: string, error: unknown, fallback: string) {
if (error instanceof Error && error.message.trim()) {
return `${fallback} (${command}: ${error.message.trim()})`
}
return fallback
}
async function runCommand(command: string, args: string[], timeoutMs: number) {
return await new Promise<{ stdout: string; stderr: string; code: number | null }>(
(resolve, reject) => {
const child = spawn(command, args, {
env: process.env,
stdio: ['ignore', 'pipe', 'pipe'],
})
let stdout = ''
let stderr = ''
let settled = false
const timer = setTimeout(() => {
if (settled) return
settled = true
child.kill('SIGTERM')
reject(new Error(`${command} ${args.join(' ')} timed out after ${timeoutMs}ms`))
}, timeoutMs)
child.stdout.on('data', (chunk: Buffer | string) => {
stdout += chunk.toString()
})
child.stderr.on('data', (chunk: Buffer | string) => {
stderr += chunk.toString()
})
child.on('error', (error) => {
if (settled) return
settled = true
clearTimeout(timer)
reject(error)
})
child.on('close', (code) => {
if (settled) return
settled = true
clearTimeout(timer)
resolve({ stdout, stderr, code })
})
},
)
}
async function isHermesInstalled() {
try {
const whichResult = await runCommand('which', ['hermes'], 5_000)
if (whichResult.code === 0 && whichResult.stdout.trim()) {
return true
}
} catch {
// Fall through to the version check.
}
try {
const versionResult = await runCommand('hermes', ['--version'], 5_000)
return versionResult.code === 0 && Boolean(versionResult.stdout.trim())
} catch {
return false
}
}
async function installHermes() {
const result = await runCommand('pip', ['install', 'hermes-agent'], 10 * 60_000)
if (result.code !== 0) {
throw new Error(result.stderr.trim() || result.stdout.trim() || 'npm install failed')
}
}
function startGatewayDetached() {
// Hermes Workspace still uses the openclaw CLI to boot the local gateway.
const child = spawn('openclaw', ['gateway', 'start', '--bind', 'lan'], {
detached: true,
env: process.env,
stdio: 'ignore',
})
child.unref()
}
async function isGatewayRunning(port: number) {
return await new Promise<boolean>((resolve) => {
const socket = net.createConnection({ host: '127.0.0.1', port }, () => {
socket.destroy()
resolve(true)
})
socket.on('error', () => resolve(false))
socket.setTimeout(1_500, () => {
socket.destroy()
resolve(false)
})
})
}
async function readGatewayConfig(): Promise<HermesConfig | null> {
// Hermes Workspace reads the OpenClaw config because the gateway still writes there.
const configPath = join(homedir(), '.openclaw', 'openclaw.json')
try {
await access(configPath)
const raw = await readFile(configPath, 'utf8')
return JSON.parse(raw) as HermesConfig
} catch {
return null
}
}
async function waitForGatewayReady() {
const deadline = Date.now() + GATEWAY_START_TIMEOUT_MS
while (Date.now() < deadline) {
const config = await readGatewayConfig()
const port = config?.gateway?.port || DEFAULT_GATEWAY_PORT
if (await isGatewayRunning(port)) {
return {
url: `ws://127.0.0.1:${port}`,
}
}
await wait(POLL_INTERVAL_MS)
}
throw new Error('Hermes gateway did not become reachable in time')
}
async function waitForGatewayToken() {
const deadline = Date.now() + TOKEN_WAIT_TIMEOUT_MS
while (Date.now() < deadline) {
const config = await readGatewayConfig()
const token = config?.gateway?.auth?.token?.trim()
const port = config?.gateway?.port || DEFAULT_GATEWAY_PORT
if (token) {
return {
token,
url: `ws://127.0.0.1:${port}`,
}
}
await wait(POLL_INTERVAL_MS)
}
throw new Error('Gateway auth token was not written to ~/.openclaw/openclaw.json')
}
export const Route = createFileRoute('/api/local-setup')({
server: {
handlers: {
GET: async ({ request }) => {
if (!requireLocalOrAuth(request)) {
return new Response(JSON.stringify({ ok: false, error: 'Unauthorized' }), {
status: 401,
headers: { 'Content-Type': 'application/json' },
})
}
const encoder = new TextEncoder()
const stream = new ReadableStream({
start(controller) {
let closed = false
let heartbeatTimer: ReturnType<typeof setInterval> | null = null
const emit = (event: SetupEvent) => {
if (closed) return
controller.enqueue(encoder.encode(`data: ${JSON.stringify(event)}\n\n`))
}
const cleanup = () => {
if (closed) return
closed = true
if (heartbeatTimer) {
clearInterval(heartbeatTimer)
heartbeatTimer = null
}
request.signal.removeEventListener('abort', onAbort)
try {
controller.close()
} catch {
// stream already closed
}
}
const onAbort = () => {
cleanup()
}
heartbeatTimer = setInterval(() => {
if (closed) return
controller.enqueue(encoder.encode(': keep-alive\n\n'))
}, HEARTBEAT_INTERVAL_MS)
request.signal.addEventListener('abort', onAbort, { once: true })
void (async () => {
try {
emit({
status: 'checking',
message: 'Checking for Hermes...',
})
let installed = await isHermesInstalled()
if (!installed) {
emit({
status: 'installing',
message: 'Installing Hermes...',
})
await installHermes()
installed = await isHermesInstalled()
}
if (!installed) {
throw new Error('Hermes is still unavailable after installation')
}
const initialConfig = await readGatewayConfig()
const initialPort = initialConfig?.gateway?.port || DEFAULT_GATEWAY_PORT
emit({
status: 'starting',
message: 'Starting gateway...',
})
if (!(await isGatewayRunning(initialPort))) {
try {
startGatewayDetached()
} catch (error) {
throw new Error(
formatCommandError(
'hermes --web',
error,
'Failed to launch the Hermes gateway',
),
)
}
}
const ready = await waitForGatewayReady()
const tokenData = await waitForGatewayToken()
emit({
status: 'ready',
message: 'Connected!',
url: tokenData.url || ready.url || DEFAULT_GATEWAY_URL,
token: tokenData.token,
})
} catch (error) {
emit({
status: 'error',
message:
error instanceof Error ? error.message : 'Local Hermes setup failed',
})
} finally {
cleanup()
}
})()
},
})
return new Response(stream, {
headers: {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache',
Connection: 'keep-alive',
'X-Accel-Buffering': 'no',
},
})
},
},
},
})

View File

@@ -1,27 +0,0 @@
import { createFileRoute } from '@tanstack/react-router'
import { json } from '@tanstack/react-start'
import { isAuthenticated } from '../../server/auth-middleware'
import { requireJsonContentType } from '../../server/rate-limit'
export const Route = createFileRoute('/api/model-switch')({
server: {
handlers: {
POST: async ({ request }) => {
if (!isAuthenticated(request)) {
return json({ ok: false, error: 'Unauthorized' }, { status: 401 })
}
const csrfCheck = requireJsonContentType(request)
if (csrfCheck) return csrfCheck
const body = (await request.json().catch(() => ({}))) as Record<string, unknown>
const model = typeof body.model === 'string' ? body.model.trim() : ''
return json({
ok: true,
resolved: {
modelProvider: model.includes('/') ? model.split('/')[0] : 'hermes-agent',
model: model.includes('/') ? model.split('/').slice(1).join('/') : model,
},
})
},
},
},
})

View File

@@ -1,46 +0,0 @@
import { networkInterfaces } from 'node:os'
import { createFileRoute } from '@tanstack/react-router'
import { json } from '@tanstack/react-start'
import { isAuthenticated } from '../../server/auth-middleware'
function getNetworkUrl(port: number): { url: string; source: 'tailscale' | 'lan' | 'localhost' } {
const nets = networkInterfaces()
let tailscaleIp: string | null = null
let lanIp: string | null = null
for (const iface of Object.values(nets)) {
if (!iface) continue
for (const net of iface) {
if (net.family !== 'IPv4' || net.internal) continue
// Tailscale IPs are always in the 100.64.0.0/10 range
if (net.address.startsWith('100.')) {
tailscaleIp = net.address
} else if (!lanIp) {
lanIp = net.address
}
}
}
if (tailscaleIp) {
return { url: `http://${tailscaleIp}:${port}`, source: 'tailscale' }
}
if (lanIp) {
return { url: `http://${lanIp}:${port}`, source: 'lan' }
}
return { url: `http://localhost:${port}`, source: 'localhost' }
}
export const Route = createFileRoute('/api/network-url')({
server: {
handlers: {
GET: async ({ request }) => {
if (!isAuthenticated(request)) {
return json({ ok: false, error: 'Unauthorized' }, { status: 401 })
}
const port = parseInt(new URL(request.url).searchParams.get('port') ?? '3000', 10)
const result = getNetworkUrl(Number.isFinite(port) ? port : 3000)
return json({ ok: true, ...result })
},
},
},
})

View File

@@ -1,37 +0,0 @@
import { createFileRoute } from '@tanstack/react-router'
import { json } from '@tanstack/react-start'
import { isAuthenticated } from '@/server/auth-middleware'
export const Route = createFileRoute('/api/ollama-health')({
server: {
handlers: {
GET: async ({ request }) => {
if (!isAuthenticated(request)) {
return json({ ok: false, error: 'Unauthorized' }, { status: 401 })
}
const targets = ['http://127.0.0.1:11434/api/tags', 'http://localhost:11434/api/tags']
for (const target of targets) {
const controller = new AbortController()
const timeout = setTimeout(() => controller.abort(), 2500)
try {
const response = await fetch(target, {
method: 'GET',
signal: controller.signal,
})
if (response.ok) {
return json({ ok: true, endpoint: target })
}
} catch {
// try next endpoint
} finally {
clearTimeout(timeout)
}
}
return json({ ok: false }, { status: 503 })
},
},
},
})

View File

@@ -1,28 +0,0 @@
import { createFileRoute } from '@tanstack/react-router'
import { json } from '@tanstack/react-start'
import { isAuthenticated } from '@/server/auth-middleware'
import { checkHealth } from '../../server/hermes-api'
export const Route = createFileRoute('/api/ping')({
server: {
handlers: {
GET: async ({ request }) => {
if (!isAuthenticated(request)) {
return json({ ok: false, error: 'Unauthorized' }, { status: 401 })
}
try {
await checkHealth()
return json({ ok: true })
} catch (err) {
return json(
{
ok: false,
error: err instanceof Error ? err.message : String(err),
},
{ status: 503 },
)
}
},
},
},
})

View File

@@ -1,61 +0,0 @@
import { createFileRoute } from '@tanstack/react-router'
import { json } from '@tanstack/react-start'
import { getProviderUsage } from '../../server/provider-usage'
import { isAuthenticated } from '../../server/auth-middleware'
const REQUEST_TIMEOUT_MS = 5000 // 5 second timeout
async function withTimeout<T>(
promise: Promise<T>,
ms: number,
timeoutMessage: string,
): Promise<T> {
let timeoutId: ReturnType<typeof setTimeout>
const timeoutPromise = new Promise<never>((_, reject) => {
timeoutId = setTimeout(() => reject(new Error(timeoutMessage)), ms)
})
try {
return await Promise.race([promise, timeoutPromise])
} finally {
clearTimeout(timeoutId!)
}
}
export type {
ProviderUsageResult,
ProviderUsageResponse,
UsageLine,
} from '../../server/provider-usage'
export const Route = createFileRoute('/api/provider-usage')({
server: {
handlers: {
GET: async ({ request }) => {
if (!isAuthenticated(request)) {
return json({ ok: false, error: 'Unauthorized' }, { status: 401 })
}
try {
const url = new URL(request.url)
const force = url.searchParams.get('force') === '1'
const payload = await withTimeout(
getProviderUsage(force),
REQUEST_TIMEOUT_MS,
'Provider usage request timed out',
)
return json(payload)
} catch (err) {
return json(
{
ok: false,
updatedAt: Date.now(),
providers: [],
error: err instanceof Error ? err.message : String(err),
},
{ status: 503 },
)
}
},
},
},
})

View File

@@ -1,22 +0,0 @@
import { createFileRoute } from '@tanstack/react-router'
import { json } from '@tanstack/react-start'
import { isAuthenticated } from '../../server/auth-middleware'
import { requireJsonContentType } from '../../server/rate-limit'
export const Route = createFileRoute('/api/session-title')({
server: {
handlers: {
POST: async ({ request }) => {
if (!isAuthenticated(request)) {
return json({ ok: false, error: 'Unauthorized' }, { status: 401 })
}
const csrfCheck = requireJsonContentType(request)
if (csrfCheck) return csrfCheck
return json(
{ ok: false, error: 'Session title generation is not available in Hermes Workspace.' },
{ status: 501 },
)
},
},
},
})

View File

@@ -1,354 +0,0 @@
import { randomUUID } from 'node:crypto'
import { createFileRoute } from '@tanstack/react-router'
import WebSocket from 'ws'
import { isAuthenticated } from '../../server/auth-middleware'
import { requireJsonContentType } from '../../server/rate-limit'
function getGatewayMessage(message: string, attachments?: Array<unknown>): string {
if (message.trim().length > 0) return message
if (attachments && attachments.length > 0) {
return 'Please review the attached content.'
}
return message
}
type GatewayFrame =
| { type: 'req'; id: string; method: string; params?: unknown }
| {
type: 'res'
id: string
ok: boolean
payload?: unknown
error?: { code: string; message: string; details?: unknown }
}
| { type: 'event'; event: string; payload?: unknown; seq?: number }
type ConnectParams = {
minProtocol: number
maxProtocol: number
client: {
id: string
displayName?: string
version: string
platform: string
mode: string
instanceId?: string
}
auth?: { token?: string; password?: string }
role?: 'operator' | 'node'
scopes?: Array<string>
}
function getGatewayConfig() {
const url = process.env.HERMES_GATEWAY_URL?.trim() || 'ws://127.0.0.1:18789'
const token = process.env.HERMES_GATEWAY_TOKEN?.trim() || ''
const password = process.env.HERMES_GATEWAY_PASSWORD?.trim() || ''
if (!token && !password) {
throw new Error(
'Missing gateway auth. Set HERMES_GATEWAY_TOKEN (recommended) or HERMES_GATEWAY_PASSWORD in the server environment.',
)
}
return { url, token, password }
}
function buildConnectParams(token: string, password: string): ConnectParams {
return {
minProtocol: 3,
maxProtocol: 3,
client: {
id: 'gateway-client-stream',
displayName: 'hermes-stream',
version: 'dev',
platform: process.platform,
mode: 'ui',
instanceId: randomUUID(),
},
auth: {
token: token || undefined,
password: password || undefined,
},
role: 'operator',
scopes: ['operator.admin'],
}
}
export const Route = createFileRoute('/api/stream')({
server: {
handlers: {
POST: async ({ request }) => {
// Auth check
if (!isAuthenticated(request)) {
return new Response(
JSON.stringify({ ok: false, error: 'Unauthorized' }),
{ status: 401, headers: { 'Content-Type': 'application/json' } },
)
}
const csrfCheck = requireJsonContentType(request)
if (csrfCheck) return csrfCheck
try {
const body = (await request.json().catch(() => ({}))) as Record<
string,
unknown
>
const sessionKey =
typeof body.sessionKey === 'string' ? body.sessionKey.trim() : ''
const friendlyId =
typeof body.friendlyId === 'string' ? body.friendlyId.trim() : ''
const message = String(body.message ?? '')
const thinking =
typeof body.thinking === 'string' ? body.thinking : undefined
const attachments = Array.isArray(body.attachments)
? body.attachments
: undefined
if (!message.trim() && (!attachments || attachments.length === 0)) {
return new Response(
JSON.stringify({ ok: false, error: 'message required' }),
{
status: 400,
headers: { 'Content-Type': 'application/json' },
},
)
}
const { url, token, password } = getGatewayConfig()
// Create SSE response stream
const encoder = new TextEncoder()
const stream = new ReadableStream({
async start(controller) {
const ws = new WebSocket(url)
let connected = false
let runId: string | null = null
const sendSSE = (event: string, data: unknown) => {
const chunk = `event: ${event}\ndata: ${JSON.stringify(data)}\n\n`
controller.enqueue(encoder.encode(chunk))
}
const cleanup = () => {
try {
if (
ws.readyState === ws.OPEN ||
ws.readyState === ws.CONNECTING
) {
ws.close()
}
} catch {
// ignore
}
}
ws.on('open', async () => {
try {
// Send connect handshake
const connectId = randomUUID()
const connectParams = buildConnectParams(token, password)
ws.send(
JSON.stringify({
type: 'req',
id: connectId,
method: 'connect',
params: connectParams,
}),
)
} catch (err) {
sendSSE('error', { message: 'Failed to connect to gateway' })
cleanup()
controller.close()
}
})
ws.on('message', (data: unknown) => {
try {
const parsed = JSON.parse(String(data)) as GatewayFrame
if (parsed.type === 'res') {
if (!connected) {
// Connect response
if (parsed.ok) {
connected = true
// Now send the chat message
const chatId = randomUUID()
ws.send(
JSON.stringify({
type: 'req',
id: chatId,
method: 'chat.send',
params: {
sessionKey: sessionKey || 'main',
friendlyId: friendlyId || undefined,
message: getGatewayMessage(message, attachments),
thinking,
attachments,
deliver: false,
stream: true, // Request streaming if supported
timeoutMs: 120_000,
idempotencyKey: randomUUID(),
},
}),
)
sendSSE('connected', { sessionKey, friendlyId })
} else {
sendSSE('error', {
message: parsed.error?.message ?? 'Connection failed',
})
cleanup()
controller.close()
}
} else {
// Chat response
if (parsed.ok) {
const payload = parsed.payload as {
runId?: string
text?: string
content?: Array<{ type: string; text?: string }>
message?: {
content?: Array<{ type: string; text?: string }>
}
}
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- runtime safety
runId = payload?.runId ?? null
sendSSE('started', { runId })
// If the response includes the full message text, emit it as a complete event
// This handles gateways that don't support streaming
let responseText = ''
if (typeof payload.text === 'string') {
responseText = payload.text
} else if (Array.isArray(payload.content)) {
responseText = payload.content
.filter((c) => c.type === 'text' && c.text)
.map((c) => c.text)
.join('')
} else if (payload.message?.content) {
responseText = payload.message.content
.filter((c) => c.type === 'text' && c.text)
.map((c) => c.text)
.join('')
}
if (responseText) {
// Emit the full response as chunks for a typing effect
// Split by words for word-by-word streaming simulation
const words = responseText.split(/(\s+)/)
let accumulated = ''
for (const word of words) {
accumulated += word
sendSSE('chunk', { delta: word, text: accumulated })
}
sendSSE('complete', { text: responseText })
cleanup()
controller.close()
}
} else {
sendSSE('error', {
message: parsed.error?.message ?? 'Chat send failed',
})
cleanup()
controller.close()
}
}
} else if (parsed.type === 'event') {
// Forward streaming events
const eventName = parsed.event
const payload = parsed.payload as Record<string, unknown>
if (
eventName === 'chat.chunk' ||
eventName === 'chat.delta' ||
eventName === 'message.delta'
) {
// Streaming text chunk
sendSSE('chunk', payload)
} else if (
eventName === 'chat.complete' ||
eventName === 'chat.done' ||
eventName === 'message.complete'
) {
// Stream complete
sendSSE('complete', payload)
cleanup()
controller.close()
} else if (
eventName === 'chat.error' ||
eventName === 'message.error'
) {
sendSSE('error', payload)
cleanup()
controller.close()
} else if (eventName === 'chat.thinking') {
// Thinking/reasoning updates
sendSSE('thinking', payload)
} else if (eventName === 'chat.tool') {
// Tool call updates
sendSSE('tool', payload)
} else {
// Forward other events
sendSSE('event', { event: eventName, ...payload })
}
}
} catch {
// Ignore parse errors
}
})
ws.on('error', (err: unknown) => {
const message = err instanceof Error ? err.message : String(err)
sendSSE('error', { message })
cleanup()
controller.close()
})
ws.on('close', () => {
sendSSE('close', {})
try {
controller.close()
} catch {
// Already closed
}
})
// Set a timeout to close the connection if no response
setTimeout(() => {
if (ws.readyState === ws.OPEN) {
sendSSE('timeout', { message: 'Request timed out' })
cleanup()
try {
controller.close()
} catch {
// Already closed
}
}
}, 125_000)
},
})
return new Response(stream, {
headers: {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache',
Connection: 'keep-alive',
'X-Accel-Buffering': 'no',
},
})
} catch (err) {
return new Response(
JSON.stringify({
ok: false,
error: err instanceof Error ? err.message : String(err),
}),
{
status: 500,
headers: { 'Content-Type': 'application/json' },
},
)
}
},
},
},
})

View File

@@ -1,22 +0,0 @@
import os from 'node:os'
import { createFileRoute } from '@tanstack/react-router'
import { json } from '@tanstack/react-start'
export const Route = createFileRoute('/api/system-metrics')({
server: {
handlers: {
GET: async () => {
const totalMem = os.totalmem()
const freeMem = os.freemem()
return json({
cpu: 0,
ramUsed: totalMem - freeMem,
ramTotal: totalMem,
diskPercent: 0,
uptime: os.uptime(),
gatewayConnected: false,
})
},
},
},
})

View File

@@ -1,133 +0,0 @@
import fs from 'node:fs/promises'
import path from 'node:path'
import { createFileRoute } from '@tanstack/react-router'
import { json } from '@tanstack/react-start'
import { z } from 'zod'
import { isAuthenticated } from '../../../server/auth-middleware'
import {
getClientIp,
rateLimit,
rateLimitResponse,
requireJsonContentType,
safeErrorMessage,
} from '../../../server/rate-limit'
// Use workspace-level tasks file so all Hermes agents share one source of truth
const WORKSPACE_ROOT =
process.env.HERMES_WORKSPACE || process.env.OPENCLAW_WORKSPACE || ''
const TASKS_FILE =
WORKSPACE_ROOT
? path.join(WORKSPACE_ROOT, 'data', 'tasks.json')
: path.join(process.cwd(), '..', 'data', 'tasks.json')
async function readTasks(): Promise<Record<string, unknown>[]> {
try {
const raw = await fs.readFile(TASKS_FILE, 'utf-8')
const parsed = JSON.parse(raw)
return Array.isArray(parsed) ? parsed : []
} catch {
return []
}
}
async function writeTasks(tasks: unknown[]): Promise<void> {
await fs.mkdir(path.dirname(TASKS_FILE), { recursive: true })
await fs.writeFile(TASKS_FILE, JSON.stringify(tasks, null, 2), 'utf-8')
}
function extractTaskId(request: Request): string {
const url = new URL(request.url)
const segments = url.pathname.split('/')
return decodeURIComponent(segments[segments.length - 1] ?? '').trim()
}
const UpdateTaskSchema = z.object({
title: z.string().trim().min(1).max(500).optional(),
description: z.string().max(5000).optional(),
status: z.enum(['backlog', 'in_progress', 'review', 'done']).optional(),
priority: z.enum(['P0', 'P1', 'P2', 'P3']).optional(),
project: z.string().max(100).optional().nullable(),
tags: z.array(z.string().max(50)).max(20).optional(),
dueDate: z.string().max(30).optional().nullable(),
reminder: z.string().max(30).optional().nullable(),
})
export const Route = createFileRoute('/api/tasks/$taskId')({
server: {
handlers: {
PATCH: async ({ request }) => {
if (!isAuthenticated(request)) {
return json({ error: 'Unauthorized' }, { status: 401 })
}
const csrfCheckPatch = requireJsonContentType(request)
if (csrfCheckPatch) return csrfCheckPatch
const ip = getClientIp(request)
if (!rateLimit(`tasks-patch:${ip}`, 30, 60_000))
return rateLimitResponse()
try {
const taskId = extractTaskId(request)
if (!taskId)
return json({ error: 'taskId is required' }, { status: 400 })
const body = await request.json().catch(() => ({}))
const parsed = UpdateTaskSchema.safeParse(body)
if (!parsed.success) {
return json(
{
error: 'Validation failed',
details: parsed.error.flatten().fieldErrors,
},
{ status: 400 },
)
}
const tasks = await readTasks()
const idx = tasks.findIndex((t) => t.id === taskId)
if (idx === -1)
return json({ error: 'Task not found' }, { status: 404 })
const updated = {
...tasks[idx],
...parsed.data,
updatedAt: new Date().toISOString(),
}
tasks[idx] = updated
await writeTasks(tasks)
return json({ task: updated })
} catch (err) {
return json({ error: safeErrorMessage(err) }, { status: 500 })
}
},
DELETE: async ({ request }) => {
if (!isAuthenticated(request)) {
return json({ error: 'Unauthorized' }, { status: 401 })
}
const ip = getClientIp(request)
if (!rateLimit(`tasks-delete:${ip}`, 30, 60_000))
return rateLimitResponse()
try {
const taskId = extractTaskId(request)
if (!taskId)
return json({ error: 'taskId is required' }, { status: 400 })
const tasks = await readTasks()
const filtered = tasks.filter((t) => t.id !== taskId)
if (filtered.length === tasks.length)
return json({ error: 'Task not found' }, { status: 404 })
await writeTasks(filtered)
return json({ ok: true })
} catch (err) {
return json({ error: safeErrorMessage(err) }, { status: 500 })
}
},
},
},
})

View File

@@ -1,111 +0,0 @@
import fs from 'node:fs/promises'
import path from 'node:path'
import { createFileRoute } from '@tanstack/react-router'
import { json } from '@tanstack/react-start'
import { z } from 'zod'
import { isAuthenticated } from '../../../server/auth-middleware'
import {
getClientIp,
rateLimit,
rateLimitResponse,
requireJsonContentType,
safeErrorMessage,
} from '../../../server/rate-limit'
// Use workspace-level tasks file so all Hermes agents share one source of truth
const WORKSPACE_ROOT =
process.env.HERMES_WORKSPACE || process.env.OPENCLAW_WORKSPACE || ''
const TASKS_FILE =
WORKSPACE_ROOT
? path.join(WORKSPACE_ROOT, 'data', 'tasks.json')
: path.join(process.cwd(), '..', 'data', 'tasks.json')
async function readTasks(): Promise<unknown[]> {
try {
const raw = await fs.readFile(TASKS_FILE, 'utf-8')
const parsed = JSON.parse(raw)
return Array.isArray(parsed) ? parsed : []
} catch {
return []
}
}
async function writeTasks(tasks: unknown[]): Promise<void> {
await fs.mkdir(path.dirname(TASKS_FILE), { recursive: true })
await fs.writeFile(TASKS_FILE, JSON.stringify(tasks, null, 2), 'utf-8')
}
const CreateTaskSchema = z.object({
title: z.string().trim().min(1).max(500),
description: z.string().max(5000).default(''),
status: z
.enum(['backlog', 'in_progress', 'review', 'done'])
.default('backlog'),
priority: z.enum(['P0', 'P1', 'P2', 'P3']).default('P1'),
project: z.string().max(100).optional(),
tags: z.array(z.string().max(50)).max(20).default([]),
dueDate: z.string().max(30).optional(),
reminder: z.string().max(30).optional(),
})
export const Route = createFileRoute('/api/tasks/')({
server: {
handlers: {
GET: async ({ request }) => {
const ip = getClientIp(request)
if (!rateLimit(`tasks-get:${ip}`, 60, 60_000))
return rateLimitResponse()
try {
const tasks = await readTasks()
return json({ tasks })
} catch (err) {
return json({ error: safeErrorMessage(err) }, { status: 500 })
}
},
POST: async ({ request }) => {
if (!isAuthenticated(request)) {
return json({ error: 'Unauthorized' }, { status: 401 })
}
const csrfCheck = requireJsonContentType(request)
if (csrfCheck) return csrfCheck
const ip = getClientIp(request)
if (!rateLimit(`tasks-post:${ip}`, 30, 60_000))
return rateLimitResponse()
try {
const body = await request.json().catch(() => ({}))
const parsed = CreateTaskSchema.safeParse(body)
if (!parsed.success) {
return json(
{
error: 'Validation failed',
details: parsed.error.flatten().fieldErrors,
},
{ status: 400 },
)
}
const now = new Date().toISOString()
const task = {
id: `TASK-${Date.now().toString(36).toUpperCase()}`,
...parsed.data,
createdAt: now,
updatedAt: now,
}
const tasks = await readTasks()
tasks.unshift(task)
await writeTasks(tasks)
return json({ task }, { status: 201 })
} catch (err) {
return json({ error: safeErrorMessage(err) }, { status: 500 })
}
},
},
},
})

View File

@@ -1,272 +0,0 @@
import { execSync } from 'node:child_process'
import path from 'node:path'
import { createFileRoute } from '@tanstack/react-router'
import { json } from '@tanstack/react-start'
import {
isAuthenticated,
requireLocalOrAuth,
} from '../../server/auth-middleware'
import {
getClientIp,
rateLimit,
rateLimitResponse,
requireJsonContentType,
} from '../../server/rate-limit'
/**
* Checks if the local repo is behind the remote.
* Returns local commit, remote commit, and whether an update is available.
*/
const CHECK_COOLDOWN_MS = 5 * 60 * 1000 // 5 min cache
let lastCheck: { at: number; result: UpdateCheckResult } | null = null
type CommitEntry = {
hash: string
subject: string
date: string
}
type UpdateCheckResult = {
updateAvailable: boolean
localVersion: string
remoteVersion: string
localCommit: string
remoteCommit: string
localDate: string
remoteDate: string
behindBy: number
repoPath: string
changelog: Array<CommitEntry>
}
let gitAvailable: boolean | null = null
function isGitAvailable(): boolean {
if (gitAvailable !== null) return gitAvailable
try {
execSync('git --version', { timeout: 5_000, stdio: 'pipe' })
gitAvailable = true
} catch {
gitAvailable = false
}
return gitAvailable
}
function runGit(args: string, cwd: string): string {
if (!isGitAvailable()) return ''
try {
return execSync(`git ${args}`, {
cwd,
timeout: 15_000,
encoding: 'utf8',
stdio: ['pipe', 'pipe', 'pipe'],
}).trim()
} catch {
return ''
}
}
function readPackageVersion(repoPath: string): string {
try {
const pkg = require(path.join(repoPath, 'package.json')) as {
version?: string
}
return pkg.version || '0.0.0'
} catch {
return '0.0.0'
}
}
function buildVersionLabel(baseVersion: string, commit: string): string {
return `${baseVersion} (${commit})`
}
function detectRemote(repoPath: string): string {
// Prefer 'production' or 'studio' remote (Eric's fork), fall back to 'origin'
const remotes = runGit('remote', repoPath)
.split('\n')
.map((r) => r.trim())
.filter(Boolean)
if (remotes.includes('production')) return 'production'
if (remotes.includes('studio')) return 'studio'
return 'origin'
}
function isGitRepo(dir: string): boolean {
try {
const fs = require('node:fs')
return fs.existsSync(path.join(dir, '.git'))
} catch {
return false
}
}
function checkForUpdates(): UpdateCheckResult {
const repoPath = path.resolve(process.cwd())
const pkgVersion = readPackageVersion(repoPath)
// Non-git installs (packaged .dmg/.exe) — no update check possible
if (!isGitAvailable() || !isGitRepo(repoPath)) {
return {
updateAvailable: false,
localVersion: pkgVersion,
remoteVersion: pkgVersion,
localCommit: 'packaged',
remoteCommit: 'packaged',
localDate: '',
remoteDate: '',
behindBy: 0,
repoPath,
changelog: [],
}
}
const remote = detectRemote(repoPath)
// Fetch latest from remote (quiet, won't fail if offline)
runGit(`fetch ${remote} --quiet`, repoPath)
const currentBranch =
runGit('rev-parse --abbrev-ref HEAD', repoPath) || 'main'
const localCommit = runGit('rev-parse --short HEAD', repoPath)
const localDate = runGit('log -1 --format=%ci', repoPath)
const remoteRef = `${remote}/${currentBranch}`
const remoteCommit = runGit(`rev-parse --short ${remoteRef}`, repoPath)
const remoteDate = runGit(`log -1 --format=%ci ${remoteRef}`, repoPath)
// Count commits behind
const behindCount = runGit(`rev-list --count HEAD..${remoteRef}`, repoPath)
const behindBy = parseInt(behindCount, 10) || 0
// Build version labels
const localVersion = buildVersionLabel(pkgVersion, localCommit)
const remoteVersion =
behindBy > 0 ? buildVersionLabel(pkgVersion, remoteCommit) : localVersion
// Get changelog (up to 20 commits)
const changelog: Array<CommitEntry> = []
if (behindBy > 0) {
const logOutput = runGit(
`log HEAD..${remoteRef} --format=%h||%s||%ci -n 20`,
repoPath,
)
for (const line of logOutput.split('\n')) {
if (!line.trim()) continue
const [hash, subject, date] = line.split('||')
if (hash && subject) {
changelog.push({
hash: hash.trim(),
subject: subject.trim(),
date: (date || '').trim(),
})
}
}
}
return {
updateAvailable: behindBy > 0,
localVersion,
remoteVersion,
localCommit,
remoteCommit: remoteCommit || localCommit,
localDate,
remoteDate: remoteDate || localDate,
behindBy,
repoPath,
changelog,
}
}
function runUpdate(): { ok: boolean; output: string } {
if (!isGitAvailable()) {
return { ok: false, output: 'git is not available in this environment' }
}
const repoPath = path.resolve(process.cwd())
const remote = detectRemote(repoPath)
const currentBranch =
runGit('rev-parse --abbrev-ref HEAD', repoPath) || 'main'
try {
// Pull latest — use merge (not rebase) to avoid conflict hell with forked repos
const pullOutput = execSync(`git pull ${remote} ${currentBranch}`, {
cwd: repoPath,
timeout: 30_000,
encoding: 'utf8',
stdio: ['pipe', 'pipe', 'pipe'],
}).trim()
// Install deps
const installOutput = execSync('npm install --prefer-offline', {
cwd: repoPath,
timeout: 120_000,
encoding: 'utf8',
}).trim()
// Clear update cache so next check shows up-to-date
lastCheck = null
return {
ok: true,
output: `${pullOutput}\n\n${installOutput}`,
}
} catch (err) {
return {
ok: false,
output: err instanceof Error ? err.message : String(err),
}
}
}
export const Route = createFileRoute('/api/update-check')({
server: {
handlers: {
GET: async ({ request }) => {
if (!isAuthenticated(request)) {
return json({ ok: false, error: 'Unauthorized' }, { status: 401 })
}
try {
const now = Date.now()
if (lastCheck && now - lastCheck.at < CHECK_COOLDOWN_MS) {
return json(lastCheck.result)
}
const result = checkForUpdates()
lastCheck = { at: now, result }
return json(result)
} catch (err) {
return json(
{ error: err instanceof Error ? err.message : String(err) },
{ status: 500 },
)
}
},
POST: async ({ request }) => {
if (!requireLocalOrAuth(request)) {
return json({ ok: false, error: 'Unauthorized' }, { status: 401 })
}
const csrfCheck = requireJsonContentType(request)
if (csrfCheck) return csrfCheck
const ip = getClientIp(request)
// update-check POST triggers git pull + npm install — high RCE risk, strict limit
if (!rateLimit(`update-check-post:${ip}`, 10, 60_000)) {
return rateLimitResponse()
}
try {
const result = runUpdate()
return json(result)
} catch (err) {
return json(
{
ok: false,
output: err instanceof Error ? err.message : String(err),
},
{ status: 500 },
)
}
},
},
},
})

View File

@@ -1,16 +0,0 @@
import { createFileRoute } from '@tanstack/react-router'
import { json } from '@tanstack/react-start'
import { isAuthenticated } from '@/server/auth-middleware'
export const Route = createFileRoute('/api/usage-analytics')({
server: {
handlers: {
GET: async ({ request }) => {
if (!isAuthenticated(request)) {
return json({ ok: false, error: 'Unauthorized' }, { status: 401 })
}
return json({ ok: true, models: [], sessions: [], agents: [], cost: [] })
},
},
},
})

View File

@@ -1,16 +0,0 @@
import { createFileRoute } from '@tanstack/react-router'
import { json } from '@tanstack/react-start'
import { isAuthenticated } from '../../server/auth-middleware'
export const Route = createFileRoute('/api/usage')({
server: {
handlers: {
GET: async ({ request }) => {
if (!isAuthenticated(request)) {
return json({ ok: false, error: 'Unauthorized' }, { status: 401 })
}
return json({ ok: true, usage: {} })
},
},
},
})

View File

@@ -1,137 +0,0 @@
import { createFileRoute } from '@tanstack/react-router'
import { json } from '@tanstack/react-start'
import { isAuthenticated } from '../../server/auth-middleware'
import { requireJsonContentType } from '../../server/rate-limit'
/**
* POST /api/validate-provider
*
* Validates an API key by making a lightweight request to the provider's API.
* Returns { ok: true } if valid, { ok: false, error: "..." } if not.
*/
type ValidateBody = {
providerId?: string
apiKey?: string
}
type ProviderConfig = {
url: string
headers: (key: string) => Record<string, string>
body?: string
method?: string
successCheck: (status: number, data: unknown) => boolean
}
const PROVIDER_VALIDATORS: Record<string, ProviderConfig> = {
anthropic: {
// Hit the messages endpoint with a minimal request — will return 400 but proves auth works
// Using a GET to /v1/models would be ideal but Anthropic doesn't have one
// Instead we send a minimal message request — if we get 400 (bad request) that means auth passed
url: 'https://api.anthropic.com/v1/messages',
headers: (key) => ({
'x-api-key': key,
'anthropic-version': '2023-06-01',
'content-type': 'application/json',
}),
body: JSON.stringify({
model: 'claude-sonnet-4-5-20250514',
max_tokens: 1,
messages: [{ role: 'user', content: 'hi' }],
}),
method: 'POST',
// 200 = valid (unlikely with max_tokens:1 but possible), 400 = valid auth but bad request
// 401/403 = invalid key
successCheck: (status) => status === 200 || status === 400 || status === 429,
},
openrouter: {
url: 'https://openrouter.ai/api/v1/auth/key',
headers: (key) => ({
Authorization: `Bearer ${key}`,
}),
successCheck: (status) => status === 200,
},
google: {
// Gemini API — list models to validate key
url: 'https://generativelanguage.googleapis.com/v1/models',
headers: (key) => ({
'x-goog-api-key': key,
}),
successCheck: (status) => status === 200,
},
openai: {
url: 'https://api.openai.com/v1/models',
headers: (key) => ({
Authorization: `Bearer ${key}`,
}),
successCheck: (status) => status === 200,
},
minimax: {
url: 'https://api.minimaxi.chat/v1/models',
headers: (key) => ({
Authorization: `Bearer ${key}`,
}),
successCheck: (status) => status === 200,
},
}
export const Route = createFileRoute('/api/validate-provider')({
server: {
handlers: {
POST: async ({ request }) => {
if (!isAuthenticated(request)) {
return json({ ok: false, error: 'Unauthorized' }, { status: 401 })
}
const csrfCheck = requireJsonContentType(request)
if (csrfCheck) return csrfCheck
try {
const body = (await request.json().catch(() => ({}))) as ValidateBody
if (!body.providerId || !body.apiKey) {
return json({ ok: false, error: 'Missing provider or API key' }, { status: 400 })
}
const validator = PROVIDER_VALIDATORS[body.providerId]
if (!validator) {
return json({ ok: false, error: `Unknown provider: ${body.providerId}` }, { status: 400 })
}
const fetchOptions: RequestInit = {
method: validator.method || 'GET',
headers: validator.headers(body.apiKey),
signal: AbortSignal.timeout(10000),
}
if (validator.body && fetchOptions.method === 'POST') {
fetchOptions.body = validator.body
}
const response = await fetch(validator.url, fetchOptions)
if (validator.successCheck(response.status, null)) {
return json({ ok: true })
}
// Try to extract error message
let errorMsg = `Invalid API key (HTTP ${response.status})`
try {
const data = (await response.json()) as { error?: { message?: string } }
if (data.error?.message) {
errorMsg = data.error.message
}
} catch {
// ignore parse errors
}
return json({ ok: false, error: errorMsg })
} catch (err) {
const msg = err instanceof Error ? err.message : String(err)
if (msg.includes('timeout') || msg.includes('abort')) {
return json({ ok: false, error: 'Request timed out — check your connection' })
}
return json({ ok: false, error: `Validation error: ${msg}` }, { status: 500 })
}
},
},
},
})

View File

@@ -1,43 +0,0 @@
import { createFileRoute } from '@tanstack/react-router'
import { json } from '@tanstack/react-start'
import { isAuthenticated } from '../../server/auth-middleware'
import {
getClientIp,
rateLimit,
rateLimitResponse,
requireJsonContentType,
safeErrorMessage,
} from '../../server/rate-limit'
import { forwardWorkspaceRequest } from '../../server/workspace-proxy'
export const Route = createFileRoute('/api/workspace-tasks/$id')({
server: {
handlers: {
PUT: async ({ request, params }) => {
if (!isAuthenticated(request)) {
return json({ ok: false, error: 'Unauthorized' }, { status: 401 })
}
const csrfCheck = requireJsonContentType(request)
if (csrfCheck) return csrfCheck
const ip = getClientIp(request)
if (!rateLimit(`workspace-tasks-put:${ip}`, 60, 60_000)) {
return rateLimitResponse()
}
try {
return await forwardWorkspaceRequest({
request,
path: `/tasks/${params.id}`,
})
} catch (error) {
return json(
{ ok: false, error: safeErrorMessage(error) },
{ status: 502 },
)
}
},
},
},
})

View File

@@ -1,68 +0,0 @@
import { createFileRoute } from '@tanstack/react-router'
import { json } from '@tanstack/react-start'
import { isAuthenticated } from '../../server/auth-middleware'
import {
getClientIp,
rateLimit,
rateLimitResponse,
requireJsonContentType,
safeErrorMessage,
} from '../../server/rate-limit'
import { forwardWorkspaceRequest } from '../../server/workspace-proxy'
export const Route = createFileRoute('/api/workspace-tasks')({
server: {
handlers: {
GET: async ({ request }) => {
if (!isAuthenticated(request)) {
return json({ ok: false, error: 'Unauthorized' }, { status: 401 })
}
const ip = getClientIp(request)
if (!rateLimit(`workspace-tasks-get:${ip}`, 120, 60_000)) {
return rateLimitResponse()
}
try {
const url = new URL(request.url)
return await forwardWorkspaceRequest({
request,
path: '/tasks',
searchParams: url.searchParams,
})
} catch (error) {
return json(
{ ok: false, error: safeErrorMessage(error) },
{ status: 502 },
)
}
},
POST: async ({ request }) => {
if (!isAuthenticated(request)) {
return json({ ok: false, error: 'Unauthorized' }, { status: 401 })
}
const csrfCheck = requireJsonContentType(request)
if (csrfCheck) return csrfCheck
const ip = getClientIp(request)
if (!rateLimit(`workspace-tasks-post:${ip}`, 30, 60_000)) {
return rateLimitResponse()
}
try {
return await forwardWorkspaceRequest({
request,
path: '/tasks',
})
} catch (error) {
return json(
{ ok: false, error: safeErrorMessage(error) },
{ status: 502 },
)
}
},
},
},
})

View File

@@ -46,7 +46,7 @@ async function detectWorkspace(savedPath?: string): Promise<{
// Priority 2: Environment variable
const envWorkspace =
process.env.HERMES_WORKSPACE_DIR?.trim() ||
process.env.OPENCLAW_WORKSPACE_DIR?.trim()
process.env.HERMES_WORKSPACE_DIR || process.env.OPENCLAW_WORKSPACE_DIR?.trim()
if (envWorkspace) {
const isValid = await isValidDirectory(envWorkspace)
if (isValid) {

View File

@@ -1,66 +0,0 @@
import { createFileRoute } from '@tanstack/react-router'
import { json } from '@tanstack/react-start'
import { isAuthenticated } from '../../../server/auth-middleware'
import {
getClientIp,
rateLimit,
rateLimitResponse,
requireJsonContentType,
safeErrorMessage,
} from '../../../server/rate-limit'
import { forwardWorkspaceRequest } from '../../../server/workspace-proxy'
export const Route = createFileRoute('/api/workspace/agents/$id')({
server: {
handlers: {
PATCH: async ({ request, params }) => {
if (!isAuthenticated(request)) {
return json({ ok: false, error: 'Unauthorized' }, { status: 401 })
}
const csrfCheck = requireJsonContentType(request)
if (csrfCheck) return csrfCheck
const ip = getClientIp(request)
if (!rateLimit(`workspace-agent-patch:${ip}`, 30, 60_000)) {
return rateLimitResponse()
}
try {
return await forwardWorkspaceRequest({
request,
path: `/agents/${encodeURIComponent(params.id)}`,
})
} catch (error) {
return json(
{ ok: false, error: safeErrorMessage(error) },
{ status: 502 },
)
}
},
DELETE: async ({ request, params }) => {
if (!isAuthenticated(request)) {
return json({ ok: false, error: 'Unauthorized' }, { status: 401 })
}
const ip = getClientIp(request)
if (!rateLimit(`workspace-agent-delete:${ip}`, 20, 60_000)) {
return rateLimitResponse()
}
try {
return await forwardWorkspaceRequest({
request,
path: `/agents/${encodeURIComponent(params.id)}`,
})
} catch (error) {
return json(
{ ok: false, error: safeErrorMessage(error) },
{ status: 502 },
)
}
},
},
},
})

View File

@@ -1,71 +0,0 @@
import { createFileRoute } from '@tanstack/react-router'
import { json } from '@tanstack/react-start'
import { isAuthenticated } from '../../../server/auth-middleware'
import {
getClientIp,
rateLimit,
rateLimitResponse,
requireJsonContentType,
safeErrorMessage,
} from '../../../server/rate-limit'
import { forwardWorkspaceRequest } from '../../../server/workspace-proxy'
export const Route = createFileRoute('/api/workspace/agents')({
server: {
handlers: {
GET: async ({ request }) => {
if (!isAuthenticated(request)) {
return json({ ok: false, error: 'Unauthorized' }, { status: 401 })
}
const ip = getClientIp(request)
if (!rateLimit(`workspace-agents-get:${ip}`, 120, 60_000)) {
return rateLimitResponse()
}
try {
const url = new URL(request.url)
const statsFor = url.searchParams.get('stats_for')?.trim()
return await forwardWorkspaceRequest({
request,
path: statsFor
? `/agents/${encodeURIComponent(statsFor)}/stats`
: '/agents',
})
} catch (error) {
return json(
{ ok: false, error: safeErrorMessage(error) },
{ status: 502 },
)
}
},
POST: async ({ request }) => {
if (!isAuthenticated(request)) {
return json({ ok: false, error: 'Unauthorized' }, { status: 401 })
}
const csrfCheck = requireJsonContentType(request)
if (csrfCheck) return csrfCheck
const ip = getClientIp(request)
if (!rateLimit(`workspace-agents-post:${ip}`, 30, 60_000)) {
return rateLimitResponse()
}
try {
return await forwardWorkspaceRequest({
request,
path: '/agents',
})
} catch (error) {
return json(
{ ok: false, error: safeErrorMessage(error) },
{ status: 502 },
)
}
},
},
},
})

View File

@@ -1,47 +0,0 @@
import { createFileRoute } from '@tanstack/react-router'
import { json } from '@tanstack/react-start'
import { isAuthenticated } from '../../../server/auth-middleware'
import {
getClientIp,
rateLimit,
rateLimitResponse,
requireJsonContentType,
safeErrorMessage,
} from '../../../server/rate-limit'
import { forwardWorkspaceRequest } from '../../../server/workspace-proxy'
export const Route = createFileRoute(
'/api/workspace/checkpoints/$id/approve-and-commit',
)({
server: {
handlers: {
POST: async ({ request, params }) => {
if (!isAuthenticated(request)) {
return json({ ok: false, error: 'Unauthorized' }, { status: 401 })
}
const csrfCheck = requireJsonContentType(request)
if (csrfCheck) return csrfCheck
const ip = getClientIp(request)
if (
!rateLimit(`workspace-checkpoint-approve-commit:${ip}`, 30, 60_000)
) {
return rateLimitResponse()
}
try {
return await forwardWorkspaceRequest({
request,
path: `/checkpoints/${params.id}/approve-and-commit`,
})
} catch (error) {
return json(
{ ok: false, error: safeErrorMessage(error) },
{ status: 502 },
)
}
},
},
},
})

View File

@@ -1,47 +0,0 @@
import { createFileRoute } from '@tanstack/react-router'
import { json } from '@tanstack/react-start'
import { isAuthenticated } from '../../../server/auth-middleware'
import {
getClientIp,
rateLimit,
rateLimitResponse,
requireJsonContentType,
safeErrorMessage,
} from '../../../server/rate-limit'
import { forwardWorkspaceRequest } from '../../../server/workspace-proxy'
export const Route = createFileRoute(
'/api/workspace/checkpoints/$id/approve-and-merge',
)({
server: {
handlers: {
POST: async ({ request, params }) => {
if (!isAuthenticated(request)) {
return json({ ok: false, error: 'Unauthorized' }, { status: 401 })
}
const csrfCheck = requireJsonContentType(request)
if (csrfCheck) return csrfCheck
const ip = getClientIp(request)
if (
!rateLimit(`workspace-checkpoint-approve-merge:${ip}`, 30, 60_000)
) {
return rateLimitResponse()
}
try {
return await forwardWorkspaceRequest({
request,
path: `/checkpoints/${params.id}/approve-and-merge`,
})
} catch (error) {
return json(
{ ok: false, error: safeErrorMessage(error) },
{ status: 502 },
)
}
},
},
},
})

View File

@@ -1,45 +0,0 @@
import { createFileRoute } from '@tanstack/react-router'
import { json } from '@tanstack/react-start'
import { isAuthenticated } from '../../../server/auth-middleware'
import {
getClientIp,
rateLimit,
rateLimitResponse,
requireJsonContentType,
safeErrorMessage,
} from '../../../server/rate-limit'
import { forwardWorkspaceRequest } from '../../../server/workspace-proxy'
export const Route = createFileRoute(
'/api/workspace/checkpoints/$id/approve-and-pr',
)({
server: {
handlers: {
POST: async ({ request, params }) => {
if (!isAuthenticated(request)) {
return json({ ok: false, error: 'Unauthorized' }, { status: 401 })
}
const csrfCheck = requireJsonContentType(request)
if (csrfCheck) return csrfCheck
const ip = getClientIp(request)
if (!rateLimit(`workspace-checkpoint-approve-pr:${ip}`, 30, 60_000)) {
return rateLimitResponse()
}
try {
return await forwardWorkspaceRequest({
request,
path: `/checkpoints/${params.id}/approve-and-pr`,
})
} catch (error) {
return json(
{ ok: false, error: safeErrorMessage(error) },
{ status: 502 },
)
}
},
},
},
})

View File

@@ -1,43 +0,0 @@
import { createFileRoute } from '@tanstack/react-router'
import { json } from '@tanstack/react-start'
import { isAuthenticated } from '../../../server/auth-middleware'
import {
getClientIp,
rateLimit,
rateLimitResponse,
requireJsonContentType,
safeErrorMessage,
} from '../../../server/rate-limit'
import { forwardWorkspaceRequest } from '../../../server/workspace-proxy'
export const Route = createFileRoute('/api/workspace/checkpoints/$id/approve')({
server: {
handlers: {
POST: async ({ request, params }) => {
if (!isAuthenticated(request)) {
return json({ ok: false, error: 'Unauthorized' }, { status: 401 })
}
const csrfCheck = requireJsonContentType(request)
if (csrfCheck) return csrfCheck
const ip = getClientIp(request)
if (!rateLimit(`workspace-checkpoint-approve:${ip}`, 30, 60_000)) {
return rateLimitResponse()
}
try {
return await forwardWorkspaceRequest({
request,
path: `/checkpoints/${params.id}/approve`,
})
} catch (error) {
return json(
{ ok: false, error: safeErrorMessage(error) },
{ status: 502 },
)
}
},
},
},
})

View File

@@ -1,39 +0,0 @@
import { createFileRoute } from '@tanstack/react-router'
import { json } from '@tanstack/react-start'
import { isAuthenticated } from '../../../server/auth-middleware'
import {
getClientIp,
rateLimit,
rateLimitResponse,
safeErrorMessage,
} from '../../../server/rate-limit'
import { forwardWorkspaceRequest } from '../../../server/workspace-proxy'
export const Route = createFileRoute('/api/workspace/checkpoints/$id/diff')({
server: {
handlers: {
GET: async ({ request, params }) => {
if (!isAuthenticated(request)) {
return json({ ok: false, error: 'Unauthorized' }, { status: 401 })
}
const ip = getClientIp(request)
if (!rateLimit(`workspace-checkpoint-diff:${ip}`, 60, 60_000)) {
return rateLimitResponse()
}
try {
return await forwardWorkspaceRequest({
request,
path: `/checkpoints/${params.id}/diff`,
})
} catch (error) {
return json(
{ ok: false, error: safeErrorMessage(error) },
{ status: 502 },
)
}
},
},
},
})

View File

@@ -1,43 +0,0 @@
import { createFileRoute } from '@tanstack/react-router'
import { json } from '@tanstack/react-start'
import { isAuthenticated } from '../../../server/auth-middleware'
import {
getClientIp,
rateLimit,
rateLimitResponse,
requireJsonContentType,
safeErrorMessage,
} from '../../../server/rate-limit'
import { forwardWorkspaceRequest } from '../../../server/workspace-proxy'
export const Route = createFileRoute('/api/workspace/checkpoints/$id/reject')({
server: {
handlers: {
POST: async ({ request, params }) => {
if (!isAuthenticated(request)) {
return json({ ok: false, error: 'Unauthorized' }, { status: 401 })
}
const csrfCheck = requireJsonContentType(request)
if (csrfCheck) return csrfCheck
const ip = getClientIp(request)
if (!rateLimit(`workspace-checkpoint-reject:${ip}`, 30, 60_000)) {
return rateLimitResponse()
}
try {
return await forwardWorkspaceRequest({
request,
path: `/checkpoints/${params.id}/reject`,
})
} catch (error) {
return json(
{ ok: false, error: safeErrorMessage(error) },
{ status: 502 },
)
}
},
},
},
})

View File

@@ -1,43 +0,0 @@
import { createFileRoute } from '@tanstack/react-router'
import { json } from '@tanstack/react-start'
import { isAuthenticated } from '../../../server/auth-middleware'
import {
getClientIp,
rateLimit,
rateLimitResponse,
requireJsonContentType,
safeErrorMessage,
} from '../../../server/rate-limit'
import { forwardWorkspaceRequest } from '../../../server/workspace-proxy'
export const Route = createFileRoute('/api/workspace/checkpoints/$id/revise')({
server: {
handlers: {
POST: async ({ request, params }) => {
if (!isAuthenticated(request)) {
return json({ ok: false, error: 'Unauthorized' }, { status: 401 })
}
const csrfCheck = requireJsonContentType(request)
if (csrfCheck) return csrfCheck
const ip = getClientIp(request)
if (!rateLimit(`workspace-checkpoint-revise:${ip}`, 30, 60_000)) {
return rateLimitResponse()
}
try {
return await forwardWorkspaceRequest({
request,
path: `/checkpoints/${params.id}/revise`,
})
} catch (error) {
return json(
{ ok: false, error: safeErrorMessage(error) },
{ status: 502 },
)
}
},
},
},
})

View File

@@ -1,39 +0,0 @@
import { createFileRoute } from '@tanstack/react-router'
import { json } from '@tanstack/react-start'
import { isAuthenticated } from '../../../server/auth-middleware'
import {
getClientIp,
rateLimit,
rateLimitResponse,
safeErrorMessage,
} from '../../../server/rate-limit'
import { forwardWorkspaceRequest } from '../../../server/workspace-proxy'
export const Route = createFileRoute('/api/workspace/checkpoints/$id')({
server: {
handlers: {
GET: async ({ request, params }) => {
if (!isAuthenticated(request)) {
return json({ ok: false, error: 'Unauthorized' }, { status: 401 })
}
const ip = getClientIp(request)
if (!rateLimit(`workspace-checkpoint-detail:${ip}`, 120, 60_000)) {
return rateLimitResponse()
}
try {
return await forwardWorkspaceRequest({
request,
path: `/checkpoints/${params.id}`,
})
} catch (error) {
return json(
{ ok: false, error: safeErrorMessage(error) },
{ status: 502 },
)
}
},
},
},
})

View File

@@ -1,45 +0,0 @@
import { createFileRoute } from '@tanstack/react-router'
import { json } from '@tanstack/react-start'
import { isAuthenticated } from '../../../server/auth-middleware'
import {
getClientIp,
rateLimit,
rateLimitResponse,
requireJsonContentType,
safeErrorMessage,
} from '../../../server/rate-limit'
import { forwardWorkspaceRequest } from '../../../server/workspace-proxy'
export const Route = createFileRoute(
'/api/workspace/checkpoints/$id/verify-tsc',
)({
server: {
handlers: {
POST: async ({ request, params }) => {
if (!isAuthenticated(request)) {
return json({ ok: false, error: 'Unauthorized' }, { status: 401 })
}
const csrfCheck = requireJsonContentType(request)
if (csrfCheck) return csrfCheck
const ip = getClientIp(request)
if (!rateLimit(`workspace-checkpoint-verify-tsc:${ip}`, 20, 60_000)) {
return rateLimitResponse()
}
try {
return await forwardWorkspaceRequest({
request,
path: `/checkpoints/${params.id}/verify-tsc`,
})
} catch (error) {
return json(
{ ok: false, error: safeErrorMessage(error) },
{ status: 502 },
)
}
},
},
},
})

View File

@@ -1,41 +0,0 @@
import { createFileRoute } from '@tanstack/react-router'
import { json } from '@tanstack/react-start'
import { isAuthenticated } from '../../../server/auth-middleware'
import {
getClientIp,
rateLimit,
rateLimitResponse,
safeErrorMessage,
} from '../../../server/rate-limit'
import { forwardWorkspaceRequest } from '../../../server/workspace-proxy'
export const Route = createFileRoute('/api/workspace/checkpoints')({
server: {
handlers: {
GET: async ({ request }) => {
if (!isAuthenticated(request)) {
return json({ ok: false, error: 'Unauthorized' }, { status: 401 })
}
const ip = getClientIp(request)
if (!rateLimit(`workspace-checkpoints-get:${ip}`, 120, 60_000)) {
return rateLimitResponse()
}
try {
const url = new URL(request.url)
return await forwardWorkspaceRequest({
request,
path: '/checkpoints',
searchParams: url.searchParams,
})
} catch (error) {
return json(
{ ok: false, error: safeErrorMessage(error) },
{ status: 502 },
)
}
},
},
},
})

View File

@@ -1,66 +0,0 @@
import { createFileRoute } from '@tanstack/react-router'
import { json } from '@tanstack/react-start'
import { isAuthenticated } from '../../../server/auth-middleware'
import {
getClientIp,
rateLimit,
rateLimitResponse,
requireJsonContentType,
safeErrorMessage,
} from '../../../server/rate-limit'
import { forwardWorkspaceRequest } from '../../../server/workspace-proxy'
export const Route = createFileRoute('/api/workspace/config')({
server: {
handlers: {
GET: async ({ request }) => {
if (!isAuthenticated(request)) {
return json({ ok: false, error: 'Unauthorized' }, { status: 401 })
}
const ip = getClientIp(request)
if (!rateLimit(`workspace-config-get:${ip}`, 120, 60_000)) {
return rateLimitResponse()
}
try {
return await forwardWorkspaceRequest({
request,
path: '/config',
})
} catch (error) {
return json(
{ ok: false, error: safeErrorMessage(error) },
{ status: 502 },
)
}
},
PATCH: async ({ request }) => {
if (!isAuthenticated(request)) {
return json({ ok: false, error: 'Unauthorized' }, { status: 401 })
}
const csrfCheck = requireJsonContentType(request)
if (csrfCheck) return csrfCheck
const ip = getClientIp(request)
if (!rateLimit(`workspace-config-patch:${ip}`, 30, 60_000)) {
return rateLimitResponse()
}
try {
return await forwardWorkspaceRequest({
request,
path: '/config',
})
} catch (error) {
return json(
{ ok: false, error: safeErrorMessage(error) },
{ status: 502 },
)
}
},
},
},
})

View File

@@ -1,43 +0,0 @@
import { createFileRoute } from '@tanstack/react-router'
import { json } from '@tanstack/react-start'
import { isAuthenticated } from '../../../server/auth-middleware'
import {
getClientIp,
rateLimit,
rateLimitResponse,
requireJsonContentType,
safeErrorMessage,
} from '../../../server/rate-limit'
import { forwardWorkspaceRequest } from '../../../server/workspace-proxy'
export const Route = createFileRoute('/api/workspace/decompose')({
server: {
handlers: {
POST: async ({ request }) => {
if (!isAuthenticated(request)) {
return json({ ok: false, error: 'Unauthorized' }, { status: 401 })
}
const csrfCheck = requireJsonContentType(request)
if (csrfCheck) return csrfCheck
const ip = getClientIp(request)
if (!rateLimit(`workspace-decompose-post:${ip}`, 10, 60_000)) {
return rateLimitResponse()
}
try {
return await forwardWorkspaceRequest({
request,
path: '/decompose',
})
} catch (error) {
return json(
{ ok: false, error: safeErrorMessage(error) },
{ status: 502 },
)
}
},
},
},
})

View File

@@ -1,41 +0,0 @@
import { createFileRoute } from '@tanstack/react-router'
import { json } from '@tanstack/react-start'
import { isAuthenticated } from '../../../server/auth-middleware'
import {
getClientIp,
rateLimit,
rateLimitResponse,
safeErrorMessage,
} from '../../../server/rate-limit'
import { forwardWorkspaceRequest } from '../../../server/workspace-proxy'
export const Route = createFileRoute('/api/workspace/events')({
server: {
handlers: {
GET: async ({ request }) => {
if (!isAuthenticated(request)) {
return json({ ok: false, error: 'Unauthorized' }, { status: 401 })
}
const ip = getClientIp(request)
if (!rateLimit(`workspace-events-get:${ip}`, 120, 60_000)) {
return rateLimitResponse()
}
try {
const url = new URL(request.url)
return await forwardWorkspaceRequest({
request,
path: '/events',
searchParams: url.searchParams,
})
} catch (error) {
return json(
{ ok: false, error: safeErrorMessage(error) },
{ status: 502 },
)
}
},
},
},
})

View File

@@ -1,182 +0,0 @@
import fs from 'node:fs/promises'
import os from 'node:os'
import path from 'node:path'
import { createFileRoute } from '@tanstack/react-router'
import { json } from '@tanstack/react-start'
import { isAuthenticated } from '../../../server/auth-middleware'
import {
getClientIp,
rateLimit,
rateLimitResponse,
safeErrorMessage,
} from '../../../server/rate-limit'
type MemorySection = 'workspace' | 'project' | 'agent'
type MemoryFileRecord = {
name: string
path: string
size: string
section: MemorySection
}
type DateStampedFile = {
name: string
fullPath: string
time: number
}
function getWorkspaceRoot(): string {
const configured = (
process.env.HERMES_WORKSPACE ||
process.env.OPENCLAW_WORKSPACE ||
''
).trim()
return path.resolve(
configured || path.join(os.homedir(), '.hermes'),
)
}
function formatKilobytes(bytes: number): string {
const kb = bytes / 1024
return `${kb < 10 ? kb.toFixed(1) : kb.toFixed(0)} KB`
}
async function statFile(
fullPath: string,
): Promise<{ size: number; mtimeMs: number } | null> {
try {
const stats = await fs.stat(fullPath)
if (!stats.isFile()) return null
return {
size: stats.size,
mtimeMs: stats.mtimeMs,
}
} catch {
return null
}
}
async function maybeFileRecord(
workspaceRoot: string,
relativePath: string,
section: MemorySection,
): Promise<MemoryFileRecord | null> {
const fullPath = path.join(workspaceRoot, relativePath)
const stats = await statFile(fullPath)
if (!stats) return null
return {
name: path.basename(relativePath),
path: relativePath,
size: formatKilobytes(stats.size),
section,
}
}
function extractDateStamp(name: string): number {
const match = name.match(/(\d{4}-\d{2}-\d{2})/)
if (!match) return Number.NaN
const parsed = Date.parse(match[1]!)
return Number.isNaN(parsed) ? Number.NaN : parsed
}
async function getLatestDailyLogs(workspaceRoot: string): Promise<Array<MemoryFileRecord>> {
const memoryRoot = path.join(workspaceRoot, 'memory')
let entries: Array<string>
try {
entries = await fs.readdir(memoryRoot)
} catch {
return []
}
const candidates: Array<DateStampedFile> = []
for (const name of entries) {
if (!name.toLowerCase().endsWith('.md')) continue
const fullPath = path.join(memoryRoot, name)
const stats = await statFile(fullPath)
if (!stats) continue
candidates.push({
name,
fullPath,
time: Number.isNaN(extractDateStamp(name))
? stats.mtimeMs
: extractDateStamp(name),
})
}
candidates.sort((a, b) => b.time - a.time || a.name.localeCompare(b.name))
const topThree = candidates.slice(0, 3)
const items = await Promise.all(
topThree.map(async (entry) => {
const stats = await statFile(entry.fullPath)
return stats
? {
name: entry.name,
path: `memory/${entry.name}`,
size: formatKilobytes(stats.size),
section: 'project' as const,
}
: null
}),
)
return items.filter(
(item): item is Extract<(typeof items)[number], MemoryFileRecord> =>
item !== null,
)
}
export const Route = createFileRoute('/api/workspace/memory-files')({
server: {
handlers: {
GET: async ({ request }) => {
if (!isAuthenticated(request)) {
return json({ ok: false, error: 'Unauthorized' }, { status: 401 })
}
const ip = getClientIp(request)
if (!rateLimit(`workspace-memory-files-get:${ip}`, 120, 60_000)) {
return rateLimitResponse()
}
try {
const workspaceRoot = getWorkspaceRoot()
const [memoryMd, soulMd, agentsMd, userMd, learningsMd, errorsMd, dailyLogs] =
await Promise.all([
maybeFileRecord(workspaceRoot, 'MEMORY.md', 'workspace'),
maybeFileRecord(workspaceRoot, 'SOUL.md', 'workspace'),
maybeFileRecord(workspaceRoot, 'AGENTS.md', 'workspace'),
maybeFileRecord(workspaceRoot, 'USER.md', 'workspace'),
maybeFileRecord(workspaceRoot, '.learnings/LEARNINGS.md', 'agent'),
maybeFileRecord(workspaceRoot, '.learnings/ERRORS.md', 'agent'),
getLatestDailyLogs(workspaceRoot),
])
const files = [
memoryMd,
soulMd,
agentsMd,
userMd,
...dailyLogs,
learningsMd,
errorsMd,
].filter((item): item is MemoryFileRecord => Boolean(item))
return json({ files })
} catch (error) {
return json(
{ ok: false, error: safeErrorMessage(error) },
{ status: 500 },
)
}
},
},
},
})

View File

@@ -1,43 +0,0 @@
import { createFileRoute } from '@tanstack/react-router'
import { json } from '@tanstack/react-start'
import { isAuthenticated } from '../../../server/auth-middleware'
import {
getClientIp,
rateLimit,
rateLimitResponse,
requireJsonContentType,
safeErrorMessage,
} from '../../../server/rate-limit'
import { forwardWorkspaceRequest } from '../../../server/workspace-proxy'
export const Route = createFileRoute('/api/workspace/missions/$id/pause')({
server: {
handlers: {
POST: async ({ request, params }) => {
if (!isAuthenticated(request)) {
return json({ ok: false, error: 'Unauthorized' }, { status: 401 })
}
const csrfCheck = requireJsonContentType(request)
if (csrfCheck) return csrfCheck
const ip = getClientIp(request)
if (!rateLimit(`workspace-mission-pause:${ip}`, 20, 60_000)) {
return rateLimitResponse()
}
try {
return await forwardWorkspaceRequest({
request,
path: `/missions/${params.id}/pause`,
})
} catch (error) {
return json(
{ ok: false, error: safeErrorMessage(error) },
{ status: 502 },
)
}
},
},
},
})

View File

@@ -1,43 +0,0 @@
import { createFileRoute } from '@tanstack/react-router'
import { json } from '@tanstack/react-start'
import { isAuthenticated } from '../../../server/auth-middleware'
import {
getClientIp,
rateLimit,
rateLimitResponse,
requireJsonContentType,
safeErrorMessage,
} from '../../../server/rate-limit'
import { forwardWorkspaceRequest } from '../../../server/workspace-proxy'
export const Route = createFileRoute('/api/workspace/missions/$id/resume')({
server: {
handlers: {
POST: async ({ request, params }) => {
if (!isAuthenticated(request)) {
return json({ ok: false, error: 'Unauthorized' }, { status: 401 })
}
const csrfCheck = requireJsonContentType(request)
if (csrfCheck) return csrfCheck
const ip = getClientIp(request)
if (!rateLimit(`workspace-mission-resume:${ip}`, 20, 60_000)) {
return rateLimitResponse()
}
try {
return await forwardWorkspaceRequest({
request,
path: `/missions/${params.id}/resume`,
})
} catch (error) {
return json(
{ ok: false, error: safeErrorMessage(error) },
{ status: 502 },
)
}
},
},
},
})

View File

@@ -1,43 +0,0 @@
import { createFileRoute } from '@tanstack/react-router'
import { json } from '@tanstack/react-start'
import { isAuthenticated } from '../../../server/auth-middleware'
import {
getClientIp,
rateLimit,
rateLimitResponse,
requireJsonContentType,
safeErrorMessage,
} from '../../../server/rate-limit'
import { forwardWorkspaceRequest } from '../../../server/workspace-proxy'
export const Route = createFileRoute('/api/workspace/missions/$id/start')({
server: {
handlers: {
POST: async ({ request, params }) => {
if (!isAuthenticated(request)) {
return json({ ok: false, error: 'Unauthorized' }, { status: 401 })
}
const csrfCheck = requireJsonContentType(request)
if (csrfCheck) return csrfCheck
const ip = getClientIp(request)
if (!rateLimit(`workspace-mission-start:${ip}`, 20, 60_000)) {
return rateLimitResponse()
}
try {
return await forwardWorkspaceRequest({
request,
path: `/missions/${params.id}/start`,
})
} catch (error) {
return json(
{ ok: false, error: safeErrorMessage(error) },
{ status: 502 },
)
}
},
},
},
})

View File

@@ -1,39 +0,0 @@
import { createFileRoute } from '@tanstack/react-router'
import { json } from '@tanstack/react-start'
import { isAuthenticated } from '../../../server/auth-middleware'
import {
getClientIp,
rateLimit,
rateLimitResponse,
safeErrorMessage,
} from '../../../server/rate-limit'
import { forwardWorkspaceRequest } from '../../../server/workspace-proxy'
export const Route = createFileRoute('/api/workspace/missions/$id/status')({
server: {
handlers: {
GET: async ({ request, params }) => {
if (!isAuthenticated(request)) {
return json({ ok: false, error: 'Unauthorized' }, { status: 401 })
}
const ip = getClientIp(request)
if (!rateLimit(`workspace-mission-status:${ip}`, 120, 60_000)) {
return rateLimitResponse()
}
try {
return await forwardWorkspaceRequest({
request,
path: `/missions/${params.id}/status`,
})
} catch (error) {
return json(
{ ok: false, error: safeErrorMessage(error) },
{ status: 502 },
)
}
},
},
},
})

View File

@@ -1,43 +0,0 @@
import { createFileRoute } from '@tanstack/react-router'
import { json } from '@tanstack/react-start'
import { isAuthenticated } from '../../../server/auth-middleware'
import {
getClientIp,
rateLimit,
rateLimitResponse,
requireJsonContentType,
safeErrorMessage,
} from '../../../server/rate-limit'
import { forwardWorkspaceRequest } from '../../../server/workspace-proxy'
export const Route = createFileRoute('/api/workspace/missions/$id/stop')({
server: {
handlers: {
POST: async ({ request, params }) => {
if (!isAuthenticated(request)) {
return json({ ok: false, error: 'Unauthorized' }, { status: 401 })
}
const csrfCheck = requireJsonContentType(request)
if (csrfCheck) return csrfCheck
const ip = getClientIp(request)
if (!rateLimit(`workspace-mission-stop:${ip}`, 20, 60_000)) {
return rateLimitResponse()
}
try {
return await forwardWorkspaceRequest({
request,
path: `/missions/${params.id}/stop`,
})
} catch (error) {
return json(
{ ok: false, error: safeErrorMessage(error) },
{ status: 502 },
)
}
},
},
},
})

View File

@@ -1,65 +0,0 @@
import { createFileRoute } from '@tanstack/react-router'
import { json } from '@tanstack/react-start'
import { isAuthenticated } from '../../../server/auth-middleware'
import {
getClientIp,
rateLimit,
rateLimitResponse,
requireJsonContentType,
safeErrorMessage,
} from '../../../server/rate-limit'
import { forwardWorkspaceRequest } from '../../../server/workspace-proxy'
export const Route = createFileRoute('/api/workspace/missions')({
server: {
handlers: {
GET: async ({ request }) => {
if (!isAuthenticated(request)) {
return json({ ok: false, error: 'Unauthorized' }, { status: 401 })
}
const ip = getClientIp(request)
if (!rateLimit(`workspace-missions-get:${ip}`, 120, 60_000)) {
return rateLimitResponse()
}
try {
return await forwardWorkspaceRequest({
request,
path: '/missions',
})
} catch (error) {
return json(
{ ok: false, error: safeErrorMessage(error) },
{ status: 502 },
)
}
},
POST: async ({ request }) => {
if (!isAuthenticated(request)) {
return json({ ok: false, error: 'Unauthorized' }, { status: 401 })
}
const csrfCheck = requireJsonContentType(request)
if (csrfCheck) return csrfCheck
const ip = getClientIp(request)
if (!rateLimit(`workspace-missions-post:${ip}`, 30, 60_000)) {
return rateLimitResponse()
}
try {
return await forwardWorkspaceRequest({
request,
path: '/missions',
})
} catch (error) {
return json(
{ ok: false, error: safeErrorMessage(error) },
{ status: 502 },
)
}
},
},
},
})

View File

@@ -1,43 +0,0 @@
import { createFileRoute } from '@tanstack/react-router'
import { json } from '@tanstack/react-start'
import { isAuthenticated } from '../../../server/auth-middleware'
import {
getClientIp,
rateLimit,
rateLimitResponse,
requireJsonContentType,
safeErrorMessage,
} from '../../../server/rate-limit'
import { forwardWorkspaceRequest } from '../../../server/workspace-proxy'
export const Route = createFileRoute('/api/workspace/phases')({
server: {
handlers: {
POST: async ({ request }) => {
if (!isAuthenticated(request)) {
return json({ ok: false, error: 'Unauthorized' }, { status: 401 })
}
const csrfCheck = requireJsonContentType(request)
if (csrfCheck) return csrfCheck
const ip = getClientIp(request)
if (!rateLimit(`workspace-phases-post:${ip}`, 30, 60_000)) {
return rateLimitResponse()
}
try {
return await forwardWorkspaceRequest({
request,
path: '/phases',
})
} catch (error) {
return json(
{ ok: false, error: safeErrorMessage(error) },
{ status: 502 },
)
}
},
},
},
})

View File

@@ -1,115 +0,0 @@
import { createFileRoute } from '@tanstack/react-router'
import { json } from '@tanstack/react-start'
import { isAuthenticated } from '../../../server/auth-middleware'
import {
getClientIp,
rateLimit,
rateLimitResponse,
requireJsonContentType,
safeErrorMessage,
} from '../../../server/rate-limit'
import { forwardWorkspaceRequest } from '../../../server/workspace-proxy'
export const Route = createFileRoute('/api/workspace/projects/$id')({
server: {
handlers: {
GET: async ({ request, params }) => {
if (!isAuthenticated(request)) {
return json({ ok: false, error: 'Unauthorized' }, { status: 401 })
}
const ip = getClientIp(request)
if (!rateLimit(`workspace-project-get:${ip}`, 120, 60_000)) {
return rateLimitResponse()
}
try {
return await forwardWorkspaceRequest({
request,
path: `/projects/${params.id}`,
})
} catch (error) {
return json(
{ ok: false, error: safeErrorMessage(error) },
{ status: 502 },
)
}
},
PUT: async ({ request, params }) => {
if (!isAuthenticated(request)) {
return json({ ok: false, error: 'Unauthorized' }, { status: 401 })
}
const csrfCheck = requireJsonContentType(request)
if (csrfCheck) return csrfCheck
const ip = getClientIp(request)
if (!rateLimit(`workspace-project-put:${ip}`, 30, 60_000)) {
return rateLimitResponse()
}
try {
return await forwardWorkspaceRequest({
request,
path: `/projects/${params.id}`,
})
} catch (error) {
return json(
{ ok: false, error: safeErrorMessage(error) },
{ status: 502 },
)
}
},
PATCH: async ({ request, params }) => {
if (!isAuthenticated(request)) {
return json({ ok: false, error: 'Unauthorized' }, { status: 401 })
}
const csrfCheck = requireJsonContentType(request)
if (csrfCheck) return csrfCheck
const ip = getClientIp(request)
if (!rateLimit(`workspace-project-patch:${ip}`, 30, 60_000)) {
return rateLimitResponse()
}
try {
return await forwardWorkspaceRequest({
request,
path: `/projects/${params.id}`,
})
} catch (error) {
return json(
{ ok: false, error: safeErrorMessage(error) },
{ status: 502 },
)
}
},
DELETE: async ({ request, params }) => {
if (!isAuthenticated(request)) {
return json({ ok: false, error: 'Unauthorized' }, { status: 401 })
}
const ip = getClientIp(request)
if (!rateLimit(`workspace-project-delete:${ip}`, 20, 60_000)) {
return rateLimitResponse()
}
try {
return await forwardWorkspaceRequest({
request,
path: `/projects/${params.id}`,
})
} catch (error) {
return json(
{ ok: false, error: safeErrorMessage(error) },
{ status: 502 },
)
}
},
},
},
})

View File

@@ -1,66 +0,0 @@
import { createFileRoute } from '@tanstack/react-router'
import { json } from '@tanstack/react-start'
import { isAuthenticated } from '../../../server/auth-middleware'
import {
getClientIp,
rateLimit,
rateLimitResponse,
requireJsonContentType,
safeErrorMessage,
} from '../../../server/rate-limit'
import { forwardWorkspaceRequest } from '../../../server/workspace-proxy'
export const Route = createFileRoute('/api/workspace/projects')({
server: {
handlers: {
GET: async ({ request }) => {
if (!isAuthenticated(request)) {
return json({ ok: false, error: 'Unauthorized' }, { status: 401 })
}
const ip = getClientIp(request)
if (!rateLimit(`workspace-projects-get:${ip}`, 120, 60_000)) {
return rateLimitResponse()
}
try {
return await forwardWorkspaceRequest({
request,
path: '/projects',
})
} catch (error) {
return json(
{ ok: false, error: safeErrorMessage(error) },
{ status: 502 },
)
}
},
POST: async ({ request }) => {
if (!isAuthenticated(request)) {
return json({ ok: false, error: 'Unauthorized' }, { status: 401 })
}
const csrfCheck = requireJsonContentType(request)
if (csrfCheck) return csrfCheck
const ip = getClientIp(request)
if (!rateLimit(`workspace-projects-post:${ip}`, 30, 60_000)) {
return rateLimitResponse()
}
try {
return await forwardWorkspaceRequest({
request,
path: '/projects',
})
} catch (error) {
return json(
{ ok: false, error: safeErrorMessage(error) },
{ status: 502 },
)
}
},
},
},
})

View File

@@ -1,39 +0,0 @@
import { createFileRoute } from '@tanstack/react-router'
import { json } from '@tanstack/react-start'
import { isAuthenticated } from '../../../server/auth-middleware'
import {
getClientIp,
rateLimit,
rateLimitResponse,
safeErrorMessage,
} from '../../../server/rate-limit'
import { forwardWorkspaceRequest } from '../../../server/workspace-proxy'
export const Route = createFileRoute('/api/workspace/skills/$id/content')({
server: {
handlers: {
GET: async ({ request, params }) => {
if (!isAuthenticated(request)) {
return json({ ok: false, error: 'Unauthorized' }, { status: 401 })
}
const ip = getClientIp(request)
if (!rateLimit(`workspace-skill-content-get:${ip}`, 120, 60_000)) {
return rateLimitResponse()
}
try {
return await forwardWorkspaceRequest({
request,
path: `/skills/${params.id}/content`,
})
} catch (error) {
return json(
{ ok: false, error: safeErrorMessage(error) },
{ status: 502 },
)
}
},
},
},
})

View File

@@ -1,39 +0,0 @@
import { createFileRoute } from '@tanstack/react-router'
import { json } from '@tanstack/react-start'
import { isAuthenticated } from '../../../server/auth-middleware'
import {
getClientIp,
rateLimit,
rateLimitResponse,
safeErrorMessage,
} from '../../../server/rate-limit'
import { forwardWorkspaceRequest } from '../../../server/workspace-proxy'
export const Route = createFileRoute('/api/workspace/skills')({
server: {
handlers: {
GET: async ({ request }) => {
if (!isAuthenticated(request)) {
return json({ ok: false, error: 'Unauthorized' }, { status: 401 })
}
const ip = getClientIp(request)
if (!rateLimit(`workspace-skills-get:${ip}`, 120, 60_000)) {
return rateLimitResponse()
}
try {
return await forwardWorkspaceRequest({
request,
path: '/skills',
})
} catch (error) {
return json(
{ ok: false, error: safeErrorMessage(error) },
{ status: 502 },
)
}
},
},
},
})

View File

@@ -1,202 +0,0 @@
import { createFileRoute } from '@tanstack/react-router'
import { json } from '@tanstack/react-start'
import { isAuthenticated } from '../../../server/auth-middleware'
import { WORKSPACE_DAEMON_ORIGIN } from '../../../server/workspace-config'
import {
getClientIp,
rateLimit,
rateLimitResponse,
safeErrorMessage,
} from '../../../server/rate-limit'
type ProjectRecord = {
id?: string
}
type TaskRecord = {
status?: string | null
}
type MissionRecord = {
tasks?: Array<TaskRecord> | null
}
type PhaseRecord = {
missions?: Array<MissionRecord> | null
}
type ProjectDetailRecord = {
phases?: Array<PhaseRecord> | null
}
type AgentRecord = {
status?: string | null
}
type TaskRunRecord = {
started_at?: string | null
completed_at?: string | null
cost_cents?: number | null
}
function asRecord(value: unknown): Record<string, unknown> | null {
if (!value || typeof value !== 'object' || Array.isArray(value)) return null
return value as Record<string, unknown>
}
function parseJson(text: string): unknown {
if (!text) return null
try {
return JSON.parse(text) as unknown
} catch {
return text
}
}
async function daemonJson(path: string): Promise<unknown> {
const response = await fetch(new URL(`/api/workspace${path}`, WORKSPACE_DAEMON_ORIGIN), {
headers: {
accept: 'application/json',
},
})
const text = await response.text()
const payload = parseJson(text)
if (!response.ok) {
const record = asRecord(payload)
throw new Error(
typeof record?.error === 'string'
? record.error
: typeof record?.message === 'string'
? record.message
: `Workspace daemon request failed with status ${response.status}`,
)
}
return payload
}
function extractItems<T>(payload: unknown, key: string): Array<T> {
if (Array.isArray(payload)) return payload as Array<T>
const record = asRecord(payload)
const direct = record?.[key]
if (Array.isArray(direct)) return direct as Array<T>
if (Array.isArray(record?.data)) return record.data as Array<T>
if (Array.isArray(record?.items)) return record.items as Array<T>
return []
}
function extractProjectIds(payload: unknown): string[] {
return extractItems<ProjectRecord>(payload, 'projects')
.map((project) => (typeof project?.id === 'string' ? project.id : null))
.filter((id): id is string => Boolean(id))
}
function extractTasksFromProject(detail: unknown): Array<TaskRecord> {
const project = asRecord(detail) as ProjectDetailRecord | null
const phases = Array.isArray(project?.phases) ? project.phases : []
return phases.flatMap((phase) =>
(Array.isArray(phase?.missions) ? phase.missions : []).flatMap((mission) =>
Array.isArray(mission?.tasks) ? mission.tasks : [],
),
)
}
function normalizeTaskBucket(status: string | null | undefined): 'running' | 'queued' | 'paused' | null {
const normalized = status?.trim().toLowerCase()
if (!normalized) return null
if (['running', 'in_progress', 'active'].includes(normalized)) return 'running'
if (['pending', 'ready', 'queued', 'waiting'].includes(normalized)) return 'queued'
if (['paused', 'blocked', 'on_hold', 'hold'].includes(normalized)) return 'paused'
return null
}
function isToday(value: string | null | undefined): boolean {
if (!value) return false
const date = new Date(value)
if (Number.isNaN(date.getTime())) return false
const now = new Date()
return date.toDateString() === now.toDateString()
}
export const Route = createFileRoute('/api/workspace/stats')({
server: {
handlers: {
GET: async ({ request }) => {
if (!isAuthenticated(request)) {
return json({ ok: false, error: 'Unauthorized' }, { status: 401 })
}
const ip = getClientIp(request)
if (!rateLimit(`workspace-stats-get:${ip}`, 120, 60_000)) {
return rateLimitResponse()
}
try {
const [projectsPayload, agentsPayload, checkpointsPayload, taskRunsPayload] =
await Promise.all([
daemonJson('/projects'),
daemonJson('/agents'),
daemonJson('/checkpoints?status=pending'),
daemonJson('/task-runs'),
])
const projectIds = extractProjectIds(projectsPayload)
const agents = extractItems<AgentRecord>(agentsPayload, 'agents')
const checkpoints = extractItems<Record<string, unknown>>(
checkpointsPayload,
'checkpoints',
)
const taskRuns = extractItems<TaskRunRecord>(taskRunsPayload, 'task_runs')
const projectDetails = await Promise.all(
projectIds.map((projectId) =>
daemonJson(`/projects/${encodeURIComponent(projectId)}`),
),
)
let running = 0
let queued = 0
let paused = 0
for (const detail of projectDetails) {
for (const task of extractTasksFromProject(detail)) {
const bucket = normalizeTaskBucket(task?.status)
if (bucket === 'running') running += 1
if (bucket === 'queued') queued += 1
if (bucket === 'paused') paused += 1
}
}
const agentsOnline = agents.filter(
(agent) => (agent?.status ?? 'offline') !== 'offline',
).length
const costToday = taskRuns.reduce((total, run) => {
if (!isToday(run?.started_at ?? run?.completed_at)) return total
return total + (typeof run?.cost_cents === 'number' ? run.cost_cents / 100 : 0)
}, 0)
return json({
projects: projectIds.length,
agentsOnline,
agentsTotal: agents.length,
running,
queued,
paused,
checkpointsPending: checkpoints.length,
policyAlerts: 0,
costToday,
})
} catch (error) {
return json(
{ ok: false, error: safeErrorMessage(error) },
{ status: 502 },
)
}
},
},
},
})

View File

@@ -1,39 +0,0 @@
import { createFileRoute } from '@tanstack/react-router'
import { json } from '@tanstack/react-start'
import { isAuthenticated } from '../../../server/auth-middleware'
import {
getClientIp,
rateLimit,
rateLimitResponse,
safeErrorMessage,
} from '../../../server/rate-limit'
import { forwardWorkspaceRequest } from '../../../server/workspace-proxy'
export const Route = createFileRoute('/api/workspace/task-runs/$id/events')({
server: {
handlers: {
GET: async ({ request, params }) => {
if (!isAuthenticated(request)) {
return json({ ok: false, error: 'Unauthorized' }, { status: 401 })
}
const ip = getClientIp(request)
if (!rateLimit(`workspace-task-run-events-get:${ip}`, 120, 60_000)) {
return rateLimitResponse()
}
try {
return await forwardWorkspaceRequest({
request,
path: `/task-runs/${params.id}/events`,
})
} catch (error) {
return json(
{ ok: false, error: safeErrorMessage(error) },
{ status: 502 },
)
}
},
},
},
})

View File

@@ -1,43 +0,0 @@
import { createFileRoute } from '@tanstack/react-router'
import { json } from '@tanstack/react-start'
import { isAuthenticated } from '../../../server/auth-middleware'
import {
getClientIp,
rateLimit,
rateLimitResponse,
requireJsonContentType,
safeErrorMessage,
} from '../../../server/rate-limit'
import { forwardWorkspaceRequest } from '../../../server/workspace-proxy'
export const Route = createFileRoute('/api/workspace/task-runs/$id/pause')({
server: {
handlers: {
POST: async ({ request, params }) => {
if (!isAuthenticated(request)) {
return json({ ok: false, error: 'Unauthorized' }, { status: 401 })
}
const csrfCheck = requireJsonContentType(request)
if (csrfCheck) return csrfCheck
const ip = getClientIp(request)
if (!rateLimit(`workspace-task-run-pause-post:${ip}`, 30, 60_000)) {
return rateLimitResponse()
}
try {
return await forwardWorkspaceRequest({
request,
path: `/task-runs/${encodeURIComponent(params.id)}/pause`,
})
} catch (error) {
return json(
{ ok: false, error: safeErrorMessage(error) },
{ status: 502 },
)
}
},
},
},
})

View File

@@ -1,43 +0,0 @@
import { createFileRoute } from '@tanstack/react-router'
import { json } from '@tanstack/react-start'
import { isAuthenticated } from '../../../server/auth-middleware'
import {
getClientIp,
rateLimit,
rateLimitResponse,
requireJsonContentType,
safeErrorMessage,
} from '../../../server/rate-limit'
import { forwardWorkspaceRequest } from '../../../server/workspace-proxy'
export const Route = createFileRoute('/api/workspace/task-runs/$id/retry')({
server: {
handlers: {
POST: async ({ request, params }) => {
if (!isAuthenticated(request)) {
return json({ ok: false, error: 'Unauthorized' }, { status: 401 })
}
const csrfCheck = requireJsonContentType(request)
if (csrfCheck) return csrfCheck
const ip = getClientIp(request)
if (!rateLimit(`workspace-task-run-retry-post:${ip}`, 30, 60_000)) {
return rateLimitResponse()
}
try {
return await forwardWorkspaceRequest({
request,
path: `/task-runs/${encodeURIComponent(params.id)}/retry`,
})
} catch (error) {
return json(
{ ok: false, error: safeErrorMessage(error) },
{ status: 502 },
)
}
},
},
},
})

View File

@@ -1,43 +0,0 @@
import { createFileRoute } from '@tanstack/react-router'
import { json } from '@tanstack/react-start'
import { isAuthenticated } from '../../../server/auth-middleware'
import {
getClientIp,
rateLimit,
rateLimitResponse,
requireJsonContentType,
safeErrorMessage,
} from '../../../server/rate-limit'
import { forwardWorkspaceRequest } from '../../../server/workspace-proxy'
export const Route = createFileRoute('/api/workspace/task-runs/$id/stop')({
server: {
handlers: {
POST: async ({ request, params }) => {
if (!isAuthenticated(request)) {
return json({ ok: false, error: 'Unauthorized' }, { status: 401 })
}
const csrfCheck = requireJsonContentType(request)
if (csrfCheck) return csrfCheck
const ip = getClientIp(request)
if (!rateLimit(`workspace-task-run-stop-post:${ip}`, 30, 60_000)) {
return rateLimitResponse()
}
try {
return await forwardWorkspaceRequest({
request,
path: `/task-runs/${encodeURIComponent(params.id)}/stop`,
})
} catch (error) {
return json(
{ ok: false, error: safeErrorMessage(error) },
{ status: 502 },
)
}
},
},
},
})

View File

@@ -1,43 +0,0 @@
import { createFileRoute } from '@tanstack/react-router'
import { json } from '@tanstack/react-start'
import { isAuthenticated } from '../../../server/auth-middleware'
import {
getClientIp,
rateLimit,
rateLimitResponse,
requireJsonContentType,
safeErrorMessage,
} from '../../../server/rate-limit'
import { forwardWorkspaceRequest } from '../../../server/workspace-proxy'
export const Route = createFileRoute('/api/workspace/task-runs/adhoc')({
server: {
handlers: {
POST: async ({ request }) => {
if (!isAuthenticated(request)) {
return json({ ok: false, error: 'Unauthorized' }, { status: 401 })
}
const csrfCheck = requireJsonContentType(request)
if (csrfCheck) return csrfCheck
const ip = getClientIp(request)
if (!rateLimit(`workspace-task-runs-adhoc-post:${ip}`, 30, 60_000)) {
return rateLimitResponse()
}
try {
return await forwardWorkspaceRequest({
request,
path: '/task-runs/adhoc',
})
} catch (error) {
return json(
{ ok: false, error: safeErrorMessage(error) },
{ status: 502 },
)
}
},
},
},
})

View File

@@ -1,41 +0,0 @@
import { createFileRoute } from '@tanstack/react-router'
import { json } from '@tanstack/react-start'
import { isAuthenticated } from '../../../server/auth-middleware'
import {
getClientIp,
rateLimit,
rateLimitResponse,
safeErrorMessage,
} from '../../../server/rate-limit'
import { forwardWorkspaceRequest } from '../../../server/workspace-proxy'
export const Route = createFileRoute('/api/workspace/task-runs')({
server: {
handlers: {
GET: async ({ request }) => {
if (!isAuthenticated(request)) {
return json({ ok: false, error: 'Unauthorized' }, { status: 401 })
}
const ip = getClientIp(request)
if (!rateLimit(`workspace-task-runs-get:${ip}`, 120, 60_000)) {
return rateLimitResponse()
}
try {
const url = new URL(request.url)
return await forwardWorkspaceRequest({
request,
path: '/task-runs',
searchParams: url.searchParams,
})
} catch (error) {
return json(
{ ok: false, error: safeErrorMessage(error) },
{ status: 502 },
)
}
},
},
},
})

View File

@@ -1,43 +0,0 @@
import { createFileRoute } from '@tanstack/react-router'
import { json } from '@tanstack/react-start'
import { isAuthenticated } from '../../../server/auth-middleware'
import {
getClientIp,
rateLimit,
rateLimitResponse,
requireJsonContentType,
safeErrorMessage,
} from '../../../server/rate-limit'
import { forwardWorkspaceRequest } from '../../../server/workspace-proxy'
export const Route = createFileRoute('/api/workspace/teams/$id/approval-config')({
server: {
handlers: {
PATCH: async ({ request, params }) => {
if (!isAuthenticated(request)) {
return json({ ok: false, error: 'Unauthorized' }, { status: 401 })
}
const csrfCheck = requireJsonContentType(request)
if (csrfCheck) return csrfCheck
const ip = getClientIp(request)
if (!rateLimit(`workspace-team-approval-patch:${ip}`, 30, 60_000)) {
return rateLimitResponse()
}
try {
return await forwardWorkspaceRequest({
request,
path: `/teams/${params.id}/approval-config`,
})
} catch (error) {
return json(
{ ok: false, error: safeErrorMessage(error) },
{ status: 502 },
)
}
},
},
},
})

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