← 返回文章列表

5000 行够不够写一个 Claude Code——读 Helixent 源码的七个意外发现

MagicCube 开源的 Helixent 用不到 5000 行 TypeScript 复刻了 Claude Code 80% 的核心骨架——本文拆解它的七个架构巧思:AsyncGenerator 主 API、累积快照流式协议、8-hook Middleware、tool-result-policy 上下文经济学等。

AI · 工具··36 分钟阅读

5000 行够不够写一个 Claude Code——读 Helixent 源码的七个意外发现

你大概觉得 Claude Code 是个很复杂的东西——有 MCP、有 Hooks、有 subagent、有 plan mode、有一堆工具、有完整的 Settings 层、有 Skills 加载、有审批系统……每一样都够一个工程师写一周。

一个叫 MagicCube 的开源项目 Helixent 最近让笔者改了想法——它用 5000 行不到的 TypeScript,复刻了 Claude Code 大约 80% 的体验骨架。读完整个仓库的当下,笔者的第一反应是:"原来核心可以这么小"。

这篇文章是笔者这几天把 Helixent 整个 src 目录读了一遍之后写的。不讲"怎么用",讲这个项目在架构上做对了什么,以及里面有哪些巧思值得抄到你自己的 Agent 里。

文章的顺序大致是:从最上层的定位讲起,到 Agent Loop 的形态,再到中间件、工具、UI、最后落到一个能随手抄的 checklist。


一、先把定位说清楚:Helixent 到底是什么

一句话:Bun 生态下的一个 ReAct Coding Agent 开源库 + CLI,作者是孙志岗,MIT license,npm 直接装。

npm install -g helixent@latest
cd your-project
helixent

跑起来你会看到一个非常熟悉的终端 TUI——输入框、流式输出、工具调用前的审批弹窗、slash 命令补全、todo 面板。如果你习惯了 Claude Code,你会产生一种"这界面怎么似曾相识"的感觉。

然后你去看它的依赖:

"dependencies": {
  "@anthropic-ai/sdk": "^0.87.0",
  "openai": "^6.33.0",
  "ink": "^6.8.0",
  "react": "^19.2.4",
  "commander": "^14.0.3",
  "zod": "^4.3.6",
  "gray-matter": "^4.0.3",
  // ...10 个左右
}

没有 MCP SDK、没有 PTY、没有私有协议。一切都是透明可读的 TypeScript——这是它最让笔者兴奋的点。想知道 Claude Code 里 apply_patch 工具内部到底怎么处理 hunk 匹配?看 src/coding/tools/apply-patch.ts 的 232 行就够了。想知道审批队列怎么做?看 src/coding/permissions/approval-manager.ts 的 60 行。

这种"把产品里所有黑盒打开让你看"的体验,是任何闭源工具给不了的。

二、四层架构:依赖方向单向,每层职责清爽

Helixent 自己的 AGENTS.md(作者写给 AI 协作者看的架构文档)里明确写了四层:

┌──────────────────────────────────────────────────────┐
│  cli        CLI 入口 + commander + Ink TUI           │
│             依赖: coding, community, foundation      │
├──────────────────────────────────────────────────────┤
│  coding     Coding Agent 工厂 + 13 个工具 + 审批     │
│             依赖: agent, foundation                  │
├──────────────────────────────────────────────────────┤
│  agent      ReAct loop + Middleware + Skills + Todos │
│             依赖: foundation                         │
├──────────────────────────────────────────────────────┤
│  foundation Model / Message / Tool 核心类型          │
│             依赖: 无(只有 zod)                     │
└──────────────────────────────────────────────────────┘

   ┌──────────────────┐
   │  community/      │  侧路:第三方 Provider 适配器
   │  ├─ anthropic/   │  依赖 foundation,不依赖 agent/cli
   │  └─ openai/      │
   └──────────────────┘

注意两个细节:

第一,依赖方向严格单向。agent 不允许依赖 coding,foundation 不允许依赖任何东西。作者在文档里写死了这个约束,而且真的执行了——grep 一下 import from "@/coding" 在 src/agent/ 下的出现次数,是 0。

