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/otty.ts ADDED
@@ -0,0 +1,766 @@
1
+ /**
2
+ * otty.ts — Otty 终端模拟器 backend for pi-interactive-subagents
3
+ *
4
+ * Otty 是一个 macOS 终端模拟器(参见 https://otty.sh / https://docs.otty.sh)。
5
+ * 与 cmux / tmux / zellij / wezterm / herdr 类似,它通过 `otty` CLI 提供
6
+ * pane / tab / window 编程化控制能力。
7
+ *
8
+ * 与其他 backend 的关键差异:
9
+ * 1. Otty 不像 cmux 那样注入 `CMUX_SURFACE_ID`。当前 agent pane id 需要
10
+ * 通过 `otty panes --json` 查询 "active=true" 的 pane 来获取,并在
11
+ * 模块加载时冻结到常量。
12
+ * 2. `otty pane send-keys` 默认 disabled(需要 `ipc-allow-send-keys = true`)。
13
+ * backend 启动时会检测该开关,未启用时 setup hint 提示用户。
14
+ * 3. `otty pane close --pane <id>` 在 v1.0.4 行为不稳定(实测仅打印 "Pane split"
15
+ * 但 pane 列表未变)。close 走 best-effort 路径:先 close pane,失败/超时
16
+ * 时降级为 close tab,最后兜底为 log warn 不 throw —— 避免 pollForExit 退出
17
+ * 流程被 close 错误打断。
18
+ * 4. pane id 是字符串(如 `p_19eefc5b4a2_11`),不像 tmux 是 `%12`。
19
+ *
20
+ * Otty 关键 IPC 接口(参见 https://docs.otty.sh/reference/cli):
21
+ * - `otty panes --json` 列出所有 pane
22
+ * - `otty pane split --direction <dir> --pane <id> --no-focus --command <cmd> --title <name>`
23
+ * - `otty pane send-keys --pane <id> -- "..." key:Enter`
24
+ * - `otty pane send-keys --pane <id> -- key:Escape`
25
+ * - `otty pane capture --pane <id> --lines <N>`
26
+ * - `otty pane close --pane <id> [--force]`
27
+ * - `otty pane focus <id>`
28
+ * - `otty tab list --json` 列出所有 tab
29
+ * - `otty tab rename --tab <tab_id> <title>`
30
+ * - `otty tab close <tab_id>`
31
+ */
32
+
33
+ import {
34
+ execFileSync,
35
+ execSync,
36
+ spawnSync,
37
+ } from "node:child_process";
38
+ import {
39
+ appendFileSync,
40
+ existsSync,
41
+ readFileSync,
42
+ rmSync,
43
+ writeFileSync,
44
+ } from "node:fs";
45
+ import { tmpdir } from "node:os";
46
+ import { i18n } from "./i18n.ts";
47
+
48
+ // ── 日志(otty 独立文件,便于区分后端) ──
49
+ const OTTY_SPLIT_LOG = "/tmp/pi-otty-split.log";
50
+ function ottyLog(msg: string): void {
51
+ try {
52
+ appendFileSync(OTTY_SPLIT_LOG, `[${new Date().toISOString()}] ${msg}`);
53
+ } catch {
54
+ /* 写日志失败不影响主流程 */
55
+ }
56
+ }
57
+
58
+ // ── 命令可用性缓存 ──
59
+
60
+ const commandAvailability = new Map<string, boolean>();
61
+
62
+ function hasCommand(command: string): boolean {
63
+ if (commandAvailability.has(command)) {
64
+ return commandAvailability.get(command)!;
65
+ }
66
+ let available = false;
67
+ try {
68
+ execFileSync("which", [command], { stdio: "ignore" });
69
+ available = true;
70
+ } catch {
71
+ available = false;
72
+ }
73
+ commandAvailability.set(command, available);
74
+ return available;
75
+ }
76
+
77
+ // ── Otty 检测 ──
78
+
79
+ /**
80
+ * 检测 otty backend 是否可用:
81
+ * 1. `otty` 命令在 PATH 中
82
+ * 2. 当前进程在 Otty 终端内运行(TERM_PROGRAM=otty)
83
+ * 3. Otty 应用正在运行(`otty panes --json` 成功)
84
+ *
85
+ * 注意:cwd 不在 `/Applications/Otty.app` 内(这是 app bundle 路径,
86
+ * Otty CLI 不在那里执行),所以只看 env 与 CLI 可用性。
87
+ */
88
+ export function isOttyRuntimeAvailable(): boolean {
89
+ if (process.env.TERM_PROGRAM !== "otty") return false;
90
+ if (!hasCommand("otty")) return false;
91
+
92
+ // 最后一道闸:otty 命令存在但 app 没启动时 `otty panes` 会失败。
93
+ // 提前 200ms 超时探一下,避免后续每次调用都等满 3s。
94
+ try {
95
+ const result = spawnSync("otty", ["panes", "--json", "--timeout", "500"], {
96
+ encoding: "utf8",
97
+ stdio: ["ignore", "pipe", "pipe"],
98
+ });
99
+ if (result.error || result.status !== 0) return false;
100
+ return Boolean(result.stdout.trim());
101
+ } catch {
102
+ return false;
103
+ }
104
+ }
105
+
106
+ /**
107
+ * send-keys 在 otty 默认 disabled。
108
+ * 提前检测一次(缓存),避免每次 sendCommand 都试一遍并抛错。
109
+ */
110
+ let sendKeysEnabledCache: boolean | null = null;
111
+
112
+ export function isOttySendKeysEnabled(): boolean {
113
+ if (sendKeysEnabledCache !== null) return sendKeysEnabledCache;
114
+ try {
115
+ // 用一个无害的"noop"探针:给 agent 自己 pane 发一个不会执行的 key:End + Escape
116
+ // 序列的低成本命令,或者直接探测配置项。
117
+ // 优先用 ipc 命令问 otty 是否允许 send-keys,避免触发误判。
118
+ // 退路:直接试一次 `pane send-keys` 看错误信息。
119
+ const result = spawnSync(
120
+ "otty",
121
+ ["pane", "send-keys", "--pane", getOttyAgentPaneId(), "--", "key:End"],
122
+ { encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] },
123
+ );
124
+ const enabled = result.status === 0;
125
+ sendKeysEnabledCache = enabled;
126
+ if (!enabled) {
127
+ ottyLog(
128
+ `[otty detect] send-keys DISABLED stderr=${JSON.stringify(
129
+ (result.stderr ?? "").trim().slice(0, 200),
130
+ )}\n`,
131
+ );
132
+ }
133
+ return enabled;
134
+ } catch (e) {
135
+ sendKeysEnabledCache = false;
136
+ ottyLog(`[otty detect] send-keys probe failed: ${(e as Error).message}\n`);
137
+ return false;
138
+ }
139
+ }
140
+
141
+ // ── Agent pane id 缓存 ──
142
+
143
+ /**
144
+ * 捕获于模块加载时的 agent pane id。
145
+ * Otty 不像 cmux 那样注入 surface id;需要在启动时通过 `otty panes --json`
146
+ * 找 `active=true` 的 pane 并冻结到常量。模块加载后再查 env 可能反映
147
+ * 用户切换焦点后的值(虽然这里走的是 IPC 而非 env,但为了一致性也冻结)。
148
+ *
149
+ * 如果暂时拿不到(Otty 刚启动、panes 还没列出来),返回 null,
150
+ * 调用方在第一次 createSurface 时重试。
151
+ */
152
+ export const AGENT_OTTY_PANE_ID: string | null = (() => {
153
+ try {
154
+ const panes = readOttyPanes();
155
+ // active=true 是当前 focus pane。但用户启动 pi 后焦点可能在 pi pane,
156
+ // 也可能在另一个 pane —— 必须按 process 名称筛选。
157
+ const piPane = panes.find(
158
+ (p) =>
159
+ p.active &&
160
+ /(^|\s)(π|pi)($|\s|-)/i.test(p.process),
161
+ );
162
+ if (piPane) return piPane.id;
163
+ // 退路:取 active=true 的 pane(不严谨,但能跑)
164
+ const active = panes.find((p) => p.active);
165
+ return active?.id ?? null;
166
+ } catch (e) {
167
+ ottyLog(`[otty init] failed to capture agent pane id: ${(e as Error).message}\n`);
168
+ return null;
169
+ }
170
+ })();
171
+
172
+ /**
173
+ * 获取 agent pane id,必要时重试。
174
+ * 用于 AGENT_OTTY_PANE_ID 启动时为 null 的情况(panes 还没就绪),
175
+ * 以及 pane 失效后的 fallback。
176
+ */
177
+ export function getOttyAgentPaneId(): string {
178
+ if (AGENT_OTTY_PANE_ID && ottyPaneExists(AGENT_OTTY_PANE_ID)) {
179
+ return AGENT_OTTY_PANE_ID;
180
+ }
181
+ // 重试一次
182
+ const refreshed = (() => {
183
+ try {
184
+ const panes = readOttyPanes();
185
+ const piPane = panes.find(
186
+ (p) => p.active && /(^|\s)(π|pi)($|\s|-)/i.test(p.process),
187
+ );
188
+ if (piPane) return piPane.id;
189
+ return panes.find((p) => p.active)?.id ?? null;
190
+ } catch {
191
+ return null;
192
+ }
193
+ })();
194
+ if (refreshed) return refreshed;
195
+ throw new Error(
196
+ "Could not determine Otty agent pane id. " +
197
+ "Make sure Otty is running and `otty panes --json` returns a list.",
198
+ );
199
+ }
200
+
201
+ function ottyPaneExists(paneId: string): boolean {
202
+ try {
203
+ return readOttyPanes().some((p) => p.id === paneId);
204
+ } catch {
205
+ return false;
206
+ }
207
+ }
208
+
209
+ // ── Otty CLI 调用的薄封装 ──
210
+
211
+ /**
212
+ * 调用 `otty` 命令并返回 stdout。
213
+ * 失败时 stderr 写入 log,原样抛错(调用方决定如何处理)。
214
+ */
215
+ function ottyExec(args: string[]): string {
216
+ const cmdline = `otty ${args
217
+ .map((a) => (a.includes(" ") || a.includes('"') ? JSON.stringify(a) : a))
218
+ .join(" ")}`;
219
+ ottyLog(`[otty exec] ${cmdline}\n`);
220
+ const result = spawnSync("otty", args, {
221
+ encoding: "utf8",
222
+ stdio: ["ignore", "pipe", "pipe"],
223
+ });
224
+ if (result.error) {
225
+ ottyLog(`[otty exec] ERROR (spawn): ${result.error.message}\n`);
226
+ throw result.error;
227
+ }
228
+ if (result.status !== 0) {
229
+ const stderr = (result.stderr ?? "").trim();
230
+ ottyLog(`[otty exec] ERROR status=${result.status} stderr=${JSON.stringify(stderr)}\n`);
231
+ throw new Error(`otty ${args[0]} failed (status=${result.status}): ${stderr}`);
232
+ }
233
+ ottyLog(`[otty exec] -> ${JSON.stringify(result.stdout.trim().slice(0, 200))}\n`);
234
+ return result.stdout;
235
+ }
236
+
237
+ /**
238
+ * 调用 `otty` 命令,丢弃 stdout。用于 sendCommand / sendKeys / closePane 这类
239
+ * 无输出的命令。
240
+ */
241
+ function ottyExecSilent(args: string[]): void {
242
+ const cmdline = `otty ${args
243
+ .map((a) => (a.includes(" ") || a.includes('"') ? JSON.stringify(a) : a))
244
+ .join(" ")}`;
245
+ ottyLog(`[otty exec silent] ${cmdline}\n`);
246
+ const result = spawnSync("otty", args, {
247
+ encoding: "utf8",
248
+ stdio: ["ignore", "pipe", "pipe"],
249
+ });
250
+ if (result.error || result.status !== 0) {
251
+ ottyLog(
252
+ `[otty exec silent] ERROR status=${result.status} stderr=${JSON.stringify(
253
+ (result.stderr ?? "").trim().slice(0, 200),
254
+ )}\n`,
255
+ );
256
+ }
257
+ }
258
+
259
+ // ── Pane 数据结构 ──
260
+
261
+ export interface OttyPaneSnapshot {
262
+ id: string;
263
+ tab_id: string;
264
+ window_id: string;
265
+ index: number;
266
+ active: boolean;
267
+ cwd: string;
268
+ process: string;
269
+ cols: number;
270
+ rows: number;
271
+ }
272
+
273
+ interface OttyListResponse<T> {
274
+ ok: boolean;
275
+ command: string;
276
+ data: T;
277
+ }
278
+
279
+ export function parseOttyJson(output: string): unknown {
280
+ const trimmed = output.trim();
281
+ if (!trimmed) return null;
282
+ try {
283
+ return JSON.parse(trimmed);
284
+ } catch (e) {
285
+ ottyLog(`[otty parse json] failed: ${(e as Error).message} raw=${JSON.stringify(trimmed.slice(0, 200))}\n`);
286
+ return null;
287
+ }
288
+ }
289
+
290
+ export function readOttyPanes(): OttyPaneSnapshot[] {
291
+ const raw = ottyExec(["panes", "--json"]);
292
+ const parsed = parseOttyJson(raw) as OttyListResponse<OttyPaneSnapshot[]> | null;
293
+ if (!parsed?.ok || !Array.isArray(parsed.data)) return [];
294
+ return parsed.data;
295
+ }
296
+
297
+ export function readOttyTabs(): Array<Record<string, unknown>> {
298
+ try {
299
+ const raw = ottyExec(["tab", "list", "--json"]);
300
+ const parsed = parseOttyJson(raw) as
301
+ | OttyListResponse<Array<Record<string, unknown>>>
302
+ | null;
303
+ if (!parsed?.ok || !Array.isArray(parsed.data)) return [];
304
+ return parsed.data;
305
+ } catch (e) {
306
+ ottyLog(`[otty tabs] list failed: ${(e as Error).message}\n`);
307
+ return [];
308
+ }
309
+ }
310
+
311
+ /**
312
+ * 从 pane id 解析 tab id。
313
+ * 优先走 `panes --json` 反查(无需走 tab list),回退到 panes-by-id 查找。
314
+ */
315
+ export function getTabIdForPane(paneId: string): string | null {
316
+ try {
317
+ const pane = readOttyPanes().find((p) => p.id === paneId);
318
+ return pane?.tab_id ?? null;
319
+ } catch {
320
+ return null;
321
+ }
322
+ }
323
+
324
+ // ── mux state 持久化 ──
325
+ //
326
+ // 与 muxy / herdr 同样的广度优先分屏策略:
327
+ // panes: 所有 subagent pane id 列表
328
+ // pos: 本轮下一个要 split 的索引
329
+ // base: 本轮开始时 pane 总数(本轮要分 base 个)
330
+ // dir: 当前方向("right" | "down")
331
+ //
332
+ // 第一轮:从 agent pane 向右分(pos=0, base=1)
333
+ // 第二轮:从第一个 pane 向下分(pos=0, base=1)
334
+ // 第三轮:第一个 pane 向右、第二个 pane 向右(pos=0, base=2)
335
+ // ……
336
+ // 每来一个 subagent:从 panes[pos] 拆 → 新 pane 追加到列表尾
337
+ // pos 走完 base 个后 → 翻转方向,重置 pos,更新 base
338
+
339
+ interface OttySplitState {
340
+ panes: string[];
341
+ pos: number;
342
+ base: number;
343
+ dir: "right" | "down";
344
+ }
345
+
346
+ function ottyStateFile(): string {
347
+ const agentId = AGENT_OTTY_PANE_ID ?? "default";
348
+ const safe = agentId.replace(/[^a-zA-Z0-9_-]/g, "_");
349
+ return `${tmpdir()}/otty-subagent-pane-${safe}.json`;
350
+ }
351
+
352
+ function ottyStateLockFile(): string {
353
+ return `${ottyStateFile()}.lock`;
354
+ }
355
+
356
+ function readOttyState(): OttySplitState {
357
+ try {
358
+ return JSON.parse(readFileSync(ottyStateFile(), "utf8"));
359
+ } catch {
360
+ return { panes: [], pos: 0, base: 0, dir: "right" };
361
+ }
362
+ }
363
+
364
+ function writeOttyState(state: OttySplitState): void {
365
+ writeFileSync(ottyStateFile(), JSON.stringify(state));
366
+ }
367
+
368
+ function acquireOttyLock(timeoutMs = 3000): boolean {
369
+ const lock = ottyStateLockFile();
370
+ const start = Date.now();
371
+ while (Date.now() - start < timeoutMs) {
372
+ if (!existsSync(lock)) {
373
+ try {
374
+ writeFileSync(lock, `${process.pid}`, { flag: "wx" });
375
+ return true;
376
+ } catch {
377
+ /* 竞争失败,继续等 */
378
+ }
379
+ }
380
+ spawnSync("sleep", ["0.05"]);
381
+ }
382
+ return false;
383
+ }
384
+
385
+ function releaseOttyLock(): void {
386
+ try {
387
+ rmSync(ottyStateLockFile());
388
+ } catch {
389
+ /* ignore */
390
+ }
391
+ }
392
+
393
+ /**
394
+ * 找到 agent pane 之后的最新 pane id,用于 split 时作为父 pane。
395
+ *
396
+ * Otty 的 `pane split --pane <parent>` 把目标 pane 拆成两个,但返回的
397
+ * stdout 在 v1.0.4 不会输出新 pane id —— 必须通过比较 split 前后的
398
+ * `otty panes --json` 列表差集来推断。
399
+ *
400
+ * 为了避免并发竞争(两次 split 之间有人插队),先记下 split 前的 pane id 集合,
401
+ * split 后差集 = 新 pane id。
402
+ */
403
+ function capturePaneIds(): Set<string> {
404
+ return new Set(readOttyPanes().map((p) => p.id));
405
+ }
406
+
407
+ function diffNewPane(before: Set<string>): string | null {
408
+ try {
409
+ const after = readOttyPanes();
410
+ const newOnes = after.filter((p) => !before.has(p.id));
411
+ if (newOnes.length === 0) return null;
412
+ if (newOnes.length === 1) return newOnes[0]!.id;
413
+ // 多于 1 个 → 取最后出现(split 后追加的通常在尾部)
414
+ ottyLog(
415
+ `[otty diff] multiple new panes after split: ${JSON.stringify(
416
+ newOnes.map((p) => p.id),
417
+ )}\n`,
418
+ );
419
+ return newOnes[newOnes.length - 1]!.id;
420
+ } catch (e) {
421
+ ottyLog(`[otty diff] failed: ${(e as Error).message}\n`);
422
+ return null;
423
+ }
424
+ }
425
+
426
+ // ── 对外 API:createSurface ──
427
+
428
+ /**
429
+ * 创建一个新的 subagent pane。
430
+ *
431
+ * 实现:广度优先分屏(与 muxy / herdr 一致)。
432
+ * 第一次 split:从 agent pane 向右拆(--no-focus 保持 agent 焦点不变)。
433
+ * 后续 split:按 state 轮转 right/down,绕圈拆分已有 subagent pane。
434
+ *
435
+ * 已知问题:
436
+ * - `otty pane split` v1.0.4 不返回新 pane id,必须靠 panes --json 差集推断。
437
+ * - 若 agent pane 失效(用户切换到别的 tab),getOttyAgentPaneId() 会抛错,
438
+ * createSurface 也跟着失败 —— 这是 fail-fast 设计,避免静默从错误位置拆。
439
+ */
440
+ export function createOttySurface(name: string): string {
441
+ const agentId = getOttyAgentPaneId();
442
+
443
+ // send-keys 没开时无法在子 pane 里发送 Enter,必须改用 --command 注入启动命令。
444
+ // 见 sendOttyCommand 的注释。
445
+ if (!isOttySendKeysEnabled()) {
446
+ ottyLog(`[otty create] send-keys disabled; createSurface will succeed but pane will be empty until otty config enables it\n`);
447
+ }
448
+
449
+ if (!acquireOttyLock()) {
450
+ ottyLog(`[otty create] failed to acquire lock\n`);
451
+ return "";
452
+ }
453
+
454
+ try {
455
+ const state = readOttyState();
456
+
457
+ // ── 首次 split ──
458
+ if (state.panes.length === 0) {
459
+ const before = capturePaneIds();
460
+ try {
461
+ ottyExec([
462
+ "pane",
463
+ "split",
464
+ "--direction",
465
+ "right",
466
+ "--pane",
467
+ agentId,
468
+ "--no-focus",
469
+ "--title",
470
+ name,
471
+ ]);
472
+ } catch (e) {
473
+ ottyLog(`[otty create] first split failed: ${(e as Error).message}\n`);
474
+ return "";
475
+ }
476
+ const newId = diffNewPane(before);
477
+ if (!newId) {
478
+ ottyLog(`[otty create] first split produced no new pane id\n`);
479
+ return "";
480
+ }
481
+ state.panes = [newId];
482
+ state.pos = 0;
483
+ state.base = 1;
484
+ state.dir = "down";
485
+ writeOttyState(state);
486
+ // 改名 tab
487
+ renameOttyTab(newId, name);
488
+ ottyLog(
489
+ `[otty create] mode=first dir=right from=${agentId} new=${newId} name=${JSON.stringify(name)}\n`,
490
+ );
491
+ return newId;
492
+ }
493
+
494
+ // ── 后续 split:先判断本轮是否结束 ──
495
+ if (state.pos >= state.base) {
496
+ state.pos = 0;
497
+ state.base = state.panes.length;
498
+ state.dir = state.dir === "right" ? "down" : "right";
499
+ }
500
+
501
+ let target = state.panes[state.pos];
502
+ if (!target) {
503
+ ottyLog(`[otty create] state.panes[${state.pos}] is undefined, fallback to agent\n`);
504
+ target = agentId;
505
+ }
506
+
507
+ const before = capturePaneIds();
508
+ let splitSucceeded = false;
509
+ try {
510
+ ottyExec([
511
+ "pane",
512
+ "split",
513
+ "--direction",
514
+ state.dir,
515
+ "--pane",
516
+ target,
517
+ "--no-focus",
518
+ "--title",
519
+ name,
520
+ ]);
521
+ splitSucceeded = true;
522
+ } catch (e) {
523
+ // target 失效 → 重置 state,从 agent pane 重新拆
524
+ ottyLog(
525
+ `[otty create] pane ${target} gone (${(e as Error).message}), reset and retry from agent pane\n`,
526
+ );
527
+ try {
528
+ rmSync(ottyStateFile());
529
+ } catch {
530
+ /* ignore */
531
+ }
532
+ target = agentId;
533
+ try {
534
+ ottyExec([
535
+ "pane",
536
+ "split",
537
+ "--direction",
538
+ "right",
539
+ "--pane",
540
+ agentId,
541
+ "--no-focus",
542
+ "--title",
543
+ name,
544
+ ]);
545
+ splitSucceeded = true;
546
+ } catch (e2) {
547
+ ottyLog(`[otty create] reset split failed: ${(e2 as Error).message}\n`);
548
+ return "";
549
+ }
550
+ }
551
+
552
+ if (!splitSucceeded) return "";
553
+ const newId = diffNewPane(before);
554
+ if (!newId) {
555
+ ottyLog(`[otty create] next split produced no new pane id\n`);
556
+ return "";
557
+ }
558
+ state.panes.push(newId);
559
+ state.pos++;
560
+ writeOttyState(state);
561
+ renameOttyTab(newId, name);
562
+ ottyLog(
563
+ `[otty create] mode=next pos=${state.pos - 1} base=${state.base} dir=${state.dir} from=${target} new=${newId} name=${JSON.stringify(name)}\n`,
564
+ );
565
+ return newId;
566
+ } finally {
567
+ releaseOttyLock();
568
+ }
569
+ }
570
+
571
+ // ── 对外 API:pane 操作 ──
572
+
573
+ /**
574
+ * 给 pane 发送命令 + Enter。
575
+ *
576
+ * 关键限制:otty `pane send-keys` 默认 disabled(`ipc-allow-send-keys = true`)。
577
+ * 若该选项未启用,本函数 noop + log warn,调用方应提示用户启用。
578
+ *
579
+ * 实现:使用 `pane send-keys --pane <id> -- "<text>" key:Enter`。
580
+ * send-keys 接受任意数量 PARTS,可混合文本与 `key:Enter` 这种命名 key。
581
+ */
582
+ export function sendOttyCommand(paneId: string, command: string): void {
583
+ if (!isOttySendKeysEnabled()) {
584
+ ottyLog(
585
+ `[otty send] pane=${paneId} cmd=${JSON.stringify(
586
+ command.slice(0, 80),
587
+ )} SKIPPED (send-keys disabled)\n`,
588
+ );
589
+ return;
590
+ }
591
+ try {
592
+ ottyExecSilent(["pane", "send-keys", "--pane", paneId, "--", command, "key:Enter"]);
593
+ } catch (e) {
594
+ ottyLog(`[otty send] pane=${paneId} cmd failed: ${(e as Error).message}\n`);
595
+ }
596
+ }
597
+
598
+ /**
599
+ * 给 pane 发送 Escape。
600
+ * send-keys 支持 `key:Escape` 这种命名 key。
601
+ */
602
+ export function sendOttyEscape(paneId: string): void {
603
+ if (!isOttySendKeysEnabled()) {
604
+ ottyLog(`[otty escape] pane=${paneId} SKIPPED (send-keys disabled)\n`);
605
+ return;
606
+ }
607
+ try {
608
+ ottyExecSilent(["pane", "send-keys", "--pane", paneId, "--", "key:Escape"]);
609
+ } catch (e) {
610
+ ottyLog(`[otty escape] pane=${paneId} failed: ${(e as Error).message}\n`);
611
+ }
612
+ }
613
+
614
+ /**
615
+ * 读取 pane 屏幕内容。
616
+ * `otty pane capture --pane <id> --lines <N>` 直接打印文本(非 JSON)。
617
+ */
618
+ export function readOttyScreen(paneId: string, lines = 50): string {
619
+ try {
620
+ return ottyExec(["pane", "capture", "--pane", paneId, "--lines", String(lines)]);
621
+ } catch (e) {
622
+ ottyLog(`[otty read] pane=${paneId} failed: ${(e as Error).message}\n`);
623
+ return "";
624
+ }
625
+ }
626
+
627
+ /**
628
+ * 关闭 pane。
629
+ *
630
+ * v1.0.4 已知:直接 `pane close --pane <id>` 可能不生效。最佳策略:
631
+ * 1. 先 `pane close --pane <id> --force`
632
+ * 2. 100ms 后检查 pane 是否还在 panes 列表
633
+ * 3. 若仍在,且 tab 里**只有这一个 pane**(孤立 tab),尝试 `tab close --tab <tab_id>`
634
+ * —— 关 tab 不会误伤其他 pane。
635
+ * 4. 若 tab 里还有其他 pane(如 agent 自己的 pane),跳过 fallback,
636
+ * 仅 log warn。否则会把 agent 自己的 pane 一起带走(参见实际事故:
637
+ * 用户在测试时执行 `otty tab close t_xxx`,把同一个 tab 里的 pi 也关了)。
638
+ * 5. 若都失败,log warn,不 throw(避免 pollForExit 退出流程被 close 错误打断)。
639
+ *
640
+ * 与 cmux/muxy 不同:otty 没有"我自己的 pane"概念,close 总是针对显式 id。
641
+ */
642
+ export function closeOttySurface(paneId: string): void {
643
+ const beforeIds = capturePaneIds();
644
+ let closed = false;
645
+
646
+ // 1. pane close --force
647
+ try {
648
+ ottyExecSilent(["pane", "close", "--pane", paneId, "--force"]);
649
+ closed = true;
650
+ } catch (e) {
651
+ ottyLog(`[otty close] pane close failed: ${(e as Error).message}\n`);
652
+ }
653
+
654
+ // 2. 验证
655
+ if (closed && beforeIds.has(paneId)) {
656
+ spawnSync("sleep", ["0.1"]);
657
+ const stillThere = capturePaneIds().has(paneId);
658
+ if (!stillThere) {
659
+ ottyLog(`[otty close] pane ${paneId} closed via pane close --force\n`);
660
+ cleanupOttyStateForPane(paneId);
661
+ return;
662
+ }
663
+ }
664
+
665
+ // 3. fallback: 仅当 pane 是 tab 的唯一成员时才关整个 tab。
666
+ // 否则关 tab 会把 agent pane 等其他 pane 也带走(实际事故)。
667
+ ottyLog(`[otty close] pane close ineffective, considering tab close fallback\n`);
668
+ const tabId = getTabIdForPane(paneId);
669
+ if (tabId) {
670
+ let panesInTab: OttyPaneSnapshot[] = [];
671
+ try {
672
+ panesInTab = readOttyPanes().filter((p) => p.tab_id === tabId);
673
+ } catch {
674
+ panesInTab = [];
675
+ }
676
+ const isLonelyTab = panesInTab.length <= 1;
677
+ if (isLonelyTab) {
678
+ try {
679
+ ottyExecSilent(["tab", "close", tabId]);
680
+ ottyLog(`[otty close] tab ${tabId} closed (lonely tab, safe)\n`);
681
+ } catch (e) {
682
+ ottyLog(`[otty close] tab close failed: ${(e as Error).message}\n`);
683
+ }
684
+ } else {
685
+ ottyLog(
686
+ `[otty close] pane=${paneId} tab=${tabId} has ${panesInTab.length} panes; ` +
687
+ `skipping tab close to avoid clobbering other panes (agent pane is likely in this tab)\n`,
688
+ );
689
+ }
690
+ }
691
+ cleanupOttyStateForPane(paneId);
692
+ }
693
+
694
+ /**
695
+ * 重命名 pane 对应的 tab。
696
+ * Otty 没有"pane -> tab id"的直接命令(pane id 包含 workspace:pane number 但
697
+ * 没法直接拿 tab number),所以用 `panes --json` 反查 tab_id。
698
+ */
699
+ export function renameOttyTab(paneId: string, name: string): void {
700
+ const tabId = getTabIdForPane(paneId);
701
+ if (!tabId) {
702
+ ottyLog(`[otty rename] pane=${paneId} no tab id found\n`);
703
+ return;
704
+ }
705
+ try {
706
+ ottyExecSilent(["tab", "rename", "--tab", tabId, name]);
707
+ } catch (e) {
708
+ ottyLog(
709
+ `[otty rename] pane=${paneId} tab=${tabId} name=${JSON.stringify(name)} failed: ${
710
+ (e as Error).message
711
+ }\n`,
712
+ );
713
+ }
714
+ }
715
+
716
+ /**
717
+ * 从 mux state marker 中清理已关闭的 pane。
718
+ * 与 muxy / herdr 的 close 行为一致:避免僵尸 ID 累积导致后续 split 走错目标。
719
+ */
720
+ function cleanupOttyStateForPane(paneId: string): void {
721
+ try {
722
+ const parsed = readOttyState();
723
+ const idx = parsed.panes.indexOf(paneId);
724
+ if (idx < 0) return;
725
+ const beforePanes = [...parsed.panes];
726
+ const beforePos = parsed.pos;
727
+ parsed.panes.splice(idx, 1);
728
+ if (typeof parsed.pos === "number" && idx < parsed.pos) {
729
+ parsed.pos = Math.max(0, parsed.pos - 1);
730
+ }
731
+ if (parsed.panes.length === 0) {
732
+ try {
733
+ rmSync(ottyStateFile());
734
+ } catch {
735
+ /* ignore */
736
+ }
737
+ ottyLog(
738
+ `[otty close] pane=${paneId} panes=${JSON.stringify(beforePanes)} -> [] pos=${beforePos} -> <marker-removed>\n`,
739
+ );
740
+ } else {
741
+ writeOttyState(parsed);
742
+ ottyLog(
743
+ `[otty close] pane=${paneId} panes=${JSON.stringify(beforePanes)} -> ${JSON.stringify(
744
+ parsed.panes,
745
+ )} pos=${beforePos} -> ${parsed.pos}\n`,
746
+ );
747
+ }
748
+ } catch {
749
+ /* no marker or parse error, nothing to clean */
750
+ }
751
+ }
752
+
753
+ // ── Setup hint ──
754
+
755
+ /**
756
+ * Otty setup hint —— 用户没在 Otty 终端内 / 没启用 send-keys 时提示。
757
+ *
758
+ * 注意:TERM_PROGRAM=otty 已经检查通过才会调用本函数。
759
+ * 这里只补充 send-keys 开关和 pane 创建失败的提示。
760
+ */
761
+ export function ottySetupHint(): string {
762
+ if (!isOttySendKeysEnabled()) {
763
+ return i18n.t("setupHint.ottySendKeys");
764
+ }
765
+ return "";
766
+ }