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 Hooks | Helixent 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 循环:
- 把所有 tool 的 promise 塞进
pending - 每轮 race 谁先 resolve 就处理谁,立即 yield 出去
- 从 remaining 里删掉,继续 race 剩下的
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 逐项对照一下:
| 对照项 | Helixent | Claude Code |
|---|---|---|
| Message transcript | 单一 union | 单一 union |
| Tool 框架 | defineTool + Zod | 内部 Zod |
| Bash/读写/搜索工具 | 全有 | 全有 |
| apply_patch | 严格 unified diff | 严格 unified diff |
| ask_user_question | 1-4 问题 2-4 选项 | 同样 schema |
| todo_write | merge=true/false, 4 状态 | 同样 API |
| Skills 路径约定 | 5 个(含 .agents/skills) | .claude/skills |
| SKILL.md frontmatter | gray-matter | gray-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。后续版本可能有变化,以仓库实际代码为准。