第二,community 是"侧路"不是 Layer。Anthropic、OpenAI 这两个 provider 适配器被特意放在 community/ 而不是 foundation/providers/。这个命名选择在传达一句话——"这些是可选的,不是核心的一部分"。未来要加 Gemini、Mistral,再加一个 community/google/ 就行,foundation 永远不动。

这种"用目录名传达架构意图"的做法,比写一大堆 ADR 文档有效得多。

三、Agent Loop 用 AsyncGenerator 作为主 API

这是笔者读完 src/agent/agent.ts 的第一个惊叹点。

传统的 Agent loop 长这样(伪代码):

// 老派:EventEmitter + callback
const agent = new Agent({...})
agent.on("message", (msg) => render(msg))
agent.on("progress", (p) => showSpinner(p))
agent.on("done", () => hideSpinner())
await agent.run(userInput)

Helixent 的写法:

// Helixent:AsyncGenerator
for await (const event of agent.stream(userMessage)) {
  if (event.type === "message") {
    enqueueMessage(event.message)
  }
  // progress 事件 UI 里忽略(用 streaming boolean 驱动 shimmer)
}

差别看起来小,实际带来的体验完全不同:

  • 类型安全:AgentEvent 是一个 union,switch 穷尽所有情况会被 TS 检查
  • 背压自然:消费者的 for await 多慢,agent 就产出多慢,没有 buffer 溢出
  • 取消信号干净:AbortSignal 配合,throws 从 await 那里传出来
  • yield* 嵌套:内部 generator 可以把自己的 yield 直通给外层

这最后一条是 agent.ts 里最漂亮的一处:

async *stream(message: UserMessage): AsyncGenerator<AgentEvent> {
  // ...
  for (let step = 1; step <= maxSteps; step++) {
    // yield* 会把 _think() 里的所有 yield 直通到外面
    // 同时拿到它的 return 值
    const assistantMessage = yield* this._think()
    //                       ^^^^^ 关键
    // ...
  }
}

async *_think(): AsyncGenerator<AgentEvent, AssistantMessage> {
  //                            ^^^ yield 类型     ^^^ return 类型
  for await (const snapshot of this.model.stream(modelContext)) {
    if (snapshot.streaming) {
      yield this._deriveProgress(snapshot)  // 途中 yield 给外层
    }
  }
  return finalMessage  // 最后 return 给 yield* 表达式
}

TypeScript 3.6 以后 AsyncGenerator 的类型签名是 <Yield, Return, Next> 三个参数,Helixent 用得非常精准——_think 的 yield 类型是 AgentEvent、return 类型是 AssistantMessage。这让外层一行 const msg = yield* this._think() 既能把内部的 progress 事件直通给调用者,又能拿到最终的 assistant message 作为返回值。

这是一个把 generator 用到它应该被用的样子的例子。

四、流式用"累积快照",不用 delta——这个决策贯穿全项目

Helixent 的 ModelProvider.stream 的契约是这样的:

/**
 * Streams the model response, yielding accumulated snapshots.
 * Each yielded value is a progressively more complete AssistantMessage.
 * The final yielded value is equivalent to what invoke() would return.
 */
stream(params): AsyncGenerator<AssistantMessage>

关键词是 accumulated snapshots——每次 yield 的不是 delta(增量片段),而是"到目前为止的完整消息"。

传统 delta 协议:              Helixent 快照协议:
yield { delta: "你" }           yield "你"
yield { delta: "好" }           yield "你好"
yield { delta: "," }           yield "你好,"
yield { delta: "世" }           yield "你好,世"
yield { delta: "界" }           yield "你好,世界"

消费者的复杂度差别很大:

  • delta 协议:消费者要维护状态,把 delta 合并起来才能渲染
  • 快照协议:消费者每次直接替换当前显示即可

这个设计贯穿了 Helixent 的整个流式体系——StreamAccumulator 在 community/anthropic/ 和 community/openai/ 各实现了一份,内部把 Anthropic 的 event-based 协议和 OpenAI 的 chunk-based 协议都统一吐成累积快照。上层只看到 snapshot,底层差异完全被吃掉。

代价:内存占用 O(n),但对话规模通常几 KB,可以忽略。 收益:UI 层代码简化一大截、跨 provider 统一、throttle 容易(只需要 debounce,不需要 merge 队列)。

