pi-terminal-mux 0.2.0 → 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,11 +1,12 @@
1
1
  {
2
2
  "name": "pi-terminal-mux",
3
- "version": "0.2.0",
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",
7
7
  "exports": {
8
8
  ".": "./index.ts",
9
+ "./package.json": "./package.json",
9
10
  "./mux": "./src/mux.ts",
10
11
  "./herdr": "./src/herdr.ts",
11
12
  "./otty": "./src/otty.ts"
@@ -51,12 +52,11 @@
51
52
  "wezterm",
52
53
  "coding-agent"
53
54
  ],
54
- "peerDependencies": {
55
- "pi-extensions-i18n": ">=0.2.0"
55
+ "dependencies": {
56
+ "pi-extensions-i18n": "^0.3.1"
56
57
  },
57
58
  "devDependencies": {
58
59
  "@types/node": "24.12.4",
59
- "pi-extensions-i18n": "^0.3.0",
60
60
  "tsx": "4.23.1",
61
61
  "typescript": "5.9.3"
62
62
  }
@@ -0,0 +1,350 @@
1
+ /**
2
+ * backends/cmux.ts — Cmux 终端后端
3
+ *
4
+ * 包含 Cmux 特定的 surface 创建(子 agent pane 复用)、命令发送、
5
+ * 屏幕读写、焦点恢复、关闭与重命名。
6
+ * parseCmux* 纯函数作为公开 API 从此文件导出。
7
+ */
8
+
9
+ import { execSync, execFileSync, spawnSync } from "node:child_process";
10
+ import { shellEscape } from "../shell.ts";
11
+ import { createBackendLogger } from "./shared.ts";
12
+ import type { BackendOps } from "./types.ts";
13
+
14
+ /** Cmux 后端日志(统一格式,写入 /tmp/pi-mux-cmux.log) */
15
+ const cmuxLog = createBackendLogger("cmux", "/tmp/pi-mux-cmux.log");
16
+
17
+ // ── 内部辅助 ──
18
+
19
+ /** Tracked subagent pane for cmux — reused across subagent launches. */
20
+ let cmuxSubagentPane: string | null = null;
21
+
22
+ type CmuxFocusSnapshot = {
23
+ surfaceRef?: string;
24
+ paneRef?: string;
25
+ };
26
+
27
+ type CmuxCreatedSurface = {
28
+ surface: string;
29
+ paneRef?: string;
30
+ };
31
+
32
+ type CmuxIdentifySnapshot = {
33
+ focused: CmuxFocusSnapshot | null;
34
+ caller: CmuxFocusSnapshot | null;
35
+ };
36
+
37
+ /** 类型守卫:非空字符串 */
38
+ function nonEmptyString(value: unknown): value is string {
39
+ return typeof value === "string" && value.length > 0;
40
+ }
41
+
42
+ /** 执行 cmux 命令并返回 stdout,失败返回 null */
43
+ function readCmux(args: string[]): string | null {
44
+ const result = spawnSync("cmux", args, { encoding: "utf8" });
45
+ if (result.error || result.status !== 0 || !result.stdout.trim()) return null;
46
+ return result.stdout;
47
+ }
48
+
49
+ // ── parseCmux* 公开纯函数 ──
50
+
51
+ export function parseCmuxFocusedSnapshot(value: unknown): CmuxFocusSnapshot | null {
52
+ if (!value || typeof value !== "object") return null;
53
+
54
+ const focused = (value as { focused?: unknown }).focused;
55
+ if (!focused || typeof focused !== "object") return null;
56
+
57
+ const record = focused as { surface_ref?: unknown; pane_ref?: unknown };
58
+ const surfaceRef = nonEmptyString(record.surface_ref) ? record.surface_ref : undefined;
59
+ const paneRef = nonEmptyString(record.pane_ref) ? record.pane_ref : undefined;
60
+
61
+ if (!surfaceRef && !paneRef) return null;
62
+ return { surfaceRef, paneRef };
63
+ }
64
+
65
+ export function parseCmuxJson(value: string): unknown | null {
66
+ try {
67
+ return JSON.parse(value);
68
+ } catch (error) {
69
+ void error;
70
+ return null;
71
+ }
72
+ }
73
+
74
+ export function parseCmuxFocusedSnapshotFromJson(value: string): CmuxFocusSnapshot | null {
75
+ return parseCmuxFocusedSnapshot(parseCmuxJson(value));
76
+ }
77
+
78
+ function parseCmuxCallerSnapshot(value: unknown): CmuxFocusSnapshot | null {
79
+ if (!value || typeof value !== "object") return null;
80
+
81
+ const caller = (value as { caller?: unknown }).caller;
82
+ if (!caller || typeof caller !== "object") return null;
83
+
84
+ const record = caller as { surface_ref?: unknown; pane_ref?: unknown };
85
+ const surfaceRef = nonEmptyString(record.surface_ref) ? record.surface_ref : undefined;
86
+ const paneRef = nonEmptyString(record.pane_ref) ? record.pane_ref : undefined;
87
+
88
+ if (!surfaceRef && !paneRef) return null;
89
+ return { surfaceRef, paneRef };
90
+ }
91
+
92
+ export function parseCmuxPaneRefForSurface(value: unknown, surface: string): string | null {
93
+ if (!value || typeof value !== "object") return null;
94
+
95
+ const record = value as { surface_ref?: unknown; pane_ref?: unknown; caller?: unknown };
96
+ if (record.surface_ref === surface && nonEmptyString(record.pane_ref)) return record.pane_ref;
97
+
98
+ const caller = record.caller;
99
+ if (!caller || typeof caller !== "object") return null;
100
+
101
+ const callerRecord = caller as { surface_ref?: unknown; pane_ref?: unknown };
102
+ if (callerRecord.surface_ref === surface && nonEmptyString(callerRecord.pane_ref)) {
103
+ return callerRecord.pane_ref;
104
+ }
105
+
106
+ return null;
107
+ }
108
+
109
+ export function parseCmuxPaneRefForSurfaceFromJson(value: string, surface: string): string | null {
110
+ return parseCmuxPaneRefForSurface(parseCmuxJson(value), surface);
111
+ }
112
+
113
+ // ── Cmux 内部操作 ──
114
+
115
+ function parseCmuxIdentifySnapshot(value: string | null): CmuxIdentifySnapshot {
116
+ const parsed = value ? parseCmuxJson(value) : null;
117
+ return {
118
+ focused: parseCmuxFocusedSnapshot(parsed),
119
+ caller: parseCmuxCallerSnapshot(parsed),
120
+ };
121
+ }
122
+
123
+ function captureCmuxIdentifySnapshot(): CmuxIdentifySnapshot {
124
+ return parseCmuxIdentifySnapshot(readCmux(["identify", "--json"]));
125
+ }
126
+
127
+ function captureCmuxFocusSnapshot(): CmuxFocusSnapshot | null {
128
+ return captureCmuxIdentifySnapshot().focused;
129
+ }
130
+
131
+ function readCmuxPaneRefForSurface(surface: string): string | null {
132
+ const info = readCmux(["identify", "--surface", surface]);
133
+ return info ? parseCmuxPaneRefForSurfaceFromJson(info, surface) : null;
134
+ }
135
+
136
+ function restoreCmuxFocusSnapshot(snapshot: CmuxFocusSnapshot | null): void {
137
+ if (!snapshot) return;
138
+
139
+ if (snapshot.paneRef) {
140
+ spawnSync("cmux", ["focus-pane", "--pane", snapshot.paneRef], { encoding: "utf8" });
141
+ }
142
+
143
+ if (snapshot.surfaceRef) {
144
+ spawnSync("cmux", ["focus-panel", "--panel", snapshot.surfaceRef], { encoding: "utf8" });
145
+ }
146
+ }
147
+
148
+ /** 等待 cmux 焦点稳定(100ms) */
149
+ function waitForCmuxFocusSettle(): void {
150
+ Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 100);
151
+ }
152
+
153
+ function cmuxFocusMatchesChild(
154
+ currentFocus: CmuxFocusSnapshot | null,
155
+ child: CmuxCreatedSurface,
156
+ ): boolean {
157
+ if (!currentFocus) return false;
158
+ if (currentFocus.surfaceRef === child.surface) return true;
159
+ return !!currentFocus.paneRef && currentFocus.paneRef === child.paneRef;
160
+ }
161
+
162
+ function cmuxFocusMatchesSurfaceRef(
163
+ currentFocus: CmuxFocusSnapshot | null,
164
+ surfaceRef: string | undefined,
165
+ ): boolean {
166
+ return !!surfaceRef && currentFocus?.surfaceRef === surfaceRef;
167
+ }
168
+
169
+ function cmuxFocusMatchesPaneRef(
170
+ currentFocus: CmuxFocusSnapshot | null,
171
+ paneRef: string | undefined,
172
+ ): boolean {
173
+ return !!paneRef && currentFocus?.paneRef === paneRef;
174
+ }
175
+
176
+ function restoreCmuxFocusIfLaunchSurfaceFocused(
177
+ snapshot: CmuxFocusSnapshot | null,
178
+ child: CmuxCreatedSurface,
179
+ options?: { sourceSurfaceRef?: string; callerSnapshot?: CmuxFocusSnapshot | null },
180
+ ): void {
181
+ if (!snapshot) return;
182
+
183
+ waitForCmuxFocusSettle();
184
+ const currentFocus = captureCmuxFocusSnapshot();
185
+ if (
186
+ cmuxFocusMatchesChild(currentFocus, child) ||
187
+ cmuxFocusMatchesSurfaceRef(currentFocus, options?.sourceSurfaceRef) ||
188
+ cmuxFocusMatchesSurfaceRef(currentFocus, options?.callerSnapshot?.surfaceRef) ||
189
+ // cmux can settle focus onto another active surface in the caller pane after creating a split/surface.
190
+ cmuxFocusMatchesPaneRef(currentFocus, options?.callerSnapshot?.paneRef)
191
+ ) {
192
+ restoreCmuxFocusSnapshot(snapshot);
193
+ }
194
+ }
195
+
196
+ function parseCmuxCreatedSurface(output: string, command: string): CmuxCreatedSurface {
197
+ const surfaceMatch = output.match(/surface:\d+/);
198
+ if (!surfaceMatch) {
199
+ throw new Error(`Unexpected cmux ${command} output: ${output}`);
200
+ }
201
+
202
+ return {
203
+ surface: surfaceMatch[0],
204
+ paneRef: output.match(/pane:\d+/)?.[0],
205
+ };
206
+ }
207
+
208
+ function renameCmuxSurface(surface: string, name: string): void {
209
+ execFileSync("cmux", ["rename-tab", "--surface", surface, name], { encoding: "utf8" });
210
+ }
211
+
212
+ function createCmuxSplitSurface(
213
+ name: string,
214
+ direction: "left" | "right" | "up" | "down",
215
+ fromSurface?: string,
216
+ ): CmuxCreatedSurface {
217
+ const identifySnapshot = captureCmuxIdentifySnapshot();
218
+ const focusSnapshot = identifySnapshot.focused;
219
+ const callerSnapshot = identifySnapshot.caller;
220
+ let child: CmuxCreatedSurface | null = null;
221
+
222
+ try {
223
+ const args = ["new-split", direction];
224
+ if (fromSurface) args.push("--surface", fromSurface);
225
+
226
+ const output = execFileSync("cmux", args, { encoding: "utf8" }).trim();
227
+ child = parseCmuxCreatedSurface(output, "new-split");
228
+ child.paneRef ??= readCmuxPaneRefForSurface(child.surface) ?? undefined;
229
+ renameCmuxSurface(child.surface, name);
230
+ return child;
231
+ } finally {
232
+ if (child) {
233
+ restoreCmuxFocusIfLaunchSurfaceFocused(focusSnapshot, child, {
234
+ sourceSurfaceRef: fromSurface,
235
+ callerSnapshot,
236
+ });
237
+ } else {
238
+ restoreCmuxFocusSnapshot(focusSnapshot);
239
+ }
240
+ }
241
+ }
242
+
243
+ /**
244
+ * 在现有 cmux pane 中创建新 surface(tab)。
245
+ */
246
+ function createSurfaceInPane(name: string, pane: string): string {
247
+ const identifySnapshot = captureCmuxIdentifySnapshot();
248
+ const focusSnapshot = identifySnapshot.focused;
249
+ const callerSnapshot = identifySnapshot.caller;
250
+ let child: CmuxCreatedSurface | null = null;
251
+
252
+ try {
253
+ const output = execFileSync("cmux", ["new-surface", "--pane", pane], { encoding: "utf8" }).trim();
254
+ child = parseCmuxCreatedSurface(output, "new-surface");
255
+ child.paneRef ??= pane;
256
+ renameCmuxSurface(child.surface, name);
257
+ return child.surface;
258
+ } finally {
259
+ if (child) {
260
+ restoreCmuxFocusIfLaunchSurfaceFocused(focusSnapshot, child, {
261
+ callerSnapshot,
262
+ });
263
+ } else {
264
+ restoreCmuxFocusSnapshot(focusSnapshot);
265
+ }
266
+ }
267
+ }
268
+
269
+ // ── BackendOps ──
270
+
271
+ export const ops: BackendOps = {
272
+ create(name: string): string {
273
+ // 优先复用已有 subagent pane(在其内创建新 tab)
274
+ if (cmuxSubagentPane) {
275
+ try {
276
+ const tree = execSync(`cmux tree`, { encoding: "utf8" });
277
+ if (tree.includes(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;
283
+ }
284
+ } catch {}
285
+ // Pane 已消失 — fall through 创建新 split
286
+ cmuxSubagentPane = null;
287
+ }
288
+
289
+ const created = createCmuxSplitSurface(name, "right", process.env.CMUX_SURFACE_ID);
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
+ );
294
+ return created.surface;
295
+ },
296
+
297
+ createSplit(name: string, direction: "left" | "right" | "up" | "down", fromSurface?: string): string {
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;
303
+ },
304
+
305
+ send(surface: string, command: string): void {
306
+ execSync(`cmux send --surface ${shellEscape(surface)} ${shellEscape(command + "\n")}`, {
307
+ encoding: "utf8",
308
+ });
309
+ },
310
+
311
+ sendEscape(surface: string): void {
312
+ execFileSync("cmux", ["send", "--surface", surface, "\u001b"], { encoding: "utf8" });
313
+ },
314
+
315
+ read(surface: string, lines = 50): string {
316
+ return execSync(`cmux read-screen --surface ${shellEscape(surface)} --lines ${lines}`, {
317
+ encoding: "utf8",
318
+ });
319
+ },
320
+
321
+ async readAsync(surface: string, lines = 50): Promise<string> {
322
+ const { promisify } = await import("node:util");
323
+ const { execFile } = await import("node:child_process");
324
+ const { stdout } = await promisify(execFile)(
325
+ "cmux",
326
+ ["read-screen", "--surface", surface, "--lines", String(lines)],
327
+ { encoding: "utf8" },
328
+ );
329
+ return stdout;
330
+ },
331
+
332
+ close(surface: string): void {
333
+ execSync(`cmux close-surface --surface ${shellEscape(surface)}`, {
334
+ encoding: "utf8",
335
+ });
336
+ cmuxLog(`[close] surface=${surface}`);
337
+ },
338
+
339
+ rename(surface: string, name: string): void {
340
+ renameCmuxSurface(surface, name);
341
+ },
342
+ };
343
+
344
+ /**
345
+ * 获取 cmux 子 agent pane(供 surface.ts 设置 lastSplitSource)。
346
+ * 返回 null 表示尚无 subagent pane。
347
+ */
348
+ export function getCmuxSubagentPane(): string | null {
349
+ return cmuxSubagentPane;
350
+ }