背景
我在 Pi Agent 中同时安装了 @georgedong32/permission-modes 和 pi-zentui。两者单独使用都没什么问题,放在一起后却有两个地方不太顺手:
- 我想保留 Zentui 的 Starship Footer,同时在 Footer 中看到当前权限模式。
- 我希望用一个简单的
/effort 命令查看或调整模型的 Thinking 强度。
当时的环境是:
- Pi:
0.84.2 - Permission Modes:
@georgedong32/permission-modes@2.6.3 - UI:
pi-zentui
这两个需求最后都通过本地 Extension 解决了。不过在动手之前,需要先理清 Pi 的插件加载方式,以及哪些 UI 能力可以叠加、哪些不能。
这次用到的 Extension 机制
Pi Extension 是由 Pi 直接加载的 TypeScript 模块,不是单独运行的进程。本文实际涉及的接口不多:用 registerCommand() 注册 /effort,用 pi.on() 管理会话生命周期,再通过 setStatus()、setFooter() 和 Thinking API 处理界面状态。
两个自定义功能都属于个人工作流,所以我把文件放在全局目录 ~/.pi/agent/extensions/,而不是项目内的 .pi/extensions/。
其中,权限模式桥接需要一个定时器。这个定时器不能在 Extension 重载后继续握着旧的 ctx,因此要跟着会话启动和关闭:
1
2
3
4
5
6
7
| pi.on("session_start", (_event, ctx) => {
// 创建当前会话使用的 timer
});
pi.on("session_shutdown", () => {
// 清理 timer
});
|
执行 /reload 时,Pi 会先触发旧实例的 session_shutdown,重新加载 Extension 后,再触发新实例的 session_start。后面的桥接代码也按这个方式处理。
问题出在 setStatus() 和 setFooter() 的语义不同。setStatus(key, value) 可以按 key 叠加,多个 Extension 发布的状态能够交给 Zentui 一起展示;setFooter(factory) 则会替换整个 Footer,后调用的一方会覆盖前一方。
permission-modes 和 Zentui 都调用了 ctx.ui.setFooter()。与其再写一个 Footer 参与覆盖,不如让 Zentui 保留渲染权,只把权限模式桥接成普通的 Extension Status。
加载顺序仍要调整:我把 Zentui 放在 packages 数组末尾,让它最后安装 Footer。但这招只对 UI 所有权有效,解决不了快捷键冲突。Pi 0.84.2 把 Shift+Tab 留给内置的 app.thinking.cycle;如果 Permission Modes 注册同一个按键,Pi 会直接跳过。需要使用该快捷键时,应在 ~/.pi/agent/keybindings.json 中重新绑定内置动作,先把它释放出来。
另外,Permission Modes 的 allow / ask / deny 是通过 tool_call 等事件实现的进程内策略,不是 Bubblewrap、容器或虚拟机那样的系统级隔离。
桥接文件位于:
1
| ~/.pi/agent/extensions/permission-mode-zentui.ts
|
完整代码如下:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
| import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
const MODE_STATUS = {
ask: { label: "● Ask", color: "muted" },
plan: { label: "⏸ Plan", color: "accent" },
auto: { label: "▶ Auto", color: "warning" },
bypass: { label: "⚡️ Bypass", color: "error" },
} as const;
/** Bridges permission-modes state into Zentui's extension-status footer. */
export default function permissionModeZentui(pi: ExtensionAPI): void {
let timer: NodeJS.Timeout | undefined;
pi.on("session_start", (_event, ctx) => {
let previousMode: string | undefined;
const syncStatus = (): void => {
const mode = process.env.PERMISSION_MODES_INHERITED_MODE;
if (!mode || mode === previousMode) return;
previousMode = mode;
const status = MODE_STATUS[mode as keyof typeof MODE_STATUS];
ctx.ui.setStatus(
"permission-mode",
status ? ctx.ui.theme.fg(status.color, status.label) : mode,
);
};
syncStatus();
timer = setInterval(syncStatus, 200);
timer.unref();
});
pi.on("session_shutdown", () => {
if (timer) clearInterval(timer);
timer = undefined;
});
}
|
syncStatus() 每 200 毫秒读取一次 PERMISSION_MODES_INHERITED_MODE,但只有模式变化时才更新 Footer。ask、plan、auto 和 bypass 会分别显示为 ● Ask、⏸ Plan、▶ Auto 和 ⚡️ Bypass。定时器则在 session_shutdown 中清理。
接着调整 ~/.pi/agent/settings.json 中的包顺序,让 Zentui 最后加载:
1
2
3
4
5
6
7
| {
"packages": [
"npm:pi-cc-extensions",
"npm:@georgedong32/permission-modes",
"npm:pi-zentui"
]
}
|
Zentui 会把 permission-mode 当作第三方 Extension Status。显示在 Footer 左边、中间还是右边,可以到 /zentui 的 Extensions 页面里设置。整个改动没有碰 npm 包中的源码,更新 Package 时也不需要反复补丁。
增加 /effort 命令
/effort 与 Footer 没有关系,我把它留在另一个文件中:
1
| ~/.pi/agent/extensions/effort-command.ts
|
代码如下:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
| import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
const EFFORT_LEVELS = ["off", "minimal", "low", "medium", "high", "xhigh", "max"] as const;
type EffortLevel = (typeof EFFORT_LEVELS)[number];
/** Reports whether a command argument is a supported thinking-effort level. */
function isEffortLevel(value: string): value is EffortLevel {
return EFFORT_LEVELS.some((level) => level === value);
}
/** Registers the /effort command for selecting the active thinking level. */
export default function effortCommand(pi: ExtensionAPI): void {
pi.registerCommand("effort", {
description: "Set thinking effort: off, minimal, low, medium, high, xhigh, or max",
getArgumentCompletions: (prefix) => {
const normalizedPrefix = prefix.toLowerCase();
const matches = EFFORT_LEVELS.filter((level) => level.startsWith(normalizedPrefix)).map(
(level) => ({
value: level,
label: level,
}),
);
return matches.length > 0 ? matches : null;
},
handler: async (args, ctx) => {
let requested = args.trim().toLowerCase();
if (!requested) {
if (!ctx.hasUI) return;
const selected = await ctx.ui.select(
`Thinking effort (current: ${pi.getThinkingLevel()})`,
[...EFFORT_LEVELS],
);
if (!selected) return;
requested = selected;
}
if (!isEffortLevel(requested)) {
ctx.ui.notify(
`Unknown effort "${requested}". Expected: ${EFFORT_LEVELS.join(", ")}`,
"error",
);
return;
}
pi.setThinkingLevel(requested);
const applied = pi.getThinkingLevel();
if (applied !== requested) {
ctx.ui.notify(
`Requested ${requested}; active model applied ${applied}`,
"warning",
);
return;
}
ctx.ui.notify(`Thinking effort: ${applied}`, "info");
},
});
}
|
不带参数的 /effort 会打开选择菜单;如果已经知道目标等级,也可以直接输入 /effort high。可选值为 off、minimal、low、medium、high、xhigh 和 max,命令本身带参数补全和输入检查。
设置后再调用 getThinkingLevel() 很有必要:有些模型不支持请求的等级,Pi 会自动降级。此时通知中应该展示实际生效值,而不是原样复述用户输入。
重载与验证
保存两个 Extension 并调整 Package 顺序后,在 Pi 中执行:
/settings reload 不是有效命令;/settings 和 /reload 是分开的。
写下这篇记录时,我已经检查过两个 TypeScript 文件的语法和 settings.json 的 JSON 格式。至于 Footer 展示和 /effort 菜单,仍需要在当前会话执行 /reload 后做一次实际交互确认,静态检查替代不了这一步。
几点注意事项
- Permission Modes 的 Auto 模式依赖分类模型判断风险。分类模型不可用时,写入操作可能 fail-closed。需要维护全局配置时,可以先切换到
/ask,再明确批准目标操作。 bypass 会放行高风险操作,不适合作为日常默认模式。- 桥接 Extension 依赖
PERMISSION_MODES_INHERITED_MODE。如果 Permission Modes 后续改名或删除这个环境变量,桥接代码也要跟着调整。