这是笔者第一次看到有项目把这个决策推得这么彻底。以前笔者自己写 LLM 流式也是用 delta,看完 Helixent 下次绝对不干这事儿了。

五、Middleware:八个 hook 把所有扩展都收进一个接口

Helixent 里没有"插件系统"、没有"事件总线"、没有"依赖注入容器"。它的扩展点只有一个——Middleware。

interface AgentMiddleware {
  beforeAgentRun?: (params) => Promise<Partial<AgentContext> | void>
  afterAgentRun?:  (params) => Promise<Partial<AgentContext> | void>
  beforeAgentStep?:(params) => Promise<Partial<AgentContext> | void>
  afterAgentStep?: (params) => Promise<Partial<AgentContext> | void>
  beforeModel?:    (params) => Promise<Partial<ModelContext> | void>
  afterModel?:     (params) => Promise<Partial<AssistantMessage> | void>
  beforeToolUse?:  (params) => Promise<BeforeToolUseResult>
  afterToolUse?:   (params) => Promise<Partial<AgentContext> | void>
}

八个 hook,对应 ReAct 循环的不同粒度:

 beforeAgentRun ────────── 整个 run 一次
   ├─ beforeAgentStep ──── 每一步
   │    ├─ beforeModel ─── model 调用前
   │    ├─ afterModel ──── model 调用后
   │    ├─ beforeToolUse ─ 每个 tool 执行前
   │    └─ afterToolUse ── 每个 tool 执行后
   └─ afterAgentStep ───── 每一步
 afterAgentRun ─────────── 整个 run 结束一次

然后所有扩展都是中间件:

  • Skills 系统?beforeAgentRun 扫描目录 + beforeModel 注入 XML。
  • Todo 系统?beforeModel 判断"几步没写 todo 了"决定是否提醒 + afterToolUse 重置计数器。
  • 审批系统?beforeToolUse 问用户 + 返回 {__skip: true, result} 短路执行。
  • 日志?任意 hook,不返回值。
  • 修改消息?afterModel 返回 {content: ...} 被 merge 进去。
// 最终组装
new Agent({
  model,
  prompt,
  middlewares: [
    createSkillsMiddleware(skillsDirs),
    todoMiddleware,
    createCodingApprovalMiddleware({ cwd, askUser, approvalPersistence }),
    // 任何你想加的……
  ],
})

对比 Claude Code 的 hook 系统(shell 命令级别),Helixent 的 middleware 是函数级别的。两者各有利弊:

CC HooksHelixent Middleware
跨语言✓ 可以用 Python/Shell 写✗ 必须 TS/JS
类型安全✗ 只能传字符串✓ Partial 合并
可 mutate context✗✓
复杂数据难传自然传

如果你在做一个闭源工具需要让非 JS 开发者扩展,选 CC 那套。如果你在做开源库、用户就是 TS 开发者,选 Helixent 这套。

最值钱的设计:{__skip, result} 返回形态

beforeToolUse 有一个特殊的返回值:

type BeforeToolUseResult =
  | Partial<AgentContext>                              // 普通 merge
  | { readonly __skip: true; readonly result: unknown }// 跳过执行,用 result 代替
  | null | undefined | void

审批中间件靠这个实现:

// src/coding/permissions/coding-approval-middleware.ts
beforeToolUse: async ({ toolUse }) => {
  if (!requiresApproval.includes(toolUse.name)) return
  const allowed = await loadAllowList(cwd)
  if (allowed.has(toolUse.name)) return

  const decision = await askUser(toolUse)
  if (decision === "deny") {
    return {
      __skip: true,
      result: `User denied execution of tool: ${toolUse.name}. ` +
              `You must either find an alternative approach or ask the user for clarification.`,
    }
  }
}

这比 express 那种 next() 机制更声明式——你不是"决定要不要往下走",而是"告诉框架结果是什么"。模型下一轮看到的就是一个被替换掉的 tool_result,完全透明。

特别留意那段 deny 文本:

You must either find an alternative approach or ask the user for clarification.

