@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 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 right-split; Orca breadth-first splits from the caller, or a new tab without a caller handle; headless fallback. `options.command` starts work during creation |
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 向右分屏;Orca 从调用方做 BFS 分屏,没有调用方 handle 时新建 tab;无后端时 headless。`options.command` 在创建时启动任务 |
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kevinhao/pi-terminal-mux",
3
- "version": "0.6.5",
3
+ "version": "0.7.0",
4
4
  "description": "Terminal surface API for tmux and Orca, with native startup commands and headless fallback",
5
5
  "type": "module",
6
6
  "main": "./index.ts",
@@ -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, hasCommand, withFileLock } from "./shared.ts";
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 withFileLock(lockPath, { timeoutMs: 3_000 }, () => {
251
- let state = new BfsSplitStateManager(markerFile);
252
- if (state.panes().length === 0) {
253
- const handle = splitOrcaTerminal("right", agentHandle, options);
254
- state.add(handle);
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
  };
@@ -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>();
@@ -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
- return ops.createSplit(name, "right", process.env.TMUX_PANE, options);
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
  };
@@ -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
  */