@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 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
- Status display is controlled by `config.json` in the extension directory. Copy `config.json.example` to get started:
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`。选择会保存到 Pi 的用户扩展配置目录;显式设置的 `PI_TERMINAL_MUX` / `PI_SUBAGENT_MUX` 优先级更高。`/subagent-config` 和 `/pi-subagent-config` 仍作为兼容别名保留。
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
- 状态显示由扩展目录下的 `config.json` 控制。复制 `config.json.example` 开始:
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))
@@ -1,4 +1,5 @@
1
1
  {
2
+ "herdrMode": "split",
2
3
  "status": {
3
4
  "enabled": true
4
5
  }
@@ -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 terminal multiplexer used by subagents"
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{value}。可选:auto、muxy、cmux、tmux、zellij、wezterm、herdr、otty、orca。",
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.9.1",
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.3.2"
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
- function muxPreferenceLabel(preference: SubagentMuxPreference): string {
972
- return preference === "auto" ? i18n.t("muxAuto") : preference;
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().toLowerCase();
1032
+ const requested = args.trim();
980
1033
  if (requested) {
981
- const preference = requested === "auto" || (SUBAGENT_MUX_BACKENDS as readonly string[]).includes(requested)
982
- ? requested as SubagentMuxPreference
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 = saveMuxPreference(preference);
990
- ctx.ui.notify(i18n.t("muxSaved", { value: muxPreferenceLabel(saved.mux) }), "info");
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<{ preference: SubagentMuxPreference; label: string }> = [
1062
+ const options: Array<MuxConfigSelection & { label: string }> = [
1005
1063
  {
1006
- preference: "auto",
1007
- label: `${current.mux === "auto" ? "●" : "○"} ${i18n.t("muxAuto")} — ${detected ?? i18n.t("muxUnavailable")}`,
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 = saveMuxPreference(selected.preference);
1025
- ctx.ui.notify(i18n.t("muxSaved", { value: muxPreferenceLabel(saved.mux) }), "info");
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
- function readStoredPreference(path: string): SubagentMuxPreference | null {
64
+ /** 读取配置对象;不存在、格式错误或非对象时返回空对象。 */
65
+ function readConfigObject(path: string): Record<string, unknown> {
37
66
  try {
38
- if (!existsSync(path)) return null;
39
- const parsed = JSON.parse(readFileSync(path, "utf-8")) as { mux?: unknown };
40
- return normalizePreference(parsed.mux);
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 null;
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
- /** Apply the persisted setting when no explicit environment override exists. */
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()) return;
60
- const preference = readStoredPreference(path);
61
- if (!preference || preference === "auto") return;
62
- process.env.PI_TERMINAL_MUX = preference;
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
- let existing: Record<string, unknown> = {};
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: any[] | undefined,
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 msg = messages[i];
40
- if (msg?.role === "assistant") {
41
- return msg.stopReason !== "aborted";
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 non-auto-exit subagent finishes generating, schedule a nudge
93
- * reminding it to call subagent_done if it hasn't already.
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
- * (e.g. during multi-turn tool use) automatically reset the timer.
97
- * The nudge only fires if no new agent activity or user input arrives
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 结束 turn 后只能靠主动调用 subagent_done 才能真正退出。
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
- function renderWidget(ctx: { ui: { setWidget: Function } }, _theme: any) {
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: any, theme: any) => {
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, null);
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
- // For non-auto-exit agents: schedule a nudge in case the AI forgot to call
220
- // subagent_done. This is automatically cleared/reset on any subsequent
221
- // agent activity or user input.
222
- scheduleAgentEndNudge();
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, null);
298
+ renderWidget(ctx);
276
299
  },
277
300
  });
278
301