@muzi3/herdr 0.1.2 → 0.1.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -7,6 +7,8 @@
7
7
  | `herdr-guide` | user-invoked 向导 | 引导用户理解、安装、配置与排查 Herdr(`herdr.dev/agent-guide.md`) |
8
8
  | `herdr-agent-state` | 扩展 | 通过 Herdr 的 pane socket 上报 agent 状态(working / blocked / idle)及会话引用,供 Herdr 面板展示 |
9
9
  | `pi-hot-restart` | 扩展 | 提供 `/pi-hot-restart` 命令:批量重启当前 herdr 里的所有 pi 窗口(跳过 working),并恢复到原 session |
10
+ | `session-notify` | 扩展 | 把 pi 的阻塞式交互弹窗(`ui_prompt_start` / `ui_prompt_end`,含 `ask_user_question`)转发为 herdr 的 `herdr:blocked` 事件(pane 显示 `blocked`),并发 macOS 桌面横幅:标题带 pane + 本轮任务,正文带问题原文 / 完成用时 |
11
+ | `tab-task-label` | 扩展(默认不启用) | 每轮提问时把所在 tab 改名为该轮任务摘要,让 herdr 界面里也能看出「在做什么」;需要时加回 `pi.extensions` |
10
12
 
11
13
  ## 来源与许可
12
14
 
