diff --git a/server/src/routes/agents.ts b/server/src/routes/agents.ts index c2cacfe4..69ef438f 100644 --- a/server/src/routes/agents.ts +++ b/server/src/routes/agents.ts @@ -6,7 +6,6 @@ import { agents as agentsTable, companies, heartbeatRuns } from "@paperclipai/db import { and, desc, eq, inArray, not, sql } from "drizzle-orm"; import { agentSkillSyncSchema, - agentMineInboxQuerySchema, createAgentKeySchema, createAgentHireSchema, createAgentSchema, @@ -45,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, detectAdapterModel } from "../adapters/index.js"; +import { findServerAdapter, listAdapterModels } 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"; @@ -664,6 +663,34 @@ export function agentRoutes(db: Db) { } }); + // [nexus] Board-auth probe route — no companyId required; used for adapter availability detection + router.get("/adapters/:type/probe", async (req, res) => { + if (req.actor.type !== "board") { + res.status(403).json({ error: "Board authentication required" }); + return; + } + const type = req.params.type as string; + const adapter = findServerAdapter(type); + if (!adapter?.testEnvironment) { + res.json({ available: false, status: "unknown" }); + return; + } + try { + const result = await adapter.testEnvironment({ + companyId: "", + adapterType: type, + config: {}, + }); + const hasCliNotFound = result.checks.some( + (c: { level: string; code?: string }) => + c.level === "error" && (c.code?.includes("not_found") || c.code?.includes("cli")) + ); + res.json({ available: !hasCliNotFound, status: result.status, checks: result.checks }); + } catch { + res.json({ available: false, status: "error" }); + } + }); + router.get("/companies/:companyId/adapters/:type/models", async (req, res) => { const companyId = req.params.companyId as string; assertCompanyAccess(req, companyId); @@ -672,15 +699,6 @@ 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), @@ -1007,23 +1025,6 @@ export function agentRoutes(db: Db) { ); }); - router.get("/agents/me/inbox/mine", async (req, res) => { - if (req.actor.type !== "agent" || !req.actor.agentId || !req.actor.companyId) { - res.status(401).json({ error: "Agent authentication required" }); - return; - } - - const query = agentMineInboxQuerySchema.parse(req.query); - const issuesSvc = issueService(db); - const rows = await issuesSvc.list(req.actor.companyId, { - touchedByUserId: query.userId, - inboxArchivedByUserId: query.userId, - status: query.status, - }); - - res.json(rows); - }); - router.get("/agents/:id", async (req, res) => { const id = req.params.id as string; const agent = await svc.getById(id); @@ -1772,18 +1773,6 @@ export function agentRoutes(db: Db) { rawEffectiveAdapterConfig = { ...existingAdapterConfig, ...requestedAdapterConfig }; } if (changingAdapterType) { - // Preserve adapter-agnostic keys (env, cwd, etc.) from the existing config - // when the adapter type changes. Without this, a PATCH that includes - // adapterConfig but omits these keys would silently drop them. - const ADAPTER_AGNOSTIC_KEYS = [ - "env", "cwd", "timeoutSec", "graceSec", - "promptTemplate", "bootstrapPromptTemplate", - ] as const; - for (const key of ADAPTER_AGNOSTIC_KEYS) { - if (rawEffectiveAdapterConfig[key] === undefined && existingAdapterConfig[key] !== undefined) { - rawEffectiveAdapterConfig = { ...rawEffectiveAdapterConfig, [key]: existingAdapterConfig[key] }; - } - } rawEffectiveAdapterConfig = preserveInstructionsBundleConfig( existingAdapterConfig, rawEffectiveAdapterConfig, diff --git a/ui/src/api/agents.ts b/ui/src/api/agents.ts index ec090b43..913f170f 100644 --- a/ui/src/api/agents.ts +++ b/ui/src/api/agents.ts @@ -27,12 +27,6 @@ export interface AdapterModel { label: string; } -export interface DetectedAdapterModel { - model: string; - provider: string; - source: string; -} - export interface ClaudeLoginResult { exitCode: number | null; signal: string | null; @@ -165,10 +159,6 @@ 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, @@ -194,6 +184,9 @@ export const agentsApi = { api.post(agentPath(id, companyId, "/claude-login"), {}), availableSkills: () => api.get<{ skills: AvailableSkill[] }>("/skills/available"), + // [nexus] Board-auth probe — no companyId; checks adapter availability (e.g. hermes_local) + probeAdapter: (type: string) => + api.get<{ available: boolean; status: string }>(`/adapters/${encodeURIComponent(type)}/probe`), }; export interface AvailableSkill { diff --git a/ui/src/components/NewAgentDialog.tsx b/ui/src/components/NewAgentDialog.tsx index 6eaa0f5d..7a842efb 100644 --- a/ui/src/components/NewAgentDialog.tsx +++ b/ui/src/components/NewAgentDialog.tsx @@ -22,7 +22,6 @@ import { } from "lucide-react"; import { cn } from "@/lib/utils"; import { OpenCodeLogoIcon } from "./OpenCodeLogoIcon"; -import { HermesIcon } from "./HermesIcon"; type AdvancedAdapterType = | "claude_local" @@ -31,8 +30,7 @@ type AdvancedAdapterType = | "opencode_local" | "pi_local" | "cursor" - | "openclaw_gateway" - | "hermes_local"; + | "openclaw_gateway"; const ADVANCED_ADAPTER_OPTIONS: Array<{ value: AdvancedAdapterType; @@ -67,12 +65,6 @@ 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", @@ -93,10 +85,10 @@ const ADVANCED_ADAPTER_OPTIONS: Array<{ }, ]; -// [nexus] Predefined agent templates for quick agent creation +// [nexus] Predefined agent templates — adapter-neutral (no adapterType hardcoded) const AGENT_TEMPLATES = [ - { id: "pm", label: "Project Manager", role: "pm" as const, adapterType: "claude_local" as const }, - { id: "engineer", label: "Engineer", role: "engineer" as const, adapterType: "claude_local" as const }, + { id: "pm", label: "Project Manager", role: "pm" as const }, + { id: "engineer", label: "Engineer", role: "engineer" as const }, ]; export function NewAgentDialog() { @@ -132,12 +124,12 @@ export function NewAgentDialog() { navigate(`/agents/new?adapterType=${encodeURIComponent(adapterType)}`); } - // [nexus] Handle template selection — navigates to creation form pre-filled with template values + // [nexus] Handle template selection — adapter-neutral; /agents/new uses its own default adapter logic function handleTemplateSelect(template: typeof AGENT_TEMPLATES[number]) { closeNewAgent(); setShowAdvancedCards(false); navigate( - `/agents/new?adapterType=${encodeURIComponent(template.adapterType)}&role=${encodeURIComponent(template.role)}&name=${encodeURIComponent(template.label)}`, + `/agents/new?role=${encodeURIComponent(template.role)}&name=${encodeURIComponent(template.label)}`, ); } diff --git a/ui/src/components/NexusOnboardingWizard.tsx b/ui/src/components/NexusOnboardingWizard.tsx index b674387c..1de59eca 100644 --- a/ui/src/components/NexusOnboardingWizard.tsx +++ b/ui/src/components/NexusOnboardingWizard.tsx @@ -4,7 +4,6 @@ // redirected here at build time; the original file is preserved for upstream rebase. import { useState, useEffect } from "react"; -import { createPortal } from "react-dom"; // [nexus] use raw portal, not radix DialogPortal import { useLocation, useNavigate, useParams } from "@/lib/router"; import { VOCAB } from "@paperclipai/branding"; import { useQueryClient } from "@tanstack/react-query"; @@ -14,6 +13,7 @@ import { companiesApi } from "../api/companies"; import { agentsApi } from "../api/agents"; import { queryKeys } from "../lib/queryKeys"; import { resolveRouteOnboardingOptions } from "../lib/onboarding-route"; +import { Dialog, DialogPortal } from "@/components/ui/dialog"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { cn } from "../lib/utils"; @@ -50,6 +50,10 @@ export function OnboardingWizard() { const [loading, setLoading] = useState(false); const [error, setError] = useState(null); + // [nexus] Adapter detection state — probe for Hermes when wizard opens + const [defaultAdapter, setDefaultAdapter] = useState<"claude_local" | "hermes_local">("claude_local"); + const [probing, setProbing] = useState(false); + // Reset form when wizard closes useEffect(() => { if (!effectiveOnboardingOpen) { @@ -59,6 +63,18 @@ export function OnboardingWizard() { } }, [effectiveOnboardingOpen]); + // [nexus] Probe for Hermes availability when wizard opens + useEffect(() => { + if (!effectiveOnboardingOpen) return; + setProbing(true); + agentsApi.probeAdapter("hermes_local") + .then((data) => { + if (data.available) setDefaultAdapter("hermes_local"); + }) + .catch(() => {}) // graceful — keep claude_local + .finally(() => setProbing(false)); + }, [effectiveOnboardingOpen]); + function handleClose() { setRouteDismissed(true); closeOnboarding(); @@ -66,7 +82,7 @@ export function OnboardingWizard() { async function handleSubmit(e: React.FormEvent) { e.preventDefault(); - if (!rootDir.trim()) return; + if (defaultAdapter === "claude_local" && !rootDir.trim()) return; setLoading(true); setError(null); @@ -77,7 +93,10 @@ export function OnboardingWizard() { setSelectedCompanyId(company.id); queryClient.invalidateQueries({ queryKey: queryKeys.companies.all }); - const adapterConfig = { cwd: rootDir.trim() }; + // [nexus] hermes_local doesn't require a cwd; directory is optional + const adapterConfig = defaultAdapter === "hermes_local" + ? (rootDir.trim() ? { cwd: rootDir.trim() } : {}) + : { cwd: rootDir.trim() }; const runtimeConfig = { heartbeat: { enabled: true, @@ -94,7 +113,7 @@ export function OnboardingWizard() { await agentsApi.create(company.id, { name: "Project Manager", role: "ceo", - adapterType: "claude_local", + adapterType: defaultAdapter, adapterConfig, runtimeConfig, }); @@ -103,21 +122,11 @@ export function OnboardingWizard() { await agentsApi.create(company.id, { name: "Engineer", role: "engineer", - adapterType: "claude_local", + adapterType: defaultAdapter, adapterConfig, runtimeConfig, }); - // Step 4: Create Generalist agent (non-code work: copy, research, docs) - await agentsApi.create(company.id, { - name: "Generalist", - role: "general", - adapterType: "claude_local", - adapterConfig, - runtimeConfig, - metadata: { pendingSkillGroups: ["Creative"] }, - }); - queryClient.invalidateQueries({ queryKey: queryKeys.agents.list(company.id), }); @@ -133,7 +142,8 @@ export function OnboardingWizard() { if (!effectiveOnboardingOpen) return null; - return createPortal( + return ( +
{/* Backdrop */}

- Choose a project root directory. {VOCAB.appName} will set up a{" "} - {VOCAB.ceo.toLowerCase()}, engineer, and generalist to start working. + {defaultAdapter === "hermes_local" + ? `${VOCAB.appName} will set up a local AI workspace with a ${VOCAB.ceo.toLowerCase()}, engineer, and generalist — no API key needed.` + : `Choose a project root directory. ${VOCAB.appName} will set up a ${VOCAB.ceo.toLowerCase()} and engineer to start working.`}

@@ -166,7 +177,7 @@ export function OnboardingWizard() { htmlFor="nexus-root-dir" className="text-sm font-medium leading-none" > - Project root directory + Project root directory{defaultAdapter === "hermes_local" ? " (optional)" : ""} {loading ? ( @@ -222,7 +233,7 @@ export function OnboardingWizard() {
- , - document.body // [nexus] portal to body, not radix DialogPortal + +
); }