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 +15 -4
- package/README.zh-CN.md +15 -4
- package/package.json +2 -2
- package/src/backends/herdr.ts +8 -2
- package/src/backends/orca.ts +125 -38
- 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 +138 -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
|
});
|
|
@@ -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
|
|
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
|
|
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
|
|
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`
|
|
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.
|
|
4
|
-
"description": "Terminal multiplexer abstraction for pi extensions — unified surface API across muxy, cmux, tmux, zellij, wezterm, herdr and
|
|
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": {
|
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/orca.ts
CHANGED
|
@@ -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()
|
|
22
|
-
*
|
|
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 {
|
|
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
|
-
*
|
|
197
|
-
*
|
|
198
|
-
*
|
|
239
|
+
* 实现:与 muxy/herdr/otty 一致,使用持久化 BFS 状态从 agent terminal 分屏:
|
|
240
|
+
* - 第一次从 agent pane 向右分屏;
|
|
241
|
+
* - 后续按 BFS 状态机轮转 right/down,均保持 agent 焦点不变(Orca split CLI 默认不抢焦点)。
|
|
199
242
|
*
|
|
200
|
-
*
|
|
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
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
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()
|
|
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.
|
|
390
|
+
* 4. 清理 BFS marker;仍失败仅 log warn
|
|
308
391
|
*/
|
|
309
392
|
export function closeOrcaSurface(handle: string): void {
|
|
310
|
-
|
|
393
|
+
try {
|
|
394
|
+
orcaExecSilent(["terminal", "close", "--terminal", handle, "--json"]);
|
|
311
395
|
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
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
|
-
|
|
323
|
-
|
|
406
|
+
orcaLog(`[close] terminal ${handle} still present, trying --tab`);
|
|
407
|
+
orcaExecSilent(["terminal", "close", "--terminal", handle, "--tab", "--json"]);
|
|
324
408
|
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
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
|
|
435
|
+
/** 创建 orca surface(从 agent terminal BFS 分屏;无 handle 时回退新 tab) */
|
|
349
436
|
create(name: string): string {
|
|
350
437
|
return createOrcaSurface(name);
|
|
351
438
|
},
|
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
|
|
|
@@ -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?:
|
|
295
|
+
options?: SendLongCommandOptions,
|
|
204
296
|
): string {
|
|
205
|
-
|
|
206
|
-
|
|
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)}
|
|
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
|
|
242
|
-
|
|
243
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
|