@kevinhao/pi-terminal-mux 0.6.5 → 0.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -1
- package/README.zh-CN.md +1 -1
- package/package.json +1 -1
- package/src/backends/orca.ts +11 -25
- package/src/backends/shared.ts +47 -0
- package/src/backends/tmux.ts +43 -4
- package/src/backends/types.ts +4 -0
- package/src/headless.ts +9 -0
- package/src/mux.ts +2 -0
- package/src/surface.ts +16 -2
package/README.md
CHANGED
|
@@ -63,7 +63,7 @@ Automatic detection prefers tmux, then Orca. Force a backend with `PI_TERMINAL_M
|
|
|
63
63
|
|
|
64
64
|
| Function | Behavior |
|
|
65
65
|
|----------|----------|
|
|
66
|
-
| `createSurface(name, options?)` | tmux
|
|
66
|
+
| `createSurface(name, options?)` | tmux and Orca both breadth-first split from the caller's pane into a grid; Orca opens a new tab without a caller handle; headless fallback. `options.command` starts work during creation |
|
|
67
67
|
| `createSurfaceSplit(name, direction, fromSurface?, options?)` | Explicit split; `direction` is left/right/up/down, `options.command` is optional. Requires tmux or Orca; headless has no pane layout |
|
|
68
68
|
| `sendCommand(surface, command)` | Send input followed by Enter to an existing terminal |
|
|
69
69
|
| `sendLongCommand(surface, command, options?)` | Write a script, then execute it. Supports `scriptPath`, `scriptPreamble`, and `interpreter: "bash" | "powershell"` |
|
package/README.zh-CN.md
CHANGED
|
@@ -63,7 +63,7 @@ const scriptPath = sendLongCommand(surface, "pi --session /tmp/child.jsonl", {
|
|
|
63
63
|
|
|
64
64
|
| 函数 | 行为 |
|
|
65
65
|
|------|------|
|
|
66
|
-
| `createSurface(name, options?)` | tmux
|
|
66
|
+
| `createSurface(name, options?)` | tmux 与 Orca 一致:从调用方 pane 出发做 BFS 网格分屏;Orca 没有调用方 handle 时新建 tab;无后端时 headless。`options.command` 在创建时启动任务 |
|
|
67
67
|
| `createSurfaceSplit(name, direction, fromSurface?, options?)` | 指定方向 left/right/up/down 分屏;可传 `options.command`。要求 tmux 或 Orca,headless 没有布局 |
|
|
68
68
|
| `sendCommand(surface, command)` | 向现有终端发送文本并回车 |
|
|
69
69
|
| `sendLongCommand(surface, command, options?)` | 先写脚本再执行;支持 `scriptPath`、`scriptPreamble` 和 `interpreter: "bash" | "powershell"` |
|
package/package.json
CHANGED
package/src/backends/orca.ts
CHANGED
|
@@ -32,8 +32,8 @@
|
|
|
32
32
|
|
|
33
33
|
import { spawnSync } from "node:child_process";
|
|
34
34
|
import { tmpdir } from "node:os";
|
|
35
|
-
import { BfsSplitStateManager, createBackendLogger,
|
|
36
|
-
import type { BackendOps, CreateSurfaceOptions } from "./types.ts";
|
|
35
|
+
import { BfsSplitStateManager, createBackendLogger, createBfsSurface, hasCommand } from "./shared.ts";
|
|
36
|
+
import type { BackendOps, CreateSurfaceOptions, SurfaceState } from "./types.ts";
|
|
37
37
|
|
|
38
38
|
const ORCA_RUNTIME_CHECK_TIMEOUT_MS = 1_500;
|
|
39
39
|
const ORCA_COMMAND_TIMEOUT_MS = 10_000;
|
|
@@ -244,30 +244,12 @@ export function createOrcaSurface(name: string, options?: CreateSurfaceOptions):
|
|
|
244
244
|
const agentHandle = AGENT_ORCA_TERMINAL_HANDLE;
|
|
245
245
|
if (!agentHandle) return createOrcaTab(name, options);
|
|
246
246
|
|
|
247
|
-
const markerFile = orcaStateFile();
|
|
248
|
-
const lockPath = `${markerFile}.lock`;
|
|
249
247
|
try {
|
|
250
|
-
return
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
return handle;
|
|
256
|
-
}
|
|
257
|
-
const next = state.next();
|
|
258
|
-
if (!next) throw new Error("Cannot determine the next Orca split source.");
|
|
259
|
-
// Resolve stale placement before submitting work. Never retry an ambiguous create failure.
|
|
260
|
-
if (queryOrcaTerminalState(next.source).kind === "absent") {
|
|
261
|
-
for (const pane of state.panes()) state.remove(pane);
|
|
262
|
-
state = new BfsSplitStateManager(markerFile);
|
|
263
|
-
const handle = splitOrcaTerminal("right", agentHandle, options);
|
|
264
|
-
state.add(handle);
|
|
265
|
-
return handle;
|
|
266
|
-
}
|
|
267
|
-
const handle = splitOrcaTerminal(next.direction, next.source, options);
|
|
268
|
-
state.advance();
|
|
269
|
-
state.add(handle);
|
|
270
|
-
return handle;
|
|
248
|
+
return createBfsSurface({
|
|
249
|
+
statePath: orcaStateFile(),
|
|
250
|
+
base: agentHandle,
|
|
251
|
+
split: (direction, source) => splitOrcaTerminal(direction, source, options),
|
|
252
|
+
isAbsent: (handle) => queryOrcaTerminalState(handle).kind === "absent",
|
|
271
253
|
});
|
|
272
254
|
} catch (error) {
|
|
273
255
|
orcaLog(`[create] failed: ${(error as Error).message}`);
|
|
@@ -490,4 +472,8 @@ export const ops: BackendOps = {
|
|
|
490
472
|
rename(surface: string, name: string): void {
|
|
491
473
|
renameOrcaTerminal(surface, name);
|
|
492
474
|
},
|
|
475
|
+
/** 查询 terminal 是否存在;查询失败归一为 unknown */
|
|
476
|
+
state(surface: string): SurfaceState {
|
|
477
|
+
return queryOrcaTerminalState(surface).kind;
|
|
478
|
+
},
|
|
493
479
|
};
|
package/src/backends/shared.ts
CHANGED
|
@@ -208,6 +208,53 @@ export class BfsSplitStateManager {
|
|
|
208
208
|
}
|
|
209
209
|
}
|
|
210
210
|
|
|
211
|
+
// ── BFS 分屏创建(tmux / Orca 共用) ──
|
|
212
|
+
|
|
213
|
+
export interface BfsSplitOptions {
|
|
214
|
+
/** BFS 状态 marker 文件路径 */
|
|
215
|
+
statePath: string;
|
|
216
|
+
/** 第一个 pane 的分屏源(agent 自己的 pane) */
|
|
217
|
+
base: string;
|
|
218
|
+
/** 执行一次分屏,返回新 pane ID */
|
|
219
|
+
split: (direction: BfsNext["direction"], source: string) => string;
|
|
220
|
+
/** 该 pane 是否已消失,用于丢弃僵尸状态 */
|
|
221
|
+
isAbsent: (pane: string) => boolean;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/**
|
|
225
|
+
* 按 BFS 网格从 base 分屏:首次向右,之后按轮次交替 right/down,
|
|
226
|
+
* 让多个 subagent 铺成二维网格而不是一路向右切窄。
|
|
227
|
+
*
|
|
228
|
+
* 状态存于 statePath(跨进程),用文件锁串行化并发创建。
|
|
229
|
+
* 下一个分屏源已消失时清空状态,重新从 base 向右开始。
|
|
230
|
+
*/
|
|
231
|
+
export function createBfsSurface(options: BfsSplitOptions): string {
|
|
232
|
+
const { statePath, base, split, isAbsent } = options;
|
|
233
|
+
return withFileLock(`${statePath}.lock`, { timeoutMs: 3_000 }, () => {
|
|
234
|
+
let state = new BfsSplitStateManager(statePath);
|
|
235
|
+
const first = (): string => {
|
|
236
|
+
const pane = split("right", base);
|
|
237
|
+
state.add(pane);
|
|
238
|
+
return pane;
|
|
239
|
+
};
|
|
240
|
+
|
|
241
|
+
if (state.panes().length === 0) return first();
|
|
242
|
+
|
|
243
|
+
const next = state.next();
|
|
244
|
+
if (!next) throw new Error("Cannot determine the next split source.");
|
|
245
|
+
if (isAbsent(next.source)) {
|
|
246
|
+
for (const pane of state.panes()) state.remove(pane);
|
|
247
|
+
state = new BfsSplitStateManager(statePath);
|
|
248
|
+
return first();
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
const pane = split(next.direction, next.source);
|
|
252
|
+
state.advance();
|
|
253
|
+
state.add(pane);
|
|
254
|
+
return pane;
|
|
255
|
+
});
|
|
256
|
+
}
|
|
257
|
+
|
|
211
258
|
// ── 命令可用性检测 ──
|
|
212
259
|
|
|
213
260
|
const commandAvailability = new Map<string, boolean>();
|
package/src/backends/tmux.ts
CHANGED
|
@@ -3,21 +3,52 @@
|
|
|
3
3
|
*
|
|
4
4
|
* Tmux 特定的 surface 操作:split-window / send-keys / capture-pane / kill-pane。
|
|
5
5
|
* Tmux 没有 pane 级命名,rename 退化为 window 命名。
|
|
6
|
+
*
|
|
7
|
+
* create() 与 Orca 保持一致:从 agent pane(TMUX_PANE)出发按 BFS 网格分屏,
|
|
8
|
+
* 首次向右,之后按轮次交替 right/down,让 subagent 铺成二维网格。
|
|
9
|
+
* 状态 marker 按 agent pane 隔离,放在系统临时目录。
|
|
6
10
|
*/
|
|
7
11
|
|
|
8
|
-
import { execFileSync, execFile } from "node:child_process";
|
|
12
|
+
import { execFileSync, execFile, spawnSync } from "node:child_process";
|
|
13
|
+
import { tmpdir } from "node:os";
|
|
9
14
|
import { promisify } from "node:util";
|
|
10
|
-
import { createBackendLogger } from "./shared.ts";
|
|
11
|
-
import type { BackendOps, CreateSurfaceOptions } from "./types.ts";
|
|
15
|
+
import { BfsSplitStateManager, createBackendLogger, createBfsSurface } from "./shared.ts";
|
|
16
|
+
import type { BackendOps, CreateSurfaceOptions, SurfaceState } from "./types.ts";
|
|
12
17
|
|
|
13
18
|
const execFileAsync = promisify(execFile);
|
|
14
19
|
|
|
15
20
|
/** Tmux 后端日志(统一格式,写入 /tmp/pi-mux-tmux.log) */
|
|
16
21
|
const tmuxLog = createBackendLogger("tmux", "/tmp/pi-mux-tmux.log");
|
|
17
22
|
|
|
23
|
+
/** BFS 分屏状态 marker 路径:按 agent pane 隔离不同 tmux 会话/窗口 */
|
|
24
|
+
function tmuxStateFile(): string {
|
|
25
|
+
const base = process.env.TMUX_PANE ?? "default";
|
|
26
|
+
return `${tmpdir()}/tmux-subagent-pane-${base.replace(/[^a-zA-Z0-9_-]/g, "_")}.json`;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** 从 BFS marker 中移除已关闭的 pane,避免后续 create 使用僵尸 pane */
|
|
30
|
+
function cleanupStateForSurface(pane: string): void {
|
|
31
|
+
try {
|
|
32
|
+
new BfsSplitStateManager(tmuxStateFile()).remove(pane);
|
|
33
|
+
} catch (e) {
|
|
34
|
+
tmuxLog(`[state] cleanup failed for ${pane}: ${(e as Error).message}`);
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function isPaneAbsent(pane: string): boolean {
|
|
39
|
+
return ops.state(pane) === "absent";
|
|
40
|
+
}
|
|
41
|
+
|
|
18
42
|
export const ops: BackendOps = {
|
|
19
43
|
create(name: string, options?: CreateSurfaceOptions): string {
|
|
20
|
-
|
|
44
|
+
const base = process.env.TMUX_PANE;
|
|
45
|
+
if (!base) return ops.createSplit(name, "right", undefined, options);
|
|
46
|
+
return createBfsSurface({
|
|
47
|
+
statePath: tmuxStateFile(),
|
|
48
|
+
base,
|
|
49
|
+
split: (direction, source) => ops.createSplit(name, direction, source, options),
|
|
50
|
+
isAbsent: isPaneAbsent,
|
|
51
|
+
});
|
|
21
52
|
},
|
|
22
53
|
|
|
23
54
|
createSplit(name: string, direction: "left" | "right" | "up" | "down", fromSurface?: string, options?: CreateSurfaceOptions): string {
|
|
@@ -76,6 +107,7 @@ export const ops: BackendOps = {
|
|
|
76
107
|
close(surface: string): void {
|
|
77
108
|
execFileSync("tmux", ["kill-pane", "-t", surface], { encoding: "utf8" });
|
|
78
109
|
tmuxLog(`[close] surface=${surface}`);
|
|
110
|
+
cleanupStateForSurface(surface);
|
|
79
111
|
},
|
|
80
112
|
|
|
81
113
|
rename(surface: string, name: string): void {
|
|
@@ -85,4 +117,11 @@ export const ops: BackendOps = {
|
|
|
85
117
|
}).trim();
|
|
86
118
|
execFileSync("tmux", ["rename-window", "-t", windowId, name], { encoding: "utf8" });
|
|
87
119
|
},
|
|
120
|
+
|
|
121
|
+
state(surface: string): SurfaceState {
|
|
122
|
+
const result = spawnSync("tmux", ["list-panes", "-a", "-F", "#{pane_id}"], { encoding: "utf8", timeout: 5000 });
|
|
123
|
+
if (result.status === 0) return result.stdout.split(/\s+/).includes(surface) ? "present" : "absent";
|
|
124
|
+
if (/no server running|error connecting.*No such file|failed to connect.*No such file/.test(result.stderr ?? "")) return "absent";
|
|
125
|
+
return "unknown";
|
|
126
|
+
},
|
|
88
127
|
};
|
package/src/backends/types.ts
CHANGED
|
@@ -17,4 +17,8 @@ export interface BackendOps {
|
|
|
17
17
|
readAsync(surface: string, lines?: number): Promise<string>;
|
|
18
18
|
close(surface: string): void;
|
|
19
19
|
rename(surface: string, name: string): void;
|
|
20
|
+
/** Absence must come from a successful inventory, never from a failed read. */
|
|
21
|
+
state(surface: string): SurfaceState;
|
|
20
22
|
}
|
|
23
|
+
|
|
24
|
+
export type SurfaceState = "present" | "absent" | "unknown";
|
package/src/headless.ts
CHANGED
|
@@ -19,6 +19,7 @@ import type { WriteStream } from "node:fs";
|
|
|
19
19
|
import { tmpdir } from "node:os";
|
|
20
20
|
import { join } from "node:path";
|
|
21
21
|
import { isMuxAvailable } from "./detection.ts";
|
|
22
|
+
import type { SurfaceState } from "./backends/types.ts";
|
|
22
23
|
|
|
23
24
|
// ── 常量 ──
|
|
24
25
|
|
|
@@ -144,6 +145,14 @@ export function spawnHeadlessProcess(
|
|
|
144
145
|
/**
|
|
145
146
|
* 关闭 headless surface:先 SIGTERM,HEADLESS_KILL_TIMEOUT_MS 后若仍存活则 SIGKILL。
|
|
146
147
|
*/
|
|
148
|
+
/** Headless surfaces have no inventory; the managed child process is the authority. */
|
|
149
|
+
export function headlessSurfaceState(surface: string): SurfaceState {
|
|
150
|
+
if (!isHeadlessSurface(surface)) return "unknown";
|
|
151
|
+
const proc = headlessProcesses.get(surface);
|
|
152
|
+
if (!proc) return "absent";
|
|
153
|
+
return proc.child.exitCode === null && proc.child.signalCode === null ? "present" : "absent";
|
|
154
|
+
}
|
|
155
|
+
|
|
147
156
|
export function closeHeadlessSurface(surface: string): void {
|
|
148
157
|
const proc = headlessProcesses.get(surface);
|
|
149
158
|
if (!proc) return;
|
package/src/mux.ts
CHANGED
|
@@ -34,6 +34,7 @@ export {
|
|
|
34
34
|
readScreen,
|
|
35
35
|
readScreenAsync,
|
|
36
36
|
closeSurface,
|
|
37
|
+
surfaceState,
|
|
37
38
|
renameSurface,
|
|
38
39
|
getRenameCapability,
|
|
39
40
|
renameCurrentTab,
|
|
@@ -53,6 +54,7 @@ export type {
|
|
|
53
54
|
RenameCapability,
|
|
54
55
|
RenameResult,
|
|
55
56
|
} from "./surface.ts";
|
|
57
|
+
export type { SurfaceState } from "./backends/types.ts";
|
|
56
58
|
|
|
57
59
|
export {
|
|
58
60
|
TERMINAL_RENAME_CONTEXT_ENV,
|
package/src/surface.ts
CHANGED
|
@@ -11,10 +11,10 @@ import { tmpdir } from "node:os";
|
|
|
11
11
|
import { dirname, join } from "node:path";
|
|
12
12
|
|
|
13
13
|
import { getMuxBackend, muxLog, muxSetupHint } from "./detection.ts";
|
|
14
|
-
import { isHeadlessSurface, createHeadlessSurface, closeHeadlessSurface, sendHeadlessEscape, readHeadlessScreen, readHeadlessScreenAsync, spawnHeadlessProcess, getHeadlessProcessExit, drainHeadlessProcess } from "./headless.ts";
|
|
14
|
+
import { isHeadlessSurface, createHeadlessSurface, closeHeadlessSurface, sendHeadlessEscape, readHeadlessScreen, readHeadlessScreenAsync, spawnHeadlessProcess, getHeadlessProcessExit, drainHeadlessProcess, headlessSurfaceState } from "./headless.ts";
|
|
15
15
|
import { shellEscape, powershellEscape } from "./shell.ts";
|
|
16
16
|
import type { MuxBackend } from "./detection.ts";
|
|
17
|
-
import type { BackendOps, CreateSurfaceOptions } from "./backends/types.ts";
|
|
17
|
+
import type { BackendOps, CreateSurfaceOptions, SurfaceState } from "./backends/types.ts";
|
|
18
18
|
import { ops as tmuxOps } from "./backends/tmux.ts";
|
|
19
19
|
import { ops as orcaOps, AGENT_ORCA_TERMINAL_HANDLE, renameOrcaTerminal } from "./backends/orca.ts";
|
|
20
20
|
|
|
@@ -257,6 +257,20 @@ export function closeSurface(surface: string): void {
|
|
|
257
257
|
backendOps[backend].close(surface);
|
|
258
258
|
}
|
|
259
259
|
|
|
260
|
+
/**
|
|
261
|
+
* Query whether a surface still exists.
|
|
262
|
+
*
|
|
263
|
+
* A failed screen read must not be reported as absence, so this asks the backend
|
|
264
|
+
* for its inventory. Headless surfaces are tracked by their managed child process.
|
|
265
|
+
*/
|
|
266
|
+
export function surfaceState(surface: string): SurfaceState {
|
|
267
|
+
if (!surface) return "unknown";
|
|
268
|
+
if (isHeadlessSurface(surface)) return headlessSurfaceState(surface);
|
|
269
|
+
const backend = getMuxBackend();
|
|
270
|
+
if (!backend) return "unknown";
|
|
271
|
+
return backendOps[backend].state(surface);
|
|
272
|
+
}
|
|
273
|
+
|
|
260
274
|
/**
|
|
261
275
|
* 重命名指定 surface。
|
|
262
276
|
*/
|