@@ -19,7 +21,7 @@ Herdr 以 [Apache-2.0](https://www.apache.org/licenses/LICENSE-2.0) 许可发布
19
21
 
20
22
  > 扩展源文件由 Herdr 集成管理,重装或更新集成时会覆盖;请在旁边添加自定义 hooks 而非编辑它。
21
23
 
22
- `pi-hot-restart.ts` 是本包自带的补充扩展,与官方 `herdr-agent-state` 互不干扰,在 `package.json` 的 `pi.extensions` 中显式声明(因 `extensions/index.ts` 存在时 pi 不会扫描同目录兄弟文件)。
24
+ `pi-hot-restart.ts`、`session-notify.ts` 与 `tab-task-label.ts`(默认未启用)是本包自带的补充扩展,与官方 `herdr-agent-state` 互不干扰,在 `package.json` 的 `pi.extensions` 中显式声明(因 `extensions/index.ts` 存在时 pi 不会扫描同目录兄弟文件)。
23
25
 
24
26
  ## 安装
25
27
 
@@ -41,6 +43,32 @@ pi install npm:@muzi3/herdr
41
43
  3. 弹出确认框,展示将重启的窗口清单与数量;
42
44
  4. 对每个目标窗口:向 pi 前台进程组发 SIGTERM 优雅退出(session 落盘),等待退出后用 `pi --session <path|id>` 在原 pane 恢复。
43
45
 
46
+ ### 等用户输入时上报 blocked + 桌面横幅
47
+
48
+ herdr 的 pi 集成只认 `herdr:blocked` 事件,且 herdr 0.9.0 对 pi 仍没有终端 blocked 识别规则(内置规则只有一条 `working_literal`,匹配 `Working...`),所以 `ask_user_question` / confirm 弹窗期间 pane 会一直停在 `working`。`session-notify` 订阅 pi core 的 `ui_prompt_start` / `ui_prompt_end`(`select`/`confirm`/`input`/`editor`/`custom` 统一包装,只在外层触发):
49
+
50
+ - 等待期发 `herdr:blocked { active: true, label }`,结束后发 `{ active: false }`;label 形如 `等待回答:<问题 header>`(`custom` 无 title,取 `rpiv:ask-user:prompt` 的问题内容)、`等待确认:<title>`。
51
+ - 契约同 pi-subagents 的 herdr-status bridge:herdr 的 `blockedCount` 是计数式的,切 label 前先发 `active:false` 再发 `active:true`;本扩展严格配对,只占一个计数。
52
+ - 同一份信号同时发 macOS 桌面横幅:标题 `herdr · <pane id> · <本轮任务>`,正文 `等待回答:<问题原文>`;每轮结束时(`agent_settled` 且 `isIdle`)再发一条 `完成 · 用时 <n>s`。
53
+
54
+ 为什么桌面横幅自己发(herdr 0.9.0 实测):
55
+
56
+ - 内置弹窗正文是固定的 `<工作区名> · <工作区编号> · <tab 名>`,pane metadata(`--title` / `--display-agent` / `--state-label`)不会进弹窗,0.9.0 也没有内容定制选项;
57
+ - herdr 会抑制「当前 tab」的通知,而一个 tab 里放多个 agent pane 是常见用法,等于一起静音;
58
+ - 所以 `ui.toast.delivery` 保持 `off`(只留 herdr 内应用 toast + 音效),桌面横幅由本扩展负责,同一状态不弹两份。
59
+
60
+ 通道:优先用 `terminal-notifier`(`/opt/homebrew/bin`、`/usr/local/bin`,或 node-notifier 自带的那份;支持 `-group`,同一 pane 的旧横幅会被新横幅替换),否则回落到系统自带 `osascript`。只在 macOS + herdr 集成环境(`HERDR_ENV=1`、`HERDR_PANE_ID`)+ TUI 会话下工作;CLI 失败或超时静默忽略,不影响 pi 本体。
61
+
62
+ > 与 `@raidou/pi-notify` 功能重叠:pi-notify 的通知内容只有 `pi — <cwd 目录名>` / `Waiting: Prompt`,区分不了同一目录下的多个 pane。两个都开会弹两份,建议在 `~/.pi/agent/settings.json` 设 `"piNotify": { "enabled": false }`。
63
+
64
+ ### (可选)tab 改名带上「在做什么」
65
+
66
+ `tab-task-label.ts` 在每轮 `before_agent_start` 时把所在 tab 改名为该轮任务摘要(`event.prompt` 首行、折叠空白、截断到 40 字符),让 herdr 界面里的 tab 名就是当前任务。桌面横幅已经带上任务名,所以它默认**不在** `pi.extensions` 中启用;需要时加回去即可。
67
+
68
+ - 只用 `HERDR_ENV` / `HERDR_TAB_ID` 门槛;CLI 调用失败或超时静默忽略,不阻塞 agent 启动。
69
+ - 会话正常退出(`session_shutdown` 且 `reason === "quit"`)时,若 tab 名仍是我们写的那一份就还原为原名;用户中途手动改过则不覆盖。
70
+ - 已知取舍:会覆盖用户自己起的 tab 名;同一 tab 内多个 pi 会话会互相覆盖,以最后提问者为准。
71
+
44
72
  ### 扩展环境变量
45
73
 
46
74
  扩展需在 Herdr 集成环境中运行,依赖以下环境变量:
@@ -50,6 +78,7 @@ pi install npm:@muzi3/herdr
50
78
  | `HERDR_ENV` | 为 `1` 时启用 |
51
79
  | `HERDR_SOCKET_PATH` | Herdr pane socket 路径 |
52
80
  | `HERDR_PANE_ID` | 当前 pane id |
81
+ | `HERDR_TAB_ID` | 当前 tab id(可选启用 `tab-task-label` 时用) |
53
82
 
54
83
  未设置这些变量时 `herdr-agent-state` 扩展静默跳过。`pi-hot-restart` 通过 PATH 调用 `herdr` CLI(`agent list` / `pane process-info` / `pane run`),不依赖上述变量。
55
84
 
@@ -0,0 +1,190 @@
1
+ /**
2
+ * session-notify
3
+ *
4
+ * 两件相关的事,共用同一份「pi 现在在等什么 / 刚做完什么」的判断:
5
+ *
6
+ * 1) blocked 状态桥:把 pi 的阻塞式交互提示转发成 herdr 的 blocked 状态。
7
+ * herdr 的 pi 集成只监听 `herdr:blocked` 事件再上报 `state: "blocked"`;而内置的
8
+ * pi 终端识别规则只有一条 working_literal(匹配 "Working..."),没有任何 blocked
9
+ * 规则 —— 没有这个桥,ask_user_question 期间 pane 一直显示 working。
10
+ * 信号来源是 pi core 的 `ui_prompt_start` / `ui_prompt_end`(runner 统一包装
11
+ * select/confirm/input/editor/custom,只在外层提示触发),所以覆盖所有阻塞弹窗;
12
+ * `custom` 不带 title,额外借 `rpiv:ask-user:prompt` 的问题内容补 label。
13
+ * herdr 的 blockedCount 是计数式的:切 label 前必须先发 `{active:false}` 降自己那
14
+ * 一份再发 `{active:true}`。本扩展严格配对 raise/lower,只占一个计数。
15
+ *
16
+ * 2) macOS 桌面横幅:herdr 自己的弹窗正文固定为「工作区 · 编号 · tab 名」,不含 pane
17
+ * 与问题原文,且按 tab 粒度抑制通知(同 tab 多 pane 会一起静音)。所以横幅自己发:
18
+ * 标题 = `herdr · <pane> · <本轮任务>`,正文 = 问题原文 / 完成用时。herdr 侧
19
+ * ui.toast.delivery 保持 off,避免同一状态弹两份。
20
+ */
21
+
22
+ import { execFile } from "node:child_process";
23
+ import { existsSync } from "node:fs";
24
+ import { homedir } from "node:os";
25
+ import { join } from "node:path";
26
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
27
+
28
+ /** herdr 集成的入站事件通道。 */
29
+ const HERDR_BLOCKED_EVENT = "herdr:blocked";
30
+ /** @juicesharp/rpiv-ask-user-question 的公开事件通道。 */
31
+ const ASK_USER_PROMPT_EVENT = "rpiv:ask-user:prompt";
32
+
33
+ const PANE_ID = process.env.HERDR_PANE_ID ?? "";
34
+ const MAX_LABEL_CHARS = 60;
35
+ const MAX_TASK_CHARS = 24;
36
+ const MAX_BODY_CHARS = 120;
37
+ const TIMEOUT_MS = 5000;
38
+
39
+ /** 各类交互提示的中文动词;未知类型回落到「等待用户输入」。 */
40
+ const KIND_VERBS: Record<string, string> = {
41
+ select: "等待选择",
42
+ confirm: "等待确认",
43
+ input: "等待输入",
44
+ editor: "等待编辑",
45
+ custom: "等待回答",
46
+ };
47
+
48
+ /** terminal-notifier 支持 -group,同一 pane 的旧横幅会被新横幅替换;osascript 没有。 */
49
+ const TERMINAL_NOTIFIER = [
50
+ "/opt/homebrew/bin/terminal-notifier",
51
+ "/usr/local/bin/terminal-notifier",
52
+ join(
53
+ homedir(),
54
+ ".pi/agent/npm/node_modules/node-notifier/vendor/mac.noindex/terminal-notifier.app/Contents/MacOS/terminal-notifier",
55
+ ),
56
+ ].find((path) => existsSync(path));
57
+
58
+ function isRecord(value: unknown): value is Record<string, unknown> {
59
+ return typeof value === "object" && value !== null;
60
+ }
61
+
62
+ function truncate(text: string, max: number): string {
63
+ return text.length > max ? `${text.slice(0, max - 1)}…` : text;
64
+ }
65
+
66
+ /** 折叠空白:AppleScript 字符串是一行,换行会把正文截断。 */
67
+ function flatten(text: string): string {
68
+ return text.replace(/\s+/g, " ").trim();
69
+ }
70
+
71
+ function applescript(value: string): string {
72
+ return `"${flatten(value).replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"`;
73
+ }
74
+
75
+ function notify(title: string, body: string): void {
76
+ if (process.platform !== "darwin") {
77
+ return;
78
+ }
79
+ const [command, args] = TERMINAL_NOTIFIER
80
+ ? [
81
+ TERMINAL_NOTIFIER,
82
+ ["-title", title, "-message", body, "-group", `herdr-${PANE_ID}`],
83
+ ]
84
+ : ["osascript", ["-e", `display notification ${applescript(body)} with title ${applescript(title)}`]];
85
+ // 纯提示层,尽力而为:失败/超时都不该影响 pi 本体。
86
+ execFile(command, args, { timeout: TIMEOUT_MS }, () => {});
87
+ }
88
+
89
+ /** 取第一条问题的 header(短)与原文(长)。 */
90
+ function readQuestion(data: unknown): { header?: string; question?: string } | undefined {
91
+ if (!isRecord(data) || !Array.isArray(data.questions)) {
92
+ return undefined;
93
+ }
94
+ const first = data.questions.find(isRecord);
95
+ if (!first) {
96
+ return undefined;
97
+ }
98
+ const header = typeof first.header === "string" ? flatten(first.header) : "";
99
+ const question = typeof first.question === "string" ? flatten(first.question) : "";
100
+ if (!header && !question) {
101
+ return undefined;
102
+ }
103
+ return { header: header || undefined, question: question || undefined };
104
+ }
105
+
106
+ export default function (pi: ExtensionAPI) {
107
+ // 与 herdr 集成保持一致:不在 herdr 集成环境里就完全静默。
108
+ if (process.env.HERDR_ENV !== "1" || !PANE_ID) {
109
+ return;
110
+ }
111
+
112
+ let rootSession = false;
113
+ let raised = false;
114
+ let raisedLabel: string | undefined;
115
+ let askUser: { header?: string; question?: string } | undefined;
116
+ let task: string | undefined;
117
+ let startedAt = 0;
118
+
119
+ function bannerTitle(): string {
120
+ return task ? `herdr · ${PANE_ID} · ${task}` : `herdr · ${PANE_ID}`;
121
+ }
122
+
123
+ function lower(): void {
124
+ if (!raised) return;
125
+ raised = false;
126
+ raisedLabel = undefined;
127
+ pi.events.emit(HERDR_BLOCKED_EVENT, { active: false });
128
+ }
129
+
130
+ function raise(kind: string, detail: string | undefined, bannerText?: string): void {
131
+ const verb = KIND_VERBS[kind] ?? "等待用户输入";
132
+ const label = detail ? `${verb}:${detail}` : "等待用户输入";
133
+ if (raised && raisedLabel === label) return;
134
+ lower(); // 先降自己那一份再抬高,保证 label 切换时计数不残留
135
+ raised = true;
136
+ raisedLabel = label;
137
+ pi.events.emit(HERDR_BLOCKED_EVENT, { active: true, label });
138
+ const body = bannerText ?? detail;
139
+ notify(bannerTitle(), body ? `${verb}:${truncate(body, MAX_BODY_CHARS)}` : verb);
140
+ }
141
+
142
+ pi.events.on(ASK_USER_PROMPT_EVENT, (data) => {
143
+ askUser = readQuestion(data);
144
+ });
145
+
146
+ pi.on("before_agent_start", (event) => {
147
+ const line = flatten(event.prompt ?? "");
148
+ task = line ? truncate(line, MAX_TASK_CHARS) : undefined;
149
+ startedAt = Date.now();
150
+ });
151
+
152
+ pi.on("ui_prompt_start", (event) => {
153
+ if (!rootSession) return;
154
+ if (event.kind === "custom") {
155
+ const detail = askUser?.header ?? askUser?.question;
156
+ raise("custom", detail ? truncate(detail, MAX_LABEL_CHARS) : undefined, askUser?.question);
157
+ return;
158
+ }
159
+ raise(event.kind, event.title);
160
+ });
161
+
162
+ pi.on("ui_prompt_end", () => {
163
+ askUser = undefined;
164
+ if (!rootSession) return;
165
+ lower();
166
+ });
167
+
168
+ pi.on("agent_settled", (_event, ctx) => {
169
+ if (!rootSession || ctx?.isIdle?.() !== true) return;
170
+ const seconds = startedAt ? Math.round((Date.now() - startedAt) / 1000) : undefined;
171
+ notify(bannerTitle(), seconds === undefined ? "完成" : `完成 · 用时 ${seconds}s`);
172
+ });
173
+
174
+ pi.on("session_start", (_event, ctx) => {
175
+ // 与 herdr 集成同一门槛:RPC/print 没有可展示的 PTY;
176
+ // reload 会同时重载集成与本扩展,两侧计数一起归零。
177
+ if (ctx?.mode !== "tui") return;
178
+ rootSession = true;
179
+ raised = false;
180
+ raisedLabel = undefined;
181
+ askUser = undefined;
182
+ startedAt = 0;
183
+ });
184
+
185
+ pi.on("session_shutdown", () => {
186
+ lower();
187
+ rootSession = false;
188
+ askUser = undefined;
189
+ });
190
+ }
@@ -0,0 +1,91 @@
1
+ /**
2
+ * tab-task-label
3
+ *
4
+ * 让 herdr 的完成/阻塞弹窗带上「这一轮在做什么」。
5
+ *
6
+ * 背景:herdr 0.8.2 的 agent 通知内容固定为
7
+ * title = "<agent> finished" / "<agent> needs attention"
8
+ * body = "<工作区名> · <工作区编号> · <tab 名>"
9
+ * 实测结论(见 packages/herdr/README.md):pane metadata(--title / --display-agent /
10
+ * --state-label)不会进入弹窗,弹窗里唯一可定制的一段就是 tab 名。所以这里在每轮
11
+ * 用户提问时把 tab 重命名为该轮任务摘要(提问首行、折叠空白、截断),这样无论是完成
12
+ * 还是阻塞待回答,弹窗正文都会带上任务信息。
13
+ *
14
+ * 取舍:会覆盖用户自己起的 tab 名(herdr 没有「临时显示名」这类旁路字段)。会话正常
15
+ * 退出(reason=quit)时,若 tab 名仍是我们写的那一份就还原成原名字;用户中途手动改过
16
+ * 则不动。同一 tab 多个 pi 会话会互相覆盖,以最后提问者为准。
17
+ */
18
+
19
+ import { execFile } from "node:child_process";
20
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
21
+
22
+ const HERDR_BIN = process.env.HERDR_BIN_PATH || "herdr";
23
+ const TAB_ID = process.env.HERDR_TAB_ID ?? "";
24
+ const MAX_LABEL_CHARS = 40;
25
+ const CLI_TIMEOUT_MS = 5000;
26
+
27
+ function run(args: string[]): Promise<string> {
28
+ return new Promise((resolve, reject) => {
29
+ execFile(HERDR_BIN, args, { timeout: CLI_TIMEOUT_MS }, (err, stdout) => {
30
+ if (err) reject(err);
31
+ else resolve(stdout);
32
+ });
33
+ });
34
+ }
35
+
36
+ /** 读本 pane 所在 tab 当前的显示名。 */
37
+ async function currentLabel(): Promise<string | undefined> {
38
+ const out = await run(["tab", "list"]);
39
+ const parsed = JSON.parse(out) as {
40
+ result?: { tabs?: Array<{ tab_id?: string; label?: string }> };
41
+ };
42
+ const tab = parsed.result?.tabs?.find((t) => t.tab_id === TAB_ID);
43
+ return tab?.label;
44
+ }
45
+
46
+ /** 提问首行 + 折叠空白 + 截断,作为 tab 名。 */
47
+ function summarize(prompt: string): string {
48
+ const line = prompt.replace(/\s+/g, " ").trim();
49
+ return line.length > MAX_LABEL_CHARS ? `${line.slice(0, MAX_LABEL_CHARS - 1)}…` : line;
50
+ }
51
+
52
+ export default function (pi: ExtensionAPI) {
53
+ // 与 herdr 集成保持一致:不在 herdr 集成环境里就完全静默。
54
+ if (process.env.HERDR_ENV !== "1" || !TAB_ID) {
55
+ return;
56
+ }
57
+
58
+ let originalLabel: string | undefined;
59
+ let originalCaptured = false;
60
+ let written: string | undefined;
61
+
62
+ pi.on("before_agent_start", (event) => {
63
+ const label = summarize(event.prompt ?? "");
64
+ if (!label || label === written) return;
65
+ // 纯展示层,尽力而为且不阻塞 agent 启动:CLI 失败/超时都不该影响 pi 本体。
66
+ void (async () => {
67
+ try {
68
+ if (!originalCaptured) {
69
+ originalLabel = await currentLabel();
70
+ originalCaptured = true;
71
+ }
72
+ await run(["tab", "rename", TAB_ID, label]);
73
+ written = label;
74
+ } catch {
75
+ // 忽略:herdr CLI 不可用、socket 断开、tab 已关闭等
76
+ }
77
+ })();
78
+ });
79
+
80
+ pi.on("session_shutdown", async (event) => {
81
+ if (event.reason !== "quit" || !written || !originalLabel) return;
82
+ try {
83
+ // 用户在会话期间手动改过 tab 名,就不覆盖他的。
84
+ if ((await currentLabel()) !== written) return;
85
+ await run(["tab", "rename", TAB_ID, originalLabel]);
86
+ written = undefined;
87
+ } catch {
88
+ // 忽略
89
+ }
90
+ });
91
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@muzi3/herdr",
3
- "version": "0.1.2",
3
+ "version": "0.1.3",
4
4
  "description": "Herdr for pi: the herdr operate skill (control Herdr from inside a pane), the herdr-guide skill (teach a human to set up and troubleshoot Herdr), and the herdr-agent-state extension (report agent state to Herdr).",
5
5
  "keywords": [
6
6
  "pi-package",
@@ -21,7 +21,8 @@
21
21
  "pi": {
22
22
  "extensions": [
23
23
  "./extensions/index.ts",
24
- "./extensions/pi-hot-restart.ts"
24
+ "./extensions/pi-hot-restart.ts",
25
+ "./extensions/session-notify.ts"
25
26
  ],
26
27
  "skills": [
27
28
  "./skills"