pi-terminal-mux 0.2.1 → 0.2.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-terminal-mux",
3
- "version": "0.2.1",
3
+ "version": "0.2.2",
4
4
  "description": "Terminal multiplexer abstraction for pi extensions — unified surface API across muxy, cmux, tmux, zellij, wezterm, herdr and otty, with headless fallback",
5
5
  "type": "module",
6
6
  "main": "./index.ts",
@@ -52,12 +52,11 @@
52
52
  "wezterm",
53
53
  "coding-agent"
54
54
  ],
55
- "peerDependencies": {
56
- "pi-extensions-i18n": ">=0.2.0"
55
+ "dependencies": {
56
+ "pi-extensions-i18n": "^0.3.1"
57
57
  },
58
58
  "devDependencies": {
59
59
  "@types/node": "24.12.4",
60
- "pi-extensions-i18n": "^0.3.0",
61
60
  "tsx": "4.23.1",
62
61
  "typescript": "5.9.3"
63
62
  }
@@ -8,8 +8,12 @@
8
8
 
9
9
  import { execSync, execFileSync, spawnSync } from "node:child_process";
10
10
  import { shellEscape } from "../shell.ts";
11
+ import { createBackendLogger } from "./shared.ts";
11
12
  import type { BackendOps } from "./types.ts";
12
13
 
14
+ /** Cmux 后端日志(统一格式,写入 /tmp/pi-mux-cmux.log) */
15
+ const cmuxLog = createBackendLogger("cmux", "/tmp/pi-mux-cmux.log");
16
+
13
17
  // ── 内部辅助 ──
14
18
 
15
19
  /** Tracked subagent pane for cmux — reused across subagent launches. */
@@ -271,7 +275,11 @@ export const ops: BackendOps = {
271
275
  try {
272
276
  const tree = execSync(`cmux tree`, { encoding: "utf8" });
273
277
  if (tree.includes(cmuxSubagentPane)) {
274
- return createSurfaceInPane(name, cmuxSubagentPane);
278
+ const surface = createSurfaceInPane(name, cmuxSubagentPane);
279
+ cmuxLog(
280
+ `[split] mode=tab-reuse pane=${cmuxSubagentPane} new=${surface} name=${JSON.stringify(name)}`,
281
+ );
282
+ return surface;
275
283
  }
276
284
  } catch {}
277
285
  // Pane 已消失 — fall through 创建新 split
@@ -280,11 +288,18 @@ export const ops: BackendOps = {
280
288
 
281
289
  const created = createCmuxSplitSurface(name, "right", process.env.CMUX_SURFACE_ID);
282
290
  cmuxSubagentPane = created.paneRef ?? null;
291
+ cmuxLog(
292
+ `[split] mode=first dir=right from=${process.env.CMUX_SURFACE_ID ?? "<unset>"} new=${created.surface} name=${JSON.stringify(name)}`,
293
+ );
283
294
  return created.surface;
284
295
  },
285
296
 
286
297
  createSplit(name: string, direction: "left" | "right" | "up" | "down", fromSurface?: string): string {
287
- return createCmuxSplitSurface(name, direction, fromSurface).surface;
298
+ const surface = createCmuxSplitSurface(name, direction, fromSurface).surface;
299
+ cmuxLog(
300
+ `[split] mode=createSurfaceSplit dir=${direction} from=${fromSurface ?? "<unset>"} new=${surface} name=${JSON.stringify(name)}`,
301
+ );
302
+ return surface;
288
303
  },
289
304
 
290
305
  send(surface: string, command: string): void {
@@ -318,6 +333,7 @@ export const ops: BackendOps = {
318
333
  execSync(`cmux close-surface --surface ${shellEscape(surface)}`, {
319
334
  encoding: "utf8",
320
335
  });
336
+ cmuxLog(`[close] surface=${surface}`);
321
337
  },
322
338
 
323
339
  rename(surface: string, name: string): void {
@@ -9,21 +9,16 @@
9
9
  * HERDR_PANE_ID(公开 id,如 "1-1")
10
10
  *
11
11
  * 所有 pane 操作通过 `herdr` CLI 完成,详见 SKILL.md。
12
+ * 日志、文件锁、BFS 分屏状态机、命令检测复用 backends/shared.ts。
12
13
  */
13
14
 
14
- import { execFileSync, execSync, spawnSync } from "node:child_process";
15
- import { appendFileSync, existsSync, readFileSync, writeFileSync, rmSync } from "node:fs";
15
+ import { execFileSync } from "node:child_process";
16
+ import { rmSync } from "node:fs";
16
17
  import { i18n } from "../i18n.ts";
18
+ import { createBackendLogger, withFileLock, BfsSplitStateManager, hasCommand } from "./shared.ts";
17
19
 
18
- // ── 日志(herdr 独立文件,便于区分后端) ──
19
- const HERDR_SPLIT_LOG = "/tmp/pi-herdr-split.log";
20
- function herdrLog(msg: string): void {
21
- try {
22
- appendFileSync(HERDR_SPLIT_LOG, `[${new Date().toISOString()}] ${msg}`);
23
- } catch {
24
- /* 写日志失败不影响主流程 */
25
- }
26
- }
20
+ // ── 日志(统一格式,写入 /tmp/pi-mux-herdr.log) ──
21
+ const herdrLog = createBackendLogger("herdr", "/tmp/pi-mux-herdr.log");
27
22
 
28
23
  /**
29
24
  * 捕获于模块加载时的 agent pane id。
@@ -34,27 +29,6 @@ export const AGENT_HERDR_PANE_ID = process.env.HERDR_PANE_ID;
34
29
  export const AGENT_HERDR_WORKSPACE_ID = process.env.HERDR_WORKSPACE_ID;
35
30
  export const AGENT_HERDR_TAB_ID = process.env.HERDR_TAB_ID;
36
31
 
37
- // ── 命令可用性缓存 ──
38
-
39
- const commandAvailability = new Map<string, boolean>();
40
-
41
- function hasCommand(command: string): boolean {
42
- if (commandAvailability.has(command)) {
43
- return commandAvailability.get(command)!;
44
- }
45
-
46
- let available = false;
47
- try {
48
- execFileSync("which", [command], { stdio: "ignore" });
49
- available = true;
50
- } catch {
51
- available = false;
52
- }
53
-
54
- commandAvailability.set(command, available);
55
- return available;
56
- }
57
-
58
32
  /**
59
33
  * 检测 herdr backend 是否可用:
60
34
  * 1. `herdr` 命令在 PATH 中
@@ -77,12 +51,12 @@ export function isHerdrRuntimeAvailable(): boolean {
77
51
 
78
52
  /**
79
53
  * 调用 `herdr` 命令并返回 stdout。
80
- * 失败时 stderr 写入 log,原样抛错(调用方决定如何处理)。
54
+ * 失败时原样抛错(调用方决定如何处理)。
81
55
  */
82
56
  function herdrExec(args: string[]): string {
83
- herdrLog(`[herdr exec] herdr ${args.map((a) => (a.includes(" ") ? JSON.stringify(a) : a)).join(" ")}\n`);
57
+ herdrLog(`[exec] herdr ${args.map((a) => (a.includes(" ") ? JSON.stringify(a) : a)).join(" ")}`);
84
58
  const out = execFileSync("herdr", args, { encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] });
85
- herdrLog(`[herdr exec] -> ${JSON.stringify(out.trim().slice(0, 200))}\n`);
59
+ herdrLog(`[exec] -> ${JSON.stringify(out.trim().slice(0, 200))}`);
86
60
  return out;
87
61
  }
88
62
 
@@ -91,7 +65,7 @@ function herdrExec(args: string[]): string {
91
65
  * 无输出的命令,遵循 SKILL.md 中"pane send-text/send-keys/run print nothing on success"。
92
66
  */
93
67
  function herdrExecSilent(args: string[]): void {
94
- herdrLog(`[herdr exec silent] herdr ${args.map((a) => (a.includes(" ") ? JSON.stringify(a) : a)).join(" ")}\n`);
68
+ herdrLog(`[exec silent] herdr ${args.map((a) => (a.includes(" ") ? JSON.stringify(a) : a)).join(" ")}`);
95
69
  execFileSync("herdr", args, { encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] });
96
70
  }
97
71
 
@@ -125,12 +99,12 @@ function extractPaneId(json: unknown): string | null {
125
99
  return null;
126
100
  }
127
101
 
128
- // ── mux state 缓存 ──
129
- //
130
- // herdr 没有 tmux `last_split_source` 之类的明确"上一个 split 的父 pane"语义。
131
- // 我们记录每个 surface 的"父 pane id",用于 close 时清理(herdr 不需要这个,但保留
132
- // 以便将来扩展 closePane 实际上只看 surface id)。
133
- const herdrPaneSources = new Map<string, string>();
102
+ // ── BFS 分屏状态 marker 路径 ──
103
+
104
+ /** herdr BFS 分屏状态 marker 文件路径(按 agent pane id 区分) */
105
+ function herdrMarkerPath(): string {
106
+ return `/tmp/herdr-subagent-pane-${(AGENT_HERDR_PANE_ID ?? "default").replace(/[^a-zA-Z0-9_-]/g, "_")}.json`;
107
+ }
134
108
 
135
109
  // ── 对外 API:createSurface 系列 ──
136
110
 
@@ -139,6 +113,7 @@ const herdrPaneSources = new Map<string, string>();
139
113
  *
140
114
  * 实现:split 当前 agent pane 右侧(--no-focus 保持 agent 焦点不变)。
141
115
  * 后续 subagent 按 breadth-first 模式轮转 right/down/right/down…(与 cmux/muxy 行为一致)。
116
+ * 锁与 BFS 状态机复用 shared.ts。
142
117
  */
143
118
  export function createHerdrSurface(name: string): string {
144
119
  if (!AGENT_HERDR_PANE_ID) {
@@ -148,142 +123,66 @@ export function createHerdrSurface(name: string): string {
148
123
  );
149
124
  }
150
125
 
151
- // muxy 同样的广度优先分屏策略:
152
- // 第一轮:从 agent pane 向右分,pos=0, base=1
153
- // 第二轮:从第一个 pane 向下分,pos=0, base=1
154
- // 第三轮:从第一个 pane 向右、第二个 pane 向右,pos=0, base=2
155
- // ……
156
- // 状态文件:/tmp/herdr-subagent-pane-<agent_pane_id>.json
157
- const markerFile = `/tmp/herdr-subagent-pane-${AGENT_HERDR_PANE_ID.replace(/[^a-zA-Z0-9_-]/g, "_")}.json`;
158
- const lockFile = `${markerFile}.lock`;
159
-
160
- // 全局锁:所有分屏操作串行化
161
- const acquired = (() => {
162
- for (let i = 0; i < 60; i++) {
163
- if (!existsSync(lockFile)) {
164
- try {
165
- writeFileSync(lockFile, `${process.pid}`, { flag: "wx" });
166
- return true;
167
- } catch {
168
- // 竞争失败,继续等待
169
- }
170
- }
171
- spawnSync("sleep", ["0.05"]);
172
- }
173
- return false;
174
- })();
126
+ const markerFile = herdrMarkerPath();
127
+ const lockPath = `${markerFile}.lock`;
175
128
 
176
- if (!acquired) {
177
- herdrLog(`[herdr split] failed to acquire lock ${lockFile}\n`);
178
- return "";
179
- }
180
-
181
- try {
182
- let state: { panes: string[]; pos: number; base: number; dir: "right" | "down" } = {
183
- panes: [],
184
- pos: 0,
185
- base: 0,
186
- dir: "right",
187
- };
188
- try {
189
- state = JSON.parse(readFileSync(markerFile, "utf8"));
190
- } catch {
191
- /* 文件不存在或损坏,用初始状态 */
192
- }
129
+ return withFileLock(lockPath, {}, () => {
130
+ const state = new BfsSplitStateManager(markerFile);
193
131
 
194
132
  // 首次 split
195
- if (state.panes.length === 0) {
133
+ if (state.panes().length === 0) {
196
134
  const output = herdrExec([
197
135
  "pane",
198
136
  "split",
199
- AGENT_HERDR_PANE_ID,
137
+ AGENT_HERDR_PANE_ID!,
200
138
  "--direction",
201
139
  "right",
202
140
  "--no-focus",
203
141
  ]);
204
- const json = parseHerdrJson(output);
205
- const newPaneId = extractPaneId(json);
142
+ const newPaneId = extractPaneId(parseHerdrJson(output));
206
143
  if (newPaneId) {
207
- state.panes = [newPaneId];
208
- state.pos = 0;
209
- state.base = 1;
210
- state.dir = "down";
211
- writeFileSync(markerFile, JSON.stringify(state));
212
- herdrPaneSources.set(newPaneId, AGENT_HERDR_PANE_ID);
144
+ state.add(newPaneId);
213
145
  renameHerdrPane(newPaneId, name);
214
146
  herdrLog(
215
- `[herdr split] mode=first dir=right from=${AGENT_HERDR_PANE_ID} new=${newPaneId} name=${JSON.stringify(name)}\n`,
147
+ `[split] mode=first dir=right from=${AGENT_HERDR_PANE_ID} new=${newPaneId} name=${JSON.stringify(name)}`,
216
148
  );
217
149
  return newPaneId;
218
150
  }
219
- herdrLog(`[herdr split] first split returned no pane id, output=${JSON.stringify(output)}\n`);
151
+ herdrLog(`[split] first split returned no pane id, output=${JSON.stringify(output)}`);
220
152
  return "";
221
153
  }
222
154
 
223
- // 本轮结束?翻转方向
224
- if (state.pos >= state.base) {
225
- state.pos = 0;
226
- state.base = state.panes.length;
227
- state.dir = state.dir === "right" ? "down" : "right";
228
- }
155
+ // 后续:BFS 分屏
156
+ const next = state.next();
157
+ if (!next) return "";
229
158
 
230
- let targetPane = state.panes[state.pos];
231
- if (!targetPane) {
232
- herdrLog(`[herdr split] state.panes[${state.pos}] is undefined\n`);
233
- return "";
234
- }
159
+ let { source } = next;
160
+ const { direction } = next;
235
161
 
236
- // 若 targetPane 过期(pane 被关闭 / session 重启),自动重置状态从 agent pane 重新分屏
162
+ // 若 source 过期(pane 被关闭 / session 重启),重置状态从 agent pane 重新分屏
237
163
  let output: string;
238
- let sourcePane = targetPane;
239
164
  try {
240
- output = herdrExec([
241
- "pane",
242
- "split",
243
- targetPane,
244
- "--direction",
245
- state.dir,
246
- "--no-focus",
247
- ]);
165
+ output = herdrExec(["pane", "split", source, "--direction", direction, "--no-focus"]);
248
166
  } catch {
249
167
  try { rmSync(markerFile); } catch { /* ignore */ }
250
- herdrLog(
251
- `[herdr split] pane ${targetPane} gone, resetting from agent pane ${AGENT_HERDR_PANE_ID}\n`,
252
- );
253
- state = { panes: [], pos: 0, base: 0, dir: "right" };
254
- targetPane = AGENT_HERDR_PANE_ID;
255
- sourcePane = AGENT_HERDR_PANE_ID;
256
- output = herdrExec([
257
- "pane",
258
- "split",
259
- AGENT_HERDR_PANE_ID,
260
- "--direction",
261
- "right",
262
- "--no-focus",
263
- ]);
168
+ herdrLog(`[split] pane ${source} gone, resetting from agent pane ${AGENT_HERDR_PANE_ID}`);
169
+ source = AGENT_HERDR_PANE_ID!;
170
+ output = herdrExec(["pane", "split", source, "--direction", "right", "--no-focus"]);
264
171
  }
265
- const json = parseHerdrJson(output);
266
- const newPaneId = extractPaneId(json);
172
+
173
+ const newPaneId = extractPaneId(parseHerdrJson(output));
267
174
  if (newPaneId) {
268
- state.panes.push(newPaneId);
269
- state.pos++;
270
- writeFileSync(markerFile, JSON.stringify(state));
271
- herdrPaneSources.set(newPaneId, sourcePane);
175
+ state.advance();
176
+ state.add(newPaneId);
272
177
  renameHerdrPane(newPaneId, name);
273
178
  herdrLog(
274
- `[herdr split] mode=next pos=${state.pos - 1} base=${state.base} dir=${state.dir} from=${targetPane} new=${newPaneId} name=${JSON.stringify(name)}\n`,
179
+ `[split] mode=next dir=${direction} from=${source} new=${newPaneId} name=${JSON.stringify(name)}`,
275
180
  );
276
181
  return newPaneId;
277
182
  }
278
- herdrLog(`[herdr split] next split returned no pane id, output=${JSON.stringify(output)}\n`);
183
+ herdrLog(`[split] next split returned no pane id, output=${JSON.stringify(output)}`);
279
184
  return "";
280
- } finally {
281
- try {
282
- rmSync(lockFile);
283
- } catch {
284
- /* ignore */
285
- }
286
- }
185
+ });
287
186
  }
288
187
 
289
188
  /**
@@ -302,11 +201,8 @@ export function splitHerdrPane(
302
201
  if (!newPaneId) {
303
202
  throw new Error(`Unexpected herdr pane split output: ${output.trim() || "(empty)"}`);
304
203
  }
305
- herdrPaneSources.set(newPaneId, fromPane);
306
204
  if (name) renameHerdrPane(newPaneId, name);
307
- herdrLog(
308
- `[herdr split] mode=direct dir=${dir} from=${fromPane} new=${newPaneId} name=${JSON.stringify(name ?? "")}\n`,
309
- );
205
+ herdrLog(`[split] mode=direct dir=${dir} from=${fromPane} new=${newPaneId} name=${JSON.stringify(name ?? "")}`);
310
206
  return newPaneId;
311
207
  }
312
208
 
@@ -320,7 +216,7 @@ export function renameHerdrPane(paneId: string, name: string): void {
320
216
  const paneLabel = wsLabel ? `${wsLabel}[${name}]` : name;
321
217
  herdrExecSilent(["pane", "rename", paneId, paneLabel]);
322
218
  } catch (e) {
323
- herdrLog(`[herdr rename pane] pane=${paneId} name=${JSON.stringify(name)} failed: ${(e as Error).message}\n`);
219
+ herdrLog(`[rename pane] pane=${paneId} name=${JSON.stringify(name)} failed: ${(e as Error).message}`);
324
220
  }
325
221
  }
326
222
 
@@ -333,7 +229,7 @@ export function renameHerdrAgent(paneId: string, name: string): void {
333
229
  try {
334
230
  herdrExecSilent(["agent", "rename", paneId, name]);
335
231
  } catch (e) {
336
- herdrLog(`[herdr rename agent] pane=${paneId} name=${JSON.stringify(name)} failed: ${(e as Error).message}\n`);
232
+ herdrLog(`[rename agent] pane=${paneId} name=${JSON.stringify(name)} failed: ${(e as Error).message}`);
337
233
  }
338
234
  }
339
235
 
@@ -376,7 +272,7 @@ export function renameHerdrTab(paneId: string, name: string): void {
376
272
  }
377
273
  }
378
274
  } catch (e) {
379
- herdrLog(`[herdr rename tab] pane=${paneId} name=${JSON.stringify(name)} failed: ${(e as Error).message}\n`);
275
+ herdrLog(`[rename tab] pane=${paneId} name=${JSON.stringify(name)} failed: ${(e as Error).message}`);
380
276
  }
381
277
  }
382
278
 
@@ -390,7 +286,7 @@ export function renameHerdrWorkspace(title: string): void {
390
286
  try {
391
287
  herdrExecSilent(["workspace", "rename", AGENT_HERDR_WORKSPACE_ID, title]);
392
288
  } catch (e) {
393
- herdrLog(`[herdr rename workspace] title=${JSON.stringify(title)} failed: ${(e as Error).message}\n`);
289
+ herdrLog(`[rename workspace] title=${JSON.stringify(title)} failed: ${(e as Error).message}`);
394
290
  }
395
291
  }
396
292
 
@@ -435,38 +331,16 @@ export function readHerdrScreen(paneId: string, lines = 50, source: "visible" |
435
331
  export function closeHerdrSurface(paneId: string): void {
436
332
  herdrExecSilent(["pane", "close", paneId]);
437
333
 
438
- // 清理 mux state marker(与 muxy 的 close 逻辑一致)
439
- const markerFile = `/tmp/herdr-subagent-pane-${(AGENT_HERDR_PANE_ID ?? "default").replace(/[^a-zA-Z0-9_-]/g, "_")}.json`;
440
- try {
441
- const parsed = JSON.parse(readFileSync(markerFile, "utf8"));
442
- if (parsed && Array.isArray(parsed.panes)) {
443
- const idx = parsed.panes.indexOf(paneId);
444
- if (idx >= 0) {
445
- const beforePanes = [...parsed.panes];
446
- const beforePos = parsed.pos;
447
- parsed.panes.splice(idx, 1);
448
- if (typeof parsed.pos === "number" && idx < parsed.pos) {
449
- parsed.pos = Math.max(0, parsed.pos - 1);
450
- }
451
- if (parsed.panes.length === 0) {
452
- rmSync(markerFile);
453
- herdrLog(
454
- `[herdr close] pane=${paneId} panes=${JSON.stringify(beforePanes)} -> [] pos=${beforePos} -> <marker-removed>\n`,
455
- );
456
- } else {
457
- writeFileSync(markerFile, JSON.stringify(parsed));
458
- herdrLog(
459
- `[herdr close] pane=${paneId} panes=${JSON.stringify(beforePanes)} -> ${JSON.stringify(parsed.panes)} pos=${beforePos} -> ${parsed.pos}\n`,
460
- );
461
- }
462
- } else {
463
- herdrLog(`[herdr close] pane=${paneId} (not in marker state.panes)\n`);
464
- }
465
- }
466
- } catch {
467
- herdrLog(`[herdr close] pane=${paneId} (no marker, nothing to clean)\n`);
334
+ // BFS 状态移除已关闭的 subagent,避免僵尸 ID 累积
335
+ const state = new BfsSplitStateManager(herdrMarkerPath());
336
+ const beforePanes = state.panes();
337
+ state.remove(paneId);
338
+ const afterPanes = state.panes();
339
+ if (beforePanes.length !== afterPanes.length) {
340
+ herdrLog(`[close] pane=${paneId} panes=${JSON.stringify(beforePanes)} -> ${JSON.stringify(afterPanes)}`);
341
+ } else {
342
+ herdrLog(`[close] pane=${paneId} (not in marker state.panes)`);
468
343
  }
469
- herdrPaneSources.delete(paneId);
470
344
  }
471
345
 
472
346
  // ── 辅助:从 pane id 解析 workspace id ──
@@ -530,4 +404,4 @@ export const ops: BackendOps = {
530
404
  rename(surface: string, name: string): void {
531
405
  renameHerdrPane(surface, name);
532
406
  },
533
- };
407
+ };