Plugins: add inspect command and capability report

This commit is contained in:
Vincent Koc 2026-03-17 10:15:22 -07:00
parent e4825a0f93
commit 3983928958
6 changed files with 469 additions and 81 deletions

View File

@ -35,7 +35,7 @@ describe("handleCommands /plugins", () => {
await workspaceHarness.cleanupWorkspaces();
});
it("lists discovered plugins and shows plugin details", async () => {
it("lists discovered plugins and inspects plugin details", async () => {
await withTempHome("openclaw-command-plugins-home-", async () => {
const workspaceDir = await workspaceHarness.createWorkspace();
await createClaudeBundlePlugin({ workspaceDir, pluginId: "superpowers" });
@ -49,13 +49,19 @@ describe("handleCommands /plugins", () => {
expect(listResult.reply?.text).toContain("superpowers");
expect(listResult.reply?.text).toContain("[disabled]");
const showParams = buildCommandTestParams("/plugin show superpowers", buildCfg(), undefined, {
workspaceDir,
});
const showParams = buildCommandTestParams(
"/plugins inspect superpowers",
buildCfg(),
undefined,
{
workspaceDir,
},
);
showParams.command.senderIsOwner = true;
const showResult = await handleCommands(showParams);
expect(showResult.reply?.text).toContain('"id": "superpowers"');
expect(showResult.reply?.text).toContain('"bundleFormat": "claude"');
expect(showResult.reply?.text).toContain('"shape":');
});
});

View File

@ -4,8 +4,13 @@ import {
writeConfigFile,
} from "../../config/config.js";
import type { OpenClawConfig } from "../../config/config.js";
import type { PluginInstallRecord } from "../../config/types.plugins.js";
import type { PluginRecord } from "../../plugins/registry.js";
import { buildPluginStatusReport, type PluginStatusReport } from "../../plugins/status.js";
import {
buildPluginInspectReport,
buildPluginStatusReport,
type PluginStatusReport,
} from "../../plugins/status.js";
import { setPluginEnabledInConfig } from "../../plugins/toggle-config.js";
import { isInternalMessageChannel } from "../../utils/message-channel.js";
import {
@ -21,6 +26,28 @@ function renderJsonBlock(label: string, value: unknown): string {
return `${label}\n\`\`\`json\n${JSON.stringify(value, null, 2)}\n\`\`\``;
}
function buildPluginInspectJson(params: {
id: string;
config: OpenClawConfig;
report: PluginStatusReport;
}): {
inspect: NonNullable<ReturnType<typeof buildPluginInspectReport>>;
install: PluginInstallRecord | null;
} | null {
const inspect = buildPluginInspectReport({
id: params.id,
config: params.config,
report: params.report,
});
if (!inspect) {
return null;
}
return {
inspect,
install: params.config.plugins?.installs?.[inspect.plugin.id] ?? null,
};
}
function formatPluginLabel(plugin: PluginRecord): string {
if (!plugin.name || plugin.name === plugin.id) {
return plugin.id;
@ -95,7 +122,7 @@ export const handlePluginsCommand: CommandHandler = async (params, allowTextComm
return unauthorized;
}
const allowInternalReadOnly =
(pluginsCommand.action === "list" || pluginsCommand.action === "show") &&
(pluginsCommand.action === "list" || pluginsCommand.action === "inspect") &&
isInternalMessageChannel(params.command.channel);
const nonOwner = allowInternalReadOnly ? null : rejectNonOwnerCommand(params, "/plugins");
if (nonOwner) {
@ -130,27 +157,30 @@ export const handlePluginsCommand: CommandHandler = async (params, allowTextComm
};
}
if (pluginsCommand.action === "show") {
if (pluginsCommand.action === "inspect") {
if (!pluginsCommand.name) {
return {
shouldContinue: false,
reply: { text: formatPluginsList(loaded.report) },
};
}
const plugin = findPlugin(loaded.report, pluginsCommand.name);
if (!plugin) {
const payload = buildPluginInspectJson({
id: pluginsCommand.name,
config: loaded.config,
report: loaded.report,
});
if (!payload) {
return {
shouldContinue: false,
reply: { text: `🔌 No plugin named "${pluginsCommand.name}" found.` },
};
}
const install = loaded.config.plugins?.installs?.[plugin.id] ?? null;
return {
shouldContinue: false,
reply: {
text: renderJsonBlock(`🔌 Plugin "${plugin.id}"`, {
plugin,
install,
text: renderJsonBlock(`🔌 Plugin "${payload.inspect.plugin.id}"`, {
...payload.inspect,
install: payload.install,
}),
},
};

View File

@ -1,6 +1,6 @@
export type PluginsCommand =
| { action: "list" }
| { action: "show"; name?: string }
| { action: "inspect"; name?: string }
| { action: "enable"; name: string }
| { action: "disable"; name: string }
| { action: "error"; message: string };
@ -22,12 +22,15 @@ export function parsePluginsCommand(raw: string): PluginsCommand | null {
if (action === "list") {
return name
? { action: "error", message: "Usage: /plugins list|show|get|enable|disable [plugin]" }
? {
action: "error",
message: "Usage: /plugins list|inspect|show|get|enable|disable [plugin]",
}
: { action: "list" };
}
if (action === "show" || action === "get") {
return { action: "show", name: name || undefined };
if (action === "inspect" || action === "show" || action === "get") {
return { action: "inspect", name: name || undefined };
}
if (action === "enable" || action === "disable") {
@ -42,6 +45,6 @@ export function parsePluginsCommand(raw: string): PluginsCommand | null {
return {
action: "error",
message: "Usage: /plugins list|show|get|enable|disable [plugin]",
message: "Usage: /plugins list|inspect|show|get|enable|disable [plugin]",
};
}

View File

@ -5,6 +5,7 @@ import type { Command } from "commander";
import type { OpenClawConfig } from "../config/config.js";
import { loadConfig, writeConfigFile } from "../config/config.js";
import { resolveStateDir } from "../config/paths.js";
import type { PluginInstallRecord } from "../config/types.plugins.js";
import { resolveArchiveKind } from "../infra/archive.js";
import { type BundledPluginSource, findBundledPluginSource } from "../plugins/bundled-sources.js";
import { enablePluginInConfig } from "../plugins/enable.js";
@ -19,7 +20,7 @@ import {
import type { PluginRecord } from "../plugins/registry.js";
import { applyExclusiveSlotSelection } from "../plugins/slots.js";
import { resolvePluginSourceRoots, formatPluginSourceForTable } from "../plugins/source-display.js";
import { buildPluginStatusReport } from "../plugins/status.js";
import { buildPluginInspectReport, buildPluginStatusReport } from "../plugins/status.js";
import { resolveUninstallDirectoryTarget, uninstallPlugin } from "../plugins/uninstall.js";
import { updateNpmInstalledPlugins } from "../plugins/update.js";
import { defaultRuntime } from "../runtime.js";
@ -42,7 +43,7 @@ export type PluginsListOptions = {
verbose?: boolean;
};
export type PluginInfoOptions = {
export type PluginInspectOptions = {
json?: boolean;
};
@ -133,6 +134,36 @@ function formatPluginLine(plugin: PluginRecord, verbose = false): string {
return parts.join("\n");
}
function formatInspectSection(title: string, lines: string[]): string[] {
if (lines.length === 0) {
return [];
}
return ["", `${theme.muted(`${title}:`)}`, ...lines];
}
function formatInstallLines(install: PluginInstallRecord | undefined): string[] {
if (!install) {
return [];
}
const lines = [`Source: ${install.source}`];
if (install.spec) {
lines.push(`Spec: ${install.spec}`);
}
if (install.sourcePath) {
lines.push(`Source path: ${shortenHomePath(install.sourcePath)}`);
}
if (install.installPath) {
lines.push(`Install path: ${shortenHomePath(install.installPath)}`);
}
if (install.version) {
lines.push(`Recorded version: ${install.version}`);
}
if (install.installedAt) {
lines.push(`Installed at: ${install.installedAt}`);
}
return lines;
}
function applySlotSelectionForPlugin(
config: OpenClawConfig,
pluginId: string,
@ -542,88 +573,133 @@ export function registerPluginsCli(program: Command) {
});
plugins
.command("info")
.description("Show plugin details")
.command("inspect")
.alias("info")
.description("Inspect plugin details")
.argument("<id>", "Plugin id")
.option("--json", "Print JSON")
.action((id: string, opts: PluginInfoOptions) => {
const report = buildPluginStatusReport();
const plugin = report.plugins.find((p) => p.id === id || p.name === id);
if (!plugin) {
.action((id: string, opts: PluginInspectOptions) => {
const cfg = loadConfig();
const report = buildPluginStatusReport({ config: cfg });
const inspect = buildPluginInspectReport({
id,
config: cfg,
report,
});
if (!inspect) {
defaultRuntime.error(`Plugin not found: ${id}`);
process.exit(1);
}
const cfg = loadConfig();
const install = cfg.plugins?.installs?.[plugin.id];
const install = cfg.plugins?.installs?.[inspect.plugin.id];
if (opts.json) {
defaultRuntime.log(JSON.stringify(plugin, null, 2));
defaultRuntime.log(
JSON.stringify(
{
...inspect,
install,
},
null,
2,
),
);
return;
}
const lines: string[] = [];
lines.push(theme.heading(plugin.name || plugin.id));
if (plugin.name && plugin.name !== plugin.id) {
lines.push(theme.muted(`id: ${plugin.id}`));
lines.push(theme.heading(inspect.plugin.name || inspect.plugin.id));
if (inspect.plugin.name && inspect.plugin.name !== inspect.plugin.id) {
lines.push(theme.muted(`id: ${inspect.plugin.id}`));
}
if (plugin.description) {
lines.push(plugin.description);
if (inspect.plugin.description) {
lines.push(inspect.plugin.description);
}
lines.push("");
lines.push(`${theme.muted("Status:")} ${plugin.status}`);
lines.push(`${theme.muted("Format:")} ${plugin.format ?? "openclaw"}`);
if (plugin.bundleFormat) {
lines.push(`${theme.muted("Bundle format:")} ${plugin.bundleFormat}`);
lines.push(`${theme.muted("Status:")} ${inspect.plugin.status}`);
lines.push(`${theme.muted("Format:")} ${inspect.plugin.format ?? "openclaw"}`);
if (inspect.plugin.bundleFormat) {
lines.push(`${theme.muted("Bundle format:")} ${inspect.plugin.bundleFormat}`);
}
lines.push(`${theme.muted("Source:")} ${shortenHomeInString(plugin.source)}`);
lines.push(`${theme.muted("Origin:")} ${plugin.origin}`);
if (plugin.version) {
lines.push(`${theme.muted("Version:")} ${plugin.version}`);
lines.push(`${theme.muted("Source:")} ${shortenHomeInString(inspect.plugin.source)}`);
lines.push(`${theme.muted("Origin:")} ${inspect.plugin.origin}`);
if (inspect.plugin.version) {
lines.push(`${theme.muted("Version:")} ${inspect.plugin.version}`);
}
if (plugin.toolNames.length > 0) {
lines.push(`${theme.muted("Tools:")} ${plugin.toolNames.join(", ")}`);
}
if (plugin.hookNames.length > 0) {
lines.push(`${theme.muted("Hooks:")} ${plugin.hookNames.join(", ")}`);
}
if (plugin.gatewayMethods.length > 0) {
lines.push(`${theme.muted("Gateway methods:")} ${plugin.gatewayMethods.join(", ")}`);
}
if (plugin.providerIds.length > 0) {
lines.push(`${theme.muted("Providers:")} ${plugin.providerIds.join(", ")}`);
}
if ((plugin.bundleCapabilities?.length ?? 0) > 0) {
lines.push(`${theme.muted("Shape:")} ${inspect.shape}`);
lines.push(`${theme.muted("Capability mode:")} ${inspect.capabilityMode}`);
lines.push(
`${theme.muted("Legacy before_agent_start:")} ${inspect.usesLegacyBeforeAgentStart ? "yes" : "no"}`,
);
if ((inspect.plugin.bundleCapabilities?.length ?? 0) > 0) {
lines.push(
`${theme.muted("Bundle capabilities:")} ${plugin.bundleCapabilities?.join(", ")}`,
`${theme.muted("Bundle capabilities:")} ${inspect.plugin.bundleCapabilities?.join(", ")}`,
);
}
if (plugin.cliCommands.length > 0) {
lines.push(`${theme.muted("CLI commands:")} ${plugin.cliCommands.join(", ")}`);
lines.push(
...formatInspectSection(
"Capabilities",
inspect.capabilities.map(
(entry) =>
`${entry.kind}: ${entry.ids.length > 0 ? entry.ids.join(", ") : "(registered)"}`,
),
),
);
lines.push(
...formatInspectSection(
"Typed hooks",
inspect.typedHooks.map((entry) =>
entry.priority == null ? entry.name : `${entry.name} (priority ${entry.priority})`,
),
),
);
lines.push(
...formatInspectSection(
"Custom hooks",
inspect.customHooks.map((entry) => `${entry.name}: ${entry.events.join(", ")}`),
),
);
lines.push(
...formatInspectSection(
"Tools",
inspect.tools.map((entry) => {
const names = entry.names.length > 0 ? entry.names.join(", ") : "(anonymous)";
return entry.optional ? `${names} [optional]` : names;
}),
),
);
lines.push(...formatInspectSection("Commands", inspect.commands));
lines.push(...formatInspectSection("CLI commands", inspect.cliCommands));
lines.push(...formatInspectSection("Services", inspect.services));
lines.push(...formatInspectSection("Gateway methods", inspect.gatewayMethods));
if (inspect.httpRouteCount > 0) {
lines.push(...formatInspectSection("HTTP routes", [String(inspect.httpRouteCount)]));
}
if (plugin.services.length > 0) {
lines.push(`${theme.muted("Services:")} ${plugin.services.join(", ")}`);
const policyLines: string[] = [];
if (typeof inspect.policy.allowPromptInjection === "boolean") {
policyLines.push(`allowPromptInjection: ${inspect.policy.allowPromptInjection}`);
}
if (plugin.error) {
lines.push(`${theme.error("Error:")} ${plugin.error}`);
if (typeof inspect.policy.allowModelOverride === "boolean") {
policyLines.push(`allowModelOverride: ${inspect.policy.allowModelOverride}`);
}
if (install) {
lines.push("");
lines.push(`${theme.muted("Install:")} ${install.source}`);
if (install.spec) {
lines.push(`${theme.muted("Spec:")} ${install.spec}`);
}
if (install.sourcePath) {
lines.push(`${theme.muted("Source path:")} ${shortenHomePath(install.sourcePath)}`);
}
if (install.installPath) {
lines.push(`${theme.muted("Install path:")} ${shortenHomePath(install.installPath)}`);
}
if (install.version) {
lines.push(`${theme.muted("Recorded version:")} ${install.version}`);
}
if (install.installedAt) {
lines.push(`${theme.muted("Installed at:")} ${install.installedAt}`);
}
if (inspect.policy.hasAllowedModelsConfig) {
policyLines.push(
`allowedModels: ${
inspect.policy.allowedModels.length > 0
? inspect.policy.allowedModels.join(", ")
: "(configured but empty)"
}`,
);
}
lines.push(...formatInspectSection("Policy", policyLines));
lines.push(
...formatInspectSection(
"Diagnostics",
inspect.diagnostics.map((entry) => `${entry.level.toUpperCase()}: ${entry.message}`),
),
);
lines.push(...formatInspectSection("Install", formatInstallLines(install)));
if (inspect.plugin.error) {
lines.push("", `${theme.error("Error:")} ${inspect.plugin.error}`);
}
defaultRuntime.log(lines.join("\n"));
});

View File

@ -3,6 +3,7 @@ import { beforeEach, describe, expect, it, vi } from "vitest";
const loadConfigMock = vi.fn();
const loadOpenClawPluginsMock = vi.fn();
let buildPluginStatusReport: typeof import("./status.js").buildPluginStatusReport;
let buildPluginInspectReport: typeof import("./status.js").buildPluginInspectReport;
vi.mock("../config/config.js", () => ({
loadConfig: () => loadConfigMock(),
@ -32,14 +33,21 @@ describe("buildPluginStatusReport", () => {
diagnostics: [],
channels: [],
providers: [],
speechProviders: [],
mediaUnderstandingProviders: [],
imageGenerationProviders: [],
webSearchProviders: [],
tools: [],
hooks: [],
typedHooks: [],
channelSetups: [],
httpRoutes: [],
gatewayHandlers: {},
cliRegistrars: [],
services: [],
commands: [],
});
({ buildPluginStatusReport } = await import("./status.js"));
({ buildPluginInspectReport, buildPluginStatusReport } = await import("./status.js"));
});
it("forwards an explicit env to plugin loading", () => {
@ -59,4 +67,93 @@ describe("buildPluginStatusReport", () => {
}),
);
});
it("builds an inspect report with capability shape and policy", () => {
loadConfigMock.mockReturnValue({
plugins: {
entries: {
google: {
hooks: { allowPromptInjection: false },
subagent: {
allowModelOverride: true,
allowedModels: ["openai/gpt-5.4"],
},
},
},
},
});
loadOpenClawPluginsMock.mockReturnValue({
plugins: [
{
id: "google",
name: "Google",
description: "Google provider plugin",
source: "/tmp/google/index.ts",
origin: "bundled",
enabled: true,
status: "loaded",
toolNames: [],
hookNames: [],
channelIds: [],
providerIds: ["google"],
speechProviderIds: [],
mediaUnderstandingProviderIds: ["google"],
imageGenerationProviderIds: ["google"],
webSearchProviderIds: ["google"],
gatewayMethods: [],
cliCommands: [],
services: [],
commands: [],
httpRoutes: 0,
hookCount: 0,
configSchema: false,
},
],
diagnostics: [{ level: "warn", pluginId: "google", message: "watch this seam" }],
channels: [],
channelSetups: [],
providers: [],
speechProviders: [],
mediaUnderstandingProviders: [],
imageGenerationProviders: [],
webSearchProviders: [],
tools: [],
hooks: [],
typedHooks: [
{
pluginId: "google",
hookName: "before_agent_start",
handler: () => undefined,
source: "/tmp/google/index.ts",
},
],
httpRoutes: [],
gatewayHandlers: {},
cliRegistrars: [],
services: [],
commands: [],
});
const inspect = buildPluginInspectReport({ id: "google" });
expect(inspect).not.toBeNull();
expect(inspect?.shape).toBe("hybrid-capability");
expect(inspect?.capabilityMode).toBe("hybrid");
expect(inspect?.capabilities.map((entry) => entry.kind)).toEqual([
"text-inference",
"media-understanding",
"image-generation",
"web-search",
]);
expect(inspect?.usesLegacyBeforeAgentStart).toBe(true);
expect(inspect?.policy).toEqual({
allowPromptInjection: false,
allowModelOverride: true,
allowedModels: ["openai/gpt-5.4"],
hasAllowedModelsConfig: true,
});
expect(inspect?.diagnostics).toEqual([
{ level: "warn", pluginId: "google", message: "watch this seam" },
]);
});
});

View File

@ -2,14 +2,67 @@ import { resolveAgentWorkspaceDir, resolveDefaultAgentId } from "../agents/agent
import { resolveDefaultAgentWorkspaceDir } from "../agents/workspace.js";
import { loadConfig } from "../config/config.js";
import { createSubsystemLogger } from "../logging/subsystem.js";
import { normalizePluginsConfig } from "./config-state.js";
import { loadOpenClawPlugins } from "./loader.js";
import { createPluginLoaderLogger } from "./logger.js";
import type { PluginRegistry } from "./registry.js";
import type { PluginDiagnostic, PluginHookName } from "./types.js";
export type PluginStatusReport = PluginRegistry & {
workspaceDir?: string;
};
export type PluginCapabilityKind =
| "text-inference"
| "speech"
| "media-understanding"
| "image-generation"
| "web-search"
| "channel";
export type PluginInspectShape =
| "hook-only"
| "plain-capability"
| "hybrid-capability"
| "non-capability";
export type PluginInspectReport = {
workspaceDir?: string;
plugin: PluginRegistry["plugins"][number];
shape: PluginInspectShape;
capabilityMode: "none" | "plain" | "hybrid";
capabilityCount: number;
capabilities: Array<{
kind: PluginCapabilityKind;
ids: string[];
}>;
typedHooks: Array<{
name: PluginHookName;
priority?: number;
}>;
customHooks: Array<{
name: string;
events: string[];
}>;
tools: Array<{
names: string[];
optional: boolean;
}>;
commands: string[];
cliCommands: string[];
services: string[];
gatewayMethods: string[];
httpRouteCount: number;
diagnostics: PluginDiagnostic[];
policy: {
allowPromptInjection?: boolean;
allowModelOverride?: boolean;
allowedModels: string[];
hasAllowedModelsConfig: boolean;
};
usesLegacyBeforeAgentStart: boolean;
};
const log = createSubsystemLogger("plugins");
export function buildPluginStatusReport(params?: {
@ -36,3 +89,126 @@ export function buildPluginStatusReport(params?: {
...registry,
};
}
function buildCapabilityEntries(plugin: PluginRegistry["plugins"][number]) {
return [
{ kind: "text-inference" as const, ids: plugin.providerIds },
{ kind: "speech" as const, ids: plugin.speechProviderIds },
{ kind: "media-understanding" as const, ids: plugin.mediaUnderstandingProviderIds },
{ kind: "image-generation" as const, ids: plugin.imageGenerationProviderIds },
{ kind: "web-search" as const, ids: plugin.webSearchProviderIds },
{ kind: "channel" as const, ids: plugin.channelIds },
].filter((entry) => entry.ids.length > 0);
}
function deriveInspectShape(params: {
capabilityCount: number;
typedHookCount: number;
customHookCount: number;
toolCount: number;
commandCount: number;
cliCount: number;
serviceCount: number;
gatewayMethodCount: number;
httpRouteCount: number;
}): PluginInspectShape {
if (params.capabilityCount > 1) {
return "hybrid-capability";
}
if (params.capabilityCount === 1) {
return "plain-capability";
}
const hasOnlyHooks =
params.typedHookCount + params.customHookCount > 0 &&
params.toolCount === 0 &&
params.commandCount === 0 &&
params.cliCount === 0 &&
params.serviceCount === 0 &&
params.gatewayMethodCount === 0 &&
params.httpRouteCount === 0;
if (hasOnlyHooks) {
return "hook-only";
}
return "non-capability";
}
export function buildPluginInspectReport(params: {
id: string;
config?: ReturnType<typeof loadConfig>;
workspaceDir?: string;
env?: NodeJS.ProcessEnv;
report?: PluginStatusReport;
}): PluginInspectReport | null {
const config = params.config ?? loadConfig();
const report =
params.report ??
buildPluginStatusReport({
config,
workspaceDir: params.workspaceDir,
env: params.env,
});
const plugin = report.plugins.find((entry) => entry.id === params.id || entry.name === params.id);
if (!plugin) {
return null;
}
const capabilities = buildCapabilityEntries(plugin);
const typedHooks = report.typedHooks
.filter((entry) => entry.pluginId === plugin.id)
.map((entry) => ({
name: entry.hookName,
priority: entry.priority,
}))
.sort((a, b) => a.name.localeCompare(b.name));
const customHooks = report.hooks
.filter((entry) => entry.pluginId === plugin.id)
.map((entry) => ({
name: entry.entry.hook.name,
events: [...entry.events].sort(),
}))
.sort((a, b) => a.name.localeCompare(b.name));
const tools = report.tools
.filter((entry) => entry.pluginId === plugin.id)
.map((entry) => ({
names: [...entry.names],
optional: entry.optional,
}));
const diagnostics = report.diagnostics.filter((entry) => entry.pluginId === plugin.id);
const policyEntry = normalizePluginsConfig(config.plugins).entries[plugin.id];
const capabilityCount = capabilities.length;
return {
workspaceDir: report.workspaceDir,
plugin,
shape: deriveInspectShape({
capabilityCount,
typedHookCount: typedHooks.length,
customHookCount: customHooks.length,
toolCount: tools.length,
commandCount: plugin.commands.length,
cliCount: plugin.cliCommands.length,
serviceCount: plugin.services.length,
gatewayMethodCount: plugin.gatewayMethods.length,
httpRouteCount: plugin.httpRoutes,
}),
capabilityMode: capabilityCount === 0 ? "none" : capabilityCount === 1 ? "plain" : "hybrid",
capabilityCount,
capabilities,
typedHooks,
customHooks,
tools,
commands: [...plugin.commands],
cliCommands: [...plugin.cliCommands],
services: [...plugin.services],
gatewayMethods: [...plugin.gatewayMethods],
httpRouteCount: plugin.httpRoutes,
diagnostics,
policy: {
allowPromptInjection: policyEntry?.hooks?.allowPromptInjection,
allowModelOverride: policyEntry?.subagent?.allowModelOverride,
allowedModels: [...(policyEntry?.subagent?.allowedModels ?? [])],
hasAllowedModelsConfig: policyEntry?.subagent?.hasAllowedModelsConfig === true,
},
usesLegacyBeforeAgentStart: typedHooks.some((entry) => entry.name === "before_agent_start"),
};
}