feat(29-01): adapter probe route, Hermes onboarding fallback, neutral templates

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Nexus Dev 2026-04-02 17:34:10 +00:00
parent a01e58d9d4
commit e0a82ed2f2
4 changed files with 71 additions and 86 deletions

View file

@ -6,7 +6,6 @@ import { agents as agentsTable, companies, heartbeatRuns } from "@paperclipai/db
import { and, desc, eq, inArray, not, sql } from "drizzle-orm"; import { and, desc, eq, inArray, not, sql } from "drizzle-orm";
import { import {
agentSkillSyncSchema, agentSkillSyncSchema,
agentMineInboxQuerySchema,
createAgentKeySchema, createAgentKeySchema,
createAgentHireSchema, createAgentHireSchema,
createAgentSchema, createAgentSchema,
@ -45,7 +44,7 @@ import {
} from "../services/index.js"; } from "../services/index.js";
import { conflict, forbidden, notFound, unprocessable } from "../errors.js"; import { conflict, forbidden, notFound, unprocessable } from "../errors.js";
import { assertBoard, assertCompanyAccess, assertInstanceAdmin, getActorInfo } from "./authz.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 { redactEventPayload } from "../redaction.js";
import { redactCurrentUserValue } from "../log-redaction.js"; import { redactCurrentUserValue } from "../log-redaction.js";
import { renderOrgChartSvg, renderOrgChartPng, type OrgNode, type OrgChartStyle, ORG_CHART_STYLES } from "./org-chart-svg.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) => { router.get("/companies/:companyId/adapters/:type/models", async (req, res) => {
const companyId = req.params.companyId as string; const companyId = req.params.companyId as string;
assertCompanyAccess(req, companyId); assertCompanyAccess(req, companyId);
@ -672,15 +699,6 @@ export function agentRoutes(db: Db) {
res.json(models); 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( router.post(
"/companies/:companyId/adapters/:type/test-environment", "/companies/:companyId/adapters/:type/test-environment",
validate(testAdapterEnvironmentSchema), 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) => { router.get("/agents/:id", async (req, res) => {
const id = req.params.id as string; const id = req.params.id as string;
const agent = await svc.getById(id); const agent = await svc.getById(id);
@ -1772,18 +1773,6 @@ export function agentRoutes(db: Db) {
rawEffectiveAdapterConfig = { ...existingAdapterConfig, ...requestedAdapterConfig }; rawEffectiveAdapterConfig = { ...existingAdapterConfig, ...requestedAdapterConfig };
} }
if (changingAdapterType) { 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( rawEffectiveAdapterConfig = preserveInstructionsBundleConfig(
existingAdapterConfig, existingAdapterConfig,
rawEffectiveAdapterConfig, rawEffectiveAdapterConfig,

View file

@ -27,12 +27,6 @@ export interface AdapterModel {
label: string; label: string;
} }
export interface DetectedAdapterModel {
model: string;
provider: string;
source: string;
}
export interface ClaudeLoginResult { export interface ClaudeLoginResult {
exitCode: number | null; exitCode: number | null;
signal: string | null; signal: string | null;
@ -165,10 +159,6 @@ export const agentsApi = {
api.get<AdapterModel[]>( api.get<AdapterModel[]>(
`/companies/${encodeURIComponent(companyId)}/adapters/${encodeURIComponent(type)}/models`, `/companies/${encodeURIComponent(companyId)}/adapters/${encodeURIComponent(type)}/models`,
), ),
detectModel: (companyId: string, type: string) =>
api.get<DetectedAdapterModel | null>(
`/companies/${encodeURIComponent(companyId)}/adapters/${encodeURIComponent(type)}/detect-model`,
),
testEnvironment: ( testEnvironment: (
companyId: string, companyId: string,
type: string, type: string,
@ -194,6 +184,9 @@ export const agentsApi = {
api.post<ClaudeLoginResult>(agentPath(id, companyId, "/claude-login"), {}), api.post<ClaudeLoginResult>(agentPath(id, companyId, "/claude-login"), {}),
availableSkills: () => availableSkills: () =>
api.get<{ skills: AvailableSkill[] }>("/skills/available"), 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 { export interface AvailableSkill {

View file

@ -22,7 +22,6 @@ import {
} from "lucide-react"; } from "lucide-react";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { OpenCodeLogoIcon } from "./OpenCodeLogoIcon"; import { OpenCodeLogoIcon } from "./OpenCodeLogoIcon";
import { HermesIcon } from "./HermesIcon";
type AdvancedAdapterType = type AdvancedAdapterType =
| "claude_local" | "claude_local"
@ -31,8 +30,7 @@ type AdvancedAdapterType =
| "opencode_local" | "opencode_local"
| "pi_local" | "pi_local"
| "cursor" | "cursor"
| "openclaw_gateway" | "openclaw_gateway";
| "hermes_local";
const ADVANCED_ADAPTER_OPTIONS: Array<{ const ADVANCED_ADAPTER_OPTIONS: Array<{
value: AdvancedAdapterType; value: AdvancedAdapterType;
@ -67,12 +65,6 @@ const ADVANCED_ADAPTER_OPTIONS: Array<{
icon: OpenCodeLogoIcon, icon: OpenCodeLogoIcon,
desc: "Local multi-provider agent", desc: "Local multi-provider agent",
}, },
{
value: "hermes_local",
label: "Hermes Agent",
icon: HermesIcon,
desc: "Local multi-provider agent",
},
{ {
value: "pi_local", value: "pi_local",
label: "Pi", 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 = [ const AGENT_TEMPLATES = [
{ id: "pm", label: "Project Manager", role: "pm" as const, adapterType: "claude_local" as const }, { id: "pm", label: "Project Manager", role: "pm" as const },
{ id: "engineer", label: "Engineer", role: "engineer" as const, adapterType: "claude_local" as const }, { id: "engineer", label: "Engineer", role: "engineer" as const },
]; ];
export function NewAgentDialog() { export function NewAgentDialog() {
@ -132,12 +124,12 @@ export function NewAgentDialog() {
navigate(`/agents/new?adapterType=${encodeURIComponent(adapterType)}`); 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]) { function handleTemplateSelect(template: typeof AGENT_TEMPLATES[number]) {
closeNewAgent(); closeNewAgent();
setShowAdvancedCards(false); setShowAdvancedCards(false);
navigate( 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)}`,
); );
} }

View file

@ -4,7 +4,6 @@
// redirected here at build time; the original file is preserved for upstream rebase. // redirected here at build time; the original file is preserved for upstream rebase.
import { useState, useEffect } from "react"; 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 { useLocation, useNavigate, useParams } from "@/lib/router";
import { VOCAB } from "@paperclipai/branding"; import { VOCAB } from "@paperclipai/branding";
import { useQueryClient } from "@tanstack/react-query"; import { useQueryClient } from "@tanstack/react-query";
@ -14,6 +13,7 @@ import { companiesApi } from "../api/companies";
import { agentsApi } from "../api/agents"; import { agentsApi } from "../api/agents";
import { queryKeys } from "../lib/queryKeys"; import { queryKeys } from "../lib/queryKeys";
import { resolveRouteOnboardingOptions } from "../lib/onboarding-route"; import { resolveRouteOnboardingOptions } from "../lib/onboarding-route";
import { Dialog, DialogPortal } from "@/components/ui/dialog";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { cn } from "../lib/utils"; import { cn } from "../lib/utils";
@ -50,6 +50,10 @@ export function OnboardingWizard() {
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(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 // Reset form when wizard closes
useEffect(() => { useEffect(() => {
if (!effectiveOnboardingOpen) { if (!effectiveOnboardingOpen) {
@ -59,6 +63,18 @@ export function OnboardingWizard() {
} }
}, [effectiveOnboardingOpen]); }, [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() { function handleClose() {
setRouteDismissed(true); setRouteDismissed(true);
closeOnboarding(); closeOnboarding();
@ -66,7 +82,7 @@ export function OnboardingWizard() {
async function handleSubmit(e: React.FormEvent) { async function handleSubmit(e: React.FormEvent) {
e.preventDefault(); e.preventDefault();
if (!rootDir.trim()) return; if (defaultAdapter === "claude_local" && !rootDir.trim()) return;
setLoading(true); setLoading(true);
setError(null); setError(null);
@ -77,7 +93,10 @@ export function OnboardingWizard() {
setSelectedCompanyId(company.id); setSelectedCompanyId(company.id);
queryClient.invalidateQueries({ queryKey: queryKeys.companies.all }); 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 = { const runtimeConfig = {
heartbeat: { heartbeat: {
enabled: true, enabled: true,
@ -94,7 +113,7 @@ export function OnboardingWizard() {
await agentsApi.create(company.id, { await agentsApi.create(company.id, {
name: "Project Manager", name: "Project Manager",
role: "ceo", role: "ceo",
adapterType: "claude_local", adapterType: defaultAdapter,
adapterConfig, adapterConfig,
runtimeConfig, runtimeConfig,
}); });
@ -103,21 +122,11 @@ export function OnboardingWizard() {
await agentsApi.create(company.id, { await agentsApi.create(company.id, {
name: "Engineer", name: "Engineer",
role: "engineer", role: "engineer",
adapterType: "claude_local", adapterType: defaultAdapter,
adapterConfig, adapterConfig,
runtimeConfig, 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({ queryClient.invalidateQueries({
queryKey: queryKeys.agents.list(company.id), queryKey: queryKeys.agents.list(company.id),
}); });
@ -133,7 +142,8 @@ export function OnboardingWizard() {
if (!effectiveOnboardingOpen) return null; if (!effectiveOnboardingOpen) return null;
return createPortal( return (
<DialogPortal>
<div className="fixed inset-0 z-50 flex items-center justify-center"> <div className="fixed inset-0 z-50 flex items-center justify-center">
{/* Backdrop */} {/* Backdrop */}
<div <div
@ -154,8 +164,9 @@ export function OnboardingWizard() {
Welcome to {VOCAB.appName} Welcome to {VOCAB.appName}
</h1> </h1>
<p className="text-sm text-muted-foreground"> <p className="text-sm text-muted-foreground">
Choose a project root directory. {VOCAB.appName} will set up a{" "} {defaultAdapter === "hermes_local"
{VOCAB.ceo.toLowerCase()}, engineer, and generalist to start working. ? `${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.`}
</p> </p>
</div> </div>
@ -166,7 +177,7 @@ export function OnboardingWizard() {
htmlFor="nexus-root-dir" htmlFor="nexus-root-dir"
className="text-sm font-medium leading-none" className="text-sm font-medium leading-none"
> >
Project root directory Project root directory{defaultAdapter === "hermes_local" ? " (optional)" : ""}
</label> </label>
<Input <Input
id="nexus-root-dir" id="nexus-root-dir"
@ -189,7 +200,7 @@ export function OnboardingWizard() {
<Button <Button
type="submit" type="submit"
disabled={loading || !rootDir.trim()} disabled={loading || probing || (defaultAdapter === "claude_local" && !rootDir.trim())}
className="w-full" className="w-full"
> >
{loading ? ( {loading ? (
@ -222,7 +233,7 @@ export function OnboardingWizard() {
</Button> </Button>
</form> </form>
</div> </div>
</div>, </div>
document.body // [nexus] portal to body, not radix DialogPortal </DialogPortal>
); );
} }