AI 参与说明(Agent:Grok):本文根据 pi-hashline-edit-pro 6.2.0 的 README,以及 2026-10-08 在一台 Linux 机器上的实际安装和运行记录整理。环境是 Node v22.19.0、
@earendil-works/pi-coding-agent1.1.0、pi-hashline-edit-pro6.2.0。文中的工具输出都是真实运行结果,但驱动工具调用的是一个脚本化的假模型,不是真实 LLM,原因见「实测」一节。社区扩展不代表 Pi 官方背书,安装前请自行审查源码和权限。执行入口 Grok Bot;模型标识与 reasoning effort 未取得运行记录;提供方 xAI。
pi-hashline-edit-pro 是 Pi Coding Agent 的一个编辑扩展。它给读出来的每一行发一个 4 个字母的锚点,模型改文件时只能按锚点指定要改哪一行。 文件在模型读过之后被别人改了,编辑会被直接拒绝,不会悄悄改到错误的位置。
本文先讲它为什么能做到这一点,再给出安装步骤、真实运行输出,以及使用时要注意的地方。
原理:为什么按锚点编辑能拒绝过期修改
问题出在「读」和「写」之间
Coding Agent 改文件分两步:先读,再写。两步之间,文件可能已经变了,比如你在编辑器里顺手改了一行,或者另一个 agent 刚提交了修改。
常见的两种定位方式,在这种情况下都可能出错:
| 定位方式 | 文件在读后被改了,会怎样 |
|---|---|
| 行号 | 上面插入或删除了行,行号整体偏移,改到别的行 |
| 搜索替换 / 模糊匹配 | 模糊匹配可能命中相似的行;精确替换(如 Pi 内置 edit 要求 oldText 唯一)不会错位,但只要旧文本还在就照改 |
| 锚点(本扩展) | 锚点只对应一个文件里的一行;对不上就拒绝 |
锚点:分配的,不是算出来的
扩展每读出一行,就从一张表里分配一个还没用过的锚点,比如 USfk。关键是锚点是分配的,不是根据内容算出来的:
- 两行内容完全一样,也会拿到不同的锚点。下面的实测里,两行
return a + b;分别是USfk和vsAR。 - 一个锚点同一时间只属于一个文件的一行。行被改了、文件被重写了,旧锚点就作废。
每一行另外还有一个内容校验和(去掉行尾空白后,用 xxhash 计算)。编辑时,扩展会检查这一行现在的内容和模型当初看到的是否一致。
类比:乐观并发控制
这和数据库里的乐观并发控制(或者 CPU 的 CAS,compare-and-swap)是同一个思路:
- 读的时候记下「我看到的版本」(锚点 + 校验和)。
- 写的时候带上这个版本。
- 版本对得上才写;对不上就拒绝,让调用方拿到最新状态再试。
它不加锁,所以不会挡住别人改文件。代价是冲突时要重试一次。
sequenceDiagram
participant M as 模型
participant E as 扩展
participant F as 文件
M->>E: read calc.ts
E->>F: 读取
E-->>M: USfk│ return a + b;(发锚点)
Note over F: 有人在编辑器里改了第 2 行
M->>E: replace USfk → 新内容
E->>F: 检查 USfk 是否还有效
E-->>M: E_STALE_ANCHOR,请重新 read
M->>E: read calc.ts
E-->>M: nvjl│ return a + b; // changed…(新锚点)
M->>E: replace_match nvjl
E->>F: 写入
E-->>M: 编辑后的 diff,带新锚点
两种拒绝,处理方式不同
| 错误码 | 什么时候出现 | 怎么处理 |
|---|---|---|
E_STALE_ANCHOR | 锚点在当前会话里已经无效:从没发给过你,或者那一行被改过、文件被重写过 | 重新 read,拿新锚点 |
E_RANGE_STALE | 要替换的范围里,有一行和上次看到的不一样了 | 错误信息直接带回这个范围的新锚点,不用重新读 |
注意:README 开头的对比表写的是两种拒绝都会「返回新锚点」,但同一份 README 的错误码表和故障排查都写着 E_STALE_ANCHOR 要重新 read。下面的实测结果和错误码表一致。
安装
前提条件:README
@earendil-works/pi-coding-agent0.99.0 或更高版本。- Node.js 22.19 或更高(扩展用
node:sqlite保存状态)。 - 官方说明:pi 发布版自带的 Bun 没有
node:sqlite,需要用 Node 运行 pi,或者用带bun:sqlite的 Bun。否则所有工具都会报E_STORE_UNAVAILABLE。这一条我没有实测。
安装:
pi install npm:pi-hashline-edit-pro
我这次的实际输出:
Installed npm:pi-hashline-edit-pro
确认已安装:
pi list
User packages:
npm:pi-hashline-edit-pro
卸载:
pi uninstall npm:pi-hashline-edit-pro
装上之后会话里会变什么
| 内置工具 | 变化 |
|---|---|
read | 被替换,输出变成 锚点│内容 |
edit | 被禁用 |
grep | anchor_grep 开启时被禁用 |
write | 保留,写完会自动附上一段带新锚点的内容 |
bash | 不变 |
如果你的提示词、skill 或 hook 依赖 read 输出行号,装之前要先改好。
实测:真实输出
怎么测的
为了让结果可复现,我没有调用真实的 LLM,而是用 pi SDK 起了一个真实的 pi 会话,加载真实的扩展,再用 pi-ai 自带的 fauxProvider(脚本化的假模型)按固定顺序发出工具调用。
所以工具的执行和输出都是真的,只有「模型决定调用什么」是写死的。这能验证扩展的行为,但不能说明真实模型用这些工具用得好不好。
会话加载扩展后,活跃的工具是:
read, bash, write, replace, replace_match, insert, copy, move, anchor_grep, undo_last_change
1. 读文件:重复的行,锚点也不同
rcBF│export function add(a: number, b: number) {
USfk│ return a + b;
Chbx│}
irvg│
NrDO│export function sub(a: number, b: number) {
vsAR│ return a + b;
beBK│}
sub 函数写错了,应该是减法。两行 return a + b; 内容完全一样,锚点分别是 USfk 和 vsAR,所以可以准确地只改第二行。
2. 按锚点替换一行
调用:
{ "remove_from": "vsAR", "remove_to": "vsAR", "text": " return a - b;" }
返回的是编辑后的 diff,新行带着新锚点 GYdm:
NrDO│export function sub(a: number, b: number) {
-vsAR│ return a + b;
+GYdm│ return a - b;
beBK│}
- 行的锚点已经作废,+ 行和上下文行的锚点可以直接用于下一次编辑,不用重新读。
3. 文件在外面被改了:拒绝
测试脚本在模型不知情的情况下,把第 2 行改成了 return a + b; // changed by someone else。模型仍然用旧锚点 USfk 去改:
[E_STALE_ANCHOR] 2 stale anchors in calc.ts: "USfk", "USfk". The file changed since read. Call read() on calc.ts for fresh anchors.
文件没有被写入。重新 read 后,这一行拿到了新锚点:
nvjl│ return a + b; // changed by someone else
4. 只改一行里的一部分:replace_match
调用:
{ "replace_from": "nvjl", "replace_to": "nvjl", "old_string": " // changed by someone else", "new_string": "" }
rcBF│export function add(a: number, b: number) {
-nvjl│ return a + b; // changed by someone else
+SMjc│ return a + b;
Chbx│}
replace_match 只替换指定的子串,这一行的其他字符不用重打一遍,就不会因为抄错一个字符而改坏。
5. 撤销
{ "path": "calc.ts" }
rcBF│export function add(a: number, b: number) {
-SMjc│ return a + b;
+nvjl│ return a + b; // changed by someone else
Chbx│}
撤销后,原来的锚点 nvjl 也恢复了。
6. 范围中间被改了:带回新锚点
先读一个配置文件:
AhNA│[server]
fpse│port = 8080
LNuj│host = localhost
svWE│[end]
测试脚本把中间的 port = 8080 改成 port = 9090,首尾两行不动。模型用 AhNA 到 svWE 替换整段:
[E_RANGE_STALE] Line 2 of the replaced range (lines 1-4) in cfg.txt does not match what was shown. Current range with fresh anchors:
AhNA│[server]
WWfk│port = 9090
LNuj│host = localhost
svWE│[end]
Retry with the fresh anchors above without a read.
即使首尾锚点都还有效,中间一行变了也会被拒绝,同时直接给出新锚点,不用再读一次。
7. 其他几个真实错误
[E_UNDO_NONE] No undo history for notes.md.
在还没有任何编辑时撤销。
[E_BAD_SHAPE] Edit request contains unknown or unsupported fields: path. Path resolution is anchor-only; retry without `path`.
默认情况下,replace 等编辑工具不接受 path 参数,文件完全由锚点决定。想要 path 的话,可以在 /hashline-config 里打开 Require path,打开后 path 必须和锚点所属的文件一致。
[E_UNDO_STALE] Cannot undo last change on notes.md: the file was modified after the edit, so nothing was reverted and the file was left untouched. The undo record is kept. Do not edit the file to force the undo. Call read() to inspect the current state.
编辑之后文件又被外部改过,撤销会被拒绝,不会覆盖别人的修改。
8 个工具
扩展注册了 8 个工具:README · Tools
| 工具 | 作用 |
|---|---|
read | 读文件,每行带锚点 |
replace | 按首尾锚点替换一段行;text 为空字符串时就是删除 |
replace_match | 在锚点范围内替换子串,其他字符不动 |
insert | 在某个锚点行的前面或后面插入 |
copy | 把一段行复制到另一个锚点后面,可以跨文件 |
move | 把一段行移动到另一个锚点后面,可以跨文件 |
anchor_grep | 基于 ripgrep 的搜索,结果行带锚点,可以直接拿去编辑 |
undo_last_change | 撤销某个文件最近一次编辑 |
同一条消息里对同一个文件的多次编辑会合并成一个批次:全部校验通过才一起写入,任何一个失败就整批都不写,一次撤销回退整批。
使用时要注意
撤销记录里有完整的文件内容
撤销表保存了每个文件最近一次编辑前后的完整文本。README 明确提醒要把这个存储当作敏感数据。
- 默认位置是
~/.config/pi-hashline-edit-pro/。设置了XDG_CONFIG_HOME时,在$XDG_CONFIG_HOME/pi-hashline-edit-pro。 - 在 POSIX 系统上,目录权限是
0700,数据库是0600。我实测的状态目录和数据库也是这两个权限。 - 想隔离的话,启动 pi 前把
PI_HASHLINE_DIR设为一个绝对路径。相对路径会报E_CONFIG。
撤销是按路径共享的
锚点归属是按会话隔离的,但撤销记录按文件路径保存,不按会话。在一个进程同时跑多个对话的情况下,任何一个对话都可以撤销另一个对话对同一文件的最近一次编辑。
另外,撤销只有一级,并且会跨重启保留。一次成功的 write 会清掉这个文件的撤销记录。
限制
| 限制 | 值 |
|---|---|
| 单文件行数 | 1,353,139 行(锚点表的大小) |
| 单文件大小 | 100MB |
| 单次输出 | 2000 行 / 50KB |
| 长行校验 | 只用前 500 字节计算校验和 |
超出行数或大小限制时,用 write 整个重写。
什么时候不适合
- 你的工作流依赖
read输出行号。 - 你用的是 pi 发布版自带的 Bun,而且换不了运行时。
- 主要是整文件生成,而不是局部修改。这时
write就够了,锚点帮不上什么忙。
和其他编辑方式比较
| 方式 | 定位依据 | 文件被改后 | 一次编辑的 token |
|---|---|---|---|
Pi 内置 edit(精确文本替换,oldText 必须唯一) | 旧文本 | 旧文本仍能唯一匹配就照改,不感知读后其他行的变化 | 要抄旧文本 |
| 行号 | 行号 | 可能改到偏移后的行 | 少 |
| pi-hashline-edit-pro | 锚点 + 校验和 | 拒绝 | 每个锚点 2 个 token(README 说明) |
README 说锚点表里的每个锚点由两个各占 1 个 token 的片段组成,所以一个锚点在读和编辑时都只占 2 个 token。这个数字来自作者在 README 里列出的几种开源模型 tokenizer,不同模型可能不同。
项目在 README 里给出了第三方的 Explicit Edit Benchmark(226 个确定性的逐字节编辑任务)结果入口。本文没有复核这些分数,所以不引用具体数字。
来历
这个项目 fork 自 RimuruW 的 pi-hashline-edit,hashline 的概念和最早的实现来自 can1357 的 oh-my-pi。「pro」版本加的是 tokenizer 友好的 4 字符锚点,以及「分配而非计算」的锚点身份。
相关阅读
- Pi Coding Agent 入门与社区玩法:Pi 的安装、Session 和扩展体系。
- omp:把 Coding Agent 接入 IDE 的开源终端工具:hashline 概念最早出现的项目。
- Agents 专栏总览
资料来源(2026-10-08 读取 / 运行)
| 来源 | 用途 |
|---|---|
| pi-hashline-edit-pro README(6.2.0) | 原理、工具、限制、错误码、隐私说明 |
| npm:pi-hashline-edit-pro | 版本 |
| earendil-works/pi | Pi Coding Agent |
| 本机运行记录 | Node v22.19.0、pi 1.1.0、扩展 6.2.0,fauxProvider 驱动的真实工具输出 |