From b6b340766737b9b330caed3f34ab1a3508064ac1 Mon Sep 17 00:00:00 2001 From: Serhii Vecherenko Date: Sat, 15 Aug 2026 16:44:22 -0700 Subject: [PATCH] fix(skills): support provider-native Claude skills - Make skill paths optional across prompts, mentions, and policies - Capture and resolve Claude SDK-native skills in sessions and probes - Upgrade the Claude Agent SDK and atomically generate protocol files - Add coverage for skill discovery, serialization, and SDK prompts --- package.json | 2 +- packages/codex-protocol/scripts/generate.mjs | 51 ++++++++----- pnpm-lock.yaml | 76 ++++++++++--------- pnpm-workspace.yaml | 9 +++ .../components/composer/MentionInput.tsx | 2 +- .../components/composer/SlashCommandChip.ts | 9 +-- .../components/composer/serializeMentions.ts | 4 +- .../thread/ThreadSlashCommands.test.tsx | 36 +++++++++ .../components/thread/threadSlashCommands.ts | 38 +++++++--- src/shared/contracts/thread.ts | 8 +- src/shared/promptContent.ts | 5 +- src/supervisor/agents/claude/index.ts | 6 +- src/supervisor/agents/claude/probe.test.ts | 57 ++++++++++++++ src/supervisor/agents/claude/probe.ts | 65 ++++++++++++++-- .../agents/claude/sdkProbeWorker.ts | 43 +++++++++-- .../agents/claude/sdkPrompt.test.ts | 56 ++++++++++++++ src/supervisor/agents/claude/sdkPrompt.ts | 6 ++ .../agents/claude/sdkSession.test.ts | 53 +++++++++++++ src/supervisor/agents/claude/sdkSession.ts | 51 +++++++++++-- src/supervisor/agents/codex/acpTurn.ts | 12 ++- src/supervisor/agents/opencode/promptParts.ts | 10 ++- src/supervisor/skills/SkillsService.ts | 24 ++++-- src/supervisor/skills/pluginSkillPolicy.ts | 4 +- src/supervisor/skills/skillPromptInjection.ts | 5 +- 24 files changed, 520 insertions(+), 112 deletions(-) create mode 100644 src/supervisor/agents/claude/sdkPrompt.test.ts diff --git a/package.json b/package.json index 3bdd78e9f..a05add010 100644 --- a/package.json +++ b/package.json @@ -77,7 +77,7 @@ }, "dependencies": { "@agentclientprotocol/sdk": "^1.2.1", - "@anthropic-ai/claude-agent-sdk": "^0.3.219", + "@anthropic-ai/claude-agent-sdk": "^0.3.233", "@aparajita/capacitor-secure-storage": "^8.0.0", "@capacitor/app": "^8.1.0", "@capacitor/push-notifications": "^8.1.1", diff --git a/packages/codex-protocol/scripts/generate.mjs b/packages/codex-protocol/scripts/generate.mjs index f2cc0ba06..a52d66c6d 100644 --- a/packages/codex-protocol/scripts/generate.mjs +++ b/packages/codex-protocol/scripts/generate.mjs @@ -1,4 +1,4 @@ -import { existsSync, rmSync } from "node:fs"; +import { existsSync, renameSync, rmSync } from "node:fs"; import { delimiter, dirname, resolve } from "node:path"; import { fileURLToPath } from "node:url"; import { spawnSync } from "node:child_process"; @@ -13,29 +13,43 @@ const binDirectories = [ const codexBinDirectory = binDirectories.find((directory) => existsSync(resolve(directory, executableName)), ); -const pnpmCli = process.env.npm_execpath; -if (!codexBinDirectory || !pnpmCli) { +if (!codexBinDirectory) { console.error( - "Cannot generate Codex protocol types: the pinned @openai/codex binary or pnpm CLI is missing. Run `pnpm install` from the repository root, then try again.", + "Cannot generate Codex protocol types: the pinned @openai/codex binary is missing. Run `pnpm install` from the repository root, then try again.", ); process.exit(1); } -rmSync(resolve(packageDir, "generated"), { recursive: true, force: true }); - -const result = spawnSync( - process.execPath, - [pnpmCli, "exec", "codex", "app-server", "generate-ts", "--experimental", "--out", "./generated"], - { - cwd: packageDir, - env: { - ...process.env, - PATH: `${codexBinDirectory}${delimiter}${process.env.PATH ?? ""}`, - }, - stdio: "inherit", +// Generate into a staging directory and swap on success, so a failed run never +// leaves the package without its committed-quality `generated/` output. +const generatedDir = resolve(packageDir, "generated"); +const stagingDir = resolve(packageDir, "generated.tmp"); +rmSync(stagingDir, { recursive: true, force: true }); + +const generateArgs = ["app-server", "generate-ts", "--experimental", "--out", "./generated.tmp"]; +// `npm_execpath` is pnpm's own CLI entry point. With a JS install it is a +// script that must run under node; the standalone distribution is a native +// executable that must be spawned directly. When it is unavailable (script run +// outside pnpm), invoke the codex bin resolved above. +const pnpmCli = process.env.npm_execpath; +const pnpmCliIsScript = pnpmCli !== undefined && /\.[cm]?js$/i.test(pnpmCli); +const [command, args] = pnpmCli + ? pnpmCliIsScript + ? [process.execPath, [pnpmCli, "exec", "codex", ...generateArgs]] + : [pnpmCli, ["exec", "codex", ...generateArgs]] + : [resolve(codexBinDirectory, executableName), generateArgs]; + +const result = spawnSync(command, args, { + cwd: packageDir, + env: { + ...process.env, + PATH: `${codexBinDirectory}${delimiter}${process.env.PATH ?? ""}`, }, -); + stdio: "inherit", + // .CMD shims cannot be spawned directly on Windows. + shell: process.platform === "win32" && command.endsWith(".CMD"), +}); if (result.error) { console.error(`Cannot generate Codex protocol types: ${result.error.message}`); @@ -46,3 +60,6 @@ if (result.status !== 0) { console.error(`Codex protocol generation failed with exit code ${result.status ?? "unknown"}.`); process.exit(result.status ?? 1); } + +rmSync(generatedDir, { recursive: true, force: true }); +renameSync(stagingDir, generatedDir); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index a115a70a3..60b629251 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -49,8 +49,8 @@ importers: specifier: ^1.2.1 version: 1.2.1(zod@4.4.3) '@anthropic-ai/claude-agent-sdk': - specifier: ^0.3.219 - version: 0.3.219(@anthropic-ai/sdk@0.93.0(zod@4.4.3))(@modelcontextprotocol/sdk@1.29.0(supports-color@10.2.2)(zod@4.4.3))(zod@4.4.3) + specifier: ^0.3.233 + version: 0.3.233(@anthropic-ai/sdk@0.93.0(zod@4.4.3))(@modelcontextprotocol/sdk@1.29.0(supports-color@10.2.2)(zod@4.4.3))(zod@4.4.3) '@aparajita/capacitor-secure-storage': specifier: ^8.0.0 version: 8.0.0 @@ -527,52 +527,52 @@ packages: '@antfu/install-pkg@1.1.0': resolution: {integrity: sha512-MGQsmw10ZyI+EJo45CdSER4zEb+p31LpDAFp2Z3gkSd1yqVZGi0Ebx++YTEMonJy4oChEMLsxZ64j8FH6sSqtQ==} - '@anthropic-ai/claude-agent-sdk-darwin-arm64@0.3.219': - resolution: {integrity: sha512-TQhGAlbMsOGXi03dwf4nD274Lc5BOJg4QFJTuXcQyhLhx0hUqzxCmtvpXt6S70K6yl1Zgc+2n5W/r1Dgt8G8iw==} + '@anthropic-ai/claude-agent-sdk-darwin-arm64@0.3.233': + resolution: {integrity: sha512-4WDiBZgcrmvTDJjS8RNZwoxGgMz/0EpOM+sYa6EtyjwHTd6It1H/+k5zBckCmBajbgS5/ASCJqdwZzi7dwBl0Q==} cpu: [arm64] os: [darwin] - '@anthropic-ai/claude-agent-sdk-darwin-x64@0.3.219': - resolution: {integrity: sha512-zA/wHN+os4yqXASXJxvGGNSD1p7LZ6BMN71CCphoy5u+d6IpNOCtATnZwINEc2fGFHpv/QDci6jhywJ2O9Flbw==} + '@anthropic-ai/claude-agent-sdk-darwin-x64@0.3.233': + resolution: {integrity: sha512-RaaEfNrbqSh77H5NdVF9cJQ0xhAUO92aOv71LSKSdAYModMeUvJN0k22Q7gvmx0TlmqJ+aVyCG8J8gVfgSL9mg==} cpu: [x64] os: [darwin] - '@anthropic-ai/claude-agent-sdk-linux-arm64-musl@0.3.219': - resolution: {integrity: sha512-yiZo+UBCp42FAYqSakgVj/g6LfTH0AklWqIZNEHfVDPaofledXdSu0QJM5yScWNg0hVMaNRXPt0DmuS1oIWW6g==} + '@anthropic-ai/claude-agent-sdk-linux-arm64-musl@0.3.233': + resolution: {integrity: sha512-Z3uZdzt6xgJ3f4NIgO6lzBYSELULKSq6AL4OsNLBzuaEpVW0iYs1kUCaD9rcMlMrf3cV+Dk/GA/lTCGMgbucjQ==} cpu: [arm64] os: [linux] libc: [musl] - '@anthropic-ai/claude-agent-sdk-linux-arm64@0.3.219': - resolution: {integrity: sha512-lizasky9Xj0ouYrz/Akst573Sm8NO3k/0iGCeCDBoQKLnONbVFwc7sHq9x426Pfcllgb4C2Xyc79/1iOALh/9g==} + '@anthropic-ai/claude-agent-sdk-linux-arm64@0.3.233': + resolution: {integrity: sha512-Az9HjQthYQqRjJCacBtDIAHX3TRGK9WlACNb/UOGAK3JndNzZMprM2mK/t6YmP2cRLJsGyorxL7HZmR9R9HYaw==} cpu: [arm64] os: [linux] libc: [glibc] - '@anthropic-ai/claude-agent-sdk-linux-x64-musl@0.3.219': - resolution: {integrity: sha512-IhfG57/XorWOQTB6BVdjEFPGH0LwOWO6HnAi+FNuCeiC+XRFl9QI9AKY43opS0gbvJWRobZO865tHX5j7Ou+ow==} + '@anthropic-ai/claude-agent-sdk-linux-x64-musl@0.3.233': + resolution: {integrity: sha512-kYBIAQCu2f1YITcGbpUN2jfrkAzs59TVAragAhE2z+GrkIcxcpZwmaRY6heMBtaSY8SuyrwgqbCW9hJALYFnEg==} cpu: [x64] os: [linux] libc: [musl] - '@anthropic-ai/claude-agent-sdk-linux-x64@0.3.219': - resolution: {integrity: sha512-yzXhEsT5XcKa8Dc3sM4D1CQ92/6YGmsBbAhRAdw0PYNkG8x6wbHqfzYZS491Bdjry4tSWemNFtGPNkHiWVo3Hg==} + '@anthropic-ai/claude-agent-sdk-linux-x64@0.3.233': + resolution: {integrity: sha512-jpbhV+n9PnxLiyheQ/HjtHIg/E5/jVsk2Vdu132BSoL/3bsObSmMqKgsqoMutzwRZvtpqRs2RPVcjsC8G4A9Zw==} cpu: [x64] os: [linux] libc: [glibc] - '@anthropic-ai/claude-agent-sdk-win32-arm64@0.3.219': - resolution: {integrity: sha512-L/cAT27t3FGP4GlJ16WpXMtaeqDu9olpOlIaqqmjElQp5Lknd8alJ3NIY/SlRAtHDvm4WXug74wnfBOSlwppkg==} + '@anthropic-ai/claude-agent-sdk-win32-arm64@0.3.233': + resolution: {integrity: sha512-aO2MaNdmQofyPLKszE4s+Ope/sLJPeI/ZlGdCcjYp7qhji2hgZ4bRWWsOrx5eKjz0gFK5CFFltILkFcNcxCsVg==} cpu: [arm64] os: [win32] - '@anthropic-ai/claude-agent-sdk-win32-x64@0.3.219': - resolution: {integrity: sha512-Fl8Rb9K64jbpC/omIF9y828LIvwRmm1zhZgO8zjalTpjZR/0C31Kl+5FwAS9KkXXUXwmdHSY+82e9dyvBrbIRw==} + '@anthropic-ai/claude-agent-sdk-win32-x64@0.3.233': + resolution: {integrity: sha512-TcAYyWPXS5mREZGUksuCZsLIRQjbo/Vriur2PqIhAmgZ1oiqBZO27a90sX60EUczD7yV8wpwOVhVLhUxO0kAEg==} cpu: [x64] os: [win32] - '@anthropic-ai/claude-agent-sdk@0.3.219': - resolution: {integrity: sha512-dJjzKxoyCQSEEEo9a0g3wBQprRMCpsqjmsNOZaGOWw+W+JHZsolDBhoYfoZ9BA8EdwgaJmbQUvhkAZjROUsBnQ==} + '@anthropic-ai/claude-agent-sdk@0.3.233': + resolution: {integrity: sha512-Dy+YqhggwtbezDy3Ap2pb1sK3bOqnI+sLNnsVjB3AUWvR0QlGnjjrjORXY03Y50I+B1eFRNEcYPAZKRYlCkSLQ==} engines: {node: '>=18.0.0'} peerDependencies: '@anthropic-ai/sdk': '>=0.93.0' @@ -3187,10 +3187,12 @@ packages: '@xmldom/xmldom@0.8.13': resolution: {integrity: sha512-KRYzxepc14G/CEpEGc3Yn+JKaAeT63smlDr+vjB8jRfgTBBI9wRj/nkQEO+ucV8p8I9bfKLWp37uHgFrbntPvw==} engines: {node: '>=10.0.0'} + deprecated: this version has critical issues, please update to the latest version '@xmldom/xmldom@0.9.10': resolution: {integrity: sha512-A9gOqLdi6cV4ibazAjcQufGj0B1y/vDqYrcuP6d/6x8P27gRS8643Dj9o1dEKtB6O7fwxb2FgBmJS2mX7gpvdw==} engines: {node: '>=14.6'} + deprecated: this version has critical issues, please update to the latest version '@xterm/addon-clipboard@0.3.0-beta.219': resolution: {integrity: sha512-KKN1BFJJbHoKKd1YZaq5Fjn85hv6/nu7Sbsobd0rhK0Ei6UBCaX+Q7xO+CtxatcOT/zaRGcCFsRvwSv92Hmk0w==} @@ -7363,44 +7365,44 @@ snapshots: package-manager-detector: 1.7.0 tinyexec: 1.2.4 - '@anthropic-ai/claude-agent-sdk-darwin-arm64@0.3.219': + '@anthropic-ai/claude-agent-sdk-darwin-arm64@0.3.233': optional: true - '@anthropic-ai/claude-agent-sdk-darwin-x64@0.3.219': + '@anthropic-ai/claude-agent-sdk-darwin-x64@0.3.233': optional: true - '@anthropic-ai/claude-agent-sdk-linux-arm64-musl@0.3.219': + '@anthropic-ai/claude-agent-sdk-linux-arm64-musl@0.3.233': optional: true - '@anthropic-ai/claude-agent-sdk-linux-arm64@0.3.219': + '@anthropic-ai/claude-agent-sdk-linux-arm64@0.3.233': optional: true - '@anthropic-ai/claude-agent-sdk-linux-x64-musl@0.3.219': + '@anthropic-ai/claude-agent-sdk-linux-x64-musl@0.3.233': optional: true - '@anthropic-ai/claude-agent-sdk-linux-x64@0.3.219': + '@anthropic-ai/claude-agent-sdk-linux-x64@0.3.233': optional: true - '@anthropic-ai/claude-agent-sdk-win32-arm64@0.3.219': + '@anthropic-ai/claude-agent-sdk-win32-arm64@0.3.233': optional: true - '@anthropic-ai/claude-agent-sdk-win32-x64@0.3.219': + '@anthropic-ai/claude-agent-sdk-win32-x64@0.3.233': optional: true - '@anthropic-ai/claude-agent-sdk@0.3.219(@anthropic-ai/sdk@0.93.0(zod@4.4.3))(@modelcontextprotocol/sdk@1.29.0(supports-color@10.2.2)(zod@4.4.3))(zod@4.4.3)': + '@anthropic-ai/claude-agent-sdk@0.3.233(@anthropic-ai/sdk@0.93.0(zod@4.4.3))(@modelcontextprotocol/sdk@1.29.0(supports-color@10.2.2)(zod@4.4.3))(zod@4.4.3)': dependencies: '@anthropic-ai/sdk': 0.93.0(zod@4.4.3) '@modelcontextprotocol/sdk': 1.29.0(supports-color@10.2.2)(zod@4.4.3) zod: 4.4.3 optionalDependencies: - '@anthropic-ai/claude-agent-sdk-darwin-arm64': 0.3.219 - '@anthropic-ai/claude-agent-sdk-darwin-x64': 0.3.219 - '@anthropic-ai/claude-agent-sdk-linux-arm64': 0.3.219 - '@anthropic-ai/claude-agent-sdk-linux-arm64-musl': 0.3.219 - '@anthropic-ai/claude-agent-sdk-linux-x64': 0.3.219 - '@anthropic-ai/claude-agent-sdk-linux-x64-musl': 0.3.219 - '@anthropic-ai/claude-agent-sdk-win32-arm64': 0.3.219 - '@anthropic-ai/claude-agent-sdk-win32-x64': 0.3.219 + '@anthropic-ai/claude-agent-sdk-darwin-arm64': 0.3.233 + '@anthropic-ai/claude-agent-sdk-darwin-x64': 0.3.233 + '@anthropic-ai/claude-agent-sdk-linux-arm64': 0.3.233 + '@anthropic-ai/claude-agent-sdk-linux-arm64-musl': 0.3.233 + '@anthropic-ai/claude-agent-sdk-linux-x64': 0.3.233 + '@anthropic-ai/claude-agent-sdk-linux-x64-musl': 0.3.233 + '@anthropic-ai/claude-agent-sdk-win32-arm64': 0.3.233 + '@anthropic-ai/claude-agent-sdk-win32-x64': 0.3.233 '@anthropic-ai/sdk@0.93.0(zod@4.4.3)': dependencies: diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index ba4253ded..59405450f 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -68,6 +68,15 @@ minimumReleaseAgeExclude: # The OpenCode connector tracks the current SDK used by Desktop's compatibility path. - "@opencode-ai/sdk@1.18.10" # Claude steering relies on the current official SDK lifecycle controls. + - "@anthropic-ai/claude-agent-sdk@0.3.233" + - "@anthropic-ai/claude-agent-sdk-darwin-arm64@0.3.233" + - "@anthropic-ai/claude-agent-sdk-darwin-x64@0.3.233" + - "@anthropic-ai/claude-agent-sdk-linux-arm64@0.3.233" + - "@anthropic-ai/claude-agent-sdk-linux-arm64-musl@0.3.233" + - "@anthropic-ai/claude-agent-sdk-linux-x64@0.3.233" + - "@anthropic-ai/claude-agent-sdk-linux-x64-musl@0.3.233" + - "@anthropic-ai/claude-agent-sdk-win32-arm64@0.3.233" + - "@anthropic-ai/claude-agent-sdk-win32-x64@0.3.233" - "@anthropic-ai/claude-agent-sdk@0.3.219" - "@anthropic-ai/claude-agent-sdk-darwin-arm64@0.3.219" - "@anthropic-ai/claude-agent-sdk-darwin-x64@0.3.219" diff --git a/src/renderer/components/composer/MentionInput.tsx b/src/renderer/components/composer/MentionInput.tsx index 7ef81f827..8f227f9c1 100644 --- a/src/renderer/components/composer/MentionInput.tsx +++ b/src/renderer/components/composer/MentionInput.tsx @@ -214,7 +214,7 @@ function placeCaretAtEnd(editor: HTMLDivElement): Range | null { function skillChipDataset(segment: Extract) { return { skillName: segment.name, - skillPath: segment.path, + ...(segment.path ? { skillPath: segment.path } : {}), skillInvocation: segment.invocation, skillProvider: segment.provider, skillScope: segment.scope, diff --git a/src/renderer/components/composer/SlashCommandChip.ts b/src/renderer/components/composer/SlashCommandChip.ts index 86a7ee551..af285e850 100644 --- a/src/renderer/components/composer/SlashCommandChip.ts +++ b/src/renderer/components/composer/SlashCommandChip.ts @@ -21,16 +21,13 @@ export function createSlashCommandChipElement( const chip = document.createElement("span"); chip.contentEditable = "false"; chip.dataset.slashCommand = command.id; + // `skillPath` is optional: provider-native skills carry no SKILL.md path. const isSkill = Boolean( - command.skillName && - command.skillPath && - command.skillInvocation && - command.skillProvider && - command.skillScope, + command.skillName && command.skillInvocation && command.skillProvider && command.skillScope, ); if (isSkill) { chip.dataset.skillName = command.skillName; - chip.dataset.skillPath = command.skillPath; + if (command.skillPath) chip.dataset.skillPath = command.skillPath; chip.dataset.skillInvocation = command.skillInvocation; chip.dataset.skillProvider = command.skillProvider; chip.dataset.skillScope = command.skillScope; diff --git a/src/renderer/components/composer/serializeMentions.ts b/src/renderer/components/composer/serializeMentions.ts index e327767a5..81cae70ff 100644 --- a/src/renderer/components/composer/serializeMentions.ts +++ b/src/renderer/components/composer/serializeMentions.ts @@ -120,7 +120,6 @@ export function serializeToSegments(container: HTMLDivElement): PromptSegment[] if (el.dataset.slashCommand) { if ( el.dataset.skillName && - el.dataset.skillPath && el.dataset.skillInvocation && el.dataset.skillProvider && (el.dataset.skillScope === "global" || el.dataset.skillScope === "project") @@ -129,7 +128,8 @@ export function serializeToSegments(container: HTMLDivElement): PromptSegment[] segments.push({ kind: "skill", name: el.dataset.skillName, - path: el.dataset.skillPath, + // Absent for provider-native skills (no SKILL.md on disk). + ...(el.dataset.skillPath ? { path: el.dataset.skillPath } : {}), invocation: el.dataset.skillInvocation, provider: el.dataset.skillProvider, scope: el.dataset.skillScope, diff --git a/src/renderer/components/thread/ThreadSlashCommands.test.tsx b/src/renderer/components/thread/ThreadSlashCommands.test.tsx index b5baf94c6..3a7473ba1 100644 --- a/src/renderer/components/thread/ThreadSlashCommands.test.tsx +++ b/src/renderer/components/thread/ThreadSlashCommands.test.tsx @@ -684,6 +684,42 @@ describe("ThreadSlashCommands", () => { ]); }); + it("keeps the SKILL.md path when a provider reports a locally scanned skill", () => { + const local = { + id: "browser-control", + label: "browser-control — Drive the browser", + description: "Drive the browser", + section: "skills" as const, + skillName: "browser-control", + skillPath: "/plugins/browser-tools/skills/browser-control/SKILL.md", + skillInvocation: "Use the browser-control skill.", + skillProvider: "Browser Tools", + skillScope: "global" as const, + pluginId: "browser-tools", + pluginName: "Browser Tools", + }; + + const commands = resolveAvailableSlashCommands( + [ + { + // Provider-native entry for the same skill: no SKILL.md path. + id: "browser-control", + label: "browser-control — Drive the browser", + description: "Drive the browser", + section: "skills", + skillName: "browser-control", + skillInvocation: "Use the browser-control skill.", + skillProvider: "Claude", + skillScope: "global", + }, + ], + undefined, + { skillCommands: [local] }, + ); + + expect(commands).toEqual([local]); + }); + it("finds ACP skill commands by their short display name", () => { const command = { id: "skill:simplify", diff --git a/src/renderer/components/thread/threadSlashCommands.ts b/src/renderer/components/thread/threadSlashCommands.ts index 370f472d3..84c42c504 100644 --- a/src/renderer/components/thread/threadSlashCommands.ts +++ b/src/renderer/components/thread/threadSlashCommands.ts @@ -82,19 +82,39 @@ function dedupeBaseCommands( }); } +/** + * Collapses skill entries reported by several sources to one per name. Earlier + * sources win, so a provider's own entry beats the locally scanned one (an ACP + * agent must be handed back its own wire id). + * + * The one exception: when *both* entries carry complete skill metadata and only + * the later one knows the skill's SKILL.md path, the path-bearing entry wins. + * A provider reporting a skill it also loads from disk (Claude's SDK lists the + * skills Poracode projected into `.claude/skills`) would otherwise erase the + * plugin identity and the on-disk path the supervisor's plugin policy and + * portable-skill fallback depend on. Position follows first sighting. + */ function mergeSkillCommands( ...sources: (readonly AgentSlashCommand[] | undefined)[] ): AgentSlashCommand[] { - const seen = new Set(); - return sources.flatMap((commands) => - (commands ?? []).flatMap((command) => { - if (!isSkillCommand(command)) return []; + const byName = new Map(); + for (const commands of sources) { + for (const command of commands ?? []) { + if (!isSkillCommand(command)) continue; const name = (command.skillName ?? command.id).toLowerCase(); - if (seen.has(name)) return []; - seen.add(name); - return [command]; - }), - ); + const existing = byName.get(name); + if (existing) { + const upgradesPath = + command.skillPath !== undefined && + existing.skillPath === undefined && + skillSegmentFromSlashCommand(existing) !== undefined && + skillSegmentFromSlashCommand(command) !== undefined; + if (!upgradesPath) continue; + } + byName.set(name, command); + } + } + return [...byName.values()]; } function resolveSkillCommands( diff --git a/src/shared/contracts/thread.ts b/src/shared/contracts/thread.ts index 68190a37a..4f2f39e1b 100644 --- a/src/shared/contracts/thread.ts +++ b/src/shared/contracts/thread.ts @@ -114,7 +114,13 @@ export const promptSegmentSchema = z.discriminatedUnion("kind", [ z.object({ kind: z.literal("skill"), name: z.string().min(1), - path: z.string().min(1), + /** + * Absolute path to the skill's SKILL.md. Absent for provider-native skills + * the agent resolves by name (e.g. Claude's bundled skills reported through + * the SDK), which have no on-disk file the app can read. Consumers must + * treat a missing path as "nothing to read/inline/policy-match". + */ + path: z.string().min(1).optional(), invocation: z.string().min(1), provider: z.string().min(1), scope: z.enum(["global", "project"]), diff --git a/src/shared/promptContent.ts b/src/shared/promptContent.ts index 4c12354fb..63bdf12b4 100644 --- a/src/shared/promptContent.ts +++ b/src/shared/promptContent.ts @@ -154,9 +154,10 @@ export function inlinePromptSegmentText(segment: PromptSegment): string { export function skillSegmentFromSlashCommand( command: AgentSlashCommand | undefined, ): Extract | undefined { + // `skillPath` is intentionally not required: provider-native skills (resolved + // by the agent from its own catalog) have no SKILL.md the app can point at. if ( !command?.skillName || - !command.skillPath || !command.skillInvocation || !command.skillProvider || !command.skillScope @@ -166,7 +167,7 @@ export function skillSegmentFromSlashCommand( return { kind: "skill", name: command.skillName, - path: command.skillPath, + ...(command.skillPath ? { path: command.skillPath } : {}), invocation: command.skillInvocation, provider: command.skillProvider, scope: command.skillScope, diff --git a/src/supervisor/agents/claude/index.ts b/src/supervisor/agents/claude/index.ts index d591f785d..fe00fea8d 100644 --- a/src/supervisor/agents/claude/index.ts +++ b/src/supervisor/agents/claude/index.ts @@ -277,7 +277,11 @@ export function createClaudeAdapter(options: ClaudeAdapterOptions = {}): AgentAd linkProjectionFromVersion: "2.1.203", }, ], - invocation: "slash", + // Skills are model-invoked through the SDK's Skill tool, which streams + // normally. Typing `/name` instead makes the CLI run an opaque local + // command that emits no stream events until it finishes (blank working + // turn). Projection is unchanged so the Skill tool still discovers them. + invocation: "prompt", precedence: { scopeOrder: ["global", "project"], global: ["claude", "agents"], diff --git a/src/supervisor/agents/claude/probe.test.ts b/src/supervisor/agents/claude/probe.test.ts index 60dbf993e..cf315312a 100644 --- a/src/supervisor/agents/claude/probe.test.ts +++ b/src/supervisor/agents/claude/probe.test.ts @@ -6,9 +6,11 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import type { Query, SDKMessage, + SlashCommand, SpawnedProcess, SpawnOptions, } from "@anthropic-ai/claude-agent-sdk"; +import { skillSegmentFromSlashCommand } from "@/shared/promptContent"; const mockSdk = vi.hoisted(() => ({ query: vi.fn<(input: unknown) => Query>(), @@ -39,6 +41,7 @@ vi.mock("@/shared/processTree", () => mockProcessTree); import { claudeCapabilitiesFromCliVersion, + mapClaudeSlashCommands, probeClaudeCapabilities, win32PathToWslMount, } from "./probe"; @@ -117,6 +120,60 @@ afterEach(() => { } }); +describe("mapClaudeSlashCommands", () => { + const commands = [ + { name: "compact", description: "Compact the conversation", argumentHint: "" }, + { name: "code-review", description: "Review the current diff", argumentHint: "" }, + ] as unknown as SlashCommand[]; + + it("maps every command as a plain slash command when no skills are reported", () => { + expect(mapClaudeSlashCommands(commands)).toEqual([ + { + id: "compact", + label: "compact — Compact the conversation", + description: "Compact the conversation", + }, + { + id: "code-review", + label: "code-review — Review the current diff", + description: "Review the current diff", + argumentHint: "", + }, + ]); + }); + + it("splits commands that are also skills into prompt-invoked skill entries", () => { + const mapped = mapClaudeSlashCommands(commands, new Set(["code-review"])); + + expect(mapped[0]).not.toHaveProperty("section"); + expect(mapped[1]).toEqual({ + id: "code-review", + label: "code-review — Review the current diff", + description: "Review the current diff", + argumentHint: "", + section: "skills", + skillName: "code-review", + skillInvocation: "Use the code-review skill.", + skillProvider: "Claude", + skillScope: "global", + }); + // Provider-native skills have no SKILL.md on disk. + expect(mapped[1]).not.toHaveProperty("skillPath"); + }); + + it("binds a provider-native skill command to a skill segment without a path", () => { + const [, skillCommand] = mapClaudeSlashCommands(commands, new Set(["code-review"])); + + expect(skillSegmentFromSlashCommand(skillCommand)).toEqual({ + kind: "skill", + name: "code-review", + invocation: "Use the code-review skill.", + provider: "Claude", + scope: "global", + }); + }); +}); + describe("claudeCapabilitiesFromCliVersion", () => { it("hides Opus 5, Fable 5, Opus 4.7, Opus 4.8, and Sonnet 5 below 2.1.111", () => { const p = claudeCapabilitiesFromCliVersion("2.1.110"); diff --git a/src/supervisor/agents/claude/probe.ts b/src/supervisor/agents/claude/probe.ts index eed496eed..3e83bf7ce 100644 --- a/src/supervisor/agents/claude/probe.ts +++ b/src/supervisor/agents/claude/probe.ts @@ -27,15 +27,66 @@ export function claudeTerminalAuthMethod(env?: Record): AgentTer return env ? { ...CLAUDE_TERMINAL_AUTH_METHOD, env } : CLAUDE_TERMINAL_AUTH_METHOD; } +/** Provider label carried by Claude's own (SDK-reported) skill entries. */ +export const CLAUDE_NATIVE_SKILL_PROVIDER = "Claude"; + +/** + * Prompt-style invocation for a model-invoked skill. Per the Agent SDK docs a + * skill is invoked by the model through the `Skill` tool, which streams events + * normally; sending the bare `/name` slash text instead makes the CLI run an + * opaque local command that emits nothing until it finishes. + */ +export function claudeSkillInvocation(name: string): string { + return `Use the ${name} skill.`; +} + +/** + * `skillNames` are the entries the SDK reports under `skills` on the session's + * `system` init message. Bundled skills appear in *both* that list and the + * slash-command list, so a command whose name is a known skill is re-flavored + * as a skill entry (model-invoked, streams) instead of a slash command + * (opaque local command, no stream events). + */ export function mapClaudeSlashCommands( commands: readonly SlashCommand[], + skillNames?: ReadonlySet, ): NonNullable { - return commands.map((c) => ({ - id: c.name, - label: c.description?.trim() ? `${c.name} — ${c.description}` : c.name, - ...(c.description?.trim() ? { description: c.description } : {}), - ...(c.argumentHint ? { argumentHint: c.argumentHint } : {}), - })); + return commands.map((c) => { + const base = { + id: c.name, + label: c.description?.trim() ? `${c.name} — ${c.description}` : c.name, + ...(c.description?.trim() ? { description: c.description } : {}), + ...(c.argumentHint ? { argumentHint: c.argumentHint } : {}), + }; + if (!skillNames?.has(c.name)) return base; + return { + ...base, + section: "skills" as const, + skillName: c.name, + skillInvocation: claudeSkillInvocation(c.name), + skillProvider: CLAUDE_NATIVE_SKILL_PROVIDER, + // Provider-native skills carry no SKILL.md path; scope is only used for + // display/precedence, and the SDK reports one flat catalog. + skillScope: "global" as const, + }; + }); +} + +/** + * Skill names for the session, read through the SDK's skill-list control + * request. Used by the probes, which never consume the message stream and so + * cannot read `skills` off the `system` init message. Returns `undefined` when + * the CLI does not support the request. + */ +export async function readClaudeSkillNames(runtime: { + reloadSkills: () => Promise<{ skills: readonly { name: string }[] }>; +}): Promise | undefined> { + try { + const { skills } = await runtime.reloadSkills(); + return new Set(skills.map((skill) => skill.name)); + } catch { + return undefined; + } } function probeDir(): string { @@ -99,7 +150,7 @@ async function probeClaudeSdkPartialNative( }, }); const init = await q.initializationResult(); - const slashCommands = mapClaudeSlashCommands(init.commands); + const slashCommands = mapClaudeSlashCommands(init.commands, await readClaudeSkillNames(q)); const modelCapabilities = claudeCapabilitiesFromSdkModels(init.models); const fastAvailable = await resolveFastAvailability( q, diff --git a/src/supervisor/agents/claude/sdkProbeWorker.ts b/src/supervisor/agents/claude/sdkProbeWorker.ts index def05296d..6592d9c82 100644 --- a/src/supervisor/agents/claude/sdkProbeWorker.ts +++ b/src/supervisor/agents/claude/sdkProbeWorker.ts @@ -14,13 +14,40 @@ import { resolveFastAvailability } from "./fastModeProbe"; import { spawnClaudeProbeProcess } from "./sdkProbeProcess"; import { claudeCapabilitiesFromSdkModels } from "./models"; -function mapCommands(commands: SlashCommand[]) { - return commands.map((c) => ({ - id: c.name, - label: c.description?.trim() ? `${c.name} — ${c.description}` : c.name, - ...(c.description?.trim() ? { description: c.description } : {}), - ...(c.argumentHint ? { argumentHint: c.argumentHint } : {}), - })); +/** + * Mirrors `mapClaudeSlashCommands` / `readClaudeSkillNames` from `probe.ts`. + * Kept local so this worker stays a self-contained bundle for in-distro `node` + * (importing `probe.ts` would drag the supervisor's WSL/base helpers in). + */ +function mapCommands(commands: SlashCommand[], skillNames?: ReadonlySet) { + return commands.map((c) => { + const base = { + id: c.name, + label: c.description?.trim() ? `${c.name} — ${c.description}` : c.name, + ...(c.description?.trim() ? { description: c.description } : {}), + ...(c.argumentHint ? { argumentHint: c.argumentHint } : {}), + }; + if (!skillNames?.has(c.name)) return base; + return { + ...base, + section: "skills" as const, + skillName: c.name, + skillInvocation: `Use the ${c.name} skill.`, + skillProvider: "Claude", + skillScope: "global" as const, + }; + }); +} + +async function readSkillNames(runtime: { + reloadSkills: () => Promise<{ skills: readonly { name: string }[] }>; +}): Promise | undefined> { + try { + const { skills } = await runtime.reloadSkills(); + return new Set(skills.map((skill) => skill.name)); + } catch { + return undefined; + } } async function main() { @@ -54,7 +81,7 @@ async function main() { }); const init = await q.initializationResult(); - const slashCommands = mapCommands(init.commands); + const slashCommands = mapCommands(init.commands, await readSkillNames(q)); const modelCapabilities = claudeCapabilitiesFromSdkModels(init.models); const fastAvailable = cachePath ? await resolveFastAvailability(q, queue, init.account?.email, cachePath) diff --git a/src/supervisor/agents/claude/sdkPrompt.test.ts b/src/supervisor/agents/claude/sdkPrompt.test.ts new file mode 100644 index 000000000..97a9cdb01 --- /dev/null +++ b/src/supervisor/agents/claude/sdkPrompt.test.ts @@ -0,0 +1,56 @@ +import { describe, expect, it } from "vitest"; +import type { PromptSegment } from "@/shared/contracts"; +import { buildSdkUserMessage } from "./sdkPrompt"; + +function textOf(message: Awaited>): string { + const content = message.message.content; + if (typeof content === "string") return content; + return content + .flatMap((block) => (block.type === "text" ? [(block as { text: string }).text] : [])) + .join(""); +} + +describe("buildSdkUserMessage", () => { + it("serializes a skill segment as its invocation text, never as an @path", async () => { + const segments: PromptSegment[] = [ + { + kind: "skill", + name: "code-review", + path: "/repo/.claude/skills/code-review/SKILL.md", + invocation: "Use the code-review skill.", + provider: "Claude", + scope: "project", + }, + { kind: "text", content: " on the current diff" }, + ]; + + const text = textOf(await buildSdkUserMessage("", segments)); + + expect(text).toBe("Use the code-review skill. on the current diff"); + expect(text).not.toContain("@/repo"); + expect(text).not.toContain("SKILL.md"); + }); + + it("serializes a provider-native skill segment that carries no path", async () => { + const segments: PromptSegment[] = [ + { + kind: "skill", + name: "code-review", + invocation: "Use the code-review skill.", + provider: "Claude", + scope: "global", + }, + ]; + + expect(textOf(await buildSdkUserMessage("", segments))).toBe("Use the code-review skill."); + }); + + it("still emits file mentions as @path", async () => { + const segments: PromptSegment[] = [ + { kind: "text", content: "look at " }, + { kind: "file", path: "/repo/src/index.ts" }, + ]; + + expect(textOf(await buildSdkUserMessage("", segments))).toBe("look at @/repo/src/index.ts"); + }); +}); diff --git a/src/supervisor/agents/claude/sdkPrompt.ts b/src/supervisor/agents/claude/sdkPrompt.ts index 89453a451..b23c9424b 100644 --- a/src/supervisor/agents/claude/sdkPrompt.ts +++ b/src/supervisor/agents/claude/sdkPrompt.ts @@ -87,6 +87,12 @@ export async function buildSdkUserMessage( }); continue; } + if (segment.kind === "skill") { + // A skill is invoked by its invocation text (model-invoked via the SDK's + // Skill tool), never as an `@` file mention. + textParts.push(segment.invocation); + continue; + } if (segment.kind === "mcp") { // MCP mentions are a plain-text directive for the turn, not a file ref. textParts.push(`@${segment.name}`); diff --git a/src/supervisor/agents/claude/sdkSession.test.ts b/src/supervisor/agents/claude/sdkSession.test.ts index 3447eaf71..f80e246b2 100644 --- a/src/supervisor/agents/claude/sdkSession.test.ts +++ b/src/supervisor/agents/claude/sdkSession.test.ts @@ -978,6 +978,59 @@ describe("ClaudeSdkSession", () => { await session.dispose(); }); + it("re-flavors commands the init message also reports as skills", async () => { + const fake = createFakeQuery([ + { name: "compact", description: "Compact the conversation", argumentHint: "" }, + { name: "code-review", description: "Review the current diff", argumentHint: "" }, + ]); + mockSdk.query.mockReturnValue(fake.runtime); + const updates: StructuredSessionUpdate[] = []; + const session = await ClaudeSdkSession.create({ + threadId: "thread-claude-skills", + projectLocation, + config, + presentationMode: "gui", + }); + session.setListener({ + onRuntimeEvent: () => {}, + onUpdate: (update) => updates.push(update), + onError: () => {}, + onClose: () => {}, + }); + + await session.openThread(config); + await flushAsyncWork(); + + fake.emitMessage({ + type: "system", + subtype: "init", + session_id: "claude-session", + skills: ["code-review"], + } as unknown as SDKMessage); + await flushAsyncWork(); + + const latest = updates.filter((update) => update.slashCommands !== undefined).at(-1); + expect(latest?.slashCommands).toEqual([ + { + id: "compact", + label: "compact — Compact the conversation", + description: "Compact the conversation", + }, + { + id: "code-review", + label: "code-review — Review the current diff", + description: "Review the current diff", + section: "skills", + skillName: "code-review", + skillInvocation: "Use the code-review skill.", + skillProvider: "Claude", + skillScope: "global", + }, + ]); + + await session.dispose(); + }); + it("refreshes current SDK context usage after result messages", async () => { const fake = createFakeQuery(); mockSdk.query.mockReturnValue(fake.runtime); diff --git a/src/supervisor/agents/claude/sdkSession.ts b/src/supervisor/agents/claude/sdkSession.ts index 1f453d0a0..37253a7e4 100644 --- a/src/supervisor/agents/claude/sdkSession.ts +++ b/src/supervisor/agents/claude/sdkSession.ts @@ -8,6 +8,7 @@ import type { PermissionUpdate, Query, SDKMessage, + SlashCommand, SpawnOptions, SpawnedProcess, } from "@anthropic-ai/claude-agent-sdk"; @@ -115,6 +116,10 @@ export class ClaudeSdkSession implements StructuredSessionHandle { private currentStatus: ThreadStatus = "idle"; private currentAttention: ThreadAttention = "none"; private currentSlashCommands: AgentSlashCommand[] | undefined; + /** Raw SDK command list, kept so a later skill-name update can re-flavor it. */ + private lastSdkCommands: readonly SlashCommand[] | undefined; + /** Skill names last reported by the CLI (`skills` on the `system` init message). */ + private lastSkillNames: ReadonlySet | undefined; private pendingRequests = new Map(); private completedTurns: CompletedClaudeTurn[] = []; private currentTurnAssistantUuid: string | undefined; @@ -201,12 +206,39 @@ export class ClaudeSdkSession implements StructuredSessionHandle { }); } + /** + * Re-maps the last raw SDK command list against the last-seen skill names. + * Commands and skill names arrive on independent paths (control response vs. + * the `system` init stream message), so either side re-applies on arrival and + * `updateSlashCommands` swallows the no-op. + */ + private applySdkSlashCommands(commands?: readonly SlashCommand[]): void { + if (commands) this.lastSdkCommands = commands; + const raw = this.lastSdkCommands; + if (!raw || raw.length === 0) return; + this.updateSlashCommands(mapClaudeSlashCommands(raw, this.lastSkillNames)); + } + + /** Skill names from the CLI's `system` init message; ignored on older CLIs. */ + private captureSkillNames(names: unknown): void { + if (!Array.isArray(names)) return; + const skillNames = new Set(names.filter((name): name is string => typeof name === "string")); + if ( + this.lastSkillNames && + this.lastSkillNames.size === skillNames.size && + [...skillNames].every((name) => this.lastSkillNames?.has(name)) + ) { + return; + } + this.lastSkillNames = skillNames; + this.applySdkSlashCommands(); + } + private async refreshSlashCommands(runtime: Query): Promise { try { const init = await runtime.initializationResult(); - const commands = mapClaudeSlashCommands(init.commands); - if (commands.length > 0) { - this.updateSlashCommands(commands); + if (init.commands.length > 0) { + this.applySdkSlashCommands(init.commands); return; } } catch { @@ -215,9 +247,10 @@ export class ClaudeSdkSession implements StructuredSessionHandle { try { const supported = await runtime.supportedCommands(); - const commands = mapClaudeSlashCommands(supported); - if (commands.length > 0) { - this.updateSlashCommands(commands); + if (supported.length > 0) { + // Reuses the last-seen skill set, so the fallback list is split the + // same way as the init list. + this.applySdkSlashCommands(supported); } } catch { // Install-time/default capabilities still provide the static fallback. @@ -874,6 +907,12 @@ export class ClaudeSdkSession implements StructuredSessionHandle { this.beginResumedTurnIfNeeded(message); + if (message.type === "system" && message.subtype === "init") { + // Bundled skills are reported both here and in the slash-command list; + // this set is what splits them out as model-invoked (streaming) skills. + this.captureSkillNames(message.skills); + } + if (message.type === "system" && message.subtype === "session_state_changed") { const mapped = mapSessionState(message.state); // While a turn completion is deferred behind live background tasks, a diff --git a/src/supervisor/agents/codex/acpTurn.ts b/src/supervisor/agents/codex/acpTurn.ts index c531105b7..86d932521 100644 --- a/src/supervisor/agents/codex/acpTurn.ts +++ b/src/supervisor/agents/codex/acpTurn.ts @@ -40,7 +40,10 @@ export function buildCodexTurnInput( inlineInstructions?: string, ): TurnStartParams["input"] { const input: TurnStartParams["input"] = []; - const hasSkillSegment = segments?.some((segment) => segment.kind === "skill") === true; + // Codex's `skill` input requires an on-disk path. A pathless (provider-native) + // skill segment therefore rides along as its plain invocation text instead. + const hasSkillSegment = + segments?.some((segment) => segment.kind === "skill" && segment.path !== undefined) === true; for (const seg of segments ?? []) { if (seg.kind === "attachment") { @@ -59,7 +62,7 @@ export function buildCodexTurnInput( path: seg.path, name: fileName(seg.path), }); - } else if (seg.kind === "skill") { + } else if (seg.kind === "skill" && seg.path !== undefined) { input.push({ type: "skill", name: seg.name, path: seg.path }); } } @@ -71,7 +74,10 @@ export function buildCodexTurnInput( const text = hasSkillSegment ? (segments ?? []) .flatMap((segment) => - segment.kind === "text" || segment.kind === "diff_comment" || segment.kind === "mcp" + segment.kind === "text" || + segment.kind === "diff_comment" || + segment.kind === "mcp" || + (segment.kind === "skill" && segment.path === undefined) ? [inlinePromptSegmentText(segment)] : [], ) diff --git a/src/supervisor/agents/opencode/promptParts.ts b/src/supervisor/agents/opencode/promptParts.ts index ba4af022e..374c2f77e 100644 --- a/src/supervisor/agents/opencode/promptParts.ts +++ b/src/supervisor/agents/opencode/promptParts.ts @@ -210,7 +210,15 @@ export function buildOpenCodePromptParts( parts.push({ type: "text", text: `@${segment.name}` }); continue; } - const absolute = resolveAbsolutePath(location, segment.path); + // A provider-native skill has no SKILL.md to attach — send its + // invocation text so the agent resolves it from its own catalog. + if (segment.kind === "skill" && segment.path === undefined) { + parts.push({ type: "text", text: segment.invocation }); + continue; + } + const segmentPath = segment.path; + if (segmentPath === undefined) continue; + const absolute = resolveAbsolutePath(location, segmentPath); const url = fileUrlForPath(location, absolute); const mime = mimeForSegment(segment, absolute); if (!shouldSendFilePart(mime)) { diff --git a/src/supervisor/skills/SkillsService.ts b/src/supervisor/skills/SkillsService.ts index ce34af4e0..609de89e2 100644 --- a/src/supervisor/skills/SkillsService.ts +++ b/src/supervisor/skills/SkillsService.ts @@ -1184,19 +1184,23 @@ export class SkillsService { if (pending.length === 0) return undefined; const sources = []; for (const segment of pending) { + // `selectSkillSegmentsForInjection` already dropped pathless + // (provider-native) segments; this only narrows the optional field. + const segmentPath = segment.path; + if (!segmentPath) continue; // Segment paths are display paths (Linux form inside WSL environments); // bundled skills keep host paths even there, so only rewrite // posix-absolute paths through the distro's UNC mapping. const fsPath = - environment.wsl && environment.distro && segment.path.startsWith("/") - ? this.wslFsPath(environment.distro, segment.path) - : segment.path; + environment.wsl && environment.distro && segmentPath.startsWith("/") + ? this.wslFsPath(environment.distro, segmentPath) + : segmentPath; try { const buffer = await readFile(fsPath); if (buffer.length > MAX_SKILL_FILE_BYTES) continue; sources.push({ name: segment.name, - directory: posix.dirname(segment.path.replace(/\\/gu, "/")), + directory: posix.dirname(segmentPath.replace(/\\/gu, "/")), content: buffer.toString("utf8"), }); } catch { @@ -1248,20 +1252,24 @@ export class SkillsService { const rewritten = segments.map((segment) => { if (segment.kind !== "skill") return segment; if (this.nativePluginReplacement(segment, input.nativePlugins)) return segment; - if (isPathUnderAny(segment.path, nativeRootPaths)) return segment; + // No SKILL.md path — the agent resolves this skill from its own catalog, + // so the invocation text is already the right thing to type. + const segmentPath = segment.path; + if (!segmentPath) return segment; + if (isPathUnderAny(segmentPath, nativeRootPaths)) return segment; const managedDisplay = managedDisplayFor(segment.scope); // Managed skills this adapter projects into its own folders resolve // natively in the CLI (e.g. `.agents` skills copied to `.claude/skills`). if ( managedDisplay && - isPathUnderAny(segment.path, [managedDisplay]) && + isPathUnderAny(segmentPath, [managedDisplay]) && projectsScope(segment.scope) ) { return segment; } - const normalized = segment.path.replace(/\\/gu, "/"); + const normalized = segmentPath.replace(/\\/gu, "/"); const hintPath = - bundledRoot && bundledHintRoot && isPathUnderAny(segment.path, [bundledRoot.displayPath]) + bundledRoot && bundledHintRoot && isPathUnderAny(segmentPath, [bundledRoot.displayPath]) ? posix.join( bundledHintRoot, posix.relative(bundledRoot.displayPath.replace(/\\/gu, "/"), normalized), diff --git a/src/supervisor/skills/pluginSkillPolicy.ts b/src/supervisor/skills/pluginSkillPolicy.ts index 6c77aec23..2a8062b6f 100644 --- a/src/supervisor/skills/pluginSkillPolicy.ts +++ b/src/supervisor/skills/pluginSkillPolicy.ts @@ -203,7 +203,9 @@ export class PluginSkillPolicy { Array<{ segment: PromptSegment; linuxPath: string }> >(); for (const segment of segments) { - if (segment.kind !== "skill") continue; + // A provider-native skill has no path, so it can never sit inside a + // plugin package boundary — leave it alone. + if (segment.kind !== "skill" || !segment.path) continue; const hostMatch = this.matchHostPath(roots, segment.path); if (hostMatch) { matched.set(segment, hostMatch); diff --git a/src/supervisor/skills/skillPromptInjection.ts b/src/supervisor/skills/skillPromptInjection.ts index c1f835d84..b2d01eae7 100644 --- a/src/supervisor/skills/skillPromptInjection.ts +++ b/src/supervisor/skills/skillPromptInjection.ts @@ -53,7 +53,10 @@ export function selectSkillSegmentsForInjection( ): SkillPromptSegment[] { const seen = new Set(); return segments.filter((segment): segment is SkillPromptSegment => { - if (segment.kind !== "skill" || isPathUnderAny(segment.path, nativeRootPaths)) return false; + // Pathless segments are provider-native skills the agent resolves by name: + // there is no SKILL.md to inline, so they are never selected. + if (segment.kind !== "skill" || !segment.path) return false; + if (isPathUnderAny(segment.path, nativeRootPaths)) return false; const key = normalizeForPrefix(segment.path); if (seen.has(key)) return false; seen.add(key);