Skip to content

MewCode 扩展机制深度分析(MCP / Skill / Hook / SubAgent / Task) ​

分析对象:mewcode/internal/{mcp,skills,hook,subagent,task},并追溯其在 cmd/mewcode/main.go、internal/agent/*、internal/tui/*、internal/tool/* 中的集成点。 所有结论均来自实际源码阅读;无法从源码确认的一律标注「源码未体现」。


模块职责 ​

子模块一句话职责要点
internal/mcpMCP 协议客户端:配置归一化 → 并发建连 → 远端工具适配为内置 tool.Tool3 文件 557 行;两层配置合并、${VAR} 展开、stdio/http 双传输、mcp__ 命名空间、单 server 失败隔离
internal/skillsSkill(能力包)系统:SKILL.md 解析、两级目录 Catalog、激活态管理、inline/fork 执行、远程安装、渲染注入8 文件 1050 行;两阶段渐进式披露(catalog 摘要 → body 全文)、ActiveSkills 跨轮常驻、GitHub 递归安装带四重配额
internal/hookAgent 生命周期挂钩引擎:11 事件 × 条件表达式 × 4 类动作,YAML 声明式7 文件 1078 行;复用 permission.Matcher 做条件匹配;shell exit 2 / HTTP {"decision":"block"} 表达拦截;only_once + async + timeout
internal/subagentAgent 角色(Definition)的解析、多来源 Catalog 与内置 embed4 文件 361 行;Markdown + YAML frontmatter;builtin(embed) → user → project 三层覆盖;SourcePlugin 占位
internal/task后台子 Agent 任务管理:启动、状态追踪、取消、完成通知、续派消息2 文件 562 行 + 4 个内置元工具(TaskList/TaskGet/TaskStop/SendMessage);纯内存、done channel 通知、SendMessage 续跑同一 Conversation

依赖方向(避免环):task → agent → subagent/hook/skills;subagent 通过 agent.AgentCatalog 接口反向抽象(agent_tool.go),task 通过 agent.TaskManager 接口反向抽象。skills 与 hook 均依赖 permission(后者复用 Matcher);skills 通过 adapter.go / ActiveSkillEntry 结构体镜像来桥接 prompt 包,避免 prompt ↔ skills 循环依赖。


MCP 客户端 ​

两种传输的建立 ​

mcp.NewManager(ctx, cfg, version)(manager.go:79)为每个 server 起一个 goroutine,sync.WaitGroup 汇总后返回:

  • stdio:exec.CommandContext(ctx2, srv.Command, srv.Args...),cmd.Env = mergeOSEnv(srv.Env)(宿主 os.Environ() 全量继承 + 额外 env 覆盖同名键),cmd.Stderr = os.Stderr 直通宿主,包装为 sdkmcp.CommandTransport{Command: cmd}。
  • http:自建 http.Client{Transport: &headerRoundTripper{base: http.DefaultTransport, headers: srv.Headers}},RoundTrip 内 req.Clone(req.Context()) 后逐条 Header.Set;包装为 sdkmcp.StreamableClientTransport{Endpoint: srv.URL, HTTPClient: hc, DisableStandaloneSSE: true} —— 即 Streamable HTTP,且显式关闭独立 SSE 通道(只用 POST + 可选的流式响应)。

客户端标识为 sdkmcp.Implementation{Name: "mewcode", Version: version},client.Connect(ctx2, transport, nil) 建立会话。

保活 ​

没有任何应用层保活:全包 grep 无 Ping / 心跳 / reconnect / Reconnect 逻辑。存活依赖 SDK 会话自身与 HTTP/stdio 传输:stdio 子进程由会话持有的 exec.Cmd 生命周期决定(进程退出即会话失效,错误在下次 CallTool 时暴露);HTTP 会话每次 CallTool 重新发请求。没有健康检查、没有断线重连、没有运行期 reload —— 配置在 main.go:76 一次性加载,想增删 MCP server 必须重启进程(源码未体现热加载)。

关闭路径:Manager.Close() 拷贝 sessions 快照后并发 cs.Close(),用 done channel + time.After(closeDeadline) 做兜底,超时后不再等待(避免退出时卡死)。main.go 中 defer mgr.Close()。

配置两层合并规则 ​

  • 用户级:~/.mewcode/config.yaml
  • 项目级:<root>/.mewcode.yaml

loadFile 对不存在的文件返回空结构 + nil;YAML 解析失败时 LoadConfig 打 stderr 告警并把该层降级为空层(userRaw = rawConfig{})。LoadConfig 签名返回 error 但实现恒为 nil(注释明确写「当前实现恒为 nil」,签名仅为未来扩展)。

mergeServers(user, project) 是整体覆盖语义:先铺用户层,再用项目层按 key 覆盖,不做字段级深合并——即项目级只要声明了同名 server,用户层的 command/args/env 全部丢弃,不会继承。

validateServer 是白名单式校验:

  • type 必须为 "stdio" 或 "http",空值报 missing type field;
  • stdio 必须有 command;http 必须有 url;
  • 任一不满足 → stderr 告警 + 跳过该 server(不阻断其他 server)。

${VAR} 展开安全 ​

  • 正则:varPattern = regexp.MustCompile(${([A-Za-z_][A-Za-z0-9_]*)}),只认 ${VAR_NAME}(字母/数字/下划线),不支持 $VAR、${VAR:-default}、嵌套、命令替换等 shell 语法 → 结构上无法构造命令注入。
  • 展开范围:applyExpansion 只对 env 与 headers 的 value 展开;command / args / url 不展开(docs/mcp/mcp-servers.example.yaml 注释亦确认)。这让 args 保持字面量,避免参数被环境变量劫持。
  • 未定义变量:替换为空串(而非保留原文或报错),并用 collectUndefined 去重后一次性 stderr 告警 [mcp] warn: undefined env var ${X} referenced by server Y。风险是静默降级(例如 Authorization: "Bearer " 变成空 token),调用失败只能在运行期表现为 401。
  • 展开发生在合并之前(对每层各自展开),保证项目层与用户层互不污染。

工具如何转换为内置 Tool 接口 ​

adaptTool(serverName, t, cs)(tool.go:110):

  • 命名空间:fullName = "mcp__" + serverName + "__" + t.Name(remoteName 保留原名用于 CallTool)。
  • 名字合法性:validToolName = ^[A-Za-z0-9_-]+$,不匹配则 stderr 告警 + 跳过(防止远端塞入奇怪字符破坏 registry/LLM function-calling 约束)。
  • 描述:空则兜底 "来自 MCP server <s> 的工具 <t>"。
  • Schema:InputSchema 经 json.Marshal → json.Unmarshal 转为 map[string]any 原样透传;为空时兜底 {"type":"object"}。
  • ReadOnly():严格只信 t.Annotations.ReadOnlyHint == true(readOnly := t.Annotations != nil && t.Annotations.ReadOnlyHint)。这一点很关键:permission.categorize(name, readOnly) 对 readOnly=true 归 CategoryRead(只读并发批次),其余全部落到 default: CategoryExec(最严,需要 Ask/规则放行)。即 MCP 工具默认按「命令执行」对待。
  • Execute(ctx, args):context.WithTimeout(ctx, 30*time.Second)(硬编码,区别于包级可改的 connectTimeout);json.Unmarshal 失败 → Result{IsError:true};CallTool 错误 → 结构化 IsError:true;返回的 Content 只拼接 *sdkmcp.TextContent,非 text 块(image/resource 等)静默丢弃并用 sync.Map nonTextWarnOnce 保证每个工具只告警一次;res.IsError 直接映射到 Result.IsError。

注册:main.go:79 把 mgr.Tools() 逐个 reg.Register(t),全量注入全局 registry——没有任何 MCP 工具级的 allow/deny 过滤或按需检索。

连接超时 / 关闭兜底常量 ​

go
var (
    connectTimeout = 30 * time.Second // 单 server 建连+ListTools 的总预算,包级 var 便于单测改短
    closeDeadline  = 5 * time.Second  // Close() 总兜底
)

另有 tool.go 内联的调用超时 30 * time.Second(非包级常量,无法配置)。

单 server 失败隔离如何实现 ​

隔离依赖三点:

  1. 每 server 一个 goroutine,wg.Wait() 只等所有 goroutine 结束,不传播 panic/error;
  2. context.WithTimeout(ctx, connectTimeout) 只约束该 server 的 Connect + ListTools,一个 server 慢/挂不会拖累其他 server(除 wg.Wait() 汇总点最多等 30s);
  3. 失败路径全部 return,只有成功路径才写共享状态:mgr.mu.Lock(); mgr.sessions = append(...); mgr.tools = append(...)。ListTools 失败时还会 _ = cs.Close() 先释放半开连接,避免泄漏。

Tools() 在锁内返回切片拷贝「防外部修改」;NewManager 末尾按 Name() 排序,保证工具顺序确定(利于 prompt cache 命中)。失败信息仅 fmt.Fprintf(os.Stderr, ...),不做重试、不做降级 register 占位工具。


Skill 系统 ​

SKILL.md 的 frontmatter 字段 ​

SkillMeta(types.go:6)与 YAML 一一对应:

字段类型语义校验/缺省
namestring唯一标识,用于 /<name> 命令与 LoadSkill 查找必填;含空格或 / 直接报错;parseSkillMD 强制 strings.ToLower
descriptionstring一句话说明,注入第一阶段 system prompt可为空(则不再有描述)
allowed_tools[]string工具白名单可空 = 不限制;启动时由 Catalog.ValidateTools 校验存在性
modestringinline / fork空或非法值 → 降级 inline + log.Printf 警告
fork_contextstringnone / recent / full非法值 → 降级 none(注意:该字段被解析但执行路径未消费,见下)
modelstringfork 模式指定模型解析保留,但 executor.go 未使用(源码未体现消费点)

splitFrontmatter 的边界:不支持前导空白(--- 必须第一列);闭合 --- 必须独占一行;无 frontmatter 时正文即全文;文件末尾 \n--- 也被接受(此时 body 为空)。注释自陈局限:YAML 多行字符串内含 \n---\n 会误判闭合。

渐进式披露(两阶段) ​

  • 阶段 1 — catalog 摘要只注入 system prompt:main.go/TUI 构造 skills.LoadCatalog(cwd);agent.Agent 经 WithCatalog 持有;每轮构建系统提示时 catalog.ToPromptItems() → prompt.RenderSkillsCatalog,产出:

    ## 可用 Skill(调用 LoadSkill 工具激活)
    
    以下 Skill 可通过 LoadSkill 工具按需激活。激活后 SOP 将钉在环境上下文最显眼位置。
    
    - **/<name>**: <description>

    只有 name + description,不含正文,因此 token 成本与 Skill 数量线性但系数极小。

  • 阶段 2 — 全文按需激活:模型调用 LoadSkill 工具(或用户输入 /<name>)→ Executor.RenderAndActivate(name,args) → catalog.GetFull(name)(强制重读磁盘上的 SKILL.md body,用于热重载:改文件即刻生效;重读失败则回退缓存 + log.Printf("[skills] debug: ..."))→ RenderBody 渲染 → host.ActivateSkill(name, body)(Agent 实现,写入 runtime.ActiveSkills)。

    已激活 body 每轮注入的是 env context 而非 system prompt:run_to_completion.go:141-150 / agent.go:268-275 里 envText = envText + "\n\n" + prompt.RenderActiveSkillsBlock(entries),产出 ## Active Skills + ### Skill: <name> + body,并口头声明「其 SOP 指令优先于通用系统指令」。这样 system prompt 前缀保持稳定(prompt cache 友好),代价是「优先级」只是提示而非机制约束。

  • 激活态生命周期:ActiveSkills 存在于 SessionRuntime,跨轮常驻;Activate 对同名重复激活是覆盖原位置(保持首次激活顺序,names map[string]int 记索引);/clear、/resume 走 runtime.ResetForNewSession → ActiveSkills.Clear()。

  • 并发隐患:Catalog.Get/GetFull/List 返回的是 byName 里同一个 *Skill 指针,而 GetFull 在仅持 RLock 的情况下通过 loadSkillBody(s) 写 s.PromptBody —— 多协程同时激活不同 Skill 时对同一结构体存在数据竞争窗口(go test -race 未覆盖此路径)。生产做法应是 copy-on-read 或返回快照。

