pi-terminal-mux 0.3.1 → 0.4.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/README.md CHANGED
@@ -37,6 +37,7 @@ if (!isMuxAvailable()) {
37
37
  const surface = createSurface("my-agent");
38
38
 
39
39
  // Long commands are written to a script file first to avoid terminal line wrapping
40
+ // (Bash by default; pass interpreter: "powershell" on Windows for PowerShell)
40
41
  const scriptPath = sendLongCommand(surface, "pi --session abc", {
41
42
  scriptPreamble: "export MY_FLAG=1",
42
43
  });
@@ -55,7 +56,7 @@ closeSurface(surface);
55
56
  | tmux | `TMUX` + `tmux` command |
56
57
  | zellij | `ZELLIJ` / `ZELLIJ_SESSION_NAME` + `zellij` command |
57
58
  | wezterm | `WEZTERM_UNIX_SOCKET` + `wezterm` command |
58
- | herdr | `HERDR_ENV=1` + `HERDR_PANE_ID` + `herdr` command |
59
+ | herdr | `HERDR_ENV=1` + `HERDR_PANE_ID` + `herdr` command (`tab` mode also requires `HERDR_WORKSPACE_ID`) |
59
60
  | otty | `TERM_PROGRAM=otty` + `otty` command |
60
61
  | orca | `TERM_PROGRAM=Orca` + `orca` command + reachable Orca runtime |
61
62
 
@@ -66,18 +67,26 @@ Default priority follows the table order (muxy first). Force a backend with:
66
67
 
67
68
  If the forced backend's runtime is unavailable, `getMuxBackend()` returns `null` — it never silently falls back to another backend.
68
69
 
70
+ ### Herdr surface mode
71
+
72
+ Herdr keeps the backward-compatible breadth-first split mode by default. Set `PI_SUBAGENT_HERDR_MODE=tab` to create one background tab per subagent, or `split` to select the original pane layout explicitly. `createSurfaceSplit()` always remains an explicit pane split.
73
+
74
+ ```bash
75
+ export PI_SUBAGENT_HERDR_MODE=tab
76
+ ```
77
+
69
78
  ## API overview
70
79
 
71
80
  ### Unified surface API (same semantics across backends)
72
81
 
73
82
  | Function | Description |
74
83
  |----------|-------------|
75
- | `createSurface(name)` | Smart placement (cmux: first right-split then tabs; zellij: tab-aware tiled/stacked; muxy/otty/orca: breadth-first splits; orca falls back to a new tab without an agent handle), returns a surface handle |
76
- | `createSurfaceSplit(name, direction, fromSurface?)` | Split in an explicit direction (left/right/up/down) |
84
+ | `createSurface(name)` | Smart placement (herdr: breadth-first splits by default, or one background tab per surface with `PI_SUBAGENT_HERDR_MODE=tab`; cmux: first right-split then tabs; zellij: tab-aware tiled/stacked; muxy/otty/orca: breadth-first splits; orca falls back to a new tab without an agent handle), returns a surface handle |
85
+ | `createSurfaceSplit(name, direction, fromSurface?, options?)` | Split in an explicit direction (left/right/up/down). `options.activate` (WezTerm only, default `false`) focuses the new pane after splitting |
77
86
  | `sendCommand(surface, command)` | Send a command and press Enter |
78
- | `sendLongCommand(surface, command, opts?)` | Write long commands to a script file first; `opts.scriptPreamble` injects env exports; returns the script path |
87
+ | `sendLongCommand(surface, command, opts?)` | Write long commands to a script file first. `opts.scriptPreamble` injects leading lines; `opts.interpreter` (`"bash"` default, or `"powershell"` on Windows) selects the scripting runtime; returns the script path |
79
88
  | `sendEscape(surface)` | Send one ESC keypress |
80
- | `readScreen(surface, lines?)` / `readScreenAsync` | Read the last N screen lines |
89
+ | `readScreen(surface, lines?, options?)` / `readScreenAsync` | Read the last N screen lines. `options.source` (herdr-only) forwards a herdr read source such as `"recent_unwrapped"`; other backends ignore it |
81
90
  | `closeSurface(surface)` | Close the surface |
82
91
  | `renameSurface(surface, name)` / `renameCurrentTab(title)` / `renameAgent(surface, name)` / `renameWorkspace(title)` | Naming, degrading per backend capability |
83
92
  | `pollForExit(surface, signal, opts)` | Wait for the process in a surface to exit: `.exit` sidecar file first, then a screen sentinel (`__SUBAGENT_DONE_<code>__`); headless uses child process exit |
@@ -95,6 +104,16 @@ Backend-native functions are also re-exported (e.g. `createHerdrSurface`, `split
95
104
 
96
105
  When no backend is detected, `createSurface` returns a `headless:`-prefixed surface, `sendLongCommand` spawns a background child process writing to a log file, and `readScreen` / `pollForExit` / `closeSurface` keep the same semantics — callers need no special-casing.
97
106
 
107
+ ## Windows PowerShell support
108
+
109
+ All platforms keep **Bash as the default** scripting runtime to preserve existing caller semantics. On Windows 11 PowerShell/WezTerm/herdr, opt in explicitly:
110
+
111
+ - **Command submission (WezTerm)**: the Enter terminator is `\r` on `win32` and `\n` elsewhere, so PowerShell input is submitted exactly once instead of stopping at the continuation prompt.
112
+ - **Long commands (`sendLongCommand`)**: pass `interpreter: "powershell"` to generate a `.ps1` and run it via `powershell.exe -NoLogo -NoProfile -ExecutionPolicy Bypass -File <path>` (mux) or `-Command "& <path>"` (headless). Explicit `scriptPath` is preserved as-is; auto paths choose `.ps1`/`.sh` by interpreter. Omitted `interpreter` keeps the existing Bash command, `.sh` paths and `$?` numeric sentinels unchanged.
113
+ - **Screen capture (`readScreen` / `readScreenAsync`)**: pass `{ source: "recent_unwrapped" }` (herdr-only) to select herdr's soft-wrap merged capture; omitted options keep herdr `recent` and other backends keep their own read semantics.
114
+
115
+ These are opt-in capabilities — existing Bash callers and `pi-interactive-subagents` continue to run under the default Bash runtime on every platform.
116
+
98
117
  ## Environment variables
99
118
 
100
119
  | Variable | Description |
@@ -102,6 +121,7 @@ When no backend is detected, `createSurface` returns a `headless:`-prefixed surf
102
121
  | `PI_TERMINAL_MUX` / `PI_SUBAGENT_MUX` | Force a backend |
103
122
  | `PI_SUBAGENT_ZELLIJ_MIN_COLUMNS` / `PI_SUBAGENT_ZELLIJ_MIN_ROWS` | Minimum usable size for zellij splits (default 50x10; stacks instead when smaller) |
104
123
  | `PI_SUBAGENT_RENAME_TMUX_WINDOW` / `PI_SUBAGENT_RENAME_TMUX_SESSION` | Allow renameCurrentTab / renameWorkspace on tmux (user naming untouched by default) |
124
+ | `PI_SUBAGENT_HERDR_MODE` | Herdr surface placement: `split` (default) or `tab` |
105
125
  | `PI_SUBAGENT_RENAME_HERDR_WORKSPACE` | Allow renameWorkspace on herdr |
106
126
  | `PI_EXTENSIONS_LOCALE` | Hint language (`zh-CN` / `en-US` / `auto`), provided by pi-extensions-i18n |
107
127
 
package/README.zh-CN.md CHANGED
@@ -36,6 +36,7 @@ if (!isMuxAvailable()) {
36
36
  const surface = createSurface("my-agent");
37
37
 
38
38
  // 长命令自动落脚本文件,避免终端宽度截断
39
+ // (默认 Bash;Windows 上显式传 interpreter: "powershell" 切到 PowerShell)
39
40
  const scriptPath = sendLongCommand(surface, "pi --session abc", {
40
41
  scriptPreamble: "export MY_FLAG=1",
41
42
  });
@@ -54,7 +55,7 @@ closeSurface(surface);
54
55
  | tmux | `TMUX` + `tmux` 命令 |
55
56
  | zellij | `ZELLIJ` / `ZELLIJ_SESSION_NAME` + `zellij` 命令 |
56
57
  | wezterm | `WEZTERM_UNIX_SOCKET` + `wezterm` 命令 |
57
- | herdr | `HERDR_ENV=1` + `HERDR_PANE_ID` + `herdr` 命令 |
58
+ | herdr | `HERDR_ENV=1` + `HERDR_PANE_ID` + `herdr` 命令(`tab` 模式还需要 `HERDR_WORKSPACE_ID`) |
58
59
  | otty | `TERM_PROGRAM=otty` + `otty` 命令 |
59
60
  | orca | `TERM_PROGRAM=Orca` + `orca` 命令 + Orca runtime 可达 |
60
61
 
@@ -65,18 +66,26 @@ closeSurface(surface);
65
66
 
66
67
  指定的后端运行环境不满足时 `getMuxBackend()` 返回 `null`,不会悄悄降级到其他后端。
67
68
 
69
+ ### Herdr surface 模式
70
+
71
+ Herdr 默认保持向后兼容的广度优先分屏模式。设置 `PI_SUBAGENT_HERDR_MODE=tab` 后,每个 subagent 会创建独立后台 Tab;设置为 `split` 可显式选择原有分屏布局。`createSurfaceSplit()` 始终保留显式 pane 分屏语义。
72
+
73
+ ```bash
74
+ export PI_SUBAGENT_HERDR_MODE=tab
75
+ ```
76
+
68
77
  ## API 概览
69
78
 
70
79
  ### 统一 surface API(跨后端语义一致)
71
80
 
72
81
  | 函数 | 说明 |
73
82
  |------|------|
74
- | `createSurface(name)` | 智能放置新 surface(cmux 首次右分屏后续开 tab、zellij tab 感知平铺/堆叠、muxy/otty/orca 广度优先分屏;orca 缺少 agent handle 时新建 tab),返回 surface 标识 |
75
- | `createSurfaceSplit(name, direction, fromSurface?)` | 指定方向(left/right/up/down)分屏 |
83
+ | `createSurface(name)` | 智能放置新 surface(herdr 默认广度优先分屏,设置 `PI_SUBAGENT_HERDR_MODE=tab` 后每个 surface 创建独立后台 Tab;cmux 首次右分屏后续开 tab、zellij tab 感知平铺/堆叠、muxy/otty/orca 广度优先分屏;orca 缺少 agent handle 时新建 tab),返回 surface 标识 |
84
+ | `createSurfaceSplit(name, direction, fromSurface?, options?)` | 指定方向(left/right/up/down)分屏;`options.activate`(仅 wezterm,默认 false)分屏后聚焦新 pane |
76
85
  | `sendCommand(surface, command)` | 发送命令并回车执行 |
77
- | `sendLongCommand(surface, command, opts?)` | 长命令先写脚本文件再执行;`opts.scriptPreamble` 可注入 env export;返回脚本路径 |
86
+ | `sendLongCommand(surface, command, opts?)` | 长命令先写脚本文件再执行;`opts.scriptPreamble` 可注入前置片段;`opts.interpreter`(默认 `"bash"`,Windows 可显式 `"powershell"`)选择脚本运行时;返回脚本路径 |
78
87
  | `sendEscape(surface)` | 发送一次 ESC |
79
- | `readScreen(surface, lines?)` / `readScreenAsync` | 读取屏幕尾部 N 行 |
88
+ | `readScreen(surface, lines?, options?)` / `readScreenAsync` | 读取屏幕尾部 N 行;`options.source`(仅 herdr)透传 herdr 读屏来源(如 `"recent_unwrapped"`),其他后端忽略 |
80
89
  | `closeSurface(surface)` | 关闭 surface |
81
90
  | `renameSurface(surface, name)` / `renameCurrentTab(title)` / `renameAgent(surface, name)` / `renameWorkspace(title)` | 命名(按后端能力降级或跳过) |
82
91
  | `pollForExit(surface, signal, opts)` | 等待 surface 内进程退出:优先 `.exit` sidecar 文件,其次屏幕 sentinel(`__SUBAGENT_DONE_<code>__`),headless 走子进程 exit |
@@ -94,6 +103,16 @@ closeSurface(surface);
94
103
 
95
104
  探测不到任何后端时,`createSurface` 返回 `headless:` 前缀的 surface,`sendLongCommand` 直接 spawn 后台子进程并把输出写入日志文件,`readScreen`/`pollForExit`/`closeSurface` 语义保持不变,调用方无需特判。
96
105
 
106
+ ## Windows PowerShell 支持
107
+
108
+ 所有平台默认保持 **Bash** 作为脚本运行时,以不变更现有调用方语义。Windows 11 PowerShell/WezTerm/herdr 组合下显式开启:
109
+
110
+ - **命令提交(wezterm)**:Enter 终止符在 `win32` 用 `\r`、其他平台用 `\n`,PowerShell 输入只会提交一次,不会停在续行提示。
111
+ - **长命令(`sendLongCommand`)**:传 `interpreter: "powershell"` 会生成 `.ps1`,mux 通过 `powershell.exe -NoLogo -NoProfile -ExecutionPolicy Bypass -File <path>` 执行、headless 走 `-Command "& <path>"`。显式 `scriptPath` 原样保留;自动路径按解释器选 `.ps1`/`.sh`。未传 interpreter 时保持既有 Bash command、`.sh` 路径与 `$?` 数值哨兵不变。
112
+ - **读屏(`readScreen` / `readScreenAsync`)**:传 `{ source: "recent_unwrapped" }`(仅 herdr)选择 herdr 的软换行合并捕获;不传 options 时 herdr 保持 `recent`,其他后端保持各自读屏语义。
113
+
114
+ 这些都是可选项——既有 Bash 调用方与 `pi-interactive-subagents` 在所有平台继续走默认 Bash 运行时。
115
+
97
116
  ## 环境变量
98
117
 
99
118
  | 变量 | 说明 |
@@ -101,6 +120,7 @@ closeSurface(surface);
101
120
  | `PI_TERMINAL_MUX` / `PI_SUBAGENT_MUX` | 强制指定后端 |
102
121
  | `PI_SUBAGENT_ZELLIJ_MIN_COLUMNS` / `PI_SUBAGENT_ZELLIJ_MIN_ROWS` | zellij 分屏最小可用尺寸(默认 50×10,不满足时改堆叠) |
103
122
  | `PI_SUBAGENT_RENAME_TMUX_WINDOW` / `PI_SUBAGENT_RENAME_TMUX_SESSION` | tmux 下允许 renameCurrentTab / renameWorkspace(默认不动用户命名) |
123
+ | `PI_SUBAGENT_HERDR_MODE` | herdr surface 放置模式:`split`(默认)或 `tab` |
104
124
  | `PI_SUBAGENT_RENAME_HERDR_WORKSPACE` | herdr 下允许 renameWorkspace |
105
125
  | `PI_EXTENSIONS_LOCALE` | 提示文案语言(`zh-CN` / `en-US` / `auto`),由 pi-extensions-i18n 提供 |
106
126
 
package/locales/mux.json CHANGED
@@ -46,5 +46,9 @@
46
46
  "setupHint.ottySendKeys": {
47
47
  "zh-CN": "Otty 的 `ipc-allow-send-keys` 未启用。如需让 pi 驱动子 agent 分屏,请在 ~/.config/otty/config.toml 中添加 `ipc-allow-send-keys = true` 并重新加载 Otty。",
48
48
  "en-US": "Otty's `ipc-allow-send-keys` is disabled. To let pi drive subagent panes, add `ipc-allow-send-keys = true` to ~/.config/otty/config.toml and reload Otty."
49
+ },
50
+ "error.invalidHerdrMode": {
51
+ "zh-CN": "不支持的 Herdr surface 模式:{value}。可选值:split、tab。",
52
+ "en-US": "Unsupported Herdr surface mode: {value}. Choose split or tab."
49
53
  }
50
54
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-terminal-mux",
3
- "version": "0.3.1",
3
+ "version": "0.4.0",
4
4
  "description": "Terminal multiplexer abstraction for pi extensions — unified surface API across muxy, cmux, tmux, zellij, wezterm, herdr, otty and orca, with headless fallback",
5
5
  "type": "module",
6
6
  "main": "./index.ts",
@@ -8,7 +8,7 @@
8
8
  * HERDR_TAB_ID(公开 id,如 "1:1")
9
9
  * HERDR_PANE_ID(公开 id,如 "1-1")
10
10
  *
11
- * 所有 pane 操作通过 `herdr` CLI 完成,详见 SKILL.md。
11
+ * 所有 tab / pane 操作通过 `herdr` CLI 完成,详见 SKILL.md。
12
12
  * 日志、文件锁、BFS 分屏状态机、命令检测复用 backends/shared.ts。
13
13
  */
14
14
 
@@ -19,6 +19,32 @@ import { createBackendLogger, withFileLock, BfsSplitStateManager, hasCommand } f
19
19
 
20
20
  // ── 日志(统一格式,写入 /tmp/pi-mux-herdr.log) ──
21
21
  const herdrLog = createBackendLogger("herdr", "/tmp/pi-mux-herdr.log");
22
+ const HERDR_TAB_CLOSE_COMMAND = ["tab", "close"] as const;
23
+ const HERDR_PANE_SPLIT_COMMAND = ["pane", "split"] as const;
24
+ const HERDR_PANE_CLOSE_COMMAND = ["pane", "close"] as const;
25
+ const HERDR_SPLIT_DIRECTION_FLAG = "--direction";
26
+ const HERDR_NO_FOCUS_FLAG = "--no-focus";
27
+ const HERDR_SPLIT_RIGHT = "right";
28
+ const HERDR_SPLIT_DOWN = "down";
29
+ const HERDR_SPLIT_UP = "up";
30
+ const HERDR_MARKER_PATH_PREFIX = "/tmp/herdr-subagent-pane-";
31
+ const HERDR_MARKER_DEFAULT_ID = "default";
32
+ const HERDR_LOCK_SUFFIX = ".lock";
33
+ export const HERDR_SURFACE_MODE_SPLIT = "split";
34
+ export const HERDR_SURFACE_MODE_TAB = "tab";
35
+ const DEFAULT_HERDR_SURFACE_MODE = HERDR_SURFACE_MODE_SPLIT;
36
+
37
+ export type HerdrSurfaceMode =
38
+ | typeof HERDR_SURFACE_MODE_SPLIT
39
+ | typeof HERDR_SURFACE_MODE_TAB;
40
+
41
+ /** 解析 Herdr surface 放置模式;未配置时保持旧版 split,非法显式值直接报错。 */
42
+ export function resolveHerdrSurfaceMode(value = process.env.PI_SUBAGENT_HERDR_MODE): HerdrSurfaceMode {
43
+ const normalized = value?.trim().toLowerCase();
44
+ if (!normalized || normalized === HERDR_SURFACE_MODE_SPLIT) return DEFAULT_HERDR_SURFACE_MODE;
45
+ if (normalized === HERDR_SURFACE_MODE_TAB) return HERDR_SURFACE_MODE_TAB;
46
+ throw new Error(i18n.t("error.invalidHerdrMode", { value }));
47
+ }
22
48
 
23
49
  /**
24
50
  * 捕获于模块加载时的 agent pane id。
@@ -33,18 +59,20 @@ export const AGENT_HERDR_TAB_ID = process.env.HERDR_TAB_ID;
33
59
  * 检测 herdr backend 是否可用:
34
60
  * 1. `herdr` 命令在 PATH 中
35
61
  * 2. 当前进程在 herdr pane 内运行(HERDR_ENV=1)
36
- * 3. HERDR_PANE_ID 已注入
62
+ * 3. HERDR_PANE_ID 已注入;tab 模式还要求 HERDR_WORKSPACE_ID
37
63
  *
38
64
  * 注意:即使 socket 暂时不通,只要命令存在且 env 注入,就认为"runtime available"。
39
- * 子 agent 创建时会用 `herdr pane split` 触发 socket 调用,那时报错即可。
65
+ * 子 agent 创建时再通过对应的 pane split / tab create 命令触发 socket 调用。
40
66
  */
41
67
  export function isHerdrRuntimeAvailable(): boolean {
42
- return (
43
- !!process.env.HERDR_ENV &&
44
- process.env.HERDR_ENV === "1" &&
45
- !!process.env.HERDR_PANE_ID &&
46
- hasCommand("herdr")
47
- );
68
+ if (
69
+ process.env.HERDR_ENV !== "1" ||
70
+ !process.env.HERDR_PANE_ID ||
71
+ !hasCommand("herdr")
72
+ ) {
73
+ return false;
74
+ }
75
+ return resolveHerdrSurfaceMode() === HERDR_SURFACE_MODE_SPLIT || !!process.env.HERDR_WORKSPACE_ID;
48
76
  }
49
77
 
50
78
  // ── herdr CLI 调用的薄封装 ──
@@ -103,19 +131,34 @@ function extractPaneId(json: unknown): string | null {
103
131
 
104
132
  /** herdr BFS 分屏状态 marker 文件路径(按 agent pane id 区分) */
105
133
  function herdrMarkerPath(): string {
106
- return `/tmp/herdr-subagent-pane-${(AGENT_HERDR_PANE_ID ?? "default").replace(/[^a-zA-Z0-9_-]/g, "_")}.json`;
134
+ const paneId = (AGENT_HERDR_PANE_ID ?? HERDR_MARKER_DEFAULT_ID).replace(/[^a-zA-Z0-9_-]/g, "_");
135
+ return `${HERDR_MARKER_PATH_PREFIX}${paneId}.json`;
107
136
  }
108
137
 
138
+ interface CreatedHerdrTab {
139
+ tabId: string;
140
+ paneId: string;
141
+ }
142
+
143
+ /** 从 `herdr tab create` 响应提取新 tab 与 root pane 的公开 id。 */
144
+ function extractCreatedHerdrTab(json: unknown): CreatedHerdrTab | null {
145
+ if (!json || typeof json !== "object") return null;
146
+ const result = (json as Record<string, unknown>).result;
147
+ if (!result || typeof result !== "object") return null;
148
+ const record = result as Record<string, unknown>;
149
+ const tab = record.tab as Record<string, unknown> | undefined;
150
+ const rootPane = record.root_pane as Record<string, unknown> | undefined;
151
+ if (typeof tab?.tab_id !== "string" || typeof rootPane?.pane_id !== "string") return null;
152
+ return { tabId: tab.tab_id, paneId: rootPane.pane_id };
153
+ }
154
+
155
+ /** 记录由本进程创建的 tab surface,使 rename / close 操作作用于整个 tab。 */
156
+ const createdHerdrTabIds = new Map<string, string>();
157
+
109
158
  // ── 对外 API:createSurface 系列 ──
110
159
 
111
- /**
112
- * 创建一个新的 subagent pane。
113
- *
114
- * 实现:split 当前 agent pane 右侧(--no-focus 保持 agent 焦点不变)。
115
- * 后续 subagent 按 breadth-first 模式轮转 right/down/right/down…(与 cmux/muxy 行为一致)。
116
- * 锁与 BFS 状态机复用 shared.ts。
117
- */
118
- export function createHerdrSurface(name: string): string {
160
+ /** 使用原有 BFS 策略创建 subagent 分屏。 */
161
+ function createHerdrSplitSurface(name: string): string {
119
162
  if (!AGENT_HERDR_PANE_ID) {
120
163
  throw new Error(
121
164
  "HERDR_PANE_ID not set; cannot determine parent pane for subagent split. " +
@@ -124,20 +167,18 @@ export function createHerdrSurface(name: string): string {
124
167
  }
125
168
 
126
169
  const markerFile = herdrMarkerPath();
127
- const lockPath = `${markerFile}.lock`;
170
+ const lockPath = `${markerFile}${HERDR_LOCK_SUFFIX}`;
128
171
 
129
172
  return withFileLock(lockPath, {}, () => {
130
173
  const state = new BfsSplitStateManager(markerFile);
131
174
 
132
- // 首次 split
133
175
  if (state.panes().length === 0) {
134
176
  const output = herdrExec([
135
- "pane",
136
- "split",
137
- AGENT_HERDR_PANE_ID!,
138
- "--direction",
139
- "right",
140
- "--no-focus",
177
+ ...HERDR_PANE_SPLIT_COMMAND,
178
+ AGENT_HERDR_PANE_ID,
179
+ HERDR_SPLIT_DIRECTION_FLAG,
180
+ HERDR_SPLIT_RIGHT,
181
+ HERDR_NO_FOCUS_FLAG,
141
182
  ]);
142
183
  const newPaneId = extractPaneId(parseHerdrJson(output));
143
184
  if (newPaneId) {
@@ -152,22 +193,31 @@ export function createHerdrSurface(name: string): string {
152
193
  return "";
153
194
  }
154
195
 
155
- // 后续:BFS 分屏
156
196
  const next = state.next();
157
197
  if (!next) return "";
158
198
 
159
199
  let { source } = next;
160
200
  const { direction } = next;
161
-
162
- // 若 source 过期(pane 被关闭 / session 重启),重置状态从 agent pane 重新分屏
163
201
  let output: string;
164
202
  try {
165
- output = herdrExec(["pane", "split", source, "--direction", direction, "--no-focus"]);
203
+ output = herdrExec([
204
+ ...HERDR_PANE_SPLIT_COMMAND,
205
+ source,
206
+ HERDR_SPLIT_DIRECTION_FLAG,
207
+ direction,
208
+ HERDR_NO_FOCUS_FLAG,
209
+ ]);
166
210
  } catch {
167
211
  try { rmSync(markerFile); } catch { /* ignore */ }
168
212
  herdrLog(`[split] pane ${source} gone, resetting from agent pane ${AGENT_HERDR_PANE_ID}`);
169
- source = AGENT_HERDR_PANE_ID!;
170
- output = herdrExec(["pane", "split", source, "--direction", "right", "--no-focus"]);
213
+ source = AGENT_HERDR_PANE_ID;
214
+ output = herdrExec([
215
+ ...HERDR_PANE_SPLIT_COMMAND,
216
+ source,
217
+ HERDR_SPLIT_DIRECTION_FLAG,
218
+ HERDR_SPLIT_RIGHT,
219
+ HERDR_NO_FOCUS_FLAG,
220
+ ]);
171
221
  }
172
222
 
173
223
  const newPaneId = extractPaneId(parseHerdrJson(output));
@@ -185,6 +235,46 @@ export function createHerdrSurface(name: string): string {
185
235
  });
186
236
  }
187
237
 
238
+ /** 在当前 workspace 中创建独立后台 tab,并返回 root pane id。 */
239
+ function createHerdrTabSurface(name: string): string {
240
+ if (!AGENT_HERDR_WORKSPACE_ID) {
241
+ throw new Error(
242
+ "HERDR_WORKSPACE_ID not set; cannot determine workspace for subagent tab. " +
243
+ "Start pi inside herdr so HERDR_WORKSPACE_ID is injected at launch.",
244
+ );
245
+ }
246
+
247
+ const tabLabel = herdrSurfaceLabel(name);
248
+ const output = herdrExec([
249
+ "tab",
250
+ "create",
251
+ "--workspace",
252
+ AGENT_HERDR_WORKSPACE_ID,
253
+ "--cwd",
254
+ process.cwd(),
255
+ "--label",
256
+ tabLabel,
257
+ "--no-focus",
258
+ ]);
259
+ const createdTab = extractCreatedHerdrTab(parseHerdrJson(output));
260
+ if (!createdTab) {
261
+ throw new Error(`Unexpected herdr tab create output: ${output.trim() || "(empty)"}`);
262
+ }
263
+
264
+ createdHerdrTabIds.set(createdTab.paneId, createdTab.tabId);
265
+ herdrLog(
266
+ `[tab create] workspace=${AGENT_HERDR_WORKSPACE_ID} tab=${createdTab.tabId} pane=${createdTab.paneId} name=${JSON.stringify(name)}`,
267
+ );
268
+ return createdTab.paneId;
269
+ }
270
+
271
+ /** 按 PI_SUBAGENT_HERDR_MODE 选择兼容分屏或独立 tab;默认保持 split。 */
272
+ export function createHerdrSurface(name: string): string {
273
+ return resolveHerdrSurfaceMode() === HERDR_SURFACE_MODE_TAB
274
+ ? createHerdrTabSurface(name)
275
+ : createHerdrSplitSurface(name);
276
+ }
277
+
188
278
  /**
189
279
  * 从指定 pane 直接分屏(不走广度优先状态机),供 createSurfaceSplit 使用。
190
280
  * herdr 文档仅明确 right/down,left/up 分别归一到 right/down。
@@ -195,8 +285,16 @@ export function splitHerdrPane(
195
285
  direction: "left" | "right" | "up" | "down",
196
286
  name?: string,
197
287
  ): string {
198
- const dir = direction === "down" || direction === "up" ? "down" : "right";
199
- const output = herdrExec(["pane", "split", fromPane, "--direction", dir, "--no-focus"]);
288
+ const dir = direction === HERDR_SPLIT_DOWN || direction === HERDR_SPLIT_UP
289
+ ? HERDR_SPLIT_DOWN
290
+ : HERDR_SPLIT_RIGHT;
291
+ const output = herdrExec([
292
+ ...HERDR_PANE_SPLIT_COMMAND,
293
+ fromPane,
294
+ HERDR_SPLIT_DIRECTION_FLAG,
295
+ dir,
296
+ HERDR_NO_FOCUS_FLAG,
297
+ ]);
200
298
  const newPaneId = extractPaneId(parseHerdrJson(output));
201
299
  if (!newPaneId) {
202
300
  throw new Error(`Unexpected herdr pane split output: ${output.trim() || "(empty)"}`);
@@ -250,6 +348,12 @@ function getWorkspaceLabel(): string | null {
250
348
  }
251
349
  }
252
350
 
351
+ /** 生成 herdr pane / tab 的统一显示标签。 */
352
+ function herdrSurfaceLabel(name: string): string {
353
+ const workspaceLabel = getWorkspaceLabel();
354
+ return workspaceLabel ? `${workspaceLabel}[${name}]` : name;
355
+ }
356
+
253
357
  /**
254
358
  * 用 herdr CLI 重命名 pane 对应的 tab。
255
359
  * tab label 格式: workspace_label[name]
@@ -258,8 +362,7 @@ export function renameHerdrTab(paneId: string, name: string): void {
258
362
  const ws = parseWorkspaceIdFromPaneId(paneId);
259
363
  if (!ws) return;
260
364
  try {
261
- const wsLabel = getWorkspaceLabel();
262
- const tabLabel = wsLabel ? `${wsLabel}[${name}]` : name;
365
+ const tabLabel = herdrSurfaceLabel(name);
263
366
  const tabsJson = herdrExec(["tab", "list", "--workspace", ws]);
264
367
  const parsed = parseHerdrJson(tabsJson);
265
368
  if (!parsed || typeof parsed !== "object") return;
@@ -308,6 +411,13 @@ export function sendHerdrEscape(paneId: string): void {
308
411
  herdrExecSilent(["pane", "send-keys", paneId, "Escape"]);
309
412
  }
310
413
 
414
+ /**
415
+ * 将 herdr source 归一化为 CLI 使用的标志值(recent_unwrapped -> recent-unwrapped,其余原样)。
416
+ */
417
+ export function herdrSourceFlag(source: "visible" | "recent" | "recent_unwrapped"): string {
418
+ return source === "recent_unwrapped" ? "recent-unwrapped" : source;
419
+ }
420
+
311
421
  /**
312
422
  * 读取 pane 屏幕内容。
313
423
  * SKILL.md:`herdr pane read <id> --source <src> --lines N` 直接打印文本(非 JSON)。
@@ -320,18 +430,23 @@ export function sendHerdrEscape(paneId: string): void {
320
430
  */
321
431
  export function readHerdrScreen(paneId: string, lines = 50, source: "visible" | "recent" | "recent_unwrapped" = "visible"): string {
322
432
  // SKILL.md 列出的 source 选项
323
- const sourceFlag = source === "recent_unwrapped" ? "recent-unwrapped" : source;
324
- return herdrExec(["pane", "read", paneId, "--source", sourceFlag, "--lines", String(lines)]);
433
+ return herdrExec(["pane", "read", paneId, "--source", herdrSourceFlag(source), "--lines", String(lines)]);
325
434
  }
326
435
 
327
436
  /**
328
- * 关闭 pane。
329
- * `herdr pane close <id>` 是 herdr CLI 子命令。
437
+ * 关闭 surface。本进程创建的 tab surface 关闭整个 tab;其他 pane id 仍按 pane 关闭。
330
438
  */
331
439
  export function closeHerdrSurface(paneId: string): void {
332
- herdrExecSilent(["pane", "close", paneId]);
440
+ const tabId = createdHerdrTabIds.get(paneId);
441
+ if (tabId) {
442
+ herdrExecSilent([...HERDR_TAB_CLOSE_COMMAND, tabId]);
443
+ createdHerdrTabIds.delete(paneId);
444
+ herdrLog(`[close] tab=${tabId} pane=${paneId}`);
445
+ return;
446
+ }
447
+
448
+ herdrExecSilent([...HERDR_PANE_CLOSE_COMMAND, paneId]);
333
449
 
334
- // 从 BFS 状态移除已关闭的 subagent,避免僵尸 ID 累积
335
450
  const state = new BfsSplitStateManager(herdrMarkerPath());
336
451
  const beforePanes = state.panes();
337
452
  state.remove(paneId);
@@ -402,6 +517,18 @@ export const ops: BackendOps = {
402
517
  closeHerdrSurface(surface);
403
518
  },
404
519
  rename(surface: string, name: string): void {
405
- renameHerdrPane(surface, name);
520
+ const tabId = createdHerdrTabIds.get(surface);
521
+ if (!tabId) {
522
+ renameHerdrPane(surface, name);
523
+ return;
524
+ }
525
+ try {
526
+ const tabLabel = herdrSurfaceLabel(name);
527
+ herdrExecSilent(["tab", "rename", tabId, tabLabel]);
528
+ } catch (error) {
529
+ herdrLog(
530
+ `[rename tab] tab=${tabId} pane=${surface} name=${JSON.stringify(name)} failed: ${(error as Error).message}`,
531
+ );
532
+ }
406
533
  },
407
534
  };
@@ -5,6 +5,15 @@
5
5
  * Record<MuxBackend, BackendOps> 实现全键派发。
6
6
  */
7
7
 
8
+ /**
9
+ * 分屏 options(后端特有能力提示,非后端忽略)。
10
+ * activate 仅供 wezterm 使用:split 成功后调用 activate-pane 聚焦新 pane;默认 false 保持当前焦点。
11
+ */
12
+ export interface CreateSplitOptions {
13
+ /** 是否激活新创建的 pane(仅 wezterm 支持,默认 false) */
14
+ activate?: boolean;
15
+ }
16
+
8
17
  /**
9
18
  * 后端 per-surface 操作接口(内部契约,不进 index.ts)。
10
19
  * create / createSplit / send / sendEscape / read / readAsync / close / rename
@@ -13,8 +22,14 @@
13
22
  export interface BackendOps {
14
23
  /** 创建新 surface(智能放置,如分屏/堆叠/新 tab) */
15
24
  create(name: string): string;
16
- /** 指定方向分屏创建新 surface */
17
- createSplit(name: string, direction: "left" | "right" | "up" | "down", fromSurface?: string): string;
25
+ /**
26
+ * 指定方向分屏创建新 surface。fromSurface 第 3 参、options 第 4 参均为可选尾部参数;
27
+ * 该签名由统一 surface API 的向后兼容要求强制(旧 3 参调用保持类型与行为不变):
28
+ * 仓库现有调用点 pi-interactive-subagents/test/integration/harness.ts 仍以
29
+ * createSurfaceSplit(name, direction, fromSurface) 三位置参调用,options 只能作为第 4 个尾部参数追加。
30
+ * 故按外部 API 兼容签名豁免参数数量约束。
31
+ */
32
+ createSplit(name: string, direction: "left" | "right" | "up" | "down", fromSurface?: string, options?: CreateSplitOptions): string;
18
33
  /** 向 surface 发送命令字符串并执行 */
19
34
  send(surface: string, command: string): void;
20
35
  /** 向 surface 发送 Escape 按键 */
@@ -15,6 +15,29 @@ const execFileAsync = promisify(execFile);
15
15
  /** WezTerm 后端日志(统一格式,写入 /tmp/pi-mux-wezterm.log) */
16
16
  const weztermLog = createBackendLogger("wezterm", "/tmp/pi-mux-wezterm.log");
17
17
 
18
+ /** WezTerm CLI 基础段 */
19
+ const CLI = "cli";
20
+ /** 激活 pane 的命令段 */
21
+ const ACTIVATE_PANE_CMD = "activate-pane";
22
+ const PANE_ID_FLAG = "--pane-id";
23
+
24
+ /**
25
+ * 生成激活 WezTerm pane 的 CLI 参数数组。
26
+ * 仅当 split 成功取得合法 pane id 后才调用(paneId 校验失败的场景不走到这里)。
27
+ */
28
+ export function weztermActivateArgs(paneId: string): string[] {
29
+ return [CLI, ACTIVATE_PANE_CMD, PANE_ID_FLAG, paneId];
30
+ }
31
+
32
+ /**
33
+ * 返回命令提交终止符。Windows 的 ConPTY 中 LF 会让 PowerShell 停在续行提示,
34
+ * 必须用 CR 提交;其他平台继续使用 LF(不用 CRLF,避免多余换行)。
35
+ * platform 参数可注入以便单元测试,缺省按当前进程平台决策。
36
+ */
37
+ export function commandTerminator(platform: string = process.platform): string {
38
+ return platform === "win32" ? "\r" : "\n";
39
+ }
40
+
18
41
  export const ops: BackendOps = {
19
42
  create(name: string): string {
20
43
  // WezTerm 的 createSurface 退化为 createSurfaceSplit "right"
@@ -22,8 +45,15 @@ export const ops: BackendOps = {
22
45
  return ops.createSplit(name, "right", fromSurface);
23
46
  },
24
47
 
25
- // BackendOps 接口定义含 4 参(含可选 fromSurface),此为实现契约。
26
- createSplit(name: string, direction: "left" | "right" | "up" | "down", fromSurface?: string): string {
48
+ // BackendOps 接口定义含 4 参(含可选 fromSurface / options),此为实现契约。
49
+ // 该签名由统一 surface API 的向后兼容要求强制(fromSurface 保持第 3 位置参数,
50
+ // activate 只能作为第 4 个尾部 options 追加),故按外部 API 签名豁免参数数量约束。
51
+ createSplit(
52
+ name: string,
53
+ direction: "left" | "right" | "up" | "down",
54
+ fromSurface?: string,
55
+ options?: { activate?: boolean },
56
+ ): string {
27
57
  const args = ["cli", "split-pane"];
28
58
  if (direction === "left") args.push("--left");
29
59
  else if (direction === "right") args.push("--right");
@@ -45,6 +75,9 @@ export const ops: BackendOps = {
45
75
  } catch {
46
76
  // Optional — tab title is cosmetic.
47
77
  }
78
+ if (options?.activate) {
79
+ execFileSync("wezterm", weztermActivateArgs(paneId), { encoding: "utf8" });
80
+ }
48
81
  weztermLog(
49
82
  `[split] dir=${direction} from=${fromSurface ?? "<unset>"} new=${paneId} name=${JSON.stringify(name)}`,
50
83
  );
@@ -54,7 +87,7 @@ export const ops: BackendOps = {
54
87
  send(surface: string, command: string): void {
55
88
  execFileSync(
56
89
  "wezterm",
57
- ["cli", "send-text", "--pane-id", surface, "--no-paste", command + "\n"],
90
+ ["cli", "send-text", "--pane-id", surface, "--no-paste", command + commandTerminator()],
58
91
  { encoding: "utf8" },
59
92
  );
60
93
  },
package/src/headless.ts CHANGED
@@ -24,6 +24,21 @@ import { isMuxAvailable } from "./detection.ts";
24
24
 
25
25
  const HEADLESS_SURFACE_PREFIX = "headless:";
26
26
 
27
+ /**
28
+ * PowerShell 启动器常量。
29
+ * headless 模式用 powershell.exe 直接 -Command 执行命令(配合 -ExecutionPolicy Bypass 允许运行脚本)。
30
+ */
31
+ const POWERSHELL_EXECUTABLE = "powershell.exe";
32
+ const POWERSHELL_SPAWN_FLAGS = [
33
+ "-NoLogo",
34
+ "-NoProfile",
35
+ "-ExecutionPolicy",
36
+ "Bypass",
37
+ "-Command",
38
+ ] as const;
39
+ /** 显式选择 PowerShell 解释器的标识 */
40
+ const INTERPRETER_POWERSHELL = "powershell";
41
+
27
42
  /** headless surface 关闭时 SIGTERM 到 SIGKILL 的等待间隔(毫秒) */
28
43
  const HEADLESS_KILL_TIMEOUT_MS = 3000;
29
44
 
@@ -56,17 +71,22 @@ export function createHeadlessSurface(name: string): string {
56
71
  // ── 后台子进程管理 ──
57
72
 
58
73
  /**
59
- * 在 headless surface 上启动一个 bash 子进程,stdout/stderr 写入日志文件。
74
+ * 在 headless surface 上启动一个子进程,stdout/stderr 写入日志文件。
60
75
  * 返回日志文件路径,调用方通过 readHeadlessScreen 读取输出。
61
76
  *
62
77
  * 注意:spawnHeadlessProcess 签名与原 mux.ts 完全一致(公开 API,不可改签名),
63
78
  * 第 4 个参数 options 为可选对象,不计入函数参数数量规则的违规。
79
+ * interpreter 缺省为 bash;显式选择 "powershell" 时用 powershell.exe -Command 直接执行命令。
64
80
  */
65
81
  export function spawnHeadlessProcess(
66
82
  surface: string,
67
83
  name: string,
68
84
  command: string,
69
- options?: { cwd?: string; env?: Record<string, string> },
85
+ options?: {
86
+ cwd?: string;
87
+ env?: Record<string, string>;
88
+ interpreter?: "bash" | "powershell";
89
+ },
70
90
  ): { logFile: string } {
71
91
  const safeId = surface.replace(/[^a-zA-Z0-9_-]/g, "_");
72
92
  const logFile = join(tmpdir(), `pi-subagent-${safeId}.log`);
@@ -78,11 +98,22 @@ export function spawnHeadlessProcess(
78
98
  }
79
99
  env.PI_SUBAGENT_HEADLESS = "1";
80
100
 
81
- const child = spawn("bash", ["-c", command], {
82
- cwd: options?.cwd || process.cwd(),
83
- stdio: ["ignore", "pipe", "pipe"],
84
- env,
85
- });
101
+ const isPowerShell = options?.interpreter === INTERPRETER_POWERSHELL;
102
+ const child = isPowerShell
103
+ ? spawn(
104
+ POWERSHELL_EXECUTABLE,
105
+ [...POWERSHELL_SPAWN_FLAGS, command],
106
+ {
107
+ cwd: options?.cwd || process.cwd(),
108
+ stdio: ["ignore", "pipe", "pipe"],
109
+ env,
110
+ },
111
+ )
112
+ : spawn("bash", ["-c", command], {
113
+ cwd: options?.cwd || process.cwd(),
114
+ stdio: ["ignore", "pipe", "pipe"],
115
+ env,
116
+ });
86
117
 
87
118
  child.stdout.pipe(logStream);
88
119
  child.stderr.pipe(logStream);
package/src/mux.ts CHANGED
@@ -57,7 +57,12 @@ export {
57
57
  getLastSplitSource,
58
58
  clearLastSplitSource,
59
59
  } from "./surface.ts";
60
- export type { PollResult } from "./surface.ts";
60
+ export type {
61
+ PollResult,
62
+ CreateSurfaceSplitOptions,
63
+ SendLongCommandOptions,
64
+ ReadScreenOptions,
65
+ } from "./surface.ts";
61
66
 
62
67
  // ── Cmux 公开解析函数 ──
63
68
  export {
package/src/shell.ts CHANGED
@@ -34,6 +34,15 @@ export function shellEscape(s: string): string {
34
34
  return "'" + s.replace(/'/g, "'\\''") + "'";
35
35
  }
36
36
 
37
+ /**
38
+ * PowerShell single-quote escape: wraps the string in single quotes,
39
+ * escaping any embedded single quotes by doubling them ('' in single-quoted strings),
40
+ * per PowerShell string literal rules.
41
+ */
42
+ export function powershellEscape(s: string): string {
43
+ return "'" + s.replace(/'/g, "''") + "'";
44
+ }
45
+
37
46
  /**
38
47
  * Return the last N lines of text.
39
48
  * 非公开 API,供 readScreen 等内部使用。
package/src/surface.ts CHANGED
@@ -34,7 +34,7 @@ import {
34
34
  getHeadlessProcessExit,
35
35
  drainHeadlessProcess,
36
36
  } from "./headless.ts";
37
- import { shellEscape } from "./shell.ts";
37
+ import { shellEscape, powershellEscape } from "./shell.ts";
38
38
  import type { MuxBackend } from "./detection.ts";
39
39
  import type { BackendOps } from "./backends/types.ts";
40
40
 
@@ -54,6 +54,7 @@ import { renameOrcaTerminal } from "./backends/orca.ts";
54
54
 
55
55
  const execFileAsync = promisify(execFile);
56
56
  const ORCA_BACKEND: MuxBackend = "orca";
57
+ const HERDR_BACKEND: MuxBackend = "herdr";
57
58
 
58
59
  // ── 全键注册表 ──
59
60
 
@@ -128,13 +129,28 @@ export function createSurface(name: string): string {
128
129
  return backendOps[backend].create(name);
129
130
  }
130
131
 
132
+ /**
133
+ * 分屏来源/激活 options 的公开类型。
134
+ * activate 仅 wezterm 支持;其他后端忽略该提示并保持既有焦点行为。
135
+ */
136
+ export interface CreateSurfaceSplitOptions {
137
+ /** 是否在分屏后激活新创建的 pane(仅 wezterm;默认 false,保持当前焦点) */
138
+ activate?: boolean;
139
+ }
140
+
131
141
  /**
132
142
  * 指定方向分屏创建新 surface。
143
+ *
144
+ * 签名沿用法(外部 API 兼容豁免参数数量约束):前三个位置参数 name/direction/fromSurface 是现有
145
+ * 公开契约,仓库内调用方(pi-interactive-subagents/test/integration/harness.ts)仍以
146
+ * createSurfaceSplit(name, direction, fromSurface) 三位置参调用,合并为参数对象会破坏这些调用点的
147
+ * 源码兼容;因此新增能力只能作为第 4 个可选尾部参数 options 追加,旧 3 参调用类型与行为保持不变。
133
148
  */
134
149
  export function createSurfaceSplit(
135
150
  name: string,
136
151
  direction: "left" | "right" | "up" | "down",
137
152
  fromSurface?: string,
153
+ options?: CreateSurfaceSplitOptions,
138
154
  ): string {
139
155
  const backend = requireMuxBackend();
140
156
 
@@ -170,7 +186,7 @@ export function createSurfaceSplit(
170
186
  lastSplitSource = source ?? null;
171
187
  }
172
188
 
173
- return backendOps[backend].createSplit(name, direction, fromSurface);
189
+ return backendOps[backend].createSplit(name, direction, fromSurface, options);
174
190
  }
175
191
 
176
192
  /**
@@ -196,85 +212,163 @@ export function sendEscape(surface: string): void {
196
212
  backendOps[backend].sendEscape(surface);
197
213
  }
198
214
 
215
+ /**
216
+ * sendLongCommand 选项。interpreter 缺省为 "bash",与既有全部调用方保持兼容;
217
+ * Windows PowerShell 调用方显式传 "powershell" 才切换到 PowerShell 脚本运行时。
218
+ */
219
+ export interface SendLongCommandOptions {
220
+ /** 显式脚本文件路径(原样保留,不自动改扩展名) */
221
+ scriptPath?: string;
222
+ /** 脚本前置片段(Shebang 除外;默认 Bash 时用于注入 env export 等) */
223
+ scriptPreamble?: string;
224
+ /** 脚本解释器,默认 "bash";显式 "powershell" 时按 PowerShell 语法生成 .ps1 并执行 */
225
+ interpreter?: "bash" | "powershell";
226
+ }
227
+
228
+ // ── 长命令脚本运行时常量 ──
229
+
230
+ /** 默认解释器:所有平台都保持 Bash,避免改变现有调用方语义 */
231
+ const DEFAULT_SEND_INTERPRETER = "bash";
232
+ const INTERPRETER_POWERSHELL = "powershell";
233
+
234
+ /** 解析 sendLongCommand 的解释器;省略时始终保持 Bash 兼容默认值。 */
235
+ export function resolveSendInterpreter(
236
+ interpreter?: "bash" | "powershell",
237
+ ): "bash" | "powershell" {
238
+ return interpreter ?? DEFAULT_SEND_INTERPRETER;
239
+ }
240
+
241
+ /** 脚本文件写入权限:PowerShell 无需可执行位,Bash 脚本需可执行位 */
242
+ const POWERSHELL_SCRIPT_MODE = 0o644;
243
+ const BASH_SCRIPT_MODE = 0o755;
244
+
245
+ /** PowerShell -File / -Command 启动器可执行文件名与前导参数 */
246
+ const POWERSHELL_EXECUTABLE = "powershell.exe";
247
+ const POWERSHELL_LAUNCH_PREFIX = ["-NoLogo", "-NoProfile", "-ExecutionPolicy", "Bypass"] as const;
248
+
249
+ /**
250
+ * 返回按解释器选择的脚本扩展名(.sh / .ps1),用于自动脚本路径的文件后缀。
251
+ */
252
+ export function sendScriptExtension(interpreter: "bash" | "powershell"): string {
253
+ return interpreter === INTERPRETER_POWERSHELL ? ".ps1" : ".sh";
254
+ }
255
+
256
+ /**
257
+ * 生成脚本内容:Bash 以 shebang + \n 分隔,PowerShell 无 shebang 且以 CRLF 分隔。
258
+ */
259
+ export function buildSendScriptContent(
260
+ interpreter: "bash" | "powershell",
261
+ preamble: string | undefined,
262
+ command: string,
263
+ ): string {
264
+ if (interpreter === INTERPRETER_POWERSHELL) {
265
+ const parts: string[] = [];
266
+ if (preamble) parts.push(preamble.trimEnd());
267
+ parts.push(command);
268
+ return parts.join("\r\n") + "\r\n";
269
+ }
270
+ const parts = ["#!/bin/bash"];
271
+ if (preamble) parts.push(preamble.trimEnd());
272
+ parts.push(command);
273
+ return parts.join("\n") + "\n";
274
+ }
275
+
276
+ /**
277
+ * 构建 mux pane 中执行脚本的 shell 调用:Bash 用 `bash <path>`,PowerShell 用
278
+ * `powershell.exe -NoLogo -NoProfile -ExecutionPolicy Bypass -File <path>`。
279
+ */
280
+ export function buildMuxInvocation(interpreter: "bash" | "powershell", scriptPath: string): string {
281
+ if (interpreter === INTERPRETER_POWERSHELL) {
282
+ return `${POWERSHELL_EXECUTABLE} ${POWERSHELL_LAUNCH_PREFIX.join(" ")} -File ${powershellEscape(scriptPath)}`;
283
+ }
284
+ return `bash ${shellEscape(scriptPath)}`;
285
+ }
286
+
199
287
  /**
200
288
  * 向 surface 发送长命令(通过脚本文件避免终端自动换行问题)。
201
- * 返回脚本文件路径。
289
+ * 返回脚本文件路径。默认解释器为 Bash(所有平台);显式 interpreter:"powershell"
290
+ * 时按 PowerShell 语法写 .ps1 并执行。
202
291
  */
203
292
  export function sendLongCommand(
204
293
  surface: string,
205
294
  command: string,
206
- options?: { scriptPath?: string; scriptPreamble?: string },
295
+ options?: SendLongCommandOptions,
207
296
  ): string {
208
- // Headless mode: spawn as a background child process
209
- if (isHeadlessSurface(surface)) {
210
- const logFile = options?.scriptPath
211
- ? options.scriptPath.replace(/\.sh$/, ".log")
212
- : undefined;
213
- const scriptPath =
214
- options?.scriptPath ??
215
- join(
216
- tmpdir(),
217
- "pi-subagent-scripts",
218
- `cmd-${Date.now()}-${Math.random().toString(16).slice(2, 8)}.sh`,
219
- );
220
- mkdirSync(dirname(scriptPath), { recursive: true });
221
- const scriptParts = ["#!/bin/bash"];
222
- if (options?.scriptPreamble) {
223
- scriptParts.push(options.scriptPreamble.trimEnd());
224
- }
225
- scriptParts.push(command);
226
- writeFileSync(scriptPath, scriptParts.join("\n") + "\n", { mode: 0o755 });
227
-
228
- spawnHeadlessProcess(surface, "subagent", `bash ${shellEscape(scriptPath)}`, {
229
- cwd: process.cwd(),
230
- env: { PI_SUBAGENT_HEADLESS: "1" },
231
- });
232
- return scriptPath;
233
- }
297
+ const interpreter = resolveSendInterpreter(options?.interpreter);
298
+ const extension = sendScriptExtension(interpreter);
234
299
 
235
300
  const scriptPath =
236
301
  options?.scriptPath ??
237
302
  join(
238
303
  tmpdir(),
239
304
  "pi-subagent-scripts",
240
- `cmd-${Date.now()}-${Math.random().toString(16).slice(2, 8)}.sh`,
305
+ `cmd-${Date.now()}-${Math.random().toString(16).slice(2, 8)}${extension}`,
241
306
  );
242
307
  mkdirSync(dirname(scriptPath), { recursive: true });
243
308
 
244
- const scriptParts = ["#!/bin/bash"];
245
- if (options?.scriptPreamble) {
246
- scriptParts.push(options.scriptPreamble.trimEnd());
309
+ const content = buildSendScriptContent(interpreter, options?.scriptPreamble, command);
310
+ const mode = interpreter === INTERPRETER_POWERSHELL ? POWERSHELL_SCRIPT_MODE : BASH_SCRIPT_MODE;
311
+ writeFileSync(scriptPath, content, { mode });
312
+
313
+ // Headless mode: spawn as a background child process
314
+ if (isHeadlessSurface(surface)) {
315
+ const headlessCommand =
316
+ interpreter === INTERPRETER_POWERSHELL
317
+ ? `& ${powershellEscape(scriptPath)}`
318
+ : `bash ${shellEscape(scriptPath)}`;
319
+ spawnHeadlessProcess(surface, "subagent", headlessCommand, {
320
+ cwd: process.cwd(),
321
+ env: { PI_SUBAGENT_HEADLESS: "1" },
322
+ interpreter,
323
+ });
324
+ return scriptPath;
247
325
  }
248
- scriptParts.push(command);
249
326
 
250
- writeFileSync(scriptPath, scriptParts.join("\n") + "\n", {
251
- mode: 0o755,
252
- });
253
- sendCommand(surface, `bash ${shellEscape(scriptPath)}`);
327
+ sendCommand(surface, buildMuxInvocation(interpreter, scriptPath));
254
328
  return scriptPath;
255
329
  }
256
330
 
331
+ /**
332
+ * 统一读屏 options。source 仅 herdr 后端消费(转发给 herdr pane read --source),
333
+ * 非 herdr 后端忽略该提示并保持各自既有读屏语义;未提供时 herdr 维持默认 recent。
334
+ */
335
+ export interface ReadScreenOptions {
336
+ source?: "recent" | "visible" | "recent_unwrapped";
337
+ }
338
+
257
339
  /**
258
340
  * 同步读取 surface 屏幕最后 N 行。
341
+ * options.source 仅对 herdr 后端生效(如 "recent_unwrapped"),其他后端忽略。
259
342
  */
260
- export function readScreen(surface: string, lines = 50): string {
343
+ export function readScreen(surface: string, lines = 50, options?: ReadScreenOptions): string {
261
344
  if (isHeadlessSurface(surface)) {
262
345
  return readHeadlessScreen(surface, lines);
263
346
  }
264
347
 
265
348
  const backend = requireMuxBackend();
349
+ if (options?.source && backend === HERDR_BACKEND) {
350
+ return readHerdrScreen(surface, lines, options.source);
351
+ }
266
352
  return backendOps[backend].read(surface, lines);
267
353
  }
268
354
 
269
355
  /**
270
356
  * 异步读取 surface 屏幕最后 N 行。
357
+ * options.source 仅对 herdr 后端生效(如 "recent_unwrapped"),其他后端忽略。
271
358
  */
272
- export async function readScreenAsync(surface: string, lines = 50): Promise<string> {
359
+ export async function readScreenAsync(
360
+ surface: string,
361
+ lines = 50,
362
+ options?: ReadScreenOptions,
363
+ ): Promise<string> {
273
364
  if (isHeadlessSurface(surface)) {
274
365
  return readHeadlessScreenAsync(surface, lines);
275
366
  }
276
367
 
277
368
  const backend = requireMuxBackend();
369
+ if (options?.source && backend === HERDR_BACKEND) {
370
+ return readHerdrScreen(surface, lines, options.source);
371
+ }
278
372
  return backendOps[backend].readAsync(surface, lines);
279
373
  }
280
374