@muzi3/herdr 0.1.3 → 0.2.0

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/LICENSE.MIT ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Can Celik
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,27 +1,62 @@
1
1
  # @muzi3/herdr
2
2
 
3
- [Herdr](https://herdr.dev/) 相关的官方 guide skill 与一个扩展打包为一个 pi package。
3
+ [Herdr](https://herdr.dev/) 相关的官方 guide skill、pane/agent 控制工具与本地补充扩展打包为一个 pi package。
4
4
 
5
5
  | 资源 | 类型 | 说明 |
6
6
  |------|------|------|
7
+ | `herdr_layout` / `herdr_pane` / `herdr_agent` | 扩展(fork) | pane/agent 控制工具:工作区 · tab · pane 拓扑,pane 的命令下发/读取/等输出/收发按键,以及 coding agent 的启动、prompt、等待与读取 |
7
8
  | `herdr-guide` | user-invoked 向导 | 引导用户理解、安装、配置与排查 Herdr(`herdr.dev/agent-guide.md`) |
8
- | `herdr-agent-state` | 扩展 | 通过 Herdr 的 pane socket 上报 agent 状态(working / blocked / idle)及会话引用,供 Herdr 面板展示 |
9
+ | `herdr-agent-state` | 扩展 | 通过 Herdr 的 pane socket 上报 agent 状态(working / blocked / idle)及会话引用,供 Herdr 面板展示;并把 pi 的阻塞式交互弹窗(`ui_prompt_start` / `ui_prompt_end`,含 `ask_user_question`)转发为 herdr 的 `herdr:blocked` 事件,使 pane 变为 `blocked`(label 带问题摘要);桌面通知交给 herdr 自己发 |
9
10
  | `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` |
11
+
12
+ 三个扩展**互相独立**:每个扩展独占 `extensions/` 下的一个目录、以 `index.ts` 为入口,互不 import,`package.json` 的 `pi.extensions` 逐条列出入口。一个扩展注册失败不会连带拖垮另外两个,日后给某个扩展加内部模块也不会影响另外两个。
13
+
14
+ ### 扩展结构
15
+
16
+ ```
17
+ extensions/
18
+ ├── herdr-agent-state/index.ts agent 状态 + blocked 状态上报
19
+ ├── pane-agent-tools/index.ts herdr_layout / herdr_pane / herdr_agent
20
+ └── pi-hot-restart/index.ts /pi-hot-restart
21
+ ```
22
+
23
+ | 入口 | 内容 | 加载门槛 |
24
+ |------|------|----------|
25
+ | `herdr-agent-state/index.ts` | agent 状态 + blocked 状态上报(herdr 官方集成正文 + 本包并入的 blocked 段落) | `HERDR_ENV=1` 且 `HERDR_SOCKET_PATH` / `HERDR_PANE_ID` 非空 |
26
+ | `pi-hot-restart/index.ts` | `/pi-hot-restart` 命令(本包自研补充) | 无,走 PATH 调 herdr CLI |
27
+ | `pane-agent-tools/index.ts` | `herdr_layout` / `herdr_pane` / `herdr_agent`(本地 fork) | `HERDR_ENV=1` 且 `HERDR_PANE_ID` 非空 |
28
+
29
+ 门槛由各扩展自己判断(没有统一 gate),所以在 herdr 之外 `/pi-hot-restart` 仍可用(它只控制本机进程,不需要 pane socket),依赖 pane 的两个扩展则静默跳过。
30
+
31
+ 每个扩展目录的入口都叫 `index.ts`,与 pi 自己的目录式扩展约定一致:扩展目录内可以再放内部模块,`pi.extensions` 只指向入口,不会误加载旁支文件。
32
+
33
+ `herdr-agent-state/index.ts` 的目录名与 herdr 集成注入的文件同名,对齐上游时可以直接对比。内容分两段:官方集成正文(一直到文件末尾之前)与之后的「本包新增」段落。本包对官方正文只有**一处**改动:官方 `export default` 开头插入一行 `registerBlockedReporting(pi);` —— 该 default export 就是本扩展的唯一入口,blocked 上报只能挂在那里。重新同步上游时(`HERDR_INTEGRATION_VERSION` 从 8 往上升)重取注入文件替换官方正文并补回那一行;`diff` 应只有三处新增、零删除,漏帖那一行会被 `tests/herdr-agent-state.test.ts` 拦下。
12
34
 
13
35
  ## 来源与许可
14
36
 
15
37
  两者内容均来自 Herdr 官方,上游仓库为 [herdrdev/herdr](https://github.com/herdrdev/herdr):
16
38
 
17
39
  - `herdr-guide` 引导 skill —— 官方 [`herdr.dev/agent-guide.md`](https://herdr.dev/agent-guide.md),教 agent 引导用户理解/配置/排障 Herdr
18
- - `herdr-agent-state` 扩展 —— Herdr 官方 `pi` 集成的 bundled 扩展,由 `herdr integration install pi` 自动写入 `~/.pi/agent/extensions/herdr-agent-state.ts`(若设置 `PI_CODING_AGENT_DIR` 则写入 `$PI_CODING_AGENT_DIR/extensions/`)
40
+ - `herdr-agent-state` 扩展 —— Herdr 官方 `pi` 集成的 bundled 扩展,本包保留其正文,只做一处改动(在官方 `export default` 开头插入一行 blocked 上报),其余新增都在文件末尾的段落里。`herdr integration install pi` 会把官方版本写入 `~/.pi/agent/extensions/herdr-agent-state.ts`(若设置 `PI_CODING_AGENT_DIR` 则写入 `$PI_CODING_AGENT_DIR/extensions/`)
19
41
 
20
42
  Herdr 以 [Apache-2.0](https://www.apache.org/licenses/LICENSE-2.0) 许可发布,本包保留其许可声明,详见 `LICENSE`。
21
43
 
22
- > 扩展源文件由 Herdr 集成管理,重装或更新集成时会覆盖;请在旁边添加自定义 hooks 而非编辑它。
44
+ > ⚠️ 本包这份与集成写入的那份**目录名相同、位置不同**(本包 `extensions/herdr-agent-state/index.ts`,集成写 `~/.pi/agent/extensions/herdr-agent-state.ts`)。若已执行过 `herdr integration install pi`,agent 目录里的副本会被 pi 自动加载,与本包同时上报状态(herdr 的 `blockedCount` 会短暂变成 2,然后一起归零)。本包已自带该功能,用本包时不必安装集成;已安装的话请删掉 `~/.pi/agent/extensions/herdr-agent-state.ts`。
45
+ >
46
+ > 无论装不装集成,agent 目录里那份都由集成管理,重装或更新集成会覆盖它;自定义 hooks 请写成旁边的独立文件而非编辑它(本包这份是 vendored 副本,由本包自己维护)。
47
+
48
+ `extensions/pane-agent-tools/index.ts`(`herdr_layout` / `herdr_pane` / `herdr_agent`)是**本地 fork**,来源与许可与上面不同:
49
+
50
+ - 上游:[`@ogulcancelik/pi-herdr`](https://github.com/ogulcancelik/pi-extensions/tree/main/packages/pi-herdr) 0.4.0([仓库](https://github.com/ogulcancelik/pi-extensions),MIT,Copyright (c) 2026 Can Celik),许可全文见本包 `LICENSE.MIT`
51
+ - 因此本包整体按 `Apache-2.0 AND MIT` 双许可表述:`LICENSE`(Apache-2.0)覆盖 Herdr 官方内容与 `pi-hot-restart.ts`,`LICENSE.MIT` 覆盖 fork 进来的 `pane-agent-tools.ts`
52
+
53
+ ### 相对上游的改动
23
54
 
24
- `pi-hot-restart.ts`、`session-notify.ts` 与 `tab-task-label.ts`(默认未启用)是本包自带的补充扩展,与官方 `herdr-agent-state` 互不干扰,在 `package.json` 的 `pi.extensions` 中显式声明(因 `extensions/index.ts` 存在时 pi 不会扫描同目录兄弟文件)。
55
+ 上游 0.4.0 把 `pane run` / `pane send-text` / `pane send-keys` 交给 `execHerdrJson`,而它额外要求 herdr CLI 输出 JSON envelope。但 herdr 0.9.0 上这三条命令**成功时退出码为 0 且 stdout 为空**,于是每一次写操作都误报 `Expected JSON output from herdr ...` —— 即使命令早已成功下发到目标 pane。实测 14 天内 156 个 pi session 日志,误报次数为 `send-keys` 964、`send-text` 923、`run` 438。
56
+
57
+ 这三处改用 `execHerdr`:它只校验退出码,并把 stderr/stdout 上的结构化 herdr 错误经 `parseHerdrError` 抛出,因此真实失败照旧报错,只去掉了那个不成立的「stdout 必须非空」约束。`pane close` / `pane wait-output` / `agent send-keys` 确实返回 envelope,保持 `execHerdrJson`(`close` 的边界由回归测试锁定)。
58
+
59
+ 上游尚未修复,相关讨论:[issue #22](https://github.com/ogulcancelik/pi-extensions/issues/22)、[issue #40](https://github.com/ogulcancelik/pi-extensions/issues/40)、[PR #27](https://github.com/ogulcancelik/pi-extensions/pull/27)(仅覆盖 `run`,未合并)。本包已不依赖 `@ogulcancelik/pi-herdr`:同时安装两者会重复注册同名工具,需先卸载上游包。
25
60
 
26
61
  ## 安装
27
62
 
@@ -34,6 +69,14 @@ pi install npm:@muzi3/herdr
34
69
  - 用户询问 Herdr 是什么、怎么安装/配置/使用,或排查 agent 未识别、快捷键失效等问题时,触发 `herdr-guide` 引导(`/herdr-guide`)
35
70
  - 触发词:`herdr` / `herdr 安装` / `herdr 配置` / `终端工作区` / `agent 状态检测` 等
36
71
 
72
+ ### pane / agent 控制工具
73
+
74
+ 三个工具直接驱动 herdr CLI,需在 herdr 集成环境(`HERDR_ENV=1` + `HERDR_PANE_ID`)中加载:
75
+
76
+ - `herdr_layout` —— 工作区 / tab / pane 的创建、列举与拓扑查询(`current` / `workspace_list` / `pane_split` / `pane_layout` 等)
77
+ - `herdr_pane` —— 普通进程的 pane 控制:`run` 下发命令、`read` 读回输出、`wait_output` 等匹配输出、`send_text` / `send_keys` 收发按键、`close` 关闭(拒绝关闭 pi 自身所在 pane)
78
+ - `herdr_agent` —— 已识别 coding agent 的 pane:`start` / `prompt` / `wait` / `read` / `send_keys` / `rename` 等
79
+
37
80
  ### 批量重启 pi 窗口
38
81
 
39
82
  在任意 pi 窗口执行 `/pi-hot-restart`,会:
@@ -43,44 +86,47 @@ pi install npm:@muzi3/herdr
43
86
  3. 弹出确认框,展示将重启的窗口清单与数量;
44
87
  4. 对每个目标窗口:向 pi 前台进程组发 SIGTERM 优雅退出(session 落盘),等待退出后用 `pi --session <path|id>` 在原 pane 恢复。
45
88
 
46
- ### 等用户输入时上报 blocked + 桌面横幅
89
+ ### 等用户输入时上报 blocked
47
90
 
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` 统一包装,只在外层触发):
91
+ herdr 的 pi 集成只认 `herdr:blocked` 事件,且 herdr 0.9.0 对 pi 仍没有终端 blocked 识别规则(内置规则只有一条 `working_literal`,匹配 `Working...`),所以 `ask_user_question` / confirm 弹窗期间 pane 会一直停在 `working`。本扩展的 blocked 上报段落订阅 pi core 的 `ui_prompt_start` / `ui_prompt_end`(`select`/`confirm`/`input`/`editor`/`custom` 统一包装,只在外层触发):
49
92
 
50
93
  - 等待期发 `herdr:blocked { active: true, label }`,结束后发 `{ active: false }`;label 形如 `等待回答:<问题 header>`(`custom` 无 title,取 `rpiv:ask-user:prompt` 的问题内容)、`等待确认:<title>`。
51
94
  - 契约同 pi-subagents 的 herdr-status bridge:herdr 的 `blockedCount` 是计数式的,切 label 前先发 `active:false` 再发 `active:true`;本扩展严格配对,只占一个计数。
52
- - 同一份信号同时发 macOS 桌面横幅:标题 `herdr · <pane id> · <本轮任务>`,正文 `等待回答:<问题原文>`;每轮结束时(`agent_settled` 且 `isIdle`)再发一条 `完成 · 用时 <n>s`。
53
95
 
54
- 为什么桌面横幅自己发(herdr 0.9.0 实测):
96
+ 桌面通知交给 herdr 自己发(`~/.config/herdr/config.toml` 设 `[ui.toast] delivery = "terminal"`):通知经终端转交给 Ghostty 显示,**点击横幅会把 Ghostty 窗口调到前面**。注意它只能到窗口粒度 —— herdr 的 pane 是 herdr TUI 内部的概念,点通知不会切到对应 pane;正文也固定为 `<工作区名> · <工作区编号> · <tab 名>`,不含 pane id 与问题原文。早期版本试过自建横幅(`osascript` / `terminal-notifier`),内容能带 pane 与问题原文,但要么不支持点击动作、要么在本机通知权限不稳;点击跳转要能捕获回调的通知器(如 alerter)才可能做到,为避免与 herdr 自己的通知重复弹两份,自建横幅已整体移除。
55
97
 
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 + 音效),桌面横幅由本扩展负责,同一状态不弹两份。
98
+ ### 扩展环境变量
59
99
 
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 本体。
100
+ 扩展需在 Herdr 集成环境中运行,依赖以下环境变量:
61
101
 
62
- > 与 `@raidou/pi-notify` 功能重叠:pi-notify 的通知内容只有 `pi — <cwd 目录名>` / `Waiting: Prompt`,区分不了同一目录下的多个 pane。两个都开会弹两份,建议在 `~/.pi/agent/settings.json` 设 `"piNotify": { "enabled": false }`。
102
+ | 变量 | 说明 |
103
+ |------|------|
104
+ | `HERDR_ENV` | 为 `1` 时启用扩展 |
105
+ | `HERDR_SOCKET_PATH` | Herdr pane socket 路径 |
106
+ | `HERDR_PANE_ID` | 当前 pane id(同时作为扩展的运行门槛) |
63
107
 
64
- ### (可选)tab 改名带上「在做什么」
108
+ 未设置时 `herdr-agent-state` 与 `pane-agent-tools` 两个扩展静默跳过(门槛见「扩展结构」),`/pi-hot-restart` 仍可用:它通过 PATH 调用 `herdr` CLI(`agent list` / `pane process-info` / `pane run`),不依赖上述变量。
65
109
 
66
- `tab-task-label.ts` 在每轮 `before_agent_start` 时把所在 tab 改名为该轮任务摘要(`event.prompt` 首行、折叠空白、截断到 40 字符),让 herdr 界面里的 tab 名就是当前任务。桌面横幅已经带上任务名,所以它默认**不在** `pi.extensions` 中启用;需要时加回去即可。
110
+ ## 测试
67
111
 
68
- - 只用 `HERDR_ENV` / `HERDR_TAB_ID` 门槛;CLI 调用失败或超时静默忽略,不阻塞 agent 启动。
69
- - 会话正常退出(`session_shutdown` 且 `reason === "quit"`)时,若 tab 名仍是我们写的那一份就还原为原名;用户中途手动改过则不覆盖。
70
- - 已知取舍:会覆盖用户自己起的 tab 名;同一 tab 内多个 pi 会话会互相覆盖,以最后提问者为准。
112
+ ```bash
113
+ node --test tests/ # 等价于 npm test
114
+ ```
71
115
 
72
- ### 扩展环境变量
116
+ `tests/` 下三个文件、共 28 个用例,`node:test` + `node:assert/strict`,无需引入 bun(Node 原生剥离 TS 类型):
73
117
 
74
- 扩展需在 Herdr 集成环境中运行,依赖以下环境变量:
118
+ `tests/pane-agent-tools.test.ts` —— 由上游 `index.test.ts`(`bun:test`)改写。除移植的 9 个用例外,额外覆盖本 fork 的 3 行改动:
75
119
 
76
- | 变量 | 说明 |
77
- |------|------|
78
- | `HERDR_ENV` | 为 `1` 时启用 |
79
- | `HERDR_SOCKET_PATH` | Herdr pane socket 路径 |
80
- | `HERDR_PANE_ID` | 当前 pane id |
81
- | `HERDR_TAB_ID` | 当前 tab id(可选启用 `tab-task-label` 时用) |
120
+ - `pane run` / `send_text` / `send_keys` 在 stdout 为空时成功,且恰好调用一次 herdr
121
+ - stdout 为 banner/ANSI 等非 JSON 内容时不当作协议数据解析
122
+ - 结构化 herdr 错误(stderr envelope)与裸非零退出仍然报错,不会被「只校验退出码」吞掉
123
+ - `pane close` 仍走 JSON envelope 路径
124
+
125
+ 这组用例对上游写法运行会有 4 个失败、对当前实现 16 个全通过,可用于确认回归网没有失效。
126
+
127
+ `tests/herdr-agent-state.test.ts` —— 起一个真实 unix socket 充当 pane socket,验证 agent 状态(working / idle)与 blocked 上报(含 label 取值、弹窗结束后的回落)真的落到连接上。用真 socket 而非桩:上报的成败判据本身就在连接上(`sendRequestAttempt` 只有连上并收到对端数据才算送达),桩掉 `net` 等于把被测逻辑一起桩掉。这一组只调 `default export`(扩展唯一入口),所以官方正文里那行 `registerBlockedReporting(pi);` 被漏帖时会直接失败。
82
128
 
83
- 未设置这些变量时 `herdr-agent-state` 扩展静默跳过。`pi-hot-restart` 通过 PATH 调用 `herdr` CLI(`agent list` / `pane process-info` / `pane run`),不依赖上述变量。
129
+ `tests/extensions.test.ts` —— 把「三个扩展各自独立」锁成断言:`pi.extensions` 与 `extensions/` 下的扩展目录一一对应(不漏声明、也不留死路径)、每个扩展目录都有 `index.ts` 入口且顶层没有散落的扩展文件、扩展之间没有跨目录 import,并且单独加载每个扩展时只会注册自己的资源(`herdr-agent-state` 只挂状态监听,`pi-hot-restart` 只有 `/pi-hot-restart`,`pane-agent-tools` 只有三个工具)。
84
130
 
85
131
  ## 参考
86
132
 
@@ -88,3 +134,4 @@ herdr 的 pi 集成只认 `herdr:blocked` 事件,且 herdr 0.9.0 对 pi 仍没
88
134
  - 引导 guide 原文:<https://herdr.dev/agent-guide.md>
89
135
  - 集成文档(Pi 章节):<https://herdr.dev/docs/integrations/>
90
136
  - 上游仓库:<https://github.com/herdrdev/herdr>
137
+ - pi-herdr 上游仓库:<https://github.com/ogulcancelik/pi-extensions/tree/main/packages/pi-herdr>
@@ -0,0 +1,409 @@
1
+ // installed by herdr
2
+ // managed by herdr; reinstalling or updating the integration overwrites this file.
3
+ // add custom hooks/plugins beside this file instead of editing it.
4
+ // HERDR_INTEGRATION_ID=pi
5
+ // HERDR_INTEGRATION_VERSION=8
6
+ // @ts-nocheck
7
+ //
8
+ // ---------------------------------------------------------------------------
9
+ // 以下为本包(@muzi3/herdr)的说明,不属于 herdr 官方正文:
10
+ //
11
+ // 1. 本文件上方内容是 herdr 官方 pi 集成正文的副本,保留是为了将来能直接与上游重新同步:
12
+ // 上游 `HERDR_INTEGRATION_VERSION` 从当前的 8 往上升时,重取 `herdr integration install pi`
13
+ // 注入的文件并替换本文件的官方段落即可(文件末尾的「本包新增」段落不受影响)。
14
+ // 本包对官方段落只有**一处**改动:`export default` 开头插入一行
15
+ // `registerBlockedReporting(pi);`(带「本包新增」标记)。因为官方的 default export 就是本
16
+ // 扩展唯一入口,blocked 上报只能挂在那里;重贴官方段落后必须补回这一行。
17
+ // 对齐判据:`diff <重取的注入文件> extensions/herdr-agent-state/index.ts` 应只有三处新增、零删除 ——
18
+ // (1)`@ts-nocheck` 之后的本包说明段落;(2)官方 `export default` 开头那 1 行插入;
19
+ // (3)文件末尾的「本包新增」段落。出现任何删除行,都说明官方段落被改写了。
20
+ // 漏掉那行插入会让 blocked 上报静默失效(tests/herdr-agent-state.test.ts 会失败)。
21
+ // 其余官方代码不改写,缩进保持其 2 空格风格;本包新增段落用仓库统一的 tab 缩进。
22
+ // 2. 职责:向 herdr pane socket 上报 agent 状态(working / blocked / idle)与会话引用,
23
+ // 并附带 blocked 状态上报(原先那个独立的 blocked-state.ts 扩展已并入文件末尾)。
24
+ // 3. 本文件是包内三个**互相独立**的扩展之一,每个扩展独占一个目录、以 `index.ts` 为入口
25
+ // (herdr-agent-state / pane-agent-tools / pi-hot-restart 互不 import,各自在
26
+ // `pi.extensions` 中显式列出)。目录名与 herdr 集成注入的文件同名,便于与注入副本对齐。
27
+ // ---------------------------------------------------------------------------
28
+
29
+ import net from "node:net";
30
+
31
+ const HERDR_ENV = process.env.HERDR_ENV;
32
+ const socketPath = process.env.HERDR_SOCKET_PATH;
33
+ const socketEndpoint =
34
+ process.platform === "win32" && socketPath ? `\\\\.\\pipe\\${socketPath}` : socketPath;
35
+ const paneId = process.env.HERDR_PANE_ID;
36
+ const source = "herdr:pi";
37
+
38
+ function enabled() {
39
+ return HERDR_ENV === "1" && !!socketPath && !!paneId;
40
+ }
41
+
42
+ function sendRequestAttempt(request: unknown, timeoutMs: number): Promise<boolean> {
43
+ if (!enabled()) {
44
+ return Promise.resolve(true);
45
+ }
46
+
47
+ return new Promise((resolve) => {
48
+ let done = false;
49
+ let timeout: ReturnType<typeof setTimeout> | undefined;
50
+ const finish = (delivered: boolean) => {
51
+ if (done) return;
52
+ done = true;
53
+ if (timeout) {
54
+ clearTimeout(timeout);
55
+ }
56
+ socket.destroy();
57
+ resolve(delivered);
58
+ };
59
+
60
+ const socket = net.createConnection(socketEndpoint!);
61
+ socket.on("error", () => finish(false));
62
+ socket.on("connect", () => socket.write(`${JSON.stringify(request)}\n`));
63
+ socket.on("data", () => finish(true));
64
+ socket.on("end", () => finish(false));
65
+ timeout = setTimeout(() => finish(false), timeoutMs);
66
+ timeout.unref?.();
67
+ });
68
+ }
69
+
70
+ async function sendRequest(request: unknown): Promise<void> {
71
+ if (await sendRequestAttempt(request, 500)) {
72
+ return;
73
+ }
74
+ await sendRequestAttempt(request, 1500);
75
+ }
76
+
77
+ type AgentState = "working" | "blocked" | "idle";
78
+
79
+ type QueuedState = {
80
+ state: AgentState;
81
+ message?: string;
82
+ seq: number;
83
+ };
84
+
85
+ let reportSeq = Date.now() * 1000;
86
+ let currentAgentSessionId: string | undefined;
87
+ let currentAgentSessionPath: string | undefined;
88
+
89
+ function nextReportSeq(): number {
90
+ reportSeq += 1;
91
+ return reportSeq;
92
+ }
93
+
94
+ function updateSessionRef(ctx: any): void {
95
+ try {
96
+ const file = ctx?.sessionManager?.getSessionFile?.();
97
+ currentAgentSessionPath =
98
+ typeof file === "string" && file.startsWith("/") ? file : undefined;
99
+ } catch {
100
+ currentAgentSessionPath = undefined;
101
+ }
102
+
103
+ try {
104
+ const id = ctx?.sessionManager?.getSessionId?.();
105
+ currentAgentSessionId = typeof id === "string" && id.length > 0 ? id : undefined;
106
+ } catch {
107
+ currentAgentSessionId = undefined;
108
+ }
109
+ }
110
+
111
+ function withSessionRef(params: Record<string, unknown>): Record<string, unknown> {
112
+ if (currentAgentSessionPath) {
113
+ return { ...params, agent_session_path: currentAgentSessionPath };
114
+ }
115
+ if (currentAgentSessionId) {
116
+ return { ...params, agent_session_id: currentAgentSessionId };
117
+ }
118
+ return params;
119
+ }
120
+
121
+ function currentSessionRef(): Record<string, unknown> | undefined {
122
+ if (currentAgentSessionPath) {
123
+ return { agent_session_path: currentAgentSessionPath };
124
+ }
125
+ if (currentAgentSessionId) {
126
+ return { agent_session_id: currentAgentSessionId };
127
+ }
128
+ return undefined;
129
+ }
130
+
131
+ function reportSession(sessionStartSource?: string): Promise<void> {
132
+ const sessionRef = currentSessionRef();
133
+ if (!sessionRef) {
134
+ return Promise.resolve();
135
+ }
136
+
137
+ return sendRequest({
138
+ id: `${source}:session:${Date.now()}:${Math.random().toString(36).slice(2)}`,
139
+ method: "pane.report_agent_session",
140
+ params: {
141
+ pane_id: paneId,
142
+ source,
143
+ agent: "pi",
144
+ seq: nextReportSeq(),
145
+ session_start_source: sessionStartSource,
146
+ ...sessionRef,
147
+ },
148
+ });
149
+ }
150
+
151
+ function sendState(state: AgentState, message?: string, seq = nextReportSeq()): Promise<void> {
152
+ return sendRequest({
153
+ id: `${source}:${Date.now()}:${Math.random().toString(36).slice(2)}`,
154
+ method: "pane.report_agent",
155
+ params: withSessionRef({
156
+ pane_id: paneId,
157
+ source,
158
+ agent: "pi",
159
+ state,
160
+ message,
161
+ seq,
162
+ }),
163
+ });
164
+ }
165
+
166
+ let sendInFlight = false;
167
+ let queuedState: QueuedState | undefined;
168
+
169
+ function queueState(state: AgentState, message?: string): void {
170
+ queuedState = { state, message, seq: nextReportSeq() };
171
+ if (!sendInFlight) {
172
+ void drainStateQueue();
173
+ }
174
+ }
175
+
176
+ async function drainStateQueue(): Promise<void> {
177
+ if (sendInFlight) {
178
+ return;
179
+ }
180
+
181
+ sendInFlight = true;
182
+ try {
183
+ while (queuedState) {
184
+ const next = queuedState;
185
+ queuedState = undefined;
186
+ await sendState(next.state, next.message, next.seq);
187
+ }
188
+ } finally {
189
+ sendInFlight = false;
190
+ if (queuedState) {
191
+ void drainStateQueue();
192
+ }
193
+ }
194
+ }
195
+
196
+ export default function (pi) {
197
+ registerBlockedReporting(pi); // 本包新增:本扩展唯一入口在这里,blocked 上报只能挂这里(见文件头第 1 条)
198
+ if (!enabled()) {
199
+ return;
200
+ }
201
+
202
+ let agentActive = false;
203
+ let blockedCount = 0;
204
+ let blockedMessage: string | undefined;
205
+ let lastState: AgentState | undefined;
206
+ let lastMessage: string | undefined;
207
+ let rootSession = false;
208
+
209
+ function desiredState() {
210
+ if (blockedCount > 0) {
211
+ return { state: "blocked" as const, message: blockedMessage };
212
+ }
213
+ if (agentActive) {
214
+ return { state: "working" as const, message: undefined };
215
+ }
216
+ return { state: "idle" as const, message: undefined };
217
+ }
218
+
219
+ function publishState(force = false) {
220
+ const next = desiredState();
221
+ if (!force && next.state === lastState && next.message === lastMessage) {
222
+ return;
223
+ }
224
+ lastState = next.state;
225
+ lastMessage = next.message;
226
+ queueState(next.state, next.message);
227
+ }
228
+
229
+ pi.events.on("herdr:blocked", (data) => {
230
+ if (!rootSession) {
231
+ return;
232
+ }
233
+ if (!data?.active) {
234
+ blockedCount = Math.max(0, blockedCount - 1);
235
+ if (blockedCount === 0) {
236
+ blockedMessage = undefined;
237
+ }
238
+ publishState();
239
+ return;
240
+ }
241
+
242
+ blockedCount += 1;
243
+ blockedMessage = data.label;
244
+ publishState();
245
+ });
246
+
247
+ pi.on("session_start", async (event, ctx) => {
248
+ // TUI only: RPC/JSON/print modes are headless (no PTY herdr can display),
249
+ // and RPC still reports hasUI=true, so mode is the reliable gate.
250
+ if (ctx?.mode !== "tui") {
251
+ return;
252
+ }
253
+ rootSession = true;
254
+ updateSessionRef(ctx);
255
+ await reportSession(event?.reason);
256
+ // A reload can replace this extension mid-run without emitting another agent_start.
257
+ agentActive = ctx?.isIdle?.() === false;
258
+ publishState(true);
259
+ });
260
+
261
+ pi.on("agent_start", (_event, ctx) => {
262
+ if (!rootSession) {
263
+ return;
264
+ }
265
+ updateSessionRef(ctx);
266
+ void reportSession();
267
+ agentActive = true;
268
+ publishState();
269
+ });
270
+
271
+ pi.on("agent_settled", (_event, ctx) => {
272
+ if (!rootSession || ctx?.isIdle?.() !== true) {
273
+ return;
274
+ }
275
+
276
+ agentActive = false;
277
+ publishState();
278
+ });
279
+ }
280
+
281
+ /* ===========================================================================
282
+ * 本包新增(@muzi3/herdr)—— 以上为 herdr 官方正文,以下为本包增量。
283
+ *
284
+ * blocked 状态上报:原 blocked-state.ts(已删除的独立扩展)的全部内容并入本文件,与 agent
285
+ * 状态上报同属「向 herdr 上报状态」这一个职责。
286
+ *
287
+ * 合并只是减少一个文件,没改行为:这里仍然只发 `herdr:blocked` 事件,由上方官方段落的
288
+ * 监听器换算成 blockedCount。保持事件通道而不是直接写 socket,一是 pi-subagents 的
289
+ * herdr-status bridge 也通过它上报,共用同一条计数路径才不会出现两个入口重复计数;
290
+ * 二是正式契约不变,与上游重新同步时无需改动监听逻辑。
291
+ * =========================================================================== */
292
+
293
+ /** herdr pi 集成的入站事件通道(由上方官方段落消费)。 */
294
+ const HERDR_BLOCKED_EVENT = "herdr:blocked";
295
+ /** @juicesharp/rpiv-ask-user-question 的公开事件通道。 */
296
+ const ASK_USER_PROMPT_EVENT = "rpiv:ask-user:prompt";
297
+
298
+ /** 与官方段落同一门槛:确认自己确实跑在 herdr 的 pane 里。 */
299
+ const IN_HERDR = process.env.HERDR_ENV === "1" && !!process.env.HERDR_PANE_ID;
300
+ const MAX_LABEL_CHARS = 60;
301
+ const WAITING = "等待回答";
302
+
303
+ function isRecord(value: unknown): value is Record<string, unknown> {
304
+ return typeof value === "object" && value !== null;
305
+ }
306
+
307
+ /** label 是一行状态文字,换行/多空格先折叠掉。 */
308
+ function flatten(text: string): string {
309
+ return text.replace(/\s+/g, " ").trim();
310
+ }
311
+
312
+ function truncate(text: string, max: number): string {
313
+ return text.length > max ? `${text.slice(0, max - 1)}…` : text;
314
+ }
315
+
316
+ /** 取第一条问题的 header(短)与原文(长)。 */
317
+ function readQuestion(data: unknown): { header?: string; question?: string } | undefined {
318
+ if (!isRecord(data) || !Array.isArray(data.questions)) {
319
+ return undefined;
320
+ }
321
+ const first = data.questions.find(isRecord);
322
+ if (!first) {
323
+ return undefined;
324
+ }
325
+ const header = typeof first.header === "string" ? flatten(first.header) : "";
326
+ const question = typeof first.question === "string" ? flatten(first.question) : "";
327
+ if (!header && !question) {
328
+ return undefined;
329
+ }
330
+ return { header: header || undefined, question: question || undefined };
331
+ }
332
+
333
+ /**
334
+ * blocked 状态上报(原 blocked-state.ts,由本文件 default export 调用)。
335
+ *
336
+ * herdr 的 pi 集成只认 `herdr:blocked` 事件,而它内置的终端识别规则只有一条
337
+ * working_literal(匹配 "Working...")、没有任何 blocked 规则 —— 没有这个桥,
338
+ * ask_user_question 期间 pane 会一直显示 working,切到别的 pane 就看不出这个会话
339
+ * 正等着人回话。
340
+ *
341
+ * 信号来源是 pi core 的 `ui_prompt_start` / `ui_prompt_end`:runner 统一包装了
342
+ * select/confirm/input/editor/custom,只在外层提示触发,所以覆盖所有阻塞弹窗。
343
+ * `custom`(ask_user_question 走这条)不带 title,额外借 `rpiv:ask-user:prompt` 的
344
+ * 问题内容补 label。
345
+ *
346
+ * herdr 侧的 blockedCount 是计数式的:切 label 前必须先发 `{active:false}` 降自己
347
+ * 那一份再发 `{active:true}`。本模块严格配对 raise/lower,只占一个计数。
348
+ *
349
+ * 签名不标注 ExtensionAPI:本文件是 @ts-nocheck 的官方副本,上方官方段落同样是
350
+ * 未标注的 `function (pi)`,保持一致便无需为类型向官方段落插入 import。
351
+ */
352
+ export function registerBlockedReporting(pi) {
353
+ if (!IN_HERDR) {
354
+ return;
355
+ }
356
+
357
+ let rootSession = false;
358
+ let raised = false;
359
+ let raisedLabel: string | undefined;
360
+ let askUser: { header?: string; question?: string } | undefined;
361
+
362
+ function lower(): void {
363
+ if (!raised) return;
364
+ raised = false;
365
+ raisedLabel = undefined;
366
+ pi.events.emit(HERDR_BLOCKED_EVENT, { active: false });
367
+ }
368
+
369
+ function raise(detail: string | undefined): void {
370
+ const label = detail ? `${WAITING}:${truncate(detail, MAX_LABEL_CHARS)}` : WAITING;
371
+ if (raised && raisedLabel === label) return;
372
+ lower(); // 先降自己那一份再抬高,保证 label 切换时计数不残留
373
+ raised = true;
374
+ raisedLabel = label;
375
+ pi.events.emit(HERDR_BLOCKED_EVENT, { active: true, label });
376
+ }
377
+
378
+ pi.events.on(ASK_USER_PROMPT_EVENT, (data) => {
379
+ askUser = readQuestion(data);
380
+ });
381
+
382
+ pi.on("ui_prompt_start", (event) => {
383
+ if (!rootSession) return;
384
+ // custom 提示(ask_user_question 走这条)的 title 是空的,用问题内容补 label。
385
+ raise(event.kind === "custom" ? (askUser?.header ?? askUser?.question) : event.title);
386
+ });
387
+
388
+ pi.on("ui_prompt_end", () => {
389
+ askUser = undefined;
390
+ if (!rootSession) return;
391
+ lower();
392
+ });
393
+
394
+ pi.on("session_start", (_event, ctx) => {
395
+ // 与 herdr 集成同一门槛:RPC/print 没有可展示的 PTY;
396
+ // reload 会同时重载集成与本扩展,两侧计数一起归零。
397
+ if (ctx?.mode !== "tui") return;
398
+ rootSession = true;
399
+ raised = false;
400
+ raisedLabel = undefined;
401
+ askUser = undefined;
402
+ });
403
+
404
+ pi.on("session_shutdown", () => {
405
+ lower();
406
+ rootSession = false;
407
+ askUser = undefined;
408
+ });
409
+ }