Phase 7: skip missing skills with ui.notify warning on expand.

buildReinjectBlocks checks registration and file presence before expandSkill; warns via ctx.ui when hasUI, no-op in RPC/print mode.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-06-17 11:59:12 +07:00
parent dc73ea9747
commit 9a197aee10
+41 -16
View File
@@ -5,6 +5,7 @@ import type {
SessionCompactEvent, SessionCompactEvent,
Skill, Skill,
} from "@earendil-works/pi-coding-agent"; } from "@earendil-works/pi-coding-agent";
import { existsSync } from "node:fs";
import { expandSkill } from "./expand.js"; import { expandSkill } from "./expand.js";
import { import {
filterSkillsNeedingReinject, filterSkillsNeedingReinject,
@@ -16,6 +17,13 @@ import type { ExtensionState } from "./state.js";
export const DEFERRED_REINJECT_CUSTOM_TYPE = "skill-reinject:inject"; export const DEFERRED_REINJECT_CUSTOM_TYPE = "skill-reinject:inject";
function notifySkippedSkill(ctx: ExtensionContext | undefined, skillName: string, reason: string): void {
if (!ctx?.hasUI) {
return;
}
ctx.ui.notify(`skill-reinject: skipped "${skillName}" — ${reason}`, "warning");
}
/** Names still registered in resourceLoader (SPEC §5.2). */ /** Names still registered in resourceLoader (SPEC §5.2). */
export function registeredSkillNames(skills: readonly Pick<Skill, "name">[]): ReadonlySet<string> { export function registeredSkillNames(skills: readonly Pick<Skill, "name">[]): ReadonlySet<string> {
return new Set(skills.map((skill) => skill.name)); return new Set(skills.map((skill) => skill.name));
@@ -66,25 +74,38 @@ export function buildReinjectBlocks(
state: ExtensionState, state: ExtensionState,
settings: SkillReinjectSettings, settings: SkillReinjectSettings,
registeredSkills: readonly Pick<Skill, "name" | "filePath" | "baseDir">[], registeredSkills: readonly Pick<Skill, "name" | "filePath" | "baseDir">[],
ctx?: ExtensionContext,
): string[] { ): string[] {
const registeredByName = new Map(registeredSkills.map((skill) => [skill.name, skill])); const registeredByName = new Map(registeredSkills.map((skill) => [skill.name, skill]));
const blocks: string[] = []; const blocks: string[] = [];
for (const name of skillNames) { for (const name of skillNames) {
const tracked = state.skills.find((skill) => skill.name === name); const tracked = state.skills.find((skill) => skill.name === name);
const registered = registeredByName.get(name); if (!tracked) {
if (!tracked || !registered) {
continue; continue;
} }
blocks.push( const registered = registeredByName.get(name);
expandSkill( if (!registered) {
{ notifySkippedSkill(ctx, name, "no longer registered");
name: tracked.name, continue;
filePath: registered.filePath, }
baseDir: registered.baseDir, if (!existsSync(registered.filePath)) {
}, notifySkippedSkill(ctx, name, "SKILL.md not found on disk");
settings.suffix, continue;
), }
); try {
blocks.push(
expandSkill(
{
name: tracked.name,
filePath: registered.filePath,
baseDir: registered.baseDir,
},
settings.suffix,
),
);
} catch {
notifySkippedSkill(ctx, name, "SKILL.md not readable");
}
} }
return blocks; return blocks;
} }
@@ -95,8 +116,9 @@ export function buildDeferredReinjectContent(
state: ExtensionState, state: ExtensionState,
settings: SkillReinjectSettings, settings: SkillReinjectSettings,
registeredSkills: readonly Pick<Skill, "name" | "filePath" | "baseDir">[], registeredSkills: readonly Pick<Skill, "name" | "filePath" | "baseDir">[],
ctx?: ExtensionContext,
): string { ): string {
return buildReinjectBlocks(pendingNames, state, settings, registeredSkills).join("\n\n"); return buildReinjectBlocks(pendingNames, state, settings, registeredSkills, ctx).join("\n\n");
} }
/** Immediate path when agent is idle: first block triggers turn, rest queue as followUp (SPEC §6.5.2). */ /** Immediate path when agent is idle: first block triggers turn, rest queue as followUp (SPEC §6.5.2). */
@@ -106,8 +128,9 @@ export function sendImmediateReinjectIdle(
state: ExtensionState, state: ExtensionState,
settings: SkillReinjectSettings, settings: SkillReinjectSettings,
registeredSkills: readonly Pick<Skill, "name" | "filePath" | "baseDir">[], registeredSkills: readonly Pick<Skill, "name" | "filePath" | "baseDir">[],
ctx?: ExtensionContext,
): void { ): void {
const blocks = buildReinjectBlocks(skillNames, state, settings, registeredSkills); const blocks = buildReinjectBlocks(skillNames, state, settings, registeredSkills, ctx);
blocks.forEach((block, index) => { blocks.forEach((block, index) => {
pi.sendUserMessage(block, index === 0 ? undefined : { deliverAs: "followUp" }); pi.sendUserMessage(block, index === 0 ? undefined : { deliverAs: "followUp" });
}); });
@@ -120,8 +143,9 @@ export function sendImmediateReinjectAllFollowUp(
state: ExtensionState, state: ExtensionState,
settings: SkillReinjectSettings, settings: SkillReinjectSettings,
registeredSkills: readonly Pick<Skill, "name" | "filePath" | "baseDir">[], registeredSkills: readonly Pick<Skill, "name" | "filePath" | "baseDir">[],
ctx?: ExtensionContext,
): void { ): void {
for (const block of buildReinjectBlocks(skillNames, state, settings, registeredSkills)) { for (const block of buildReinjectBlocks(skillNames, state, settings, registeredSkills, ctx)) {
pi.sendUserMessage(block, { deliverAs: "followUp" }); pi.sendUserMessage(block, { deliverAs: "followUp" });
} }
} }
@@ -134,12 +158,13 @@ export function tryConsumeDeferredReinject(
state: ExtensionState, state: ExtensionState,
settings: SkillReinjectSettings, settings: SkillReinjectSettings,
registeredSkills: readonly Pick<Skill, "name" | "filePath" | "baseDir">[], registeredSkills: readonly Pick<Skill, "name" | "filePath" | "baseDir">[],
ctx?: ExtensionContext,
): BeforeAgentStartEventResult | undefined { ): BeforeAgentStartEventResult | undefined {
if (state.pendingReinject.length === 0) { if (state.pendingReinject.length === 0) {
return undefined; return undefined;
} }
const pendingNames = [...state.pendingReinject]; const pendingNames = [...state.pendingReinject];
const content = buildDeferredReinjectContent(pendingNames, state, settings, registeredSkills); const content = buildDeferredReinjectContent(pendingNames, state, settings, registeredSkills, ctx);
if (!content) { if (!content) {
state.pendingReinject = []; state.pendingReinject = [];
return undefined; return undefined;