feat: mac node exec policy + remote skills hot reload

This commit is contained in:
Peter Steinberger
2026-01-16 03:45:03 +00:00
parent abcca86e4e
commit b2b331230b
36 changed files with 977 additions and 40 deletions

View File

@@ -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),
),
};
}

View File

@@ -15,6 +15,7 @@ export {
} from "./skills/env-overrides.js";
export type {
ClawdbotSkillMetadata,
SkillEligibilityContext,
SkillEntry,
SkillInstallSpec,
SkillSnapshot,

View File

@@ -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;
}

View 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);
}

View File

@@ -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;
};

View File

@@ -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: {

View File

@@ -5,6 +5,7 @@ import {
import { createClawdbotCodingTools } from "../../agents/pi-tools.js";
import { resolveSandboxRuntimeStatus } from "../../agents/sandbox.js";
import { buildWorkspaceSkillSnapshot } from "../../agents/skills.js";
import { getSkillsSnapshotVersion } from "../../agents/skills/refresh.js";
import { buildAgentSystemPrompt } from "../../agents/system-prompt.js";
import { buildSystemPromptReport } from "../../agents/system-prompt-report.js";
import { buildToolSummaryMap } from "../../agents/tool-summaries.js";
@@ -13,6 +14,7 @@ import {
loadWorkspaceBootstrapFiles,
} from "../../agents/workspace.js";
import type { SessionSystemPromptReport } from "../../config/sessions/types.js";
import { getRemoteSkillEligibility } from "../../infra/skills-remote.js";
import type { ReplyPayload } from "../types.js";
import type { HandleCommandsParams } from "./commands-types.js";
@@ -62,7 +64,11 @@ async function resolveContextReport(
});
const skillsSnapshot = (() => {
try {
return buildWorkspaceSkillSnapshot(workspaceDir, { config: params.cfg });
return buildWorkspaceSkillSnapshot(workspaceDir, {
config: params.cfg,
eligibility: { remote: getRemoteSkillEligibility() },
snapshotVersion: getSkillsSnapshotVersion(workspaceDir),
});
} catch {
return { prompt: "", skills: [], resolvedSkills: [] };
}

View File

@@ -1,9 +1,14 @@
import crypto from "node:crypto";
import { buildWorkspaceSkillSnapshot } from "../../agents/skills.js";
import {
ensureSkillsWatcher,
getSkillsSnapshotVersion,
} from "../../agents/skills/refresh.js";
import type { ClawdbotConfig } from "../../config/config.js";
import { type SessionEntry, updateSessionStore } from "../../config/sessions.js";
import { buildChannelSummary } from "../../infra/channel-summary.js";
import { getRemoteSkillEligibility } from "../../infra/skills-remote.js";
import { drainSystemEventEntries } from "../../infra/system-events.js";
export async function prependSystemEvents(params: {
@@ -88,6 +93,11 @@ export async function ensureSkillSnapshot(params: {
let nextEntry = sessionEntry;
let systemSent = sessionEntry?.systemSent ?? false;
const remoteEligibility = getRemoteSkillEligibility();
const snapshotVersion = getSkillsSnapshotVersion(workspaceDir);
ensureSkillsWatcher({ workspaceDir, config: cfg });
const shouldRefreshSnapshot =
snapshotVersion > 0 && (nextEntry?.skillsSnapshot?.version ?? 0) < snapshotVersion;
if (isFirstTurnInSession && sessionStore && sessionKey) {
const current = nextEntry ??
@@ -96,10 +106,12 @@ export async function ensureSkillSnapshot(params: {
updatedAt: Date.now(),
};
const skillSnapshot =
isFirstTurnInSession || !current.skillsSnapshot
isFirstTurnInSession || !current.skillsSnapshot || shouldRefreshSnapshot
? buildWorkspaceSkillSnapshot(workspaceDir, {
config: cfg,
skillFilter,
eligibility: { remote: remoteEligibility },
snapshotVersion,
})
: current.skillsSnapshot;
nextEntry = {
@@ -118,20 +130,28 @@ export async function ensureSkillSnapshot(params: {
systemSent = true;
}
const skillsSnapshot =
nextEntry?.skillsSnapshot ??
(isFirstTurnInSession
? undefined
: buildWorkspaceSkillSnapshot(workspaceDir, {
config: cfg,
skillFilter,
}));
const skillsSnapshot = shouldRefreshSnapshot
? buildWorkspaceSkillSnapshot(workspaceDir, {
config: cfg,
skillFilter,
eligibility: { remote: remoteEligibility },
snapshotVersion,
})
: nextEntry?.skillsSnapshot ??
(isFirstTurnInSession
? undefined
: buildWorkspaceSkillSnapshot(workspaceDir, {
config: cfg,
skillFilter,
eligibility: { remote: remoteEligibility },
snapshotVersion,
}));
if (
skillsSnapshot &&
sessionStore &&
sessionKey &&
!isFirstTurnInSession &&
!nextEntry?.skillsSnapshot
(!nextEntry?.skillsSnapshot || shouldRefreshSnapshot)
) {
const current = nextEntry ?? {
sessionId: sessionId ?? crypto.randomUUID(),

View File

@@ -19,6 +19,7 @@ import {
} from "../agents/model-selection.js";
import { runEmbeddedPiAgent } from "../agents/pi-embedded.js";
import { buildWorkspaceSkillSnapshot } from "../agents/skills.js";
import { getSkillsSnapshotVersion } from "../agents/skills/refresh.js";
import { resolveAgentTimeoutMs } from "../agents/timeout.js";
import { ensureAgentWorkspace } from "../agents/workspace.js";
import {
@@ -43,6 +44,7 @@ import {
emitAgentEvent,
registerAgentRunContext,
} from "../infra/agent-events.js";
import { getRemoteSkillEligibility } from "../infra/skills-remote.js";
import { defaultRuntime, type RuntimeEnv } from "../runtime.js";
import { applyVerboseOverride } from "../sessions/level-overrides.js";
import { resolveSendPolicy } from "../sessions/send-policy.js";
@@ -157,8 +159,13 @@ export async function agentCommand(
}
const needsSkillsSnapshot = isNewSession || !sessionEntry?.skillsSnapshot;
const skillsSnapshotVersion = getSkillsSnapshotVersion(workspaceDir);
const skillsSnapshot = needsSkillsSnapshot
? buildWorkspaceSkillSnapshot(workspaceDir, { config: cfg })
? buildWorkspaceSkillSnapshot(workspaceDir, {
config: cfg,
eligibility: { remote: getRemoteSkillEligibility() },
snapshotVersion: skillsSnapshotVersion,
})
: sessionEntry?.skillsSnapshot;
if (skillsSnapshot && sessionStore && sessionKey && needsSkillsSnapshot) {

View File

@@ -13,6 +13,7 @@ import { inspectPortUsage } from "../infra/ports.js";
import { readRestartSentinel } from "../infra/restart-sentinel.js";
import { readTailscaleStatusJson } from "../infra/tailscale.js";
import { checkUpdateStatus, compareSemverStrings } from "../infra/update-check.js";
import { getRemoteSkillEligibility } from "../infra/skills-remote.js";
import { runExec } from "../process/exec.js";
import type { RuntimeEnv } from "../runtime.js";
import { VERSION } from "../version.js";
@@ -217,6 +218,7 @@ export async function statusAllCommand(
try {
return buildWorkspaceSkillStatus(defaultWorkspace, {
config: cfg,
eligibility: { remote: getRemoteSkillEligibility() },
});
} catch {
return null;

View File

@@ -120,6 +120,8 @@ const FIELD_LABELS: Record<string, string> = {
"gateway.http.endpoints.chatCompletions.enabled": "OpenAI Chat Completions Endpoint",
"gateway.reload.mode": "Config Reload Mode",
"gateway.reload.debounceMs": "Config Reload Debounce (ms)",
"skills.load.watch": "Watch Skills",
"skills.load.watchDebounceMs": "Skills Watch Debounce (ms)",
"agents.defaults.workspace": "Workspace",
"agents.defaults.bootstrapMaxChars": "Bootstrap Max Chars",
"agents.defaults.memorySearch": "Memory Search",

View File

@@ -95,6 +95,7 @@ export type SessionSkillSnapshot = {
prompt: string;
skills: Array<{ name: string; primaryEnv?: string }>;
resolvedSkills?: Skill[];
version?: number;
};
export type SessionSystemPromptReport = {

View File

@@ -11,6 +11,10 @@ export type SkillsLoadConfig = {
* Each directory should contain skill subfolders with `SKILL.md`.
*/
extraDirs?: string[];
/** Watch skill folders for changes and refresh the skills snapshot. */
watch?: boolean;
/** Debounce for the skills watcher (ms). */
watchDebounceMs?: number;
};
export type SkillsInstallConfig = {

View File

@@ -270,6 +270,8 @@ export const ClawdbotSchema = z
load: z
.object({
extraDirs: z.array(z.string()).optional(),
watch: z.boolean().optional(),
watchDebounceMs: z.number().int().min(0).optional(),
})
.optional(),
install: z

View File

@@ -20,6 +20,7 @@ import {
} from "../../agents/model-selection.js";
import { runEmbeddedPiAgent } from "../../agents/pi-embedded.js";
import { buildWorkspaceSkillSnapshot } from "../../agents/skills.js";
import { getSkillsSnapshotVersion } from "../../agents/skills/refresh.js";
import { resolveAgentTimeoutMs } from "../../agents/timeout.js";
import { hasNonzeroUsage } from "../../agents/usage.js";
import { ensureAgentWorkspace } from "../../agents/workspace.js";
@@ -34,6 +35,7 @@ import { resolveSessionTranscriptPath, updateSessionStore } from "../../config/s
import type { AgentDefaultsConfig } from "../../config/types.js";
import { registerAgentRunContext } from "../../infra/agent-events.js";
import { deliverOutboundPayloads } from "../../infra/outbound/deliver.js";
import { getRemoteSkillEligibility } from "../../infra/skills-remote.js";
import { buildAgentMainSessionKey, normalizeAgentId } from "../../routing/session-key.js";
import type { CronJob } from "../types.js";
import { resolveDeliveryTarget } from "./delivery-target.js";
@@ -205,9 +207,12 @@ export async function runCronIsolatedAgentTurn(params: {
const commandBody = base;
const needsSkillsSnapshot = cronSession.isNewSession || !cronSession.sessionEntry.skillsSnapshot;
const skillsSnapshotVersion = getSkillsSnapshotVersion(workspaceDir);
const skillsSnapshot = needsSkillsSnapshot
? buildWorkspaceSkillSnapshot(workspaceDir, {
config: cfgWithAgentDefaults,
eligibility: { remote: getRemoteSkillEligibility() },
snapshotVersion: skillsSnapshotVersion,
})
: cronSession.sessionEntry.skillsSnapshot;
if (needsSkillsSnapshot && skillsSnapshot) {

View File

@@ -3,6 +3,7 @@ import type { CanvasHostHandler, CanvasHostServer } from "../canvas-host/server.
import { startCanvasHost } from "../canvas-host/server.js";
import type { CliDeps } from "../cli/deps.js";
import type { HealthSummary } from "../commands/health.js";
import type { ClawdbotConfig } from "../config/config.js";
import { deriveDefaultBridgePort, deriveDefaultCanvasHostPort } from "../config/port-defaults.js";
import type { NodeBridgeServer } from "../infra/bridge/server.js";
import { pickPrimaryTailnetIPv4, pickPrimaryTailnetIPv6 } from "../infra/tailnet.js";
@@ -33,15 +34,7 @@ export type GatewayBridgeRuntime = {
};
export async function startGatewayBridgeRuntime(params: {
cfg: {
bridge?: {
enabled?: boolean;
port?: number;
bind?: "loopback" | "lan" | "auto" | "custom";
};
canvasHost?: { port?: number; root?: string; liveReload?: boolean };
discovery?: { wideArea?: { enabled?: boolean } };
};
cfg: ClawdbotConfig;
port: number;
canvasHostEnabled: boolean;
canvasHost: CanvasHostHandler | null;
@@ -200,6 +193,7 @@ export async function startGatewayBridgeRuntime(params: {
: undefined;
const bridgeRuntime = await startGatewayNodeBridge({
cfg: params.cfg,
bridgeEnabled,
bridgePort,
bridgeHost,

View File

@@ -3,6 +3,7 @@ import { installSkill } from "../../agents/skills-install.js";
import { buildWorkspaceSkillStatus } from "../../agents/skills-status.js";
import type { ClawdbotConfig } from "../../config/config.js";
import { loadConfig, writeConfigFile } from "../../config/config.js";
import { getRemoteSkillEligibility } from "../../infra/skills-remote.js";
import {
ErrorCodes,
errorShape,
@@ -30,6 +31,7 @@ export const skillsHandlers: GatewayRequestHandlers = {
const workspaceDir = resolveAgentWorkspaceDir(cfg, resolveDefaultAgentId(cfg));
const report = buildWorkspaceSkillStatus(workspaceDir, {
config: cfg,
eligibility: { remote: getRemoteSkillEligibility() },
});
respond(true, report, undefined);
},

View File

@@ -1,5 +1,8 @@
import type { NodeBridgeServer } from "../infra/bridge/server.js";
import { startNodeBridgeServer } from "../infra/bridge/server.js";
import type { ClawdbotConfig } from "../config/config.js";
import { bumpSkillsSnapshotVersion } from "../agents/skills/refresh.js";
import { recordRemoteNodeInfo, refreshRemoteNodeBins } from "../infra/skills-remote.js";
import { listSystemPresence, upsertPresence } from "../infra/system-presence.js";
import { loadVoiceWakeConfig } from "../infra/voicewake.js";
import { isLoopbackAddress } from "./net.js";
@@ -16,6 +19,7 @@ export type GatewayNodeBridgeRuntime = {
};
export async function startGatewayNodeBridge(params: {
cfg: ClawdbotConfig;
bridgeEnabled: boolean;
bridgePort: number;
bridgeHost: string | null;
@@ -114,6 +118,21 @@ export async function startGatewayNodeBridge(params: {
onAuthenticated: async (node) => {
beaconNodePresence(node, "node-connected");
startNodePresenceTimer(node);
recordRemoteNodeInfo({
nodeId: node.nodeId,
displayName: node.displayName,
platform: node.platform,
deviceFamily: node.deviceFamily,
commands: node.commands,
});
bumpSkillsSnapshotVersion({ reason: "remote-node" });
await refreshRemoteNodeBins({
nodeId: node.nodeId,
platform: node.platform,
deviceFamily: node.deviceFamily,
commands: node.commands,
cfg: params.cfg,
});
try {
const cfg = await loadVoiceWakeConfig();

View File

@@ -1,5 +1,6 @@
import { resolveAgentWorkspaceDir, resolveDefaultAgentId } from "../agents/agent-scope.js";
import { initSubagentRegistry } from "../agents/subagent-registry.js";
import { registerSkillsChangeListener } from "../agents/skills/refresh.js";
import type { CanvasHostServer } from "../canvas-host/server.js";
import { type ChannelId, listChannelPlugins } from "../channels/plugins/index.js";
import { createDefaultDeps } from "../cli/deps.js";
@@ -16,6 +17,11 @@ import { onHeartbeatEvent } from "../infra/heartbeat-events.js";
import { startHeartbeatRunner } from "../infra/heartbeat-runner.js";
import { getMachineDisplayName } from "../infra/machine-name.js";
import { ensureClawdbotCliOnPath } from "../infra/path-env.js";
import {
primeRemoteSkillsCache,
refreshRemoteBinsForConnectedNodes,
setSkillsRemoteBridge,
} from "../infra/skills-remote.js";
import { autoMigrateLegacyState } from "../infra/state-migrations.js";
import { createSubsystemLogger, runtimeForLogger } from "../logging.js";
import type { PluginServicesHandle } from "../plugins/services.js";
@@ -288,6 +294,13 @@ export async function startGatewayServer(
const bridgeSendToAllSubscribed = bridgeRuntime.bridgeSendToAllSubscribed;
const broadcastVoiceWakeChanged = bridgeRuntime.broadcastVoiceWakeChanged;
setSkillsRemoteBridge(bridge);
void primeRemoteSkillsCache();
registerSkillsChangeListener(() => {
const latest = loadConfig();
void refreshRemoteBinsForConnectedNodes(latest);
});
const { tickInterval, healthInterval, dedupeCleanup } = startGatewayMaintenanceTimers({
broadcast,
bridgeSendToAllSubscribed,

View File

@@ -30,6 +30,7 @@ export type NodePairingPairedNode = {
modelIdentifier?: string;
caps?: string[];
commands?: string[];
bins?: string[];
permissions?: Record<string, boolean>;
remoteIp?: string;
createdAtMs: number;
@@ -272,6 +273,7 @@ export async function updatePairedNodeMetadata(
remoteIp: patch.remoteIp ?? existing.remoteIp,
caps: patch.caps ?? existing.caps,
commands: patch.commands ?? existing.commands,
bins: patch.bins ?? existing.bins,
permissions: patch.permissions ?? existing.permissions,
};

226
src/infra/skills-remote.ts Normal file
View File

@@ -0,0 +1,226 @@
import type { SkillEligibilityContext, SkillEntry } from "../agents/skills.js";
import { loadWorkspaceSkillEntries } from "../agents/skills.js";
import { resolveAgentWorkspaceDir, resolveDefaultAgentId } from "../agents/agent-scope.js";
import type { ClawdbotConfig } from "../config/config.js";
import type { NodeBridgeServer } from "./bridge/server.js";
import { listNodePairing, updatePairedNodeMetadata } from "./node-pairing.js";
import { createSubsystemLogger } from "../logging.js";
import { bumpSkillsSnapshotVersion } from "../agents/skills/refresh.js";
type RemoteNodeRecord = {
nodeId: string;
displayName?: string;
platform?: string;
deviceFamily?: string;
commands?: string[];
bins: Set<string>;
};
const log = createSubsystemLogger("gateway/skills-remote");
const remoteNodes = new Map<string, RemoteNodeRecord>();
let remoteBridge: NodeBridgeServer | null = null;
function isMacPlatform(platform?: string, deviceFamily?: string): boolean {
const platformNorm = String(platform ?? "").trim().toLowerCase();
const familyNorm = String(deviceFamily ?? "").trim().toLowerCase();
if (platformNorm.includes("mac")) return true;
if (platformNorm.includes("darwin")) return true;
if (familyNorm === "mac") return true;
return false;
}
function supportsSystemRun(commands?: string[]): boolean {
return Array.isArray(commands) && commands.includes("system.run");
}
function upsertNode(record: {
nodeId: string;
displayName?: string;
platform?: string;
deviceFamily?: string;
commands?: string[];
bins?: string[];
}) {
const existing = remoteNodes.get(record.nodeId);
const bins = new Set<string>(record.bins ?? existing?.bins ?? []);
remoteNodes.set(record.nodeId, {
nodeId: record.nodeId,
displayName: record.displayName ?? existing?.displayName,
platform: record.platform ?? existing?.platform,
deviceFamily: record.deviceFamily ?? existing?.deviceFamily,
commands: record.commands ?? existing?.commands,
bins,
});
}
export function setSkillsRemoteBridge(bridge: NodeBridgeServer | null) {
remoteBridge = bridge;
}
export async function primeRemoteSkillsCache() {
try {
const list = await listNodePairing();
let sawMac = false;
for (const node of list.paired) {
upsertNode({
nodeId: node.nodeId,
displayName: node.displayName,
platform: node.platform,
deviceFamily: node.deviceFamily,
commands: node.commands,
bins: node.bins,
});
if (isMacPlatform(node.platform, node.deviceFamily) && supportsSystemRun(node.commands)) {
sawMac = true;
}
}
if (sawMac) {
bumpSkillsSnapshotVersion({ reason: "remote-node" });
}
} catch (err) {
log.warn(`failed to prime remote skills cache: ${String(err)}`);
}
}
export function recordRemoteNodeInfo(node: {
nodeId: string;
displayName?: string;
platform?: string;
deviceFamily?: string;
commands?: string[];
}) {
upsertNode(node);
}
export function recordRemoteNodeBins(nodeId: string, bins: string[]) {
upsertNode({ nodeId, bins });
}
function listWorkspaceDirs(cfg: ClawdbotConfig): string[] {
const dirs = new Set<string>();
const list = cfg.agents?.list;
if (Array.isArray(list)) {
for (const entry of list) {
if (entry && typeof entry === "object" && typeof entry.id === "string") {
dirs.add(resolveAgentWorkspaceDir(cfg, entry.id));
}
}
}
dirs.add(resolveAgentWorkspaceDir(cfg, resolveDefaultAgentId(cfg)));
return [...dirs];
}
function collectRequiredBins(entries: SkillEntry[], targetPlatform: string): string[] {
const bins = new Set<string>();
for (const entry of entries) {
const os = entry.clawdbot?.os ?? [];
if (os.length > 0 && !os.includes(targetPlatform)) continue;
const required = entry.clawdbot?.requires?.bins ?? [];
const anyBins = entry.clawdbot?.requires?.anyBins ?? [];
for (const bin of required) {
if (bin.trim()) bins.add(bin.trim());
}
for (const bin of anyBins) {
if (bin.trim()) bins.add(bin.trim());
}
}
return [...bins];
}
function buildBinProbeScript(bins: string[]): string {
const escaped = bins.map((bin) => `'${bin.replace(/'/g, `'\\''`)}'`).join(" ");
return `for b in ${escaped}; do if command -v "$b" >/dev/null 2>&1; then echo "$b"; fi; done`;
}
export async function refreshRemoteNodeBins(params: {
nodeId: string;
platform?: string;
deviceFamily?: string;
commands?: string[];
cfg: ClawdbotConfig;
timeoutMs?: number;
}) {
if (!remoteBridge) return;
if (!isMacPlatform(params.platform, params.deviceFamily)) return;
if (!supportsSystemRun(params.commands)) return;
const workspaceDirs = listWorkspaceDirs(params.cfg);
const requiredBins = new Set<string>();
for (const workspaceDir of workspaceDirs) {
const entries = loadWorkspaceSkillEntries(workspaceDir, { config: params.cfg });
for (const bin of collectRequiredBins(entries, "darwin")) {
requiredBins.add(bin);
}
}
if (requiredBins.size === 0) return;
const script = buildBinProbeScript([...requiredBins]);
const payload = {
command: ["/bin/sh", "-lc", script],
};
try {
const res = await remoteBridge.invoke({
nodeId: params.nodeId,
command: "system.run",
paramsJSON: JSON.stringify(payload),
timeoutMs: params.timeoutMs ?? 15_000,
});
if (!res.ok) {
log.warn(`remote bin probe failed (${params.nodeId}): ${res.error?.message ?? "unknown"}`);
return;
}
const raw = typeof res.payloadJSON === "string" ? res.payloadJSON : "";
const parsed =
raw && raw.trim().length > 0
? (JSON.parse(raw) as { stdout?: string })
: ({ stdout: "" } as { stdout?: string });
const stdout = typeof parsed.stdout === "string" ? parsed.stdout : "";
const bins = stdout
.split(/\r?\n/)
.map((line) => line.trim())
.filter(Boolean);
recordRemoteNodeBins(params.nodeId, bins);
await updatePairedNodeMetadata(params.nodeId, { bins });
bumpSkillsSnapshotVersion({ reason: "remote-node" });
} catch (err) {
log.warn(`remote bin probe error (${params.nodeId}): ${String(err)}`);
}
}
export function getRemoteSkillEligibility(): SkillEligibilityContext["remote"] | undefined {
const macNodes = [...remoteNodes.values()].filter(
(node) => isMacPlatform(node.platform, node.deviceFamily) && supportsSystemRun(node.commands),
);
if (macNodes.length === 0) return undefined;
const bins = new Set<string>();
for (const node of macNodes) {
for (const bin of node.bins) bins.add(bin);
}
const labels = macNodes
.map((node) => node.displayName ?? node.nodeId)
.filter(Boolean);
const note =
labels.length > 0
? `Remote macOS node available (${labels.join(", ")}). Run macOS-only skills via nodes.run on that node.`
: "Remote macOS node available. Run macOS-only skills via nodes.run on that node.";
return {
platforms: ["darwin"],
hasBin: (bin) => bins.has(bin),
hasAnyBin: (required) => required.some((bin) => bins.has(bin)),
note,
};
}
export async function refreshRemoteBinsForConnectedNodes(cfg: ClawdbotConfig) {
if (!remoteBridge) return;
const connected = remoteBridge.listConnected();
for (const node of connected) {
await refreshRemoteNodeBins({
nodeId: node.nodeId,
platform: node.platform,
deviceFamily: node.deviceFamily,
commands: node.commands,
cfg,
});
}
}