pi-terminal-mux 0.3.1 → 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 +14 -3
- package/README.zh-CN.md +14 -3
- package/package.json +1 -1
- package/src/backends/herdr.ts +8 -2
- package/src/backends/types.ts +17 -2
- package/src/backends/wezterm.ts +36 -3
- package/src/headless.ts +38 -7
- package/src/mux.ts +6 -1
- package/src/shell.ts +9 -0
- package/src/surface.ts +135 -41
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
|
});
|
|
@@ -73,11 +74,11 @@ If the forced backend's runtime is unavailable, `getMuxBackend()` returns `null`
|
|
|
73
74
|
| Function | Description |
|
|
74
75
|
|----------|-------------|
|
|
75
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 |
|
|
76
|
-
| `createSurfaceSplit(name, direction, fromSurface?)` | Split in an explicit direction (left/right/up/down) |
|
|
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
|
|
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
|
});
|
|
@@ -72,11 +73,11 @@ closeSurface(surface);
|
|
|
72
73
|
| 函数 | 说明 |
|
|
73
74
|
|------|------|
|
|
74
75
|
| `createSurface(name)` | 智能放置新 surface(cmux 首次右分屏后续开 tab、zellij tab 感知平铺/堆叠、muxy/otty/orca 广度优先分屏;orca 缺少 agent handle 时新建 tab),返回 surface 标识 |
|
|
75
|
-
| `createSurfaceSplit(name, direction, fromSurface?)` | 指定方向(left/right/up/down
|
|
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`
|
|
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,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-terminal-mux",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.2",
|
|
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",
|
package/src/backends/herdr.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
/**
|
package/src/backends/types.ts
CHANGED
|
@@ -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
|
-
/**
|
|
17
|
-
|
|
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 按键 */
|
package/src/backends/wezterm.ts
CHANGED
|
@@ -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
|
-
|
|
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 +
|
|
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
|
|
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?: {
|
|
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
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
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 {
|
|
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?:
|
|
295
|
+
options?: SendLongCommandOptions,
|
|
207
296
|
): string {
|
|
208
|
-
|
|
209
|
-
|
|
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)}
|
|
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
|
|
245
|
-
|
|
246
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
|