Claude Code Skill Runtime 深度解析:可复用 AI 工作流引擎的架构与实现

从 Promise Memoization、realpath 去重到 contextModifier 闭包链的 Skill Runtime 实现原理全解剖

Posted by 爱折腾的工程师 on Tuesday, April 7, 2026

一、引言: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 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 五路并行加载 + 策略控制

Skill 发现与加载流程

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 skills
  • isBareMode() → --bare 标志跳过所有自动发现,只加载 --add-dir
  • isEnvTruthy(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 闭包链

SkillTool 执行管线

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 排除法白名单权限模型

Skill 安全与权限模型

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 = 主线程
}>

写入点(两处):

  1. processSlashCommand.tsx 中 getMessagesForPromptSlashCommand → addInvokedSkill(command.name, skillPath, skillContent, agentId)
  2. 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-tools frontmatter → contextModifier 自动注入
  • 可组合:技能可以通过 SkillTool 调用其他技能
  • 可观察:hooks 系统覆盖完整生命周期(9 种事件类型)
  • 自进化:每 5 条消息自动检测用户偏好并重写 SKILL.md
  • 可信任:六级信任层级 + Safe Properties 排除法 + per-process nonce 安全写入

这种架构将 AI Agent 从"单轮响应"推向了"可编程工作流引擎"。


本文基于公开可获取的源码快照进行纯技术架构分析,仅用于学习和研究目的。原始 Claude Code 源码版权归 Anthropic 所有。

「真诚赞赏,手留余香」

爱折腾的工程师

真诚赞赏,手留余香

使用微信扫描二维码完成支付