执行模式(inline / fork) ​

Executor.Execute(executor.go:35)按 skill.Meta.Mode 分流:

  • inline(默认):body = RenderBody(skill, args) → e.host.ActivateSkill(skill.Meta.Name, body) → 返回 (isFork=false, body, "", nil);调用方(command/skills.go 的 makeSkillHandler)用 ui.InjectAndSend("/"+name, body) 把 body 当一条 user message 注入并发起一轮对话。
  • fork:runFork 新建 conversation.New() + AddUser(body),然后 forkHost.RunSubAgent(ctx, forkConv, skill.Meta.AllowedTools),返回 (isFork=true, "", finalText, err),由调用方把 finalText 当 assistant 侧结果呈现。

关键实现现状(必须诚实标注):

  1. SkillForkHost 接口在 executor.go:17 定义,但全仓无任何实现(grep RunSubAgent 只命中接口与 runFork 调用点)→ fork 路径实跑必然返回 "fork mode 需要 SkillForkHost,但未提供"。
  2. command/skills.go 对 KindSkillFork 直接 return fmt.Errorf("fork skill %q 暂不支持通过命令直接调用,请使用自然语言触发 LoadSkill"),且 makeSkillHandler 调用 exec.Execute(ctx, name, "", nil) 时 forkHost 传 nil。
  3. 通过自然语言走 LoadSkill 时,LoadSkillTool 调用的是 RenderAndActivate,忽略 mode,一律 inline 激活。

结论:fork 模式的解析、目录、AllowedTools 传递链路都已就绪,但执行侧未接线;fork_context(none/recent/full)在整个代码库中没有消费点。

工具白名单 ​

  • 声明层:allowed_tools。Catalog.ValidateTools(toolExists) 在 TUI 初始化时以 registry.Get 作为 lookup 遍历全部 Skill,不存在的工具名 stderr 告警 [skills] warn: Skill %q 引用了不存在的工具 %q。
  • 渲染层(软约束):RenderBody 在白名单非空时于 body 顶部插入: This skill is designed to use only these tools: a, b. Prefer them over other tools when possible.\n\n---\n\n 即 inline 模式下白名单只是提示词,不产生 registry 级过滤。
  • 执行层(硬约束,仅 fork 路径):runFork 把 skill.Meta.AllowedTools 透传给 SkillForkHost.RunSubAgent,由宿主收窄(当前无实现)。
  • 工具侧豁免:LoadSkillTool.IsSystem() 返回 true,Registry.DefinitionsFiltered 对系统工具无条件豁免白名单(registry.go:108)——所以 allowed_tools 再窄也不会锁死 Skill 激活能力。LoadSkillTool 也标 ReadOnly() = true,可进只读并发批次。

安装流程(install.go) ​

入口 tool.InstallSkillTool(internal/tool/install_skill.go,非系统工具,ReadOnly()=false,受权限引擎约束):

  1. skills.ParseSkillURL(url) 按 host 分派三种来源:
    • skills.sh / www.skills.sh:https://skills.sh/<org>/<name>/<version> → skillName = parts[1],apiURL = https://api.github.com/repos/<org>/<name>/contents/skills/<name>(走 GitHub Contents API 作后端;version 段被忽略,源码未体现版本锁定)。
    • github.com:/<owner>/<repo>/tree/<ref>/<path> → skillName = 路径最后一段,apiURL = https://api.github.com/repos/<owner>/<repo>/contents/<path>?ref=<ref>(要求 parts[2] == "tree")。
    • raw.githubusercontent.com:skillName = filepath.Base(strings.TrimSuffix(u.Path, "/SKILL.md")),apiURL 即原 URL。
    • 其他 host → 明确报错「不支持的 Skill URL host」。
  2. skills.Install(name, apiURL, installRoot):
    • os.MkdirTemp("", "mewcode-skill-*") 建 staging 目录(defer os.RemoveAll(staging));
    • fetchGitHubDir 递归 Accept: application/vnd.github.v3+json + User-Agent: mewcode;403 特判为 rate limit 并附 body 前 1KB,404 明确报错;
    • 四重配额:maxInstallFileSize = 1<<20(单文件 1 MiB,先按 API 返回的 size 预检,下载再用 io.LimitReader 二次截断)、maxInstallTotalSize = 8<<20(累计 8 MiB,递归前/中双重检查)、maxInstallFileCount = 64、maxInstallDepth = 4(递归深度)、installHTTPTimeout = 60s(http.Client{Timeout});
    • 必须含 SKILL.md:os.Stat(staging/SKILL.md) 不存在 → "下载内容不含 SKILL.md,拒绝安装";
    • 原子替换:os.Rename(staging, targetDir)(先 MkdirAll(filepath.Dir(targetDir));同名旧目录先 RemoveAll)。注意 os.Rename 只在同文件系统内原子,os.MkdirTemp("") 落在 $TMPDIR,跨设备时会 EXDEV 失败(当前代码未处理 → 生产需先 copy 到目标同盘的 .tmp)。
    • installRoot 硬编码为用户级 ~/.mewcode/skills(install_skill.go 内 filepath.Join(home, ".mewcode", "skills")),不支持安装到项目级。
  3. 安装后:catalog.Reload(workDir) → onInstalled(name) 回调(TUI 里是 command.RegisterSkillsAsCommands(cmdReg, cat, exec),把新 Skill 注册为 /<name> 命令),返回 "Skill %s 安装成功!%d 个文件已安装到 %s。输入 /%s 即可使用。"。

校验强度诚实评估:无签名、无内容哈希、无 SKILL.md frontmatter 预校验、无依赖/脚本扫描;SKILL.md 里写的 shell 命令最终由 Agent 通过 Bash 工具执行——等价于「装 Skill = 授予执行任意指令的输入源」。同时只允许 GitHub 域(ParseSkillURL 白名单 host),算是把攻击面收窄到 GitHub 内容信任。

渲染时如何注入提示 ​

RenderBody(skill, args) 三步(render.go):

  1. 白名单提示插到 body 顶部(见上);
  2. $ARGUMENTS 占位符 strings.ReplaceAll 全量替换为 args;
  3. 无 $ARGUMENTS 但 args 非空 → 末尾追加 "\n\n## User Request\n\n" + args。

注入位置:inline 走 TUI 的 InjectAndSend(作为 user message);已激活态走每轮 envText 的 ## Active Skills 块(在 user 消息之前的 environment 文本区)。TUI 侧 /<name> 命令调用时 args 恒为空串(exec.Execute(ctx, name, "", nil)),参数化调用只在 fork/自然语言路径存在(源码未体现 slash 命令传参)。

Skill 存放路径与工程约定 ​

代码实际扫描:~/.mewcode/skills/(SourceUser)→ <workDir>/.mewcode/skills/(SourceProject,后扫覆盖同名),只认「目录下有 SKILL.md」(parseSkillDir 拼 filepath.Join(dir, "SKILL.md")),单目录内文件被忽略。

与工作区 CLAUDE.md 的约定不一致:CLAUDE.md 要求写入 .agents/skills/ 并软链 .claude/skills/,而 LoadCatalog 只扫 .mewcode/skills/(全仓 grep agents/skills、.claude/skills 零命中)。若要在本项目里生效,需要在 .mewcode/skills/ 下再建软链,或后续为 catalog 增加可配置扫描根。


Hook 生命周期 ​

所有事件类型(event.go) ​

allEvents 共 11 个,注释里逐个写明了派发时机:

事件时机可拦截
SessionStart启动进入会话 / /clear 新建会话后、首条 user 消息之前否
SessionEnd进程关闭前、/clear 关闭旧会话前、/resume 离开旧会话前否
SessionResume/resume 恢复完成后、首条 user 消息之前否
UserPromptSubmitTUI 提交非 Slash 的 user 消息、写入对话历史之前是
StopAgent.Run 自然停止后、Done: true emit 之前否
PreUserMessage每轮 streamOnce 调 provider.Stream 之前否
PreToolUseexecuteBatched 每条 tool call 执行前、权限引擎 Check 之前是
PostToolUse单条 tool call 拿到 result 之后、emit PhaseEnd 之前否
PreCompactcompact.ManageContext 之前否
PostCompactcompact.ManageContext 返回后否
Notification权限 Ask 弹出审批时、Stream 返回 error 时否