这不是冷冰冰的 "Access denied",而是给模型的下一步指导。这种用 tool_result 文本引导模型行为的微操,是 prompt engineering 里很容易被忽视的一层——错误文本本身就是给下一轮模型的 prompt。

六、_act() 的 Promise.race 循环——并行工具执行最漂亮的写法

这段是整个项目笔者最喜欢的代码。

场景:模型一次产出 3 个 tool_use(比如 read 3 个文件),你想让它们并行执行。

新手写法:

const results = await Promise.all(toolUses.map(t => tool.invoke(t.input)))
for (const result of results) {
  messages.push({ role: "tool", content: [result] })
}

问题:必须所有 tool 都完成才能处理,最慢的那个拖死全场。UI 看起来就是"卡一下,然后所有结果一起刷出来"。

Helixent 的写法:

private async *_act(toolUses: ToolUseContent[]): AsyncGenerator<AgentEvent> {
  const pending = toolUses.map(async (toolUse, index) => {
    const result = await tool.invoke(toolUse.input, signal)
    return { index, toolUseId, toolName, result }
  })

  const remaining = new Set(pending.map((_, i) => i))
  while (remaining.size > 0) {
    // race:谁先完成就拿谁
    const candidates = [...remaining].map((i) => pending[i])
    const resolved = await Promise.race([...candidates, abortPromise])
    remaining.delete(resolved.index)

    // 立即产出
    const toolMessage: ToolMessage = {
      role: "tool",
      content: [{ type: "tool_result", tool_use_id, content: format(resolved.result) }],
    }
    this._appendMessage(toolMessage)
    yield { type: "message", message: toolMessage }
  }
}

关键是 while remaining.size > 0 这个 race 循环:

  1. 把所有 tool 的 promise 塞进 pending
  2. 每轮 race 谁先 resolve 就处理谁,立即 yield 出去
  3. 从 remaining 里删掉,继续 race 剩下的
  4. abortPromise 参与 race,让整体可取消

效果:3 个 tool 调用,谁先 ready UI 就立刻显示谁的结果,不等慢的。abort 信号能整体中断所有正在跑的 tool。错误隔离——单个 tool 失败不影响其他。

这是 "边执行边产出" 在 JS 里最优雅的写法,笔者看完当场决定把自己另一个项目里的 Promise.all 全改成这个模式。适用于任何"n 个并行任务、每完成一个立即响应"的场景。

七、tool-result-policy:给每个工具单独配"上下文经济学"策略

这是 Helixent 里一个特别不起眼、但实际极其关键的设计。

文件:src/agent/tool-result-policy.ts

export function getToolResultPolicy(toolName: string): ToolResultPolicy {
  switch (toolName) {
    case "list_files":
    case "glob_search":
    case "grep_search":
    case "file_info":
    case "mkdir":
    case "move_path":
      return { preferSummaryOnly: true, includeData: false, maxStringLength: 1000 }

    case "read_file":
      return { preferSummaryOnly: false, includeData: true, maxStringLength: 12000 }

    case "apply_patch":
    case "write_file":
    case "str_replace":
      return { preferSummaryOnly: false, includeData: true, maxStringLength: 4000 }

    default:
      return DEFAULT_POLICY
  }
}

一张表,把工具分成三类、对应三种"给模型看的详细程度":

类别示例策略理由
发现类list/glob/grep/mkdir/move只给 summary模型只需要知道"有结果/没结果",数据本身不影响决策
读取类read_file给 12KB data这是模型最需要 context 的地方
写入类write/patch/str_replace给 4KB 确认需要知道"写了哪些文件",但不用重复内容

笔者自己以前写工具的时候,所有工具结果一股脑 JSON.stringify 全塞给模型。读完这张表的当下感觉自己之前的实现在浪费对话 token 的一半——list_files 返回 100 个文件的完整 stat 信息,而模型其实只关心"找到了哪些相关的"。

这个策略可以直接抄到任何 Agent 项目里,立竿见影。

还有一个更细的小点:formatToolResultForMessage 里 read_file 有一个特判——直接返回 raw string,不包装 JSON:

export function formatToolResultForMessage({ toolName, result }) {
  if (toolName === "read_file" && typeof result === "string") {
    return result  // 直接返回,不包装
  }
  // 其他工具走 normalize + policy
  // ...
}

