pi-terminal-mux 0.3.0 → 0.3.1

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
@@ -72,7 +72,7 @@ If the forced backend's runtime is unavailable, `getMuxBackend()` returns `null`
72
72
 
73
73
  | Function | Description |
74
74
  |----------|-------------|
75
- | `createSurface(name)` | Smart placement (cmux: first right-split then tabs; zellij: tab-aware tiled/stacked; muxy/otty: breadth-first splits; orca: new tab in the current worktree), returns a surface handle |
75
+ | `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
76
  | `createSurfaceSplit(name, direction, fromSurface?)` | Split in an explicit direction (left/right/up/down) |
77
77
  | `sendCommand(surface, command)` | Send a command and press Enter |
78
78
  | `sendLongCommand(surface, command, opts?)` | Write long commands to a script file first; `opts.scriptPreamble` injects env exports; returns the script path |
package/README.zh-CN.md CHANGED
@@ -71,7 +71,7 @@ closeSurface(surface);
71
71
 
72
72
  | 函数 | 说明 |
73
73
  |------|------|
74
- | `createSurface(name)` | 智能放置新 surface(cmux 首次右分屏后续开 tab、zellij tab 感知平铺/堆叠、muxy/otty 广度优先分屏、orca 在当前 worktree 新建 tab),返回 surface 标识 |
74
+ | `createSurface(name)` | 智能放置新 surface(cmux 首次右分屏后续开 tab、zellij tab 感知平铺/堆叠、muxy/otty/orca 广度优先分屏;orca 缺少 agent handle 时新建 tab),返回 surface 标识 |
75
75
  | `createSurfaceSplit(name, direction, fromSurface?)` | 指定方向(left/right/up/down)分屏 |
76
76
  | `sendCommand(surface, command)` | 发送命令并回车执行 |
77
77
  | `sendLongCommand(surface, command, opts?)` | 长命令先写脚本文件再执行;`opts.scriptPreamble` 可注入 env export;返回脚本路径 |
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "pi-terminal-mux",
3
- "version": "0.3.0",
4
- "description": "Terminal multiplexer abstraction for pi extensions — unified surface API across muxy, cmux, tmux, zellij, wezterm, herdr and otty, with headless fallback",
3
+ "version": "0.3.1",
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": {
@@ -18,9 +18,8 @@
18
18
  * 与其他 backend 的关键差异:
19
19
  * 1. Orca 注入 ORCA_TERMINAL_HANDLE(类似 cmux 的 CMUX_SURFACE_ID),
20
20
  * 模块加载时冻结为 AGENT_ORCA_TERMINAL_HANDLE。
21
- * 2. create() 走"新 tab"而非分屏:Orca worktree 中心的工作台,
22
- * 官方建议 agent `terminal create` 开新终端;不拆分 agent 自己的
23
- * pane,避免压缩 pi 的 UI。因此不使用 shared.ts 的 BFS 分屏状态机。
21
+ * 2. create() muxy/herdr/otty 一样从 agent terminal 分屏,并通过
22
+ * shared.ts BFS 状态机轮转 right/down;没有 agent handle 时回退为新 tab。
24
23
  * 3. Escape 通过发送裸 ESC(`\u001b`,即 `0x1b`)字节实现,TUI 会将其解释为 Escape 键。
25
24
  * 4. rename 作用于 tab 标题;split 出来的 pane 与源 pane 共享 tab,
26
25
  * 所以 createSplit 不做 rename(否则会把 agent 所在 tab 一起改名)。
@@ -32,7 +31,8 @@
32
31
  */
33
32
 
34
33
  import { spawnSync } from "node:child_process";
35
- import { createBackendLogger, hasCommand } from "./shared.ts";
34
+ import { tmpdir } from "node:os";
35
+ import { BfsSplitStateManager, createBackendLogger, hasCommand, withFileLock } from "./shared.ts";
36
36
  import type { BackendOps } from "./types.ts";
37
37
 
38
38
  const ORCA_RUNTIME_CHECK_TIMEOUT_MS = 1_500;
@@ -139,6 +139,49 @@ export function orcaSplitDirection(direction: "left" | "right" | "up" | "down"):
139
139
  return direction === "left" || direction === "right" ? "vertical" : "horizontal";
140
140
  }
141
141
 
142
+ // ── Orca BFS 分屏状态 ──
143
+
144
+ /**
145
+ * 返回 Orca subagent 分屏状态 marker 路径。
146
+ * 使用 agent handle 隔离不同 Orca 会话,路径放在系统临时目录中。
147
+ */
148
+ function orcaStateFile(): string {
149
+ const agentHandle = AGENT_ORCA_TERMINAL_HANDLE ?? "default";
150
+ const safe = agentHandle.replace(/[^a-zA-Z0-9_-]/g, "_");
151
+ return `${tmpdir()}/orca-subagent-pane-${safe}.json`;
152
+ }
153
+
154
+ /**
155
+ * 从 BFS marker 中移除已关闭的 pane,避免后续 create 使用僵尸 handle。
156
+ */
157
+ function cleanupOrcaStateForSurface(handle: string): void {
158
+ try {
159
+ const state = new BfsSplitStateManager(orcaStateFile());
160
+ state.remove(handle);
161
+ } catch (e) {
162
+ orcaLog(`[state] cleanup failed for ${handle}: ${(e as Error).message}`);
163
+ }
164
+ }
165
+
166
+ /**
167
+ * 调用 `orca terminal create` 新建 tab,作为没有 agent handle 时的回退。
168
+ */
169
+ function createOrcaTab(name: string): string {
170
+ try {
171
+ const raw = orcaExec(["terminal", "create", "--title", name, "--json"]);
172
+ const handle = extractOrcaCreateHandle(parseOrcaJson(raw));
173
+ if (!handle) {
174
+ orcaLog(`[create] no handle in tab response for name=${JSON.stringify(name)}`);
175
+ return "";
176
+ }
177
+ orcaLog(`[create] fallback=tab new=${handle} name=${JSON.stringify(name)}`);
178
+ return handle;
179
+ } catch (e) {
180
+ orcaLog(`[create] fallback tab failed: ${(e as Error).message}`);
181
+ return "";
182
+ }
183
+ }
184
+
142
185
  // ── Orca CLI 调用的薄封装 ──
143
186
 
144
187
  /**
@@ -193,22 +236,62 @@ function orcaExecSilent(args: string[]): void {
193
236
  /**
194
237
  * 创建一个新的 subagent terminal。
195
238
  *
196
- * 实现:`orca terminal create --title <name>` 在当前 worktree 新建 tab
197
- * (CLI 语义:不抢焦点)。与 otty/muxy 的 BFS 分屏不同,新 tab 不会压缩
198
- * agent 自己的 pane,符合 Orca worktree 中心工作流。
239
+ * 实现:与 muxy/herdr/otty 一致,使用持久化 BFS 状态从 agent terminal 分屏:
240
+ * - 第一次从 agent pane 向右分屏;
241
+ * - 后续按 BFS 状态机轮转 right/down,均保持 agent 焦点不变(Orca split CLI 默认不抢焦点)。
199
242
  *
200
- * 失败返回 ""(与 otty createSurface 一致,调用方统一处理)。
243
+ * Orca 没有注入 agent handle 时回退为新建 tab,兼容手动调用原生 API 的场景。
244
+ * 失败返回 ""(与其他 backend create 一致,调用方统一处理)。
201
245
  */
202
246
  export function createOrcaSurface(name: string): string {
247
+ const agentHandle = AGENT_ORCA_TERMINAL_HANDLE;
248
+ if (!agentHandle) return createOrcaTab(name);
249
+
250
+ const markerFile = orcaStateFile();
251
+ const lockPath = `${markerFile}.lock`;
252
+
203
253
  try {
204
- const raw = orcaExec(["terminal", "create", "--title", name, "--json"]);
205
- const handle = extractOrcaCreateHandle(parseOrcaJson(raw));
206
- if (!handle) {
207
- orcaLog(`[create] no handle in response for name=${JSON.stringify(name)}`);
208
- return "";
209
- }
210
- orcaLog(`[create] new=${handle} name=${JSON.stringify(name)}`);
211
- return handle;
254
+ return withFileLock(lockPath, { timeoutMs: 3_000 }, () => {
255
+ let state = new BfsSplitStateManager(markerFile);
256
+
257
+ // 首次 split:从 agent pane 向右分屏。
258
+ if (state.panes().length === 0) {
259
+ const newHandle = splitOrcaTerminal("right", agentHandle);
260
+ if (!newHandle) {
261
+ orcaLog(`[create] first split failed from=${agentHandle}`);
262
+ return "";
263
+ }
264
+ state.add(newHandle);
265
+ orcaLog(`[create] mode=first dir=right from=${agentHandle} new=${newHandle} name=${JSON.stringify(name)}`);
266
+ return newHandle;
267
+ }
268
+
269
+ const next = state.next();
270
+ if (!next) return "";
271
+
272
+ // 正常路径按 BFS 状态机分屏;目标 pane 失效时清空状态并从 agent pane 恢复。
273
+ let newHandle = splitOrcaTerminal(next.direction, next.source);
274
+ if (newHandle) {
275
+ state.advance();
276
+ state.add(newHandle);
277
+ orcaLog(
278
+ `[create] mode=next dir=${next.direction} from=${next.source} new=${newHandle} name=${JSON.stringify(name)}`,
279
+ );
280
+ return newHandle;
281
+ }
282
+
283
+ orcaLog(`[create] pane ${next.source} unavailable, reset and retry from agent pane`);
284
+ for (const pane of state.panes()) state.remove(pane);
285
+ state = new BfsSplitStateManager(markerFile);
286
+ newHandle = splitOrcaTerminal("right", agentHandle);
287
+ if (!newHandle) {
288
+ orcaLog(`[create] reset split failed from=${agentHandle}`);
289
+ return "";
290
+ }
291
+ state.add(newHandle);
292
+ orcaLog(`[create] mode=recovered dir=right from=${agentHandle} new=${newHandle} name=${JSON.stringify(name)}`);
293
+ return newHandle;
294
+ });
212
295
  } catch (e) {
213
296
  orcaLog(`[create] failed: ${(e as Error).message}`);
214
297
  return "";
@@ -299,36 +382,40 @@ function queryOrcaTerminalExists(handle: string): boolean | null {
299
382
  /**
300
383
  * 关闭 terminal。
301
384
  *
302
- * create() 生成的 surface 独占一个 tab,split 生成的 pane 共享 tab。
385
+ * create() createSplit() 生成的 pane 可能与其他 pane 共享 tab。
303
386
  * 策略(best-effort,绝不 throw,避免 pollForExit 退出流程被打断):
304
387
  * 1. `terminal close`(关 pane/session)
305
388
  * 2. 验证 handle 是否还在列表;不在了则完成
306
389
  * 3. 仍在则补 `terminal close --tab`(单 pane tab 的残留情况)
307
- * 4. 仍失败仅 log warn
390
+ * 4. 清理 BFS marker;仍失败仅 log warn
308
391
  */
309
392
  export function closeOrcaSurface(handle: string): void {
310
- orcaExecSilent(["terminal", "close", "--terminal", handle, "--json"]);
393
+ try {
394
+ orcaExecSilent(["terminal", "close", "--terminal", handle, "--json"]);
311
395
 
312
- let exists = queryOrcaTerminalExists(handle);
313
- if (exists === false) {
314
- orcaLog(`[close] terminal ${handle} closed`);
315
- return;
316
- }
317
- if (exists === null) {
318
- // 查询失败不能当作已关闭;继续尝试 --tab 兜底
319
- orcaLog(`[close] WARN terminal ${handle} list query failed, cannot verify close`);
320
- }
396
+ let exists = queryOrcaTerminalExists(handle);
397
+ if (exists === false) {
398
+ orcaLog(`[close] terminal ${handle} closed`);
399
+ return;
400
+ }
401
+ if (exists === null) {
402
+ // 查询失败不能当作已关闭;继续尝试 --tab 兜底
403
+ orcaLog(`[close] WARN terminal ${handle} list query failed, cannot verify close`);
404
+ }
321
405
 
322
- orcaLog(`[close] terminal ${handle} still present, trying --tab`);
323
- orcaExecSilent(["terminal", "close", "--terminal", handle, "--tab", "--json"]);
406
+ orcaLog(`[close] terminal ${handle} still present, trying --tab`);
407
+ orcaExecSilent(["terminal", "close", "--terminal", handle, "--tab", "--json"]);
324
408
 
325
- exists = queryOrcaTerminalExists(handle);
326
- if (exists === false) {
327
- orcaLog(`[close] terminal ${handle} closed via --tab`);
328
- } else if (exists === null) {
329
- orcaLog(`[close] WARN terminal ${handle} close sent but unverifiable (list query failed)`);
330
- } else {
331
- orcaLog(`[close] WARN terminal ${handle} still present after close --tab`);
409
+ exists = queryOrcaTerminalExists(handle);
410
+ if (exists === false) {
411
+ orcaLog(`[close] terminal ${handle} closed via --tab`);
412
+ } else if (exists === null) {
413
+ orcaLog(`[close] WARN terminal ${handle} close sent but unverifiable (list query failed)`);
414
+ } else {
415
+ orcaLog(`[close] WARN terminal ${handle} still present after close --tab`);
416
+ }
417
+ } finally {
418
+ cleanupOrcaStateForSurface(handle);
332
419
  }
333
420
  }
334
421
 
@@ -345,7 +432,7 @@ export function renameOrcaTerminal(handle: string, name: string): void {
345
432
 
346
433
  /** BackendOps 适配器:所有方法薄包装 orca 原生函数 */
347
434
  export const ops: BackendOps = {
348
- /** 创建 orca surface(新 tab,不抢焦点) */
435
+ /** 创建 orca surface(从 agent terminal BFS 分屏;无 handle 时回退新 tab */
349
436
  create(name: string): string {
350
437
  return createOrcaSurface(name);
351
438
  },
package/src/surface.ts CHANGED
@@ -53,6 +53,7 @@ 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";
56
57
 
57
58
  // ── 全键注册表 ──
58
59
 
@@ -117,6 +118,8 @@ export function createSurface(name: string): string {
117
118
  lastSplitSource = AGENT_MUXY_PANE_ID ?? null;
118
119
  } else if (backend === "otty") {
119
120
  lastSplitSource = AGENT_OTTY_PANE_ID ?? null;
121
+ } else if (backend === ORCA_BACKEND) {
122
+ lastSplitSource = AGENT_ORCA_TERMINAL_HANDLE ?? null;
120
123
  } else {
121
124
  // tmux / wezterm / zellij / herdr
122
125
  lastSplitSource = process.env.TMUX_PANE ?? null;