blockingEvents = {PreToolUse, UserPromptSubmit};IsBlocking(e) 供引擎与 loader 共用(loader 用它拒绝 async + 拦截事件)。

派发点核对(含一处未接线):

  • agent.Agent.Run / RunToCompletion:PreUserMessage、PreCompact、PostCompact、PreToolUse、PostToolUse、Notification(agent.go:320 只在 sErr != nil 时以 kind=stream_error, detail=err 派发)、Stop(自然停止 / 未知工具超限 / 触达 maxIterations 三处)。
  • tui/hooks.go:SessionStart(Model.Init)、SessionEnd(/clear、退出)、UserPromptSubmit;cmd/mewcode/main.go:150 还有一处进程退出前的 SessionEnd 兜底。
  • EventSessionResume 只出现在 event.go(定义 + allEvents),全仓无派发点 → 当前是「已定义未实现」状态(/resume 路径只走 runtime.ResetForNewSession + hookEngine.ResetForNewSession)。

Payload 通用字段由 agent.basePayload / tui.baseHookPayload 构造:event、session_id、cwd、mode。其中 agent.basePayload 的 session_id 恒为空字符串(代码里 sessionID = "" + _ = sessionID // TODO: 对接 Session 的 ID 字段),而 TUI 侧能正确取到 m.runtime.Session.SessionID —— 即 Agent 侧事件(Pre/PostToolUse 等)在 payload 里拿不到 session_id。

事件特定字段:prompt(UserPromptSubmit)、tool_name + tool_input(Pre/PostToolUse)、tool_result + is_error(PostToolUse)、trigger + before_tokens + after_tokens(PostCompact)、kind + detail(Notification)、iter(Stop)。

匹配器(matcher.go) ​

  • GetByPath(payload, "tool_input.command"):按 . 逐层下钻 map[string]any;任一层为 nil/缺键/非 map → 返回空串 ""(而不是报错),意味着 not 匹配器在字段缺失时会「因为空串不匹配内层」而恒真——写规则时需注意。终值转换:string 原样、bool/float64/其他走 fmt.Sprint、map[string]any / []any 走 json.Marshal(嵌套对象以 JSON 字符串参与匹配)。

  • EvalCondition(c, p):c == nil → true(无条件触发);len(Atoms) == 0 → true;CombineAllOf 全部满足(Matcher == nil 的原子被 continue 跳过,等效放行);CombineAnyOf 任一满足;default → false(保守拒绝触发)。

  • Matcher 实现直接复用 permission.CompileMatcher(pattern, isCommand=false):

    • exact → "=" + value → matcherExact
    • regex → "~" + value → matcherRegex(Go regexp.Compile,编译失败即规则编译失败)
    • glob → 原值 → matcherGlob(isCommand=false,即 glob 按文件路径语义展开 / 与 **)
    • not → "!" + inner.String() → matcherNot

    compileMatch 注释明确提示了后果:hook 场景下 glob 走路径语义,要匹配 Bash 命令串请用 exact 或 regex。这是「复用一个实现」换来的语义陷阱。

规则格式(YAML 字段) ​

顶层 hooks: 数组,逐条:

yaml
hooks:
  - name: block-write            # 必填,日志/only_once/冲突检测键
    event: PreToolUse            # 必填,须在 11 个枚举内
    if:                          # 可选,nil = 无条件
      all_of:                    # 与 any_of 互斥;两者都空 → 编译失败
        - field: tool_name       # 必填,点路径
          match:
            type: exact          # exact | regex | glob | not
            value: write_file
            # inner: {...}       # 仅 not 使用,递归同结构
    action:
      type: shell                # shell | prompt | http | subagent
      command: "..."             # shell 必填
      # text: "..."              # prompt 必填
      # url/method/headers/body # http 必填 url;method 缺省 POST;body 可模板
      # agent_name/prompt       # subagent 必填两者
    only_once: false             # 会话内只跑一次
    async: false                 # 后台异步;拦截类事件禁止 true
    timeout: "30s"               # time.ParseDuration;非法即编译失败;缺省 30s

编译期校验(compileRule / compileAction / compileIf / compileMatch)逐条拦截:name 空、event 未知、action.type 未知或必填子字段缺失、if 同时给 all_of+any_of、if 两者都不给、atom 的 field 空、match 的 value 空、not 缺 inner、timeout 解析失败、async && IsBlocking(event)。任何编译失败只跳过该条规则并 stderr 输出,不阻断 Load 返回。

执行器如何跑命令 ​

Executor.Run 按 Action.Type 分发(executor.go:38),blocking 参数决定「是否解释拦截信号」:

  • shell:exec.CommandContext(ctx, "sh", "-c", cmd),payload 序列化为单行 JSON 经 stdin 传入(marshalSorted,Go json.Marshal 天然按 key 字典序),stdout/stderr 分别缓冲。超时用 context.WithTimeout(ctx, timeout)(timeout=0 → 30s)。判定顺序:
    1. ctx.Err() != nil → Err: "timed out after <d>"(超时算 hook 失败,不算拦截);
    2. *exec.ExitError 且 blocking && code == 2 → Blocked: true,Reason = strings.TrimSpace(stderr + stdout),为空则 "blocked by hook (exit code 2)";
    3. code == 0 → 放行(ExecutionResult{});
    4. 其他非零 → Err: "exit %d: <stderr>"(hook 失败,不拦截);
    5. 非 ExitError(启动失败)→ Err: err。
  • prompt:ExecutionResult{Prompt: pa.Text} —— 纯数据,无副作用。
  • http:body 空 → JSON payload;非空 → text/template 渲染(注释写明「只支持最基本字段访问,不开放函数调用」,但实际是裸 template.New("hook").Parse,并未显式清空 FuncMap,只是模板按 payload 作为 data 执行;生产上应显式限制);method 缺省 POST;headers 逐条 Set,未设 Content-Type 时补 application/json;拦截判定仅当 blocking 且状态码 2xx:解析 body 为 map[string]any,decision == "block" → Blocked,reason 空则 "blocked by http hook";JSON 解析失败 → Err(hook 失败,不拦截)。非 2xx 一律放行。
  • subagent:占位实现 —— fmt.Fprintf(os.Stderr, "[hook subagent] not yet implemented, skipped: %s\n", ...) 后返回空结果。SubagentAction 结构与 YAML 校验(要求 agent_name + prompt)都已就绪,但执行未接线。

超时与输出如何处理 ​

  • 超时来源:Rule.Timeout(YAML,缺省 30s)→ shell/http 各自的 context.WithTimeout;HTTP 还额外套了一层 http.Client{Timeout: timeout}(双保险)。Executor 结构体里那个 httpClient 字段(Timeout: 30s)实际在 runHTTP 中被局部新建的 client 覆盖,属于遗留死字段。
  • 输出不被 Agent 消费:shell 的 stdout/stderr 只在「拦截」时拼进 Reason,成功时完全丢弃(不注入上下文、不记日志)。http 的响应体除 decision 外同样丢弃。prompt 动作的 Text 是唯一进入 LLM 上下文的产物:DispatchResult.InjectedPrompts → agent.dispatchHook 里 runtime.AppendReminders(...) → PendingReminders → 下一轮 buildReminder 取用(TakeReminders 取出即清空)。

能否阻断主流程 / 退出码语义 ​

能,但只在两个拦截类事件上,且阻断是「工具级」而非「循环级」:

  • 引擎层:Dispatch 遍历按 YAML 声明序的规则,命中拦截后 break —— 首个表达 Blocked 的规则即中断同事件的后续规则(engine.go:102-108)。
  • Agent 层:PreToolUse 的 hr.Blocked 被转成工具结果 hookBlockedResult(call.ID, hr.BlockingHookName, hr.Reason)(IsError: true,内容形如 [hook <name>] <reason>),跳过权限引擎 Check,不执行工具;Agent 循环继续,模型看到错误结果后自行决定改道。只读批次与串行批次(executeBatched 两条分支,agent.go:550 / agent.go:665)都做了同样处理,并保证 Start/End 事件的 emit 顺序。
  • TUI 层:dispatchUserPromptSubmit 返回 (blocked, reason, blockingHookName) 交由 TUI 决定是否吞掉该条 user 消息(TUI 侧处理细节不在本次阅读范围,源码未体现其 UI 呈现)。

退出码语义汇总:

  • 0 → 放行(无论 stdout 内容);
  • 2 → 仅拦截类事件下表达拦截,原因取 stderr+stdout 合并去尾换行;
  • 1/其他非零 → hook 自身失败,stderr 打 [hook <name>] <event> failed: ...,放行;
  • 超时 → hook 失败,放行;
  • 未启用 blocking(非拦截事件)时 exit 2 也走「其他非零 → 失败放行」分支。

async 的语义边界:Dispatch 对 rule.Async 起 go func 并立即 markOnce 后 continue,实测三点后果:① async 结果不进 InjectedPrompts、不进 Blocked(hook/e2e_test.go 专门断言此行为);② 传入的是 context.Background(),脱离父 ctx 取消,Agent 退出后仍可能继续跑;③ loader 已在编译期禁止 async 用于 PreToolUse/UserPromptSubmit,所以「异步拦截」这个矛盾组合不会出现。

加载与优先级:Load(projectRoot) 的 candidates 顺序是 项目级 <root>/.mewcode/hooks.yaml → 用户级 ~/.mewcode/hooks.yaml,两层规则叠加(rules = append(...),不是覆盖),仅当 name 冲突时 seenNames 拒绝后加载者并 stderr 提示 name conflict with previously loaded hook, skipped。注意这与 MCP 的「后层整体覆盖」策略方向相反,是两套扩展体系里一处不一致的语义。

only_once 由 Engine.onceFired map[string]bool 记录(markOnce 只在 OnlyOnce 为真时写),ResetForNewSession 在 /clear、/resume 时清空(由 runtime.ResetForNewSession 调用)。Rules() / Sources() 暴露给 /hooks 命令展示。


SubAgent 机制 ​

定义文件格式与存放目录 ​

Markdown 文件 + YAML frontmatter(parser.go),body 即该角色的 SystemPrompt。加载顺序与覆盖(LoadCatalog):

  1. builtin://go:embed builtin/*.md(embed.go),解析失败直接 panic(注释:内嵌文件是代码的一部分,构建期错误即灾难);跳过文件名以 . 开头的条目;
  2. user:~/.mewcode/agents/*.md;
  3. project:<root>/.mewcode/agents/*.md;
  4. plugin:SourcePlugin 占位,本期不实现(LoadCatalog 注释「4. 插件级:本期跳过」)。

addAll 是同名后者覆盖前者(c.defs[d.Name] = d),即 project > user > builtin;bySource 保留每层副本供 /agents 展示。目录扫描只取 filepath.Ext(name) == ".md" 且非目录的条目;单个文件失败 stderr 告警 + 跳过,不阻断启动。Resolve(name) 大小写敏感(直接 map 查,无 ToLower;而 skills 的 Catalog.Get 是大小写不敏感的 strings.ToLower —— 两套体系又一处不一致)。

frontmatter 字段(agentFM):

字段校验缺省
name必填,agentNameRegex = ^[A-Za-z][A-Za-z0-9\-_]{0,31}$(注释说明「允许大写以兼容 Explore/Plan 等内置角色名」,与 spec 文字的「小写」不一致)—
description必填—
tools白名单,空 = 不收窄空
disallowedTools黑名单空
model""/inherit/haiku/sonnet/opus;非法 → 警告 + 降级 inheritinherit
maxTurns负数归零0 = 用全局 maxIterations(25)
permissionModedefault/acceptEdits/plan/bypassPermissions/dontAsk;非法 → 警告 + defaultdefault
background强制后台false

dontAsk 是子 Agent 专属:解析为 PermissionMode = ModeDefault + DontAsk = true(自动批准所有规则未命中的工具),作为独立布尔透传到 agent.WithDontAsk。

frontmatter 解析:parseFrontmatterAndBody 与 skills/parser.go 的 splitFrontmatter 逻辑几乎重复(注释自陈「独立实现一份以避免循环依赖」),差异是 subagent 版本多做了 UTF-8 BOM 剥离。

内置(embed)了哪些 ​

3 个(internal/subagent/builtin/):

文件name关键 frontmatterbody 要点
explore.mdExploredisallowedTools: [write_file, edit_file]、model: haiku、maxTurns: 30只读文件搜索专家;禁止创建/修改/删除;Bash 仅限只读(ls/git log/find/cat);鼓励并行工具调用
plan.mdPlandisallowedTools: [write_file, edit_file, Agent]、maxTurns: 15、permissionMode: plan软件架构师;只读规划;输出分步计划,末尾必须列 3-5 个最关键文件路径
general-purpose.mdgeneral-purposemaxTurns: 30(无白/黑名单)通用子 Agent,全工具;要求简洁报告

(注意 Plan 的 disallowedTools 里带 Agent 是冗余的防御性写法——Agent 对所有子 Agent 都已被 ALL_AGENT_DISALLOWED_TOOLS 禁用。)

如何被主 Agent 调用 ​

唯一入口是 Agent 工具(internal/agent/agent_tool.go,TUI 里 registry.Register(agentTool),仅在 subAgentCatalog != nil && taskMgr != nil 时注册):

  • Schema 参数:prompt(必填)、description(必填)、subagent_type、model(enum haiku/sonnet/opus/inherit)、run_in_background、name。
  • Description() 动态生成:列出 catalog.List() 的全部角色名(可用的 subagent_type: Explore, Plan, general-purpose),并说明「不传 subagent_type 则为 Fork 模式(继承父对话历史)」。
  • 执行流程(Execute):
    1. 参数校验(prompt/description 必填,parent 必须已注入,否则 "Agent 工具未就绪(parent 未注入)");
    2. subagent_type 非空 → catalog.Resolve,找不到则报 未知 subagent_type;为空 → catalog.ForkDefinition()(Name = "__fork__",IsFork() 判定,MaxTurns: 25,Model: inherit,Tools/DisallowedTools 留空 = 继承父工具集);
    3. background = def.Background || aArgs.RunInBackground || isFork(Fork 无条件后台);后台被配置禁用(bgEnabled=false,硬编码 true)→ 直接报错;
    4. 工具过滤:tool.ApplyAgentToolFilter(tool.FilterParams{...});
    5. 构造子 Agent(agent.New)与子 Conversation;
    6. 后台 → taskMgr.Launch 返回 {"task_id":..,"status":"async_launched"};前台 → 带 120s 超时跑完。

已被解析但未生效的字段(重要面试点):aArgs.Model 与 def.Model 在 agent_tool.go 中无任何消费点——子 Agent 用的是 t.parent.provider(New(t.parent.provider, ...)),没有 provider 选择/映射逻辑。所以 model: haiku 目前只是元数据。

上下文隔离方式 ​

  • 独立 Agent 实例:agent.New(parent.provider, parent.registry, parent.version, parent.eng, opts...),opts 包含 WithRuntime(&SessionRuntime{ContextWindow: parent.runtime.ContextWindow})(上下文窗口继承父,其余 compact 子状态全新)、WithAllowedTools(allowed)、WithSystemPrompt(def.SystemPrompt)、WithMaxTurns(def.MaxTurns)、WithPermissionMode(...)、WithDontAsk(...)、WithHookEngine(parent.hookEngine)(hook 引擎共享,子 Agent 的工具调用同样会触发 PreToolUse/PostToolUse hook)。
  • 独立 Conversation:
    • 角色路径(subagent_type 指定)→ conversation.New(),空对话起步,只有 RunToCompletion 里 conv.AddUser(task) 的那一条任务消息;
    • Fork 路径 → BuildForkedMessages(parentMsgs, prompt)(agent/fork.go):① cloneMessages 深拷贝(含 ToolCalls/ToolResults 切片);② fixPendingToolCalls 给末尾悬空 tool_use 补 placeholder ToolResult(Content: "[forked, skipped]"、IsError: true)以保证消息格式合法;③ 追加 user 消息 = ForkBoilerplate + task。boilerplate 用 <fork_boilerplate> 标签包裹,硬约束「不能再 Fork、不要提问、直接干活、限定范围、报告以 Scope: 开头且 500 字内」;IsForkContext(msgs) 作为 caller 链丢失时的兜底检测。
  • 系统提示隔离的细节:RunToCompletion 先 prompt.BuildSystemPrompt(instructionText, memoryText, skillsCatalogText) 组好系统提示,紧接着 if a.systemPrompt != "" { sys = a.systemPrompt } —— 即有自定义 body 时整段替换,不会再注入 instruction/memory/Skill catalog。所以内置角色的子 Agent 看不到 Skill 目录(也就无法用 LoadSkill),这是一处明确的设计取舍而非 bug。
  • 工具隔离五层(tool/filter.go,spec F30):① 起点 = parent.registry.Names();② 去掉 ALL_AGENT_DISALLOWED_TOOLS(当前仅 Agent,禁止递归 fork);③ 若后台 → 与 ASYNC_AGENT_ALLOWED_TOOLS(read_file/write_file/edit_file/glob/grep/bash/load_skill/install_skill)取交集,且 isAsyncAllowed 对 mcp__ 前缀工具动态放行;④ 去掉 disallowedTools;⑤ 有 tools 白名单则取交集。CUSTOM_AGENT_DISALLOWED_TOOLS 是空数组占位(接口预留)。
    • 风险点:过滤是按工具名做的,Explore 只禁了 write_file/edit_file,不影响 mcp__* 写类工具(如 GitHub 建 issue 的 MCP 工具);后台白名单却放行 write_file/bash —— 后者的「最小权限」语义主要靠 ASYNC_AGENT_ALLOWED_TOOLS 的静态列表维护。

结果如何回传 ​

  • 前台:subAgent.RunToCompletion(timeoutCtx, subConv, aArgs.Prompt, events) 返回 finalText(实现为 lastAssistantText(conv) 或 ensureFinal 包装),直接塞进 tool.Result{Content: finalText} 由 Agent 工具框架回灌给主对话。子 Agent 的中间事件通过 events chan Event(容量 32)透传,drainEvents/emitEvent 全是非阻塞发送(select { case ch <- ev: default: }),慢消费者只会丢事件不会阻塞执行。
  • 超时切入后台:if timeoutCtx.Err() != nil → taskMgr.AdoptRunning(ctx, subAgent, subConv, name, events, cancel),返回 {"task_id":..,"status":"timed_out_to_background"} —— 前台跑不完的任务被接管为后台任务且不丢进度。
  • 后台:Manager 记录 Result,完成后把 id 推入 done channel;TUI consumeTaskDone 构造:
    <task-notification>
    Task <id> (name="<name>"): <status>[Error: ...]
    Result: <result>
    </task-notification>
    追加到 runtime.AppendReminders,从而在下一轮以 reminder 形式进入主 Agent 上下文。

并发 / 轮次上限 ​

  • 轮次:turns := a.maxTurns; if turns == 0 { turns = maxIterations },maxIterations = 25(agent.go:150);内置角色分别覆写为 30/15/30;ForkDefinition().MaxTurns = 25。触达上限 → ErrMaxTurnsReached("达到最大轮数")。
  • 未知工具防护:子 Agent 用 maxUnknownRunSub = 2(比主 Agent 的 maxUnknownRun = 3 更严);连续 N 轮「整轮只产生未知工具调用」→ 返回 "连续多轮只产生未知工具调用"。
  • 单次前台时长:autoBackgroundDuration = 120 * time.Second(包级 var,注释「可被测试修改」),超时不是失败而是转后台。
  • 并发上限:源码未体现。全仓无 semaphore / worker pool / 最大并发子 Agent 数;每次 Agent 工具调用都直接起新 Agent + goroutine(后台路径),理论上模型一轮里并发 10 个 Agent 调用就会起 10 个子 Agent(只读批次还会并发执行这些工具调用)。生产化必须补并发闸门与总量配额。
  • 任务队列上限:done channel 缓冲 32,满则 dropping notification for <id> 写 stderr(任务本体仍在 map 里,只是通知丢)。

Task 管理器 ​

后台任务如何建模 ​

BackgroundTask(manager.go:56)是完整状态快照:ID / Name / SubAgent *agent.Agent / Conv *conversation.Conversation / Task / Status / Result / Err / StartTime / EndTime / Cancel context.CancelFunc / Usage{Input,Output,CacheWrite,CacheRead} / ToolCount / LastActivity。

ID 生成:nextID() = atomic.AddInt64(&counter,1) 后 fmt.Sprintf("task_%08x", uint32((time.Now().UnixNano() ^ n) & 0xFFFFFFFF)) —— 时间戳异或计数器取低 32 位,存在碰撞可能(不同时刻纳秒差恰好抵消计数差时),生产应换 UUID/单调递增 ID。

Manager 内部:tasks map[string]*BackgroundTask(id → task)、byName map[string]string(name → id,弱引用,后启动覆盖前)、done chan string(缓冲 32)、counter int64。List() 还用手写双重循环冒泡按 StartTime 升序排序(O(n²),任务数小无所谓,但明显是「先用能跑的」实现)。

PartialState{LastAssistantText, ToolCount, LastActivity, Usage} 结构已定义但 Launch/AdoptRunning 签名与实现中未使用(AdoptRunning 注释只提到 partial 参数可由调用方传,实际签名里没有该参数)→ 属于预留/未接线。

持久化在哪 ​

没有持久化。任务状态纯内存 map,进程退出即全部丢失;internal/session 的 JSONL Writer 只负责对话消息(grep task in internal/session 零命中),不落盘任务表。SendMessage 依赖的 byName 与 Conv 也都在内存中 —— 重启后既不能 TaskGet 历史任务,也不能续派。这是与 Claude Code 等生产实现最大的差距之一(详见最后一节)。

与 SubAgent 的关系 ​

  • TaskManager 接口(定义在 agent 包,避免 agent → task 的依赖)只有两个方法:Launch 与 AdoptRunning;task.Manager 实现它,AgentTool 在后台/超时转后台两条路径上调用。
  • Manager 不做执行,只做宿主与生命周期:真正的执行是 ag.RunToCompletion(ctx, conv, taskText, events),Manager 起 goroutine 包住它、聚合事件、判定终态、发通知。
  • 反向依赖通过接口解耦:agent.AgentTool 持有 TaskManager 接口;task.Manager 持有 *agent.Agent(结构体,非接口)—— 依赖方向是 task → agent,因此 agent 里定义接口是必要的(否则成环)。
  • AgentTool 的 name 参数(可选)就是给 SendMessage 用的寻址键:Launch 时 if name != "" { m.byName[name] = id }。

状态机 ​

Status 四态:StatusRunning / StatusCompleted / StatusFailed / StatusCancelled(String() 输出 running/completed/failed/cancelled)。

                 Launch()                       RunToCompletion err==nil
  (无) ─────────────────────▶ Running ───────────────────────────────▶ Completed
                                │  ▲                                    │
                    err != nil  │  │ SendMessage(name,msg)              │
        ┌───────────────────────┴──┴────────────────────────────────────┘
        │  ctx.Canceled → Cancelled        (仅当当前为 Completed 才允许续派)
        │  其他 err      → Failed(Err, Result)
        │  panic         → Failed("subagent panic: %v")   [defer recover]
        └─ Stop(id) → t.Cancel() → ctx 取消 → Cancelled

细节与边界:

  • Launch:context.WithCancel(parentCtx) 生成任务 ctx;defer 里 recover() 兜 panic 置 Failed,随后必发通知(select { case m.done <- id: default: stderr });内部另起 aggregateTaskEvents(events, bt) 消费 events chan agent.Event(容量 32)统计 ToolCount(Phase == PhaseEnd 计数 + 记 LastActivity)与累计 Usage。
  • SendMessage:先按 name 找 id(找不到 → ErrTaskNotFound);仅 Status == StatusCompleted 才允许,否则 ErrTaskBusy(无法打断正在跑的任务);然后 bt.Conv.AddUser(message)、bt.Status = StatusRunning、新建 ctx 覆盖 bt.Cancel、重跑 RunToCompletion(ctx, bt.Conv, "", events)(task 传空串,因为消息已入 Conv)。线程安全上有瑕疵:bt.Status/bt.Conv 的读写未持 m.mu,与后台 goroutine 写 bt.Status 存在竞争窗口。
  • AdoptRunning:不执行 agent(前台已由 RunToCompletion 跑着),只接管 ev channel 直到关闭;结束时「状态已由 RunToCompletion 设置」——若仍为 Running 则补 Completed(注释如此),即取消了 Cancelled 判定依赖上游(bt.Status 实际由 agent 内部事件流决定,源码未体现谁写 Cancelled,存在状态可能被误标为 Completed 的风险)。
  • Stop:t.Cancel()(nil 则返回 false)→ 返回 true;不立即改状态,等 goroutine 观测到 ctx.Canceled 才置 Cancelled。
  • 通知一致性:TaskStop 返回 {"status":"cancellation_requested"}(异步语义,未等终态)。

四个内置工具(tools.go) ​

工具ReadOnly参数行为
TaskListtrue无mgr.List() → JSON 数组 {id,name,status,tool_count,last_activity}(描述称「非 Terminated 任务」,但实现不过滤终态,全部返回)
TaskGettruetask_id(必填)返回 id/name/status/task/result/tool_count/last_activity/usage{input,output,cache_write,cache_read}/start_time/end_time(时间格式 2006-01-02 15:04:05)
TaskStopfalsetask_idmgr.Stop;失败返回 未找到任务或无法取消
SendMessagefalsename + messagemgr.SendMessage;成功返回 {"task_id":"..","status":"resumed"};失败把 ErrTaskBusy/ErrTaskNotFound 以 IsError 文本回传

四个工具在 TUI 初始化时注册进全局 registry(tui.go:257-262),因此在主 Agent视角可用;ASYNC_AGENT_ALLOWED_TOOLS 特意不含这些元工具,后台子 Agent 无法操作彼此的任务表。


设计决策与权衡 ​

  1. 决策:MCP 每个 server 独立 goroutine + 独立 connectTimeout,失败只跳过该 server(NewManager)。 为什么:MCP server 是外部不可信依赖(npx 拉包、远端 HTTP),任何一个挂掉都不该阻塞 CLI 启动。 替代方案:串行连接(启动被最慢 server 拖住)或整体失败退出(一个坏配置让工具全灭)。

  2. 决策:MCP 两层配置用项目级整体覆盖同名 server,不做字段级深合并(mergeServers)。 为什么:可预测——项目级写出完整定义即可复现,不会「继承到一半的 env」产生难查的隐式耦合。 替代方案:深合并(用户层 env 与项目层 env 混合,token 串味、调试成本高)。

  3. 决策:${VAR} 只对 env/headers 的 value 展开,command/args/url 保持字面量(applyExpansion)。 为什么:把「环境变量注入」限制在凭据位置,命令与参数不被环境篡改,天然规避一类命令注入。 替代方案:全字段展开(command: "${MCP_CMD}" 灵活但等于把 exec 目标交给环境变量)。

  4. 决策:正则只认 ${NAME},不支持 ${VAR:-default}、$VAR、命令替换。 为什么:结构上排除 shell 语义,实现 10 行、审计成本低。 替代方案:os.Expand(支持 $VAR 更顺手,但也把 $ 变成需要转义的元字符,YAML 里更易踩坑)。

  5. 决策:未定义变量替换为空串 + 去重 stderr 告警,而不是报错拒绝加载(collectUndefined)。 为什么:环境变量缺失很常见(CI/新机器),不应该因此让整个 MCP 工具集不可用。 替代方案:加载即失败(更安全,但用户体验差)。代价:静默降级成空 token,只能靠运行期 401 才发现。

  6. 决策:MCP 工具统一命名空间 mcp__<server>__<tool> + 字符白名单 ^[A-Za-z0-9_-]+$。 为什么:与内置工具(read_file/bash…)无碰撞;三段式可读;白名单挡住远端注入非法字符破坏 function-calling 协议。 替代方案:保留远端原名(冲突即覆盖)+ 运行期重命名映射(复杂度转移到每次调用)。

  7. 决策:MCP 工具 ReadOnly() 只信 annotations.readOnlyHint,不猜。 为什么:猜错的代价是「写操作被当只读并发执行且绕过 Ask」;宁可把未标注工具落到 CategoryExec(最严)走 Ask。 替代方案:按 name 关键字启发式(get/list → 只读)——方便但引入权限绕过面。

  8. 决策:connectTimeout/closeDeadline 做成包级 var,而工具调用超时 30s 内联硬编码。 为什么:前者需要在测试里改短(manager_test.go 依赖),后者当前无测试需求,先简单。 替代方案:统一放到 Config/ServerConfig(生产更合理:不同 server 的调用耗时差异巨大,长任务型 MCP 工具会被 30s 砍掉)。代价:用户无法为慢 server 调参。

  9. 决策:Skill 走两阶段渐进式披露——阶段 1 只往 system prompt 注入 name + description,阶段 2 由 LoadSkill 按需拉全文(RenderSkillsCatalog / RenderActiveSkillsBlock)。 为什么:Skill 数量增长时上下文成本按「摘要行」而非「全文」增长;正文只在真正需要时付费。 替代方案:全量注入全文(token 爆炸)或纯检索(需要 embedding 基础设施)。

  10. 决策:已激活 body 注入 env context 而非改写 system prompt,并用「SOP 优先于通用系统指令」的口头声明表达优先级。 为什么:system prompt 前缀稳定 → provider 侧 prompt cache 持续命中(main.go 里专门的 render 顺序也服务于这点)。 替代方案:把 body 拼进 system prompt(优先级更硬,但每次激活都让 cache 全量失效、成本高)。代价:优先级是提示而非机制,模型可被后续指令带偏。

  11. 决策:Catalog.GetFull 每次执行都强制重读磁盘 SKILL.md,失败回退缓存。 为什么:Skill 是用户正在编辑的「活的」文件,热重载让改完立刻生效,无需重启进程。 替代方案:只在启动/reload 时读(一致性好、I/O 少)。代价:实现了只持 RLock 却写共享 *Skill.PromptBody 的竞态窗口(应改为返回拷贝)。

  12. 决策:LoadSkill 标记为 SystemTool(IsSystem()=true),在 DefinitionsFiltered 中被无条件豁免白名单。 为什么:避免死锁——如果 Skill 的 allowed_tools 不含 LoadSkill,激活能力就自我封锁了。 替代方案:把 LoadSkill 塞进所有白名单(易漏、体验差)。

  13. 决策:Skill 的 allowed_tools 在 inline 模式下只是提示词(RenderBody 顶部插入 This skill is designed to use only these tools: ...),硬收窄只在 fork 路径通过 RunSubAgent(..., allowedTools) 实现。 为什么:inline 是把 SOP 注入当前对话,真正的工具权限仍由全局 permission.Engine 与 mode 管,避免两套权限源打架。 替代方案:inline 也做 registry 级过滤(更安全,但会与用户当前 mode/权限规则冲突,且 Skill 边界模糊)。代价:白名单可被模型忽略;且 SkillHost 接口只给了 (name, body),根本没有传白名单的通道。

  14. 决策:Hook 的条件匹配复用 permission.Matcher(= / ~ / ! / glob 四实现),而不是自造一套匹配语法。 为什么:一个匹配语义、一处实现、一处修复;权限规则的 allow/deny 与 hook 的 if 共享同一套 glob/exact/regex 心智模型。 替代方案:hook 专用匹配器(更贴合,但两套实现必然漂移)。代价:CompileMatcher(pattern, isCommand=false) 让 glob 走文件路径语义,匹配 Bash 命令串必须改用 exact/regex——compileMatch 只能靠注释提醒。

  15. 决策:Hook 拦截用进程约定——shell exit 2、HTTP 2xx + body {"decision":"block","reason":"..."}。 为什么:任何语言、任何脚本都能写 hook,无需 SDK;与既有生态(Claude Code hooks)习惯一致。 替代方案:结构化 stdout JSON 协议(更强表达力,如 additionalContext、continue:false,但要求 hook 作者遵守格式)。代价:只能表达「拦截/不拦截」,无法回传上下文给模型;exit 1 只等于「hook 失败」。

  16. 决策:Hook 加载是两层叠加(append),仅在 name 冲突时拒绝后加载者;而 MCP 配置是后层覆盖。 为什么:hook 场景「项目 hook + 个人 hook 同时生效」更符合直觉(如项目要求格式化、个人要求通知)。 替代方案:与 MCP 一致的覆盖语义。代价:两套扩展体系语义相反,用户需记住「hook 叠加、MCP 覆盖」;且用户级无法覆盖项目级同名 hook(只被跳过)。

  17. 决策:async 与拦截类事件(PreToolUse/UserPromptSubmit)在编译期互斥(compileRule 直接拒绝)。 为什么:异步无法在同步调用栈里表达拦截,若允许就是「配置看着生效、实际不生效」的静默错行为。 替代方案:运行期忽略 async 并告警(用户更困惑)。代价:写 hook 的人必须理解哪些事件是「拦截类」。

  18. 决策:Hook 自身失败(超时、非 2 退出、HTTP JSON 解析失败)一律放行,只写 stderr。 为什么:hook 是外挂自动化,其 bug 不该阻断用户的编码流程(可用性优先)。 替代方案:fail-closed(安全优先,如 CI 场景更合理)。代价:hook 静默失效难以察觉(无审计、无计数)。

  19. 决策:SubAgent 定义用 Markdown + YAML frontmatter,内置角色 //go:embed 且解析失败 panic;外部文件解析失败只告警跳过。 为什么:与 Skill 同一套作者体验(会写 markdown 就会写角色);内嵌文件是二进制的一部分,构建期就该炸。 替代方案:JSON/TOML 定义(机器友好、人难写);内嵌失败降级(发布出去才发现内置角色缺失)。

  20. 决策:Fork 路径无条件后台,且克隆父对话时修补悬空 tool_use(补 IsError 的 "[forked, skipped]" placeholder)。 为什么:Fork 语义是「带着父上下文干一件长活」,天然慢;而 Anthropic 消息协议强校验 assistant 的 tool_use 必须有对应 tool_result,否则整轮请求 400。 替代方案:截断到最后一条完整轮(丢失近期上下文)或把悬空调用重发(副作用重复执行)。代价:模型可能看到「工具被跳过」的错误结果而对上下文产生误判。

  21. 决策:前台子 Agent 120s 超时后 AdoptRunning 自动转后台,而不是报错终止。 为什么:已经花掉的 token 与进度不浪费,用户也不被长时间阻塞;这也是「Agent 工具调用必须及时返回」的前提。 替代方案:直接失败(浪费已付成本)或无限等待(卡死主循环)。

  22. 决策:子 Agent 工具集走五层过滤(ApplyAgentToolFilter),且 Agent 工具对所有子 Agent 硬禁用。 为什么:防止递归 fork 造成的指数级 fan-out;后台 Agent 用静态白名单收窄到「读写文件 + bash + skill」这类原子能力。 替代方案:只在 prompt 里写「不要调用 Agent」(模型会违规)。代价:白名单是硬编码列表,新增工具(如 WebSearch)必须记得同步 ASYNC_AGENT_ALLOWED_TOOLS,否则后台子 Agent 能力静默缺失。

  23. 决策:task.Manager 纯内存、done channel(缓冲 32)推送完成通知,满则丢通知 + stderr。 为什么:TUI 是单进程短生命周期;不落盘就没有并发写文件、崩溃恢复、迁移的复杂度。 替代方案:写 session JSONL / SQLite(可恢复、可审计,但引入 I/O 与 schema 迁移负担)。代价:重启即丢全部任务与结果;done 满时用户看不到完成提示(任务本体还在,可用 TaskGet 捞)。

  24. 决策:task 与 agent 之间用接口 + 反向接口解耦(TaskManager 定义在 agent,agent.Catalog 抽象在 agent_tool.go),而不是把 SubAgent/Task 合并成一个包。 为什么:分包让关注点分离(角色定义 vs 执行循环 vs 生命周期管理),且便于单测用 mock 替身;Go 无法跨包互引,接口放「使用方」是最小耦合解法。 替代方案:单包大杂烩(无环但难维护)或事件总线(解耦更彻底但调试难)。


面试官可能追问(20 条) ​

Q:MewCode 支持哪两种 MCP 传输?分别怎么构造? A:stdio 与 Streamable HTTP。stdio 侧 exec.CommandContext(ctx2, srv.Command, srv.Args...),cmd.Env = mergeOSEnv(srv.Env)、cmd.Stderr = os.Stderr,包装成 sdkmcp.CommandTransport;HTTP 侧自建 http.Client + headerRoundTripper(RoundTrip 里 req.Clone(req.Context()) 后逐条 Header.Set),包装成 sdkmcp.StreamableClientTransport{Endpoint, HTTPClient, DisableStandaloneSSE: true}。两者都通过 client.Connect(ctx2, transport, nil) 建会话,客户端标识 sdkmcp.Implementation{Name: "mewcode", Version: version}。

Q:MCP 连接怎么保活?断了会怎样? A:没有任何应用层保活——全包无 Ping/心跳/Reconnect(grep 零命中)。stdio 子进程存活即会话存活,进程退出后错误在下次 CallTool 才暴露;HTTP 每次调用重新发请求。没有重连、没有运行期 reload(配置只在 main.go:76 加载一次),增删 server 必须重启。可用的只有退出兜底:Manager.Close() 并发 cs.Close(),closeDeadline = 5s 到点不再等。

Q:${VAR} 展开有什么安全考量?怎么处理未定义变量? A:正则 varPattern = \$\{([A-Za-z_][A-Za-z0-9_]*)\} 只认 ${NAME},不支持 $VAR/默认值/命令替换,结构上排除 shell 注入;applyExpansion 只展开 env 与 headers 的 value,command/args/url 不展开。未定义变量替换为空串,由 collectUndefined 去重后一次性 [mcp] warn: undefined env var ${X} referenced by server Y——不报错是为了不阻断启动,代价是静默降级(token 变空,运行期 401)。

Q:两层 MCP 配置怎么合并?是深合并吗? A:用户级 ~/.mewcode/config.yaml + 项目级 <root>/.mewcode.yaml。mergeServers 是整体覆盖:先铺用户层再用项目层按 server 名覆盖,同名 server 的 command/args/env 不会部分继承。解析失败的那一层降级为空层 + stderr 告警;LoadConfig 签名有 error 但实现恒返回 nil(注释明说)。

Q:MCP 工具怎么转换成内置 tool.Tool?命名空间与超时怎么设? A:adaptTool 生成 fullName = "mcp__<server>__<tool>",用 validToolName = ^[A-Za-z0-9_-]+$ 校验(非法则告警并跳过),描述空则兜底,InputSchema 经 marshal/unmarshal 转 map[string]any 原样透传(空则 {"type":"object"}),ReadOnly() 严格取 t.Annotations.ReadOnlyHint。Execute 里 CallTool 只用 remoteName,返回只拼 *sdkmcp.TextContent,非 text 块丢弃(nonTextWarnOnce 保证每工具只告警一次)。超时有三处:建连 + ListTools 用包级 connectTimeout = 30s(可被单测改短)、Close() 用 closeDeadline = 5s 兜底、单次 CallTool 在 mcpTool.Execute 里硬编码 context.WithTimeout(ctx, 30*time.Second)(无配置入口,长任务型 MCP 工具会被砍)。

Q:一个 MCP server 挂了会影响其他 server 吗? A:不会。NewManager 给每个 server 起独立 goroutine、各自 context.WithTimeout(ctx, connectTimeout),失败/超时只 stderr 告警后 return;ListTools 失败还会 _ = cs.Close() 释放半开连接。只有成功路径才在 mgr.mu 保护下写 sessions/tools,最后 wg.Wait() + 按工具名排序返回。没有任何重试。

Q:MCP 工具的权限是怎么判的? A:靠 ReadOnly()。permission.categorize(name, readOnly) 里 readOnly=true → CategoryRead(可进只读并发批次),否则 default 分支 → CategoryExec(最严,走 Ask/规则)。因为 ReadOnly 只信远端 annotations.readOnlyHint,未标注的 MCP 工具默认被当命令执行类对待。

Q:SKILL.md 的 frontmatter 有哪些字段?非法值怎么处理? A:SkillMeta 六个字段:name(必填、不能含空格或 /、强制转小写)、description、allowed_tools、mode(inline/fork)、fork_context(none/recent/full)、model。非法 mode → 警告 + 降级 inline;非法 fork_context → 警告 + 降级 none;name 缺失或非法直接让该 Skill 解析失败被跳过。

Q:什么叫渐进式披露?MewCode 是怎么做的? A:阶段 1 只把 name + description 注入 system prompt(Catalog.ToPromptItems → prompt.RenderSkillsCatalog,产出 ## 可用 Skill(调用 LoadSkill 工具激活) + - **/name**: desc 列表);阶段 2 由模型调用 LoadSkill 工具触发 Executor.RenderAndActivate → Catalog.GetFull 重读磁盘 body → 写入 runtime.ActiveSkills,之后每轮以 prompt.RenderActiveSkillsBlock 注入 env context(## Active Skills 块),而不是改 system prompt —— 为了 prompt cache 稳定。

Q:Skill 的 fork 模式、fork_context、model 字段都生效吗? A:都不生效(设计已定、执行未接线)。SkillForkHost 接口在 executor.go:17 定义后全仓无实现(grep RunSubAgent 只命中接口与调用点),runFork 会返回 "fork mode 需要 SkillForkHost,但未提供";command/skills.go 对 KindSkillFork 也直接报错「fork skill 暂不支持通过命令直接调用」;而 LoadSkillTool 走 RenderAndActivate 会忽略 mode 一律 inline 激活。SkillMeta.ForkContext 与 SkillMeta.Model 同样只在 parseSkillMD 里被解析校验,runFork 既没按 fork_context 决定是否携带父历史,也没按 model 指定模型。

Q:Skill 的 allowed_tools 真的是硬约束吗? A:分两层。inline 模式下是软约束——RenderBody 只在 body 顶部插一句 This skill is designed to use only these tools: a, b. Prefer them over other tools when possible.,没有 registry 级过滤;硬约束只存在于 fork 路径(RunSubAgent 第三参数接收白名单,但该宿主未实现)。另外 Catalog.ValidateTools(toolExists) 只在启动时校验工具名是否存在并 stderr 告警。LoadSkill 因 IsSystem()=true 在 DefinitionsFiltered 里被豁免白名单,不会被自己的白名单锁死。

Q:InstallSkill 从哪里装?怎么校验?有什么限额? A:tool.InstallSkillTool → skills.ParseSkillURL 支持三种 host:skills.sh/<org>/<name>/<version>(转 GitHub Contents API,version 段被忽略)、github.com/<owner>/<repo>/tree/<ref>/<path>(转 Contents API + ?ref=)、raw.githubusercontent.com(直接用)。skills.Install 先下到 os.MkdirTemp staging,必须含 SKILL.md 否则拒绝;配额 maxInstallFileSize=1MiB、maxInstallTotalSize=8MiB、maxInstallFileCount=64、maxInstallDepth=4、installHTTPTimeout=60s,403 特判 rate limit;成功后 os.Rename 原子替换到 硬编码的 ~/.mewcode/skills。没有签名/哈希校验,SKILL.md 里的指令最终经 Bash 工具执行,等于「装 Skill = 引入可执行指令源」。

Q:Hook 有哪 11 个事件?哪些能拦截? A:event.go 的 allEvents:SessionStart、SessionEnd、SessionResume、UserPromptSubmit、Stop、PreUserMessage、PreToolUse、PostToolUse、PreCompact、PostCompact、Notification。blockingEvents = {PreToolUse, UserPromptSubmit} 两个可拦截,IsBlocking(e) 同时被引擎和 loader 使用(loader 用它拒绝 async + 拦截事件)。另外注意 SessionResume 只有定义,全仓无派发点。

Q:Hook 条件怎么匹配?为什么 glob 匹配命令会失效? A:EvalCondition 支持 all_of/any_of(互斥,只能二选一;nil 或零原子 = 无条件触发),单条原子是 AtomCondition{Field, Matcher},Field 走 GetByPath 点路径下钻(缺字段返回空串而非报错);Matcher 复用 permission.CompileMatcher(pattern, isCommand=false),即 exact→=v、regex→~v、not→!inner、glob→原值。因为传的是 isCommand=false,glob 按文件路径语义展开,匹配 Bash 命令串必须用 exact 或 regex(compileMatch 注释里明确提示)。

Q:Hook 的 shell 动作怎么执行?退出码与阻断语义是什么? A:exec.CommandContext(ctx, "sh", "-c", cmd),payload 用 marshalSorted 序列化成单行 JSON 经 stdin 传入(Go json.Marshal 保证 key 字典序),stdout/stderr 分别缓冲,超时 Rule.Timeout(缺省 30s)。退出码:0 放行;2 且事件为拦截类 → Blocked,Reason = TrimSpace(stderr+stdout),空则 "blocked by hook (exit code 2)";其他非零 → Err(hook 失败,放行);超时 → Err(也是放行)。阻断范围仅限 PreToolUse 与 UserPromptSubmit:Dispatch 里首个表达 Blocked 的规则会 break(中断同事件后续规则);PreToolUse 在 executeBatched 里被转成 hookBlockedResult(IsError:true,内容 [hook <name>] <reason>)并跳过权限 Check、不执行工具,但 Agent 循环继续,模型看到错误结果自行改道;UserPromptSubmit 由 TUI 的 dispatchUserPromptSubmit 返回 (blocked, reason, hookName) 决定是否吞掉该消息。

Q:Hook 的 async 有什么限制?prompt 动作注入到哪? A:async 在编译期禁止用于 PreToolUse/UserPromptSubmit(compileRule 直接拒绝);运行期 Dispatch 起 goroutine 时用的是 context.Background()(脱离父 ctx),且结果不进 InjectedPrompts、不参与 Blocked 判定(e2e_test.go 有专门断言)。prompt 动作的 Text 经 DispatchResult.InjectedPrompts → agent.dispatchHook 里 runtime.AppendReminders 进 PendingReminders,下一轮由 buildReminder/TakeReminders 取出注入 reminder 区。

Q:内置了哪些 SubAgent 角色?怎么被调用? A:3 个:Explore(disallowedTools: [write_file, edit_file]、model: haiku、maxTurns: 30、只读搜索)、Plan(禁 write/edit/Agent、maxTurns: 15、permissionMode: plan)、general-purpose(maxTurns: 30、全工具)。唯一入口是 Agent 工具(agent_tool.go),参数 prompt/description(必填)+ subagent_type/model/run_in_background/name;Description() 动态列出 catalog.List() 的角色名。subagent_type 留空 → catalog.ForkDefinition()(Name="__fork__",IsFork() 判定)走 Fork 路径且无条件后台。

Q:SubAgent 的上下文如何隔离?结果怎么回来? A:隔离靠三点:独立 agent.New(...)(SessionRuntime{ContextWindow} 继承父但状态全新,共享 provider/registry/permission/hookEngine)、独立 Conversation(角色路径 conversation.New() 空对话;Fork 路径 BuildForkedMessages 深拷贝父消息 + fixPendingToolCalls 给悬空 tool_use 补 "[forked, skipped]" placeholder + 追加 ForkBoilerplate + task)、工具走 ApplyAgentToolFilter 五层过滤(含 ALL_AGENT_DISALLOWED_TOOLS = ["Agent"] 禁递归)。注意:有自定义 systemPrompt 时 sys = a.systemPrompt 整段替换系统提示。结果:前台直接把 RunToCompletion 的 finalText 作为 tool.Result.Content;后台由 task.Manager 记 Result → done channel → TUI consumeTaskDone 生成 <task-notification> 注入 PendingReminders。

Q:SubAgent 有轮次或并发上限吗?模型字段生效吗? A:轮次有——maxTurns(定义文件,0 则用 maxIterations = 25),另有 maxUnknownRunSub = 2(比主 Agent 的 maxUnknownRun = 3 更严);前台单次时长 autoBackgroundDuration = 120s 超时后 AdoptRunning 转后台而不是失败。并发上限源码未体现——无 semaphore/worker pool,模型一轮里并发 N 个 Agent 调用就会起 N 个子 Agent。model 字段与 AgentArgs.Model 都未生效:agent_tool.go 里子 Agent 固定用 t.parent.provider,没有 provider 选择或 haiku/sonnet/opus 映射逻辑。

Q:Task 的状态机长什么样?SendMessage 有什么前置条件?持久化在哪? A:四态 StatusRunning/Completed/Failed/Cancelled。Launch → Running;RunToCompletion 返回后按错误分类:context.Canceled → Cancelled,其他 err → Failed(带 Err+Result),成功 → Completed(带 Result);panic 由 defer recover 置 Failed("subagent panic: ...")。SendMessage(name, msg) 要求 Status == StatusCompleted 才允许续派(否则 ErrTaskBusy),找不到名字返回 ErrTaskNotFound;续派是 Conv.AddUser(message) + 重置 Running + 重跑 RunToCompletion(ctx, bt.Conv, "", events)。AdoptRunning 只消费事件流,结束时若仍 Running 补 Completed。没有任何持久化:Manager 全部状态在内存 map(tasks/byName/done chan),进程退出即丢;internal/session 的 JSONL Writer 只写对话消息,不含任务表(grep 零命中),所以重启后无法 TaskGet 历史任务、无法 SendMessage 续派。另外 done channel 缓冲 32,满则丢通知 + stderr;nextID() 用 UnixNano() ^ counter 取低 32 位,存在碰撞可能。


企业级对应方案 ​

1. Skill 体系 vs Claude Code Skills ​

Claude Code 的 Skills 是「目录 + SKILL.md frontmatter(name/description/allowed-tools)」+ 渐进式披露,与本项目骨架高度一致(本项目额外支持 mode/fork_context/model,且 RenderBody 的 $ARGUMENTS 占位符与"无占位符则追加 ## User Request"的处理很贴近 slash-command 语义)。差异与补齐点:

  • 路径:Claude Code 走 .claude/skills/(并可软链到 .agents/skills/,与工作区 CLAUDE.md 约定一致),本项目 LoadCatalog 只扫 .mewcode/skills/,与自己的文档约定脱节 → 补齐:扫描根可配置 + 兼容 .agents/skills。
  • 分发:Claude Code 有 plugin marketplace / /plugin install;本项目只有 InstallSkill(GitHub 三源)。生产化必须补签名/内容哈希校验(当前一条 SKILL.md 即可引入任意指令 + 后续 Bash 执行)、来源 allowlist、安装前静态扫描(提示注入、bash 调用、外联域名)。
  • fork 落地:本项目 fork 链路已解析但 SkillForkHost 无实现、fork_context 未消费 → 补齐后建议明确「fork 子会话是否带父历史、带多少(none/recent/full)、模型如何路由」。
  • 一致性:Catalog.GetFull 在 RLock 下写共享 *Skill.PromptBody 是真竞态,应改为「查询返回拷贝」或 atomic.Pointer[Skill] 快照。

2. Hook 生命周期 vs Claude Code Hooks ​

事件名几乎对齐(PreToolUse/PostToolUse/UserPromptSubmit/Stop/SessionStart/PreCompact),工程化程度差距明显:

  • 协议:Claude Code hook 通过 stdout JSON 回传 decision/continue/stopReason/additionalContext(可以把额外上下文喂给模型);本项目只有 exit 2 + {"decision":"block"} 两种二元信号,成功路径的 stdout 完全丢弃 → 补齐:定义结构化输出(additionalContext、updatedInput)并接入 runtime.AppendReminders。
  • 可观测性:本项目 hook 失败只写 stderr,无计数器/无审计/无 trace → 应统一 OpenTelemetry span(hook name、event、耗时、exit code、blocked)并落审计日志(谁在什么 payload 下拦了什么)。
  • 配置语义:本项目 hook 是叠加、MCP 是覆盖,两套语义并存易错;企业版应统一为「项目级可覆盖用户级 / 显式 merge 策略」。
  • 安全:shell 动作是无沙箱的任意代码执行(sh -c),且仓库级 hooks.yaml 可随 clone 带入 → 企业版必须有「项目 hook 首次执行需人工批准」+ 命令 allowlist + 环境变量最小暴露(当前 cmd.Env 继承全部宿主环境,凭据会暴露给 hook 子进程——注意 mergeOSEnv 只在 MCP 里做,hook 的 exec.CommandContext 完全继承宿主 env)。
  • 未接线项:ActionSubagent(占位)、EventSessionResume(无派发点)需要补齐才算完整生命周期。

3. SubAgent vs Claude Code Subagents 与 OpenAI Agents SDK handoff ​

  • Claude Code subagents:.claude/agents/*.md + description 触发、tools 白名单、独立上下文、结果摘要回主 Agent。本项目对齐度很高,且多了 embed 内置角色与 Fork 路径。关键差距:① model(haiku/sonnet/opus)字段未生效——企业版需要 provider/model 路由表 + 成本上限;② 无并发/总量配额;③ 无「子 Agent 调用审计」(谁被派了什么任务、花了多少 token —— task.Usage 有数据但没有汇总/上报)。
  • OpenAI Agents SDK handoff:handoff 是「把控制权交给另一个 agent」的一等公民,带 input_filter、on_handoff 回调、以及 handoff 后由新 agent 直接对用户产出的语义;Agent 工具是「工具调用 + 结果返回调用方」。本项目属于后者(tool-style delegation),好处是父 Agent 始终掌握控制权与最终回复;要补 handoff 语义需引入「控制权转移 + 会话所有权」概念,并处理 guardrails(本项目对应 permission.Engine + DontAsk,但 dontAsk 是子 Agent 专属的放宽开关,值得做安全审查)。
  • 回传质量:本项目靠 prompt 约束(ForkBoilerplate「报告以 Scope: 开头、500 字以内」/ general-purpose「只包含要点」)来控制回传长度——这是 prompt 级约束而非机制级截断,企业版应在 AgentTool.Execute 里做结果长度硬截断 + 摘要回写。
  • LangGraph subgraph:LangGraph 用显式 state schema + checkpointer(可中断/恢复/重放),subgraph 之间靠 state 键传递;本项目用「深拷贝 Conversation + shared SessionRuntime 的部分字段」模拟隔离,没有 checkpoint/replay,子 Agent 跑挂无法从中间态恢复(Task 也只有 PartialState 结构占位、未使用)。生产化建议:把子 Agent 状态显式化为可序列化 struct + 每轮 checkpoint,SendMessage/AdoptRunning 才能变成可靠的状态迁移而不是「同进程内存续跑」。

4. Task 管理器 vs Claude Code Task 体系 ​

Claude Code 的 Task 分两层:面向用户的 todo/task 列表(可跨会话、可见进度)+ 面向后台 agent 的任务运行;本项目 internal/task 只覆盖后者(后台子 Agent 生命周期),没有 todo 类工具(全仓 grep todo 无相关工具,只有注释里的 TODO)。补齐清单:

  • 持久化与恢复:JSONL/SQLite 落盘任务表(id/name/status/result/usage/时间戳),启动时恢复未完成任务的元数据并给出「上次未完成」提示;当前重启即丢。
  • ID 与并发:nextID() 的 32 位异或碰撞风险 → UUIDv7;补最大并发子 Agent 数、任务总量与 TTL 清理(否则 tasks map 无界增长)。
  • 状态机严谨性:SendMessage 对 bt.Status/bt.Conv 的读写未持 m.mu(与后台 goroutine 竞争);AdoptRunning 把「仍 Running」一律补 Completed,可能掩盖真实的 Cancelled;TaskList 描述称「非 Terminated 任务」但实现不过滤终态。这些都应在生产版修正并加 -race 测试。
  • 通知可靠性:done chan 满即丢通知 → 改为「通知 + 可轮询的持久化状态」,让 TaskList 成为可靠的信息源。
  • 可观测性:Usage{Input,Output,CacheWrite,CacheRead} 与 ToolCount 已采集,但没有聚合上报(成本看板需要按 task/子 Agent 维度汇总)。

5. MCP 生态治理(鉴权 / 审计 / 限流 / 工具选择率) ​

本项目 MCP 客户端属于最小可用实现,治理能力基本空白,逐项补齐:

  • 鉴权:只支持 headers 静态注入 + ${VAR} 展开(headerRoundTripper),无 OAuth 2.1 / DCR / token 刷新(MCP 规范已把 OAuth 作为远程 server 标准路径)→ 需要接入授权码流程、token 缓存(keychain)、按 server 粒度的凭据隔离与最小 scope。
  • 审计:工具调用只有失败时 stderr 告警,无 invocation 审计(谁/何时/什么参数/耗时/结果大小/是否被权限拒绝)→ 应对接统一审计日志与 OTel span,参数按敏感字段脱敏。
  • 限流与配额:无 per-server 并发限制、无 QPS、无超时按工具可配(CallTool 硬编码 30s)、无失败熔断 → 需要 golang.org/x/time/rate 令牌桶 + 断路器 + 每 server 超时/重试策略(含幂等判定:只读工具才可安全重试)。
  • 工具选择率优化:main.go 把 mgr.Tools() 全量 register 并全量注入模型,server 一多就上下文膨胀且工具选择率下降 → 需要 ① 按需 list_tools + 工具检索(向量/BM25 或名称前缀分组);② 「工具集分层」(核心工具常驻、长尾工具按会话意图动态挂载);③ 观测每个 MCP 工具的调用率/成功率,淘汰低效工具;④ 明确 namespace 在 prompt 中的分组呈现(当前只是一串 mcp__a__b,模型容易选错同族工具)。
  • 隔离与稳定:stdio server 无资源限制(CPU/内存/网络),npx 拉包即执行 → 生产需沙箱(容器/seatbelt/landlock)、只允许 allowlist 的 command、DisableStandaloneSSE: true 这类传输细节应可配置。

6. A2A 协议与跨进程 Agent 互操作 ​

A2A(Agent2Agent)的核心是 agent card 发现、task 生命周期(submitted/working/completed/failed)、SSE/推送通知、以及跨厂商的鉴权与授权。本项目 SubAgent 是同进程内方法调用(agent.New + RunToCompletion),Task 管理与 A2A 的 task 状态语义部分同构(Running/Completed/Failed/Cancelled ↔ working/completed/failed/canceled),可以说是「A2A 的单进程内核」。生产化路径:

  • 抽出 AgentTransport 抽象(本地 in-process / 远端 A2A),让 Agent 工具既能调本地角色也能调远端 agent;
  • 为每个 Definition 生成 agent card(name/description/tools 已是现成字段),暴露 /.well-known/agent-card.json 类似发现端点;
  • 鉴权与授权下沉到 transport 层(本地是 permission.Engine,远端是 OAuth/mTLS + 能力声明校验);
  • 长任务与断线续传对齐 A2A 的 push notification:本项目已有 done channel + <task-notification> 的好起点,但需要持久化 + 可重放(见 Task 一节)。

一句话总结差距:MewCode 的五个扩展模块在接口划分、分层覆盖、渐进式披露、失败隔离这些"架构面"上完成度相当高(尤其是 MCP 单 server 隔离、Skill 两阶段披露、Hook 事件枚举对齐、SubAgent 五层工具过滤、Task 前后台接管),但在**持久化、并发配额、审计限流、鉴权、可编程结果协议(结构化 hook 输出 / handoff 语义)**这些"生产面"上仍是 demo 级;此外存在若干「已定义未接线」项(Skill fork/fork_context/model、Hook ActionSubagent/EventSessionResume、Task PartialState、SubAgent model),面试中主动指出这些比只讲设计更能体现真实读码深度。

持续学习,持续构建。