From f489645738f03bafd48c8f9f971fbbf7f272576f Mon Sep 17 00:00:00 2001 From: "Tao Sun (from Dev Box)" Date: Wed, 29 Jul 2026 17:25:16 +0800 Subject: [PATCH 1/8] Add per-agent skills binding (step 1: data model) Introduce the data model for binding skills to agents. Every agent in the catalog now carries an explicit `skills` list defaulting to the full set of 23 skill IDs (union of certified + managed skills from deployer/skill_catalog.py). Adds ALL_SKILL_IDS, a getAgentSkills helper, and tests. No runtime behavior changes. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 1fe75171-fea2-45ef-9401-bf8a4ea0f2ba --- desktop/src/agent-catalog.test.ts | 74 +++++++++++++++++++++++++++++++ desktop/src/agent-catalog.ts | 49 ++++++++++++++++++++ 2 files changed, 123 insertions(+) create mode 100644 desktop/src/agent-catalog.test.ts diff --git a/desktop/src/agent-catalog.test.ts b/desktop/src/agent-catalog.test.ts new file mode 100644 index 0000000..33ae59b --- /dev/null +++ b/desktop/src/agent-catalog.test.ts @@ -0,0 +1,74 @@ +import { describe, expect, it } from "vitest"; +import { AGENT_CATALOG, ALL_SKILL_IDS, getAgentSkills } from "./agent-catalog"; + +const EXPECTED_SKILL_IDS = [ + "1password", + "blucli", + "canvas", + "coding-agent", + "desktop-organizer", + "excel-xlsx", + "healthcheck", + "mcporter", + "nano-pdf", + "obsidian", + "officecli", + "openai-whisper", + "openhue", + "oracle", + "powerpoint-pptx", + "security-practice", + "session-logs", + "sherpa-onnx-tts", + "skill-creator", + "songsee", + "sonoscli", + "video-frames", + "word-docx", +]; + +describe("agent catalog skills binding", () => { + it("exposes exactly the 23 expected skill IDs", () => { + expect(ALL_SKILL_IDS).toEqual(EXPECTED_SKILL_IDS); + expect(ALL_SKILL_IDS).toHaveLength(23); + }); + + it("keeps ALL_SKILL_IDS sorted with no duplicates", () => { + const sorted = [...ALL_SKILL_IDS].sort((a, b) => a.localeCompare(b, "en")); + expect([...ALL_SKILL_IDS]).toEqual(sorted); + expect(new Set(ALL_SKILL_IDS).size).toBe(ALL_SKILL_IDS.length); + }); + + it("binds every agent to the full skill list", () => { + for (const agent of AGENT_CATALOG) { + expect(agent.skills).toEqual(ALL_SKILL_IDS); + } + }); + + it("gives each agent its own skills array instance", () => { + // No agent shares a reference with another agent or with ALL_SKILL_IDS. + for (const agent of AGENT_CATALOG) { + expect(agent.skills).not.toBe(ALL_SKILL_IDS); + } + const seen = new Set(); + for (const agent of AGENT_CATALOG) { + expect(seen.has(agent.skills)).toBe(false); + seen.add(agent.skills); + } + + // Mutating one agent's array does not affect others or ALL_SKILL_IDS. + const [first, second] = AGENT_CATALOG; + (first.skills as string[]).push("__mutation-probe__"); + try { + expect(second.skills).toEqual(ALL_SKILL_IDS); + expect(ALL_SKILL_IDS).toEqual(EXPECTED_SKILL_IDS); + } finally { + (first.skills as string[]).pop(); + } + }); + + it("resolves skills for a known agent and returns [] for an unknown one", () => { + expect(getAgentSkills("coder")).toEqual(ALL_SKILL_IDS); + expect(getAgentSkills("does-not-exist")).toEqual([]); + }); +}); diff --git a/desktop/src/agent-catalog.ts b/desktop/src/agent-catalog.ts index a47fe72..49e994a 100644 --- a/desktop/src/agent-catalog.ts +++ b/desktop/src/agent-catalog.ts @@ -1,3 +1,34 @@ +// The complete universe of skill IDs shipped by MicroClaw: the union of the +// certified/bundled skills (SKILL_CATALOG) and the managed skills +// (MANAGED_SKILL_CATALOG), alphabetically sorted. This MUST be kept in sync with +// `deployer/skill_catalog.py` (SKILL_CATALOG + MANAGED_SKILL_CATALOG) — adding or +// removing a skill there requires the same change here. +export const ALL_SKILL_IDS: readonly string[] = [ + "1password", + "blucli", + "canvas", + "coding-agent", + "desktop-organizer", + "excel-xlsx", + "healthcheck", + "mcporter", + "nano-pdf", + "obsidian", + "officecli", + "openai-whisper", + "openhue", + "oracle", + "powerpoint-pptx", + "security-practice", + "session-logs", + "sherpa-onnx-tts", + "skill-creator", + "songsee", + "sonoscli", + "video-frames", + "word-docx", +]; + export interface AgentCatalogEntry { id: string; name: string; @@ -8,6 +39,7 @@ export interface AgentCatalogEntry { tagKeys: string[]; taskKeys: Array<{ titleKey: string; descKey: string }>; installedByDefault: boolean; + skills: readonly string[]; workspaceDirName?: string; personaProfile?: "master-archive"; } @@ -27,6 +59,7 @@ export const AGENT_CATALOG: readonly AgentCatalogEntry[] = [ { titleKey: "agent.main.task.3.title", descKey: "agent.main.task.3.desc" }, ], installedByDefault: true, + skills: [...ALL_SKILL_IDS], }, { id: "master-archive", @@ -55,6 +88,7 @@ export const AGENT_CATALOG: readonly AgentCatalogEntry[] = [ }, ], installedByDefault: false, + skills: [...ALL_SKILL_IDS], workspaceDirName: "workspace-master-archive", personaProfile: "master-archive", }, @@ -72,6 +106,7 @@ export const AGENT_CATALOG: readonly AgentCatalogEntry[] = [ { titleKey: "agent.coder.task.3.title", descKey: "agent.coder.task.3.desc" }, ], installedByDefault: false, + skills: [...ALL_SKILL_IDS], }, { id: "painter", @@ -87,6 +122,7 @@ export const AGENT_CATALOG: readonly AgentCatalogEntry[] = [ { titleKey: "agent.painter.task.3.title", descKey: "agent.painter.task.3.desc" }, ], installedByDefault: false, + skills: [...ALL_SKILL_IDS], }, { id: "master", @@ -102,6 +138,7 @@ export const AGENT_CATALOG: readonly AgentCatalogEntry[] = [ { titleKey: "agent.master.task.3.title", descKey: "agent.master.task.3.desc" }, ], installedByDefault: false, + skills: [...ALL_SKILL_IDS], }, { id: "growth-hacker", @@ -130,6 +167,7 @@ export const AGENT_CATALOG: readonly AgentCatalogEntry[] = [ }, ], installedByDefault: false, + skills: [...ALL_SKILL_IDS], }, { id: "leopard", @@ -145,6 +183,7 @@ export const AGENT_CATALOG: readonly AgentCatalogEntry[] = [ { titleKey: "agent.leopard.task.3.title", descKey: "agent.leopard.task.3.desc" }, ], installedByDefault: false, + skills: [...ALL_SKILL_IDS], }, { id: "singer", @@ -160,9 +199,19 @@ export const AGENT_CATALOG: readonly AgentCatalogEntry[] = [ { titleKey: "agent.singer.task.3.title", descKey: "agent.singer.task.3.desc" }, ], installedByDefault: false, + skills: [...ALL_SKILL_IDS], }, ]; export const DEFAULT_AGENT_IDS = AGENT_CATALOG.filter( (agent) => agent.installedByDefault, ).map((agent) => agent.id); + +/** + * Returns the skill IDs bound to the given agent, or an empty array if the agent + * id is not present in AGENT_CATALOG. + */ +export function getAgentSkills(agentId: string): readonly string[] { + const agent = AGENT_CATALOG.find((entry) => entry.id === agentId); + return agent ? agent.skills : []; +} From 5ec4484097835b3a8166212ce021639b4802fed6 Mon Sep 17 00:00:00 2001 From: "Tao Sun (from Dev Box)" Date: Wed, 29 Jul 2026 17:42:49 +0800 Subject: [PATCH 2/8] Bind per-agent skills into openclaw.json (step 2) Write each agent's catalog skill allowlist into agents.list[*].skills so OpenClaw's per-agent skill filter binds skills natively. Skills are read per-agent from the catalog (never hardcoded) and enforced authoritatively for catalog-known agents during reconciliation; custom agent ids are left untouched. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 1fe75171-fea2-45ef-9401-bf8a4ea0f2ba --- desktop/src/agent-personas.test.ts | 88 ++++++++++++++++++++++++++++++ desktop/src/agent-personas.ts | 13 +++++ 2 files changed, 101 insertions(+) diff --git a/desktop/src/agent-personas.test.ts b/desktop/src/agent-personas.test.ts index 928ada3..9dbee97 100644 --- a/desktop/src/agent-personas.test.ts +++ b/desktop/src/agent-personas.test.ts @@ -2,6 +2,7 @@ import * as fs from "fs"; import * as os from "os"; import * as path from "path"; import { afterEach, describe, expect, it } from "vitest"; +import { getAgentSkills } from "./agent-catalog"; import { AGENT_PERSONAS, DEFAULT_AGENT_PERSONAS, @@ -27,6 +28,13 @@ afterEach(() => { } }); +type AgentListEntry = { id: string; skills?: readonly string[] } & Record; + +function agentList(config: { agents?: unknown }): AgentListEntry[] { + const agents = (config.agents ?? {}) as { list?: unknown }; + return (Array.isArray(agents.list) ? agents.list : []) as AgentListEntry[]; +} + describe("agent personas", () => { it("registers only main for a new installation", () => { const stateDir = createStateDir(); @@ -211,4 +219,84 @@ describe("agent personas", () => { ), ).toBe(path.join(openClawHome, "archive")); }); + + it("writes each generated agent's catalog skills into agents.list", () => { + const stateDir = createStateDir(); + const persona = getAgentPersona("master-archive"); + if (!persona) throw new Error("Master Archive persona is not registered"); + const config = { agents: {} }; + + ensureAgentPersonasConfig(config, stateDir, [...DEFAULT_AGENT_PERSONAS, persona]); + + for (const entry of agentList(config)) { + expect(entry.skills).toEqual(getAgentSkills(entry.id)); + } + }); + + it("gives each agent a distinct skills array instance", () => { + const stateDir = createStateDir(); + const persona = getAgentPersona("master-archive"); + if (!persona) throw new Error("Master Archive persona is not registered"); + const config = { agents: {} }; + + ensureAgentPersonasConfig(config, stateDir, [...DEFAULT_AGENT_PERSONAS, persona]); + + const mainEntry = agentList(config).find((entry) => entry.id === "main"); + const archiveEntry = agentList(config).find( + (entry) => entry.id === "master-archive", + ); + if (!mainEntry || !archiveEntry) throw new Error("expected both agents"); + + expect(mainEntry.skills).not.toBe(archiveEntry.skills); + (mainEntry.skills as string[]).push("mutated"); + expect(archiveEntry.skills).not.toContain("mutated"); + expect(getAgentSkills("master-archive")).not.toContain("mutated"); + expect(getAgentSkills("main")).not.toContain("mutated"); + }); + + it("overwrites a stale skills value for a catalog-known agent", () => { + const stateDir = createStateDir(); + const config = { + agents: { + list: [{ id: "main", name: "Assistant", default: true, skills: ["stale-skill"] }], + }, + }; + + const result = ensureAgentPersonasConfig(config, stateDir); + + const mainEntry = agentList(config).find((entry) => entry.id === "main"); + expect(mainEntry?.skills).toEqual(getAgentSkills("main")); + expect(result.changed).toBe(true); + }); + + it("leaves a custom non-catalog agent without an injected skills field", () => { + const stateDir = createStateDir(); + const config = { + agents: { + list: [ + { id: "main", name: "Assistant", default: true }, + { id: "custom-bot", name: "Custom Bot" }, + ], + }, + }; + + ensureAgentPersonasConfig(config, stateDir); + + const customEntry = agentList(config).find((entry) => entry.id === "custom-bot"); + expect(customEntry).not.toHaveProperty("skills"); + const mainEntry = agentList(config).find((entry) => entry.id === "main"); + expect(mainEntry?.skills).toEqual(getAgentSkills("main")); + }); + + it("reports changed when skills are added to a config that lacked them", () => { + const stateDir = createStateDir(); + const config = { + agents: { + list: [{ id: "main", name: "Assistant", default: true }], + }, + }; + + expect(ensureAgentPersonasConfig(config, stateDir).changed).toBe(true); + expect(ensureAgentPersonasConfig(config, stateDir).changed).toBe(false); + }); }); diff --git a/desktop/src/agent-personas.ts b/desktop/src/agent-personas.ts index fd56851..520092f 100644 --- a/desktop/src/agent-personas.ts +++ b/desktop/src/agent-personas.ts @@ -12,6 +12,7 @@ interface WorkspaceFiles { export interface AgentPersona { id: string; name: string; + skills: readonly string[]; workspaceDirName?: string; workspaceFiles?: WorkspaceFiles; } @@ -112,6 +113,7 @@ const PERSONA_PROFILES: Record = { export const AGENT_PERSONAS: readonly AgentPersona[] = AGENT_CATALOG.map((agent) => ({ id: agent.id, name: agent.name, + skills: agent.skills, workspaceDirName: agent.workspaceDirName, workspaceFiles: agent.personaProfile ? PERSONA_PROFILES[agent.personaProfile] : undefined, })); @@ -150,6 +152,7 @@ function createAgentEntry(persona: AgentPersona, stateDir: string): Record = { name: persona.name }; const workspace = getAgentWorkspacePath(stateDir, persona); if (workspace) entry.workspace = workspace; + entry.skills = [...persona.skills]; return entry; } @@ -252,6 +255,16 @@ export function ensureAgentPersonasConfig( } } + const catalogSkillsById = new Map( + AGENT_PERSONAS.map((persona) => [persona.id, persona.skills]), + ); + for (const entry of entries) { + const catalogSkills = catalogSkillsById.get(entry.id); + if (catalogSkills !== undefined) { + entry.skills = [...catalogSkills]; + } + } + const changed = !hasList || Object.hasOwn(agents, "entries") || From 3d7511e70bea75945798af994dffa960cf46b330 Mon Sep 17 00:00:00 2001 From: "Tao Sun (from Dev Box)" Date: Wed, 29 Jul 2026 19:16:23 +0800 Subject: [PATCH 3/8] Add dev-only per-agent Skills panel (agent-skills step 4) Add a DEV-ONLY "Skills" tab to ChatView that lets developers toggle each agent's skill set at runtime and reload the gateway once, without editing code. The tab and panel are gated on import.meta.env.DEV so they are absent from production builds. - ChatView: extend viewMode union with "skills"; add a v-if="isDev" toggle button and render SkillsDevPanel when selected. - SkillsDevPanel: agent picker (catalog agents), 23 skill toggles reflecting config.agents.list[id].skills, select/clear all, Apply & reload with applying/applied/failed feedback. - main.ts: new skills:set-agent-skills IPC that validates the agent id and skill ids, writes openclaw.json atomically, and restarts the gateway with rollback on failure. Exposed via preload as skills.setAgentSkills. - agent-catalog: add pure isKnownSkillId / sanitizeAgentSkillIds helpers. - agent-personas: change catalog-skill seeding from authoritative overwrite to fill-if-missing so dev-edited skills survive gateway reloads. Fresh installs still seed the full catalog list. - i18n: add skills.dev.* keys in en-US and zh-CN. - Update/extend unit tests for the seeding change and new helpers. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 1fe75171-fea2-45ef-9401-bf8a4ea0f2ba --- desktop/renderer/env.d.ts | 1 + .../src/components/skills/SkillsDevPanel.vue | 344 ++++++++++++++++++ desktop/renderer/src/i18n/en-US.ts | 15 + desktop/renderer/src/i18n/zh-CN.ts | 15 + desktop/renderer/src/views/ChatView.vue | 17 +- desktop/src/agent-catalog.test.ts | 29 +- desktop/src/agent-catalog.ts | 31 ++ desktop/src/agent-personas.test.ts | 6 +- desktop/src/agent-personas.ts | 5 +- desktop/src/main.ts | 70 ++++ desktop/src/preload.ts | 5 + 11 files changed, 532 insertions(+), 6 deletions(-) create mode 100644 desktop/renderer/src/components/skills/SkillsDevPanel.vue diff --git a/desktop/renderer/env.d.ts b/desktop/renderer/env.d.ts index 5e83b3f..a3d3a41 100644 --- a/desktop/renderer/env.d.ts +++ b/desktop/renderer/env.d.ts @@ -131,6 +131,7 @@ interface OpenClawAPI { refresh(): Promise<{ builtin: SkillEntry[]; custom: SkillEntry[]; managed: SkillEntry[] }>; updateAllowlist(allowBundled: string[]): Promise; updateManagedEntries(entries: Record): Promise; + setAgentSkills(agentId: string, skillIds: string[]): Promise<{ agentId: string; skills: string[] }>; integrityCheck(): Promise; pendingIntegrityResult(): Promise; acceptIntegrityChanges(): Promise; diff --git a/desktop/renderer/src/components/skills/SkillsDevPanel.vue b/desktop/renderer/src/components/skills/SkillsDevPanel.vue new file mode 100644 index 0000000..91d4472 --- /dev/null +++ b/desktop/renderer/src/components/skills/SkillsDevPanel.vue @@ -0,0 +1,344 @@ + + + + + diff --git a/desktop/renderer/src/i18n/en-US.ts b/desktop/renderer/src/i18n/en-US.ts index 579c463..43b2a48 100644 --- a/desktop/renderer/src/i18n/en-US.ts +++ b/desktop/renderer/src/i18n/en-US.ts @@ -699,6 +699,21 @@ export default { "usage.noSessions": "No sessions in this time range", "usage.footer": "Data sourced from OpenClaw Gateway. Requires the gateway to be running.", + // ── Skills (dev-only panel) ── + "skills.dev.tab": "Skills", + "skills.dev.title": "Agent Skills (Dev)", + "skills.dev.subtitle": "Toggle which skills each agent can use, then apply and reload the gateway. Development builds only.", + "skills.dev.agent": "Agent", + "skills.dev.selectAll": "Select all", + "skills.dev.clearAll": "Clear all", + "skills.dev.enabledCount": "{count} of {total} enabled", + "skills.dev.loading": "Loading agent skills…", + "skills.dev.loadFailed": "Failed to load skills: {error}", + "skills.dev.apply": "Apply & reload", + "skills.dev.applying": "Applying…", + "skills.dev.applied": "Applied. Gateway reloaded.", + "skills.dev.applyFailed": "Failed to apply: {error}", + // ── Studio ── "studio.switchToStudio": "Studio", "studio.switchToChat": "Chat", diff --git a/desktop/renderer/src/i18n/zh-CN.ts b/desktop/renderer/src/i18n/zh-CN.ts index 5b23978..fbbdd41 100644 --- a/desktop/renderer/src/i18n/zh-CN.ts +++ b/desktop/renderer/src/i18n/zh-CN.ts @@ -672,6 +672,21 @@ export default { "usage.noSessions": "此时间范围内暂无会话", "usage.footer": "数据来源于 OpenClaw Gateway,需要 Gateway 运行中。", + // ── Skills (dev-only panel) ── + "skills.dev.tab": "技能", + "skills.dev.title": "智能体技能(开发)", + "skills.dev.subtitle": "切换每个智能体可使用的技能,然后应用并重载网关。仅限开发版本。", + "skills.dev.agent": "智能体", + "skills.dev.selectAll": "全选", + "skills.dev.clearAll": "清空", + "skills.dev.enabledCount": "已启用 {count} / {total}", + "skills.dev.loading": "正在加载智能体技能……", + "skills.dev.loadFailed": "加载技能失败:{error}", + "skills.dev.apply": "应用并重载", + "skills.dev.applying": "正在应用……", + "skills.dev.applied": "已应用,网关已重载。", + "skills.dev.applyFailed": "应用失败:{error}", + // ── Studio ── "studio.switchToStudio": "工作室", "studio.switchToChat": "对话", diff --git a/desktop/renderer/src/views/ChatView.vue b/desktop/renderer/src/views/ChatView.vue index 0f50b73..846afb0 100644 --- a/desktop/renderer/src/views/ChatView.vue +++ b/desktop/renderer/src/views/ChatView.vue @@ -16,6 +16,14 @@ > {{ t("studio.switchToStudio") }} + @@ -199,6 +207,11 @@ + + + @@ -215,6 +228,7 @@ import StudioGame from "@/components/studio/StudioGame.vue"; import StudioChatPanel from "@/components/studio/StudioChatPanel.vue"; import StudioStatusPanel from "@/components/studio/StudioStatusPanel.vue"; import ModelSetupDialog from "@/components/ModelSetupDialog.vue"; +import SkillsDevPanel from "@/components/skills/SkillsDevPanel.vue"; import { useRoute } from "vue-router"; const route = useRoute(); @@ -222,7 +236,8 @@ const chatStore = useChatStore(); const agentStore = useAgentStore(); const studioStore = useStudioStore(); -const viewMode = ref<"chat" | "studio">("chat"); +const viewMode = ref<"chat" | "studio" | "skills">("chat"); +const isDev = import.meta.env.DEV; const inputText = ref(""); const inputRef = ref(); const threadRef = ref(); diff --git a/desktop/src/agent-catalog.test.ts b/desktop/src/agent-catalog.test.ts index 33ae59b..62e81de 100644 --- a/desktop/src/agent-catalog.test.ts +++ b/desktop/src/agent-catalog.test.ts @@ -1,5 +1,11 @@ import { describe, expect, it } from "vitest"; -import { AGENT_CATALOG, ALL_SKILL_IDS, getAgentSkills } from "./agent-catalog"; +import { + AGENT_CATALOG, + ALL_SKILL_IDS, + getAgentSkills, + isKnownSkillId, + sanitizeAgentSkillIds, +} from "./agent-catalog"; const EXPECTED_SKILL_IDS = [ "1password", @@ -71,4 +77,25 @@ describe("agent catalog skills binding", () => { expect(getAgentSkills("coder")).toEqual(ALL_SKILL_IDS); expect(getAgentSkills("does-not-exist")).toEqual([]); }); + + it("recognizes known skill ids and rejects unknown ones", () => { + expect(isKnownSkillId("canvas")).toBe(true); + expect(isKnownSkillId("not-a-skill")).toBe(false); + }); + + it("sanitizes requested skill ids into sorted, de-duplicated known ids", () => { + expect(sanitizeAgentSkillIds(["canvas", "1password", "canvas"])).toEqual([ + "1password", + "canvas", + ]); + expect(sanitizeAgentSkillIds([])).toEqual([]); + }); + + it("throws when a requested skill id is unknown or malformed", () => { + expect(() => sanitizeAgentSkillIds(["canvas", "bogus"])).toThrow(/Unknown skill id/); + expect(() => sanitizeAgentSkillIds(["", "canvas"])).toThrow(/non-empty strings/); + expect(() => sanitizeAgentSkillIds("canvas" as unknown as string[])).toThrow( + /must be an array/, + ); + }); }); diff --git a/desktop/src/agent-catalog.ts b/desktop/src/agent-catalog.ts index 49e994a..72f4188 100644 --- a/desktop/src/agent-catalog.ts +++ b/desktop/src/agent-catalog.ts @@ -215,3 +215,34 @@ export function getAgentSkills(agentId: string): readonly string[] { const agent = AGENT_CATALOG.find((entry) => entry.id === agentId); return agent ? agent.skills : []; } + +const ALL_SKILL_ID_SET: ReadonlySet = new Set(ALL_SKILL_IDS); + +/** + * Returns true when the given id is one of the known catalog skill ids. + */ +export function isKnownSkillId(skillId: string): boolean { + return ALL_SKILL_ID_SET.has(skillId); +} + +/** + * Validates and normalizes a requested list of skill ids into a sorted, + * de-duplicated array of known catalog skill ids. Throws when the input is not + * an array, contains non-string / empty entries, or references an unknown id. + */ +export function sanitizeAgentSkillIds(skillIds: readonly string[]): string[] { + if (!Array.isArray(skillIds)) { + throw new Error("skillIds must be an array"); + } + const unique = new Set(); + for (const skillId of skillIds) { + if (typeof skillId !== "string" || skillId.length === 0) { + throw new Error("skillIds must contain only non-empty strings"); + } + if (!ALL_SKILL_ID_SET.has(skillId)) { + throw new Error(`Unknown skill id: ${skillId}`); + } + unique.add(skillId); + } + return [...unique].sort(); +} diff --git a/desktop/src/agent-personas.test.ts b/desktop/src/agent-personas.test.ts index 9dbee97..c024e17 100644 --- a/desktop/src/agent-personas.test.ts +++ b/desktop/src/agent-personas.test.ts @@ -254,7 +254,7 @@ describe("agent personas", () => { expect(getAgentSkills("main")).not.toContain("mutated"); }); - it("overwrites a stale skills value for a catalog-known agent", () => { + it("preserves an existing skills value for a catalog-known agent", () => { const stateDir = createStateDir(); const config = { agents: { @@ -265,8 +265,8 @@ describe("agent personas", () => { const result = ensureAgentPersonasConfig(config, stateDir); const mainEntry = agentList(config).find((entry) => entry.id === "main"); - expect(mainEntry?.skills).toEqual(getAgentSkills("main")); - expect(result.changed).toBe(true); + expect(mainEntry?.skills).toEqual(["stale-skill"]); + expect(result.changed).toBe(false); }); it("leaves a custom non-catalog agent without an injected skills field", () => { diff --git a/desktop/src/agent-personas.ts b/desktop/src/agent-personas.ts index 520092f..c756746 100644 --- a/desktop/src/agent-personas.ts +++ b/desktop/src/agent-personas.ts @@ -260,7 +260,10 @@ export function ensureAgentPersonasConfig( ); for (const entry of entries) { const catalogSkills = catalogSkillsById.get(entry.id); - if (catalogSkills !== undefined) { + // Seed catalog skills ONLY when the entry has no skills key yet + // (fill-if-missing). Existing skills arrays — including dev-edited ones from + // the runtime Skills panel — are preserved across gateway reloads. + if (catalogSkills !== undefined && !Object.hasOwn(entry, "skills")) { entry.skills = [...catalogSkills]; } } diff --git a/desktop/src/main.ts b/desktop/src/main.ts index d7086f4..1c31e19 100644 --- a/desktop/src/main.ts +++ b/desktop/src/main.ts @@ -90,6 +90,7 @@ import { type AgentPersona, type AgentRosterConfig, } from "./agent-personas"; +import { sanitizeAgentSkillIds } from "./agent-catalog"; /** * Normalize a directory path for comparison/storage. @@ -3133,6 +3134,75 @@ function registerIpcHandlers(): void { pendingIntegrityResult = null; }); + ipcMain.handle( + "skills:set-agent-skills", + async ( + _event, + agentId: string, + skillIds: string[], + ): Promise<{ agentId: string; skills: string[] }> => { + if (typeof agentId !== "string" || agentId.length === 0) { + throw new Error("A non-empty agentId is required"); + } + const skills = sanitizeAgentSkillIds(skillIds ?? []); + + const configPath = getConfigPath(); + const originalConfigText = fs.readFileSync(configPath, "utf-8"); + const config = readConfig(); + if (!config) throw new Error("OpenClaw configuration is unavailable"); + + const agents = config.agents; + if (!agents || !Array.isArray(agents.list)) { + throw new Error("No configured agents to update"); + } + const entry = agents.list.find( + (candidate: unknown): candidate is Record => + typeof candidate === "object" && + candidate !== null && + (candidate as { id?: unknown }).id === agentId, + ); + if (!entry) { + throw new Error(`Unknown agent "${agentId}"`); + } + entry.skills = skills; + + const alreadyRunning = await checkExistingGateway(gatewayPort); + const managedGateway = gatewaySpawnedByUs && gatewayProcess !== null; + if ( + requiresExternalGatewayStop( + alreadyRunning, + true, + gatewaySpawnedByUs, + gatewayProcess !== null, + ) + ) { + throw new Error( + "Cannot update agent skills while MicroClaw is connected to an externally managed Gateway", + ); + } + + let restartAttempted = false; + try { + writeConfigTextAtomically(JSON.stringify(config, null, 2)); + restartAttempted = true; + await restartManagedGateway(`Updating skills for agent ${agentId}`); + return { agentId, skills }; + } catch (error) { + try { + writeConfigTextAtomically(originalConfigText); + if (restartAttempted && managedGateway) { + await restartManagedGateway( + `Rolling back failed skills update for ${agentId}`, + ); + } + } catch { + // Preserve the original failure; rollback is best-effort. + } + throw error; + } + }, + ); + // --- Chat (WebSocket gateway protocol) --- ipcMain.handle( "chat:send-message", diff --git a/desktop/src/preload.ts b/desktop/src/preload.ts index c193cf3..7809b37 100644 --- a/desktop/src/preload.ts +++ b/desktop/src/preload.ts @@ -57,6 +57,11 @@ contextBridge.exposeInMainWorld("openclaw", { ipcRenderer.invoke("skills:update-allowlist", allowBundled), updateManagedEntries: (entries: Record) => ipcRenderer.invoke("skills:update-managed-entries", entries), + setAgentSkills: (agentId: string, skillIds: string[]) => + ipcRenderer.invoke("skills:set-agent-skills", agentId, skillIds) as Promise<{ + agentId: string; + skills: string[]; + }>, integrityCheck: () => ipcRenderer.invoke("skills:integrity-check"), pendingIntegrityResult: () => ipcRenderer.invoke("skills:pending-integrity-result"), acceptIntegrityChanges: () => ipcRenderer.invoke("skills:accept-integrity-changes"), From 8406f41f421daece75b9d5e7b48c4b4081935f7a Mon Sep 17 00:00:00 2001 From: Copilot App <223556219+Copilot@users.noreply.github.com> Date: Thu, 30 Jul 2026 12:09:12 +0800 Subject: [PATCH 4/8] Skills diagnostics, global master toggle, and ID-matching fix (step 5) Fix per-agent skill binding: OpenClaw filters agents.list[*].skills against each skill's frontmatter name, not its slug. Add SKILL_MATCH_NAMES + resolveSkillFilterNames and write resolved match-names to openclaw.json (seeding + set-agent-skills), while keeping the UI/IPC input slug-based. Round-trip reads recognize both slug- and name-form stored values via matchesSkill. Add dev-only diagnostics: skills:get-status IPC runs the OpenClaw CLI directly (skills list/check --json) to surface each skill's gating status and missing requirements, rendered as badges + reasons in SkillsDevPanel with a Model-visible summary and Refresh. Add a per-skill global master toggle: skills:set-global-enabled IPC manages both skills.entries[key].enabled and skills.allowBundled (bundled only), mirroring the set-agent-skills atomic-write + single managed-gateway restart with rollback. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 1fe75171-fea2-45ef-9401-bf8a4ea0f2ba --- desktop/renderer/env.d.ts | 29 ++ .../src/components/skills/SkillsDevPanel.vue | 247 +++++++++++++- desktop/renderer/src/i18n/en-US.ts | 20 ++ desktop/renderer/src/i18n/zh-CN.ts | 20 ++ desktop/src/agent-catalog.test.ts | 50 +++ desktop/src/agent-catalog.ts | 41 +++ desktop/src/agent-personas.test.ts | 32 +- desktop/src/agent-personas.ts | 9 +- desktop/src/constants.ts | 3 + desktop/src/main.ts | 313 +++++++++++++++++- desktop/src/preload.ts | 6 + 11 files changed, 752 insertions(+), 18 deletions(-) diff --git a/desktop/renderer/env.d.ts b/desktop/renderer/env.d.ts index a3d3a41..d705dd9 100644 --- a/desktop/renderer/env.d.ts +++ b/desktop/renderer/env.d.ts @@ -63,6 +63,33 @@ interface SkillEntry { eligible: boolean; } +interface SkillStatusRecord { + skillKey: string; + name: string; + source: string; + bundled: boolean; + eligible: boolean; + disabled: boolean; + modelVisible: boolean; + commandVisible: boolean; + blockedByAllowlist: boolean; + blockedByAgentFilter: boolean; + missing: { + bins: string[]; + anyBins: string[]; + env: string[]; + config: string[]; + os: string[]; + }; +} + +interface SkillsStatus { + ok: boolean; + error?: string; + summary: { total: number; modelVisible: number }; + skills: SkillStatusRecord[]; +} + interface IntegrityChange { skill: string; source: string; @@ -132,6 +159,8 @@ interface OpenClawAPI { updateAllowlist(allowBundled: string[]): Promise; updateManagedEntries(entries: Record): Promise; setAgentSkills(agentId: string, skillIds: string[]): Promise<{ agentId: string; skills: string[] }>; + getStatus(agentId: string): Promise; + setGlobalEnabled(skillKey: string, enabled: boolean): Promise<{ skillKey: string; enabled: boolean }>; integrityCheck(): Promise; pendingIntegrityResult(): Promise; acceptIntegrityChanges(): Promise; diff --git a/desktop/renderer/src/components/skills/SkillsDevPanel.vue b/desktop/renderer/src/components/skills/SkillsDevPanel.vue index 91d4472..8ed20cd 100644 --- a/desktop/renderer/src/components/skills/SkillsDevPanel.vue +++ b/desktop/renderer/src/components/skills/SkillsDevPanel.vue @@ -29,18 +29,55 @@ + {{ t("skills.dev.enabledCount", { count: enabledCount, total: allSkillIds.length }) }} +
+ + {{ t("skills.dev.modelVisibleSummary", { + visible: status.summary.modelVisible, + total: status.summary.total, + }) }} + + + {{ t("skills.dev.statusFailed", { error: statusError }) }} + +
+
{{ t("skills.dev.loading") }}
  • - +
    + + +
    +
    + {{ badgeText(id) }} + {{ reasonText(id) }} + {{ missingText(id) }} +
@@ -65,7 +102,7 @@ import { computed, onMounted, ref, watch } from "vue"; import { t } from "@/i18n"; import { useAgentStore } from "@/stores/agents"; -import { AGENT_CATALOG, ALL_SKILL_IDS } from "../../../../src/agent-catalog"; +import { AGENT_CATALOG, ALL_SKILL_IDS, matchesSkill } from "../../../../src/agent-catalog"; const agentStore = useAgentStore(); @@ -80,6 +117,11 @@ const applying = ref(false); const feedback = ref(""); const feedbackKind = ref<"info" | "success" | "error">("info"); +const status = ref(null); +const statusLoading = ref(false); +const statusError = ref(""); +const globalBusy = ref>(new Set()); + function resolveDefaultAgentId(): string { const current = agentStore.currentAgentId; return agents.some((a) => a.id === current) ? current : "main"; @@ -88,6 +130,61 @@ function resolveDefaultAgentId(): string { const enabledCount = computed(() => pending.value.size); const dirty = computed(() => !setsEqual(pending.value, persisted.value)); +// Match a status record (keyed by the CLI's frontmatter name or slug) back to a +// catalog slug, tolerating both the slug- and display-name forms. +function statusFor(slug: string): SkillStatusRecord | undefined { + const records = status.value?.skills; + if (!records) return undefined; + return records.find( + (record) => matchesSkill(record.skillKey, slug) || matchesSkill(record.name, slug), + ); +} + +function globalOn(slug: string): boolean { + const record = statusFor(slug); + // Unknown skills default to "on" so the switch isn't misleadingly off. + if (!record) return true; + return !record.disabled && !record.blockedByAllowlist; +} + +function badgeClass(slug: string): string { + const record = statusFor(slug); + if (!record) return "is-unknown"; + if (record.modelVisible) return "is-visible"; + return "is-hidden"; +} + +function badgeText(slug: string): string { + const record = statusFor(slug); + if (!record) return t("skills.dev.badge.unknown"); + return record.modelVisible ? t("skills.dev.badge.visible") : t("skills.dev.badge.hidden"); +} + +function reasonText(slug: string): string { + const record = statusFor(slug); + if (!record || record.modelVisible) return ""; + if (record.blockedByAllowlist) return t("skills.dev.reason.allowlist"); + if (record.blockedByAgentFilter) return t("skills.dev.reason.agentFilter"); + if (record.disabled) return t("skills.dev.reason.disabled"); + if (!record.eligible) return t("skills.dev.reason.missing"); + return ""; +} + +function missingText(slug: string): string { + const record = statusFor(slug); + if (!record || record.modelVisible || record.eligible) return ""; + const parts: string[] = []; + const bins = [...record.missing.bins, ...record.missing.anyBins]; + if (bins.length) parts.push(t("skills.dev.missing.bins", { list: bins.join(", ") })); + if (record.missing.env.length) + parts.push(t("skills.dev.missing.env", { list: record.missing.env.join(", ") })); + if (record.missing.config.length) + parts.push(t("skills.dev.missing.config", { list: record.missing.config.join(", ") })); + if (record.missing.os.length) + parts.push(t("skills.dev.missing.os", { list: record.missing.os.join(", ") })); + return parts.join(" · "); +} + function setsEqual(a: Set, b: Set): boolean { if (a.size !== b.size) return false; for (const value of a) if (!b.has(value)) return false; @@ -106,11 +203,14 @@ async function loadAgentSkills(agentId: string): Promise { candidate !== null && (candidate as { id?: unknown }).id === agentId, ) as { skills?: unknown } | undefined; - const skills = Array.isArray(entry?.skills) - ? entry.skills.filter( - (id: unknown): id is string => typeof id === "string" && allSkillIds.includes(id), - ) + const stored = Array.isArray(entry?.skills) + ? entry.skills.filter((id: unknown): id is string => typeof id === "string") : []; + // A stored value counts as ON if it matches EITHER the slug or the mapped + // OpenClaw match-name, so both older slug-form and current name-form register. + const skills = allSkillIds.filter((slug) => + stored.some((value) => matchesSkill(value, slug)), + ); persisted.value = new Set(skills); pending.value = new Set(skills); } catch (error) { @@ -144,6 +244,53 @@ function clearAll(): void { pending.value = new Set(); } +async function loadStatus(agentId: string): Promise { + statusLoading.value = true; + statusError.value = ""; + try { + const result = await window.openclaw.skills.getStatus(agentId); + if (result.ok) { + status.value = result; + } else { + status.value = null; + statusError.value = result.error ?? "unknown error"; + } + } catch (error) { + status.value = null; + statusError.value = error instanceof Error ? error.message : String(error); + } finally { + statusLoading.value = false; + } +} + +function refreshStatus(): void { + void loadStatus(selectedAgentId.value); +} + +async function toggleGlobal(slug: string): Promise { + const next = !globalOn(slug); + const busy = new Set(globalBusy.value); + busy.add(slug); + globalBusy.value = busy; + feedbackKind.value = "info"; + feedback.value = t("skills.dev.globalUpdating", { skill: slug }); + try { + await window.openclaw.skills.setGlobalEnabled(slug, next); + feedbackKind.value = "success"; + feedback.value = t("skills.dev.globalUpdated", { skill: slug }); + await loadStatus(selectedAgentId.value); + } catch (error) { + feedbackKind.value = "error"; + feedback.value = t("skills.dev.globalFailed", { + error: error instanceof Error ? error.message : String(error), + }); + } finally { + const done = new Set(globalBusy.value); + done.delete(slug); + globalBusy.value = done; + } +} + async function apply(): Promise { applying.value = true; feedbackKind.value = "info"; @@ -155,6 +302,7 @@ async function apply(): Promise { persisted.value = new Set(skillIds); feedbackKind.value = "success"; feedback.value = t("skills.dev.applied"); + await loadStatus(agentId); } catch (error) { feedbackKind.value = "error"; feedback.value = t("skills.dev.applyFailed", { @@ -167,10 +315,12 @@ async function apply(): Promise { watch(selectedAgentId, (agentId) => { void loadAgentSkills(agentId); + void loadStatus(agentId); }); onMounted(() => { void loadAgentSkills(selectedAgentId.value); + void loadStatus(selectedAgentId.value); }); @@ -299,6 +449,85 @@ onMounted(() => { white-space: nowrap; } +.skills-dev-summary { + display: flex; + gap: 12px; + font-size: 12px; + color: var(--ux-text-muted); + margin-bottom: 8px; + min-height: 16px; +} + +.skills-dev-status-error { + color: var(--ux-status-error, #c62828); +} + +.skills-dev-item-main { + display: flex; + align-items: center; + gap: 8px; + justify-content: space-between; +} + +.skills-dev-toggle-hint { + font-size: 11px; + color: var(--ux-text-muted); +} + +.skills-dev-global { + display: flex; + align-items: center; + gap: 4px; + cursor: pointer; + flex-shrink: 0; +} + +.skills-dev-global-label { + font-size: 11px; + color: var(--ux-text-muted); +} + +.skills-dev-item-status { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 6px; + padding: 2px 8px 4px 8px; +} + +.skills-dev-badge { + font-size: 11px; + font-weight: 500; + padding: 1px 6px; + border-radius: 999px; + white-space: nowrap; +} + +.skills-dev-badge.is-visible { + background: var(--ux-status-success-bg, rgba(46, 125, 50, 0.14)); + color: var(--ux-status-success, #2e7d32); +} + +.skills-dev-badge.is-hidden { + background: var(--ux-status-warning-bg, rgba(198, 40, 40, 0.12)); + color: var(--ux-status-error, #c62828); +} + +.skills-dev-badge.is-unknown { + background: var(--ux-surface-hover); + color: var(--ux-text-muted); +} + +.skills-dev-reason { + font-size: 11px; + color: var(--ux-text-primary); +} + +.skills-dev-missing { + font-size: 11px; + color: var(--ux-text-muted); +} + .skills-dev-footer { display: flex; align-items: center; diff --git a/desktop/renderer/src/i18n/en-US.ts b/desktop/renderer/src/i18n/en-US.ts index 43b2a48..38cb6a3 100644 --- a/desktop/renderer/src/i18n/en-US.ts +++ b/desktop/renderer/src/i18n/en-US.ts @@ -713,6 +713,26 @@ export default { "skills.dev.applying": "Applying…", "skills.dev.applied": "Applied. Gateway reloaded.", "skills.dev.applyFailed": "Failed to apply: {error}", + "skills.dev.refresh": "Refresh status", + "skills.dev.refreshing": "Refreshing…", + "skills.dev.thisAgent": "This agent", + "skills.dev.global": "Global", + "skills.dev.modelVisibleSummary": "Model-visible: {visible} / {total}", + "skills.dev.statusFailed": "Status unavailable: {error}", + "skills.dev.badge.visible": "Model-visible", + "skills.dev.badge.hidden": "Not injected", + "skills.dev.badge.unknown": "No status", + "skills.dev.reason.allowlist": "Blocked by global allowlist", + "skills.dev.reason.agentFilter": "Excluded by agent allowlist", + "skills.dev.reason.disabled": "Disabled", + "skills.dev.reason.missing": "Missing requirements", + "skills.dev.missing.bins": "bins: {list}", + "skills.dev.missing.env": "env: {list}", + "skills.dev.missing.config": "config: {list}", + "skills.dev.missing.os": "os: {list}", + "skills.dev.globalUpdating": "Updating global state for {skill}…", + "skills.dev.globalUpdated": "Updated global state for {skill}. Gateway reloaded.", + "skills.dev.globalFailed": "Failed to update global state: {error}", // ── Studio ── "studio.switchToStudio": "Studio", diff --git a/desktop/renderer/src/i18n/zh-CN.ts b/desktop/renderer/src/i18n/zh-CN.ts index fbbdd41..5e9353c 100644 --- a/desktop/renderer/src/i18n/zh-CN.ts +++ b/desktop/renderer/src/i18n/zh-CN.ts @@ -686,6 +686,26 @@ export default { "skills.dev.applying": "正在应用……", "skills.dev.applied": "已应用,网关已重载。", "skills.dev.applyFailed": "应用失败:{error}", + "skills.dev.refresh": "刷新状态", + "skills.dev.refreshing": "刷新中…", + "skills.dev.thisAgent": "本智能体", + "skills.dev.global": "全局", + "skills.dev.modelVisibleSummary": "模型可见:{visible} / {total}", + "skills.dev.statusFailed": "无法获取状态:{error}", + "skills.dev.badge.visible": "模型可见", + "skills.dev.badge.hidden": "未注入", + "skills.dev.badge.unknown": "无状态", + "skills.dev.reason.allowlist": "被全局允许列表拦截", + "skills.dev.reason.agentFilter": "被智能体允许列表排除", + "skills.dev.reason.disabled": "已禁用", + "skills.dev.reason.missing": "缺少依赖", + "skills.dev.missing.bins": "命令:{list}", + "skills.dev.missing.env": "环境变量:{list}", + "skills.dev.missing.config": "配置项:{list}", + "skills.dev.missing.os": "系统:{list}", + "skills.dev.globalUpdating": "正在更新 {skill} 的全局状态…", + "skills.dev.globalUpdated": "已更新 {skill} 的全局状态,网关已重载。", + "skills.dev.globalFailed": "更新全局状态失败:{error}", // ── Studio ── "studio.switchToStudio": "工作室", diff --git a/desktop/src/agent-catalog.test.ts b/desktop/src/agent-catalog.test.ts index 62e81de..f85f717 100644 --- a/desktop/src/agent-catalog.test.ts +++ b/desktop/src/agent-catalog.test.ts @@ -4,7 +4,10 @@ import { ALL_SKILL_IDS, getAgentSkills, isKnownSkillId, + matchesSkill, + resolveSkillFilterNames, sanitizeAgentSkillIds, + SKILL_MATCH_NAMES, } from "./agent-catalog"; const EXPECTED_SKILL_IDS = [ @@ -99,3 +102,50 @@ describe("agent catalog skills binding", () => { ); }); }); + +describe("OpenClaw skill match-name resolution", () => { + const EXCEPTIONS: Record = { + "desktop-organizer": "Desktop Organizer", + "excel-xlsx": "Excel / XLSX", + "powerpoint-pptx": "Powerpoint / PPTX", + "security-practice": "Security Practice", + "word-docx": "Word / DOCX", + }; + + it("maps exactly the five slug/name exceptions", () => { + expect(SKILL_MATCH_NAMES).toEqual(EXCEPTIONS); + }); + + it("only maps slugs that are real catalog ids", () => { + for (const slug of Object.keys(SKILL_MATCH_NAMES)) { + expect(isKnownSkillId(slug)).toBe(true); + } + }); + + it("resolveSkillFilterNames maps the exceptions and passes others through", () => { + expect(resolveSkillFilterNames(["excel-xlsx", "1password", "word-docx", "canvas"])).toEqual([ + "Excel / XLSX", + "1password", + "Word / DOCX", + "canvas", + ]); + }); + + it("resolves every catalog id, leaving non-exception slugs unchanged", () => { + const resolved = resolveSkillFilterNames(ALL_SKILL_IDS); + for (let i = 0; i < ALL_SKILL_IDS.length; i++) { + const slug = ALL_SKILL_IDS[i]; + expect(resolved[i]).toBe(EXCEPTIONS[slug] ?? slug); + } + }); + + it("matchesSkill recognizes both slug- and name-form stored values", () => { + // Exception: both slug and mapped name register. + expect(matchesSkill("excel-xlsx", "excel-xlsx")).toBe(true); + expect(matchesSkill("Excel / XLSX", "excel-xlsx")).toBe(true); + // Non-exception: slug matches, unrelated value does not. + expect(matchesSkill("1password", "1password")).toBe(true); + expect(matchesSkill("Excel / XLSX", "1password")).toBe(false); + expect(matchesSkill("word-docx", "excel-xlsx")).toBe(false); + }); +}); diff --git a/desktop/src/agent-catalog.ts b/desktop/src/agent-catalog.ts index 72f4188..b0f3f9e 100644 --- a/desktop/src/agent-catalog.ts +++ b/desktop/src/agent-catalog.ts @@ -207,6 +207,47 @@ export const DEFAULT_AGENT_IDS = AGENT_CATALOG.filter( (agent) => agent.installedByDefault, ).map((agent) => agent.id); +// OpenClaw's runtime filters the per-agent allowlist (config.agents.list[*].skills) +// against each skill's FRONTMATTER `name:` field — NOT its directory slug/skillKey +// (see openclaw dist workspace bundle: `filtered.filter(entry => allowed.has(entry.skill.name))`). +// For most skills the frontmatter `name:` equals the slug, so they bind either way. The +// entries below are the ONLY skills whose installed frontmatter `name:` differs from the +// slug; without remapping them, they are silently excluded from an agent even when eligible. +// +// SYNC: each value here MUST match the exact `name:` field in the corresponding installed +// skill's SKILL.md frontmatter. Keep this map limited to the differing cases. +// +// NOTE: `canvas` is present in ALL_SKILL_IDS (kept in sync with deployer/skill_catalog.py) +// but resolves to no installed skill on any known host — it is a phantom that matches +// nothing. It is intentionally left in ALL_SKILL_IDS; removing it is out of scope. +export const SKILL_MATCH_NAMES: Readonly> = { + "desktop-organizer": "Desktop Organizer", + "excel-xlsx": "Excel / XLSX", + "powerpoint-pptx": "Powerpoint / PPTX", + "security-practice": "Security Practice", + "word-docx": "Word / DOCX", +}; + +/** + * Resolves catalog slugs to the match-names OpenClaw's runtime expects in the + * per-agent allowlist. Slugs without a mapping pass through unchanged. Call this + * ONLY at the moment of writing openclaw.json — the renderer/UI and IPC input stay + * slug-based. + */ +export function resolveSkillFilterNames(ids: readonly string[]): string[] { + return ids.map((id) => SKILL_MATCH_NAMES[id] ?? id); +} + +/** + * Returns true when a value stored in config.agents.list[*].skills corresponds to + * the given catalog slug. Matches EITHER the raw slug (older config written before + * the name remap) OR the mapped match-name (current config), so round-trip reads + * recognize both forms. + */ +export function matchesSkill(storedValue: string, slug: string): boolean { + return storedValue === slug || storedValue === SKILL_MATCH_NAMES[slug]; +} + /** * Returns the skill IDs bound to the given agent, or an empty array if the agent * id is not present in AGENT_CATALOG. diff --git a/desktop/src/agent-personas.test.ts b/desktop/src/agent-personas.test.ts index c024e17..7cdd22c 100644 --- a/desktop/src/agent-personas.test.ts +++ b/desktop/src/agent-personas.test.ts @@ -2,7 +2,7 @@ import * as fs from "fs"; import * as os from "os"; import * as path from "path"; import { afterEach, describe, expect, it } from "vitest"; -import { getAgentSkills } from "./agent-catalog"; +import { getAgentSkills, matchesSkill, resolveSkillFilterNames } from "./agent-catalog"; import { AGENT_PERSONAS, DEFAULT_AGENT_PERSONAS, @@ -229,7 +229,33 @@ describe("agent personas", () => { ensureAgentPersonasConfig(config, stateDir, [...DEFAULT_AGENT_PERSONAS, persona]); for (const entry of agentList(config)) { - expect(entry.skills).toEqual(getAgentSkills(entry.id)); + // Config stores OpenClaw match-names, not raw catalog slugs. + expect(entry.skills).toEqual(resolveSkillFilterNames(getAgentSkills(entry.id))); + } + }); + + it("writes OpenClaw match-names (not slugs) for remapped skills", () => { + const stateDir = createStateDir(); + const config = { agents: {} }; + + ensureAgentPersonasConfig(config, stateDir); + + const mainEntry = agentList(config).find((entry) => entry.id === "main"); + const skills = (mainEntry?.skills ?? []) as string[]; + // The five display-name exceptions must be stored by their frontmatter name. + expect(skills).toContain("Excel / XLSX"); + expect(skills).toContain("Word / DOCX"); + expect(skills).toContain("Powerpoint / PPTX"); + expect(skills).toContain("Desktop Organizer"); + expect(skills).toContain("Security Practice"); + // Their raw slugs must NOT appear. + expect(skills).not.toContain("excel-xlsx"); + expect(skills).not.toContain("word-docx"); + // Non-exception slugs pass through unchanged. + expect(skills).toContain("1password"); + // Every stored value round-trips back to a catalog slug via matchesSkill. + for (const slug of getAgentSkills("main")) { + expect(skills.some((value) => matchesSkill(value, slug))).toBe(true); } }); @@ -285,7 +311,7 @@ describe("agent personas", () => { const customEntry = agentList(config).find((entry) => entry.id === "custom-bot"); expect(customEntry).not.toHaveProperty("skills"); const mainEntry = agentList(config).find((entry) => entry.id === "main"); - expect(mainEntry?.skills).toEqual(getAgentSkills("main")); + expect(mainEntry?.skills).toEqual(resolveSkillFilterNames(getAgentSkills("main"))); }); it("reports changed when skills are added to a config that lacked them", () => { diff --git a/desktop/src/agent-personas.ts b/desktop/src/agent-personas.ts index c756746..a77dbc0 100644 --- a/desktop/src/agent-personas.ts +++ b/desktop/src/agent-personas.ts @@ -1,7 +1,7 @@ import * as fs from "fs"; import * as os from "os"; import * as path from "path"; -import { AGENT_CATALOG, DEFAULT_AGENT_IDS } from "./agent-catalog"; +import { AGENT_CATALOG, DEFAULT_AGENT_IDS, resolveSkillFilterNames } from "./agent-catalog"; interface WorkspaceFiles { "AGENTS.md": string; @@ -152,7 +152,9 @@ function createAgentEntry(persona: AgentPersona, stateDir: string): Record = { name: persona.name }; const workspace = getAgentWorkspacePath(stateDir, persona); if (workspace) entry.workspace = workspace; - entry.skills = [...persona.skills]; + // Write the OpenClaw match-names (not raw slugs) so the runtime's frontmatter-name + // based allowlist filter binds every eligible skill. See resolveSkillFilterNames. + entry.skills = resolveSkillFilterNames([...persona.skills]); return entry; } @@ -264,7 +266,8 @@ export function ensureAgentPersonasConfig( // (fill-if-missing). Existing skills arrays — including dev-edited ones from // the runtime Skills panel — are preserved across gateway reloads. if (catalogSkills !== undefined && !Object.hasOwn(entry, "skills")) { - entry.skills = [...catalogSkills]; + // Persist match-names (not raw slugs) — see resolveSkillFilterNames. + entry.skills = resolveSkillFilterNames([...catalogSkills]); } } diff --git a/desktop/src/constants.ts b/desktop/src/constants.ts index c41eafe..ce80736 100644 --- a/desktop/src/constants.ts +++ b/desktop/src/constants.ts @@ -64,6 +64,9 @@ export const SANDBOX_PERMISSION_TIMEOUT_MS = 60_000; /** Timeout for the WeChat login flow (ms). */ export const WEIXIN_LOGIN_TIMEOUT_MS = 180_000; +/** Timeout for each OpenClaw `skills` diagnostics CLI invocation (ms). */ +export const SKILLS_STATUS_TIMEOUT_MS = 30_000; + /** Number of days of usage data to query from the gateway. */ export const USAGE_QUERY_DAYS = 30; diff --git a/desktop/src/main.ts b/desktop/src/main.ts index 1c31e19..906d11f 100644 --- a/desktop/src/main.ts +++ b/desktop/src/main.ts @@ -54,6 +54,7 @@ import { MIN_WINDOW_HEIGHT, POST_SPAWN_RESTART_DELAY_MS, WEIXIN_LOGIN_TIMEOUT_MS, + SKILLS_STATUS_TIMEOUT_MS, USAGE_QUERY_DAYS, UPDATE_MANIFEST_URL, } from "./constants"; @@ -90,7 +91,7 @@ import { type AgentPersona, type AgentRosterConfig, } from "./agent-personas"; -import { sanitizeAgentSkillIds } from "./agent-catalog"; +import { resolveSkillFilterNames, sanitizeAgentSkillIds } from "./agent-catalog"; /** * Normalize a directory path for comparison/storage. @@ -1840,8 +1841,124 @@ function notifyRemotePermissionNeeded(): void { sendWeixinNotification(cachedRemoteSource, mainT(lang, "perm.remoteNotify")); } +/** One skill's diagnostic status merged from `skills check` + `skills list --json`. */ +interface SkillStatusRecord { + skillKey: string; + name: string; + source: string; + bundled: boolean; + eligible: boolean; + disabled: boolean; + modelVisible: boolean; + commandVisible: boolean; + blockedByAllowlist: boolean; + blockedByAgentFilter: boolean; + missing: { + bins: string[]; + anyBins: string[]; + env: string[]; + config: string[]; + os: string[]; + }; +} + +interface SkillsStatusResult { + ok: boolean; + error?: string; + summary: { + total: number; + modelVisible: number; + }; + skills: SkillStatusRecord[]; +} + +/** + * OpenClaw's CLI prints a config-warning banner before its JSON payload. Strip + * everything before the first top-level `{` so the remainder parses cleanly. + */ +function stripJsonBanner(raw: string): string { + const start = raw.indexOf("{"); + return start >= 0 ? raw.slice(start) : raw; +} + +function asStringArray(value: unknown): string[] { + return Array.isArray(value) ? value.filter((v): v is string => typeof v === "string") : []; +} + +/** + * Merge the outputs of `skills list --agent --json` (per-skill booleans + + * `missing`) and `skills check --agent --json` (aggregate buckets + + * detailed `missingRequirements`) into one per-skill record list. `list` is the + * primary source; `check` supplies `missingRequirements` detail and the model-visible + * bucket. Either input may be null when its CLI call failed. + */ +function normalizeSkillsStatus( + listJson: Record | null, + checkJson: Record | null, +): SkillStatusRecord[] { + // Detailed missing-requirement info keyed by skill name from `check`. + const missingByName = new Map< + string, + { bins: string[]; anyBins: string[]; env: string[]; config: string[]; os: string[] } + >(); + const missingReqs = Array.isArray(checkJson?.missingRequirements) + ? (checkJson!.missingRequirements as unknown[]) + : []; + for (const req of missingReqs) { + if (typeof req !== "object" || req === null) continue; + const r = req as Record; + const name = typeof r.name === "string" ? r.name : undefined; + if (!name) continue; + const m = (r.missing ?? {}) as Record; + missingByName.set(name, { + bins: asStringArray(m.bins), + anyBins: asStringArray(m.anyBins), + env: asStringArray(m.env), + config: asStringArray(m.config), + os: asStringArray(m.os), + }); + } + + const rawSkills = Array.isArray(listJson?.skills) ? (listJson!.skills as unknown[]) : []; + const records: SkillStatusRecord[] = []; + for (const raw of rawSkills) { + if (typeof raw !== "object" || raw === null) continue; + const s = raw as Record; + const name = typeof s.name === "string" ? s.name : ""; + // `skills list` keys skills by their frontmatter `name`; treat it as the skillKey + // the UI needs for global gating only when a distinct key isn't provided. + const skillKey = + typeof s.skillKey === "string" + ? s.skillKey + : typeof s.key === "string" + ? s.key + : name; + const detail = missingByName.get(name); + const perSkillMissing = (s.missing ?? {}) as Record; + records.push({ + skillKey, + name, + source: typeof s.source === "string" ? s.source : "", + bundled: Boolean(s.bundled), + eligible: Boolean(s.eligible), + disabled: Boolean(s.disabled), + modelVisible: Boolean(s.modelVisible), + commandVisible: Boolean(s.commandVisible), + blockedByAllowlist: Boolean(s.blockedByAllowlist), + blockedByAgentFilter: Boolean(s.blockedByAgentFilter), + missing: detail ?? { + bins: asStringArray(perSkillMissing.bins), + anyBins: asStringArray(perSkillMissing.anyBins), + env: asStringArray(perSkillMissing.env), + config: asStringArray(perSkillMissing.config), + os: asStringArray(perSkillMissing.os), + }, + }); + } + return records; +} + async function startGateway(): Promise { - // Prevent concurrent calls — only one startGateway at a time if (gatewayStartInProgress) { console.log("[gateway] startGateway() already in progress, skipping"); return; @@ -3164,7 +3281,10 @@ function registerIpcHandlers(): void { if (!entry) { throw new Error(`Unknown agent "${agentId}"`); } - entry.skills = skills; + // Persist match-names (not raw slugs) so OpenClaw's frontmatter-name based + // allowlist filter binds every eligible skill. The IPC input and return value + // stay slug-based; resolution happens only at the openclaw.json write. + entry.skills = resolveSkillFilterNames(skills); const alreadyRunning = await checkExistingGateway(gatewayPort); const managedGateway = gatewaySpawnedByUs && gatewayProcess !== null; @@ -3203,6 +3323,193 @@ function registerIpcHandlers(): void { }, ); + // Dev-only diagnostics: run the OpenClaw CLI directly (reads config from disk; + // does NOT require the gateway to be up) to surface each skill's gating status + // for the given agent. Merges `skills list` (per-skill booleans + missing) with + // `skills check` (aggregate buckets + detailed missingRequirements). + ipcMain.handle( + "skills:get-status", + async (_event, agentId: string): Promise => { + const emptySummary = { total: 0, modelVisible: 0 }; + if (typeof agentId !== "string" || agentId.length === 0) { + return { ok: false, error: "A non-empty agentId is required", summary: emptySummary, skills: [] }; + } + + const nodePath = resolveNodePath(); + const entryPath = resolveOpenClawEntry(); + if (!fs.existsSync(nodePath) || !fs.existsSync(entryPath)) { + return { ok: false, error: "OpenClaw CLI not found", summary: emptySummary, skills: [] }; + } + + const stateDir = getOpenClawStateDir(); + const compileCacheDir = path.join(stateDir, COMPILE_CACHE_SUBDIR); + + const runCli = (subArgs: string[]): Promise | null> => + new Promise((resolve) => { + const spawnOpts: Record = { + cwd: path.dirname(entryPath), + env: { + ...process.env, + OPENCLAW_STATE_DIR: stateDir, + NODE_COMPILE_CACHE: compileCacheDir, + }, + stdio: ["ignore", "pipe", "pipe"], + windowsHide: true, + }; + if (process.platform === "win32") { + spawnOpts.creationFlags = CREATE_NO_WINDOW; + } + let stdout = ""; + let stderr = ""; + let settled = false; + const child = spawn(nodePath, [entryPath, ...subArgs], spawnOpts); + child.stdout?.on("data", (chunk: Buffer) => (stdout += chunk.toString("utf-8"))); + child.stderr?.on("data", (chunk: Buffer) => (stderr += chunk.toString("utf-8"))); + const finish = (value: Record | null) => { + if (settled) return; + settled = true; + resolve(value); + }; + child.on("error", () => finish(null)); + child.on("close", () => { + try { + const parsed = JSON.parse(stripJsonBanner(stdout)); + finish(typeof parsed === "object" && parsed !== null ? parsed : null); + } catch { + if (stderr.trim()) console.error(`[skills:get-status] CLI stderr: ${stderr.trim()}`); + finish(null); + } + }); + setTimeout(() => { + if (!settled) { + child.kill(); + finish(null); + } + }, SKILLS_STATUS_TIMEOUT_MS); + }); + + try { + const [listJson, checkJson] = await Promise.all([ + runCli(["skills", "list", "--agent", agentId, "--json"]), + runCli(["skills", "check", "--agent", agentId, "--json"]), + ]); + if (!listJson && !checkJson) { + return { + ok: false, + error: "OpenClaw skills CLI produced no parseable output", + summary: emptySummary, + skills: [], + }; + } + const skills = normalizeSkillsStatus(listJson, checkJson); + return { + ok: true, + summary: { + total: skills.length, + modelVisible: skills.filter((s) => s.modelVisible).length, + }, + skills, + }; + } catch (error) { + return { + ok: false, + error: error instanceof Error ? error.message : String(error), + summary: emptySummary, + skills: [], + }; + } + }, + ); + + // Dev-only: global master toggle for a single skill. Controls OpenClaw's global + // gating (both layers): the per-skill `skills.entries[key].enabled` flag and, for + // bundled skills, membership in `skills.allowBundled`. Operates on skillKey (slug). + ipcMain.handle( + "skills:set-global-enabled", + async ( + _event, + params: { skillKey: string; enabled: boolean }, + ): Promise<{ skillKey: string; enabled: boolean }> => { + const skillKey = params?.skillKey; + const enabled = params?.enabled; + if (typeof skillKey !== "string" || skillKey.length === 0) { + throw new Error("A non-empty skillKey is required"); + } + if (typeof enabled !== "boolean") { + throw new Error("enabled must be a boolean"); + } + + // Validate against actually-discovered skills and learn whether it's bundled. + const payload = buildSkillsPayload(false); + const discovered = [...payload.builtin, ...payload.custom, ...payload.managed].find( + (s) => s.id === skillKey, + ); + if (!discovered) { + throw new Error(`Unknown skill "${skillKey}"`); + } + const isBundled = discovered.source === "builtin"; + + const configPath = getConfigPath(); + const originalConfigText = fs.readFileSync(configPath, "utf-8"); + const config = readConfig(); + if (!config) throw new Error("OpenClaw configuration is unavailable"); + + if (!config.skills) config.skills = {}; + if (!config.skills.entries) config.skills.entries = {}; + if (!config.skills.entries[skillKey]) config.skills.entries[skillKey] = {}; + + if (enabled) { + // Clear any global disable, and — for bundled skills gated by a non-empty + // allowlist — add the key so it isn't excluded. When allowBundled is absent + // or empty, bundled skills are already globally allowed, so leave it alone + // rather than creating a newly restrictive allowlist. + config.skills.entries[skillKey].enabled = true; + if (isBundled && Array.isArray(config.skills.allowBundled) && config.skills.allowBundled.length > 0) { + if (!config.skills.allowBundled.includes(skillKey)) { + config.skills.allowBundled.push(skillKey); + } + } + } else { + config.skills.entries[skillKey].enabled = false; + } + + const alreadyRunning = await checkExistingGateway(gatewayPort); + const managedGateway = gatewaySpawnedByUs && gatewayProcess !== null; + if ( + requiresExternalGatewayStop( + alreadyRunning, + true, + gatewaySpawnedByUs, + gatewayProcess !== null, + ) + ) { + throw new Error( + "Cannot update skill state while MicroClaw is connected to an externally managed Gateway", + ); + } + + let restartAttempted = false; + try { + writeConfigTextAtomically(JSON.stringify(config, null, 2)); + restartAttempted = true; + await restartManagedGateway(`Toggling global state for skill ${skillKey}`); + return { skillKey, enabled }; + } catch (error) { + try { + writeConfigTextAtomically(originalConfigText); + if (restartAttempted && managedGateway) { + await restartManagedGateway( + `Rolling back failed global toggle for ${skillKey}`, + ); + } + } catch { + // Preserve the original failure; rollback is best-effort. + } + throw error; + } + }, + ); + // --- Chat (WebSocket gateway protocol) --- ipcMain.handle( "chat:send-message", diff --git a/desktop/src/preload.ts b/desktop/src/preload.ts index 7809b37..453582f 100644 --- a/desktop/src/preload.ts +++ b/desktop/src/preload.ts @@ -62,6 +62,12 @@ contextBridge.exposeInMainWorld("openclaw", { agentId: string; skills: string[]; }>, + getStatus: (agentId: string) => ipcRenderer.invoke("skills:get-status", agentId), + setGlobalEnabled: (skillKey: string, enabled: boolean) => + ipcRenderer.invoke("skills:set-global-enabled", { skillKey, enabled }) as Promise<{ + skillKey: string; + enabled: boolean; + }>, integrityCheck: () => ipcRenderer.invoke("skills:integrity-check"), pendingIntegrityResult: () => ipcRenderer.invoke("skills:pending-integrity-result"), acceptIntegrityChanges: () => ipcRenderer.invoke("skills:accept-integrity-changes"), From 648e354ea1ccc4feeea3473f292efa9642be7dea Mon Sep 17 00:00:00 2001 From: Copilot App <223556219+Copilot@users.noreply.github.com> Date: Thu, 30 Jul 2026 12:37:19 +0800 Subject: [PATCH 5/8] Fix skills status timeout and panel layout Raise SKILLS_STATUS_TIMEOUT_MS to 120s so the cold OpenClaw CLI (52-81s to emit JSON) is not killed before producing output, and drop the redundant 'skills check' spawn since 'skills list --json' already carries every per-skill boolean and missing-requirement the panel uses. Add a visible 'checking status' loading line during the ~1 minute wait. Redesign SkillsDevPanel as a single-column vertical list: skill name + status/reason/missing on the left, per-agent 'This agent' and 'Global' toggles right-aligned, no horizontal overflow or clipped labels. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 1fe75171-fea2-45ef-9401-bf8a4ea0f2ba --- .../src/components/skills/SkillsDevPanel.vue | 73 ++++++++++++------- desktop/renderer/src/i18n/en-US.ts | 1 + desktop/renderer/src/i18n/zh-CN.ts | 1 + desktop/src/constants.ts | 6 +- desktop/src/main.ts | 13 ++-- 5 files changed, 61 insertions(+), 33 deletions(-) diff --git a/desktop/renderer/src/components/skills/SkillsDevPanel.vue b/desktop/renderer/src/components/skills/SkillsDevPanel.vue index 8ed20cd..5c3805c 100644 --- a/desktop/renderer/src/components/skills/SkillsDevPanel.vue +++ b/desktop/renderer/src/components/skills/SkillsDevPanel.vue @@ -43,7 +43,10 @@
- + + {{ t("skills.dev.statusChecking") }} + + {{ t("skills.dev.modelVisibleSummary", { visible: status.summary.modelVisible, total: status.summary.total, @@ -57,10 +60,17 @@
{{ t("skills.dev.loading") }}
  • -
    +
    + {{ id }} +
    + {{ badgeText(id) }} + {{ reasonText(id) }} + {{ missingText(id) }} +
    +
    +
    -
    - {{ badgeText(id) }} - {{ reasonText(id) }} - {{ missingText(id) }} -
@@ -419,34 +424,54 @@ onMounted(() => { list-style: none; margin: 0; padding: 0; - display: grid; - grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); - gap: 6px 16px; + display: flex; + flex-direction: column; + gap: 6px; } .skills-dev-item { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 16px; + min-width: 0; + padding: 8px 10px; + border: 1px solid var(--ux-border); + border-radius: 8px; +} + +.skills-dev-item-left { + display: flex; + flex-direction: column; + gap: 4px; min-width: 0; + flex: 1; +} + +.skills-dev-item-controls { + display: flex; + align-items: center; + gap: 16px; + flex-shrink: 0; } .skills-dev-toggle { display: flex; align-items: center; - gap: 8px; - padding: 6px 8px; + gap: 6px; border-radius: 8px; cursor: pointer; -} - -.skills-dev-toggle:hover { - background: var(--ux-surface-hover); + white-space: nowrap; } .skills-dev-skill-id { font-size: 13px; + font-weight: 500; color: var(--ux-text-primary); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; + min-width: 0; } .skills-dev-summary { @@ -458,15 +483,13 @@ onMounted(() => { min-height: 16px; } -.skills-dev-status-error { - color: var(--ux-status-error, #c62828); +.skills-dev-status-checking { + color: var(--ux-ctrl-brand-rest); + font-weight: 500; } -.skills-dev-item-main { - display: flex; - align-items: center; - gap: 8px; - justify-content: space-between; +.skills-dev-status-error { + color: var(--ux-status-error, #c62828); } .skills-dev-toggle-hint { @@ -480,6 +503,7 @@ onMounted(() => { gap: 4px; cursor: pointer; flex-shrink: 0; + white-space: nowrap; } .skills-dev-global-label { @@ -492,7 +516,6 @@ onMounted(() => { flex-wrap: wrap; align-items: center; gap: 6px; - padding: 2px 8px 4px 8px; } .skills-dev-badge { diff --git a/desktop/renderer/src/i18n/en-US.ts b/desktop/renderer/src/i18n/en-US.ts index 38cb6a3..8eb6c6b 100644 --- a/desktop/renderer/src/i18n/en-US.ts +++ b/desktop/renderer/src/i18n/en-US.ts @@ -719,6 +719,7 @@ export default { "skills.dev.global": "Global", "skills.dev.modelVisibleSummary": "Model-visible: {visible} / {total}", "skills.dev.statusFailed": "Status unavailable: {error}", + "skills.dev.statusChecking": "Checking skill status… this can take up to a minute.", "skills.dev.badge.visible": "Model-visible", "skills.dev.badge.hidden": "Not injected", "skills.dev.badge.unknown": "No status", diff --git a/desktop/renderer/src/i18n/zh-CN.ts b/desktop/renderer/src/i18n/zh-CN.ts index 5e9353c..3967185 100644 --- a/desktop/renderer/src/i18n/zh-CN.ts +++ b/desktop/renderer/src/i18n/zh-CN.ts @@ -692,6 +692,7 @@ export default { "skills.dev.global": "全局", "skills.dev.modelVisibleSummary": "模型可见:{visible} / {total}", "skills.dev.statusFailed": "无法获取状态:{error}", + "skills.dev.statusChecking": "正在检查技能状态…最长可能需要一分钟。", "skills.dev.badge.visible": "模型可见", "skills.dev.badge.hidden": "未注入", "skills.dev.badge.unknown": "无状态", diff --git a/desktop/src/constants.ts b/desktop/src/constants.ts index ce80736..aec4dc8 100644 --- a/desktop/src/constants.ts +++ b/desktop/src/constants.ts @@ -64,8 +64,10 @@ export const SANDBOX_PERMISSION_TIMEOUT_MS = 60_000; /** Timeout for the WeChat login flow (ms). */ export const WEIXIN_LOGIN_TIMEOUT_MS = 180_000; -/** Timeout for each OpenClaw `skills` diagnostics CLI invocation (ms). */ -export const SKILLS_STATUS_TIMEOUT_MS = 30_000; +/** Timeout for each OpenClaw `skills` diagnostics CLI invocation (ms). The CLI is + * spawned cold and can take ~60–90s to emit its JSON on slower machines, so this + * is generous headroom to avoid killing the child before it produces output. */ +export const SKILLS_STATUS_TIMEOUT_MS = 120_000; /** Number of days of usage data to query from the gateway. */ export const USAGE_QUERY_DAYS = 30; diff --git a/desktop/src/main.ts b/desktop/src/main.ts index 906d11f..869fb14 100644 --- a/desktop/src/main.ts +++ b/desktop/src/main.ts @@ -3389,11 +3389,12 @@ function registerIpcHandlers(): void { }); try { - const [listJson, checkJson] = await Promise.all([ - runCli(["skills", "list", "--agent", agentId, "--json"]), - runCli(["skills", "check", "--agent", agentId, "--json"]), - ]); - if (!listJson && !checkJson) { + // `skills list --json` already carries every per-skill boolean and the + // per-skill `missing` requirements the panel renders, so we only spawn one + // CLI process (the cold CLI can take ~60–90s). normalizeSkillsStatus stays + // tolerant of a null `check` payload. + const listJson = await runCli(["skills", "list", "--agent", agentId, "--json"]); + if (!listJson) { return { ok: false, error: "OpenClaw skills CLI produced no parseable output", @@ -3401,7 +3402,7 @@ function registerIpcHandlers(): void { skills: [], }; } - const skills = normalizeSkillsStatus(listJson, checkJson); + const skills = normalizeSkillsStatus(listJson, null); return { ok: true, summary: { From 140920de65ea941de87f332f0958f5334ab0a2d8 Mon Sep 17 00:00:00 2001 From: Copilot App <223556219+Copilot@users.noreply.github.com> Date: Thu, 30 Jul 2026 13:13:14 +0800 Subject: [PATCH 6/8] Add Not installed badge and defer the global skill toggle Show a muted 'Not installed' badge for catalog slugs (e.g. canvas) that return no record once status has loaded successfully, instead of a bare disabled Global switch. Make the per-skill Global master toggle deferred like the per-agent toggle: flipping it only stages a pending change, and a single 'Apply & reload' now persists BOTH the per-agent allowlist and every global on/off change in ONE atomic config write + ONE gateway restart via the new skills:apply-agent-config IPC. Factor the config mutations into shared skill-config helpers (applyAgentSkillsToConfig / applyGlobalSkillChange) reused by the existing set-agent-skills and set-global-enabled handlers, and unit-test them. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 1fe75171-fea2-45ef-9401-bf8a4ea0f2ba --- desktop/renderer/env.d.ts | 9 ++ .../src/components/skills/SkillsDevPanel.vue | 96 +++++++++---- desktop/renderer/src/i18n/en-US.ts | 4 +- desktop/renderer/src/i18n/zh-CN.ts | 6 +- desktop/src/main.ts | 136 +++++++++++++----- desktop/src/preload.ts | 14 ++ desktop/src/skill-config.test.ts | 121 ++++++++++++++++ desktop/src/skill-config.ts | 90 ++++++++++++ 8 files changed, 406 insertions(+), 70 deletions(-) create mode 100644 desktop/src/skill-config.test.ts create mode 100644 desktop/src/skill-config.ts diff --git a/desktop/renderer/env.d.ts b/desktop/renderer/env.d.ts index d705dd9..95ba87e 100644 --- a/desktop/renderer/env.d.ts +++ b/desktop/renderer/env.d.ts @@ -161,6 +161,15 @@ interface OpenClawAPI { setAgentSkills(agentId: string, skillIds: string[]): Promise<{ agentId: string; skills: string[] }>; getStatus(agentId: string): Promise; setGlobalEnabled(skillKey: string, enabled: boolean): Promise<{ skillKey: string; enabled: boolean }>; + applyAgentConfig( + agentId: string, + skillIds: string[], + globalChanges: { skillKey: string; enabled: boolean }[], + ): Promise<{ + agentId: string; + skills: string[]; + globalChanges: { skillKey: string; enabled: boolean }[]; + }>; integrityCheck(): Promise; pendingIntegrityResult(): Promise; acceptIntegrityChanges(): Promise; diff --git a/desktop/renderer/src/components/skills/SkillsDevPanel.vue b/desktop/renderer/src/components/skills/SkillsDevPanel.vue index 5c3805c..5ad3196 100644 --- a/desktop/renderer/src/components/skills/SkillsDevPanel.vue +++ b/desktop/renderer/src/components/skills/SkillsDevPanel.vue @@ -67,6 +67,11 @@ {{ reasonText(id) }} {{ missingText(id) }} +
+ + {{ t("skills.dev.badge.notInstalled") }} + +