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 372df3919a
commit 1ff3953c97
4 changed files with 71 additions and 86 deletions

View file

@ -6,7 +6,6 @@ import { agents as agentsTable, companies, heartbeatRuns, issues as issuesTable
import { and, desc, eq, inArray, not, sql } from "drizzle-orm";
import {
agentSkillSyncSchema,
agentMineInboxQuerySchema,
createAgentKeySchema,
createAgentHireSchema,
createAgentSchema,
@ -46,7 +45,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";
@ -740,6 +739,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);
@ -748,15 +775,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),
@ -1083,23 +1101,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);
@ -1856,18 +1857,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,

View file

@ -28,12 +28,6 @@ export interface AdapterModel {
label: string;
}
export interface DetectedAdapterModel {
model: string;
provider: string;
source: string;
}
export interface ClaudeLoginResult {
exitCode: number | null;
signal: string | null;
@ -166,10 +160,6 @@ export const agentsApi = {
api.get<AdapterModel[]>(
`/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: (
companyId: string,
type: string,
@ -195,6 +185,9 @@ export const agentsApi = {
api.post<ClaudeLoginResult>(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 {

View file

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

View file

@ -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<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
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 (
<DialogPortal>
<div className="fixed inset-0 z-50 flex items-center justify-center">
{/* Backdrop */}
<div
@ -154,8 +164,9 @@ export function OnboardingWizard() {
Welcome to {VOCAB.appName}
</h1>
<p className="text-sm text-muted-foreground">
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.`}
</p>
</div>
@ -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)" : ""}
</label>
<Input
id="nexus-root-dir"
@ -189,7 +200,7 @@ export function OnboardingWizard() {
<Button
type="submit"
disabled={loading || !rootDir.trim()}
disabled={loading || probing || (defaultAdapter === "claude_local" && !rootDir.trim())}
className="w-full"
>
{loading ? (
@ -222,7 +233,7 @@ export function OnboardingWizard() {
</Button>
</form>
</div>
</div>,
document.body // [nexus] portal to body, not radix DialogPortal
</div>
</DialogPortal>
);
}