@maplezzk/pi-interactive-subagents 3.9.1 → 3.10.1
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 +7 -4
- package/README.zh-CN.md +7 -4
- package/config.json.example +1 -0
- package/locales/index.json +12 -4
- package/package.json +3 -3
- package/pi-extension/subagents/index.ts +100 -19
- package/pi-extension/subagents/mux-config.ts +85 -24
- package/pi-extension/subagents/subagent-done.ts +45 -22
package/README.md
CHANGED
|
@@ -8,7 +8,7 @@ https://github.com/user-attachments/assets/30adb156-cfb4-4c47-84ca-dd4aa80cba9f
|
|
|
8
8
|
|
|
9
9
|
## How It Works
|
|
10
10
|
|
|
11
|
-
Call `subagent()` and it **returns immediately**. The sub-agent runs in its own terminal pane. A live widget above the input shows all running agents with their current state — `starting`, `active`, `waiting`, `stalled`, or `running`. When a sub-agent finishes, its result is **steered back** into the main session as an async notification — triggering a new turn so the agent can process it.
|
|
11
|
+
Call `subagent()` and it **returns immediately**. The sub-agent runs in its own terminal pane. A live widget above the input shows all running agents with their current state — `starting`, `active`, `waiting`, `stalled`, or `running`. When a sub-agent finishes, its result is **steered back** into the main session as an async notification — triggering a new turn so the agent can process it. Completion reminders are injected only after the model stops normally; user aborts and provider errors stay quiet.
|
|
12
12
|
|
|
13
13
|
```
|
|
14
14
|
╭─ Subagents ──────────────────────────── 2 running ─╮
|
|
@@ -59,9 +59,9 @@ zellij --session pi # then run: pi
|
|
|
59
59
|
# just run pi inside Orca — no wrapper needed
|
|
60
60
|
```
|
|
61
61
|
|
|
62
|
-
Optional: set `PI_SUBAGENT_MUX=muxy|cmux|tmux|zellij|wezterm|herdr|otty|orca` to force a specific backend.
|
|
62
|
+
Optional: set `PI_SUBAGENT_MUX=muxy|cmux|tmux|zellij|wezterm|herdr|otty|orca` to force a specific backend. Herdr uses its original split layout by default; set `PI_SUBAGENT_HERDR_MODE=tab` for one background tab per subagent, or `split` to select pane splitting explicitly.
|
|
63
63
|
|
|
64
|
-
You can also configure it from inside Pi with `/config:subagent`. Run it without arguments for an interactive menu, or use `/config:subagent auto|muxy|cmux|tmux|zellij|wezterm|herdr|otty|orca` for a direct choice. The selection is persisted in Pi's user extension config directory; explicit `PI_TERMINAL_MUX` / `PI_SUBAGENT_MUX` environment variables take precedence. `/subagent-config` and `/pi-subagent-config` remain available as compatibility aliases.
|
|
64
|
+
You can also configure it from inside Pi with `/config:subagent`. Run it without arguments for an interactive menu, or use `/config:subagent auto|muxy|cmux|tmux|zellij|wezterm|herdr [split|tab]|otty|orca` for a direct choice. For example, `/config:subagent herdr tab` persists both the Herdr backend and tab mode. The selection is persisted in Pi's user extension config directory; explicit `PI_TERMINAL_MUX` / `PI_SUBAGENT_MUX` and `PI_SUBAGENT_HERDR_MODE` environment variables take precedence. `/subagent-config` and `/pi-subagent-config` remain available as compatibility aliases.
|
|
65
65
|
|
|
66
66
|
> **Otty notes:**
|
|
67
67
|
> - Otty sets `TERM_PROGRAM=otty` automatically when pi runs inside it; the backend detects this env var.
|
|
@@ -159,7 +159,7 @@ These labels are no longer derived from session-file growth. Session JSONL is st
|
|
|
159
159
|
|
|
160
160
|
#### Configuration
|
|
161
161
|
|
|
162
|
-
|
|
162
|
+
Subagent settings, including status display and the persisted Herdr mode, are controlled by `config.json` in the extension directory. Copy `config.json.example` to get started:
|
|
163
163
|
|
|
164
164
|
```bash
|
|
165
165
|
cp config.json.example config.json
|
|
@@ -167,12 +167,15 @@ cp config.json.example config.json
|
|
|
167
167
|
|
|
168
168
|
```json
|
|
169
169
|
{
|
|
170
|
+
"herdrMode": "split",
|
|
170
171
|
"status": {
|
|
171
172
|
"enabled": true
|
|
172
173
|
}
|
|
173
174
|
}
|
|
174
175
|
```
|
|
175
176
|
|
|
177
|
+
`herdrMode` accepts `split` (default, backward-compatible pane layout) or `tab` (one background tab per subagent). The `/config:subagent herdr split|tab` command updates this field.
|
|
178
|
+
|
|
176
179
|
`config.json` is gitignored so local overrides don't get committed.
|
|
177
180
|
|
|
178
181
|
---
|
package/README.zh-CN.md
CHANGED
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
|
|
7
7
|
## 工作原理
|
|
8
8
|
|
|
9
|
-
调用 `subagent()` 后**立即返回**,子 agent 在自己的终端分屏中运行。输入框上方的实时 widget 展示所有运行中的 agent 及其状态(`starting`、`active`、`waiting`、`stalled`、`running`)。子 agent
|
|
9
|
+
调用 `subagent()` 后**立即返回**,子 agent 在自己的终端分屏中运行。输入框上方的实时 widget 展示所有运行中的 agent 及其状态(`starting`、`active`、`waiting`、`stalled`、`running`)。子 agent 完成后,结果以异步通知形式**回流**到主会话,触发新一轮处理。完成提醒仅在模型正常停止后注入;用户手动终止或提供方异常不会触发。
|
|
10
10
|
|
|
11
11
|
```typescript
|
|
12
12
|
subagent({ name: "Scout: Auth", agent: "scout", task: "分析 auth 模块" });
|
|
@@ -32,10 +32,10 @@ tmux new -A -s pi 'pi'
|
|
|
32
32
|
zellij --session pi # 然后运行 pi
|
|
33
33
|
```
|
|
34
34
|
|
|
35
|
-
可选:设置 `PI_SUBAGENT_MUX=muxy|cmux|tmux|zellij|wezterm|herdr|otty|orca` 强制指定后端。
|
|
35
|
+
可选:设置 `PI_SUBAGENT_MUX=muxy|cmux|tmux|zellij|wezterm|herdr|otty|orca` 强制指定后端。Herdr 默认保持原有分屏布局;设置 `PI_SUBAGENT_HERDR_MODE=tab` 后每个 subagent 创建独立后台 Tab,设置为 `split` 可显式选择分屏模式。
|
|
36
36
|
|
|
37
37
|
也可以在 Pi 内通过 `/config:subagent` 配置:不带参数打开选择菜单,或直接运行
|
|
38
|
-
`/config:subagent auto|muxy|cmux|tmux|zellij|wezterm|herdr|otty|orca
|
|
38
|
+
`/config:subagent auto|muxy|cmux|tmux|zellij|wezterm|herdr [split|tab]|otty|orca`。例如 `/config:subagent herdr tab` 会同时持久化 Herdr 后端与 Tab 模式。选择会保存到 Pi 的用户扩展配置目录;显式设置的 `PI_TERMINAL_MUX` / `PI_SUBAGENT_MUX` 与 `PI_SUBAGENT_HERDR_MODE` 优先级更高。`/subagent-config` 和 `/pi-subagent-config` 仍作为兼容别名保留。
|
|
39
39
|
|
|
40
40
|
## 主要能力
|
|
41
41
|
|
|
@@ -50,7 +50,7 @@ zellij --session pi # 然后运行 pi
|
|
|
50
50
|
|
|
51
51
|
## 配置
|
|
52
52
|
|
|
53
|
-
|
|
53
|
+
状态显示与持久化 Herdr 模式由扩展目录下的 `config.json` 控制。复制 `config.json.example` 开始:
|
|
54
54
|
|
|
55
55
|
```bash
|
|
56
56
|
cp config.json.example config.json
|
|
@@ -58,12 +58,15 @@ cp config.json.example config.json
|
|
|
58
58
|
|
|
59
59
|
```json
|
|
60
60
|
{
|
|
61
|
+
"herdrMode": "split",
|
|
61
62
|
"status": {
|
|
62
63
|
"enabled": true
|
|
63
64
|
}
|
|
64
65
|
}
|
|
65
66
|
```
|
|
66
67
|
|
|
68
|
+
`herdrMode` 支持 `split`(默认,兼容原有 pane 布局)和 `tab`(每个 subagent 独立后台 Tab)。`/config:subagent herdr split|tab` 会更新该字段。
|
|
69
|
+
|
|
67
70
|
## 致谢
|
|
68
71
|
|
|
69
72
|
- 原作者 **HazAT** 的设计与实现([原始仓库](https://github.com/HazAT/pi-interactive-subagents))
|
package/config.json.example
CHANGED
package/locales/index.json
CHANGED
|
@@ -4,8 +4,8 @@
|
|
|
4
4
|
"en-US": "[Auto reminder]\n• Done → call subagent_done to finish.\n• Before finishing, self-check: are you spinning in place? If so, converge your result immediately and hand it back to the main agent with caller_ping — don't overthink.\n• Still working → ignore."
|
|
5
5
|
},
|
|
6
6
|
"muxCommandDescription": {
|
|
7
|
-
"zh-CN": "配置 subagent
|
|
8
|
-
"en-US": "Configure the
|
|
7
|
+
"zh-CN": "配置 subagent 使用的终端复用器及 Herdr 布局模式",
|
|
8
|
+
"en-US": "Configure the subagent multiplexer and Herdr layout mode"
|
|
9
9
|
},
|
|
10
10
|
"muxConfigTitle": {
|
|
11
11
|
"zh-CN": "选择 subagent 终端复用器(当前:{value})",
|
|
@@ -32,8 +32,16 @@
|
|
|
32
32
|
"en-US": "Subagent multiplexer set to: {value}"
|
|
33
33
|
},
|
|
34
34
|
"muxInvalid": {
|
|
35
|
-
"zh-CN": "不支持的 mux
|
|
36
|
-
"en-US": "Unsupported mux: {value}. Choose auto, muxy, cmux, tmux, zellij, wezterm, herdr, otty, or orca."
|
|
35
|
+
"zh-CN": "不支持的 mux 配置:{value}。可选:auto、muxy、cmux、tmux、zellij、wezterm、herdr [split|tab]、otty、orca。",
|
|
36
|
+
"en-US": "Unsupported mux setting: {value}. Choose auto, muxy, cmux, tmux, zellij, wezterm, herdr [split|tab], otty, or orca."
|
|
37
|
+
},
|
|
38
|
+
"herdrModeSplit": {
|
|
39
|
+
"zh-CN": "分屏",
|
|
40
|
+
"en-US": "split panes"
|
|
41
|
+
},
|
|
42
|
+
"herdrModeTab": {
|
|
43
|
+
"zh-CN": "独立 Tab",
|
|
44
|
+
"en-US": "separate tabs"
|
|
37
45
|
},
|
|
38
46
|
"muxInteractiveOnly": {
|
|
39
47
|
"zh-CN": "请在支持 UI 的 Pi 会话中运行 /config:subagent。",
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@maplezzk/pi-interactive-subagents",
|
|
3
|
-
"version": "3.
|
|
3
|
+
"version": "3.10.1",
|
|
4
4
|
"description": "Interactive async subagents for pi — spawn, orchestrate, and manage sub-agent sessions in multiplexer panes. Fork of HazAT/pi-interactive-subagents.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./index.ts",
|
|
@@ -17,7 +17,7 @@
|
|
|
17
17
|
"README.zh-CN.md"
|
|
18
18
|
],
|
|
19
19
|
"scripts": {
|
|
20
|
-
"test": "tsx --test test/test.ts",
|
|
20
|
+
"test": "tsx --test test/subagent-done-nudge.test.ts test/test.ts",
|
|
21
21
|
"test:integration": "tsx --test --test-concurrency=1 test/integration/*.test.ts",
|
|
22
22
|
"typecheck": "tsc --noEmit --pretty false",
|
|
23
23
|
"build": "npm run typecheck",
|
|
@@ -59,7 +59,7 @@
|
|
|
59
59
|
"dependencies": {
|
|
60
60
|
"ajv": "^8.20.0",
|
|
61
61
|
"pi-extensions-i18n": "^0.3.1",
|
|
62
|
-
"pi-terminal-mux": "^0.
|
|
62
|
+
"pi-terminal-mux": "^0.4.0"
|
|
63
63
|
},
|
|
64
64
|
"peerDependencies": {
|
|
65
65
|
"@earendil-works/pi-coding-agent": ">=0.80.0 <0.81.0",
|
|
@@ -44,9 +44,14 @@ import {
|
|
|
44
44
|
|
|
45
45
|
import {
|
|
46
46
|
applyPersistedMuxPreference,
|
|
47
|
+
HERDR_SURFACE_MODE_TAB,
|
|
48
|
+
HERDR_SURFACE_MODES,
|
|
49
|
+
loadHerdrModeConfig,
|
|
47
50
|
loadMuxConfig,
|
|
51
|
+
saveHerdrMode,
|
|
48
52
|
saveMuxPreference,
|
|
49
53
|
SUBAGENT_MUX_BACKENDS,
|
|
54
|
+
type HerdrSurfaceMode,
|
|
50
55
|
type SubagentMuxPreference,
|
|
51
56
|
} from "./mux-config.ts";
|
|
52
57
|
|
|
@@ -968,26 +973,78 @@ function isMuxBackendAvailable(backend: MuxBackend): boolean {
|
|
|
968
973
|
}
|
|
969
974
|
}
|
|
970
975
|
|
|
971
|
-
|
|
972
|
-
|
|
976
|
+
const AUTO_MUX_PREFERENCE: SubagentMuxPreference = "auto";
|
|
977
|
+
const HERDR_MUX_BACKEND: MuxBackend = "herdr";
|
|
978
|
+
const MAX_MUX_CONFIG_TOKENS = 2;
|
|
979
|
+
|
|
980
|
+
interface MuxConfigSelection {
|
|
981
|
+
preference: SubagentMuxPreference;
|
|
982
|
+
herdrMode?: HerdrSurfaceMode;
|
|
983
|
+
}
|
|
984
|
+
|
|
985
|
+
/** 返回本地化的 Herdr surface 模式名称。 */
|
|
986
|
+
function herdrModeLabel(mode: HerdrSurfaceMode): string {
|
|
987
|
+
return mode === HERDR_SURFACE_MODE_TAB
|
|
988
|
+
? i18n.t("herdrModeTab")
|
|
989
|
+
: i18n.t("herdrModeSplit");
|
|
990
|
+
}
|
|
991
|
+
|
|
992
|
+
/** 格式化当前 mux;Herdr 同时展示 split/tab 子模式。 */
|
|
993
|
+
function muxPreferenceLabel(preference: SubagentMuxPreference, herdrMode?: HerdrSurfaceMode): string {
|
|
994
|
+
if (preference === AUTO_MUX_PREFERENCE) return i18n.t("muxAuto");
|
|
995
|
+
if (preference === HERDR_MUX_BACKEND && herdrMode) {
|
|
996
|
+
return `herdr (${herdrModeLabel(herdrMode)})`;
|
|
997
|
+
}
|
|
998
|
+
return preference;
|
|
999
|
+
}
|
|
1000
|
+
|
|
1001
|
+
/** 解析 `/config:subagent` 直接参数,Herdr 可追加 split/tab。 */
|
|
1002
|
+
function parseMuxConfigRequest(requested: string): MuxConfigSelection | null {
|
|
1003
|
+
const tokens = requested.trim().toLowerCase().split(/\s+/).filter(Boolean);
|
|
1004
|
+
if (tokens.length === 0 || tokens.length > MAX_MUX_CONFIG_TOKENS) return null;
|
|
1005
|
+
|
|
1006
|
+
const requestedMux = tokens[0];
|
|
1007
|
+
const preference = requestedMux === AUTO_MUX_PREFERENCE || (SUBAGENT_MUX_BACKENDS as readonly string[]).includes(requestedMux)
|
|
1008
|
+
? requestedMux as SubagentMuxPreference
|
|
1009
|
+
: null;
|
|
1010
|
+
if (!preference) return null;
|
|
1011
|
+
|
|
1012
|
+
const requestedMode = tokens[1];
|
|
1013
|
+
if (!requestedMode) return { preference };
|
|
1014
|
+
if (preference !== HERDR_MUX_BACKEND || !(HERDR_SURFACE_MODES as readonly string[]).includes(requestedMode)) {
|
|
1015
|
+
return null;
|
|
1016
|
+
}
|
|
1017
|
+
return { preference, herdrMode: requestedMode as HerdrSurfaceMode };
|
|
973
1018
|
}
|
|
974
1019
|
|
|
1020
|
+
/** 保存 mux 选择,并在 Herdr 选项携带模式时一并持久化。 */
|
|
1021
|
+
function saveMuxConfigSelection(selection: MuxConfigSelection): MuxConfigSelection {
|
|
1022
|
+
const savedMux = saveMuxPreference(selection.preference);
|
|
1023
|
+
const savedMode = selection.herdrMode ? saveHerdrMode(selection.herdrMode).herdrMode : undefined;
|
|
1024
|
+
return { preference: savedMux.mux, herdrMode: savedMode };
|
|
1025
|
+
}
|
|
1026
|
+
|
|
1027
|
+
/** 注册 mux 配置命令及其兼容别名,并持久化用户选择。 */
|
|
975
1028
|
function registerMuxConfigCommand(pi: ExtensionAPI): void {
|
|
976
1029
|
const command = {
|
|
977
1030
|
description: i18n.t("muxCommandDescription"),
|
|
978
1031
|
handler: async (args, ctx) => {
|
|
979
|
-
const requested = args.trim()
|
|
1032
|
+
const requested = args.trim();
|
|
980
1033
|
if (requested) {
|
|
981
|
-
const
|
|
982
|
-
|
|
983
|
-
: null;
|
|
984
|
-
if (!preference) {
|
|
1034
|
+
const selection = parseMuxConfigRequest(requested);
|
|
1035
|
+
if (!selection) {
|
|
985
1036
|
ctx.ui.notify(i18n.t("muxInvalid", { value: requested }), "warning");
|
|
986
1037
|
return;
|
|
987
1038
|
}
|
|
988
1039
|
try {
|
|
989
|
-
const saved =
|
|
990
|
-
|
|
1040
|
+
const saved = saveMuxConfigSelection(selection);
|
|
1041
|
+
const herdrMode = saved.preference === HERDR_MUX_BACKEND
|
|
1042
|
+
? saved.herdrMode ?? loadHerdrModeConfig().herdrMode
|
|
1043
|
+
: undefined;
|
|
1044
|
+
ctx.ui.notify(
|
|
1045
|
+
i18n.t("muxSaved", { value: muxPreferenceLabel(saved.preference, herdrMode) }),
|
|
1046
|
+
"info",
|
|
1047
|
+
);
|
|
991
1048
|
} catch (error) {
|
|
992
1049
|
ctx.ui.notify(String(error), "error");
|
|
993
1050
|
}
|
|
@@ -1000,20 +1057,38 @@ function registerMuxConfigCommand(pi: ExtensionAPI): void {
|
|
|
1000
1057
|
}
|
|
1001
1058
|
|
|
1002
1059
|
const current = loadMuxConfig();
|
|
1060
|
+
const currentHerdrMode = loadHerdrModeConfig().herdrMode;
|
|
1003
1061
|
const detected = getMuxBackend();
|
|
1004
|
-
const options: Array<
|
|
1062
|
+
const options: Array<MuxConfigSelection & { label: string }> = [
|
|
1005
1063
|
{
|
|
1006
|
-
preference:
|
|
1007
|
-
label: `${current.mux ===
|
|
1064
|
+
preference: AUTO_MUX_PREFERENCE,
|
|
1065
|
+
label: `${current.mux === AUTO_MUX_PREFERENCE ? "●" : "○"} ${i18n.t("muxAuto")} — ${detected ?? i18n.t("muxUnavailable")}`,
|
|
1008
1066
|
},
|
|
1009
|
-
...SUBAGENT_MUX_BACKENDS.map((backend) => ({
|
|
1010
|
-
preference: backend,
|
|
1011
|
-
label: `${current.mux === backend ? "●" : "○"} ${backend} — ${isMuxBackendAvailable(backend) ? i18n.t("muxAvailable") : i18n.t("muxUnavailable")}`,
|
|
1012
|
-
})),
|
|
1013
1067
|
];
|
|
1068
|
+
for (const backend of SUBAGENT_MUX_BACKENDS) {
|
|
1069
|
+
const available = isMuxBackendAvailable(backend)
|
|
1070
|
+
? i18n.t("muxAvailable")
|
|
1071
|
+
: i18n.t("muxUnavailable");
|
|
1072
|
+
if (backend === HERDR_MUX_BACKEND) {
|
|
1073
|
+
for (const herdrMode of HERDR_SURFACE_MODES) {
|
|
1074
|
+
const selected = current.mux === backend && currentHerdrMode === herdrMode;
|
|
1075
|
+
options.push({
|
|
1076
|
+
preference: backend,
|
|
1077
|
+
herdrMode,
|
|
1078
|
+
label: `${selected ? "●" : "○"} ${muxPreferenceLabel(backend, herdrMode)} — ${available}`,
|
|
1079
|
+
});
|
|
1080
|
+
}
|
|
1081
|
+
continue;
|
|
1082
|
+
}
|
|
1083
|
+
options.push({
|
|
1084
|
+
preference: backend,
|
|
1085
|
+
label: `${current.mux === backend ? "●" : "○"} ${backend} — ${available}`,
|
|
1086
|
+
});
|
|
1087
|
+
}
|
|
1088
|
+
|
|
1014
1089
|
const exit = i18n.t("muxExit");
|
|
1015
1090
|
const choice = await ctx.ui.select(
|
|
1016
|
-
i18n.t("muxConfigTitle", { value: muxPreferenceLabel(current.mux) }),
|
|
1091
|
+
i18n.t("muxConfigTitle", { value: muxPreferenceLabel(current.mux, currentHerdrMode) }),
|
|
1017
1092
|
[...options.map((option) => option.label), exit],
|
|
1018
1093
|
);
|
|
1019
1094
|
if (!choice || choice === exit) return;
|
|
@@ -1021,8 +1096,13 @@ function registerMuxConfigCommand(pi: ExtensionAPI): void {
|
|
|
1021
1096
|
const selected = options.find((option) => option.label === choice);
|
|
1022
1097
|
if (!selected) return;
|
|
1023
1098
|
try {
|
|
1024
|
-
const saved =
|
|
1025
|
-
ctx.ui.notify(
|
|
1099
|
+
const saved = saveMuxConfigSelection(selected);
|
|
1100
|
+
ctx.ui.notify(
|
|
1101
|
+
i18n.t("muxSaved", {
|
|
1102
|
+
value: muxPreferenceLabel(saved.preference, saved.herdrMode),
|
|
1103
|
+
}),
|
|
1104
|
+
"info",
|
|
1105
|
+
);
|
|
1026
1106
|
} catch (error) {
|
|
1027
1107
|
ctx.ui.notify(String(error), "error");
|
|
1028
1108
|
}
|
|
@@ -1035,6 +1115,7 @@ function registerMuxConfigCommand(pi: ExtensionAPI): void {
|
|
|
1035
1115
|
|
|
1036
1116
|
export const __test__ = {
|
|
1037
1117
|
borderLine,
|
|
1118
|
+
parseMuxConfigRequest,
|
|
1038
1119
|
getShellReadyDelayMs,
|
|
1039
1120
|
renderSubagentWidgetLines,
|
|
1040
1121
|
loadAgentDefaults,
|
|
@@ -5,14 +5,25 @@ import type { MuxBackend } from "pi-terminal-mux";
|
|
|
5
5
|
|
|
6
6
|
export type SubagentMuxPreference = "auto" | MuxBackend;
|
|
7
7
|
export type SubagentMuxConfigSource = "environment" | "file" | "default";
|
|
8
|
+
export const HERDR_SURFACE_MODE_SPLIT = "split";
|
|
9
|
+
export const HERDR_SURFACE_MODE_TAB = "tab";
|
|
10
|
+
export const HERDR_SURFACE_MODES = [HERDR_SURFACE_MODE_SPLIT, HERDR_SURFACE_MODE_TAB] as const;
|
|
11
|
+
export type HerdrSurfaceMode = (typeof HERDR_SURFACE_MODES)[number];
|
|
8
12
|
|
|
9
13
|
export interface SubagentMuxConfig {
|
|
10
14
|
mux: SubagentMuxPreference;
|
|
11
15
|
source: SubagentMuxConfigSource;
|
|
12
16
|
}
|
|
13
17
|
|
|
18
|
+
export interface SubagentHerdrModeConfig {
|
|
19
|
+
herdrMode: HerdrSurfaceMode;
|
|
20
|
+
source: SubagentMuxConfigSource;
|
|
21
|
+
}
|
|
22
|
+
|
|
14
23
|
const BACKENDS: readonly MuxBackend[] = ["muxy", "cmux", "tmux", "zellij", "wezterm", "herdr", "otty", "orca"];
|
|
15
24
|
const CONFIG_FILE = "config.json";
|
|
25
|
+
const HERDR_MODE_ENV = "PI_SUBAGENT_HERDR_MODE";
|
|
26
|
+
const DEFAULT_HERDR_MODE: HerdrSurfaceMode = HERDR_SURFACE_MODE_SPLIT;
|
|
16
27
|
|
|
17
28
|
function isMuxBackend(value: unknown): value is MuxBackend {
|
|
18
29
|
return typeof value === "string" && (BACKENDS as readonly string[]).includes(value);
|
|
@@ -23,26 +34,61 @@ function normalizePreference(value: unknown): SubagentMuxPreference | null {
|
|
|
23
34
|
return isMuxBackend(value) ? value : null;
|
|
24
35
|
}
|
|
25
36
|
|
|
37
|
+
/** 将未知配置值收窄为受支持的 Herdr surface 模式。 */
|
|
38
|
+
function normalizeHerdrMode(value: unknown): HerdrSurfaceMode | null {
|
|
39
|
+
return typeof value === "string" && (HERDR_SURFACE_MODES as readonly string[]).includes(value)
|
|
40
|
+
? value as HerdrSurfaceMode
|
|
41
|
+
: null;
|
|
42
|
+
}
|
|
43
|
+
|
|
26
44
|
function environmentPreference(): MuxBackend | null {
|
|
27
45
|
const value = (process.env.PI_TERMINAL_MUX ?? process.env.PI_SUBAGENT_MUX ?? "").trim().toLowerCase();
|
|
28
46
|
return isMuxBackend(value) ? value : null;
|
|
29
47
|
}
|
|
30
48
|
|
|
49
|
+
/** 读取有效的 Herdr 模式环境变量;非法值留给 pi-terminal-mux 显式报错。 */
|
|
50
|
+
function environmentHerdrMode(): HerdrSurfaceMode | null {
|
|
51
|
+
return normalizeHerdrMode(process.env[HERDR_MODE_ENV]?.trim().toLowerCase());
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** 判断用户是否显式提供了非空 Herdr 模式环境变量,包括待报错的非法值。 */
|
|
55
|
+
function hasHerdrModeEnvironmentOverride(): boolean {
|
|
56
|
+
return !!process.env[HERDR_MODE_ENV]?.trim();
|
|
57
|
+
}
|
|
58
|
+
|
|
31
59
|
/** User-level config shared by the subagent extension across Pi sessions. */
|
|
32
60
|
export function muxConfigPath(): string {
|
|
33
61
|
return join(getAgentDir(), "extensions", "pi-interactive-subagents", CONFIG_FILE);
|
|
34
62
|
}
|
|
35
63
|
|
|
36
|
-
|
|
64
|
+
/** 读取配置对象;不存在、格式错误或非对象时返回空对象。 */
|
|
65
|
+
function readConfigObject(path: string): Record<string, unknown> {
|
|
37
66
|
try {
|
|
38
|
-
if (!existsSync(path)) return
|
|
39
|
-
const parsed = JSON.parse(readFileSync(path, "utf-8")) as
|
|
40
|
-
return
|
|
67
|
+
if (!existsSync(path)) return {};
|
|
68
|
+
const parsed = JSON.parse(readFileSync(path, "utf-8")) as unknown;
|
|
69
|
+
return parsed && typeof parsed === "object" && !Array.isArray(parsed)
|
|
70
|
+
? parsed as Record<string, unknown>
|
|
71
|
+
: {};
|
|
41
72
|
} catch {
|
|
42
|
-
return
|
|
73
|
+
return {};
|
|
43
74
|
}
|
|
44
75
|
}
|
|
45
76
|
|
|
77
|
+
/** 写入完整配置对象并保留稳定的格式与结尾换行。 */
|
|
78
|
+
function writeConfigObject(path: string, config: Record<string, unknown>): void {
|
|
79
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
80
|
+
writeFileSync(path, `${JSON.stringify(config, null, 2)}\n`, "utf-8");
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function readStoredPreference(path: string): SubagentMuxPreference | null {
|
|
84
|
+
return normalizePreference(readConfigObject(path).mux);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** 读取持久化 Herdr 模式;旧配置缺少字段时返回 null。 */
|
|
88
|
+
function readStoredHerdrMode(path: string): HerdrSurfaceMode | null {
|
|
89
|
+
return normalizeHerdrMode(readConfigObject(path).herdrMode);
|
|
90
|
+
}
|
|
91
|
+
|
|
46
92
|
/** Environment variables override the persisted slash-command setting. */
|
|
47
93
|
export function loadMuxConfig(path = muxConfigPath()): SubagentMuxConfig {
|
|
48
94
|
const env = environmentPreference();
|
|
@@ -54,12 +100,30 @@ export function loadMuxConfig(path = muxConfigPath()): SubagentMuxConfig {
|
|
|
54
100
|
return { mux: "auto", source: "default" };
|
|
55
101
|
}
|
|
56
102
|
|
|
57
|
-
/**
|
|
103
|
+
/** Herdr mode environment variables override the persisted setting; default stays split. */
|
|
104
|
+
export function loadHerdrModeConfig(path = muxConfigPath()): SubagentHerdrModeConfig {
|
|
105
|
+
const env = environmentHerdrMode();
|
|
106
|
+
if (env) return { herdrMode: env, source: "environment" };
|
|
107
|
+
|
|
108
|
+
const stored = readStoredHerdrMode(path);
|
|
109
|
+
if (stored) return { herdrMode: stored, source: "file" };
|
|
110
|
+
|
|
111
|
+
return { herdrMode: DEFAULT_HERDR_MODE, source: "default" };
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** Apply persisted mux and Herdr mode settings when no explicit environment override exists. */
|
|
58
115
|
export function applyPersistedMuxPreference(path = muxConfigPath()): void {
|
|
59
|
-
if (environmentPreference())
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
116
|
+
if (!environmentPreference()) {
|
|
117
|
+
const preference = readStoredPreference(path);
|
|
118
|
+
if (preference && preference !== "auto") {
|
|
119
|
+
process.env.PI_TERMINAL_MUX = preference;
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
if (!hasHerdrModeEnvironmentOverride()) {
|
|
124
|
+
const herdrMode = readStoredHerdrMode(path);
|
|
125
|
+
if (herdrMode) process.env[HERDR_MODE_ENV] = herdrMode;
|
|
126
|
+
}
|
|
63
127
|
}
|
|
64
128
|
|
|
65
129
|
/** Persist a slash-command selection and make it effective immediately. */
|
|
@@ -67,20 +131,7 @@ export function saveMuxPreference(preference: SubagentMuxPreference, path = muxC
|
|
|
67
131
|
const normalized = normalizePreference(preference);
|
|
68
132
|
if (!normalized) throw new Error(`Unsupported subagent mux preference: ${String(preference)}`);
|
|
69
133
|
|
|
70
|
-
|
|
71
|
-
try {
|
|
72
|
-
if (existsSync(path)) {
|
|
73
|
-
const parsed = JSON.parse(readFileSync(path, "utf-8")) as unknown;
|
|
74
|
-
if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
|
|
75
|
-
existing = parsed as Record<string, unknown>;
|
|
76
|
-
}
|
|
77
|
-
}
|
|
78
|
-
} catch {
|
|
79
|
-
// A malformed file is replaced with a valid config by this explicit save.
|
|
80
|
-
}
|
|
81
|
-
|
|
82
|
-
mkdirSync(dirname(path), { recursive: true });
|
|
83
|
-
writeFileSync(path, `${JSON.stringify({ ...existing, mux: normalized }, null, 2)}\n`, "utf-8");
|
|
134
|
+
writeConfigObject(path, { ...readConfigObject(path), mux: normalized });
|
|
84
135
|
|
|
85
136
|
if (normalized === "auto") {
|
|
86
137
|
delete process.env.PI_TERMINAL_MUX;
|
|
@@ -93,4 +144,14 @@ export function saveMuxPreference(preference: SubagentMuxPreference, path = muxC
|
|
|
93
144
|
return { mux: normalized, source: "file" };
|
|
94
145
|
}
|
|
95
146
|
|
|
147
|
+
/** Persist a Herdr split/tab selection and make it effective immediately. */
|
|
148
|
+
export function saveHerdrMode(mode: HerdrSurfaceMode, path = muxConfigPath()): SubagentHerdrModeConfig {
|
|
149
|
+
const normalized = normalizeHerdrMode(mode);
|
|
150
|
+
if (!normalized) throw new Error(`Unsupported Herdr surface mode: ${String(mode)}`);
|
|
151
|
+
|
|
152
|
+
writeConfigObject(path, { ...readConfigObject(path), herdrMode: normalized });
|
|
153
|
+
process.env[HERDR_MODE_ENV] = normalized;
|
|
154
|
+
return { herdrMode: normalized, source: "file" };
|
|
155
|
+
}
|
|
156
|
+
|
|
96
157
|
export { BACKENDS as SUBAGENT_MUX_BACKENDS };
|
|
@@ -13,7 +13,7 @@
|
|
|
13
13
|
* 现在不论 autoExit env 如何,agent 都必须主动调用 subagent_done 或 caller_ping
|
|
14
14
|
* 才能结束。如果 agent 不调,会被 nudge 提醒。
|
|
15
15
|
*/
|
|
16
|
-
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
16
|
+
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
17
17
|
import { Box, Text } from "@earendil-works/pi-tui";
|
|
18
18
|
import { Type } from "@sinclair/typebox";
|
|
19
19
|
import { writeFileSync } from "node:fs";
|
|
@@ -22,23 +22,41 @@ import { createTranslator, loadCatalog } from "pi-extensions-i18n";
|
|
|
22
22
|
import { createSubagentActivityRecorder } from "./activity.ts";
|
|
23
23
|
|
|
24
24
|
const i18n = createTranslator(loadCatalog(new URL("../../locales/index.json", import.meta.url)));
|
|
25
|
+
const ASSISTANT_ROLE = "assistant";
|
|
26
|
+
const NORMAL_STOP_REASON = "stop";
|
|
27
|
+
const ABORTED_STOP_REASON = "aborted";
|
|
25
28
|
|
|
29
|
+
/** Treat input as manual takeover only after the first agent run has started. */
|
|
26
30
|
export function shouldMarkUserTookOver(agentStarted: boolean): boolean {
|
|
27
31
|
return agentStarted;
|
|
28
32
|
}
|
|
29
33
|
|
|
34
|
+
/** Return true only when the latest assistant message ended by the model stopping normally. */
|
|
35
|
+
export function shouldScheduleAgentEndNudge(
|
|
36
|
+
messages: readonly { role?: string; stopReason?: string }[] | undefined,
|
|
37
|
+
): boolean {
|
|
38
|
+
if (!messages) return false;
|
|
39
|
+
|
|
40
|
+
for (let i = messages.length - 1; i >= 0; i--) {
|
|
41
|
+
const message = messages[i];
|
|
42
|
+
if (message?.role === ASSISTANT_ROLE) {
|
|
43
|
+
return message.stopReason === NORMAL_STOP_REASON;
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
return false;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** Preserve the former auto-exit decision for callers that still use this helper. */
|
|
30
51
|
export function shouldAutoExitOnAgentEnd(
|
|
31
52
|
_userTookOver: boolean,
|
|
32
|
-
messages:
|
|
53
|
+
messages: readonly { role?: string; stopReason?: string }[] | undefined,
|
|
33
54
|
): boolean {
|
|
34
|
-
// Manual input should not strand an auto-exit subagent. If the latest agent
|
|
35
|
-
// turn completed normally, close the session. Escape/abort still leaves it
|
|
36
|
-
// open for inspection or another prompt.
|
|
37
55
|
if (messages) {
|
|
38
56
|
for (let i = messages.length - 1; i >= 0; i--) {
|
|
39
|
-
const
|
|
40
|
-
if (
|
|
41
|
-
return
|
|
57
|
+
const message = messages[i];
|
|
58
|
+
if (message?.role === ASSISTANT_ROLE) {
|
|
59
|
+
return message.stopReason !== ABORTED_STOP_REASON;
|
|
42
60
|
}
|
|
43
61
|
}
|
|
44
62
|
}
|
|
@@ -46,6 +64,7 @@ export function shouldAutoExitOnAgentEnd(
|
|
|
46
64
|
return true;
|
|
47
65
|
}
|
|
48
66
|
|
|
67
|
+
/** Parse the comma-separated denied-tool setting and discard blank entries. */
|
|
49
68
|
export function parseDeniedTools(rawValue: string | undefined): string[] {
|
|
50
69
|
return (rawValue ?? "")
|
|
51
70
|
.split(",")
|
|
@@ -81,6 +100,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
81
100
|
let userInputAfterAgentEnd = false;
|
|
82
101
|
let nudgeTimer: ReturnType<typeof setTimeout> | null = null;
|
|
83
102
|
|
|
103
|
+
/** Cancel and forget the pending completion reminder, if any. */
|
|
84
104
|
function clearNudgeTimer(): void {
|
|
85
105
|
if (nudgeTimer !== null) {
|
|
86
106
|
clearTimeout(nudgeTimer);
|
|
@@ -89,18 +109,17 @@ export default function (pi: ExtensionAPI) {
|
|
|
89
109
|
}
|
|
90
110
|
|
|
91
111
|
/**
|
|
92
|
-
* After a
|
|
93
|
-
*
|
|
112
|
+
* After a subagent stops normally, schedule a nudge reminding it to call
|
|
113
|
+
* subagent_done if it hasn't already. Error and aborted runs are excluded.
|
|
94
114
|
*
|
|
95
115
|
* Each call replaces any pending nudge, so repeated agent_end events
|
|
96
|
-
*
|
|
97
|
-
*
|
|
98
|
-
* within NUDGE_DELAY_MS.
|
|
116
|
+
* automatically reset the timer. The nudge only fires if no new agent
|
|
117
|
+
* activity or user input arrives within NUDGE_DELAY_MS.
|
|
99
118
|
*/
|
|
100
119
|
function scheduleAgentEndNudge(): void {
|
|
101
120
|
clearNudgeTimer();
|
|
102
121
|
// 不论 autoExit 是否启用,都必须 nudge — autoExit 已被移除,
|
|
103
|
-
// agent
|
|
122
|
+
// agent 正常结束 turn 后只能靠主动调用 subagent_done 才能真正退出。
|
|
104
123
|
if (NUDGE_DISABLED || doneCalled) return;
|
|
105
124
|
|
|
106
125
|
nudgeTimer = setTimeout(() => {
|
|
@@ -114,10 +133,11 @@ export default function (pi: ExtensionAPI) {
|
|
|
114
133
|
}, NUDGE_DELAY_MS);
|
|
115
134
|
}
|
|
116
135
|
|
|
117
|
-
|
|
136
|
+
/** Render the subagent identity and tool availability widget. */
|
|
137
|
+
function renderWidget(ctx: Pick<ExtensionContext, "ui">): void {
|
|
118
138
|
ctx.ui.setWidget(
|
|
119
139
|
"subagent-tools",
|
|
120
|
-
(_tui
|
|
140
|
+
(_tui, theme) => {
|
|
121
141
|
const box = new Box(1, 0, (text: string) => theme.bg("toolSuccessBg", text));
|
|
122
142
|
|
|
123
143
|
const label = subagentAgent || subagentName;
|
|
@@ -178,7 +198,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
178
198
|
toolNames = tools.map((t) => t.name).sort();
|
|
179
199
|
denied = parseDeniedTools(deniedToolsValue);
|
|
180
200
|
|
|
181
|
-
renderWidget(ctx
|
|
201
|
+
renderWidget(ctx);
|
|
182
202
|
});
|
|
183
203
|
|
|
184
204
|
pi.on("input", () => {
|
|
@@ -216,10 +236,13 @@ export default function (pi: ExtensionAPI) {
|
|
|
216
236
|
// subagent_done(或 caller_ping)。如果 agent 不调,下面会有 nudge 提醒。
|
|
217
237
|
recorder.agentEndWaiting();
|
|
218
238
|
|
|
219
|
-
//
|
|
220
|
-
//
|
|
221
|
-
|
|
222
|
-
|
|
239
|
+
// Only a normal model stop means the agent itself chose to finish.
|
|
240
|
+
// Provider errors and user aborts must stay quiet.
|
|
241
|
+
if (shouldScheduleAgentEndNudge(event.messages)) {
|
|
242
|
+
scheduleAgentEndNudge();
|
|
243
|
+
} else {
|
|
244
|
+
clearNudgeTimer();
|
|
245
|
+
}
|
|
223
246
|
});
|
|
224
247
|
|
|
225
248
|
pi.on("turn_start", (event) => {
|
|
@@ -272,7 +295,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
272
295
|
description: "Toggle subagent tools widget",
|
|
273
296
|
handler: (ctx) => {
|
|
274
297
|
expanded = !expanded;
|
|
275
|
-
renderWidget(ctx
|
|
298
|
+
renderWidget(ctx);
|
|
276
299
|
},
|
|
277
300
|
});
|
|
278
301
|
|