pi-terminal-mux 0.3.0 → 0.3.2

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
  });
@@ -72,12 +73,12 @@ If the forced backend's runtime is unavailable, `getMuxBackend()` returns `null`
72
73
 
73
74
  | Function | Description |
74
75
  |----------|-------------|
75
- | `createSurface(name)` | Smart placement (cmux: first right-split then tabs; zellij: tab-aware tiled/stacked; muxy/otty: breadth-first splits; orca: new tab in the current worktree), returns a surface handle |
76
- | `createSurfaceSplit(name, direction, fromSurface?)` | Split in an explicit direction (left/right/up/down) |
76
+ | `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 |
77
+ | `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
78
  | `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 |
79
+ | `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
80
  | `sendEscape(surface)` | Send one ESC keypress |
80
- | `readScreen(surface, lines?)` / `readScreenAsync` | Read the last N screen lines |
81
+ | `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
82
  | `closeSurface(surface)` | Close the surface |
82
83
  | `renameSurface(surface, name)` / `renameCurrentTab(title)` / `renameAgent(surface, name)` / `renameWorkspace(title)` | Naming, degrading per backend capability |
83
84
  | `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 +96,16 @@ Backend-native functions are also re-exported (e.g. `createHerdrSurface`, `split
95
96
 
96
97
  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
98
 
99
+ ## Windows PowerShell support
100
+
101
+ All platforms keep **Bash as the default** scripting runtime to preserve existing caller semantics. On Windows 11 PowerShell/WezTerm/herdr, opt in explicitly:
102
+
103
+ - **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.
104
+ - **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.
105
+ - **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.
106
+
107
+ These are opt-in capabilities — existing Bash callers and `pi-interactive-subagents` continue to run under the default Bash runtime on every platform.
108
+
98
109
  ## Environment variables
99
110
 
100
111
  | Variable | Description |
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
  });
@@ -71,12 +72,12 @@ closeSurface(surface);
71
72
 
72
73
  | 函数 | 说明 |
73
74
  |------|------|
74
- | `createSurface(name)` | 智能放置新 surface(cmux 首次右分屏后续开 tab、zellij tab 感知平铺/堆叠、muxy/otty 广度优先分屏、orca 在当前 worktree 新建 tab),返回 surface 标识 |
75
- | `createSurfaceSplit(name, direction, fromSurface?)` | 指定方向(left/right/up/down)分屏 |
75
+ | `createSurface(name)` | 智能放置新 surface(cmux 首次右分屏后续开 tab、zellij tab 感知平铺/堆叠、muxy/otty/orca 广度优先分屏;orca 缺少 agent handle 时新建 tab),返回 surface 标识 |
76
+ | `createSurfaceSplit(name, direction, fromSurface?, options?)` | 指定方向(left/right/up/down)分屏;`options.activate`(仅 wezterm,默认 false)分屏后聚焦新 pane |
76
77
  | `sendCommand(surface, command)` | 发送命令并回车执行 |
77
- | `sendLongCommand(surface, command, opts?)` | 长命令先写脚本文件再执行;`opts.scriptPreamble` 可注入 env export;返回脚本路径 |
78
+ | `sendLongCommand(surface, command, opts?)` | 长命令先写脚本文件再执行;`opts.scriptPreamble` 可注入前置片段;`opts.interpreter`(默认 `"bash"`,Windows 可显式 `"powershell"`)选择脚本运行时;返回脚本路径 |
78
79
  | `sendEscape(surface)` | 发送一次 ESC |
79
- | `readScreen(surface, lines?)` / `readScreenAsync` | 读取屏幕尾部 N |
80
+ | `readScreen(surface, lines?, options?)` / `readScreenAsync` | 读取屏幕尾部 N 行;`options.source`(仅 herdr)透传 herdr 读屏来源(如 `"recent_unwrapped"`),其他后端忽略 |
80
81
  | `closeSurface(surface)` | 关闭 surface |
81
82
  | `renameSurface(surface, name)` / `renameCurrentTab(title)` / `renameAgent(surface, name)` / `renameWorkspace(title)` | 命名(按后端能力降级或跳过) |
82
83
  | `pollForExit(surface, signal, opts)` | 等待 surface 内进程退出:优先 `.exit` sidecar 文件,其次屏幕 sentinel(`__SUBAGENT_DONE_<code>__`),headless 走子进程 exit |
@@ -94,6 +95,16 @@ closeSurface(surface);
94
95
 
95
96
  探测不到任何后端时,`createSurface` 返回 `headless:` 前缀的 surface,`sendLongCommand` 直接 spawn 后台子进程并把输出写入日志文件,`readScreen`/`pollForExit`/`closeSurface` 语义保持不变,调用方无需特判。
96
97
 
98
+ ## Windows PowerShell 支持
99
+
100
+ 所有平台默认保持 **Bash** 作为脚本运行时,以不变更现有调用方语义。Windows 11 PowerShell/WezTerm/herdr 组合下显式开启:
101
+
102
+ - **命令提交(wezterm)**:Enter 终止符在 `win32` 用 `\r`、其他平台用 `\n`,PowerShell 输入只会提交一次,不会停在续行提示。
103
+ - **长命令(`sendLongCommand`)**:传 `interpreter: "powershell"` 会生成 `.ps1`,mux 通过 `powershell.exe -NoLogo -NoProfile -ExecutionPolicy Bypass -File <path>` 执行、headless 走 `-Command "& <path>"`。显式 `scriptPath` 原样保留;自动路径按解释器选 `.ps1`/`.sh`。未传 interpreter 时保持既有 Bash command、`.sh` 路径与 `$?` 数值哨兵不变。
104
+ - **读屏(`readScreen` / `readScreenAsync`)**:传 `{ source: "recent_unwrapped" }`(仅 herdr)选择 herdr 的软换行合并捕获;不传 options 时 herdr 保持 `recent`,其他后端保持各自读屏语义。
105
+
106
+ 这些都是可选项——既有 Bash 调用方与 `pi-interactive-subagents` 在所有平台继续走默认 Bash 运行时。
107
+
97
108
  ## 环境变量
98
109
 
99
110
  | 变量 | 说明 |
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "pi-terminal-mux",
3
- "version": "0.3.0",
4
- "description": "Terminal multiplexer abstraction for pi extensions — unified surface API across muxy, cmux, tmux, zellij, wezterm, herdr and otty, with headless fallback",
3
+ "version": "0.3.2",
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",
7
7
  "exports": {
@@ -308,6 +308,13 @@ export function sendHerdrEscape(paneId: string): void {
308
308
  herdrExecSilent(["pane", "send-keys", paneId, "Escape"]);
309
309
  }
310
310
 
311
+ /**
312
+ * 将 herdr source 归一化为 CLI 使用的标志值(recent_unwrapped -> recent-unwrapped,其余原样)。
313
+ */
314
+ export function herdrSourceFlag(source: "visible" | "recent" | "recent_unwrapped"): string {
315
+ return source === "recent_unwrapped" ? "recent-unwrapped" : source;
316
+ }
317
+
311
318
  /**
312
319
  * 读取 pane 屏幕内容。
313
320
  * SKILL.md:`herdr pane read <id> --source <src> --lines N` 直接打印文本(非 JSON)。
@@ -320,8 +327,7 @@ export function sendHerdrEscape(paneId: string): void {
320
327
  */
321
328
  export function readHerdrScreen(paneId: string, lines = 50, source: "visible" | "recent" | "recent_unwrapped" = "visible"): string {
322
329
  // SKILL.md 列出的 source 选项
323
- const sourceFlag = source === "recent_unwrapped" ? "recent-unwrapped" : source;
324
- return herdrExec(["pane", "read", paneId, "--source", sourceFlag, "--lines", String(lines)]);
330
+ return herdrExec(["pane", "read", paneId, "--source", herdrSourceFlag(source), "--lines", String(lines)]);
325
331
  }
326
332
 
327
333
  /**
@@ -18,9 +18,8 @@
18
18
  * 与其他 backend 的关键差异:
19
19
  * 1. Orca 注入 ORCA_TERMINAL_HANDLE(类似 cmux 的 CMUX_SURFACE_ID),
20
20
  * 模块加载时冻结为 AGENT_ORCA_TERMINAL_HANDLE。
21
- * 2. create() 走"新 tab"而非分屏:Orca worktree 中心的工作台,
22
- * 官方建议 agent `terminal create` 开新终端;不拆分 agent 自己的
23
- * pane,避免压缩 pi 的 UI。因此不使用 shared.ts 的 BFS 分屏状态机。
21
+ * 2. create() muxy/herdr/otty 一样从 agent terminal 分屏,并通过
22
+ * shared.ts BFS 状态机轮转 right/down;没有 agent handle 时回退为新 tab。
24
23
  * 3. Escape 通过发送裸 ESC(`\u001b`,即 `0x1b`)字节实现,TUI 会将其解释为 Escape 键。
25
24
  * 4. rename 作用于 tab 标题;split 出来的 pane 与源 pane 共享 tab,
26
25
  * 所以 createSplit 不做 rename(否则会把 agent 所在 tab 一起改名)。
@@ -32,7 +31,8 @@
32
31
  */
33
32
 
34
33
  import { spawnSync } from "node:child_process";
35
- import { createBackendLogger, hasCommand } from "./shared.ts";
34
+ import { tmpdir } from "node:os";
35
+ import { BfsSplitStateManager, createBackendLogger, hasCommand, withFileLock } from "./shared.ts";
36
36
  import type { BackendOps } from "./types.ts";
37
37
 
38
38
  const ORCA_RUNTIME_CHECK_TIMEOUT_MS = 1_500;
@@ -139,6 +139,49 @@ export function orcaSplitDirection(direction: "left" | "right" | "up" | "down"):
139
139
  return direction === "left" || direction === "right" ? "vertical" : "horizontal";
140
140
  }
141
141
 
142
+ // ── Orca BFS 分屏状态 ──
143
+
144
+ /**
145
+ * 返回 Orca subagent 分屏状态 marker 路径。
146
+ * 使用 agent handle 隔离不同 Orca 会话,路径放在系统临时目录中。
147
+ */
148
+ function orcaStateFile(): string {
149
+ const agentHandle = AGENT_ORCA_TERMINAL_HANDLE ?? "default";
150
+ const safe = agentHandle.replace(/[^a-zA-Z0-9_-]/g, "_");
151
+ return `${tmpdir()}/orca-subagent-pane-${safe}.json`;
152
+ }
153
+
154
+ /**
155
+ * 从 BFS marker 中移除已关闭的 pane,避免后续 create 使用僵尸 handle。
156
+ */
157
+ function cleanupOrcaStateForSurface(handle: string): void {
158
+ try {
159
+ const state = new BfsSplitStateManager(orcaStateFile());
160
+ state.remove(handle);
161
+ } catch (e) {
162
+ orcaLog(`[state] cleanup failed for ${handle}: ${(e as Error).message}`);
163
+ }
164
+ }
165
+
166
+ /**
167
+ * 调用 `orca terminal create` 新建 tab,作为没有 agent handle 时的回退。
168
+ */
169
+ function createOrcaTab(name: string): string {
170
+ try {
171
+ const raw = orcaExec(["terminal", "create", "--title", name, "--json"]);
172
+ const handle = extractOrcaCreateHandle(parseOrcaJson(raw));
173
+ if (!handle) {
174
+ orcaLog(`[create] no handle in tab response for name=${JSON.stringify(name)}`);
175
+ return "";
176
+ }
177
+ orcaLog(`[create] fallback=tab new=${handle} name=${JSON.stringify(name)}`);
178
+ return handle;
179
+ } catch (e) {
180
+ orcaLog(`[create] fallback tab failed: ${(e as Error).message}`);
181
+ return "";
182
+ }
183
+ }
184
+
142
185
  // ── Orca CLI 调用的薄封装 ──
143
186
 
144
187
  /**
@@ -193,22 +236,62 @@ function orcaExecSilent(args: string[]): void {
193
236
  /**
194
237
  * 创建一个新的 subagent terminal。
195
238
  *
196
- * 实现:`orca terminal create --title <name>` 在当前 worktree 新建 tab
197
- * (CLI 语义:不抢焦点)。与 otty/muxy 的 BFS 分屏不同,新 tab 不会压缩
198
- * agent 自己的 pane,符合 Orca worktree 中心工作流。
239
+ * 实现:与 muxy/herdr/otty 一致,使用持久化 BFS 状态从 agent terminal 分屏:
240
+ * - 第一次从 agent pane 向右分屏;
241
+ * - 后续按 BFS 状态机轮转 right/down,均保持 agent 焦点不变(Orca split CLI 默认不抢焦点)。
199
242
  *
200
- * 失败返回 ""(与 otty createSurface 一致,调用方统一处理)。
243
+ * Orca 没有注入 agent handle 时回退为新建 tab,兼容手动调用原生 API 的场景。
244
+ * 失败返回 ""(与其他 backend create 一致,调用方统一处理)。
201
245
  */
202
246
  export function createOrcaSurface(name: string): string {
247
+ const agentHandle = AGENT_ORCA_TERMINAL_HANDLE;
248
+ if (!agentHandle) return createOrcaTab(name);
249
+
250
+ const markerFile = orcaStateFile();
251
+ const lockPath = `${markerFile}.lock`;
252
+
203
253
  try {
204
- const raw = orcaExec(["terminal", "create", "--title", name, "--json"]);
205
- const handle = extractOrcaCreateHandle(parseOrcaJson(raw));
206
- if (!handle) {
207
- orcaLog(`[create] no handle in response for name=${JSON.stringify(name)}`);
208
- return "";
209
- }
210
- orcaLog(`[create] new=${handle} name=${JSON.stringify(name)}`);
211
- return handle;
254
+ return withFileLock(lockPath, { timeoutMs: 3_000 }, () => {
255
+ let state = new BfsSplitStateManager(markerFile);
256
+
257
+ // 首次 split:从 agent pane 向右分屏。
258
+ if (state.panes().length === 0) {
259
+ const newHandle = splitOrcaTerminal("right", agentHandle);
260
+ if (!newHandle) {
261
+ orcaLog(`[create] first split failed from=${agentHandle}`);
262
+ return "";
263
+ }
264
+ state.add(newHandle);
265
+ orcaLog(`[create] mode=first dir=right from=${agentHandle} new=${newHandle} name=${JSON.stringify(name)}`);
266
+ return newHandle;
267
+ }
268
+
269
+ const next = state.next();
270
+ if (!next) return "";
271
+
272
+ // 正常路径按 BFS 状态机分屏;目标 pane 失效时清空状态并从 agent pane 恢复。
273
+ let newHandle = splitOrcaTerminal(next.direction, next.source);
274
+ if (newHandle) {
275
+ state.advance();
276
+ state.add(newHandle);
277
+ orcaLog(
278
+ `[create] mode=next dir=${next.direction} from=${next.source} new=${newHandle} name=${JSON.stringify(name)}`,
279
+ );
280
+ return newHandle;
281
+ }
282
+
283
+ orcaLog(`[create] pane ${next.source} unavailable, reset and retry from agent pane`);
284
+ for (const pane of state.panes()) state.remove(pane);
285
+ state = new BfsSplitStateManager(markerFile);
286
+ newHandle = splitOrcaTerminal("right", agentHandle);
287
+ if (!newHandle) {
288
+ orcaLog(`[create] reset split failed from=${agentHandle}`);
289
+ return "";
290
+ }
291
+ state.add(newHandle);
292
+ orcaLog(`[create] mode=recovered dir=right from=${agentHandle} new=${newHandle} name=${JSON.stringify(name)}`);
293
+ return newHandle;
294
+ });
212
295
  } catch (e) {
213
296
  orcaLog(`[create] failed: ${(e as Error).message}`);
214
297
  return "";
@@ -299,36 +382,40 @@ function queryOrcaTerminalExists(handle: string): boolean | null {
299
382
  /**
300
383
  * 关闭 terminal。
301
384
  *
302
- * create() 生成的 surface 独占一个 tab,split 生成的 pane 共享 tab。
385
+ * create() createSplit() 生成的 pane 可能与其他 pane 共享 tab。
303
386
  * 策略(best-effort,绝不 throw,避免 pollForExit 退出流程被打断):
304
387
  * 1. `terminal close`(关 pane/session)
305
388
  * 2. 验证 handle 是否还在列表;不在了则完成
306
389
  * 3. 仍在则补 `terminal close --tab`(单 pane tab 的残留情况)
307
- * 4. 仍失败仅 log warn
390
+ * 4. 清理 BFS marker;仍失败仅 log warn
308
391
  */
309
392
  export function closeOrcaSurface(handle: string): void {
310
- orcaExecSilent(["terminal", "close", "--terminal", handle, "--json"]);
393
+ try {
394
+ orcaExecSilent(["terminal", "close", "--terminal", handle, "--json"]);
311
395
 
312
- let exists = queryOrcaTerminalExists(handle);
313
- if (exists === false) {
314
- orcaLog(`[close] terminal ${handle} closed`);
315
- return;
316
- }
317
- if (exists === null) {
318
- // 查询失败不能当作已关闭;继续尝试 --tab 兜底
319
- orcaLog(`[close] WARN terminal ${handle} list query failed, cannot verify close`);
320
- }
396
+ let exists = queryOrcaTerminalExists(handle);
397
+ if (exists === false) {
398
+ orcaLog(`[close] terminal ${handle} closed`);
399
+ return;
400
+ }
401
+ if (exists === null) {
402
+ // 查询失败不能当作已关闭;继续尝试 --tab 兜底
403
+ orcaLog(`[close] WARN terminal ${handle} list query failed, cannot verify close`);
404
+ }
321
405
 
322
- orcaLog(`[close] terminal ${handle} still present, trying --tab`);
323
- orcaExecSilent(["terminal", "close", "--terminal", handle, "--tab", "--json"]);
406
+ orcaLog(`[close] terminal ${handle} still present, trying --tab`);
407
+ orcaExecSilent(["terminal", "close", "--terminal", handle, "--tab", "--json"]);
324
408
 
325
- exists = queryOrcaTerminalExists(handle);
326
- if (exists === false) {
327
- orcaLog(`[close] terminal ${handle} closed via --tab`);
328
- } else if (exists === null) {
329
- orcaLog(`[close] WARN terminal ${handle} close sent but unverifiable (list query failed)`);
330
- } else {
331
- orcaLog(`[close] WARN terminal ${handle} still present after close --tab`);
409
+ exists = queryOrcaTerminalExists(handle);
410
+ if (exists === false) {
411
+ orcaLog(`[close] terminal ${handle} closed via --tab`);
412
+ } else if (exists === null) {
413
+ orcaLog(`[close] WARN terminal ${handle} close sent but unverifiable (list query failed)`);
414
+ } else {
415
+ orcaLog(`[close] WARN terminal ${handle} still present after close --tab`);
416
+ }
417
+ } finally {
418
+ cleanupOrcaStateForSurface(handle);
332
419
  }
333
420
  }
334
421
 
@@ -345,7 +432,7 @@ export function renameOrcaTerminal(handle: string, name: string): void {
345
432
 
346
433
  /** BackendOps 适配器:所有方法薄包装 orca 原生函数 */
347
434
  export const ops: BackendOps = {
348
- /** 创建 orca surface(新 tab,不抢焦点) */
435
+ /** 创建 orca surface(从 agent terminal BFS 分屏;无 handle 时回退新 tab */
349
436
  create(name: string): string {
350
437
  return createOrcaSurface(name);
351
438
  },
@@ -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
 
@@ -53,6 +53,8 @@ import { sendOttyCommand, sendOttyEscape, readOttyScreen, closeOttySurface, rena
53
53
  import { renameOrcaTerminal } from "./backends/orca.ts";
54
54
 
55
55
  const execFileAsync = promisify(execFile);
56
+ const ORCA_BACKEND: MuxBackend = "orca";
57
+ const HERDR_BACKEND: MuxBackend = "herdr";
56
58
 
57
59
  // ── 全键注册表 ──
58
60
 
@@ -117,6 +119,8 @@ export function createSurface(name: string): string {
117
119
  lastSplitSource = AGENT_MUXY_PANE_ID ?? null;
118
120
  } else if (backend === "otty") {
119
121
  lastSplitSource = AGENT_OTTY_PANE_ID ?? null;
122
+ } else if (backend === ORCA_BACKEND) {
123
+ lastSplitSource = AGENT_ORCA_TERMINAL_HANDLE ?? null;
120
124
  } else {
121
125
  // tmux / wezterm / zellij / herdr
122
126
  lastSplitSource = process.env.TMUX_PANE ?? null;
@@ -125,13 +129,28 @@ export function createSurface(name: string): string {
125
129
  return backendOps[backend].create(name);
126
130
  }
127
131
 
132
+ /**
133
+ * 分屏来源/激活 options 的公开类型。
134
+ * activate 仅 wezterm 支持;其他后端忽略该提示并保持既有焦点行为。
135
+ */
136
+ export interface CreateSurfaceSplitOptions {
137
+ /** 是否在分屏后激活新创建的 pane(仅 wezterm;默认 false,保持当前焦点) */
138
+ activate?: boolean;
139
+ }
140
+
128
141
  /**
129
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 参调用类型与行为保持不变。
130
148
  */
131
149
  export function createSurfaceSplit(
132
150
  name: string,
133
151
  direction: "left" | "right" | "up" | "down",
134
152
  fromSurface?: string,
153
+ options?: CreateSurfaceSplitOptions,
135
154
  ): string {
136
155
  const backend = requireMuxBackend();
137
156
 
@@ -167,7 +186,7 @@ export function createSurfaceSplit(
167
186
  lastSplitSource = source ?? null;
168
187
  }
169
188
 
170
- return backendOps[backend].createSplit(name, direction, fromSurface);
189
+ return backendOps[backend].createSplit(name, direction, fromSurface, options);
171
190
  }
172
191
 
173
192
  /**
@@ -193,85 +212,163 @@ export function sendEscape(surface: string): void {
193
212
  backendOps[backend].sendEscape(surface);
194
213
  }
195
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
+
196
287
  /**
197
288
  * 向 surface 发送长命令(通过脚本文件避免终端自动换行问题)。
198
- * 返回脚本文件路径。
289
+ * 返回脚本文件路径。默认解释器为 Bash(所有平台);显式 interpreter:"powershell"
290
+ * 时按 PowerShell 语法写 .ps1 并执行。
199
291
  */
200
292
  export function sendLongCommand(
201
293
  surface: string,
202
294
  command: string,
203
- options?: { scriptPath?: string; scriptPreamble?: string },
295
+ options?: SendLongCommandOptions,
204
296
  ): string {
205
- // Headless mode: spawn as a background child process
206
- if (isHeadlessSurface(surface)) {
207
- const logFile = options?.scriptPath
208
- ? options.scriptPath.replace(/\.sh$/, ".log")
209
- : undefined;
210
- const scriptPath =
211
- options?.scriptPath ??
212
- join(
213
- tmpdir(),
214
- "pi-subagent-scripts",
215
- `cmd-${Date.now()}-${Math.random().toString(16).slice(2, 8)}.sh`,
216
- );
217
- mkdirSync(dirname(scriptPath), { recursive: true });
218
- const scriptParts = ["#!/bin/bash"];
219
- if (options?.scriptPreamble) {
220
- scriptParts.push(options.scriptPreamble.trimEnd());
221
- }
222
- scriptParts.push(command);
223
- writeFileSync(scriptPath, scriptParts.join("\n") + "\n", { mode: 0o755 });
224
-
225
- spawnHeadlessProcess(surface, "subagent", `bash ${shellEscape(scriptPath)}`, {
226
- cwd: process.cwd(),
227
- env: { PI_SUBAGENT_HEADLESS: "1" },
228
- });
229
- return scriptPath;
230
- }
297
+ const interpreter = resolveSendInterpreter(options?.interpreter);
298
+ const extension = sendScriptExtension(interpreter);
231
299
 
232
300
  const scriptPath =
233
301
  options?.scriptPath ??
234
302
  join(
235
303
  tmpdir(),
236
304
  "pi-subagent-scripts",
237
- `cmd-${Date.now()}-${Math.random().toString(16).slice(2, 8)}.sh`,
305
+ `cmd-${Date.now()}-${Math.random().toString(16).slice(2, 8)}${extension}`,
238
306
  );
239
307
  mkdirSync(dirname(scriptPath), { recursive: true });
240
308
 
241
- const scriptParts = ["#!/bin/bash"];
242
- if (options?.scriptPreamble) {
243
- 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;
244
325
  }
245
- scriptParts.push(command);
246
326
 
247
- writeFileSync(scriptPath, scriptParts.join("\n") + "\n", {
248
- mode: 0o755,
249
- });
250
- sendCommand(surface, `bash ${shellEscape(scriptPath)}`);
327
+ sendCommand(surface, buildMuxInvocation(interpreter, scriptPath));
251
328
  return scriptPath;
252
329
  }
253
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
+
254
339
  /**
255
340
  * 同步读取 surface 屏幕最后 N 行。
341
+ * options.source 仅对 herdr 后端生效(如 "recent_unwrapped"),其他后端忽略。
256
342
  */
257
- export function readScreen(surface: string, lines = 50): string {
343
+ export function readScreen(surface: string, lines = 50, options?: ReadScreenOptions): string {
258
344
  if (isHeadlessSurface(surface)) {
259
345
  return readHeadlessScreen(surface, lines);
260
346
  }
261
347
 
262
348
  const backend = requireMuxBackend();
349
+ if (options?.source && backend === HERDR_BACKEND) {
350
+ return readHerdrScreen(surface, lines, options.source);
351
+ }
263
352
  return backendOps[backend].read(surface, lines);
264
353
  }
265
354
 
266
355
  /**
267
356
  * 异步读取 surface 屏幕最后 N 行。
357
+ * options.source 仅对 herdr 后端生效(如 "recent_unwrapped"),其他后端忽略。
268
358
  */
269
- 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> {
270
364
  if (isHeadlessSurface(surface)) {
271
365
  return readHeadlessScreenAsync(surface, lines);
272
366
  }
273
367
 
274
368
  const backend = requireMuxBackend();
369
+ if (options?.source && backend === HERDR_BACKEND) {
370
+ return readHerdrScreen(surface, lines, options.source);
371
+ }
275
372
  return backendOps[backend].readAsync(surface, lines);
276
373
  }
277
374