为什么?因为模型看到 <tool_result>\n123: import ...\n124: ...\n</tool_result> 比 <tool_result>{"ok":true,"summary":"Read 500 lines","data":"123: import...\\n"}</tool_result> 认知负担低得多。省下的那点 JSON 包装在 read_file 这种超高频的工具上,积少成多。

八、几个值得一起学的小点

上面那些是"大"的,下面几个是"小但值钱"的。

8.1 每个工具强制要求 description 作为第一个参数

parameters: z.object({
  description: z.string().describe(
    "Explain why you want to execute the command. " +
    "Always place `description` as the first parameter."
  ),
  command: z.string(),
})

这是一个让模型先说"为什么"再执行的 prompt engineering 技巧。配合 UI 显示 description 作为 tool 标题,用户看到的是:

⏺ 查找 eslint 配置位置
  └─ /Users/felix/ai/0421-helixent :: eslint.config

而不是冷冰冰的 grep_search(path=..., pattern=...)。能读的意图 > 精确的参数。

8.2 apply_patch 的"硬失败"策略

Helixent 的 apply_patch 严格到苛刻——context line 不匹配就直接 throw,绝不做模糊匹配:

if (actual !== line.text) {
  throw new Error(
    `Context mismatch in ${file.newPath} at line ${sourceIndex + 1}: ` +
    `expected ${JSON.stringify(line.text)}, got ${JSON.stringify(actual)}`
  )
}

笔者第一次看觉得"这也太严了吧",读完一会儿才反应过来——这是故意的。Coding Agent 经常生成"看起来对"但 context 偏移的 patch,如果允许模糊匹配就会引入静默 bug(打在了错的地方但没报错)。

硬失败的机制是:patch 失败 → 模型收到错误 → 重新 read_file → 生成新 patch。这条路径稍微慢一点,但永远不会静默损坏文件。

这个 trade-off 值得每个做 Coding Agent 的人想清楚:"宁可多跑一轮,不能错改一个字节"——这是 Agent 应该有的姿态。

8.3 Skills 的 progressive loading

Helixent 支持 agentskills.io 的标准 Skills 格式。加载时的做法很聪明——只把 frontmatter 塞进 system prompt,不塞正文:

<skill_system>
<instructions>
...
**Progressive Loading Pattern:**
1. When a user query matches a skill's use case, immediately call `read_file`
   on the skill's main file using the path attribute
...
</instructions>
<skills>
  <skill name="skill-creator" path="/Users/.../skills/skill-creator/SKILL.md">
  Create and manage skills
  </skill>
  <skill name="frontend-design" path="...">
  Frontend design and UI development
  </skill>
</skills>
</skill_system>

关键是 path="..." 这个 attribute——告诉模型"要用就先 read_file 这个路径"。这样初始 context 只有 skills 列表(几百字节),真正需要的时候才 read_file 加载正文(几千字节)。

一台装了几十个 skill 的机器,启动时 context 增加约等于 0;用到的时候才按需展开。

8.4 Settings 三层合并 + permissions.allow 用 Set merge

async load(cwd: string): Promise<Settings> {
  const paths = [
    this.userSettingsPath(),              // ~/.helixent/settings.json
    this.projectSettingsPath(cwd),        // ./.helixent/settings.json
    this.projectLocalSettingsPath(cwd),   // ./.helixent/settings.local.json
  ]
  // ...
}

大多数字段 last-write-wins,permissions.allow 特判用 Set merge(三层并集)。这让:

  • 用户级 allowlist(bash、git 之类跨项目都信任的)在 ~/.helixent/settings.json
  • 项目级 allowlist 在 ./.helixent/settings.json(提交 git 团队共享)
  • 个人临时 allowlist 在 ./.helixent/settings.local.json(gitignored)

allow 用 Set 并集而不是覆盖是一个对的细节——不会出现"项目里加了一条就把全局的全覆盖了"的惊喜。这个细节跟 Claude Code 完全同构,作者显然做过对标。

九、和 Claude Code 对比:Helixent 像什么

把 Helixent 和 CC 逐项对照一下:

对照项HelixentClaude Code
Message transcript单一 union单一 union
Tool 框架defineTool + Zod内部 Zod
Bash/读写/搜索工具全有全有
apply_patch严格 unified diff严格 unified diff
ask_user_question1-4 问题 2-4 选项同样 schema
todo_writemerge=true/false, 4 状态同样 API
Skills 路径约定5 个(含 .agents/skills).claude/skills
SKILL.md frontmattergray-mattergray-matter
AGENTS.md 自动注入✓CLAUDE.md 自动注入
审批需要的工具清单bash/write/patch/mkdir/move同样清单
Settings 三层合并user / project / project.local同样
permissions.allow Set merge✓✓
Slash commands + skills/help /clear + /<skill>/help /clear + /<skill>

几乎一一对应。

Helixent 没有的 CC 特性:MCP、Hooks、subagent(Task tool)、WebFetch、plan mode、多轮 context 压缩。

这些是 CC 的"产品宽度",不是"核心深度"。Helixent 把核心 80% 用 5000 行写完了,剩下 20% 的宽度给生态去长。

笔者读完的整体判断:

这不是一个 CC 的 clone,是一个 CC 的 reference implementation。

如果你想理解 Claude Code 为什么是现在这个形态、里面每个决策背后的 trade-off 是什么,读 Helixent 比读任何 CC 的博客都有效——因为你能看见具体的代码。

十、Checklist:哪些直接能抄

如果你明天就要动手写一个 Coding Agent(或改一个旧的),下面这些东西可以直接从 Helixent 抄:

架构层

  • Message 用一个 union 类型贯穿所有层,provider 适配器做双向转换
  • Model = name + provider + options,provider 接口只有 invoke 和 stream
  • Agent loop 的主 API 是 stream(): AsyncGenerator<AgentEvent>
  • 流式协议返回"累积快照"而不是 delta
  • 中间件接口 = 8 个生命周期 hook + 返回 Partial 被 merge

工具层

  • 每个工具强制 description 作为第一个参数
  • StructuredToolResult 用 tagged union {ok, summary, data/error, code}
  • 按工具名配 tool-result-policy(summaryOnly/maxStringLength)
  • apply_patch 严格 context 匹配,不模糊匹配
  • read_file 的 tool_result 直接返回 raw string,不包 JSON

Agent 执行

  • _act() 用 Promise.race 循环,不用 Promise.all
  • beforeToolUse 支持返回 {__skip: true, result} 跳过执行
  • 错误文本要带引导语(告诉模型下一步该干嘛)
  • AbortController 穿透到 tool,用 signal 参与 race

Skills / Settings

  • Skills = SKILL.md + frontmatter + progressive loading(路径塞 prompt,正文按需 read_file)
  • 审批清单 = "改变 fs 或执行命令的工具"
  • Settings 三层合并,permissions.allow 用 Set 做并集
  • AGENTS.md / CLAUDE.md 自动注入为 user message

UI 层(如果是 Ink TUI)

  • 最新消息用 Ink 渲染,历史消息 flush 到 scrollback 写 stdout
  • 50ms 批处理 setState 防止高频重绘
  • ask_user_question / approval 用单例 Manager + subscribe pattern

这张表大概覆盖了笔者从 Helixent 学到的 80%。

十一、最后一句话

笔者读开源项目一般不会把感想写成一篇文章,但 Helixent 这个让笔者例外了。原因不是它"做了什么新的事"——它做的每件事都不新,都在 Claude Code 里见过、在若干教程里讲过——而是它把每件事都做到了非常干净的样子,干净到你读完代码会产生"原来就这么简单"的错觉。

这种让复杂的事看起来简单的能力,是少数顶级工程师才有的品味。

推荐指数:★★★★★

阅读时间建议:一个完整周末,从 src/foundation/ 开始往上读,跟着依赖方向走。

如果你在做 Agent 相关的任何工作、或者单纯想提升自己的 TypeScript 架构品味——Go read it.

仓库地址:https://github.com/MagicCube/helixent


本文基于 Helixent main 分支 v1.1.0 源码,阅读时间 2026-04-21。后续版本可能有变化,以仓库实际代码为准。