跳到正文

目录

oh-my-pi:把 hash-anchored 编辑做进终端 AI 编程 Agent

oh-my-pi:把 hash-anchored 编辑做进终端 AI 编程 Agent

先给判断

can1357/oh-my-pi(命令名 omp,项目主页 omp.sh)是 Can Bölük 创建、Stencil Labs 维护的终端 AI 编程 Agent,2025-12-31 立项,九个月做到 33.9k Stars、3.7k Forks(2026-10-01 核实)。它 fork 自 Mario Zechner 的 pi-mono,把 “Pi” 从一个底层 LLM 客户端重写成"开箱即用的编码工作流"。

它值得专门拆开看的工程原因有三条:

  1. 编辑格式动了根。绝大多数终端 Agent 给模型一个 str_replace(old, new):模型必须把要替换的整段代码原样抄进调用,缩进、空格、换行、emoji 差一个字符就 String not found,然后进入重试循环,每试一次就把同样的原文重新烧一遍 token。omp 的解法是 hashline——行号加内容哈希做成一等公民锚点,模型只说"第 12 行换成这一句",不重抄原文。
  2. harness(工具编排层)当产品做。14 个 LSP 操作 + 28 个 DAP 操作、流级规则中断、advisor 第二模型盯梢、APFS 级 subagent 隔离,这些在其他工具里算插件生态的事,在 omp 里是内置的。
  3. 热路径不走 fork-exec。约 80k 行 Rust 内核把 bash、ripgrep、glob、find、tree-sitter、BPE 计数全部 in-process 链接,模型每调一次工具不用起一个新进程。

作者给出的基准最能说明第 1 条的分量:Grok Code Fast 1 换上 hashline 后,编辑 pass rate 从 6.7% 跳到 68.3%——模型没换,换的只是编辑格式。这篇文章拆开 hashline 的格式与状态机、harness 的分层结构、这批数字该怎么读,以及什么时候不该选 omp。

快速信息卡

项目信息
Stars / Forks33,917 / 3,658(2026-10-01,GitHub API)
许可证MIT
语言TypeScript(主体)+ Rust(内核约 80k 行)
运行时Bun ≥ 1.3.14,macOS / Linux / Windows 原生
来源fork 自 badlogic/pi-mono,由 Stencil Labs 维护
仓库can1357/oh-my-pi

系统地图:四层组织

omp 是一个 monorepo:16 个 npm 包加一组 Rust crate,按"交互 → 编排 → 工具 → 内核"四层组织:

oh-my-pi
├── 交互层
│   ├── packages/coding-agent/        # CLI 入口与 SDK(omp、omp -p、omp acp)
│   ├── packages/tui/                 # 差分渲染 TUI(Kitty 键盘协议 + PHF 完美哈希)
│   └── packages/collab-web/          # 浏览器 guest client + relay
│
├── 编排层
│   ├── packages/agent/               # Agent runtime、tool calling、状态机
│   ├── packages/mnemopi/             # 本地 SQLite 记忆引擎
│   └── packages/snapcompact/         # 位图帧上下文压缩 + SQuAD 评测
│
├── 工具层(31 个内置工具)
│   ├── read / write / edit / ast_edit / ast_grep / grep / glob
│   ├── bash / eval                   # 运行时(持久 Python + JavaScript 双 kernel)
│   ├── lsp / debug / security_scan   # 代码智能
│   ├── task / wait / todo / ask      # 协调
│   ├── browser / computer / web_search / github / generate_image / tts
│   └── checkpoint / rewind / retain / recall / reflect / memory_edit / learn / manage_skill
│
├── 模型层
│   ├── packages/ai/                  # 多 provider LLM 客户端(流式 + 工具调用)
│   ├── packages/catalog/             # 模型目录 + provider 描述符
│   └── packages/wire/                # collab 协议类型
│
└── Rust 内核(核心 6 crate,~80k 行 + 同量级 vendored 代码)
    ├── crates/pi-natives/           # N-API cdylib 聚合(约 25k 行)
    ├── crates/pi-shell/             # brush-shell 包装的 bash + PTY(约 38k 行)
    ├── crates/pi-ast/               # tree-sitter + ast-grep 包装
    ├── crates/pi-iso/               # APFS/btrfs/zfs/overlayfs 工作区隔离
    ├── crates/pi-walker/            # 并行文件遍历 + grep/glob/workspace 共享缓存
    ├── crates/pi-edit/              # edit 工具引擎:hashline / apply_patch / patch / replace / sloppy 五种模式
    └── crates/vendor/brush-core/    # vendored brush-shell fork

