一句话定位
通过 --ssh user@host 把 pi 的内置工具(read/write/edit/bash)全部切换到远程机器执行------221 行,工具改写(依赖注入换执行环境)的完整范例。
作用(为什么存在)
前三期拆过拦截、生命周期、行为替换,这期是工具改写:让 Agent 直接操作远程机器,而不是只在本地干活。
它的核心机制非常优雅:内置工具工厂接受 operations 参数 ------createReadTool(localCwd, { operations }) 替换底层实现,工具定义(参数、描述、渲染)完全不变。pi 只依赖输入输出契约,不关心真实实现是本地还是 SSH。
更妙的是一个插件同时支持两种模式 :--ssh 传了就远程执行,不传就完全走本地------插件对普通用户是透明的。
关键信息
| 项 | 内容 |
|---|---|
| 源码位置 | examples/extensions/ssh.ts(221 行) |
| 核心 API | registerFlag/getFlag + registerTool(同名覆盖内置)+ createReadTool 等(注入 operations)+ on("user_bash") + on("before_agent_start") |
| 插件类型 | 工具改写型(依赖注入换执行环境) |
触发流程 / 数据流
lua
pi -e ssh.ts --ssh user@host
→ 工厂期:registerFlag("ssh") 注册 --ssh;创建本地工具实例;同名覆盖 read/write/edit/bash
→ session_start:getFlag("ssh") 解析远程地址
├─ 含 :path → 直接用
└─ 无 path → 远程执行 pwd 自动探测
→ 工具 execute:getSsh() 有值 → 用远程 ops 重建工具 → 远程执行;无值 → 本地
→ user_bash(! 命令):SSH 激活时返回远程 bash ops
→ before_agent_start:改 system prompt 的 cwd,让 LLM 认知工作在远程
架构 / 流程

关键代码解读
typescript
// ① operations 注入:替换工具底层实现(ssh.ts:47-62)
function createRemoteReadOps(remote, remoteCwd, localCwd): ReadOperations {
const toRemote = (p) => p.replace(localCwd, remoteCwd); // 本地路径 → 远程路径
return {
readFile: (p) => sshExec(remote, `cat ${JSON.stringify(toRemote(p))}`),
access: (p) => sshExec(remote, `test -r ${JSON.stringify(toRemote(p))}`).then(() => {}),
detectImageMimeType: async (p) => { /* file --mime-type ... */ },
};
}
// ② 同名覆盖内置工具:SSH 激活时换远程实现(ssh.ts:128-140)
pi.registerTool({
...localRead, // 定义(name/description/parameters)不变
async execute(id, params, signal, onUpdate, _ctx) {
const ssh = getSsh();
if (ssh) {
const tool = createReadTool(localCwd, { operations: createRemoteReadOps(ssh.remote, ssh.remoteCwd, localCwd) });
return tool.execute(id, params, signal, onUpdate); // 换成远程执行
}
return localRead.execute(id, params, signal, onUpdate); // 默认本地
},
});
// ③ session_start 延迟解析:工厂期 CLI flag 不可用(ssh.ts:184-199)
pi.on("session_start", async (_event, ctx) => {
const arg = pi.getFlag("ssh") as string | undefined; // 此时 flag 才可读
if (arg) {
if (arg.includes(":")) {
const [remote, path] = arg.split(":");
resolvedSsh = { remote, remoteCwd: path };
} else {
const pwd = (await sshExec(arg, "pwd")).toString().trim(); // 无路径→远程 pwd 探测
resolvedSsh = { remote: arg, remoteCwd: pwd };
}
ctx.ui.setStatus("ssh", ctx.ui.theme.fg("accent", `SSH: ${resolvedSsh.remote}:${resolvedSsh.remoteCwd}`));
}
});
亮点 / 踩坑
亮点 1:依赖注入换执行环境。 operations 是接口契约(Read/Write/Edit/Bash 四个接口各不同),注入实现即可换环境------工具定义、LLM 感知、渲染全都不变。这是「面向接口编程」在插件层的落地。
亮点 2:生命周期分离。 注册动作(registerFlag/registerTool)在工厂期做,读运行时状态(getFlag)必须等 session_start------因为工厂期 CLI flag 还没注入。一个 resolvedSsh = null 占位 + getSsh() 现查,让本地/远程两种模式同时成立。
踩坑:只支持 key 认证。 源码注释明确「SSH key-based auth (no password prompts)」,且 spawn("ssh", ..., { stdio: ["ignore", "pipe", "pipe"] }) 把 stdin 设成 ignore------远程要密码时没法输入,只能等超时 。现象:每次工具调用卡十几秒到几十秒(17s/53s),全是等密码超时。解法:配置 SSH 免密(ssh-keygen + ssh-copy-id + 远程 chmod 700 ~/.ssh && chmod 600 authorized_keys)。诊断口诀:先直连 time ssh host "echo ok" 排除网络,再查认证方式。
边界(Limitations)
| 边界 | 表现 |
|---|---|
| 认证 | 必须 SSH key 免密(stdin ignore 无法输密码) |
| 生效时机 | --ssh 在 session_start 才解析;无 flag 时完全走本地 |
| 路径映射 | toRemote 用 localCwd → remoteCwd 字符串替换,路径结构不同会错 |
| 覆盖范围 | read/write/edit/bash 四个内置工具 + 用户 ! 命令 |
场景(Scenarios)
- SSH 模式 :
pi -e ssh.ts --ssh user@host或user@host:/path→ 所有工具远程执行,状态栏显示 SSH - 本地模式:无 --ssh → 完全等同内置工具(插件透明)
- 无路径 :
--ssh user@host→ session_start 时远程 pwd 探测 - 失败:认证失败 / 远程无 bash → sshExec reject → 工具报错
可借鉴的模式
- 依赖注入换执行环境 :工具工厂接受
operations替换底层实现------「定义不变、实现可换」。pi 只依赖输入输出契约。 - 同名覆盖内置工具:registerTool 注册与内置同名工具即可接管行为------LLM 感知的工具接口完全不变。
- 工厂期/运行期分离:注册动作工厂期做,读运行时状态 session_start 才做------生命周期决定 API 可用阶段。
- JSON.stringify 防注入:命令拼接时把路径序列化成带引号的合法字面量,防止空格/特殊字符破坏 shell(也是命令注入防护)。
- system prompt 改写:before_agent_start 改 cwd,让 LLM 的认知与实际执行环境一致。
一句话总结
221 行教你「工具改写」:依赖注入换执行环境、同名覆盖接管内置工具、工厂期/运行期分离------一个插件同时支持本地和远程两种模式。