一、引言:Skill 是 Claude Code 最精巧的运行时子系统
在 上一篇文章 中,我们全景式地拆解了 Claude Code 的启动流程、Agentic Loop、工具系统与权限治理。本文聚焦于其中工程密度最高的子系统——Skill Runtime,深入到每一个关键函数的实现原理。
一句话定义:Skill =
Command { type: 'prompt', getPromptForCommand: (args, ctx) => ContentBlockParam[] }。它是一段可参数化、带权限声明、支持 hooks 和条件激活的 Markdown prompt,在运行时通过SkillTool的三路分发引擎(inline / fork / remote)注入 LLM 对话。
本文基于 2026 年 3 月 31 日泄露的 TypeScript 源码快照,逐函数剖析 Skill Runtime 的完整实现链路。
全文架构
| 章节 | 核心实现原理 | 关键文件 |
|---|---|---|
| 二 | Skill 数据模型与 Prompt 展开管线 | bundledSkills.ts, loadSkillsDir.ts |
| 三 | 六源并行加载与 realpath 去重算法 | loadSkillsDir.ts |
| 四 | SkillTool 三路分发与 contextModifier 闭包链 | SkillTool.ts |
| 五 | Safe Properties 排除法白名单权限模型 | SkillTool.ts, PermissionContext.ts |
| 六 | Post-sampling Hook 驱动的自动改进引擎 | skillImprovement.ts, registerSkillHooks.ts |
| 七 | Compaction 恢复与 Invoked Skills 状态管理 | state.ts, processSlashCommand.tsx |
| 八 | chokidar + Bun polling fallback 热重载机制 | skillChangeDetector.ts |
二、Skill 数据模型与 Prompt 展开管线
2.1 运行时类型统一:所有来源 → Command
无论来自哪个来源(bundled / disk / plugin / MCP),Skill 在运行时都被统一表示为 Command { type: 'prompt' } 类型。核心是 getPromptForCommand 方法——一个 异步闭包,封装了完整的 prompt 展开逻辑:
// src/skills/loadSkillsDir.ts — createSkillCommand 核心
async getPromptForCommand(args, toolUseContext) {
// Step 1: Base directory 前缀注入
let finalContent = baseDir
? `Base directory for this skill: ${baseDir}\n\n${markdownContent}`
: markdownContent
// Step 2: 命名参数替换 ($arg_name → 实际值)
finalContent = substituteArguments(finalContent, args, true, argumentNames)
// Step 3: ${CLAUDE_SKILL_DIR} 变量替换 (Windows 路径归一化)
if (baseDir) {
const skillDir = process.platform === 'win32'
? baseDir.replace(/\\/g, '/') // Windows: 反斜杠 → 正斜杠
: baseDir
finalContent = finalContent.replace(/\${CLAUDE_SKILL_DIR}/g, skillDir)
}
// Step 4: ${CLAUDE_SESSION_ID} 替换
finalContent = finalContent.replace(/\${CLAUDE_SESSION_ID}/g, getSessionId())
// Step 5: Shell 命令内联执行 (!`command` 语法)
// 安全关键: MCP 技能 (loadedFrom === 'mcp') 禁止此步骤
if (loadedFrom !== 'mcp') {
finalContent = await executeShellCommandsInPrompt(
finalContent,
{
...toolUseContext,
getAppState() {
// 注入 allowedTools 到权限上下文,使 shell 命令继承技能权限
const appState = toolUseContext.getAppState()
return {
...appState,
toolPermissionContext: {
...appState.toolPermissionContext,
alwaysAllowRules: {
...appState.toolPermissionContext.alwaysAllowRules,
command: allowedTools, // ← 技能声明的工具权限
},
},
}
},
},
`/${skillName}`,
shell, // 可选的自定义 shell (从 frontmatter 解析)
)
}
return [{ type: 'text', text: finalContent }]
}
关键设计:Step 5 中 getAppState 的 monkey-patch 使得技能 Markdown 中嵌入的 !command shell 命令自动继承技能声明的 allowed-tools 权限——无需用户二次授权。
2.2 内置技能的 Promise Memoization 模式
内置技能(bundled skills)可能附带参考文件(如 verify 技能的 examples),这些文件需要懒提取到磁盘。registerBundledSkill 使用了一个精妙的闭包级 Promise memoization:
// src/skills/bundledSkills.ts
export function registerBundledSkill(definition: BundledSkillDefinition): void {
if (files && Object.keys(files).length > 0) {
skillRoot = getBundledSkillExtractDir(definition.name)
// 关键: memo 的是 Promise 本身, 不是 result
let extractionPromise: Promise<string | null> | undefined
const inner = definition.getPromptForCommand
getPromptForCommand = async (args, ctx) => {
extractionPromise ??= extractBundledSkillFiles(definition.name, files)
const extractedDir = await extractionPromise
const blocks = await inner(args, ctx)
if (extractedDir === null) return blocks // 提取失败 → 优雅降级
return prependBaseDir(blocks, extractedDir)
}
}
// ...
}
为什么 memo Promise 而非 result?
- 如果 memo result(
string | null),并发调用者在第一次提取完成前会触发重复的文件写入 ??=(nullish coalescing assignment) 确保只赋值一次,后续调用者await同一个 Promise- 提取失败返回
null,技能继续工作(无 base-directory 前缀),实现优雅降级
2.3 文件提取的安全措施链
// 安全层级 1: per-process nonce 目录 (getBundledSkillsRoot)
// 即使攻击者通过 inotify 获知父目录, 也无法预测子目录名
// 安全层级 2: 路径遍历防护
function resolveSkillFilePath(baseDir: string, relPath: string): string {
const normalized = normalize(relPath)
if (isAbsolute(normalized) ||
normalized.split(pathSep).includes('..') ||
normalized.split('/').includes('..')) // 兼容 Windows 路径分隔符
throw new Error(`bundled skill file path escapes skill dir: ${relPath}`)
return join(baseDir, normalized)
}
// 安全层级 3: 防符号链接写入
const SAFE_WRITE_FLAGS = process.platform === 'win32'
? 'wx' // Windows: libuv 不支持 numeric O_EXCL
: fsConstants.O_WRONLY | fsConstants.O_CREAT | fsConstants.O_EXCL | O_NOFOLLOW
async function safeWriteFile(p: string, content: string): Promise<void> {
const fh = await open(p, SAFE_WRITE_FLAGS, 0o600) // owner-only
try { await fh.writeFile(content, 'utf8') }
finally { await fh.close() }
}
// 安全层级 4: 目录权限
await mkdir(parent, { recursive: true, mode: 0o700 }) // owner-only
源码注释明确解释了设计意图:不 unlink+retry on EEXIST——因为 unlink() 会跟随中间路径上的符号链接。
三、六源并行加载与 realpath 去重算法
3.1 五路并行加载 + 策略控制
getSkillDirCommands 使用 lodash-es/memoize(以 cwd 为缓存 key),首次调用时五路并行加载所有来源:
const [managed, user, projectNested, additionalNested, legacy] = await Promise.all([
// 1. 管理策略目录 (除非 CLAUDE_CODE_DISABLE_POLICY_SKILLS)
loadSkillsFromSkillsDir(managedSkillsDir, 'policySettings'),
// 2. 用户目录 (除非 !isSettingSourceEnabled('userSettings') || locked)
loadSkillsFromSkillsDir(userSkillsDir, 'userSettings'),
// 3. 项目目录 (逐级上行: cwd/.claude/skills, ../claude/skills, ...)
Promise.all(projectDirs.map(dir => loadSkillsFromSkillsDir(dir, 'projectSettings'))),
// 4. --add-dir 附加目录
Promise.all(additionalDirs.map(dir => loadSkillsFromSkillsDir(...))),
// 5. 旧式 /commands/ 目录
loadSkillsFromCommandsDir(cwd),
])
策略控制点:
isRestrictedToPluginOnly('skills')→ 锁定后只加载 managed + plugin skillsisBareMode()→--bare标志跳过所有自动发现,只加载--add-dirisEnvTruthy(CLAUDE_CODE_DISABLE_POLICY_SKILLS)→ 跳过管理策略
3.2 realpath 去重:并行预计算 + 同步去重
去重面临一个性能挑战:realpath() 是 I/O 操作。解决方案是预计算并行化,去重同步化:
// Phase 1: 并行预计算所有文件的 canonical path
const fileIds = await Promise.all(
allSkillsWithPaths.map(({ skill, filePath }) =>
skill.type === 'prompt' ? getFileIdentity(filePath) : Promise.resolve(null),
),
)
// Phase 2: 同步去重 (first-wins 优先级)
const seenFileIds = new Map<string, SettingSource>()
for (let i = 0; i < allSkillsWithPaths.length; i++) {
const fileId = fileIds[i]
if (fileId === null || fileId === undefined) {
deduplicatedSkills.push(skill) // 无法解析的文件不去重
continue
}
const existingSource = seenFileIds.get(fileId)
if (existingSource !== undefined) {
logForDebugging(`Skipping duplicate skill '${skill.name}' from ${skill.source}`)
continue
}
seenFileIds.set(fileId, skill.source)
deduplicatedSkills.push(skill)
}
为什么用 realpath 而非 inode? 源码注释引用了 issue #13893:某些文件系统(virtual/container/NFS/ExFAT)报告不可靠的 inode 值(如 inode 0 或精度丢失)。realpath 是 filesystem-agnostic 的。
3.3 条件技能的三 Map 状态管理
条件技能使用三个模块级状态协同工作:
const conditionalSkills = new Map<string, Command>() // 待激活池
const activatedConditionalSkillNames = new Set<string>() // 已激活名 (跨 cache clear)
const dynamicSkills = new Map<string, Command>() // 活跃池
export function activateConditionalSkillsForPaths(filePaths, cwd): string[] {
for (const [name, skill] of conditionalSkills) {
const skillIgnore = ignore().add(skill.paths) // gitignore 风格匹配
for (const filePath of filePaths) {
const relativePath = isAbsolute(filePath) ? relative(cwd, filePath) : filePath
// 防御: 空路径、../逃逸、跨驱动器绝对路径
if (!relativePath || relativePath.startsWith('..') || isAbsolute(relativePath)) continue
if (skillIgnore.ignores(relativePath)) {
dynamicSkills.set(name, skill) // 移入活跃池
conditionalSkills.delete(name) // 从待激活池移除
activatedConditionalSkillNames.add(name) // 持久记录 (cache clear 不影响)
break
}
}
}
skillsLoaded.emit() // Signal 通知
}
activatedConditionalSkillNames 的设计意图:clearSkillCaches() 会清除 conditionalSkills 和 dynamicSkills,但 activatedConditionalSkillNames 保留——这样 reload 后已激活的技能不会回到待激活池。
3.4 MCP 技能桥接:Write-Once Registry 打破循环依赖
// src/skills/mcpSkillBuilders.ts — 依赖图叶子节点 (只导入类型)
let builders: MCPSkillBuilders | null = null
export function registerMCPSkillBuilders(b: MCPSkillBuilders): void { builders = b }
export function getMCPSkillBuilders(): MCPSkillBuilders {
if (!builders) throw new Error('MCP skill builders not registered')
return builders
}
// loadSkillsDir.ts 文件末尾的顶层副作用 (模块初始化时执行)
registerMCPSkillBuilders({ createSkillCommand, parseSkillFrontmatterFields })
源码注释详细解释了为什么两个替代方案都不可行:
- 变量名动态 import
await import(variable)→ Bun bundle 后路径解析为/$bunfs/root/...,运行时找不到模块 - 字面量动态 import
await import('./loadSkillsDir.js')→ dependency-cruiser 追踪到,loadSkillsDir传递性依赖几乎所有模块,单条新边扇出为数十条 cycle violations
四、SkillTool 三路分发与 contextModifier 闭包链
4.1 getAllCommands:MCP Skill 的安全过滤
async function getAllCommands(context: ToolUseContext): Promise<Command[]> {
// 只包含 loadedFrom === 'mcp' 的 MCP skills, 不包含 plain MCP prompts
// 修复: 此前模型可以通过猜测 mcp__server__prompt 名称调用 MCP prompts
const mcpSkills = context.getAppState().mcp.commands.filter(
cmd => cmd.type === 'prompt' && cmd.loadedFrom === 'mcp',
)
if (mcpSkills.length === 0) return getCommands(getProjectRoot())
const localCommands = await getCommands(getProjectRoot())
return uniqBy([...localCommands, ...mcpSkills], 'name') // 本地优先
}
4.2 Inline 路径的 contextModifier 闭包链
Inline 执行是最复杂的路径。其核心创新是 contextModifier——一个闭包,在技能 prompt 注入后持续影响后续所有 tool 调用:
return {
data: { success: true, commandName, allowedTools, model },
newMessages, // 技能 prompt 注入主对话
contextModifier(ctx) {
let modifiedContext = ctx
// Layer 1: 工具权限合并
if (allowedTools.length > 0) {
const previousGetAppState = modifiedContext.getAppState // 捕获前一层
modifiedContext = {
...modifiedContext,
getAppState() {
const appState = previousGetAppState() // 链式调用, 不是 ctx.getAppState
return {
...appState,
toolPermissionContext: {
...appState.toolPermissionContext,
alwaysAllowRules: {
...appState.toolPermissionContext.alwaysAllowRules,
command: [
...new Set([ // Set 去重
...(appState.toolPermissionContext.alwaysAllowRules.command || []),
...allowedTools,
]),
],
},
},
}
},
}
}
// Layer 2: 模型覆盖 (保留 [1m] 后缀防止降低 context window)
if (model) {
modifiedContext = {
...modifiedContext,
options: {
...modifiedContext.options,
mainLoopModel: resolveSkillModelOverride(model, ctx.options.mainLoopModel),
},
}
}
// Layer 3: Effort level 覆盖
if (effort !== undefined) {
const previousGetAppState = modifiedContext.getAppState // 再次捕获
modifiedContext = {
...modifiedContext,
getAppState() {
return { ...previousGetAppState(), effortValue: effort }
},
}
}
return modifiedContext
},
}
链式 getAppState 的关键:每层修改都捕获 previousGetAppState(而非 ctx.getAppState),确保多层 contextModifier 正确组合。如果直接引用 ctx.getAppState,Layer 3 的修改会跳过 Layer 1。
4.3 Fork 路径的隔离执行与内存管理
async function executeForkedSkill(command, commandName, args, context, ...) {
const agentId = createAgentId()
// prepareForkedCommandContext: 获取 prompt + 工具权限 + agent 定义
const { modifiedGetAppState, baseAgent, promptMessages, skillContent } =
await prepareForkedCommandContext(command, args || '', context)
// 合并 effort 到 agent definition
const agentDefinition = command.effort !== undefined
? { ...baseAgent, effort: command.effort }
: baseAgent
const agentMessages: Message[] = []
try {
// runAgent 返回 async iterator
for await (const message of runAgent({
agentDefinition,
promptMessages,
toolUseContext: { ...context, getAppState: modifiedGetAppState },
isAsync: false, // 同步执行, 阻塞主对话
querySource: 'agent:custom',
model: command.model as ModelAlias | undefined,
override: { agentId }, // 注入自定义 agentId
})) {
agentMessages.push(message)
// 上报进度 (tool_use / tool_result)
if ((message.type === 'assistant' || message.type === 'user') && onProgress) {
// ... progress reporting
}
}
const resultText = extractResultText(agentMessages, 'Skill execution completed')
agentMessages.length = 0 // 显式释放消息内存
return { data: { success: true, commandName, status: 'forked', agentId, result: resultText } }
} finally {
clearInvokedSkillsForAgent(agentId) // 释放 compaction 状态
}
}
prepareForkedCommandContext 内部通过 createGetAppStateWithAllowedTools 包装 getAppState,使用 Set 去重合并工具权限。Agent 查找遵循 fallback 链:command.agent → 'general-purpose' → agents[0]。
五、Safe Properties 排除法白名单权限模型
5.1 checkPermissions 的四阶段决策链
Deny Rules → [Remote Canonical 自动放行] → Allow Rules → Safe Properties 自动放行 → User Ask
规则匹配支持两种模式:
- 精确匹配:
Skill(review-pr)→ 匹配review-pr - 前缀通配:
Skill(review:*)→ 匹配review-pr、review-code等
5.2 Safe Properties 排除法白名单
这是权限系统中最精巧的设计——排除法确保新属性默认需要权限:
const SAFE_SKILL_PROPERTIES = new Set([
// PromptCommand properties
'type', 'progressMessage', 'contentLength', 'argNames', 'model',
'effort', 'source', 'pluginInfo', 'disableNonInteractive',
'skillRoot', 'context', 'agent', 'getPromptForCommand', 'frontmatterKeys',
// CommandBase properties
'name', 'description', 'hasUserSpecifiedDescription', 'isEnabled',
'isHidden', 'aliases', 'isMcp', 'argumentHint', 'whenToUse',
'paths', 'version', 'disableModelInvocation', 'userInvocable',
'loadedFrom', 'immediate', 'userFacingName',
])
function skillHasOnlySafeProperties(command: Command): boolean {
for (const key of Object.keys(command)) {
if (SAFE_SKILL_PROPERTIES.has(key)) continue
const value = (command as Record<string, unknown>)[key]
// 忽略: undefined, null, 空数组, 空对象
if (value === undefined || value === null) continue
if (Array.isArray(value) && value.length === 0) continue
if (typeof value === 'object' && !Array.isArray(value) && Object.keys(value).length === 0) continue
return false // 有非安全属性且有值 → 需要权限
}
return true
}
注意 hooks 和 allowedTools 不在白名单中——有 hooks 或 allowedTools 的技能必须经过用户审批。
六、Post-sampling Hook 驱动的自动改进引擎
6.1 改进检测:每 5 条消息的增量分析
// src/utils/hooks/skillImprovement.ts
function createSkillImprovementHook() {
let lastAnalyzedCount = 0 // 上次分析时的 user 消息数
let lastAnalyzedIndex = 0 // 上次分析到的消息索引
const config: ApiQueryHookConfig<SkillUpdate[]> = {
async shouldRun(context) {
if (context.querySource !== 'repl_main_thread') return false
if (!findProjectSkill()) return false // 只分析项目级技能
const userCount = count(context.messages, m => m.type === 'user')
if (userCount - lastAnalyzedCount < 5) return false // 每 5 条才触发
lastAnalyzedCount = userCount
return true
},
buildMessages(context) {
const projectSkill = findProjectSkill()!
const newMessages = context.messages.slice(lastAnalyzedIndex) // 增量分析
lastAnalyzedIndex = context.messages.length
return [createUserMessage({
content: `...<skill_definition>${projectSkill.content}</skill_definition>
<recent_messages>${formatRecentMessages(newMessages)}</recent_messages>...`
})]
},
parseResponse(content) {
const updatesStr = extractTag(content, 'updates')
return updatesStr ? jsonParse(updatesStr) : [] // 解析失败 → 空数组
},
getModel: getSmallFastModel, // 使用小模型降低成本
}
return createApiQueryHook(config)
}
Feature Gate:双重开关 feature('SKILL_IMPROVEMENT') && getFeatureValue_CACHED_MAY_BE_STALE('tengu_copper_panda', false)
6.2 改进应用:side-channel LLM 重写 SKILL.md
export async function applySkillImprovement(skillName: string, updates: SkillUpdate[]) {
const filePath = join(getCwd(), '.claude', 'skills', skillName, 'SKILL.md')
const currentContent = await fs.readFile(filePath, 'utf-8')
const response = await queryModelWithoutStreaming({
messages: [createUserMessage({ content: `...` })],
systemPrompt: asSystemPrompt(['You edit skill definition files...']),
thinkingConfig: { type: 'disabled' }, // 省 token
tools: [], // 不需要工具
signal: createAbortController().signal, // 非阻塞
options: {
model: getSmallFastModel(),
temperatureOverride: 0, // 确定性输出
querySource: 'skill_improvement_apply',
},
})
const updatedContent = extractTag(responseText, 'updated_file')
if (updatedContent) await fs.writeFile(filePath, updatedContent, 'utf-8')
}
6.3 技能 Hooks 的 Session-scoped 注册
// src/utils/hooks/registerSkillHooks.ts
export function registerSkillHooks(setAppState, sessionId, hooks, skillName, skillRoot?) {
for (const eventName of HOOK_EVENTS) {
const matchers = hooks[eventName]
if (!matchers) continue
for (const matcher of matchers) {
for (const hook of matcher.hooks) {
// once: true → 成功后自动 removeSessionHook
const onHookSuccess = hook.once
? () => removeSessionHook(setAppState, sessionId, eventName, hook)
: undefined
addSessionHook(setAppState, sessionId, eventName,
matcher.matcher || '', hook, onHookSuccess, skillRoot)
}
}
}
}
支持的 Hook 事件:PreToolUse, PostToolUse, PostToolUseFailure, Stop, SessionStart, UserPromptSubmit, PreCompact, PostCompact, Notification, PermissionRequest
七、Compaction 恢复与 Invoked Skills 状态管理
7.1 invokedSkills Map 的复合键设计
// src/bootstrap/state.ts
// 键格式: `${agentId ?? ''}:${skillName}` — 防止跨 agent 覆盖
invokedSkills: Map<string, {
skillName: string
skillPath: string // 格式: `${source}:${name}` (如 "projectSettings:my-skill")
content: string // 已经过完整展开的 prompt 内容
invokedAt: number
agentId: string | null // null = 主线程
}>
写入点(两处):
processSlashCommand.tsx中getMessagesForPromptSlashCommand→addInvokedSkill(command.name, skillPath, skillContent, agentId)SkillTool.ts中executeRemoteSkill→addInvokedSkill(commandName, skillPath, finalContent, agentId)
读取点:
skillImprovement.ts→getInvokedSkillsForAgent(null)只读主线程技能- Compaction 恢复逻辑 →
getInvokedSkills()获取所有技能内容
清理点:
clearInvokedSkills(preservedAgentIds?)— compaction 时保留指定 agent 的技能clearInvokedSkillsForAgent(agentId)— fork 技能执行完毕后释放
7.2 processSlashCommand 中的完整注册链
// src/utils/processUserInput/processSlashCommand.tsx
async function getMessagesForPromptSlashCommand(command, args, context, ...) {
// 1. 获取展开后的 prompt 内容
const result = await command.getPromptForCommand(args, context)
// 2. 注册 skill hooks (受策略控制)
const hooksAllowed = !isRestrictedToPluginOnly('hooks') || isSourceAdminTrusted(command.source)
if (command.hooks && hooksAllowed) {
registerSkillHooks(context.setAppState, sessionId, command.hooks, command.name, command.skillRoot)
}
// 3. 注册到 compaction 恢复系统
const skillPath = command.source ? `${command.source}:${command.name}` : command.name
const skillContent = result.filter(b => b.type === 'text').map(b => b.text).join('\n\n')
addInvokedSkill(command.name, skillPath, skillContent, getAgentContext()?.agentId ?? null)
// 4. 提取附件 (@-mentions, MCP resources) — skipSkillDiscovery 避免 AKI 延迟
const attachmentMessages = await toArray(
getAttachmentMessages(/* ... */, { skipSkillDiscovery: true })
)
// 5. 构建消息列表
return {
messages: [
createUserMessage({ content: metadata, uuid }), // 元数据
createUserMessage({ content: mainContent, isMeta: true }), // 技能内容
...attachmentMessages, // 附件
createAttachmentMessage({ type: 'command_permissions', allowedTools, model }), // 权限
],
shouldQuery: true,
allowedTools, model, effort, command,
}
}
八、chokidar + Bun polling fallback 热重载机制
8.1 Bun FSWatcher 死锁的规避
// src/utils/skills/skillChangeDetector.ts
const USE_POLLING = typeof Bun !== 'undefined'
源码注释引用了两个 Bun issue(#27469, #26385):Bun 的 fs.watch() 有 PathWatcherManager 死锁——主线程关闭 watcher 时,如果 File Watcher 线程正在递送事件,两个线程会在 __ulock_wait2 中互相死锁。chokidar 在 depth: 2 的技能目录上可靠触发此 bug。
8.2 防抖的三重保护
const RELOAD_DEBOUNCE_MS = 300
function scheduleReload(changedPath: string): void {
pendingChangedPaths.add(changedPath) // 累积路径
if (reloadTimer) clearTimeout(reloadTimer) // 重置定时器
reloadTimer = setTimeout(async () => {
reloadTimer = null
const paths = [...pendingChangedPaths]
pendingChangedPaths.clear()
// 保护 1: ConfigChange hook 可阻止重载
const results = await executeConfigChangeHooks('skills', paths[0]!)
if (hasBlockingResult(results)) return
// 保护 2: 级联清除三层缓存
clearSkillCaches() // getSkillDirCommands.cache + conditionalSkills
clearCommandsCache() // getCommands memoization
resetSentSkillNames() // 触发技能列表重发
// 保护 3: Signal 通知 UI (React hook 订阅)
skillsChanged.emit()
}, RELOAD_DEBOUNCE_MS)
}
动态技能加载的特殊处理:onDynamicSkillsLoaded 回调只清除 clearCommandMemoizationCaches()(不调用 clearCommandsCache()),因为后者会触发 clearSkillCaches() 清除刚刚加载的动态技能。
8.3 Token Budget 管理算法
技能列表在 system prompt 中的空间被精确控制:
export const SKILL_BUDGET_CONTEXT_PERCENT = 0.01 // 上下文窗口的 1%
export const CHARS_PER_TOKEN = 4
export const MAX_LISTING_DESC_CHARS = 250
export function formatCommandsWithinBudget(commands, contextWindowTokens?) {
const budget = getCharBudget(contextWindowTokens)
const fullTotal = commands.reduce((sum, cmd) => sum + stringWidth(formatDesc(cmd)), 0)
if (fullTotal <= budget) return fullEntries.join('\n') // 全描述
// 分区: bundled (永不截断) vs rest
const bundledChars = /* bundled 技能占用 */
const remainingBudget = budget - bundledChars
const maxDescLen = Math.floor(remainingBudget / restCommands.length)
if (maxDescLen < 20) return /* 极端: rest 只显示名字 */
return /* 截断 rest 描述到 maxDescLen */
}
使用 stringWidth(非 length)精确计算 CJK/emoji 字符宽度。
九、设计洞察与关键实现模式
9.1 七大关键实现模式
| 模式 | 实现位置 | 解决的技术问题 |
|---|---|---|
| Promise Memoization | bundledSkills.ts |
并发安全的懒加载(memo Promise 而非 result) |
| realpath 并行去重 | loadSkillsDir.ts |
I/O 密集操作的 Promise.all 预计算 + 同步去重 |
| contextModifier 闭包链 | SkillTool.ts |
多层 getAppState 修改的正确组合(链式捕获 previousGetAppState) |
| Safe Properties 排除法 | SkillTool.ts |
新属性默认需要权限(正向枚举安全属性) |
| Write-once Registry | mcpSkillBuilders.ts |
Bun bundle 中的循环依赖(不是 dynamic import) |
| 三 Map 条件激活 | loadSkillsDir.ts |
conditional → dynamic 状态转移,activated 跨 cache clear 保留 |
| Bun Polling Fallback | skillChangeDetector.ts |
FSWatcher 死锁的运行时环境检测与降级 |
9.2 Skill Runtime 的数据流全景
┌─ bundled/index.ts ──→ registerBundledSkill()
├─ loadSkillsDir.ts ──→ getSkillDirCommands() [memoized]
来源层 ─→ 注册层 ├─ builtinPlugins.ts ──→ getBuiltinPluginSkillCommands()
├─ MCP server ──→ mcpSkillBuilders → createSkillCommand()
└─ dynamicSkills Map ←── discoverSkillDirsForPaths()
│
▼
发现层 ──→ getCommands(cwd) ──→ formatCommandsWithinBudget() ──→ System Prompt
│
▼ (模型调用 Skill tool)
执行层 ──→ SkillTool.validateInput() → checkPermissions() → call()
│
┌─────────────────┼──────────────────┐
▼ ▼ ▼
Inline Fork Remote
processPrompt... executeForked... executeRemote...
│ │ │
▼ ▼ ▼
newMessages + runAgent() → loadRemoteSkill()
contextModifier extractResult + addInvokedSkill
│ │ │
└───────────────────┴────────────────────┘
│
状态层 ──→ addInvokedSkill() ──→ compaction 恢复
registerSkillHooks() ──→ session hooks
skillImprovement ──→ auto-rewrite SKILL.md
skillChangeDetector ──→ chokidar + debounce → cache invalidation
9.3 对 AI Agent 工程化的启示
Claude Code 的 Skill Runtime 展示了 “Prompt-as-Function” 范式的完整工程化实现:
- 参数化:
$arg_name+${CLAUDE_SKILL_DIR}+${CLAUDE_SESSION_ID} - 权限声明式:
allowed-toolsfrontmatter → contextModifier 自动注入 - 可组合:技能可以通过 SkillTool 调用其他技能
- 可观察:hooks 系统覆盖完整生命周期(9 种事件类型)
- 自进化:每 5 条消息自动检测用户偏好并重写 SKILL.md
- 可信任:六级信任层级 + Safe Properties 排除法 + per-process nonce 安全写入
这种架构将 AI Agent 从"单轮响应"推向了"可编程工作流引擎"。
本文基于公开可获取的源码快照进行纯技术架构分析,仅用于学习和研究目的。原始 Claude Code 源码版权归 Anthropic 所有。
「真诚赞赏,手留余香」
真诚赞赏,手留余香
使用微信扫描二维码完成支付