feat(hermes): upgrade hermes-paperclip-adapter + UI adapter + skills + detectModel

Upgrades hermes-paperclip-adapter from 0.1.1 to ^0.2.0 and wires in all new
capabilities introduced in v0.2.0:

Server
- Upgrade hermes-paperclip-adapter 0.1.1 -> ^0.2.0 (pending PR#10 merge)
- Wire listSkills + syncSkills from hermes-paperclip-adapter/server
- Add detectModel to hermesLocalAdapter (reads ~/.hermes/config.yaml)
- Add detectAdapterModel() function + /adapters/:type/detect-model route
- Export detectAdapterModel from server/src/adapters/index.ts

Types
- Add optional detectModel? to ServerAdapterModule in adapter-utils

UI
- Add hermes-paperclip-adapter ^0.2.0 to ui/package.json (for /ui exports)
- New ui/src/adapters/hermes-local/ — config fields + UI adapter module
- Register hermesLocalUIAdapter in UI adapter registry
- New HermesIcon (caduceus SVG) for adapter pickers
- AgentConfigForm: detect-model button, creatable model input, preserve
  adapter-agnostic fields (env, promptTemplate) when switching adapter type
- NewAgentDialog + OnboardingWizard: add Hermes to adapter picker
- Agents, OrgChart, InviteLanding, NewAgent, agent-config-primitives: add
  hermes_local label + enable in adapter sets
- AgentDetail: smarter run summary excerpt extraction
- RunTranscriptView: improved Hermes stdout rendering

NOTE: requires hermes-paperclip-adapter@0.2.0 on npm.
      Blocked on NousResearch/hermes-paperclip-adapter#10 merging.
This commit is contained in:
HenkDz 2026-03-28 01:34:48 +01:00
parent 0ac01a04e5
commit 1583a2d65a
22 changed files with 634 additions and 33 deletions

View file

@ -287,6 +287,12 @@ export interface ServerAdapterModule {
* without knowing provider-specific credential paths or API shapes.
*/
getQuotaWindows?: () => Promise<ProviderQuotaResult>;
/**
* 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>;
}
// ---------------------------------------------------------------------------

View file

@ -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",

View file

@ -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,

View file

@ -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<string, ServerAdapterModule>(
@ -219,6 +225,15 @@ export function listServerAdapters(): ServerAdapterModule[] {
return Array.from(adaptersByType.values());
}
export async function detectAdapterModel(
type: string,
): Promise<{ model: string | null; provider: string | null; source: string | null } | 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;
}

View file

@ -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 ?? { model: null, provider: null, source: null });
});
router.post(
"/companies/:companyId/adapters/:type/test-environment",
validate(testAdapterEnvironmentSchema),

View file

@ -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",

View file

@ -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 (
<Field label="Agent instructions file" hint={instructionsFileHint}>
<div className="flex items-center gap-2">
<DraftInput
value={
isCreate
? values!.instructionsFilePath ?? ""
: eff(
"adapterConfig",
"instructionsFilePath",
String(config.instructionsFilePath ?? ""),
)
}
onCommit={(v) =>
isCreate
? set!({ instructionsFilePath: v })
: mark("adapterConfig", "instructionsFilePath", v || undefined)
}
immediate
className={inputClass}
placeholder="/absolute/path/to/AGENTS.md"
/>
<ChoosePathButton />
</div>
</Field>
);
}

View file

@ -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,
};

View file

@ -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,

View file

@ -27,6 +27,12 @@ export interface AdapterModel {
label: string;
}
export interface DetectedAdapterModel {
model: string | null;
provider: string | null;
source: string | null;
}
export interface ClaudeLoginResult {
exitCode: number | null;
signal: string | null;
@ -159,6 +165,10 @@ export const agentsApi = {
api.get<AdapterModel[]>(
`/companies/${encodeURIComponent(companyId)}/adapters/${encodeURIComponent(type)}/models`,
),
detectModel: (companyId: string, type: string) =>
api.get<DetectedAdapterModel>(
`/companies/${encodeURIComponent(companyId)}/adapters/${encodeURIComponent(type)}/detect-model`,
),
testEnvironment: (
companyId: string,
type: string,

View file

@ -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<string, unknown>;
const adapterAgnosticKeys = [
"env",
"promptTemplate",
"instructionsFilePath",
"cwd",
"timeoutSec",
"graceSec",
"bootstrapPromptTemplate",
];
const preserved: Record<string, unknown> = {};
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<string, unknown>;
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,17 @@ 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}
/>
{fetchedModelsError && (
<p className="text-xs text-destructive">
@ -976,7 +1021,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 +1338,9 @@ function ModelDropdown({
allowDefault,
required,
groupByProvider,
creatable,
detectedModel,
onDetectModel,
}: {
models: AdapterModel[];
value: string;
@ -1302,9 +1350,19 @@ function ModelDropdown({
allowDefault: boolean;
required: boolean;
groupByProvider: boolean;
creatable?: boolean;
detectedModel?: string | null;
onDetectModel?: () => Promise<string | null>;
}) {
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 +1399,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 (
<Field label="Model" hint={help.model}>
<Popover
@ -1351,7 +1424,7 @@ function ModelDropdown({
}}
>
<PopoverTrigger asChild>
<button className="inline-flex items-center gap-1.5 rounded-md border border-border px-2.5 py-1.5 text-sm hover:bg-accent/50 transition-colors w-full justify-between">
<button type="button" className="inline-flex items-center gap-1.5 rounded-md border border-border px-2.5 py-1.5 text-sm hover:bg-accent/50 transition-colors w-full justify-between">
<span className={cn(!value && "text-muted-foreground")}>
{selected
? selected.label
@ -1361,16 +1434,83 @@ function ModelDropdown({
</button>
</PopoverTrigger>
<PopoverContent className="w-[var(--radix-popover-trigger-width)] p-1" align="start">
<input
className="w-full px-2 py-1.5 text-xs bg-transparent outline-none border-b border-border mb-1 placeholder:text-muted-foreground/50"
placeholder="Search models..."
value={modelSearch}
onChange={(e) => setModelSearch(e.target.value)}
autoFocus
/>
<div className="relative mb-1">
<input
className="w-full px-2 py-1.5 pr-6 text-xs bg-transparent outline-none border-b border-border placeholder:text-muted-foreground/50"
placeholder={creatable ? "Search models... (type to create)" : "Search models..."}
value={modelSearch}
onChange={(e) => setModelSearch(e.target.value)}
/>
{modelSearch && (
<button
type="button"
className="absolute right-1.5 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
onClick={() => setModelSearch("")}
>
<svg aria-hidden="true" focusable="false" className="h-3 w-3" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<line x1="18" y1="6" x2="6" y2="18" />
<line x1="6" y1="6" x2="18" y2="18" />
</svg>
</button>
)}
</div>
{onDetectModel && !detectedModel && !modelSearch.trim() && (
<button
type="button"
className="flex items-center gap-1.5 w-full px-2 py-1.5 text-xs rounded hover:bg-accent/50 text-muted-foreground"
onClick={() => {
void handleDetectModel();
}}
disabled={detectingModel}
>
<svg aria-hidden="true" focusable="false" className="h-3 w-3" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M21 12a9 9 0 0 0-9-9 9.75 9.75 0 0 0-6.74 2.74L3 8" />
<path d="M3 3v5h5" />
</svg>
{detectingModel ? "Detecting..." : "Detect from Hermes config"}
</button>
)}
{value && !models.some((m) => m.id === value) && (
<button
type="button"
className={cn(
"flex items-center w-full px-2 py-1.5 text-sm rounded bg-accent/50",
)}
onClick={() => {
onOpenChange(false);
}}
>
<span className="block w-full text-left truncate font-mono text-xs" title={value}>
{value}
</span>
<span className="shrink-0 ml-auto text-[9px] font-medium px-1.5 py-0.5 rounded-full bg-green-500/15 text-green-400 border border-green-500/20">
current
</span>
</button>
)}
{detectedModel && detectedModel !== value && (
<button
type="button"
className={cn(
"flex items-center w-full px-2 py-1.5 text-sm rounded hover:bg-accent/50",
)}
onClick={() => {
onChange(detectedModel);
onOpenChange(false);
}}
>
<span className="block w-full text-left truncate font-mono text-xs" title={detectedModel}>
{detectedModel}
</span>
<span className="shrink-0 ml-auto text-[9px] font-medium px-1.5 py-0.5 rounded-full bg-blue-500/15 text-blue-400 border border-blue-500/20">
detected
</span>
</button>
)}
<div className="max-h-[240px] overflow-y-auto">
{allowDefault && (
<button
type="button"
className={cn(
"flex items-center gap-2 w-full px-2 py-1.5 text-sm rounded hover:bg-accent/50",
!value && "bg-accent",
@ -1383,6 +1523,20 @@ function ModelDropdown({
Default
</button>
)}
{canCreateManualModel && (
<button
type="button"
className="flex items-center justify-between gap-2 w-full px-2 py-1.5 text-sm rounded hover:bg-accent/50"
onClick={() => {
onChange(manualModel);
onOpenChange(false);
setModelSearch("");
}}
>
<span>Use manual model</span>
<span className="text-xs font-mono text-muted-foreground">{manualModel}</span>
</button>
)}
{groupedModels.map((group) => (
<div key={group.provider} className="mb-1 last:mb-0">
{groupByProvider && (
@ -1392,6 +1546,7 @@ function ModelDropdown({
)}
{group.entries.map((m) => (
<button
type="button"
key={m.id}
className={cn(
"flex items-center w-full px-2 py-1.5 text-sm rounded hover:bg-accent/50",
@ -1409,8 +1564,14 @@ function ModelDropdown({
))}
</div>
))}
{filteredModels.length === 0 && (
<p className="px-2 py-1.5 text-xs text-muted-foreground">No models found.</p>
{filteredModels.length === 0 && !canCreateManualModel && (
<div className="px-2 py-2 space-y-2">
<p className="text-xs text-muted-foreground">
{onDetectModel
? "No Hermes model detected yet. Configure Hermes or enter a provider/model manually."
: "No models found."}
</p>
</div>
)}
</div>
</PopoverContent>

View file

@ -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 (
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
className={cn(className)}
>
{/* Central staff */}
<line x1="12" y1="6" x2="12" y2="23" />
{/* Left serpent curves */}
<path d="M12 8 C10 9 9.5 11 10.5 13 C11.5 15 10 17 12 18" />
{/* Right serpent curves */}
<path d="M12 8 C14 9 14.5 11 13.5 13 C12.5 15 14 17 12 18" />
{/* Snake heads facing outward */}
<circle cx="10" cy="8" r="0.8" fill="currentColor" stroke="none" />
<circle cx="14" cy="8" r="0.8" fill="currentColor" stroke="none" />
{/* Wings at top of staff */}
<path d="M12 6 L8 3 L6 5 L9 6" strokeWidth="1.2" />
<path d="M12 6 L16 3 L18 5 L15 6" strokeWidth="1.2" />
{/* Wing feather details */}
<line x1="7.5" y1="4" x2="7" y2="5.2" strokeWidth="1" />
<line x1="16.5" y1="4" x2="17" y2="5.2" strokeWidth="1" />
{/* Staff sphere at top */}
<circle cx="12" cy="6.5" r="1.2" />
</svg>
);
}

