pi-terminal-mux 0.2.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/src/herdr.ts ADDED
@@ -0,0 +1,499 @@
1
+ /**
2
+ * herdr.ts — herdr multiplexer backend for pi-interactive-subagents
3
+ *
4
+ * herdr 是一个终端原生 agent multiplexer(参见 https://herdr.dev)。
5
+ * 当 pi 在 herdr 管理的 pane 内运行时,herdr 会注入:
6
+ * HERDR_ENV=1
7
+ * HERDR_WORKSPACE_ID(公开 id,如 "1")
8
+ * HERDR_TAB_ID(公开 id,如 "1:1")
9
+ * HERDR_PANE_ID(公开 id,如 "1-1")
10
+ *
11
+ * 所有 pane 操作通过 `herdr` CLI 完成,详见 SKILL.md。
12
+ */
13
+
14
+ import { execFileSync, execSync, spawnSync } from "node:child_process";
15
+ import { appendFileSync, existsSync, readFileSync, writeFileSync, rmSync } from "node:fs";
16
+ import { i18n } from "./i18n.ts";
17
+
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
+ }
27
+
28
+ /**
29
+ * 捕获于模块加载时的 agent pane id。
30
+ * herdr 在启动 pane 的子进程时注入 HERDR_PANE_ID(公开 id 格式,如 "1-1")。
31
+ * 模块加载后再读取 env 可能反映用户切换焦点后的值,所以冻结到常量。
32
+ */
33
+ export const AGENT_HERDR_PANE_ID = process.env.HERDR_PANE_ID;
34
+ export const AGENT_HERDR_WORKSPACE_ID = process.env.HERDR_WORKSPACE_ID;
35
+ export const AGENT_HERDR_TAB_ID = process.env.HERDR_TAB_ID;
36
+
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
+ /**
59
+ * 检测 herdr backend 是否可用:
60
+ * 1. `herdr` 命令在 PATH 中
61
+ * 2. 当前进程在 herdr pane 内运行(HERDR_ENV=1)
62
+ * 3. HERDR_PANE_ID 已注入
63
+ *
64
+ * 注意:即使 socket 暂时不通,只要命令存在且 env 注入,就认为"runtime available"。
65
+ * 子 agent 创建时会用 `herdr pane split` 触发 socket 调用,那时报错即可。
66
+ */
67
+ export function isHerdrRuntimeAvailable(): boolean {
68
+ return (
69
+ !!process.env.HERDR_ENV &&
70
+ process.env.HERDR_ENV === "1" &&
71
+ !!process.env.HERDR_PANE_ID &&
72
+ hasCommand("herdr")
73
+ );
74
+ }
75
+
76
+ // ── herdr CLI 调用的薄封装 ──
77
+
78
+ /**
79
+ * 调用 `herdr` 命令并返回 stdout。
80
+ * 失败时 stderr 写入 log,原样抛错(调用方决定如何处理)。
81
+ */
82
+ function herdrExec(args: string[]): string {
83
+ herdrLog(`[herdr exec] herdr ${args.map((a) => (a.includes(" ") ? JSON.stringify(a) : a)).join(" ")}\n`);
84
+ const out = execFileSync("herdr", args, { encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] });
85
+ herdrLog(`[herdr exec] -> ${JSON.stringify(out.trim().slice(0, 200))}\n`);
86
+ return out;
87
+ }
88
+
89
+ /**
90
+ * 调用 `herdr` 命令,丢弃 stdout。用于 sendCommand / sendKeys / closePane 这类
91
+ * 无输出的命令,遵循 SKILL.md 中"pane send-text/send-keys/run print nothing on success"。
92
+ */
93
+ function herdrExecSilent(args: string[]): void {
94
+ herdrLog(`[herdr exec silent] herdr ${args.map((a) => (a.includes(" ") ? JSON.stringify(a) : a)).join(" ")}\n`);
95
+ execFileSync("herdr", args, { encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] });
96
+ }
97
+
98
+ /**
99
+ * 解析 herdr JSON 输出,失败返回 null。
100
+ * SKILL.md 说明:`workspace list`、`tab create`、`pane split` 等成功命令打印 JSON。
101
+ */
102
+ function parseHerdrJson(output: string): unknown {
103
+ const trimmed = output.trim();
104
+ if (!trimmed) return null;
105
+ try {
106
+ return JSON.parse(trimmed);
107
+ } catch {
108
+ return null;
109
+ }
110
+ }
111
+
112
+ /**
113
+ * 从 herdr JSON 响应中提取 pane 的公开 id。
114
+ * pane split 响应格式:`{ "id": "...", "result": { "type": "pane_info", "pane": { "pane_id": "1-2", ... } } }`
115
+ */
116
+ function extractPaneId(json: unknown): string | null {
117
+ if (!json || typeof json !== "object") return null;
118
+ const obj = json as Record<string, unknown>;
119
+ const result = obj.result;
120
+ if (!result || typeof result !== "object") return null;
121
+ const r = result as Record<string, unknown>;
122
+ // 多种结果类型都包含 pane:pane_info、pane_created 等
123
+ const pane = (r.pane ?? r.root_pane) as Record<string, unknown> | undefined;
124
+ if (pane && typeof pane.pane_id === "string") return pane.pane_id;
125
+ return null;
126
+ }
127
+
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>();
134
+
135
+ // ── 对外 API:createSurface 系列 ──
136
+
137
+ /**
138
+ * 创建一个新的 subagent pane。
139
+ *
140
+ * 实现:split 当前 agent pane 右侧(--no-focus 保持 agent 焦点不变)。
141
+ * 后续 subagent 按 breadth-first 模式轮转 right/down/right/down…(与 cmux/muxy 行为一致)。
142
+ */
143
+ export function createHerdrSurface(name: string): string {
144
+ if (!AGENT_HERDR_PANE_ID) {
145
+ throw new Error(
146
+ "HERDR_PANE_ID not set; cannot determine parent pane for subagent split. " +
147
+ "Start pi inside herdr so HERDR_PANE_ID is injected at launch.",
148
+ );
149
+ }
150
+
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
+ })();
175
+
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
+ }
193
+
194
+ // 首次 split
195
+ if (state.panes.length === 0) {
196
+ const output = herdrExec([
197
+ "pane",
198
+ "split",
199
+ AGENT_HERDR_PANE_ID,
200
+ "--direction",
201
+ "right",
202
+ "--no-focus",
203
+ ]);
204
+ const json = parseHerdrJson(output);
205
+ const newPaneId = extractPaneId(json);
206
+ 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);
213
+ renameHerdrPane(newPaneId, name);
214
+ herdrLog(
215
+ `[herdr split] mode=first dir=right from=${AGENT_HERDR_PANE_ID} new=${newPaneId} name=${JSON.stringify(name)}\n`,
216
+ );
217
+ return newPaneId;
218
+ }
219
+ herdrLog(`[herdr split] first split returned no pane id, output=${JSON.stringify(output)}\n`);
220
+ return "";
221
+ }
222
+
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
+ }
229
+
230
+ let targetPane = state.panes[state.pos];
231
+ if (!targetPane) {
232
+ herdrLog(`[herdr split] state.panes[${state.pos}] is undefined\n`);
233
+ return "";
234
+ }
235
+
236
+ // 若 targetPane 过期(pane 被关闭 / session 重启),自动重置状态从 agent pane 重新分屏
237
+ let output: string;
238
+ let sourcePane = targetPane;
239
+ try {
240
+ output = herdrExec([
241
+ "pane",
242
+ "split",
243
+ targetPane,
244
+ "--direction",
245
+ state.dir,
246
+ "--no-focus",
247
+ ]);
248
+ } catch {
249
+ 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
+ ]);
264
+ }
265
+ const json = parseHerdrJson(output);
266
+ const newPaneId = extractPaneId(json);
267
+ if (newPaneId) {
268
+ state.panes.push(newPaneId);
269
+ state.pos++;
270
+ writeFileSync(markerFile, JSON.stringify(state));
271
+ herdrPaneSources.set(newPaneId, sourcePane);
272
+ renameHerdrPane(newPaneId, name);
273
+ 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`,
275
+ );
276
+ return newPaneId;
277
+ }
278
+ herdrLog(`[herdr split] next split returned no pane id, output=${JSON.stringify(output)}\n`);
279
+ return "";
280
+ } finally {
281
+ try {
282
+ rmSync(lockFile);
283
+ } catch {
284
+ /* ignore */
285
+ }
286
+ }
287
+ }
288
+
289
+ /**
290
+ * 从指定 pane 直接分屏(不走广度优先状态机),供 createSurfaceSplit 使用。
291
+ * herdr 文档仅明确 right/down,left/up 分别归一到 right/down。
292
+ * 返回新 pane 的公开 id;识别失败时抛错。
293
+ */
294
+ export function splitHerdrPane(
295
+ fromPane: string,
296
+ direction: "left" | "right" | "up" | "down",
297
+ name?: string,
298
+ ): string {
299
+ const dir = direction === "down" || direction === "up" ? "down" : "right";
300
+ const output = herdrExec(["pane", "split", fromPane, "--direction", dir, "--no-focus"]);
301
+ const newPaneId = extractPaneId(parseHerdrJson(output));
302
+ if (!newPaneId) {
303
+ throw new Error(`Unexpected herdr pane split output: ${output.trim() || "(empty)"}`);
304
+ }
305
+ herdrPaneSources.set(newPaneId, fromPane);
306
+ if (name) renameHerdrPane(newPaneId, name);
307
+ herdrLog(
308
+ `[herdr split] mode=direct dir=${dir} from=${fromPane} new=${newPaneId} name=${JSON.stringify(name ?? "")}\n`,
309
+ );
310
+ return newPaneId;
311
+ }
312
+
313
+ /**
314
+ * 用 herdr CLI 重命名 pane 的 label(pane 名称)。
315
+ * 格式: workspace_label[name]
316
+ */
317
+ export function renameHerdrPane(paneId: string, name: string): void {
318
+ try {
319
+ const wsLabel = getWorkspaceLabel();
320
+ const paneLabel = wsLabel ? `${wsLabel}[${name}]` : name;
321
+ herdrExecSilent(["pane", "rename", paneId, paneLabel]);
322
+ } catch (e) {
323
+ herdrLog(`[herdr rename pane] pane=${paneId} name=${JSON.stringify(name)} failed: ${(e as Error).message}\n`);
324
+ }
325
+ }
326
+
327
+ /**
328
+ * 用 herdr CLI 重命名 agent 标题(左侧侧栏显示的名字)。
329
+ * 需要在 pi 启动并被 herdr 检测到 agent 后才能生效。
330
+ * 若 agent 尚未检测到则静默失败。
331
+ */
332
+ export function renameHerdrAgent(paneId: string, name: string): void {
333
+ try {
334
+ herdrExecSilent(["agent", "rename", paneId, name]);
335
+ } catch (e) {
336
+ herdrLog(`[herdr rename agent] pane=${paneId} name=${JSON.stringify(name)} failed: ${(e as Error).message}\n`);
337
+ }
338
+ }
339
+
340
+ /**
341
+ * 获取当前 workspace 的 label。
342
+ */
343
+ function getWorkspaceLabel(): string | null {
344
+ if (!AGENT_HERDR_WORKSPACE_ID) return null;
345
+ try {
346
+ const output = herdrExec(["workspace", "get", AGENT_HERDR_WORKSPACE_ID]);
347
+ const parsed = parseHerdrJson(output);
348
+ if (!parsed || typeof parsed !== "object") return null;
349
+ const result = (parsed as Record<string, unknown>).result as Record<string, unknown> | undefined;
350
+ const workspace = result?.workspace as Record<string, unknown> | undefined;
351
+ return (workspace?.label as string) ?? null;
352
+ } catch {
353
+ return null;
354
+ }
355
+ }
356
+
357
+ /**
358
+ * 用 herdr CLI 重命名 pane 对应的 tab。
359
+ * tab label 格式: workspace_label[name]
360
+ */
361
+ export function renameHerdrTab(paneId: string, name: string): void {
362
+ const ws = parseWorkspaceIdFromPaneId(paneId);
363
+ if (!ws) return;
364
+ try {
365
+ const wsLabel = getWorkspaceLabel();
366
+ const tabLabel = wsLabel ? `${wsLabel}[${name}]` : name;
367
+ const tabsJson = herdrExec(["tab", "list", "--workspace", ws]);
368
+ const parsed = parseHerdrJson(tabsJson);
369
+ if (!parsed || typeof parsed !== "object") return;
370
+ const result = (parsed as Record<string, unknown>).result as Record<string, unknown> | undefined;
371
+ const tabs = (result?.tabs as Array<Record<string, unknown>>) ?? [];
372
+ if (tabs.length > 0) {
373
+ const firstTab = tabs[0];
374
+ if (firstTab && typeof firstTab.tab_id === "string") {
375
+ herdrExecSilent(["tab", "rename", firstTab.tab_id, tabLabel]);
376
+ }
377
+ }
378
+ } catch (e) {
379
+ herdrLog(`[herdr rename tab] pane=${paneId} name=${JSON.stringify(name)} failed: ${(e as Error).message}\n`);
380
+ }
381
+ }
382
+
383
+ /**
384
+ * 重命名 workspace。herdr 中 workspace rename 命令是 `herdr workspace rename <id> <label>`。
385
+ * 仅当环境变量 PI_SUBAGENT_RENAME_HERDR_WORKSPACE=1 时启用(保守策略,避免影响用户命名)。
386
+ */
387
+ export function renameHerdrWorkspace(title: string): void {
388
+ if (process.env.PI_SUBAGENT_RENAME_HERDR_WORKSPACE !== "1") return;
389
+ if (!AGENT_HERDR_WORKSPACE_ID) return;
390
+ try {
391
+ herdrExecSilent(["workspace", "rename", AGENT_HERDR_WORKSPACE_ID, title]);
392
+ } catch (e) {
393
+ herdrLog(`[herdr rename workspace] title=${JSON.stringify(title)} failed: ${(e as Error).message}\n`);
394
+ }
395
+ }
396
+
397
+ // ── 对外 API:pane 操作 ──
398
+
399
+ /**
400
+ * 给 pane 发送命令 + Enter。
401
+ * 使用 `herdr pane run <id> <cmd>` 一条命令搞定(SKILL.md 保证会发真实 Enter)。
402
+ */
403
+ export function sendHerdrCommand(paneId: string, command: string): void {
404
+ herdrExecSilent(["pane", "run", paneId, command]);
405
+ }
406
+
407
+ /**
408
+ * 给 pane 发送 Escape。
409
+ * `herdr pane send-keys <id> Escape`(SKILL.md 中 send-keys 接受 "Escape" 这种 key name)。
410
+ */
411
+ export function sendHerdrEscape(paneId: string): void {
412
+ herdrExecSilent(["pane", "send-keys", paneId, "Escape"]);
413
+ }
414
+
415
+ /**
416
+ * 读取 pane 屏幕内容。
417
+ * SKILL.md:`herdr pane read <id> --source <src> --lines N` 直接打印文本(非 JSON)。
418
+ *
419
+ * 默认 source 用 `visible`,与其他 backend (cmux / wezterm) 的 readScreen 语义一致:
420
+ * 读当前 viewport,新 pane 没 scrollback 时不会返回空。
421
+ *
422
+ * wait output 机制如果需要 recent_unwrapped 语义,请另行包装 — 这里只服务 subagent
423
+ * 状态检测和实时读屏,不需要 soft-wrap 合并。
424
+ */
425
+ export function readHerdrScreen(paneId: string, lines = 50, source: "visible" | "recent" | "recent_unwrapped" = "visible"): string {
426
+ // SKILL.md 列出的 source 选项
427
+ const sourceFlag = source === "recent_unwrapped" ? "recent-unwrapped" : source;
428
+ return herdrExec(["pane", "read", paneId, "--source", sourceFlag, "--lines", String(lines)]);
429
+ }
430
+
431
+ /**
432
+ * 关闭 pane。
433
+ * `herdr pane close <id>` 是 herdr CLI 子命令。
434
+ */
435
+ export function closeHerdrSurface(paneId: string): void {
436
+ herdrExecSilent(["pane", "close", paneId]);
437
+
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`);
468
+ }
469
+ herdrPaneSources.delete(paneId);
470
+ }
471
+
472
+ // ── 辅助:从 pane id 解析 workspace id ──
473
+ //
474
+ // herdr 公开 id 格式:
475
+ // workspace: "1", "2" 或 "wA"
476
+ // tab: "1:1", "wA:t1"
477
+ // pane: "1-1", "wA-3", "wA:p3"
478
+ //
479
+ // pane id 的 workspace 段总是位于 "-" 或 ":p" 之前。
480
+ function parseWorkspaceIdFromPaneId(paneId: string): string | null {
481
+ // "1-1" -> "1", "wA-3" -> "wA", "wA:p3" -> "wA"
482
+ const dashMatch = paneId.match(/^([^-:]+)-/);
483
+ if (dashMatch) return dashMatch[1] ?? null;
484
+ const colonMatch = paneId.match(/^([^:]+):p/);
485
+ if (colonMatch) return colonMatch[1] ?? null;
486
+ return null;
487
+ }
488
+
489
+ // ── 与 mux 检测 / setup hint 的集成辅助 ──
490
+
491
+ /**
492
+ * herdr setup hint —— 用户没在 herdr pane 内时提示。
493
+ */
494
+ export function herdrSetupHint(preferred: boolean): string {
495
+ if (preferred) {
496
+ return i18n.t("setupHint.herdrPreferred");
497
+ }
498
+ return "";
499
+ }
package/src/i18n.ts ADDED
@@ -0,0 +1,3 @@
1
+ import { createTranslator, loadCatalog } from "pi-extensions-i18n";
2
+
3
+ export const i18n = createTranslator(loadCatalog(new URL("../locales/mux.json", import.meta.url)));
package/src/index.ts ADDED
@@ -0,0 +1,111 @@
1
+ /**
2
+ * pi-terminal-mux — 终端多路复用器统一抽象层
3
+ *
4
+ * 支持后端:muxy / cmux / tmux / zellij / wezterm / herdr / otty,
5
+ * 探测不到任何后端时自动降级为 headless(后台子进程 + 日志文件)。
6
+ *
7
+ * 统一 surface API(跨后端一致语义):
8
+ * - createSurface(name) 智能放置(分屏/堆叠/新 tab,按后端策略)
9
+ * - createSurfaceSplit(name, dir, from?) 指定方向分屏
10
+ * - sendCommand / sendLongCommand / sendEscape
11
+ * - readScreen / readScreenAsync
12
+ * - closeSurface
13
+ * - renameCurrentTab / renameAgent / renameWorkspace
14
+ * - pollForExit 等待 surface 内进程退出(.exit sidecar / sentinel)
15
+ *
16
+ * 后端探测:
17
+ * - getMuxBackend() 当前命中的后端(含 PI_TERMINAL_MUX / PI_SUBAGENT_MUX 偏好)
18
+ * - isMuxAvailable() / isHeadlessMode()
19
+ * - muxSetupHint() 面向用户的安装提示(中英文,走 pi-extensions-i18n)
20
+ *
21
+ * 各后端原生函数(createHerdrSurface、sendOttyCommand 等)也可按需直接引用。
22
+ */
23
+
24
+ // ── 统一抽象层(含类型与 headless 降级) ──
25
+ export * from "./mux.ts";
26
+
27
+ // ── herdr 后端原生 API(renameHerdrTab / renameHerdrWorkspace 已由 mux.ts 透出,避免冲突) ──
28
+ export {
29
+ AGENT_HERDR_PANE_ID,
30
+ AGENT_HERDR_WORKSPACE_ID,
31
+ AGENT_HERDR_TAB_ID,
32
+ isHerdrRuntimeAvailable,
33
+ createHerdrSurface,
34
+ splitHerdrPane,
35
+ renameHerdrPane,
36
+ renameHerdrAgent,
37
+ sendHerdrCommand,
38
+ sendHerdrEscape,
39
+ readHerdrScreen,
40
+ closeHerdrSurface,
41
+ herdrSetupHint,
42
+ } from "./herdr.ts";
43
+
44
+ // ── otty 后端原生 API ──
45
+ export {
46
+ AGENT_OTTY_PANE_ID,
47
+ getOttyAgentPaneId,
48
+ isOttyRuntimeAvailable,
49
+ isOttySendKeysEnabled,
50
+ parseOttyJson,
51
+ readOttyPanes,
52
+ readOttyTabs,
53
+ getTabIdForPane,
54
+ createOttySurface,
55
+ sendOttyCommand,
56
+ sendOttyEscape,
57
+ readOttyScreen,
58
+ closeOttySurface,
59
+ renameOttyTab,
60
+ ottySetupHint,
61
+ } from "./otty.ts";
62
+ export type { OttyPaneSnapshot } from "./otty.ts";
63
+
64
+ // ── 便捷函数 ──
65
+ import {
66
+ AGENT_MUXY_PANE_ID,
67
+ getMuxBackend,
68
+ type MuxBackend,
69
+ } from "./mux.ts";
70
+ import { AGENT_HERDR_PANE_ID } from "./herdr.ts";
71
+ import { AGENT_OTTY_PANE_ID } from "./otty.ts";
72
+
73
+ /**
74
+ * 返回各后端注入 agent pane 标识的环境变量名(用于错误提示)。
75
+ * otty 不通过 env 注入(走 IPC 探测),返回 null。
76
+ */
77
+ export function backendAgentPaneEnvVar(backend: MuxBackend): string | null {
78
+ switch (backend) {
79
+ case "muxy":
80
+ return "MUXY_PANE_ID";
81
+ case "cmux":
82
+ return "CMUX_SURFACE_ID";
83
+ case "tmux":
84
+ return "TMUX_PANE";
85
+ case "zellij":
86
+ return "ZELLIJ_PANE_ID";
87
+ case "wezterm":
88
+ return "WEZTERM_PANE";
89
+ case "herdr":
90
+ return "HERDR_PANE_ID";
91
+ case "otty":
92
+ return null;
93
+ }
94
+ }
95
+
96
+ /**
97
+ * 返回 agent 自身所在 pane 的标识(按当前或指定后端)。
98
+ * muxy/herdr/otty 使用模块加载时捕获的 ID(不受焦点切换影响),
99
+ * 其余后端动态读取对应环境变量。
100
+ */
101
+ export function getAgentPaneId(backend?: MuxBackend | null): string | null {
102
+ const resolved = backend ?? getMuxBackend();
103
+ if (resolved === "muxy") return AGENT_MUXY_PANE_ID ?? null;
104
+ if (resolved === "herdr") return AGENT_HERDR_PANE_ID ?? null;
105
+ if (resolved === "otty") return AGENT_OTTY_PANE_ID ?? null;
106
+ if (resolved === "tmux") return process.env.TMUX_PANE ?? null;
107
+ if (resolved === "wezterm") return process.env.WEZTERM_PANE ?? null;
108
+ if (resolved === "zellij") return process.env.ZELLIJ_PANE_ID ?? null;
109
+ if (resolved === "cmux") return process.env.CMUX_SURFACE_ID ?? null;
110
+ return null;
111
+ }