feat: mac node exec policy + remote skills hot reload
This commit is contained in:
@@ -12,6 +12,7 @@ import {
|
||||
resolveSkillConfig,
|
||||
resolveSkillsInstallPreferences,
|
||||
type SkillEntry,
|
||||
type SkillEligibilityContext,
|
||||
type SkillInstallSpec,
|
||||
type SkillsInstallPreferences,
|
||||
} from "./skills.js";
|
||||
@@ -135,6 +136,7 @@ function buildSkillStatus(
|
||||
entry: SkillEntry,
|
||||
config?: ClawdbotConfig,
|
||||
prefs?: SkillsInstallPreferences,
|
||||
eligibility?: SkillEligibilityContext,
|
||||
): SkillStatusEntry {
|
||||
const skillKey = resolveSkillKey(entry);
|
||||
const skillConfig = resolveSkillConfig(config, skillKey);
|
||||
@@ -156,13 +158,25 @@ function buildSkillStatus(
|
||||
const requiredConfig = entry.clawdbot?.requires?.config ?? [];
|
||||
const requiredOs = entry.clawdbot?.os ?? [];
|
||||
|
||||
const missingBins = requiredBins.filter((bin) => !hasBinary(bin));
|
||||
const missingBins = requiredBins.filter((bin) => {
|
||||
if (hasBinary(bin)) return false;
|
||||
if (eligibility?.remote?.hasBin?.(bin)) return false;
|
||||
return true;
|
||||
});
|
||||
const missingAnyBins =
|
||||
requiredAnyBins.length > 0 && !requiredAnyBins.some((bin) => hasBinary(bin))
|
||||
requiredAnyBins.length > 0 &&
|
||||
!(
|
||||
requiredAnyBins.some((bin) => hasBinary(bin)) ||
|
||||
eligibility?.remote?.hasAnyBin?.(requiredAnyBins)
|
||||
)
|
||||
? requiredAnyBins
|
||||
: [];
|
||||
const missingOs =
|
||||
requiredOs.length > 0 && !requiredOs.includes(process.platform) ? requiredOs : [];
|
||||
requiredOs.length > 0 &&
|
||||
!requiredOs.includes(process.platform) &&
|
||||
!eligibility?.remote?.platforms?.some((platform) => requiredOs.includes(platform))
|
||||
? requiredOs
|
||||
: [];
|
||||
|
||||
const missingEnv: string[] = [];
|
||||
for (const envName of requiredEnv) {
|
||||
@@ -233,6 +247,7 @@ export function buildWorkspaceSkillStatus(
|
||||
config?: ClawdbotConfig;
|
||||
managedSkillsDir?: string;
|
||||
entries?: SkillEntry[];
|
||||
eligibility?: SkillEligibilityContext;
|
||||
},
|
||||
): SkillStatusReport {
|
||||
const managedSkillsDir = opts?.managedSkillsDir ?? path.join(CONFIG_DIR, "skills");
|
||||
@@ -241,6 +256,8 @@ export function buildWorkspaceSkillStatus(
|
||||
return {
|
||||
workspaceDir,
|
||||
managedSkillsDir,
|
||||
skills: skillEntries.map((entry) => buildSkillStatus(entry, opts?.config, prefs)),
|
||||
skills: skillEntries.map((entry) =>
|
||||
buildSkillStatus(entry, opts?.config, prefs, opts?.eligibility),
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -15,6 +15,7 @@ export {
|
||||
} from "./skills/env-overrides.js";
|
||||
export type {
|
||||
ClawdbotSkillMetadata,
|
||||
SkillEligibilityContext,
|
||||
SkillEntry,
|
||||
SkillInstallSpec,
|
||||
SkillSnapshot,
|
||||
|
||||
@@ -2,7 +2,7 @@ import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import type { ClawdbotConfig, SkillConfig } from "../../config/config.js";
|
||||
import { resolveSkillKey } from "./frontmatter.js";
|
||||
import type { SkillEntry } from "./types.js";
|
||||
import type { SkillEligibilityContext, SkillEntry } from "./types.js";
|
||||
|
||||
const DEFAULT_CONFIG_VALUES: Record<string, boolean> = {
|
||||
"browser.enabled": true,
|
||||
@@ -89,16 +89,22 @@ export function hasBinary(bin: string): boolean {
|
||||
export function shouldIncludeSkill(params: {
|
||||
entry: SkillEntry;
|
||||
config?: ClawdbotConfig;
|
||||
eligibility?: SkillEligibilityContext;
|
||||
}): boolean {
|
||||
const { entry, config } = params;
|
||||
const { entry, config, eligibility } = params;
|
||||
const skillKey = resolveSkillKey(entry.skill, entry);
|
||||
const skillConfig = resolveSkillConfig(config, skillKey);
|
||||
const allowBundled = normalizeAllowlist(config?.skills?.allowBundled);
|
||||
const osList = entry.clawdbot?.os ?? [];
|
||||
const remotePlatforms = eligibility?.remote?.platforms ?? [];
|
||||
|
||||
if (skillConfig?.enabled === false) return false;
|
||||
if (!isBundledSkillAllowed(entry, allowBundled)) return false;
|
||||
if (osList.length > 0 && !osList.includes(resolveRuntimePlatform())) {
|
||||
if (
|
||||
osList.length > 0 &&
|
||||
!osList.includes(resolveRuntimePlatform()) &&
|
||||
!remotePlatforms.some((platform) => osList.includes(platform))
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
if (entry.clawdbot?.always === true) {
|
||||
@@ -108,12 +114,16 @@ export function shouldIncludeSkill(params: {
|
||||
const requiredBins = entry.clawdbot?.requires?.bins ?? [];
|
||||
if (requiredBins.length > 0) {
|
||||
for (const bin of requiredBins) {
|
||||
if (!hasBinary(bin)) return false;
|
||||
if (hasBinary(bin)) continue;
|
||||
if (eligibility?.remote?.hasBin?.(bin)) continue;
|
||||
return false;
|
||||
}
|
||||
}
|
||||
const requiredAnyBins = entry.clawdbot?.requires?.anyBins ?? [];
|
||||
if (requiredAnyBins.length > 0) {
|
||||
const anyFound = requiredAnyBins.some((bin) => hasBinary(bin));
|
||||
const anyFound =
|
||||
requiredAnyBins.some((bin) => hasBinary(bin)) ||
|
||||
eligibility?.remote?.hasAnyBin?.(requiredAnyBins);
|
||||
if (!anyFound) return false;
|
||||
}
|
||||
|
||||
|
||||
158
src/agents/skills/refresh.ts
Normal file
158
src/agents/skills/refresh.ts
Normal file
@@ -0,0 +1,158 @@
|
||||
import path from "node:path";
|
||||
|
||||
import chokidar, { type FSWatcher } from "chokidar";
|
||||
|
||||
import type { ClawdbotConfig } from "../../config/config.js";
|
||||
import { createSubsystemLogger } from "../../logging.js";
|
||||
import { CONFIG_DIR, resolveUserPath } from "../../utils.js";
|
||||
|
||||
type SkillsChangeEvent = {
|
||||
workspaceDir?: string;
|
||||
reason: "watch" | "manual" | "remote-node";
|
||||
changedPath?: string;
|
||||
};
|
||||
|
||||
type SkillsWatchState = {
|
||||
watcher: FSWatcher;
|
||||
pathsKey: string;
|
||||
debounceMs: number;
|
||||
timer?: ReturnType<typeof setTimeout>;
|
||||
pendingPath?: string;
|
||||
};
|
||||
|
||||
const log = createSubsystemLogger("gateway/skills");
|
||||
const listeners = new Set<(event: SkillsChangeEvent) => void>();
|
||||
const workspaceVersions = new Map<string, number>();
|
||||
const watchers = new Map<string, SkillsWatchState>();
|
||||
let globalVersion = 0;
|
||||
|
||||
function bumpVersion(current: number): number {
|
||||
const now = Date.now();
|
||||
return now <= current ? current + 1 : now;
|
||||
}
|
||||
|
||||
function emit(event: SkillsChangeEvent) {
|
||||
for (const listener of listeners) {
|
||||
try {
|
||||
listener(event);
|
||||
} catch (err) {
|
||||
log.warn(`skills change listener failed: ${String(err)}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function resolveWatchPaths(workspaceDir: string, config?: ClawdbotConfig): string[] {
|
||||
const paths: string[] = [];
|
||||
if (workspaceDir.trim()) {
|
||||
paths.push(path.join(workspaceDir, "skills"));
|
||||
}
|
||||
paths.push(path.join(CONFIG_DIR, "skills"));
|
||||
const extraDirsRaw = config?.skills?.load?.extraDirs ?? [];
|
||||
const extraDirs = extraDirsRaw
|
||||
.map((d) => (typeof d === "string" ? d.trim() : ""))
|
||||
.filter(Boolean)
|
||||
.map((dir) => resolveUserPath(dir));
|
||||
paths.push(...extraDirs);
|
||||
return paths;
|
||||
}
|
||||
|
||||
export function registerSkillsChangeListener(listener: (event: SkillsChangeEvent) => void) {
|
||||
listeners.add(listener);
|
||||
return () => {
|
||||
listeners.delete(listener);
|
||||
};
|
||||
}
|
||||
|
||||
export function bumpSkillsSnapshotVersion(params?: {
|
||||
workspaceDir?: string;
|
||||
reason?: SkillsChangeEvent["reason"];
|
||||
changedPath?: string;
|
||||
}): number {
|
||||
const reason = params?.reason ?? "manual";
|
||||
const changedPath = params?.changedPath;
|
||||
if (params?.workspaceDir) {
|
||||
const current = workspaceVersions.get(params.workspaceDir) ?? 0;
|
||||
const next = bumpVersion(current);
|
||||
workspaceVersions.set(params.workspaceDir, next);
|
||||
emit({ workspaceDir: params.workspaceDir, reason, changedPath });
|
||||
return next;
|
||||
}
|
||||
globalVersion = bumpVersion(globalVersion);
|
||||
emit({ reason, changedPath });
|
||||
return globalVersion;
|
||||
}
|
||||
|
||||
export function getSkillsSnapshotVersion(workspaceDir?: string): number {
|
||||
if (!workspaceDir) return globalVersion;
|
||||
const local = workspaceVersions.get(workspaceDir) ?? 0;
|
||||
return Math.max(globalVersion, local);
|
||||
}
|
||||
|
||||
export function ensureSkillsWatcher(params: {
|
||||
workspaceDir: string;
|
||||
config?: ClawdbotConfig;
|
||||
}) {
|
||||
const workspaceDir = params.workspaceDir.trim();
|
||||
if (!workspaceDir) return;
|
||||
const watchEnabled = params.config?.skills?.load?.watch !== false;
|
||||
const debounceMsRaw = params.config?.skills?.load?.watchDebounceMs;
|
||||
const debounceMs =
|
||||
typeof debounceMsRaw === "number" && Number.isFinite(debounceMsRaw)
|
||||
? Math.max(0, debounceMsRaw)
|
||||
: 250;
|
||||
|
||||
const existing = watchers.get(workspaceDir);
|
||||
if (!watchEnabled) {
|
||||
if (existing) {
|
||||
watchers.delete(workspaceDir);
|
||||
existing.timer && clearTimeout(existing.timer);
|
||||
void existing.watcher.close().catch(() => {});
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const watchPaths = resolveWatchPaths(workspaceDir, params.config);
|
||||
const pathsKey = watchPaths.join("|");
|
||||
if (existing && existing.pathsKey === pathsKey && existing.debounceMs === debounceMs) {
|
||||
return;
|
||||
}
|
||||
if (existing) {
|
||||
watchers.delete(workspaceDir);
|
||||
existing.timer && clearTimeout(existing.timer);
|
||||
void existing.watcher.close().catch(() => {});
|
||||
}
|
||||
|
||||
const watcher = chokidar.watch(watchPaths, {
|
||||
ignoreInitial: true,
|
||||
awaitWriteFinish: {
|
||||
stabilityThreshold: debounceMs,
|
||||
pollInterval: 100,
|
||||
},
|
||||
});
|
||||
|
||||
const state: SkillsWatchState = { watcher, pathsKey, debounceMs };
|
||||
|
||||
const schedule = (changedPath?: string) => {
|
||||
state.pendingPath = changedPath ?? state.pendingPath;
|
||||
if (state.timer) clearTimeout(state.timer);
|
||||
state.timer = setTimeout(() => {
|
||||
const pendingPath = state.pendingPath;
|
||||
state.pendingPath = undefined;
|
||||
state.timer = undefined;
|
||||
bumpSkillsSnapshotVersion({
|
||||
workspaceDir,
|
||||
reason: "watch",
|
||||
changedPath: pendingPath,
|
||||
});
|
||||
}, debounceMs);
|
||||
};
|
||||
|
||||
watcher.on("add", (p) => schedule(p));
|
||||
watcher.on("change", (p) => schedule(p));
|
||||
watcher.on("unlink", (p) => schedule(p));
|
||||
watcher.on("error", (err) => {
|
||||
log.warn(`skills watcher error (${workspaceDir}): ${String(err)}`);
|
||||
});
|
||||
|
||||
watchers.set(workspaceDir, state);
|
||||
}
|
||||
@@ -39,8 +39,18 @@ export type SkillEntry = {
|
||||
clawdbot?: ClawdbotSkillMetadata;
|
||||
};
|
||||
|
||||
export type SkillEligibilityContext = {
|
||||
remote?: {
|
||||
platforms: string[];
|
||||
hasBin: (bin: string) => boolean;
|
||||
hasAnyBin: (bins: string[]) => boolean;
|
||||
note?: string;
|
||||
};
|
||||
};
|
||||
|
||||
export type SkillSnapshot = {
|
||||
prompt: string;
|
||||
skills: Array<{ name: string; primaryEnv?: string }>;
|
||||
resolvedSkills?: Skill[];
|
||||
version?: number;
|
||||
};
|
||||
|
||||
@@ -13,7 +13,12 @@ import { resolveBundledSkillsDir } from "./bundled-dir.js";
|
||||
import { shouldIncludeSkill } from "./config.js";
|
||||
import { parseFrontmatter, resolveClawdbotMetadata } from "./frontmatter.js";
|
||||
import { serializeByKey } from "./serialize.js";
|
||||
import type { ParsedSkillFrontmatter, SkillEntry, SkillSnapshot } from "./types.js";
|
||||
import type {
|
||||
ParsedSkillFrontmatter,
|
||||
SkillEligibilityContext,
|
||||
SkillEntry,
|
||||
SkillSnapshot,
|
||||
} from "./types.js";
|
||||
|
||||
const fsp = fs.promises;
|
||||
|
||||
@@ -21,8 +26,9 @@ function filterSkillEntries(
|
||||
entries: SkillEntry[],
|
||||
config?: ClawdbotConfig,
|
||||
skillFilter?: string[],
|
||||
eligibility?: SkillEligibilityContext,
|
||||
): SkillEntry[] {
|
||||
let filtered = entries.filter((entry) => shouldIncludeSkill({ entry, config }));
|
||||
let filtered = entries.filter((entry) => shouldIncludeSkill({ entry, config, eligibility }));
|
||||
// If skillFilter is provided, only include skills in the filter list.
|
||||
if (skillFilter !== undefined) {
|
||||
const normalized = skillFilter.map((entry) => String(entry).trim()).filter(Boolean);
|
||||
@@ -122,18 +128,28 @@ export function buildWorkspaceSkillSnapshot(
|
||||
entries?: SkillEntry[];
|
||||
/** If provided, only include skills with these names */
|
||||
skillFilter?: string[];
|
||||
eligibility?: SkillEligibilityContext;
|
||||
snapshotVersion?: number;
|
||||
},
|
||||
): SkillSnapshot {
|
||||
const skillEntries = opts?.entries ?? loadSkillEntries(workspaceDir, opts);
|
||||
const eligible = filterSkillEntries(skillEntries, opts?.config, opts?.skillFilter);
|
||||
const eligible = filterSkillEntries(
|
||||
skillEntries,
|
||||
opts?.config,
|
||||
opts?.skillFilter,
|
||||
opts?.eligibility,
|
||||
);
|
||||
const resolvedSkills = eligible.map((entry) => entry.skill);
|
||||
const remoteNote = opts?.eligibility?.remote?.note?.trim();
|
||||
const prompt = [remoteNote, formatSkillsForPrompt(resolvedSkills)].filter(Boolean).join("\n");
|
||||
return {
|
||||
prompt: formatSkillsForPrompt(resolvedSkills),
|
||||
prompt,
|
||||
skills: eligible.map((entry) => ({
|
||||
name: entry.skill.name,
|
||||
primaryEnv: entry.clawdbot?.primaryEnv,
|
||||
})),
|
||||
resolvedSkills,
|
||||
version: opts?.snapshotVersion,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -146,11 +162,20 @@ export function buildWorkspaceSkillsPrompt(
|
||||
entries?: SkillEntry[];
|
||||
/** If provided, only include skills with these names */
|
||||
skillFilter?: string[];
|
||||
eligibility?: SkillEligibilityContext;
|
||||
},
|
||||
): string {
|
||||
const skillEntries = opts?.entries ?? loadSkillEntries(workspaceDir, opts);
|
||||
const eligible = filterSkillEntries(skillEntries, opts?.config, opts?.skillFilter);
|
||||
return formatSkillsForPrompt(eligible.map((entry) => entry.skill));
|
||||
const eligible = filterSkillEntries(
|
||||
skillEntries,
|
||||
opts?.config,
|
||||
opts?.skillFilter,
|
||||
opts?.eligibility,
|
||||
);
|
||||
const remoteNote = opts?.eligibility?.remote?.note?.trim();
|
||||
return [remoteNote, formatSkillsForPrompt(eligible.map((entry) => entry.skill))]
|
||||
.filter(Boolean)
|
||||
.join("\n");
|
||||
}
|
||||
|
||||
export function resolveSkillsPromptForRun(params: {
|
||||
|
||||
Reference in New Issue
Block a user