四层的职责切割:

  • 交互层只管输入输出:TUI、CLI 参数、ACP/JSON-RPC 协议、Node SDK
  • 编排层管"Agent 这一轮走哪些工具、context 长什么样"
  • 工具层管每一类操作的具体语义:read 读 PDF/notebook/URL/SQLite,debug 跟 lldb-dap 对话
  • Rust 内核管高频底层操作:搜索、shell、AST、文本宽度、BPE 计数全部 in-process

一个值得注意的迁移:hashline 引擎最早是 TypeScript 包(packages/hashline),2026 年下半年已重写进 Rust crate pi-edit——现在编辑落地、快照管理、stale 恢复都跑在内核里,packages/hashline 已从仓库消失。引用旧路径的资料(包括本文的早期版本)需要更新。

四层之间的调用方向严格自上而下,所有 native 调用经过编排层的 N-API 绑定。

hashline:把锚点做成一等公民

str_replace 失败的根本原因是"模型必须重打一遍要替换的原文"。任何空白、缩进、引号、转义的差异都会让锚点漂移,触发 String not found,模型进入"再试一次"循环——每次都把同样的原文重新放进 prompt 烧 token。

hashline 的解法是双重锚定:

  1. 行号——read 返回的每行带 LINE:TEXT 前缀(12:const greeting = "hi";)
  2. 快照标签——文件头 4 位十六进制哈希,记录"我看到的文件状态"

