diff --git a/packages/adapter-utils/src/types.ts b/packages/adapter-utils/src/types.ts index ce89e0e8..9337fad0 100644 --- a/packages/adapter-utils/src/types.ts +++ b/packages/adapter-utils/src/types.ts @@ -287,6 +287,12 @@ export interface ServerAdapterModule { * without knowing provider-specific credential paths or API shapes. */ getQuotaWindows?: () => Promise; + /** + * Optional: detect the currently configured model from local config files. + * Returns the detected model/provider and the config source, or null if + * the adapter does not support detection or no config is found. + */ + detectModel?: () => Promise<{ model: string; provider: string; source: string } | null>; } // --------------------------------------------------------------------------- diff --git a/server/package.json b/server/package.json index c4053237..0f7efa44 100644 --- a/server/package.json +++ b/server/package.json @@ -65,7 +65,7 @@ "drizzle-orm": "^0.38.4", "embedded-postgres": "^18.1.0-beta.16", "express": "^5.1.0", - "hermes-paperclip-adapter": "0.1.1", + "hermes-paperclip-adapter": "^0.2.0", "jsdom": "^28.1.0", "multer": "^2.0.2", "open": "^11.0.0", diff --git a/server/src/adapters/index.ts b/server/src/adapters/index.ts index 8d86eb52..8be40a51 100644 --- a/server/src/adapters/index.ts +++ b/server/src/adapters/index.ts @@ -1,4 +1,4 @@ -export { getServerAdapter, listAdapterModels, listServerAdapters, findServerAdapter } from "./registry.js"; +export { getServerAdapter, listAdapterModels, listServerAdapters, findServerAdapter, detectAdapterModel } from "./registry.js"; export type { ServerAdapterModule, AdapterExecutionContext, diff --git a/server/src/adapters/registry.ts b/server/src/adapters/registry.ts index 67a8e95b..3db7cdf9 100644 --- a/server/src/adapters/registry.ts +++ b/server/src/adapters/registry.ts @@ -70,6 +70,9 @@ import { execute as hermesExecute, testEnvironment as hermesTestEnvironment, sessionCodec as hermesSessionCodec, + listSkills as hermesListSkills, + syncSkills as hermesSyncSkills, + detectModel as detectModelFromHermes, } from "hermes-paperclip-adapter/server"; import { agentConfigurationDoc as hermesAgentConfigurationDoc, @@ -176,9 +179,12 @@ const hermesLocalAdapter: ServerAdapterModule = { execute: hermesExecute, testEnvironment: hermesTestEnvironment, sessionCodec: hermesSessionCodec, + listSkills: hermesListSkills, + syncSkills: hermesSyncSkills, models: hermesModels, supportsLocalAgentJwt: true, agentConfigurationDoc: hermesAgentConfigurationDoc, + detectModel: () => detectModelFromHermes(), }; const adaptersByType = new Map( @@ -219,6 +225,15 @@ export function listServerAdapters(): ServerAdapterModule[] { return Array.from(adaptersByType.values()); } +export async function detectAdapterModel( + type: string, +): Promise<{ model: string; provider: string; source: string } | null> { + const adapter = adaptersByType.get(type); + if (!adapter?.detectModel) return null; + const detected = await adapter.detectModel(); + return detected ? { model: detected.model, provider: detected.provider, source: detected.source } : null; +} + export function findServerAdapter(type: string): ServerAdapterModule | null { return adaptersByType.get(type) ?? null; } diff --git a/server/src/routes/agents.ts b/server/src/routes/agents.ts index f642eb10..b4964578 100644 --- a/server/src/routes/agents.ts +++ b/server/src/routes/agents.ts @@ -44,7 +44,7 @@ import { } from "../services/index.js"; import { conflict, forbidden, notFound, unprocessable } from "../errors.js"; import { assertBoard, assertCompanyAccess, assertInstanceAdmin, getActorInfo } from "./authz.js"; -import { findServerAdapter, listAdapterModels } from "../adapters/index.js"; +import { findServerAdapter, listAdapterModels, detectAdapterModel } from "../adapters/index.js"; import { redactEventPayload } from "../redaction.js"; import { redactCurrentUserValue } from "../log-redaction.js"; import { renderOrgChartSvg, renderOrgChartPng, type OrgNode, type OrgChartStyle, ORG_CHART_STYLES } from "./org-chart-svg.js"; @@ -671,6 +671,15 @@ export function agentRoutes(db: Db) { res.json(models); }); + router.get("/companies/:companyId/adapters/:type/detect-model", async (req, res) => { + const companyId = req.params.companyId as string; + assertCompanyAccess(req, companyId); + const type = req.params.type as string; + + const detected = await detectAdapterModel(type); + res.json(detected); + }); + router.post( "/companies/:companyId/adapters/:type/test-environment", validate(testAdapterEnvironmentSchema), diff --git a/ui/package.json b/ui/package.json index c2471b4b..d344f67a 100644 --- a/ui/package.json +++ b/ui/package.json @@ -41,6 +41,7 @@ "@paperclipai/adapter-pi-local": "workspace:*", "@paperclipai/adapter-utils": "workspace:*", "@paperclipai/shared": "workspace:*", + "hermes-paperclip-adapter": "^0.2.0", "@radix-ui/react-slot": "^1.2.4", "@tailwindcss/typography": "^0.5.19", "@tanstack/react-query": "^5.90.21", diff --git a/ui/src/adapters/hermes-local/config-fields.tsx b/ui/src/adapters/hermes-local/config-fields.tsx new file mode 100644 index 00000000..62b85fea --- /dev/null +++ b/ui/src/adapters/hermes-local/config-fields.tsx @@ -0,0 +1,49 @@ +import type { AdapterConfigFieldsProps } from "../types"; +import { + Field, + DraftInput, +} from "../../components/agent-config-primitives"; +import { ChoosePathButton } from "../../components/PathInstructionsModal"; + +const inputClass = + "w-full rounded-md border border-border px-2.5 py-1.5 bg-transparent outline-none text-sm font-mono placeholder:text-muted-foreground/40"; +const instructionsFileHint = + "Absolute path to a markdown file (e.g. AGENTS.md) that defines this agent's behavior. Injected into the system prompt at runtime."; + +export function HermesLocalConfigFields({ + isCreate, + values, + set, + config, + eff, + mark, + hideInstructionsFile, +}: AdapterConfigFieldsProps) { + if (hideInstructionsFile) return null; + return ( + +
+ + isCreate + ? set!({ instructionsFilePath: v }) + : mark("adapterConfig", "instructionsFilePath", v || undefined) + } + immediate + className={inputClass} + placeholder="/absolute/path/to/AGENTS.md" + /> + +
+
+ ); +} diff --git a/ui/src/adapters/hermes-local/index.ts b/ui/src/adapters/hermes-local/index.ts new file mode 100644 index 00000000..97c064f8 --- /dev/null +++ b/ui/src/adapters/hermes-local/index.ts @@ -0,0 +1,12 @@ +import type { UIAdapterModule } from "../types"; +import { parseHermesStdoutLine } from "hermes-paperclip-adapter/ui"; +import { HermesLocalConfigFields } from "./config-fields"; +import { buildHermesConfig } from "hermes-paperclip-adapter/ui"; + +export const hermesLocalUIAdapter: UIAdapterModule = { + type: "hermes_local", + label: "Hermes Agent", + parseStdoutLine: parseHermesStdoutLine, + ConfigFields: HermesLocalConfigFields, + buildAdapterConfig: buildHermesConfig, +}; diff --git a/ui/src/adapters/registry.ts b/ui/src/adapters/registry.ts index fc7be2cf..67d89ada 100644 --- a/ui/src/adapters/registry.ts +++ b/ui/src/adapters/registry.ts @@ -3,6 +3,7 @@ import { claudeLocalUIAdapter } from "./claude-local"; import { codexLocalUIAdapter } from "./codex-local"; import { cursorLocalUIAdapter } from "./cursor"; import { geminiLocalUIAdapter } from "./gemini-local"; +import { hermesLocalUIAdapter } from "./hermes-local"; import { openCodeLocalUIAdapter } from "./opencode-local"; import { piLocalUIAdapter } from "./pi-local"; import { openClawGatewayUIAdapter } from "./openclaw-gateway"; @@ -13,6 +14,7 @@ const uiAdapters: UIAdapterModule[] = [ claudeLocalUIAdapter, codexLocalUIAdapter, geminiLocalUIAdapter, + hermesLocalUIAdapter, openCodeLocalUIAdapter, piLocalUIAdapter, cursorLocalUIAdapter, diff --git a/ui/src/api/agents.ts b/ui/src/api/agents.ts index ccaf15c0..ec090b43 100644 --- a/ui/src/api/agents.ts +++ b/ui/src/api/agents.ts @@ -27,6 +27,12 @@ export interface AdapterModel { label: string; } +export interface DetectedAdapterModel { + model: string; + provider: string; + source: string; +} + export interface ClaudeLoginResult { exitCode: number | null; signal: string | null; @@ -159,6 +165,10 @@ export const agentsApi = { api.get( `/companies/${encodeURIComponent(companyId)}/adapters/${encodeURIComponent(type)}/models`, ), + detectModel: (companyId: string, type: string) => + api.get( + `/companies/${encodeURIComponent(companyId)}/adapters/${encodeURIComponent(type)}/detect-model`, + ), testEnvironment: ( companyId: string, type: string, diff --git a/ui/src/components/AgentConfigForm.tsx b/ui/src/components/AgentConfigForm.tsx index 1810e9a8..b9781a05 100644 --- a/ui/src/components/AgentConfigForm.tsx +++ b/ui/src/components/AgentConfigForm.tsx @@ -248,9 +248,26 @@ export function AgentConfigForm(props: AgentConfigFormProps) { } if (overlay.adapterType !== undefined) { patch.adapterType = overlay.adapterType; - // When adapter type changes, send only the new config — don't merge - // with old config since old adapter fields are meaningless for the new type - patch.adapterConfig = overlay.adapterConfig; + // When adapter type changes, replace adapter-specific fields but preserve + // adapter-agnostic fields (env, promptTemplate, etc.) that are shared + // across all adapter types. + const existing = (agent.adapterConfig ?? {}) as Record; + const adapterAgnosticKeys = [ + "env", + "promptTemplate", + "instructionsFilePath", + "cwd", + "timeoutSec", + "graceSec", + "bootstrapPromptTemplate", + ]; + const preserved: Record = {}; + for (const key of adapterAgnosticKeys) { + if (key in existing) { + preserved[key] = existing[key]; + } + } + patch.adapterConfig = { ...preserved, ...overlay.adapterConfig }; } else if (Object.keys(overlay.adapterConfig).length > 0) { const existing = (agent.adapterConfig ?? {}) as Record; patch.adapterConfig = { ...existing, ...overlay.adapterConfig }; @@ -296,9 +313,11 @@ export function AgentConfigForm(props: AgentConfigFormProps) { adapterType === "claude_local" || adapterType === "codex_local" || adapterType === "gemini_local" || + adapterType === "hermes_local" || adapterType === "opencode_local" || adapterType === "pi_local" || adapterType === "cursor"; + const isHermesLocal = adapterType === "hermes_local"; const showLegacyWorkingDirectoryField = isLocal && shouldShowLegacyWorkingDirectoryField({ isCreate, adapterConfig: config }); const uiAdapter = useMemo(() => getUIAdapter(adapterType), [adapterType]); @@ -315,6 +334,22 @@ export function AgentConfigForm(props: AgentConfigFormProps) { enabled: Boolean(selectedCompanyId), }); const models = fetchedModels ?? externalModels ?? []; + const { + data: detectedModelData, + refetch: refetchDetectedModel, + } = useQuery({ + queryKey: selectedCompanyId + ? queryKeys.agents.detectModel(selectedCompanyId, adapterType) + : ["agents", "none", "detect-model", adapterType], + queryFn: () => { + if (!selectedCompanyId) { + throw new Error("Select a company to detect the Hermes model"); + } + return agentsApi.detectModel(selectedCompanyId, adapterType); + }, + enabled: Boolean(selectedCompanyId && isHermesLocal), + }); + const detectedModel = detectedModelData?.model ?? null; const { data: companyAgents = [] } = useQuery({ queryKey: selectedCompanyId ? queryKeys.agents.list(selectedCompanyId) : ["agents", "none", "list"], @@ -688,6 +723,8 @@ export function AgentConfigForm(props: AgentConfigFormProps) { ? "codex" : adapterType === "gemini_local" ? "gemini" + : adapterType === "hermes_local" + ? "hermes" : adapterType === "pi_local" ? "pi" : adapterType === "cursor" @@ -709,9 +746,18 @@ export function AgentConfigForm(props: AgentConfigFormProps) { } open={modelOpen} onOpenChange={setModelOpen} - allowDefault={adapterType !== "opencode_local"} - required={adapterType === "opencode_local"} + allowDefault={adapterType !== "opencode_local" && adapterType !== "hermes_local"} + required={adapterType === "opencode_local" || adapterType === "hermes_local"} groupByProvider={adapterType === "opencode_local"} + creatable={adapterType === "hermes_local"} + detectedModel={adapterType === "hermes_local" ? detectedModel : null} + onDetectModel={adapterType === "hermes_local" + ? async () => { + const result = await refetchDetectedModel(); + return result.data?.model ?? null; + } + : undefined} + detectModelLabel={adapterType === "hermes_local" ? "Detect from Hermes config" : undefined} /> {fetchedModelsError && (

@@ -976,7 +1022,7 @@ function AdapterEnvironmentResult({ result }: { result: AdapterEnvironmentTestRe /* ---- Internal sub-components ---- */ -const ENABLED_ADAPTER_TYPES = new Set(["claude_local", "codex_local", "gemini_local", "opencode_local", "pi_local", "cursor"]); +const ENABLED_ADAPTER_TYPES = new Set(["claude_local", "codex_local", "gemini_local", "opencode_local", "pi_local", "cursor", "hermes_local"]); /** Display list includes all real adapter types plus UI-only coming-soon entries. */ const ADAPTER_DISPLAY_LIST: { value: string; label: string; comingSoon: boolean }[] = [ @@ -1293,6 +1339,10 @@ function ModelDropdown({ allowDefault, required, groupByProvider, + creatable, + detectedModel, + onDetectModel, + detectModelLabel, }: { models: AdapterModel[]; value: string; @@ -1302,9 +1352,20 @@ function ModelDropdown({ allowDefault: boolean; required: boolean; groupByProvider: boolean; + creatable?: boolean; + detectedModel?: string | null; + onDetectModel?: () => Promise; + detectModelLabel?: string; }) { const [modelSearch, setModelSearch] = useState(""); + const [detectingModel, setDetectingModel] = useState(false); const selected = models.find((m) => m.id === value); + const manualModel = modelSearch.trim(); + const canCreateManualModel = Boolean( + creatable && + manualModel && + !models.some((m) => m.id.toLowerCase() === manualModel.toLowerCase()), + ); const filteredModels = useMemo(() => { return models.filter((m) => { if (!modelSearch.trim()) return true; @@ -1341,6 +1402,21 @@ function ModelDropdown({ })); }, [filteredModels, groupByProvider]); + async function handleDetectModel() { + if (!onDetectModel) return; + setDetectingModel(true); + try { + const nextModel = await onDetectModel(); + if (nextModel) { + onChange(nextModel); + onOpenChange(false); + setModelSearch(""); + } + } finally { + setDetectingModel(false); + } + } + return ( - - setModelSearch(e.target.value)} - autoFocus - /> +

+ setModelSearch(e.target.value)} + autoFocus + /> + {modelSearch && ( + + )} +
+ {onDetectModel && !detectedModel && !modelSearch.trim() && ( + + )} + {value && !models.some((m) => m.id === value) && ( + + )} + {detectedModel && detectedModel !== value && ( + + )}
{allowDefault && ( + )} {groupedModels.map((group) => (
{groupByProvider && ( @@ -1392,6 +1550,7 @@ function ModelDropdown({ )} {group.entries.map((m) => (
diff --git a/ui/src/components/HermesIcon.tsx b/ui/src/components/HermesIcon.tsx new file mode 100644 index 00000000..fb02623a --- /dev/null +++ b/ui/src/components/HermesIcon.tsx @@ -0,0 +1,43 @@ +import { cn } from "../lib/utils"; + +interface HermesIconProps { + className?: string; +} + +/** + * Hermes caduceus icon — winged staff with two intertwined serpents. + * Replaces the generic Zap icon for the hermes_local adapter type. + * + * ⚕️ inspired but as the proper caduceus (Hermes' symbol): staff + two snakes + wings. + */ +export function HermesIcon({ className }: HermesIconProps) { + return ( + + {/* Central staff */} + + {/* Left serpent curves */} + + {/* Right serpent curves */} + + {/* Snake heads facing outward */} + + + {/* Wings at top of staff */} + + + {/* Wing feather details */} + + + {/* Staff sphere at top */} + + + ); +} diff --git a/ui/src/components/NewAgentDialog.tsx b/ui/src/components/NewAgentDialog.tsx index 15114bf7..aaaf7c6d 100644 --- a/ui/src/components/NewAgentDialog.tsx +++ b/ui/src/components/NewAgentDialog.tsx @@ -21,6 +21,7 @@ import { } from "lucide-react"; import { cn } from "@/lib/utils"; import { OpenCodeLogoIcon } from "./OpenCodeLogoIcon"; +import { HermesIcon } from "./HermesIcon"; type AdvancedAdapterType = | "claude_local" @@ -29,7 +30,8 @@ type AdvancedAdapterType = | "opencode_local" | "pi_local" | "cursor" - | "openclaw_gateway"; + | "openclaw_gateway" + | "hermes_local"; const ADVANCED_ADAPTER_OPTIONS: Array<{ value: AdvancedAdapterType; @@ -64,6 +66,12 @@ const ADVANCED_ADAPTER_OPTIONS: Array<{ icon: OpenCodeLogoIcon, desc: "Local multi-provider agent", }, + { + value: "hermes_local", + label: "Hermes Agent", + icon: HermesIcon, + desc: "Local multi-provider agent", + }, { value: "pi_local", label: "Pi", diff --git a/ui/src/components/OnboardingWizard.tsx b/ui/src/components/OnboardingWizard.tsx index cd28af9f..b3ec724e 100644 --- a/ui/src/components/OnboardingWizard.tsx +++ b/ui/src/components/OnboardingWizard.tsx @@ -56,12 +56,14 @@ import { ChevronDown, X } from "lucide-react"; +import { HermesIcon } from "./HermesIcon"; type Step = 1 | 2 | 3 | 4; type AdapterType = | "claude_local" | "codex_local" | "gemini_local" + | "hermes_local" | "opencode_local" | "pi_local" | "cursor" @@ -208,6 +210,7 @@ export function OnboardingWizard() { adapterType === "claude_local" || adapterType === "codex_local" || adapterType === "gemini_local" || + adapterType === "hermes_local" || adapterType === "opencode_local" || adapterType === "pi_local" || adapterType === "cursor"; @@ -217,6 +220,8 @@ export function OnboardingWizard() { ? "codex" : adapterType === "gemini_local" ? "gemini" + : adapterType === "hermes_local" + ? "hermes" : adapterType === "pi_local" ? "pi" : adapterType === "cursor" @@ -843,6 +848,12 @@ export function OnboardingWizard() { icon: MousePointer2, desc: "Local Cursor agent" }, + { + value: "hermes_local" as const, + label: "Hermes Agent", + icon: HermesIcon, + desc: "Local multi-provider agent" + }, { value: "openclaw_gateway" as const, label: "OpenClaw Gateway", @@ -902,6 +913,7 @@ export function OnboardingWizard() { {(adapterType === "claude_local" || adapterType === "codex_local" || adapterType === "gemini_local" || + adapterType === "hermes_local" || adapterType === "opencode_local" || adapterType === "pi_local" || adapterType === "cursor") && ( diff --git a/ui/src/components/agent-config-primitives.tsx b/ui/src/components/agent-config-primitives.tsx index 70694f73..e66bf4d8 100644 --- a/ui/src/components/agent-config-primitives.tsx +++ b/ui/src/components/agent-config-primitives.tsx @@ -64,6 +64,7 @@ export const adapterLabels: Record = { opencode_local: "OpenCode (local)", openclaw_gateway: "OpenClaw Gateway", cursor: "Cursor (local)", + hermes_local: "Hermes Agent", process: "Process", http: "HTTP", }; diff --git a/ui/src/components/transcript/RunTranscriptView.tsx b/ui/src/components/transcript/RunTranscriptView.tsx index cd52dbc1..f39167f8 100644 --- a/ui/src/components/transcript/RunTranscriptView.tsx +++ b/ui/src/components/transcript/RunTranscriptView.tsx @@ -72,6 +72,26 @@ type TranscriptBlock = status: "running" | "completed" | "error"; }>; } + | { + type: "tool_group"; + ts: string; + endTs?: string; + items: Array<{ + ts: string; + endTs?: string; + name: string; + input: unknown; + result?: string; + isError?: boolean; + status: "running" | "completed" | "error"; + }>; + } + | { + type: "stderr_group"; + ts: string; + endTs?: string; + lines: Array<{ ts: string; text: string }>; + } | { type: "stdout"; ts: string; @@ -325,6 +345,48 @@ function groupCommandBlocks(blocks: TranscriptBlock[]): TranscriptBlock[] { return grouped; } +/** Group consecutive non-command tool blocks into a single tool_group accordion. */ +function groupToolBlocks(blocks: TranscriptBlock[]): TranscriptBlock[] { + const grouped: TranscriptBlock[] = []; + let pending: Array["items"][number]> = []; + let groupTs: string | null = null; + let groupEndTs: string | undefined; + + const flush = () => { + if (pending.length === 0 || !groupTs) return; + grouped.push({ + type: "tool_group", + ts: groupTs, + endTs: groupEndTs, + items: pending, + }); + pending = []; + groupTs = null; + groupEndTs = undefined; + }; + + for (const block of blocks) { + if (block.type === "tool" && !isCommandTool(block.name, block.input)) { + if (!groupTs) groupTs = block.ts; + groupEndTs = block.endTs ?? block.ts; + pending.push({ + ts: block.ts, + endTs: block.endTs, + name: block.name, + input: block.input, + result: block.result, + isError: block.isError, + status: block.status, + }); + continue; + } + flush(); + grouped.push(block); + } + flush(); + return grouped; +} + export function normalizeTranscript(entries: TranscriptEntry[], streaming: boolean): TranscriptBlock[] { const blocks: TranscriptBlock[] = []; const pendingToolBlocks = new Map>(); @@ -437,13 +499,19 @@ export function normalizeTranscript(entries: TranscriptEntry[], streaming: boole if (shouldHideNiceModeStderr(entry.text)) { continue; } - blocks.push({ - type: "event", - ts: entry.ts, - label: "stderr", - tone: "error", - text: entry.text, - }); + // Batch consecutive stderr entries into a single group + const prev = blocks[blocks.length - 1]; + if (prev && prev.type === "stderr_group") { + prev.lines.push({ ts: entry.ts, text: entry.text }); + prev.endTs = entry.ts; + } else { + blocks.push({ + type: "stderr_group", + ts: entry.ts, + endTs: entry.ts, + lines: [{ ts: entry.ts, text: entry.text }], + }); + } continue; } @@ -508,7 +576,7 @@ export function normalizeTranscript(entries: TranscriptEntry[], streaming: boole } } - return groupCommandBlocks(blocks); + return groupToolBlocks(groupCommandBlocks(blocks)); } function TranscriptMessageBlock({ @@ -805,6 +873,139 @@ function TranscriptCommandGroup({ ); } +function TranscriptToolGroup({ + block, + density, +}: { + block: Extract; + density: TranscriptDensity; +}) { + const [open, setOpen] = useState(false); + const compact = density === "compact"; + const runningItem = [...block.items].reverse().find((item) => item.status === "running"); + const hasError = block.items.some((item) => item.status === "error"); + const isRunning = Boolean(runningItem); + const uniqueNames = [...new Set(block.items.map((item) => item.name))]; + const toolLabel = + uniqueNames.length === 1 + ? humanizeLabel(uniqueNames[0]) + : `${uniqueNames.length} tools`; + const title = isRunning + ? `Using ${toolLabel}` + : block.items.length === 1 + ? `Used ${toolLabel}` + : `Used ${toolLabel} (${block.items.length} calls)`; + const subtitle = runningItem + ? summarizeToolInput(runningItem.name, runningItem.input, density) + : null; + const statusTone = isRunning + ? "text-cyan-700 dark:text-cyan-300" + : "text-foreground/70"; + + return ( +
+
{ if (hasSelectedText()) return; setOpen((v) => !v); }} + onKeyDown={(e) => { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); setOpen((v) => !v); } }} + > +
+ {block.items.slice(0, Math.min(block.items.length, 3)).map((item, index) => { + const isItemRunning = item.status === "running"; + const isItemError = item.status === "error"; + return ( + 0 && "-ml-1.5", + isItemRunning + ? "border-cyan-500/25 bg-cyan-500/[0.08] text-cyan-600 dark:text-cyan-300" + : isItemError + ? "border-red-500/25 bg-red-500/[0.08] text-red-600 dark:text-red-300" + : "border-border/70 bg-background text-foreground/55", + isItemRunning && "animate-pulse", + )} + > + + + ); + })} +
+
+
+ {title} +
+ {subtitle && ( +
+ {subtitle} +
+ )} +
+ +
+ {open && ( +
+ {block.items.map((item, index) => ( +
+
+ + + + + {humanizeLabel(item.name)} + + + {item.status === "running" ? "Running" : item.status === "error" ? "Errored" : "Completed"} + +
+
+
+
Input
+
+                    {formatToolPayload(item.input) || ""}
+                  
+
+ {item.result && ( +
+
Result
+
+                      {formatToolPayload(item.result)}
+                    
+
+ )} +
+
+ ))} +
+ )} +
+ ); +} + function TranscriptActivityRow({ block, density, @@ -883,6 +1084,43 @@ function TranscriptEventRow({ ); } +function TranscriptStderrGroup({ + block, + density, +}: { + block: Extract; + density: TranscriptDensity; +}) { + const [open, setOpen] = useState(false); + const compact = density === "compact"; + return ( +
+
setOpen((v) => !v)} + onKeyDown={(e) => { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); setOpen((v) => !v); } }} + > + + {block.lines.length} log {block.lines.length === 1 ? "line" : "lines"} + + {open ? : } +
+ {open && ( +
+          {block.lines.map((line, i) => (
+            
+              {i > 0 ? "\n" : ""}
+              {line.text}
+            
+          ))}
+        
+ )} +
+ ); +} + function TranscriptStdoutRow({ block, density, @@ -1003,6 +1241,8 @@ export function RunTranscriptView({ )} {block.type === "tool" && } {block.type === "command_group" && } + {block.type === "tool_group" && } + {block.type === "stderr_group" && } {block.type === "stdout" && ( )} diff --git a/ui/src/lib/queryKeys.ts b/ui/src/lib/queryKeys.ts index ecee1a20..8b7f2cd7 100644 --- a/ui/src/lib/queryKeys.ts +++ b/ui/src/lib/queryKeys.ts @@ -25,6 +25,8 @@ export const queryKeys = { configRevisions: (agentId: string) => ["agents", "config-revisions", agentId] as const, adapterModels: (companyId: string, adapterType: string) => ["agents", companyId, "adapter-models", adapterType] as const, + detectModel: (companyId: string, adapterType: string) => + ["agents", companyId, "detect-model", adapterType] as const, }, issues: { list: (companyId: string) => ["issues", companyId] as const, diff --git a/ui/src/pages/AgentDetail.tsx b/ui/src/pages/AgentDetail.tsx index c0bed886..3e19d294 100644 --- a/ui/src/pages/AgentDetail.tsx +++ b/ui/src/pages/AgentDetail.tsx @@ -1075,10 +1075,28 @@ function LatestRunCard({ runs, agentId }: { runs: HeartbeatRun[]; agentId: strin const isLive = run.status === "running" || run.status === "queued"; const statusInfo = runStatusIcons[run.status] ?? { icon: Clock, color: "text-neutral-400" }; const StatusIcon = statusInfo.icon; - const summary = run.resultJson + const summaryRaw = run.resultJson ? String((run.resultJson as Record).summary ?? (run.resultJson as Record).result ?? "") : run.error ?? ""; + // Extract a clean 2-3 line excerpt: first non-empty, non-header, non-list-mark lines + const summary = useMemo(() => { + if (!summaryRaw) return ""; + const lines = summaryRaw + .replace(/^#{1,6}\s+/gm, "") + .split("\n") + .map((l) => l.trim()) + .filter((l) => l.length > 0 && !l.startsWith("---") && !l.startsWith("|") && !l.startsWith("```") && !/^[-*>]/.test(l) && !/^\d+\./.test(l)); + const excerpt: string[] = []; + let chars = 0; + for (const line of lines) { + if (excerpt.length >= 3 || chars + line.length > 280) break; + excerpt.push(line); + chars += line.length; + } + return excerpt.join(" "); + }, [summaryRaw]); + return (
@@ -2351,6 +2369,7 @@ function AgentSkillsTab({ const queryClient = useQueryClient(); const [skillDraft, setSkillDraft] = useState([]); const [lastSavedSkills, setLastSavedSkills] = useState([]); + const [unmanagedOpen, setUnmanagedOpen] = useState(false); const lastSavedSkillsRef = useRef([]); const hasHydratedSkillSnapshotRef = useRef(false); const skipNextSkillAutosaveRef = useRef(true); @@ -2680,12 +2699,19 @@ function AgentSkillsTab({ {unmanagedSkillRows.length > 0 && (
-
+
setUnmanagedOpen((v) => !v)} + onKeyDown={(e) => { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); setUnmanagedOpen((v) => !v); } }} + > - User-installed skills, not managed by Paperclip + ({unmanagedSkillRows.length}) User-installed skills, not managed by Paperclip + {unmanagedOpen ? : }
- {unmanagedSkillRows.map(renderSkillRow)} + {unmanagedOpen && unmanagedSkillRows.map(renderSkillRow)}
)} diff --git a/ui/src/pages/Agents.tsx b/ui/src/pages/Agents.tsx index b14112c4..a157f777 100644 --- a/ui/src/pages/Agents.tsx +++ b/ui/src/pages/Agents.tsx @@ -26,6 +26,7 @@ const adapterLabels: Record = { gemini_local: "Gemini", opencode_local: "OpenCode", cursor: "Cursor", + hermes_local: "Hermes", openclaw_gateway: "OpenClaw Gateway", process: "Process", http: "HTTP", diff --git a/ui/src/pages/InviteLanding.tsx b/ui/src/pages/InviteLanding.tsx index e288babe..ec0d5e1d 100644 --- a/ui/src/pages/InviteLanding.tsx +++ b/ui/src/pages/InviteLanding.tsx @@ -20,11 +20,12 @@ const adapterLabels: Record = { pi_local: "Pi (local)", openclaw_gateway: "OpenClaw Gateway", cursor: "Cursor (local)", + hermes_local: "Hermes Agent", process: "Process", http: "HTTP", }; -const ENABLED_INVITE_ADAPTERS = new Set(["claude_local", "codex_local", "gemini_local", "opencode_local", "pi_local", "cursor"]); +const ENABLED_INVITE_ADAPTERS = new Set(["claude_local", "codex_local", "gemini_local", "opencode_local", "pi_local", "cursor", "hermes_local"]); function dateTime(value: string) { return new Date(value).toLocaleString(); diff --git a/ui/src/pages/NewAgent.tsx b/ui/src/pages/NewAgent.tsx index b8787be2..69415db6 100644 --- a/ui/src/pages/NewAgent.tsx +++ b/ui/src/pages/NewAgent.tsx @@ -35,6 +35,7 @@ const SUPPORTED_ADVANCED_ADAPTER_TYPES = new Set = { gemini_local: "Gemini", opencode_local: "OpenCode", cursor: "Cursor", + hermes_local: "Hermes", openclaw_gateway: "OpenClaw Gateway", process: "Process", http: "HTTP",