View file

@ -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",

View file

@ -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") && (

View file

@ -64,6 +64,7 @@ export const adapterLabels: Record<string, string> = {
opencode_local: "OpenCode (local)",
openclaw_gateway: "OpenClaw Gateway",
cursor: "Cursor (local)",
hermes_local: "Hermes Agent",
process: "Process",
http: "HTTP",
};

View file

@ -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<Extract<TranscriptBlock, { type: "tool_group" }>["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<string, Extract<TranscriptBlock, { type: "tool" }>>();
@ -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<TranscriptBlock, { type: "tool_group" }>;
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 (
<div className="rounded-xl border border-border/40 bg-muted/[0.25]">
<div
role="button"
tabIndex={0}
className={cn("flex cursor-pointer gap-2 px-3 py-2.5", subtitle ? "items-start" : "items-center")}
onClick={() => { if (hasSelectedText()) return; setOpen((v) => !v); }}
onKeyDown={(e) => { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); setOpen((v) => !v); } }}
>
<div className={cn("flex shrink-0 items-center", subtitle && "mt-0.5")}>
{block.items.slice(0, Math.min(block.items.length, 3)).map((item, index) => {
const isItemRunning = item.status === "running";
const isItemError = item.status === "error";
return (
<span
key={`${item.ts}-${index}`}
className={cn(
"inline-flex h-6 w-6 items-center justify-center rounded-full border shadow-sm",
index > 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",
)}
>
<Wrench className="h-3.5 w-3.5" />
</span>
);
})}
</div>
<div className="min-w-0 flex-1">
<div className={cn("font-semibold uppercase leading-none tracking-[0.1em]", compact ? "text-[10px]" : "text-[11px]", "text-muted-foreground/70")}>
{title}
</div>
{subtitle && (
<div className={cn("mt-1 break-words font-mono text-foreground/85", compact ? "text-xs" : "text-sm")}>
{subtitle}
</div>
)}
</div>
<button
type="button"
className={cn("inline-flex h-5 w-5 items-center justify-center text-muted-foreground transition-colors hover:text-foreground", subtitle && "mt-0.5")}
onClick={(e) => { e.stopPropagation(); setOpen((v) => !v); }}
aria-label={open ? "Collapse tool details" : "Expand tool details"}
>
{open ? <ChevronDown className="h-4 w-4" /> : <ChevronRight className="h-4 w-4" />}
</button>
</div>
{open && (
<div className={cn("space-y-2 border-t border-border/30 px-3 py-3", hasError && "rounded-b-xl")}>
{block.items.map((item, index) => (
<div key={`${item.ts}-${index}`} className="space-y-1.5">
<div className="flex items-center gap-2">
<span className={cn(
"inline-flex h-5 w-5 shrink-0 items-center justify-center rounded-full border",
item.status === "error"
? "border-red-500/25 bg-red-500/[0.08] text-red-600 dark:text-red-300"
: item.status === "running"
? "border-cyan-500/25 bg-cyan-500/[0.08] text-cyan-600 dark:text-cyan-300"
: "border-border/70 bg-background text-foreground/55",
)}>
<Wrench className="h-3 w-3" />
</span>
<span className={cn("text-[10px] font-semibold uppercase tracking-[0.14em] text-muted-foreground")}>
{humanizeLabel(item.name)}
</span>
<span className={cn("text-[10px] font-semibold uppercase tracking-[0.14em]",
item.status === "running" ? "text-cyan-700 dark:text-cyan-300"
: item.status === "error" ? "text-red-700 dark:text-red-300"
: "text-emerald-700 dark:text-emerald-300"
)}>
{item.status === "running" ? "Running" : item.status === "error" ? "Errored" : "Completed"}
</span>
</div>
<div className={cn("grid gap-2 pl-7", compact ? "grid-cols-1" : "lg:grid-cols-2")}>
<div>
<div className="mb-0.5 text-[10px] font-semibold uppercase tracking-[0.18em] text-muted-foreground">Input</div>
<pre className="overflow-x-auto whitespace-pre-wrap break-words font-mono text-[11px] text-foreground/80">
{formatToolPayload(item.input) || "<empty>"}
</pre>
</div>
{item.result && (
<div>
<div className="mb-0.5 text-[10px] font-semibold uppercase tracking-[0.18em] text-muted-foreground">Result</div>
<pre className={cn(
"overflow-x-auto whitespace-pre-wrap break-words font-mono text-[11px]",
item.status === "error" ? "text-red-700 dark:text-red-300" : "text-foreground/80",
)}>
{formatToolPayload(item.result)}
</pre>
</div>
)}
</div>
</div>
))}
</div>
)}
</div>
);
}
function TranscriptActivityRow({
block,
density,
@ -883,6 +1084,43 @@ function TranscriptEventRow({
);
}
function TranscriptStderrGroup({
block,
density,
}: {
block: Extract<TranscriptBlock, { type: "stderr_group" }>;
density: TranscriptDensity;
}) {
const [open, setOpen] = useState(false);
const compact = density === "compact";
return (
<div className="rounded-xl border border-amber-500/20 bg-amber-500/[0.06] p-2 text-amber-700 dark:text-amber-300">
<div
role="button"
tabIndex={0}
className="flex cursor-pointer items-center gap-2"
onClick={() => setOpen((v) => !v)}
onKeyDown={(e) => { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); setOpen((v) => !v); } }}
>
<span className={cn("text-[10px] font-semibold uppercase tracking-[0.14em]")}>
{block.lines.length} log {block.lines.length === 1 ? "line" : "lines"}
</span>
{open ? <ChevronDown className="h-3.5 w-3.5" /> : <ChevronRight className="h-3.5 w-3.5" />}
</div>
{open && (
<pre className="mt-2 overflow-x-auto whitespace-pre-wrap break-words font-mono text-[11px] text-amber-700/80 dark:text-amber-300/80 pl-5">
{block.lines.map((line, i) => (
<span key={`${line.ts}-${i}`}>
<span className="select-none text-amber-500/50 dark:text-amber-400/40">{i > 0 ? "\n" : ""}</span>
{line.text}
</span>
))}
</pre>
)}
</div>
);
}
function TranscriptStdoutRow({
block,
density,
@ -1003,6 +1241,8 @@ export function RunTranscriptView({
)}
{block.type === "tool" && <TranscriptToolCard block={block} density={density} />}
{block.type === "command_group" && <TranscriptCommandGroup block={block} density={density} />}
{block.type === "tool_group" && <TranscriptToolGroup block={block} density={density} />}
{block.type === "stderr_group" && <TranscriptStderrGroup block={block} density={density} />}
{block.type === "stdout" && (
<TranscriptStdoutRow block={block} density={density} collapseByDefault={collapseStdout} />
)}

View file

@ -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,

View file

@ -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<string, unknown>).summary ?? (run.resultJson as Record<string, unknown>).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("```"));
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 (
<div className="space-y-3">
<div className="flex w-full items-center justify-between">
@ -2351,6 +2369,7 @@ function AgentSkillsTab({
const queryClient = useQueryClient();
const [skillDraft, setSkillDraft] = useState<string[]>([]);
const [lastSavedSkills, setLastSavedSkills] = useState<string[]>([]);
const [unmanagedOpen, setUnmanagedOpen] = useState(false);
const lastSavedSkillsRef = useRef<string[]>([]);
const hasHydratedSkillSnapshotRef = useRef(false);
const skipNextSkillAutosaveRef = useRef(true);
@ -2680,12 +2699,19 @@ function AgentSkillsTab({
{unmanagedSkillRows.length > 0 && (
<section className="border-y border-border">
<div className="border-b border-border bg-muted/40 px-3 py-2">
<div
role="button"
tabIndex={0}
className="flex cursor-pointer items-center gap-2 border-b border-border bg-muted/40 px-3 py-2 select-none"
onClick={() => setUnmanagedOpen((v) => !v)}
onKeyDown={(e) => { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); setUnmanagedOpen((v) => !v); } }}
>
<span className="text-xs font-medium text-muted-foreground">
User-installed skills, not managed by Paperclip
({unmanagedSkillRows.length}) User-installed skills, not managed by Paperclip
</span>
{unmanagedOpen ? <ChevronDown className="h-3.5 w-3.5 text-muted-foreground" /> : <ChevronRight className="h-3.5 w-3.5 text-muted-foreground" />}
</div>
{unmanagedSkillRows.map(renderSkillRow)}
{unmanagedOpen && unmanagedSkillRows.map(renderSkillRow)}
</section>
)}
</>

View file

@ -26,6 +26,7 @@ const adapterLabels: Record<string, string> = {
gemini_local: "Gemini",
opencode_local: "OpenCode",
cursor: "Cursor",
hermes_local: "Hermes",
openclaw_gateway: "OpenClaw Gateway",
process: "Process",
http: "HTTP",

View file

@ -20,11 +20,12 @@ const adapterLabels: Record<string, string> = {
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();

View file

@ -35,6 +35,7 @@ const SUPPORTED_ADVANCED_ADAPTER_TYPES = new Set<CreateConfigValues["adapterType
"opencode_local",
"pi_local",
"cursor",
"hermes_local",
"openclaw_gateway",
]);

View file

@ -122,6 +122,7 @@ const adapterLabels: Record<string, string> = {
gemini_local: "Gemini",
opencode_local: "OpenCode",
cursor: "Cursor",
hermes_local: "Hermes",
openclaw_gateway: "OpenClaw Gateway",
process: "Process",
http: "HTTP",