Pi Agent 权限模式与 Thinking Effort 自定义

背景

我在 Pi Agent 中同时安装了 @georgedong32/permission-modes 和 pi-zentui。两者单独使用都没什么问题,放在一起后却有两个地方不太顺手:

  1. 我想保留 Zentui 的 Starship Footer,同时在 Footer 中看到当前权限模式。
  2. 我希望用一个简单的 /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 中执行:

1
/reload

/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 后续改名或删除这个环境变量,桥接代码也要跟着调整。
使用 Hugo 构建
主题 Stack 由 Jimmy 设计