完整 patch 用 *** Begin Patch / *** End Patch 包裹,文件 section 的头是 [PATH#TAG]。TAG 由快照存储对规范化后的全文算 XXH32 得到:LF 统一换行、剥 BOM、剥行尾空白——所以模型抄不抄错行尾空格根本不影响匹配。

文件 section 内部的操作符(当前语法,来自 crates/pi-edit/prompts/hashline.md):

操作语法含义
替换行PUT N.=M:用 body 替换 N 到 M 行(含两端),单行写 PUT N.=N:
替换块PUT N*:替换 N 行所在的整个语法块(tree-sitter 定位)
前插 / 后插PUT <N: / PUT >N:在 N 行之前 / 之后插入 body
块后插PUT >N*:在 N 行所在块的末尾之后插入(同级深度)
文件头 / 尾PUT <1: / PUT >$:文件首尾插入
删除CUT N.=M / CUT N*删除行范围 / 块,无 body,可加 @名字 存入寄存器
寄存器粘贴PUT <N @r / PUT N.=M @r把寄存器内容粘到空隙 / 覆盖范围
文件级REM / MV DEST删文件 / 改名

核心规则只有一条:body 行只有 +TEXT。没有 -old 行,没有上下文行,没有 unified diff 头——parser 见到 - 行直接拒绝,因为行范围本身已经指名了要改哪些行。

快照的生命周期

每次 read 或 search 返回时,快照存储记录一份文件版本。模型拿到的响应长这样:

[greet.py#A1B2]
1:def greet(name):
2:    msg = "Hello, " + name
3:    print(msg)
4:greet("world")

模型编辑时必须带上 #A1B2。编辑成功一次,引擎给文件算出新哈希、作废旧行号,模型要么从编辑响应里取新行号,要么重新 read。prompt 规则写得很硬:

After EVERY edit tag/numbers change: use edit response or fresh read; stale tag/surprise → STOP, re-read.

如果模型拿旧 tag 编辑,而文件已经被另一个进程(或前一次编辑)改掉了,引擎检测到哈希不匹配:不静默应用,不做"尽力匹配",直接拒绝。Claude Code / Gemini CLI 这类工具在 str_replace 失败时通常会重试两三次再放弃,hashline 在第一次 stale 就 STOP。

拒绝之后还有一层恢复:pi-edit 的 recovery 模块会把旧快照上的锚点尝试映射到当前文件——文件被外部改过、行号偏移、会话链断裂分别有对应的告警路径;映射不干净才真正报错。快照本身有 LRU 上限(每路径 4 个版本、总量 64 MB、单文件 4 MB 以上不快照),连续三次完全相同的 no-op 编辑会触发硬上限中断,防止模型烧 token 打转。

一次真实编辑:str_replace vs hashline

假设模型要在一个 Python 文件里加一行守卫,并把字符串拼接换成 f-string。原文:

def greet(name):
    msg = "Hello, " + name
    print(msg)

greet("world")

str_replace 风格(Claude Code / Gemini CLI / Aider):

# 调用 1:插入守卫
str_replace(
  file_path="greet.py",
  old_string='def greet(name):\n    msg = "Hello, " + name',
  new_string='def greet(name):\n    if not name: name = "stranger"\n    msg = "Hello, " + name'
)

# 调用 2:替换字符串拼接为 f-string
str_replace(
  file_path="greet.py",
  old_string='    msg = "Hello, " + name',
  new_string='    greeting = "Hi"\n    msg = f"{greeting}, {name}"'
)

# 调用 3:删除 print
str_replace(
  file_path="greet.py",
  old_string='    print(msg)\n\ngreet',
  new_string='greet'
)

每次调用都要把原文重打一遍。第二次调用的 old_string 必须是第一次插入之后的实际内容,第三次同理。三次调用三段原文。

hashline 风格(omp,一个 patch 搞定):

*** Begin Patch
[greet.py#A1B2]
PUT >1:
+    if not name: name = "stranger"
PUT 2.=2:
+    greeting = "Hi"
+    msg = f"{greeting}, {name}"
CUT 3
*** End Patch

模型不重打任何已有行——所有锚点都是行号,输入不到 str_replace 一半。

更重要的差异是失败率。str_replace 在这些场景会失败:缩进用了 tab 而原文是空格、原文有模型看不见的 BOM、行尾有 trailing whitespace、CRLF 对 LF、文件在 Agent 思考期间被 formatter 改过、原文里的中文注释或 emoji 被转义错。hashline 对这些不敏感——它操作的是规范化后的行号与行内容,读入时怎么规范化,编辑时就怎么还原。

基准数据怎么读

作者在 The Harness Problem 里给的数字(README 同步引用):

模型指标数字说明
Grok Code Fast 1编辑 pass rate6.7% → 68.3%换编辑格式,其余不动
Gemini 3 Flashpass rate+5pp超过 Google 自家的 str_replace 实现
Grok 4 Fast输出 token−61%重试循环消失后输出塌缩
MiniMaxpass rate2.1×同权重同 prompt,仅 harness 变

这批数字测的是同一批编辑任务在只更换编辑格式时的通过率——模型、权重、prompt 不变,变的只有 harness。所以数字反映的是锚定机制的质量,而不是模型能力的变化;6.7% 到 68.3% 的跃迁说明这些模型本来就能做对,之前是死在 anchor 漂移上。

反过来,有三件事不能从这批数字推出:一,omp 在端到端软件工程基准上整体优于 Claude Code——这里只测了"编辑落地"这一环;二,hashline 对强模型收益一样大——Gemini 3 Flash 只涨 5pp,说明 harness 的收益上限受模型自身格式遵循能力影响;三,这是作者自家的基准,尚无第三方复现,读的时候打折是应该的。

与 Claude Code / Gemini CLI / Aider 的对比

维度oh-my-piClaude CodeGemini CLIAider
锚定方式hashline(行号 + 4-hex tag)str_replace(字符串匹配)str_replacesearch/replace 块
工具面31 内置 + 14 LSP + 28 DAPBash/Read/Write/Edit/Grep/Glob 等同 Claude CodeShell 命令 + architect 编辑模式
LSP rename 走 willRenameFiles✅❌❌❌
内置 DAP 调试(lldb/dlv/debugpy)✅❌❌❌
持久 Python + JavaScript 双 kernel✅(eval)❌❌❌
浏览器自动化✅(Puppeteer + stealth)❌❌❌
工作区隔离(APFS clone / reflink)✅(pi-iso)❌❌❌
bash / ripgrep in-process✅(brush + pi-builtins)❌(fork-exec)❌❌
Provider 数60+Anthropic 为主Google 为主任意 OpenAI 兼容
per-role 路由✅(9 个角色)❌❌❌
LicenseMIT商业Apache-2.0Apache-2.0

几个关键差异:

  • LSP rename:其他工具的 rename 是文本替换,不知道 re-exports 和 barrel 文件的拓扑。omp 调 workspace/willRenameFiles,TypeScript 项目的 import alias、barrel re-export、跨文件引用一次性正确更新。
  • DAP 调试:其他工具遇到 segfault 还是让 Agent 加 print。omp 可以 attach lldb-dap 到二进制、设断点、单步、读 frame 变量;Go 服务挂起就 attach dlv 走 goroutine。
  • In-process bash:brush(vendored 的 Rust bash 实现)让 session 状态跨调用保留,cd、环境变量、alias 不丢;ls、sed、sort、xargs、jq 等数十个常用命令被移植成 in-process builtin。其他工具每条命令 fork 一个新 bash 进程。
  • APFS 隔离:pi-iso 在 macOS 用 clonefile(2),Linux 用 btrfs/zfs reflink,几乎零成本给每个 subagent 一份独立 worktree;其他工具的 subagent 共用工作区,会互相踩。

代价也实在:monorepo 体积大,首次安装要 bun ≥ 1.3.14(也有 curl/brew/nix/PowerShell 安装脚本),自定义 provider 要写 YAML。但对长任务——多文件重构、debug session、subagent fan-out——这些代价换回的是模型不重打原文、不反复 read 验证环境、不等 fork-exec。

Rust 内核:约 80k 行在做什么

packages/natives 是 N-API addon,把 Rust crate 暴露给 Node。核心 6 个 crate 的分布(README per-crate 表,仅代码行):

Crate功能约行数
pi-shell内嵌 bash 引擎 · 持久 session · in-process coreutils 分发38,000
pi-nativesN-API 表面——下表所有模块的宿主25,000
pi-walker并行 ignore 感知遍历 + grep/glob/workspace 共享扫描缓存5,200
pi-iso工作区隔离:apfs / btrfs / zfs / reflink / overlayfs / projfs3,300
pi-asttree-sitter + ast-grep 匹配、块解析、结构化摘要2,900
pi-voice音频采集/播放 · Opus · WebRTC1,000

pi-natives 内部按模块划分(glue 和测试不计):

模块功能底层库约行数
desktop窗口/显示枚举 · 截图 · 原生输入 · AX 树(computer 工具背后)xcap · enigo · OS AX FFI10,600
grepregex 搜索 · 并行/串行 · glob/type 过滤 · fuzzygrep-regex · grep-searcher3,280
textANSI 感知宽度 · 截断 · 列切片 · SGR 保留 wrapunicode-width · segmentation2,070
snapcompact位图帧光栅化 + PNG 编码(上下文压缩用)image · png1,760
keysKitty 键盘协议 + xterm fallback · PHF 完美哈希phf1,740
astast-grep 模式匹配与结构化改写ast-grep-core1,510
diff结构化文件 diffin-tree1,030
ptysudo / ssh 交互式 prompt 的 PTYportable-pty630
crash_handler原生 crash 捕获与上报in-tree610
highlight语法高亮 · 11 语义类别 · 30+ aliasessyntect550
appearanceMode 2031 + macOS 深浅色(CoreFoundation FFI)core-foundation450
tasklibuv 线程池阻塞任务 · 取消 · 超时 · profilingtokio · napi440
globglob 发现 + type 过滤 + mtime 排序 + gitignoreignore · globset430
fdfind 工具的文件遍历ignore385
clipboard系统剪贴板文本/图片(不依赖 xclip/pbcopy)arboard370
workspacegitignore + AGENTS.md 单遍扫描ignore275
powermacOS 电源断言(阻止休眠)IOKit FFI270
prof环形 buffer profiler + folded-stack + SVG 火焰图inferno240
file_lock跨进程咨询锁in-tree210
ps跨平台进程树 kill + 后代列举libc · libproc · CreateToolhelp32Snapshot195
tokensO200k / Cl100k BPE 计数(两表内嵌)tiktoken-rs70
htmlHTML → Markdown(可选内容清理)html-to-markdown-rs60
sixel终端图像渲染(PNG/JPEG/WebP/GIF → SIXEL)icy_sixel · image55

几个值得注意的设计选择:

  • shell 用 brush 而非 tokio::process:brush-shell 的 fork 被 vendored 进仓库,bash 是图灵完备的,跑一个进程内实现比每次 fork 外部 bash 更可控,session 状态可以持久;常用外部命令也被移植进 builtins crate,热路径零 fork/exec。
  • tokens 内嵌 BPE 表:O200k 和 Cl100k 两张表常驻内存,token 计数不需要调外部服务或起 Python。
  • iso 用平台抽象层:macOS APFS clone、Linux btrfs/zfs reflink、overlayfs、projfs 各走各的系统调用,接口统一。
  • desktop 是新的大头:1.06 万行,撑起 computer 工具——枚举窗口、截屏、原生输入、走系统无障碍树,让 Agent 直接操作桌面。
  • phf 完美哈希:keys 模块编译时生成 O(1) 查表,键盘事件零间接跳转。

平台编译目标:linux-x64/arm64、darwin-x64/arm64、win32-x64/arm64,x64 附带 AVX2 与 baseline 双二进制。同一个 omp 二进制在三大平台原生跑,不要求用户机器上装好 rg/grep/find/bash。

长任务工作流

hashline 是底座,omp 在它之上放了一组长任务能力:

Time-traveling stream rules。规则平时休眠,模型输出流一旦匹配某个 regex(比如 Box::leak),流在 token 级立即中断,规则作为 system reminder 注入,模型从同一位置重试。README 的演示:模型正要写 Box::leak,流被截断并注入"不要在生产代码路径用 Box::leak",模型改用 Arc<str> 并向用户确认。注入跨 compact 存活,同一规则下次触发不用重学。

/advisor。给 advisor 角色配一个 review 模型(比如 openai-codex/gpt-5.5),它在自己独立的 context 和模型上读主 Agent 的每一轮,注入 inline 备注——aside、concern、blocker 三档。主 Agent 看到备注要么修正,要么解释为什么不改。做事的模型不被 review prompt 污染上下文。

/collab。把 live session 挂到本地 relay,发回 omp join <id> 命令、my.omp.sh 链接和 QR 码。队友从另一台终端 omp join 接入,或浏览器只读围观;/collab view 是纯只读链接。帧在客户端封存,relay 拿不到你的密钥。

/review。起 reviewer subagent 并行扫 branch / 单个 commit / 未提交改动,每个输出带 P0–P3 优先级和置信度的结构化 issue,主 Agent 聚合成排序清单加一句 verdict:ships / ships with fixes / blocks。

记忆。retain 写事实,recall 拉原始记忆,reflect 在记忆库上合成答案,learn 沉淀可复用经验并可以晋升为托管 skill。记忆引擎用 memory.backend 选择——本地 SQLite(mnemopi)、Hindsight 或其他后端,按项目隔离:A 项目学到的不会污染 B 项目。session 结束时压缩成 mental model,下次该项目第一轮自动加载。

Subagent 与 Agent Hub。task 把工作拆给并行 worker,各自跑在隔离 worktree 里,返回 schema 校验过的结构化结果,父级直接读字段不用解析散文。Alt+A 打开 Agent Hub 看每个 worker 的实时转录、用量,可以插话或停掉卡住的 worker。

统一 :// 命名空间。read pr://1428 和 read src/foo.ts 返回同一结构;agent://<id>/findings.0.path 直接按路径取 subagent 输出的某个字段。PR、issue、skill、冲突(conflict://N 配 @theirs/@ours/@base)都是文件系统形状,模型学一个接口就够。

接入方式五个入口:omp 交互 TUI、omp -p 单次 prompt、Node SDK(@oh-my-pi/pi-coding-agent,session 发 typed event)、omp --mode rpc(NDJSON over stdio)、omp acp(Agent Client Protocol,Zed 等编辑器驱动)。

适用边界与选型

适合 omp 的场景:

  • 多文件长任务:跨 5+ 文件的重构、debug session、workspace 级搜索替换
  • 吃 LSP/DAP 的项目:TypeScript / Rust / Go / C++,rename 引用、attach debugger 是日常
  • 强 harness 需求:advisor、流级规则、项目记忆、隔离 subagent 你都要
  • 多 provider 切换:plan 角色用 Opus、smol subagent 用便宜模型、reviewer 用另一家,9 个角色各配各的
  • Windows 原生环境:omp 原生跑 win32-x64/arm64,不用 WSL 桥

不适合的场景:

  • 要 IDE 原生体验:Zed(ACP)能拿到 in-editor 体验,但和 Cursor / Windsurf 的"左边编辑器右边聊天"比,omp 还是 terminal first
  • 要零配置开箱:安装要 bun 或安装脚本,自定义 provider 要手写 YAML;Claude Code 的开箱度更高
  • 锁定单一厂商:想保持 Anthropic-only 团队,omp 的 60+ provider 开放度是负担不是特性
  • 轻量小项目:200 行的 Python 脚本,str_replace 足够,hashline 加 31 个工具的复杂度过剩
  • 要云端托管:omp 是本地 CLI,没有 hosted 版

选型速查:

需求首选次选
长任务 + 多文件 + 强 harnessoh-my-piClaude Code
强 IDE 集成 + 云端CursorWindsurf
终端 + 多 provider + 跨平台oh-my-piAider
一次性脚本、低学习成本Claude Code / Gemini CLIAider
真实 LSP rename / DAP 调试oh-my-piCursor

一句话收尾:omp 把"模型改文件"这一件最频繁的事从概率游戏改成了确定性协议——行号和哈希不会含糊,stale 就拒绝,恢复有兜底。如果你的日常工作是多文件长任务,它值得装一次试试;如果你的场景是单文件小修,str_replace 系工具依然是更省心的选择。

常见问题

stale tag 报错怎么处理? 这是设计行为而非故障:文件在模型读之后被改过(手动编辑、formatter、另一个 Agent)。让模型重新 read 目标文件再发 patch 即可;正常情况下 recovery 会自动重映射锚点,只有映射不干净才需要人工介入。

生产环境要注意什么? 模型成本按角色分开预算(9 个角色的用量在 Agent Hub 里能看到);github、generate_image、tts、记忆工具等默认关闭,启用前确认数据出境策略;hashline 拒绝 patch 是保护机制,不要在包装层里绕过它。

许可证和商用? MIT。vendored 的 brush-core 等第三方代码保留各自上游许可证,细节见仓库的 THIRD-PARTY-NOTICES.txt。

遇到问题去哪? 文档在 omp.sh,源码和 issue 在 GitHub 仓库,社区在 Discord。

参考链接

文中 stars/forks、工具数、LoC 数据核实于 2026-10-01 的 GitHub API 与 main 分支 README;hashline 语法以 crates/pi-edit 当前源码为准。

参与讨论

使用 GitHub 登录。欢迎补充事实、异议与实践。