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/README.md +115 -0
- package/README.zh-CN.md +114 -0
- package/index.ts +1 -0
- package/locales/mux.json +46 -0
- package/package.json +63 -0
- package/src/herdr.ts +499 -0
- package/src/i18n.ts +3 -0
- package/src/index.ts +111 -0
- package/src/mux.ts +2110 -0
- package/src/otty.ts +766 -0
- package/tsconfig.json +13 -0
package/src/mux.ts
ADDED
|
@@ -0,0 +1,2110 @@
|
|
|
1
|
+
import { execSync, execFile, execFileSync, spawnSync, spawn, type ChildProcess } from "node:child_process";
|
|
2
|
+
import { promisify } from "node:util";
|
|
3
|
+
import { existsSync, readFileSync, rmSync, writeFileSync, mkdirSync, statSync, createWriteStream, appendFileSync } from "node:fs";
|
|
4
|
+
import { tmpdir } from "node:os";
|
|
5
|
+
import { basename, dirname, join } from "node:path";
|
|
6
|
+
import type { WriteStream } from "node:fs";
|
|
7
|
+
import { i18n } from "./i18n.ts";
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* 分屏调试日志写入文件,避免污染 TUI 终端。
|
|
11
|
+
* 日志路径: /tmp/pi-muxy-split.log
|
|
12
|
+
*
|
|
13
|
+
* 注意:必须用 /tmp/,不能用 os.tmpdir()。herdr 的日志已经写到
|
|
14
|
+
* /tmp/pi-herdr-split.log,用户排查问题时只看 /tmp/ 即可。
|
|
15
|
+
* 之前用 tmpdir() 导致 macOS 上日志落到 /var/folders/.../T/,与
|
|
16
|
+
* /tmp/ 分裂,用户 grep /tmp 找不到,调试时被坑过。
|
|
17
|
+
*/
|
|
18
|
+
const MUXY_SPLIT_LOG = "/tmp/pi-muxy-split.log";
|
|
19
|
+
|
|
20
|
+
export function muxLog(msg: string): void {
|
|
21
|
+
try {
|
|
22
|
+
appendFileSync(MUXY_SPLIT_LOG, `[${new Date().toISOString()}] ${msg}`);
|
|
23
|
+
} catch {
|
|
24
|
+
// 写日志失败不影响主流程
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const execFileAsync = promisify(execFile);
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Agent's own pane ID in Muxy, captured at module load time.
|
|
32
|
+
* Unlike reading process.env.MUXY_PANE_ID dynamically (which may reflect
|
|
33
|
+
* the user's currently focused pane after switching projects in Muxy),
|
|
34
|
+
* this constant always points to the pane where the agent/pi was launched.
|
|
35
|
+
*/
|
|
36
|
+
export const AGENT_MUXY_PANE_ID = process.env.MUXY_PANE_ID;
|
|
37
|
+
|
|
38
|
+
export type MuxBackend = "cmux" | "muxy" | "tmux" | "zellij" | "wezterm" | "herdr" | "otty";
|
|
39
|
+
|
|
40
|
+
const commandAvailability = new Map<string, boolean>();
|
|
41
|
+
|
|
42
|
+
function hasCommand(command: string): boolean {
|
|
43
|
+
if (commandAvailability.has(command)) {
|
|
44
|
+
return commandAvailability.get(command)!;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
let available = false;
|
|
48
|
+
if (process.platform === "win32") {
|
|
49
|
+
try {
|
|
50
|
+
execFileSync("where.exe", [command], { stdio: "ignore" });
|
|
51
|
+
available = true;
|
|
52
|
+
} catch {
|
|
53
|
+
try {
|
|
54
|
+
execSync(`command -v ${command}`, { stdio: "ignore" });
|
|
55
|
+
available = true;
|
|
56
|
+
} catch {
|
|
57
|
+
available = false;
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
} else {
|
|
61
|
+
try {
|
|
62
|
+
execSync(`command -v ${command}`, { stdio: "ignore" });
|
|
63
|
+
available = true;
|
|
64
|
+
} catch {
|
|
65
|
+
available = false;
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
commandAvailability.set(command, available);
|
|
70
|
+
return available;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function muxPreference(): MuxBackend | null {
|
|
74
|
+
// PI_TERMINAL_MUX 为通用包的首选变量;PI_SUBAGENT_MUX 保留向后兼容。
|
|
75
|
+
const pref = (process.env.PI_TERMINAL_MUX ?? process.env.PI_SUBAGENT_MUX ?? "").trim().toLowerCase();
|
|
76
|
+
if (
|
|
77
|
+
pref === "cmux" ||
|
|
78
|
+
pref === "muxy" ||
|
|
79
|
+
pref === "tmux" ||
|
|
80
|
+
pref === "zellij" ||
|
|
81
|
+
pref === "wezterm" ||
|
|
82
|
+
pref === "herdr" ||
|
|
83
|
+
pref === "otty"
|
|
84
|
+
)
|
|
85
|
+
return pref;
|
|
86
|
+
return null;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function isCmuxRuntimeAvailable(): boolean {
|
|
90
|
+
return !!process.env.CMUX_SOCKET_PATH && hasCommand("cmux");
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function isMuxyRuntimeAvailable(): boolean {
|
|
94
|
+
return !!process.env.MUXY_SOCKET_PATH && hasCommand("muxy");
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function isTmuxRuntimeAvailable(): boolean {
|
|
98
|
+
return !!process.env.TMUX && hasCommand("tmux");
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function isZellijRuntimeAvailable(): boolean {
|
|
102
|
+
return !!(process.env.ZELLIJ || process.env.ZELLIJ_SESSION_NAME) && hasCommand("zellij");
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function isWezTermRuntimeAvailable(): boolean {
|
|
106
|
+
return !!process.env.WEZTERM_UNIX_SOCKET && hasCommand("wezterm");
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
import {
|
|
110
|
+
isHerdrRuntimeAvailable,
|
|
111
|
+
createHerdrSurface,
|
|
112
|
+
splitHerdrPane,
|
|
113
|
+
renameHerdrPane,
|
|
114
|
+
renameHerdrAgent,
|
|
115
|
+
renameHerdrTab,
|
|
116
|
+
renameHerdrWorkspace,
|
|
117
|
+
sendHerdrCommand,
|
|
118
|
+
sendHerdrEscape,
|
|
119
|
+
readHerdrScreen,
|
|
120
|
+
closeHerdrSurface,
|
|
121
|
+
AGENT_HERDR_PANE_ID,
|
|
122
|
+
} from "./herdr.ts";
|
|
123
|
+
import {
|
|
124
|
+
isOttyRuntimeAvailable,
|
|
125
|
+
createOttySurface,
|
|
126
|
+
sendOttyCommand,
|
|
127
|
+
sendOttyEscape,
|
|
128
|
+
readOttyScreen,
|
|
129
|
+
closeOttySurface,
|
|
130
|
+
renameOttyTab,
|
|
131
|
+
ottySetupHint,
|
|
132
|
+
AGENT_OTTY_PANE_ID,
|
|
133
|
+
} from "./otty.ts";
|
|
134
|
+
|
|
135
|
+
export function isCmuxAvailable(): boolean {
|
|
136
|
+
return isCmuxRuntimeAvailable();
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
export function isTmuxAvailable(): boolean {
|
|
140
|
+
return isTmuxRuntimeAvailable();
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
export function isZellijAvailable(): boolean {
|
|
144
|
+
return isZellijRuntimeAvailable();
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
export function isWezTermAvailable(): boolean {
|
|
148
|
+
return isWezTermRuntimeAvailable();
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
export function isHerdrAvailable(): boolean {
|
|
152
|
+
return isHerdrRuntimeAvailable();
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
export function isOttyAvailable(): boolean {
|
|
156
|
+
return isOttyRuntimeAvailable();
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
export function getMuxBackend(): MuxBackend | null {
|
|
160
|
+
const pref = muxPreference();
|
|
161
|
+
if (pref === "cmux") return isCmuxRuntimeAvailable() ? "cmux" : null;
|
|
162
|
+
if (pref === "muxy") return isMuxyRuntimeAvailable() ? "muxy" : null;
|
|
163
|
+
if (pref === "tmux") return isTmuxRuntimeAvailable() ? "tmux" : null;
|
|
164
|
+
if (pref === "zellij") return isZellijRuntimeAvailable() ? "zellij" : null;
|
|
165
|
+
if (pref === "wezterm") return isWezTermRuntimeAvailable() ? "wezterm" : null;
|
|
166
|
+
if (pref === "herdr") return isHerdrRuntimeAvailable() ? "herdr" : null;
|
|
167
|
+
if (pref === "otty") return isOttyRuntimeAvailable() ? "otty" : null;
|
|
168
|
+
|
|
169
|
+
if (isMuxyRuntimeAvailable()) return "muxy";
|
|
170
|
+
if (isCmuxRuntimeAvailable()) return "cmux";
|
|
171
|
+
if (isTmuxRuntimeAvailable()) return "tmux";
|
|
172
|
+
if (isZellijRuntimeAvailable()) return "zellij";
|
|
173
|
+
if (isWezTermRuntimeAvailable()) return "wezterm";
|
|
174
|
+
if (isHerdrRuntimeAvailable()) return "herdr";
|
|
175
|
+
if (isOttyRuntimeAvailable()) return "otty";
|
|
176
|
+
return null;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
export function isMuxAvailable(): boolean {
|
|
180
|
+
return getMuxBackend() !== null;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
export function muxSetupHint(): string {
|
|
184
|
+
const pref = muxPreference();
|
|
185
|
+
if (pref) {
|
|
186
|
+
const hint = i18n.t(`setupHint.${pref}`);
|
|
187
|
+
// otty 在 send-keys 未启用时补充更具体的提示
|
|
188
|
+
if (pref === "otty") {
|
|
189
|
+
return ottySetupHint() || hint;
|
|
190
|
+
}
|
|
191
|
+
return hint;
|
|
192
|
+
}
|
|
193
|
+
return i18n.t("setupHint.generic");
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
// ── Headless mode (no terminal multiplexer available) ──
|
|
197
|
+
|
|
198
|
+
const HEADLESS_SURFACE_PREFIX = "headless:";
|
|
199
|
+
|
|
200
|
+
interface HeadlessProcess {
|
|
201
|
+
child: ChildProcess;
|
|
202
|
+
exitPromise: Promise<{ exitCode: number }>;
|
|
203
|
+
resolveExit: ((value: { exitCode: number }) => void) | null;
|
|
204
|
+
logStream: WriteStream;
|
|
205
|
+
logFile: string;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
const headlessProcesses = new Map<string, HeadlessProcess>();
|
|
209
|
+
|
|
210
|
+
export function createHeadlessSurface(name: string): string {
|
|
211
|
+
const id = `${HEADLESS_SURFACE_PREFIX}${Date.now()}-${Math.random().toString(16).slice(2, 8)}`;
|
|
212
|
+
return id;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
export function spawnHeadlessProcess(
|
|
216
|
+
surface: string,
|
|
217
|
+
name: string,
|
|
218
|
+
command: string,
|
|
219
|
+
options?: { cwd?: string; env?: Record<string, string> },
|
|
220
|
+
): { logFile: string } {
|
|
221
|
+
const safeId = surface.replace(/[^a-zA-Z0-9_-]/g, "_");
|
|
222
|
+
const logFile = join(tmpdir(), `pi-subagent-${safeId}.log`);
|
|
223
|
+
const logStream = createWriteStream(logFile, { flags: "w" });
|
|
224
|
+
|
|
225
|
+
const env = { ...process.env };
|
|
226
|
+
if (options?.env) {
|
|
227
|
+
Object.assign(env, options.env);
|
|
228
|
+
}
|
|
229
|
+
env.PI_SUBAGENT_HEADLESS = "1";
|
|
230
|
+
|
|
231
|
+
const child = spawn("bash", ["-c", command], {
|
|
232
|
+
cwd: options?.cwd || process.cwd(),
|
|
233
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
234
|
+
env,
|
|
235
|
+
});
|
|
236
|
+
|
|
237
|
+
child.stdout.pipe(logStream);
|
|
238
|
+
child.stderr.pipe(logStream);
|
|
239
|
+
child.stdout.resume();
|
|
240
|
+
child.stderr.resume();
|
|
241
|
+
|
|
242
|
+
let resolveExit: ((value: { exitCode: number }) => void) | null = null;
|
|
243
|
+
const exitPromise = new Promise<{ exitCode: number }>((resolve) => {
|
|
244
|
+
resolveExit = resolve;
|
|
245
|
+
child.on("exit", (code) => {
|
|
246
|
+
logStream.end();
|
|
247
|
+
resolve({ exitCode: code ?? 1 });
|
|
248
|
+
});
|
|
249
|
+
child.on("error", () => {
|
|
250
|
+
logStream.end();
|
|
251
|
+
resolve({ exitCode: 1 });
|
|
252
|
+
});
|
|
253
|
+
});
|
|
254
|
+
|
|
255
|
+
headlessProcesses.set(surface, { child, exitPromise, resolveExit, logStream, logFile });
|
|
256
|
+
|
|
257
|
+
return { logFile };
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
export function closeHeadlessSurface(surface: string): void {
|
|
261
|
+
const proc = headlessProcesses.get(surface);
|
|
262
|
+
if (!proc) return;
|
|
263
|
+
try {
|
|
264
|
+
proc.child.kill("SIGTERM");
|
|
265
|
+
} catch {}
|
|
266
|
+
setTimeout(() => {
|
|
267
|
+
try {
|
|
268
|
+
proc.child.kill("SIGKILL");
|
|
269
|
+
} catch {}
|
|
270
|
+
headlessProcesses.delete(surface);
|
|
271
|
+
}, 3000).unref();
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
export function sendHeadlessEscape(surface: string): void {
|
|
275
|
+
const proc = headlessProcesses.get(surface);
|
|
276
|
+
if (!proc) return;
|
|
277
|
+
try {
|
|
278
|
+
proc.child.kill("SIGINT");
|
|
279
|
+
} catch {}
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
export function readHeadlessScreen(surface: string, lines = 50): string {
|
|
283
|
+
const proc = headlessProcesses.get(surface);
|
|
284
|
+
if (!proc) return "";
|
|
285
|
+
try {
|
|
286
|
+
const content = readFileSync(proc.logFile, "utf8");
|
|
287
|
+
const split = content.split("\n");
|
|
288
|
+
return split.slice(-lines).join("\n");
|
|
289
|
+
} catch {
|
|
290
|
+
return "";
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
export async function readHeadlessScreenAsync(surface: string, lines = 50): Promise<string> {
|
|
295
|
+
return readHeadlessScreen(surface, lines);
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
export function isHeadlessSurface(surface: string): boolean {
|
|
299
|
+
return surface.startsWith(HEADLESS_SURFACE_PREFIX) || headlessProcesses.has(surface);
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
export function isHeadlessMode(): boolean {
|
|
303
|
+
return !isMuxAvailable();
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
export function cleanupHeadlessProcesses(): void {
|
|
307
|
+
for (const [surface, proc] of headlessProcesses) {
|
|
308
|
+
try {
|
|
309
|
+
proc.child.kill("SIGTERM");
|
|
310
|
+
} catch {}
|
|
311
|
+
}
|
|
312
|
+
headlessProcesses.clear();
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
export function getHeadlessProcessExit(surface: string): Promise<{ exitCode: number }> | null {
|
|
316
|
+
const proc = headlessProcesses.get(surface);
|
|
317
|
+
return proc?.exitPromise ?? null;
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
export function drainHeadlessProcess(surface: string): void {
|
|
321
|
+
headlessProcesses.delete(surface);
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
function requireMuxBackend(): MuxBackend {
|
|
325
|
+
const backend = getMuxBackend();
|
|
326
|
+
if (!backend) {
|
|
327
|
+
throw new Error(`${i18n.t("setupHint.none")} ${muxSetupHint()}`);
|
|
328
|
+
}
|
|
329
|
+
return backend;
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
/**
|
|
333
|
+
* Detect if the user's default shell is fish.
|
|
334
|
+
* Fish uses $status instead of $? for exit codes.
|
|
335
|
+
*/
|
|
336
|
+
export function isFishShell(): boolean {
|
|
337
|
+
const shell = process.env.SHELL ?? "";
|
|
338
|
+
return basename(shell) === "fish";
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
/**
|
|
342
|
+
* Return the shell-appropriate exit status variable ($? for bash/zsh, $status for fish).
|
|
343
|
+
*/
|
|
344
|
+
export function exitStatusVar(): string {
|
|
345
|
+
return isFishShell() ? "$status" : "$?";
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
export function shellEscape(s: string): string {
|
|
349
|
+
return "'" + s.replace(/'/g, "'\\''") + "'";
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
function tailLines(text: string, lines: number): string {
|
|
353
|
+
const split = text.split("\n");
|
|
354
|
+
if (split.length <= lines) return text;
|
|
355
|
+
return split.slice(-lines).join("\n");
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
function zellijPaneId(surface: string): string {
|
|
359
|
+
return surface.startsWith("pane:") ? surface.slice("pane:".length) : surface;
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
function zellijEnv(surface?: string): NodeJS.ProcessEnv {
|
|
363
|
+
const env: NodeJS.ProcessEnv = { ...process.env };
|
|
364
|
+
if (surface) {
|
|
365
|
+
env.ZELLIJ_PANE_ID = zellijPaneId(surface);
|
|
366
|
+
}
|
|
367
|
+
return env;
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
/**
|
|
371
|
+
* Pane-scoped zellij actions that must target a specific pane via --pane-id
|
|
372
|
+
* (the ZELLIJ_PANE_ID env var is ignored by most of these).
|
|
373
|
+
* See https://github.com/HazAT/pi-interactive-subagents/issues/19
|
|
374
|
+
*/
|
|
375
|
+
const ZELLIJ_PANE_SCOPED_ACTIONS = new Set([
|
|
376
|
+
"close-pane",
|
|
377
|
+
"dump-screen",
|
|
378
|
+
"rename-pane",
|
|
379
|
+
"move-pane",
|
|
380
|
+
"write",
|
|
381
|
+
"write-chars",
|
|
382
|
+
"send-keys",
|
|
383
|
+
]);
|
|
384
|
+
|
|
385
|
+
function zellijActionArgs(args: string[], surface?: string): string[] {
|
|
386
|
+
if (!surface) return ["action", ...args];
|
|
387
|
+
const action = args[0];
|
|
388
|
+
if (!ZELLIJ_PANE_SCOPED_ACTIONS.has(action)) return ["action", ...args];
|
|
389
|
+
// Don't double-add if caller already specified it.
|
|
390
|
+
if (args.includes("--pane-id") || args.includes("-p")) return ["action", ...args];
|
|
391
|
+
return ["action", action, "--pane-id", zellijPaneId(surface), ...args.slice(1)];
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
function zellijActionSync(args: string[], surface?: string): string {
|
|
395
|
+
return execFileSync("zellij", zellijActionArgs(args, surface), {
|
|
396
|
+
encoding: "utf8",
|
|
397
|
+
env: zellijEnv(surface),
|
|
398
|
+
});
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
async function zellijActionAsync(args: string[], surface?: string): Promise<string> {
|
|
402
|
+
const { stdout } = await execFileAsync("zellij", zellijActionArgs(args, surface), {
|
|
403
|
+
encoding: "utf8",
|
|
404
|
+
env: zellijEnv(surface),
|
|
405
|
+
});
|
|
406
|
+
return stdout;
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
/** Tracked subagent pane for cmux — reused across subagent launches. */
|
|
410
|
+
let cmuxSubagentPane: string | null = null;
|
|
411
|
+
|
|
412
|
+
/**
|
|
413
|
+
* 最近一次 createSurface / createSurfaceSplit 的来源 pane。
|
|
414
|
+
* 用于在 pi TUI 中展示「新分屏来自哪个 pane」。
|
|
415
|
+
* 每次调用 createSurface 时更新,调用方读取后可重置。
|
|
416
|
+
*/
|
|
417
|
+
let lastSplitSource: string | null = null;
|
|
418
|
+
|
|
419
|
+
export function getLastSplitSource(): string | null {
|
|
420
|
+
return lastSplitSource;
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
export function clearLastSplitSource(): void {
|
|
424
|
+
lastSplitSource = null;
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
// Mirrors Zellij 0.44.x tab minimums, used to predict which pane Zellij itself
|
|
428
|
+
// will choose for a directionless split.
|
|
429
|
+
const ZELLIJ_MIN_TERMINAL_WIDTH = 5;
|
|
430
|
+
const ZELLIJ_MIN_TERMINAL_HEIGHT = 5;
|
|
431
|
+
const ZELLIJ_CURSOR_HEIGHT_WIDTH_RATIO = 4;
|
|
432
|
+
|
|
433
|
+
// Pi subagents need more usable space than Zellij's internal minimum. These can
|
|
434
|
+
// be tuned per session without another code change.
|
|
435
|
+
const DEFAULT_ZELLIJ_SUBAGENT_MIN_COLUMNS = 50;
|
|
436
|
+
const DEFAULT_ZELLIJ_SUBAGENT_MIN_ROWS = 10;
|
|
437
|
+
|
|
438
|
+
export interface ZellijPaneSnapshot {
|
|
439
|
+
id: number;
|
|
440
|
+
is_plugin?: boolean;
|
|
441
|
+
is_floating?: boolean;
|
|
442
|
+
is_selectable?: boolean;
|
|
443
|
+
exited?: boolean;
|
|
444
|
+
pane_rows?: number;
|
|
445
|
+
pane_columns?: number;
|
|
446
|
+
tab_id?: number;
|
|
447
|
+
is_focused?: boolean;
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
export type ZellijSplitDirection = "down" | "right";
|
|
451
|
+
|
|
452
|
+
export type ZellijPlacementPlan =
|
|
453
|
+
| {
|
|
454
|
+
mode: "split";
|
|
455
|
+
anchorPaneId: number;
|
|
456
|
+
targetPaneId: number;
|
|
457
|
+
tabId: number;
|
|
458
|
+
splitDirection: ZellijSplitDirection;
|
|
459
|
+
}
|
|
460
|
+
| { mode: "stack"; anchorPaneId: number; targetPaneId: number; tabId: number };
|
|
461
|
+
|
|
462
|
+
function paneArea(pane: ZellijPaneSnapshot): number {
|
|
463
|
+
return (pane.pane_rows ?? 0) * (pane.pane_columns ?? 0);
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
function isUsableZellijTiledPane(pane: ZellijPaneSnapshot): boolean {
|
|
467
|
+
return (
|
|
468
|
+
!pane.is_plugin &&
|
|
469
|
+
!pane.is_floating &&
|
|
470
|
+
pane.is_selectable !== false &&
|
|
471
|
+
!pane.exited &&
|
|
472
|
+
typeof pane.pane_rows === "number" &&
|
|
473
|
+
typeof pane.pane_columns === "number"
|
|
474
|
+
);
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
export function predictZellijSplitDirection(pane: ZellijPaneSnapshot): ZellijSplitDirection | null {
|
|
478
|
+
const columns = pane.pane_columns ?? 0;
|
|
479
|
+
const rows = pane.pane_rows ?? 0;
|
|
480
|
+
if (columns < ZELLIJ_MIN_TERMINAL_WIDTH || rows < ZELLIJ_MIN_TERMINAL_HEIGHT) return null;
|
|
481
|
+
|
|
482
|
+
if (
|
|
483
|
+
rows * ZELLIJ_CURSOR_HEIGHT_WIDTH_RATIO > columns &&
|
|
484
|
+
rows > ZELLIJ_MIN_TERMINAL_HEIGHT * 2
|
|
485
|
+
) {
|
|
486
|
+
return "down";
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
if (columns > ZELLIJ_MIN_TERMINAL_WIDTH * 2) {
|
|
490
|
+
return "right";
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
return null;
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
export function canSplitZellijPane(
|
|
497
|
+
pane: ZellijPaneSnapshot,
|
|
498
|
+
minColumns = ZELLIJ_MIN_TERMINAL_WIDTH,
|
|
499
|
+
minRows = ZELLIJ_MIN_TERMINAL_HEIGHT,
|
|
500
|
+
): boolean {
|
|
501
|
+
const columns = pane.pane_columns ?? 0;
|
|
502
|
+
const rows = pane.pane_rows ?? 0;
|
|
503
|
+
const direction = predictZellijSplitDirection(pane);
|
|
504
|
+
if (!direction) return false;
|
|
505
|
+
|
|
506
|
+
if (direction === "down") {
|
|
507
|
+
return columns >= minColumns && Math.floor(rows / 2) >= minRows;
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
return rows >= minRows && Math.floor(columns / 2) >= minColumns;
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
function zellijTabPanesForParent(
|
|
514
|
+
panes: ZellijPaneSnapshot[],
|
|
515
|
+
parentPaneId: number,
|
|
516
|
+
): { parentPane: ZellijPaneSnapshot; tabPanes: ZellijPaneSnapshot[] } | null {
|
|
517
|
+
const parentPane = panes.find((pane) => !pane.is_plugin && pane.id === parentPaneId);
|
|
518
|
+
if (!parentPane || typeof parentPane.tab_id !== "number") return null;
|
|
519
|
+
|
|
520
|
+
const tabPanes = panes
|
|
521
|
+
.filter((pane) => pane.tab_id === parentPane.tab_id)
|
|
522
|
+
.filter(isUsableZellijTiledPane);
|
|
523
|
+
|
|
524
|
+
return { parentPane, tabPanes };
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
export function selectZellijStackPlacement(
|
|
528
|
+
panes: ZellijPaneSnapshot[],
|
|
529
|
+
parentPaneId: number,
|
|
530
|
+
): ZellijPlacementPlan | null {
|
|
531
|
+
const tabInfo = zellijTabPanesForParent(panes, parentPaneId);
|
|
532
|
+
if (!tabInfo) return null;
|
|
533
|
+
|
|
534
|
+
const stackTarget = tabInfo.tabPanes
|
|
535
|
+
.filter((pane) => pane.id !== parentPaneId)
|
|
536
|
+
.sort((a, b) => paneArea(b) - paneArea(a))[0];
|
|
537
|
+
if (!stackTarget) return null;
|
|
538
|
+
|
|
539
|
+
return {
|
|
540
|
+
mode: "stack",
|
|
541
|
+
anchorPaneId: stackTarget.id,
|
|
542
|
+
targetPaneId: stackTarget.id,
|
|
543
|
+
tabId: tabInfo.parentPane.tab_id!,
|
|
544
|
+
};
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
export function selectZellijPlacement(
|
|
548
|
+
panes: ZellijPaneSnapshot[],
|
|
549
|
+
parentPaneId: number,
|
|
550
|
+
minColumns = DEFAULT_ZELLIJ_SUBAGENT_MIN_COLUMNS,
|
|
551
|
+
minRows = DEFAULT_ZELLIJ_SUBAGENT_MIN_ROWS,
|
|
552
|
+
): ZellijPlacementPlan | null {
|
|
553
|
+
const tabInfo = zellijTabPanesForParent(panes, parentPaneId);
|
|
554
|
+
if (!tabInfo) return null;
|
|
555
|
+
|
|
556
|
+
const zellijSplitCandidates = tabInfo.tabPanes
|
|
557
|
+
.map((pane) => ({ pane, splitDirection: predictZellijSplitDirection(pane) }))
|
|
558
|
+
.filter(
|
|
559
|
+
(candidate): candidate is { pane: ZellijPaneSnapshot; splitDirection: ZellijSplitDirection } =>
|
|
560
|
+
candidate.splitDirection !== null &&
|
|
561
|
+
canSplitZellijPane(candidate.pane, ZELLIJ_MIN_TERMINAL_WIDTH, ZELLIJ_MIN_TERMINAL_HEIGHT),
|
|
562
|
+
);
|
|
563
|
+
|
|
564
|
+
const safeSplitCandidates = zellijSplitCandidates.filter((candidate) =>
|
|
565
|
+
canSplitZellijPane(candidate.pane, minColumns, minRows),
|
|
566
|
+
);
|
|
567
|
+
|
|
568
|
+
// Split creation is tab-scoped, so Zellij chooses the concrete split pane.
|
|
569
|
+
// Only split when every pane Zellij might split would remain usable.
|
|
570
|
+
if (
|
|
571
|
+
zellijSplitCandidates.length > 0 &&
|
|
572
|
+
safeSplitCandidates.length === zellijSplitCandidates.length
|
|
573
|
+
) {
|
|
574
|
+
const splitTarget = safeSplitCandidates.sort((a, b) => paneArea(b.pane) - paneArea(a.pane))[0];
|
|
575
|
+
return {
|
|
576
|
+
mode: "split",
|
|
577
|
+
anchorPaneId: splitTarget.pane.id,
|
|
578
|
+
targetPaneId: splitTarget.pane.id,
|
|
579
|
+
tabId: tabInfo.parentPane.tab_id!,
|
|
580
|
+
splitDirection: splitTarget.splitDirection,
|
|
581
|
+
};
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
return selectZellijStackPlacement(panes, parentPaneId);
|
|
585
|
+
}
|
|
586
|
+
|
|
587
|
+
function parseZellijPaneSurface(rawId: string, context: string): string {
|
|
588
|
+
const idMatch = rawId.match(/(\d+)/);
|
|
589
|
+
if (!idMatch) {
|
|
590
|
+
throw new Error(`Unexpected zellij pane id from ${context}: ${rawId || "(empty)"}`);
|
|
591
|
+
}
|
|
592
|
+
return `pane:${idMatch[1]}`;
|
|
593
|
+
}
|
|
594
|
+
|
|
595
|
+
function readZellijPanes(): ZellijPaneSnapshot[] {
|
|
596
|
+
let lastError: unknown;
|
|
597
|
+
for (let attempt = 0; attempt < 3; attempt++) {
|
|
598
|
+
try {
|
|
599
|
+
const output = zellijActionSync(["list-panes", "--json", "--geometry", "--state", "--tab"]);
|
|
600
|
+
if (!output.trim()) {
|
|
601
|
+
throw new Error("Unexpected zellij list-panes output: empty");
|
|
602
|
+
}
|
|
603
|
+
const parsed = JSON.parse(output);
|
|
604
|
+
if (!Array.isArray(parsed)) {
|
|
605
|
+
throw new Error("Unexpected zellij list-panes output: not an array");
|
|
606
|
+
}
|
|
607
|
+
return parsed as ZellijPaneSnapshot[];
|
|
608
|
+
} catch (error) {
|
|
609
|
+
lastError = error;
|
|
610
|
+
if (attempt < 2) sleepSync(50);
|
|
611
|
+
}
|
|
612
|
+
}
|
|
613
|
+
throw lastError;
|
|
614
|
+
}
|
|
615
|
+
|
|
616
|
+
function createZellijTiledPane(name: string, tabId: number): string {
|
|
617
|
+
const args = ["new-pane", "--tab-id", String(tabId), "--name", name, "--cwd", process.cwd()];
|
|
618
|
+
return parseZellijPaneSurface(zellijActionSync(args).trim(), "new-pane");
|
|
619
|
+
}
|
|
620
|
+
|
|
621
|
+
function createZellijStackedPane(name: string, anchorSurface: string): string {
|
|
622
|
+
const args = [
|
|
623
|
+
"new-pane",
|
|
624
|
+
"--stacked",
|
|
625
|
+
"--near-current-pane",
|
|
626
|
+
"--name",
|
|
627
|
+
name,
|
|
628
|
+
"--cwd",
|
|
629
|
+
process.cwd(),
|
|
630
|
+
];
|
|
631
|
+
return parseZellijPaneSurface(zellijActionSync(args, anchorSurface).trim(), "new-pane --stacked");
|
|
632
|
+
}
|
|
633
|
+
|
|
634
|
+
function createZellijTab(name: string): string {
|
|
635
|
+
const tabIdRaw = zellijActionSync(["new-tab", "--name", name, "--cwd", process.cwd()]).trim();
|
|
636
|
+
const tabId = Number(tabIdRaw);
|
|
637
|
+
if (!Number.isInteger(tabId)) {
|
|
638
|
+
throw new Error(`Unexpected zellij tab id from new-tab: ${tabIdRaw || "(empty)"}`);
|
|
639
|
+
}
|
|
640
|
+
|
|
641
|
+
try {
|
|
642
|
+
const panes = readZellijPanes();
|
|
643
|
+
const pane = panes.find(
|
|
644
|
+
(candidate) =>
|
|
645
|
+
candidate.tab_id === tabId &&
|
|
646
|
+
isUsableZellijTiledPane(candidate) &&
|
|
647
|
+
typeof candidate.id === "number",
|
|
648
|
+
);
|
|
649
|
+
if (!pane) {
|
|
650
|
+
throw new Error(`Could not find initial pane for zellij tab ${tabId}`);
|
|
651
|
+
}
|
|
652
|
+
|
|
653
|
+
const surface = `pane:${pane.id}`;
|
|
654
|
+
try {
|
|
655
|
+
zellijActionSync(["rename-pane", name], surface);
|
|
656
|
+
} catch {
|
|
657
|
+
// Optional.
|
|
658
|
+
}
|
|
659
|
+
return surface;
|
|
660
|
+
} catch (error) {
|
|
661
|
+
try {
|
|
662
|
+
zellijActionSync(["close-tab", "--tab-id", String(tabId)]);
|
|
663
|
+
} catch {
|
|
664
|
+
// Best effort cleanup for tabs created before post-creation inspection failed.
|
|
665
|
+
}
|
|
666
|
+
throw error;
|
|
667
|
+
}
|
|
668
|
+
}
|
|
669
|
+
|
|
670
|
+
function envPositiveInteger(name: string, fallback: number): number {
|
|
671
|
+
const value = Number(process.env[name]);
|
|
672
|
+
return Number.isInteger(value) && value > 0 ? value : fallback;
|
|
673
|
+
}
|
|
674
|
+
|
|
675
|
+
function sleepSync(milliseconds: number): void {
|
|
676
|
+
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, milliseconds);
|
|
677
|
+
}
|
|
678
|
+
|
|
679
|
+
function zellijSurfaceLockPath(): string {
|
|
680
|
+
const session = (process.env.ZELLIJ_SESSION_NAME ?? process.env.ZELLIJ ?? "default").replace(
|
|
681
|
+
/[^A-Za-z0-9_.-]/g,
|
|
682
|
+
"_",
|
|
683
|
+
);
|
|
684
|
+
return join(tmpdir(), `pi-zellij-surface-${session}.lock`);
|
|
685
|
+
}
|
|
686
|
+
|
|
687
|
+
function withZellijSurfaceLock<T>(callback: () => T): T {
|
|
688
|
+
const lockPath = zellijSurfaceLockPath();
|
|
689
|
+
const deadline = Date.now() + 10000;
|
|
690
|
+
|
|
691
|
+
while (true) {
|
|
692
|
+
try {
|
|
693
|
+
mkdirSync(lockPath);
|
|
694
|
+
writeFileSync(join(lockPath, "owner"), `${process.pid}\n`);
|
|
695
|
+
break;
|
|
696
|
+
} catch (error) {
|
|
697
|
+
const code = (error as NodeJS.ErrnoException).code;
|
|
698
|
+
if (code !== "EEXIST") throw error;
|
|
699
|
+
|
|
700
|
+
try {
|
|
701
|
+
if (Date.now() - statSync(lockPath).mtimeMs > 30000) {
|
|
702
|
+
rmSync(lockPath, { recursive: true, force: true });
|
|
703
|
+
continue;
|
|
704
|
+
}
|
|
705
|
+
} catch {}
|
|
706
|
+
|
|
707
|
+
if (Date.now() > deadline) {
|
|
708
|
+
throw new Error(`Timed out waiting for zellij surface lock: ${lockPath}`);
|
|
709
|
+
}
|
|
710
|
+
sleepSync(50);
|
|
711
|
+
}
|
|
712
|
+
}
|
|
713
|
+
|
|
714
|
+
try {
|
|
715
|
+
return callback();
|
|
716
|
+
} finally {
|
|
717
|
+
rmSync(lockPath, { recursive: true, force: true });
|
|
718
|
+
}
|
|
719
|
+
}
|
|
720
|
+
|
|
721
|
+
function createZellijSurfaceUnlocked(name: string): string {
|
|
722
|
+
const parentPaneIdRaw = process.env.ZELLIJ_PANE_ID;
|
|
723
|
+
const parentPaneId = parentPaneIdRaw ? Number(parentPaneIdRaw) : NaN;
|
|
724
|
+
const minColumns = envPositiveInteger(
|
|
725
|
+
"PI_SUBAGENT_ZELLIJ_MIN_COLUMNS",
|
|
726
|
+
DEFAULT_ZELLIJ_SUBAGENT_MIN_COLUMNS,
|
|
727
|
+
);
|
|
728
|
+
const minRows = envPositiveInteger(
|
|
729
|
+
"PI_SUBAGENT_ZELLIJ_MIN_ROWS",
|
|
730
|
+
DEFAULT_ZELLIJ_SUBAGENT_MIN_ROWS,
|
|
731
|
+
);
|
|
732
|
+
|
|
733
|
+
const plan = Number.isInteger(parentPaneId)
|
|
734
|
+
? selectZellijPlacement(readZellijPanes(), parentPaneId, minColumns, minRows)
|
|
735
|
+
: null;
|
|
736
|
+
|
|
737
|
+
if (plan?.mode === "split") {
|
|
738
|
+
return createZellijTiledPane(name, plan.tabId);
|
|
739
|
+
}
|
|
740
|
+
|
|
741
|
+
if (plan?.mode === "stack") {
|
|
742
|
+
return createZellijStackedPane(name, `pane:${plan.targetPaneId}`);
|
|
743
|
+
}
|
|
744
|
+
|
|
745
|
+
return createZellijTab(name);
|
|
746
|
+
}
|
|
747
|
+
|
|
748
|
+
function createZellijSurface(name: string): string {
|
|
749
|
+
return withZellijSurfaceLock(() => createZellijSurfaceUnlocked(name));
|
|
750
|
+
}
|
|
751
|
+
|
|
752
|
+
type CmuxFocusSnapshot = {
|
|
753
|
+
surfaceRef?: string;
|
|
754
|
+
paneRef?: string;
|
|
755
|
+
};
|
|
756
|
+
|
|
757
|
+
type CmuxCreatedSurface = {
|
|
758
|
+
surface: string;
|
|
759
|
+
paneRef?: string;
|
|
760
|
+
};
|
|
761
|
+
|
|
762
|
+
type CmuxIdentifySnapshot = {
|
|
763
|
+
focused: CmuxFocusSnapshot | null;
|
|
764
|
+
caller: CmuxFocusSnapshot | null;
|
|
765
|
+
};
|
|
766
|
+
|
|
767
|
+
function nonEmptyString(value: unknown): value is string {
|
|
768
|
+
return typeof value === "string" && value.length > 0;
|
|
769
|
+
}
|
|
770
|
+
|
|
771
|
+
export function parseCmuxFocusedSnapshot(value: unknown): CmuxFocusSnapshot | null {
|
|
772
|
+
if (!value || typeof value !== "object") return null;
|
|
773
|
+
|
|
774
|
+
const focused = (value as { focused?: unknown }).focused;
|
|
775
|
+
if (!focused || typeof focused !== "object") return null;
|
|
776
|
+
|
|
777
|
+
const record = focused as { surface_ref?: unknown; pane_ref?: unknown };
|
|
778
|
+
const surfaceRef = nonEmptyString(record.surface_ref) ? record.surface_ref : undefined;
|
|
779
|
+
const paneRef = nonEmptyString(record.pane_ref) ? record.pane_ref : undefined;
|
|
780
|
+
|
|
781
|
+
if (!surfaceRef && !paneRef) return null;
|
|
782
|
+
return { surfaceRef, paneRef };
|
|
783
|
+
}
|
|
784
|
+
|
|
785
|
+
export function parseCmuxJson(value: string): unknown | null {
|
|
786
|
+
try {
|
|
787
|
+
return JSON.parse(value);
|
|
788
|
+
} catch (error) {
|
|
789
|
+
void error;
|
|
790
|
+
return null;
|
|
791
|
+
}
|
|
792
|
+
}
|
|
793
|
+
|
|
794
|
+
export function parseCmuxFocusedSnapshotFromJson(value: string): CmuxFocusSnapshot | null {
|
|
795
|
+
return parseCmuxFocusedSnapshot(parseCmuxJson(value));
|
|
796
|
+
}
|
|
797
|
+
|
|
798
|
+
function parseCmuxCallerSnapshot(value: unknown): CmuxFocusSnapshot | null {
|
|
799
|
+
if (!value || typeof value !== "object") return null;
|
|
800
|
+
|
|
801
|
+
const caller = (value as { caller?: unknown }).caller;
|
|
802
|
+
if (!caller || typeof caller !== "object") return null;
|
|
803
|
+
|
|
804
|
+
const record = caller as { surface_ref?: unknown; pane_ref?: unknown };
|
|
805
|
+
const surfaceRef = nonEmptyString(record.surface_ref) ? record.surface_ref : undefined;
|
|
806
|
+
const paneRef = nonEmptyString(record.pane_ref) ? record.pane_ref : undefined;
|
|
807
|
+
|
|
808
|
+
if (!surfaceRef && !paneRef) return null;
|
|
809
|
+
return { surfaceRef, paneRef };
|
|
810
|
+
}
|
|
811
|
+
|
|
812
|
+
export function parseCmuxPaneRefForSurface(value: unknown, surface: string): string | null {
|
|
813
|
+
if (!value || typeof value !== "object") return null;
|
|
814
|
+
|
|
815
|
+
const record = value as { surface_ref?: unknown; pane_ref?: unknown; caller?: unknown };
|
|
816
|
+
if (record.surface_ref === surface && nonEmptyString(record.pane_ref)) return record.pane_ref;
|
|
817
|
+
|
|
818
|
+
const caller = record.caller;
|
|
819
|
+
if (!caller || typeof caller !== "object") return null;
|
|
820
|
+
|
|
821
|
+
const callerRecord = caller as { surface_ref?: unknown; pane_ref?: unknown };
|
|
822
|
+
if (callerRecord.surface_ref === surface && nonEmptyString(callerRecord.pane_ref)) {
|
|
823
|
+
return callerRecord.pane_ref;
|
|
824
|
+
}
|
|
825
|
+
|
|
826
|
+
return null;
|
|
827
|
+
}
|
|
828
|
+
|
|
829
|
+
export function parseCmuxPaneRefForSurfaceFromJson(value: string, surface: string): string | null {
|
|
830
|
+
return parseCmuxPaneRefForSurface(parseCmuxJson(value), surface);
|
|
831
|
+
}
|
|
832
|
+
|
|
833
|
+
function readCmux(args: string[]): string | null {
|
|
834
|
+
const result = spawnSync("cmux", args, { encoding: "utf8" });
|
|
835
|
+
if (result.error || result.status !== 0 || !result.stdout.trim()) return null;
|
|
836
|
+
return result.stdout;
|
|
837
|
+
}
|
|
838
|
+
|
|
839
|
+
function parseCmuxIdentifySnapshot(value: string | null): CmuxIdentifySnapshot {
|
|
840
|
+
const parsed = value ? parseCmuxJson(value) : null;
|
|
841
|
+
return {
|
|
842
|
+
focused: parseCmuxFocusedSnapshot(parsed),
|
|
843
|
+
caller: parseCmuxCallerSnapshot(parsed),
|
|
844
|
+
};
|
|
845
|
+
}
|
|
846
|
+
|
|
847
|
+
function captureCmuxIdentifySnapshot(): CmuxIdentifySnapshot {
|
|
848
|
+
return parseCmuxIdentifySnapshot(readCmux(["identify", "--json"]));
|
|
849
|
+
}
|
|
850
|
+
|
|
851
|
+
function captureCmuxFocusSnapshot(): CmuxFocusSnapshot | null {
|
|
852
|
+
return captureCmuxIdentifySnapshot().focused;
|
|
853
|
+
}
|
|
854
|
+
|
|
855
|
+
function readCmuxPaneRefForSurface(surface: string): string | null {
|
|
856
|
+
const info = readCmux(["identify", "--surface", surface]);
|
|
857
|
+
return info ? parseCmuxPaneRefForSurfaceFromJson(info, surface) : null;
|
|
858
|
+
}
|
|
859
|
+
|
|
860
|
+
function restoreCmuxFocusSnapshot(snapshot: CmuxFocusSnapshot | null): void {
|
|
861
|
+
if (!snapshot) return;
|
|
862
|
+
|
|
863
|
+
if (snapshot.paneRef) {
|
|
864
|
+
spawnSync("cmux", ["focus-pane", "--pane", snapshot.paneRef], { encoding: "utf8" });
|
|
865
|
+
}
|
|
866
|
+
|
|
867
|
+
if (snapshot.surfaceRef) {
|
|
868
|
+
spawnSync("cmux", ["focus-panel", "--panel", snapshot.surfaceRef], { encoding: "utf8" });
|
|
869
|
+
}
|
|
870
|
+
}
|
|
871
|
+
|
|
872
|
+
function waitForCmuxFocusSettle(): void {
|
|
873
|
+
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 100);
|
|
874
|
+
}
|
|
875
|
+
|
|
876
|
+
function cmuxFocusMatchesChild(
|
|
877
|
+
currentFocus: CmuxFocusSnapshot | null,
|
|
878
|
+
child: CmuxCreatedSurface,
|
|
879
|
+
): boolean {
|
|
880
|
+
if (!currentFocus) return false;
|
|
881
|
+
if (currentFocus.surfaceRef === child.surface) return true;
|
|
882
|
+
return !!currentFocus.paneRef && currentFocus.paneRef === child.paneRef;
|
|
883
|
+
}
|
|
884
|
+
|
|
885
|
+
function cmuxFocusMatchesSurfaceRef(
|
|
886
|
+
currentFocus: CmuxFocusSnapshot | null,
|
|
887
|
+
surfaceRef: string | undefined,
|
|
888
|
+
): boolean {
|
|
889
|
+
return !!surfaceRef && currentFocus?.surfaceRef === surfaceRef;
|
|
890
|
+
}
|
|
891
|
+
|
|
892
|
+
function cmuxFocusMatchesPaneRef(
|
|
893
|
+
currentFocus: CmuxFocusSnapshot | null,
|
|
894
|
+
paneRef: string | undefined,
|
|
895
|
+
): boolean {
|
|
896
|
+
return !!paneRef && currentFocus?.paneRef === paneRef;
|
|
897
|
+
}
|
|
898
|
+
|
|
899
|
+
function restoreCmuxFocusIfLaunchSurfaceFocused(
|
|
900
|
+
snapshot: CmuxFocusSnapshot | null,
|
|
901
|
+
child: CmuxCreatedSurface,
|
|
902
|
+
options?: { sourceSurfaceRef?: string; callerSnapshot?: CmuxFocusSnapshot | null },
|
|
903
|
+
): void {
|
|
904
|
+
if (!snapshot) return;
|
|
905
|
+
|
|
906
|
+
waitForCmuxFocusSettle();
|
|
907
|
+
const currentFocus = captureCmuxFocusSnapshot();
|
|
908
|
+
if (
|
|
909
|
+
cmuxFocusMatchesChild(currentFocus, child) ||
|
|
910
|
+
cmuxFocusMatchesSurfaceRef(currentFocus, options?.sourceSurfaceRef) ||
|
|
911
|
+
cmuxFocusMatchesSurfaceRef(currentFocus, options?.callerSnapshot?.surfaceRef) ||
|
|
912
|
+
// cmux can settle focus onto another active surface in the caller pane after creating a split/surface.
|
|
913
|
+
cmuxFocusMatchesPaneRef(currentFocus, options?.callerSnapshot?.paneRef)
|
|
914
|
+
) {
|
|
915
|
+
restoreCmuxFocusSnapshot(snapshot);
|
|
916
|
+
}
|
|
917
|
+
}
|
|
918
|
+
|
|
919
|
+
function parseCmuxCreatedSurface(output: string, command: string): CmuxCreatedSurface {
|
|
920
|
+
const surfaceMatch = output.match(/surface:\d+/);
|
|
921
|
+
if (!surfaceMatch) {
|
|
922
|
+
throw new Error(`Unexpected cmux ${command} output: ${output}`);
|
|
923
|
+
}
|
|
924
|
+
|
|
925
|
+
return {
|
|
926
|
+
surface: surfaceMatch[0],
|
|
927
|
+
paneRef: output.match(/pane:\d+/)?.[0],
|
|
928
|
+
};
|
|
929
|
+
}
|
|
930
|
+
|
|
931
|
+
function renameCmuxSurface(surface: string, name: string): void {
|
|
932
|
+
execFileSync("cmux", ["rename-tab", "--surface", surface, name], { encoding: "utf8" });
|
|
933
|
+
}
|
|
934
|
+
|
|
935
|
+
function renameMuxySurface(surface: string, name: string): void {
|
|
936
|
+
try {
|
|
937
|
+
execFileSync("muxy", ["rename-pane", "--pane", surface, name], { encoding: "utf8" });
|
|
938
|
+
} catch {}
|
|
939
|
+
}
|
|
940
|
+
|
|
941
|
+
function createCmuxSplitSurface(
|
|
942
|
+
name: string,
|
|
943
|
+
direction: "left" | "right" | "up" | "down",
|
|
944
|
+
fromSurface?: string,
|
|
945
|
+
): CmuxCreatedSurface {
|
|
946
|
+
const identifySnapshot = captureCmuxIdentifySnapshot();
|
|
947
|
+
const focusSnapshot = identifySnapshot.focused;
|
|
948
|
+
const callerSnapshot = identifySnapshot.caller;
|
|
949
|
+
let child: CmuxCreatedSurface | null = null;
|
|
950
|
+
|
|
951
|
+
try {
|
|
952
|
+
const args = ["new-split", direction];
|
|
953
|
+
if (fromSurface) args.push("--surface", fromSurface);
|
|
954
|
+
|
|
955
|
+
const output = execFileSync("cmux", args, { encoding: "utf8" }).trim();
|
|
956
|
+
child = parseCmuxCreatedSurface(output, "new-split");
|
|
957
|
+
child.paneRef ??= readCmuxPaneRefForSurface(child.surface) ?? undefined;
|
|
958
|
+
renameCmuxSurface(child.surface, name);
|
|
959
|
+
return child;
|
|
960
|
+
} finally {
|
|
961
|
+
if (child) {
|
|
962
|
+
restoreCmuxFocusIfLaunchSurfaceFocused(focusSnapshot, child, {
|
|
963
|
+
sourceSurfaceRef: fromSurface,
|
|
964
|
+
callerSnapshot,
|
|
965
|
+
});
|
|
966
|
+
} else {
|
|
967
|
+
restoreCmuxFocusSnapshot(focusSnapshot);
|
|
968
|
+
}
|
|
969
|
+
}
|
|
970
|
+
}
|
|
971
|
+
|
|
972
|
+
/**
|
|
973
|
+
* Create a new terminal surface for a subagent.
|
|
974
|
+
*
|
|
975
|
+
* For cmux: the first call creates a right-split pane; subsequent calls add
|
|
976
|
+
* tabs to that same pane (avoiding ever-narrower splits).
|
|
977
|
+
* For zellij: chooses a tab-aware tiled or stacked placement.
|
|
978
|
+
* For tmux/wezterm: falls back to split behavior.
|
|
979
|
+
*
|
|
980
|
+
* Returns an identifier (`surface:42` in cmux, `%12` in tmux, `pane:7` in zellij, `42` in wezterm).
|
|
981
|
+
*/
|
|
982
|
+
export function createSurface(name: string): string {
|
|
983
|
+
// Fall back to headless mode when no terminal multiplexer is available
|
|
984
|
+
if (!isMuxAvailable()) {
|
|
985
|
+
return createHeadlessSurface(name);
|
|
986
|
+
}
|
|
987
|
+
|
|
988
|
+
const backend = getMuxBackend();
|
|
989
|
+
|
|
990
|
+
if (backend === "cmux" && cmuxSubagentPane) {
|
|
991
|
+
// Verify the pane still exists before adding a tab to it
|
|
992
|
+
try {
|
|
993
|
+
const tree = execSync(`cmux tree`, { encoding: "utf8" });
|
|
994
|
+
if (tree.includes(cmuxSubagentPane)) {
|
|
995
|
+
return createSurfaceInPane(name, cmuxSubagentPane);
|
|
996
|
+
}
|
|
997
|
+
} catch {}
|
|
998
|
+
// Pane is gone — fall through to create a new split
|
|
999
|
+
cmuxSubagentPane = null;
|
|
1000
|
+
}
|
|
1001
|
+
|
|
1002
|
+
if (backend === "muxy") {
|
|
1003
|
+
const markerFile = `/tmp/muxy-subagent-pane-${AGENT_MUXY_PANE_ID || "default"}`;
|
|
1004
|
+
const lockFile = `${markerFile}.lock`;
|
|
1005
|
+
|
|
1006
|
+
// ── 全局锁:所有分屏操作串行化,防止并发竞争 ──
|
|
1007
|
+
const acquired = (() => {
|
|
1008
|
+
for (let i = 0; i < 60; i++) {
|
|
1009
|
+
if (!existsSync(lockFile)) {
|
|
1010
|
+
try {
|
|
1011
|
+
writeFileSync(lockFile, `${process.pid}`, { flag: "wx" });
|
|
1012
|
+
return true;
|
|
1013
|
+
} catch {
|
|
1014
|
+
// 竞争失败,继续等待
|
|
1015
|
+
}
|
|
1016
|
+
}
|
|
1017
|
+
spawnSync("sleep", ["0.05"]);
|
|
1018
|
+
}
|
|
1019
|
+
return false;
|
|
1020
|
+
})();
|
|
1021
|
+
|
|
1022
|
+
if (!acquired) return "";
|
|
1023
|
+
|
|
1024
|
+
try {
|
|
1025
|
+
// ── 广度优先分屏 ──
|
|
1026
|
+
// 状态(JSON):{ panes: string[], pos: number, base: number, dir: "right"|"down" }
|
|
1027
|
+
// panes: 所有 subagent pane ID 列表
|
|
1028
|
+
// pos: 本轮下一个要 split 的 pane 索引
|
|
1029
|
+
// base: 本轮开始时 pane 总数(本轮要分 base 个)
|
|
1030
|
+
// dir: 当前方向
|
|
1031
|
+
// 每来一个 subagent:从 panes[pos] 分 → 新 pane 追加到列表尾
|
|
1032
|
+
// pos 走完 base 个后 → 翻转方向,重置 pos,更新 base
|
|
1033
|
+
let state: { panes: string[]; pos: number; base: number; dir: "right" | "down" } = {
|
|
1034
|
+
panes: [], pos: 0, base: 0, dir: "right",
|
|
1035
|
+
};
|
|
1036
|
+
try {
|
|
1037
|
+
state = JSON.parse(readFileSync(markerFile, "utf8"));
|
|
1038
|
+
} catch {}
|
|
1039
|
+
|
|
1040
|
+
// 首次:split-right from parent
|
|
1041
|
+
if (state.panes.length === 0) {
|
|
1042
|
+
// 必须从 agent 启动时的 pane 拆,不能 fallback 到当前焦点 pane。
|
|
1043
|
+
// 如果 MUXY_PANE_ID 没注入(pi 是从 muxy 外面启动的,或模块加载时 env 尚未注入),
|
|
1044
|
+
// 抛错让用户明确知道根因,而不是静默从当前激活 pane 拆出。
|
|
1045
|
+
if (!AGENT_MUXY_PANE_ID) {
|
|
1046
|
+
throw new Error(
|
|
1047
|
+
"MUXY_PANE_ID not set; cannot determine parent pane for first subagent split. " +
|
|
1048
|
+
"Start pi inside Muxy so MUXY_PANE_ID is injected at launch.",
|
|
1049
|
+
);
|
|
1050
|
+
}
|
|
1051
|
+
const args = ["split-right", "--from", AGENT_MUXY_PANE_ID];
|
|
1052
|
+
lastSplitSource = AGENT_MUXY_PANE_ID;
|
|
1053
|
+
muxLog(
|
|
1054
|
+
`[muxy split] mode=first dir=right from=AGENT_MUXY_PANE_ID=${AGENT_MUXY_PANE_ID} new=<pending> name=${JSON.stringify(name)}\n`,
|
|
1055
|
+
);
|
|
1056
|
+
const output = execFileSync("muxy", args, { encoding: "utf8" }).trim();
|
|
1057
|
+
if (output) {
|
|
1058
|
+
state.panes = [output];
|
|
1059
|
+
state.pos = 0;
|
|
1060
|
+
state.base = 1;
|
|
1061
|
+
state.dir = "down";
|
|
1062
|
+
writeFileSync(markerFile, JSON.stringify(state));
|
|
1063
|
+
renameMuxySurface(output, name);
|
|
1064
|
+
muxLog(
|
|
1065
|
+
`[muxy split] mode=first dir=right from=AGENT_MUXY_PANE_ID=${AGENT_MUXY_PANE_ID} new=${output} name=${JSON.stringify(name)}\n`,
|
|
1066
|
+
);
|
|
1067
|
+
}
|
|
1068
|
+
return output;
|
|
1069
|
+
}
|
|
1070
|
+
|
|
1071
|
+
// 本轮结束?翻转方向
|
|
1072
|
+
if (state.pos >= state.base) {
|
|
1073
|
+
state.pos = 0;
|
|
1074
|
+
state.base = state.panes.length;
|
|
1075
|
+
state.dir = state.dir === "right" ? "down" : "right";
|
|
1076
|
+
}
|
|
1077
|
+
|
|
1078
|
+
const targetPane = state.panes[state.pos];
|
|
1079
|
+
lastSplitSource = targetPane ?? null;
|
|
1080
|
+
const muxyDir = state.dir === "right" ? "split-right" : "split-down";
|
|
1081
|
+
const args = [muxyDir];
|
|
1082
|
+
if (targetPane) args.push("--from", targetPane);
|
|
1083
|
+
|
|
1084
|
+
muxLog(
|
|
1085
|
+
`[muxy split] mode=next pos=${state.pos} base=${state.base} dir=${state.dir} from=state.panes[${state.pos}]=${targetPane} new=<pending> name=${JSON.stringify(name)}\n`,
|
|
1086
|
+
);
|
|
1087
|
+
const output = execFileSync("muxy", args, { encoding: "utf8" }).trim();
|
|
1088
|
+
|
|
1089
|
+
if (output) {
|
|
1090
|
+
state.panes.push(output);
|
|
1091
|
+
state.pos++;
|
|
1092
|
+
writeFileSync(markerFile, JSON.stringify(state));
|
|
1093
|
+
renameMuxySurface(output, name);
|
|
1094
|
+
muxLog(
|
|
1095
|
+
`[muxy split] mode=next pos=${state.pos - 1} base=${state.base} dir=${state.dir} from=state.panes[${state.pos - 1}]=${targetPane} new=${output} name=${JSON.stringify(name)}\n`,
|
|
1096
|
+
);
|
|
1097
|
+
}
|
|
1098
|
+
|
|
1099
|
+
return output;
|
|
1100
|
+
} finally {
|
|
1101
|
+
try { rmSync(lockFile); } catch {}
|
|
1102
|
+
}
|
|
1103
|
+
}
|
|
1104
|
+
|
|
1105
|
+
if (backend === "cmux") {
|
|
1106
|
+
lastSplitSource = process.env.CMUX_SURFACE_ID ?? null;
|
|
1107
|
+
const created = createCmuxSplitSurface(name, "right", process.env.CMUX_SURFACE_ID);
|
|
1108
|
+
cmuxSubagentPane = created.paneRef ?? null;
|
|
1109
|
+
return created.surface;
|
|
1110
|
+
}
|
|
1111
|
+
|
|
1112
|
+
if (backend === "zellij") {
|
|
1113
|
+
return createZellijSurface(name);
|
|
1114
|
+
}
|
|
1115
|
+
|
|
1116
|
+
if (backend === "herdr") {
|
|
1117
|
+
return createHerdrSurface(name);
|
|
1118
|
+
}
|
|
1119
|
+
|
|
1120
|
+
if (backend === "otty") {
|
|
1121
|
+
lastSplitSource = AGENT_OTTY_PANE_ID ?? null;
|
|
1122
|
+
return createOttySurface(name);
|
|
1123
|
+
}
|
|
1124
|
+
|
|
1125
|
+
// On tmux, target the parent pi's pane so splits follow the agent, not the user's focus.
|
|
1126
|
+
// See https://github.com/HazAT/pi-interactive-subagents/issues/12
|
|
1127
|
+
const fromSurface = backend === "tmux" ? process.env.TMUX_PANE : undefined;
|
|
1128
|
+
lastSplitSource = fromSurface ?? null;
|
|
1129
|
+
return createSurfaceSplit(name, "right", fromSurface);
|
|
1130
|
+
}
|
|
1131
|
+
|
|
1132
|
+
/**
|
|
1133
|
+
* Create a new surface (tab) in an existing cmux pane.
|
|
1134
|
+
*/
|
|
1135
|
+
function createSurfaceInPane(name: string, pane: string): string {
|
|
1136
|
+
const identifySnapshot = captureCmuxIdentifySnapshot();
|
|
1137
|
+
const focusSnapshot = identifySnapshot.focused;
|
|
1138
|
+
const callerSnapshot = identifySnapshot.caller;
|
|
1139
|
+
let child: CmuxCreatedSurface | null = null;
|
|
1140
|
+
|
|
1141
|
+
try {
|
|
1142
|
+
const output = execFileSync("cmux", ["new-surface", "--pane", pane], { encoding: "utf8" }).trim();
|
|
1143
|
+
child = parseCmuxCreatedSurface(output, "new-surface");
|
|
1144
|
+
child.paneRef ??= pane;
|
|
1145
|
+
renameCmuxSurface(child.surface, name);
|
|
1146
|
+
return child.surface;
|
|
1147
|
+
} finally {
|
|
1148
|
+
if (child) {
|
|
1149
|
+
restoreCmuxFocusIfLaunchSurfaceFocused(focusSnapshot, child, {
|
|
1150
|
+
callerSnapshot,
|
|
1151
|
+
});
|
|
1152
|
+
} else {
|
|
1153
|
+
restoreCmuxFocusSnapshot(focusSnapshot);
|
|
1154
|
+
}
|
|
1155
|
+
}
|
|
1156
|
+
}
|
|
1157
|
+
|
|
1158
|
+
/**
|
|
1159
|
+
* Create a new split in the given direction from an optional source pane.
|
|
1160
|
+
* Returns an identifier (`surface:42` in cmux, `%12` in tmux, `pane:7` in zellij, `42` in wezterm).
|
|
1161
|
+
*/
|
|
1162
|
+
export function createSurfaceSplit(
|
|
1163
|
+
name: string,
|
|
1164
|
+
direction: "left" | "right" | "up" | "down",
|
|
1165
|
+
fromSurface?: string,
|
|
1166
|
+
): string {
|
|
1167
|
+
const backend = requireMuxBackend();
|
|
1168
|
+
|
|
1169
|
+
if (backend === "muxy") {
|
|
1170
|
+
const dir = direction === "down" || direction === "up" ? "down" : "right";
|
|
1171
|
+
// 优先用入参 fromSurface(与 cmux/zellij/tmux/wezterm 保持一致),fallback 到
|
|
1172
|
+
// agent 启动时的 pane。两者都没有就抛错,避免静默 fallback 到当前焦点 pane。
|
|
1173
|
+
const sourcePane = fromSurface ?? AGENT_MUXY_PANE_ID;
|
|
1174
|
+
const sourceOrigin = fromSurface
|
|
1175
|
+
? `fromSurface=${fromSurface}`
|
|
1176
|
+
: `AGENT_MUXY_PANE_ID=${AGENT_MUXY_PANE_ID ?? "<unset>"}`;
|
|
1177
|
+
if (!sourcePane) {
|
|
1178
|
+
throw new Error(
|
|
1179
|
+
"MUXY_PANE_ID not set and no fromSurface provided; cannot determine source pane for split. " +
|
|
1180
|
+
"Start pi inside Muxy so MUXY_PANE_ID is injected at launch.",
|
|
1181
|
+
);
|
|
1182
|
+
}
|
|
1183
|
+
const args = [`split-${dir}`, "--from", sourcePane];
|
|
1184
|
+
lastSplitSource = sourcePane;
|
|
1185
|
+
muxLog(
|
|
1186
|
+
`[muxy split] mode=createSurfaceSplit dir=${dir} from=${sourcePane} (${sourceOrigin}) new=<pending> name=${JSON.stringify(name)}\n`,
|
|
1187
|
+
);
|
|
1188
|
+
const output = execFileSync("muxy", args, { encoding: "utf8" }).trim();
|
|
1189
|
+
if (output) {
|
|
1190
|
+
renameMuxySurface(output, name);
|
|
1191
|
+
muxLog(
|
|
1192
|
+
`[muxy split] mode=createSurfaceSplit dir=${dir} from=${sourcePane} (${sourceOrigin}) new=${output} name=${JSON.stringify(name)}\n`,
|
|
1193
|
+
);
|
|
1194
|
+
}
|
|
1195
|
+
return output;
|
|
1196
|
+
}
|
|
1197
|
+
|
|
1198
|
+
if (backend === "cmux") {
|
|
1199
|
+
return createCmuxSplitSurface(name, direction, fromSurface).surface;
|
|
1200
|
+
}
|
|
1201
|
+
|
|
1202
|
+
if (backend === "tmux") {
|
|
1203
|
+
const args = ["split-window", "-d"];
|
|
1204
|
+
if (direction === "left" || direction === "right") {
|
|
1205
|
+
args.push("-h");
|
|
1206
|
+
} else {
|
|
1207
|
+
args.push("-v");
|
|
1208
|
+
}
|
|
1209
|
+
if (direction === "left" || direction === "up") {
|
|
1210
|
+
args.push("-b");
|
|
1211
|
+
}
|
|
1212
|
+
if (fromSurface) {
|
|
1213
|
+
args.push("-t", fromSurface);
|
|
1214
|
+
}
|
|
1215
|
+
args.push("-P", "-F", "#{pane_id}");
|
|
1216
|
+
|
|
1217
|
+
const pane = execFileSync("tmux", args, { encoding: "utf8" }).trim();
|
|
1218
|
+
if (!pane.startsWith("%")) {
|
|
1219
|
+
throw new Error(`Unexpected tmux split-window output: ${pane}`);
|
|
1220
|
+
}
|
|
1221
|
+
|
|
1222
|
+
return pane;
|
|
1223
|
+
}
|
|
1224
|
+
|
|
1225
|
+
if (backend === "wezterm") {
|
|
1226
|
+
const args = ["cli", "split-pane"];
|
|
1227
|
+
if (direction === "left") args.push("--left");
|
|
1228
|
+
else if (direction === "right") args.push("--right");
|
|
1229
|
+
else if (direction === "up") args.push("--top");
|
|
1230
|
+
else args.push("--bottom");
|
|
1231
|
+
args.push("--cwd", process.cwd());
|
|
1232
|
+
if (fromSurface) {
|
|
1233
|
+
args.push("--pane-id", fromSurface);
|
|
1234
|
+
}
|
|
1235
|
+
const paneId = execFileSync("wezterm", args, { encoding: "utf8" }).trim();
|
|
1236
|
+
if (!paneId || !/^\d+$/.test(paneId)) {
|
|
1237
|
+
throw new Error(`Unexpected wezterm split-pane output: ${paneId || "(empty)"}`);
|
|
1238
|
+
}
|
|
1239
|
+
try {
|
|
1240
|
+
execFileSync("wezterm", ["cli", "set-tab-title", "--pane-id", paneId, name], {
|
|
1241
|
+
encoding: "utf8",
|
|
1242
|
+
});
|
|
1243
|
+
} catch {
|
|
1244
|
+
// Optional — tab title is cosmetic.
|
|
1245
|
+
}
|
|
1246
|
+
return paneId;
|
|
1247
|
+
}
|
|
1248
|
+
|
|
1249
|
+
if (backend === "herdr") {
|
|
1250
|
+
const sourcePane = fromSurface ?? AGENT_HERDR_PANE_ID;
|
|
1251
|
+
if (!sourcePane) {
|
|
1252
|
+
throw new Error(
|
|
1253
|
+
"HERDR_PANE_ID not set and no fromSurface provided; cannot determine source pane for split. " +
|
|
1254
|
+
"Start pi inside herdr so HERDR_PANE_ID is injected at launch.",
|
|
1255
|
+
);
|
|
1256
|
+
}
|
|
1257
|
+
lastSplitSource = sourcePane;
|
|
1258
|
+
return splitHerdrPane(sourcePane, direction, name);
|
|
1259
|
+
}
|
|
1260
|
+
|
|
1261
|
+
if (backend === "otty") {
|
|
1262
|
+
// otty 用 --direction right|down|left|up + --pane <parent>,且不返回新 pane id。
|
|
1263
|
+
// createOttySurface 内部用广度优先策略,对外只暴露"加一个 pane"。
|
|
1264
|
+
// 这里为了接口一致(返回 surface id),调用 createOttySurface
|
|
1265
|
+
// 并把 fromSurface / direction 作为 hint 注入。
|
|
1266
|
+
// 注:direction 在 otty 的广度优先策略中被忽略(顺序固定),
|
|
1267
|
+
// 但 fromSurface 会作为 fallback parent(如果提供)。
|
|
1268
|
+
lastSplitSource = fromSurface ?? AGENT_OTTY_PANE_ID ?? null;
|
|
1269
|
+
return createOttySurface(name);
|
|
1270
|
+
}
|
|
1271
|
+
|
|
1272
|
+
// zellij
|
|
1273
|
+
const directionArg = direction === "left" || direction === "right" ? "right" : "down";
|
|
1274
|
+
const args = ["new-pane", "--direction", directionArg, "--name", name, "--cwd", process.cwd()];
|
|
1275
|
+
|
|
1276
|
+
let rawId: string;
|
|
1277
|
+
try {
|
|
1278
|
+
rawId = zellijActionSync(args, fromSurface).trim();
|
|
1279
|
+
} catch {
|
|
1280
|
+
if (!fromSurface) throw new Error("Failed to create zellij pane");
|
|
1281
|
+
rawId = zellijActionSync(args).trim();
|
|
1282
|
+
}
|
|
1283
|
+
|
|
1284
|
+
// zellij returns the pane ID as e.g. "terminal_7" — extract the numeric part.
|
|
1285
|
+
// Previously we sent `write-chars "echo $ZELLIJ_PANE_ID"` to a temp file, but
|
|
1286
|
+
// `write-chars` without --pane-id targets the focused pane, which raced on tab switches.
|
|
1287
|
+
const surface = parseZellijPaneSurface(rawId, "new-pane");
|
|
1288
|
+
|
|
1289
|
+
if (direction === "left" || direction === "up") {
|
|
1290
|
+
try {
|
|
1291
|
+
zellijActionSync(["move-pane", direction], surface);
|
|
1292
|
+
} catch {
|
|
1293
|
+
// Optional layout polish.
|
|
1294
|
+
}
|
|
1295
|
+
}
|
|
1296
|
+
|
|
1297
|
+
try {
|
|
1298
|
+
zellijActionSync(["rename-pane", name], surface);
|
|
1299
|
+
} catch {
|
|
1300
|
+
// Optional.
|
|
1301
|
+
}
|
|
1302
|
+
|
|
1303
|
+
return surface;
|
|
1304
|
+
}
|
|
1305
|
+
|
|
1306
|
+
/**
|
|
1307
|
+
* Rename the current tab/window.
|
|
1308
|
+
*/
|
|
1309
|
+
export function renameCurrentTab(title: string): void {
|
|
1310
|
+
if (isHeadlessMode()) return; // no tab to rename
|
|
1311
|
+
|
|
1312
|
+
const backend = requireMuxBackend();
|
|
1313
|
+
|
|
1314
|
+
if (backend === "cmux") {
|
|
1315
|
+
const surfaceId = process.env.CMUX_SURFACE_ID;
|
|
1316
|
+
if (!surfaceId) throw new Error("CMUX_SURFACE_ID not set");
|
|
1317
|
+
execSync(`cmux rename-tab --surface ${shellEscape(surfaceId)} ${shellEscape(title)}`, {
|
|
1318
|
+
encoding: "utf8",
|
|
1319
|
+
});
|
|
1320
|
+
return;
|
|
1321
|
+
}
|
|
1322
|
+
|
|
1323
|
+
if (backend === "muxy") {
|
|
1324
|
+
const paneId = AGENT_MUXY_PANE_ID;
|
|
1325
|
+
if (!paneId) throw new Error("MUXY_PANE_ID not set");
|
|
1326
|
+
execFileSync("muxy", ["rename-pane", "--pane", paneId, title], { encoding: "utf8" });
|
|
1327
|
+
return;
|
|
1328
|
+
}
|
|
1329
|
+
|
|
1330
|
+
if (backend === "tmux") {
|
|
1331
|
+
if (process.env.PI_SUBAGENT_RENAME_TMUX_WINDOW !== "1") {
|
|
1332
|
+
return;
|
|
1333
|
+
}
|
|
1334
|
+
const paneId = process.env.TMUX_PANE;
|
|
1335
|
+
if (!paneId) throw new Error("TMUX_PANE not set");
|
|
1336
|
+
const windowId = execFileSync("tmux", ["display-message", "-p", "-t", paneId, "#{window_id}"], {
|
|
1337
|
+
encoding: "utf8",
|
|
1338
|
+
}).trim();
|
|
1339
|
+
execFileSync("tmux", ["rename-window", "-t", windowId, title], { encoding: "utf8" });
|
|
1340
|
+
return;
|
|
1341
|
+
}
|
|
1342
|
+
|
|
1343
|
+
if (backend === "wezterm") {
|
|
1344
|
+
const paneId = process.env.WEZTERM_PANE;
|
|
1345
|
+
const args = ["cli", "set-tab-title"];
|
|
1346
|
+
if (paneId) args.push("--pane-id", paneId);
|
|
1347
|
+
args.push(title);
|
|
1348
|
+
execFileSync("wezterm", args, { encoding: "utf8" });
|
|
1349
|
+
return;
|
|
1350
|
+
}
|
|
1351
|
+
|
|
1352
|
+
if (backend === "herdr") {
|
|
1353
|
+
// herdr 中 agent 自己 pane 的 tab 名就是用户最关心的。
|
|
1354
|
+
renameHerdrTab(AGENT_HERDR_PANE_ID ?? "", title);
|
|
1355
|
+
return;
|
|
1356
|
+
}
|
|
1357
|
+
|
|
1358
|
+
if (backend === "otty") {
|
|
1359
|
+
// 与 herdr 一致:重命名 agent 自己 pane 所属的 tab,避免覆盖用户的 tab title。
|
|
1360
|
+
renameOttyTab(AGENT_OTTY_PANE_ID ?? "", title);
|
|
1361
|
+
return;
|
|
1362
|
+
}
|
|
1363
|
+
|
|
1364
|
+
// zellij: rename the agent's own pane, not the whole tab. In multi-pane layouts,
|
|
1365
|
+
// rename-tab clobbers the user's tab title whenever a subagent starts or /plan runs.
|
|
1366
|
+
// Closes #21.
|
|
1367
|
+
const paneId = process.env.ZELLIJ_PANE_ID;
|
|
1368
|
+
if (paneId) {
|
|
1369
|
+
zellijActionSync(["rename-pane", title], `pane:${paneId}`);
|
|
1370
|
+
} else {
|
|
1371
|
+
zellijActionSync(["rename-pane", title]);
|
|
1372
|
+
}
|
|
1373
|
+
}
|
|
1374
|
+
|
|
1375
|
+
/**
|
|
1376
|
+
* Rename the agent pane itself when no tab concept exists. For herdr we
|
|
1377
|
+
* delegate to renameHerdrTab which targets the pane's containing tab.
|
|
1378
|
+
*/
|
|
1379
|
+
export { renameHerdrTab };
|
|
1380
|
+
|
|
1381
|
+
/**
|
|
1382
|
+
* 重命名指定 surface(跨后端统一)。
|
|
1383
|
+
* headless surface 无实体 pane,直接 no-op。
|
|
1384
|
+
*/
|
|
1385
|
+
export function renameSurface(surface: string, name: string): void {
|
|
1386
|
+
if (isHeadlessSurface(surface)) return;
|
|
1387
|
+
|
|
1388
|
+
const backend = requireMuxBackend();
|
|
1389
|
+
|
|
1390
|
+
if (backend === "cmux") {
|
|
1391
|
+
renameCmuxSurface(surface, name);
|
|
1392
|
+
return;
|
|
1393
|
+
}
|
|
1394
|
+
if (backend === "muxy") {
|
|
1395
|
+
renameMuxySurface(surface, name);
|
|
1396
|
+
return;
|
|
1397
|
+
}
|
|
1398
|
+
if (backend === "tmux") {
|
|
1399
|
+
// tmux 没有 pane 级命名,退化为所在 window 命名(调用方只对自建 surface 使用)
|
|
1400
|
+
const windowId = execFileSync("tmux", ["display-message", "-p", "-t", surface, "#{window_id}"], {
|
|
1401
|
+
encoding: "utf8",
|
|
1402
|
+
}).trim();
|
|
1403
|
+
execFileSync("tmux", ["rename-window", "-t", windowId, name], { encoding: "utf8" });
|
|
1404
|
+
return;
|
|
1405
|
+
}
|
|
1406
|
+
if (backend === "wezterm") {
|
|
1407
|
+
execFileSync("wezterm", ["cli", "set-tab-title", "--pane-id", surface, name], {
|
|
1408
|
+
encoding: "utf8",
|
|
1409
|
+
});
|
|
1410
|
+
return;
|
|
1411
|
+
}
|
|
1412
|
+
if (backend === "herdr") {
|
|
1413
|
+
renameHerdrPane(surface, name);
|
|
1414
|
+
return;
|
|
1415
|
+
}
|
|
1416
|
+
if (backend === "otty") {
|
|
1417
|
+
renameOttyTab(surface, name);
|
|
1418
|
+
return;
|
|
1419
|
+
}
|
|
1420
|
+
// zellij
|
|
1421
|
+
zellijActionSync(["rename-pane", name], surface);
|
|
1422
|
+
}
|
|
1423
|
+
|
|
1424
|
+
/**
|
|
1425
|
+
* Rename the agent display name on a subagent surface (left sidebar title in herdr).
|
|
1426
|
+
* This requires the agent to be detected first, so call it after pi starts.
|
|
1427
|
+
* Silently no-ops on non-herdr backends or if the agent hasn't been detected yet.
|
|
1428
|
+
*/
|
|
1429
|
+
export function renameAgent(surface: string, name: string): void {
|
|
1430
|
+
if (isHeadlessMode()) return;
|
|
1431
|
+
const backend = getMuxBackend();
|
|
1432
|
+
if (backend === "herdr") {
|
|
1433
|
+
renameHerdrAgent(surface, name);
|
|
1434
|
+
}
|
|
1435
|
+
}
|
|
1436
|
+
|
|
1437
|
+
/**
|
|
1438
|
+
* Rename the current workspace/session where supported.
|
|
1439
|
+
*/
|
|
1440
|
+
export function renameWorkspace(title: string): void {
|
|
1441
|
+
if (isHeadlessMode()) return; // no workspace to rename
|
|
1442
|
+
|
|
1443
|
+
const backend = requireMuxBackend();
|
|
1444
|
+
|
|
1445
|
+
if (backend === "cmux") {
|
|
1446
|
+
execSync(`cmux workspace-action --action rename --title ${shellEscape(title)}`, {
|
|
1447
|
+
encoding: "utf8",
|
|
1448
|
+
});
|
|
1449
|
+
return;
|
|
1450
|
+
}
|
|
1451
|
+
|
|
1452
|
+
if (backend === "muxy") {
|
|
1453
|
+
// Muxy doesn't have a separate workspace concept; rename is handled at pane level
|
|
1454
|
+
return;
|
|
1455
|
+
}
|
|
1456
|
+
|
|
1457
|
+
if (backend === "tmux") {
|
|
1458
|
+
if (process.env.PI_SUBAGENT_RENAME_TMUX_SESSION !== "1") {
|
|
1459
|
+
return;
|
|
1460
|
+
}
|
|
1461
|
+
|
|
1462
|
+
const paneId = process.env.TMUX_PANE;
|
|
1463
|
+
if (!paneId) throw new Error("TMUX_PANE not set");
|
|
1464
|
+
const sessionId = execFileSync(
|
|
1465
|
+
"tmux",
|
|
1466
|
+
["display-message", "-p", "-t", paneId, "#{session_id}"],
|
|
1467
|
+
{
|
|
1468
|
+
encoding: "utf8",
|
|
1469
|
+
},
|
|
1470
|
+
).trim();
|
|
1471
|
+
execFileSync("tmux", ["rename-session", "-t", sessionId, title], { encoding: "utf8" });
|
|
1472
|
+
return;
|
|
1473
|
+
}
|
|
1474
|
+
|
|
1475
|
+
if (backend === "wezterm") {
|
|
1476
|
+
const paneId = process.env.WEZTERM_PANE;
|
|
1477
|
+
const args = ["cli", "set-window-title"];
|
|
1478
|
+
if (paneId) args.push("--pane-id", paneId);
|
|
1479
|
+
args.push(title);
|
|
1480
|
+
try {
|
|
1481
|
+
execFileSync("wezterm", args, { encoding: "utf8" });
|
|
1482
|
+
} catch {
|
|
1483
|
+
// Optional — window title is cosmetic.
|
|
1484
|
+
}
|
|
1485
|
+
return;
|
|
1486
|
+
}
|
|
1487
|
+
|
|
1488
|
+
if (backend === "herdr") {
|
|
1489
|
+
renameHerdrWorkspace(title);
|
|
1490
|
+
return;
|
|
1491
|
+
}
|
|
1492
|
+
|
|
1493
|
+
if (backend === "otty") {
|
|
1494
|
+
// Otty 没有显式 workspace rename 命令;rename-tab 已在 renameCurrentTab 中调用。
|
|
1495
|
+
return;
|
|
1496
|
+
}
|
|
1497
|
+
|
|
1498
|
+
// Skip session rename for zellij. rename-session renames the socket file
|
|
1499
|
+
// but the ZELLIJ_SESSION_NAME env var in the parent process keeps the old
|
|
1500
|
+
// name, so all subsequent `zellij action ...` CLI calls fail with
|
|
1501
|
+
// "There is no active session!" because the CLI can't find the socket.
|
|
1502
|
+
// Additionally, pi titles often contain special characters (em dashes,
|
|
1503
|
+
// spaces) that fail zellij's session name validation on lookup.
|
|
1504
|
+
// rename-tab (called separately) is sufficient for user-visible naming.
|
|
1505
|
+
}
|
|
1506
|
+
|
|
1507
|
+
/**
|
|
1508
|
+
* Re-export herdr workspace rename so callers can swap implementations by backend.
|
|
1509
|
+
* Internal: gated by PI_SUBAGENT_RENAME_HERDR_WORKSPACE env var.
|
|
1510
|
+
*/
|
|
1511
|
+
export { renameHerdrWorkspace };
|
|
1512
|
+
|
|
1513
|
+
/**
|
|
1514
|
+
* Send a command string to a pane and execute it.
|
|
1515
|
+
*/
|
|
1516
|
+
export function sendCommand(surface: string, command: string): void {
|
|
1517
|
+
// Headless mode: commands are sent via sendLongCommand which spawns the process directly.
|
|
1518
|
+
// Standalone sendCommand on a headless surface is a no-op — there's no running pane to send to.
|
|
1519
|
+
if (isHeadlessSurface(surface)) {
|
|
1520
|
+
return;
|
|
1521
|
+
}
|
|
1522
|
+
|
|
1523
|
+
const backend = requireMuxBackend();
|
|
1524
|
+
|
|
1525
|
+
if (backend === "cmux" || backend === "muxy") {
|
|
1526
|
+
if (backend === "muxy") {
|
|
1527
|
+
execFileSync("muxy", ["send", "--pane", surface, command], { encoding: "utf8" });
|
|
1528
|
+
execFileSync("muxy", ["send-keys", "--pane", surface, "Enter"], { encoding: "utf8" });
|
|
1529
|
+
return;
|
|
1530
|
+
}
|
|
1531
|
+
execSync(`cmux send --surface ${shellEscape(surface)} ${shellEscape(command + "\n")}`, {
|
|
1532
|
+
encoding: "utf8",
|
|
1533
|
+
});
|
|
1534
|
+
return;
|
|
1535
|
+
}
|
|
1536
|
+
|
|
1537
|
+
if (backend === "tmux") {
|
|
1538
|
+
execFileSync("tmux", ["send-keys", "-t", surface, "-l", command], { encoding: "utf8" });
|
|
1539
|
+
execFileSync("tmux", ["send-keys", "-t", surface, "Enter"], { encoding: "utf8" });
|
|
1540
|
+
return;
|
|
1541
|
+
}
|
|
1542
|
+
|
|
1543
|
+
if (backend === "wezterm") {
|
|
1544
|
+
execFileSync(
|
|
1545
|
+
"wezterm",
|
|
1546
|
+
["cli", "send-text", "--pane-id", surface, "--no-paste", command + "\n"],
|
|
1547
|
+
{ encoding: "utf8" },
|
|
1548
|
+
);
|
|
1549
|
+
return;
|
|
1550
|
+
}
|
|
1551
|
+
|
|
1552
|
+
if (backend === "herdr") {
|
|
1553
|
+
sendHerdrCommand(surface, command);
|
|
1554
|
+
return;
|
|
1555
|
+
}
|
|
1556
|
+
|
|
1557
|
+
if (backend === "otty") {
|
|
1558
|
+
sendOttyCommand(surface, command);
|
|
1559
|
+
return;
|
|
1560
|
+
}
|
|
1561
|
+
|
|
1562
|
+
zellijActionSync(["write-chars", command], surface);
|
|
1563
|
+
zellijActionSync(["write", "13"], surface);
|
|
1564
|
+
}
|
|
1565
|
+
|
|
1566
|
+
/**
|
|
1567
|
+
* Send one Escape keypress to an active pane.
|
|
1568
|
+
*/
|
|
1569
|
+
export function sendEscape(surface: string): void {
|
|
1570
|
+
if (isHeadlessSurface(surface)) {
|
|
1571
|
+
sendHeadlessEscape(surface);
|
|
1572
|
+
return;
|
|
1573
|
+
}
|
|
1574
|
+
|
|
1575
|
+
const backend = requireMuxBackend();
|
|
1576
|
+
|
|
1577
|
+
if (backend === "cmux") {
|
|
1578
|
+
execFileSync("cmux", ["send", "--surface", surface, "\u001b"], { encoding: "utf8" });
|
|
1579
|
+
return;
|
|
1580
|
+
}
|
|
1581
|
+
|
|
1582
|
+
if (backend === "muxy") {
|
|
1583
|
+
// Use send (raw bytes) instead of send-keys Escape — send-keys may not
|
|
1584
|
+
// correctly translate the "Escape" key name to an actual ESC at the PTY level.
|
|
1585
|
+
// This matches how cmux and wezterm send the actual escape character (0x1B).
|
|
1586
|
+
execFileSync("muxy", ["send", "--pane", surface, "\u001b"], { encoding: "utf8" });
|
|
1587
|
+
return;
|
|
1588
|
+
}
|
|
1589
|
+
|
|
1590
|
+
if (backend === "tmux") {
|
|
1591
|
+
execFileSync("tmux", ["send-keys", "-t", surface, "Escape"], { encoding: "utf8" });
|
|
1592
|
+
return;
|
|
1593
|
+
}
|
|
1594
|
+
|
|
1595
|
+
if (backend === "wezterm") {
|
|
1596
|
+
execFileSync("wezterm", ["cli", "send-text", "--pane-id", surface, "--no-paste", "\u001b"], {
|
|
1597
|
+
encoding: "utf8",
|
|
1598
|
+
});
|
|
1599
|
+
return;
|
|
1600
|
+
}
|
|
1601
|
+
|
|
1602
|
+
if (backend === "herdr") {
|
|
1603
|
+
sendHerdrEscape(surface);
|
|
1604
|
+
return;
|
|
1605
|
+
}
|
|
1606
|
+
|
|
1607
|
+
if (backend === "otty") {
|
|
1608
|
+
sendOttyEscape(surface);
|
|
1609
|
+
return;
|
|
1610
|
+
}
|
|
1611
|
+
|
|
1612
|
+
zellijActionSync(["write", "27"], surface);
|
|
1613
|
+
}
|
|
1614
|
+
|
|
1615
|
+
/**
|
|
1616
|
+
* Send a long command to a pane by writing it to a script file first.
|
|
1617
|
+
* This avoids terminal line-wrapping issues that break commands exceeding the
|
|
1618
|
+
* pane's column width when sent character-by-character via sendCommand.
|
|
1619
|
+
*
|
|
1620
|
+
* By default the script is written to a temp directory, but callers can pass a
|
|
1621
|
+
* stable path (for example under session artifacts) so the exact invocation is
|
|
1622
|
+
* preserved for debugging.
|
|
1623
|
+
*
|
|
1624
|
+
* Returns the script path.
|
|
1625
|
+
*/
|
|
1626
|
+
export function sendLongCommand(
|
|
1627
|
+
surface: string,
|
|
1628
|
+
command: string,
|
|
1629
|
+
options?: { scriptPath?: string; scriptPreamble?: string },
|
|
1630
|
+
): string {
|
|
1631
|
+
// Headless mode: spawn as a background child process instead of sending to a mux pane
|
|
1632
|
+
if (isHeadlessSurface(surface)) {
|
|
1633
|
+
const logFile = options?.scriptPath
|
|
1634
|
+
? options.scriptPath.replace(/\.sh$/, ".log")
|
|
1635
|
+
: undefined;
|
|
1636
|
+
// Write the script file for debugging even in headless mode
|
|
1637
|
+
const scriptPath =
|
|
1638
|
+
options?.scriptPath ??
|
|
1639
|
+
join(
|
|
1640
|
+
tmpdir(),
|
|
1641
|
+
"pi-subagent-scripts",
|
|
1642
|
+
`cmd-${Date.now()}-${Math.random().toString(16).slice(2, 8)}.sh`,
|
|
1643
|
+
);
|
|
1644
|
+
mkdirSync(dirname(scriptPath), { recursive: true });
|
|
1645
|
+
const scriptParts = ["#!/bin/bash"];
|
|
1646
|
+
if (options?.scriptPreamble) {
|
|
1647
|
+
scriptParts.push(options.scriptPreamble.trimEnd());
|
|
1648
|
+
}
|
|
1649
|
+
scriptParts.push(command);
|
|
1650
|
+
writeFileSync(scriptPath, scriptParts.join("\n") + "\n", { mode: 0o755 });
|
|
1651
|
+
|
|
1652
|
+
spawnHeadlessProcess(surface, "subagent", `bash ${shellEscape(scriptPath)}`, {
|
|
1653
|
+
cwd: process.cwd(),
|
|
1654
|
+
env: { PI_SUBAGENT_HEADLESS: "1" },
|
|
1655
|
+
});
|
|
1656
|
+
return scriptPath;
|
|
1657
|
+
}
|
|
1658
|
+
|
|
1659
|
+
const scriptPath =
|
|
1660
|
+
options?.scriptPath ??
|
|
1661
|
+
join(
|
|
1662
|
+
tmpdir(),
|
|
1663
|
+
"pi-subagent-scripts",
|
|
1664
|
+
`cmd-${Date.now()}-${Math.random().toString(16).slice(2, 8)}.sh`,
|
|
1665
|
+
);
|
|
1666
|
+
mkdirSync(dirname(scriptPath), { recursive: true });
|
|
1667
|
+
|
|
1668
|
+
const scriptParts = ["#!/bin/bash"];
|
|
1669
|
+
if (options?.scriptPreamble) {
|
|
1670
|
+
scriptParts.push(options.scriptPreamble.trimEnd());
|
|
1671
|
+
}
|
|
1672
|
+
scriptParts.push(command);
|
|
1673
|
+
|
|
1674
|
+
writeFileSync(scriptPath, scriptParts.join("\n") + "\n", {
|
|
1675
|
+
mode: 0o755,
|
|
1676
|
+
});
|
|
1677
|
+
sendCommand(surface, `bash ${shellEscape(scriptPath)}`);
|
|
1678
|
+
return scriptPath;
|
|
1679
|
+
}
|
|
1680
|
+
|
|
1681
|
+
/**
|
|
1682
|
+
* Read the screen contents of a pane (sync).
|
|
1683
|
+
*/
|
|
1684
|
+
export function readScreen(surface: string, lines = 50): string {
|
|
1685
|
+
if (isHeadlessSurface(surface)) {
|
|
1686
|
+
return readHeadlessScreen(surface, lines);
|
|
1687
|
+
}
|
|
1688
|
+
|
|
1689
|
+
const backend = requireMuxBackend();
|
|
1690
|
+
|
|
1691
|
+
if (backend === "cmux") {
|
|
1692
|
+
return execSync(`cmux read-screen --surface ${shellEscape(surface)} --lines ${lines}`, {
|
|
1693
|
+
encoding: "utf8",
|
|
1694
|
+
});
|
|
1695
|
+
}
|
|
1696
|
+
|
|
1697
|
+
if (backend === "muxy") {
|
|
1698
|
+
return execFileSync("muxy", ["read-screen", "--pane", surface, "--lines", String(lines)], {
|
|
1699
|
+
encoding: "utf8",
|
|
1700
|
+
});
|
|
1701
|
+
}
|
|
1702
|
+
|
|
1703
|
+
if (backend === "tmux") {
|
|
1704
|
+
return execFileSync(
|
|
1705
|
+
"tmux",
|
|
1706
|
+
["capture-pane", "-p", "-t", surface, "-S", `-${Math.max(1, lines)}`],
|
|
1707
|
+
{
|
|
1708
|
+
encoding: "utf8",
|
|
1709
|
+
},
|
|
1710
|
+
);
|
|
1711
|
+
}
|
|
1712
|
+
|
|
1713
|
+
if (backend === "wezterm") {
|
|
1714
|
+
const raw = execFileSync(
|
|
1715
|
+
"wezterm",
|
|
1716
|
+
["cli", "get-text", "--pane-id", surface],
|
|
1717
|
+
{ encoding: "utf8" },
|
|
1718
|
+
);
|
|
1719
|
+
return tailLines(raw, lines);
|
|
1720
|
+
}
|
|
1721
|
+
|
|
1722
|
+
if (backend === "herdr") {
|
|
1723
|
+
// 用 recent 读最近 scrollback,确保 sentinel 检测可靠。
|
|
1724
|
+
// visible 只读当前 viewport,pi 退出后终端恢复主缓冲区,sentinel 可能不在 viewport 内,
|
|
1725
|
+
// 导致 pollForExit 检测不到退出、closeSurface 不执行、分屏关不掉。
|
|
1726
|
+
return readHerdrScreen(surface, lines, "recent");
|
|
1727
|
+
}
|
|
1728
|
+
|
|
1729
|
+
if (backend === "otty") {
|
|
1730
|
+
return readOttyScreen(surface, lines);
|
|
1731
|
+
}
|
|
1732
|
+
|
|
1733
|
+
// Zellij 0.44+: use --pane-id flag + stdout instead of env var + temp file.
|
|
1734
|
+
// The ZELLIJ_PANE_ID env var doesn't reliably target other panes for dump-screen,
|
|
1735
|
+
// and --path may silently fail to create the file. Stdout capture is robust.
|
|
1736
|
+
const paneId = zellijPaneId(surface);
|
|
1737
|
+
const raw = execFileSync(
|
|
1738
|
+
"zellij",
|
|
1739
|
+
["action", "dump-screen", "--pane-id", paneId],
|
|
1740
|
+
{ encoding: "utf8" },
|
|
1741
|
+
);
|
|
1742
|
+
return tailLines(raw, lines);
|
|
1743
|
+
}
|
|
1744
|
+
|
|
1745
|
+
/**
|
|
1746
|
+
* Read the screen contents of a pane (async).
|
|
1747
|
+
*/
|
|
1748
|
+
export async function readScreenAsync(surface: string, lines = 50): Promise<string> {
|
|
1749
|
+
if (isHeadlessSurface(surface)) {
|
|
1750
|
+
return readHeadlessScreen(surface, lines);
|
|
1751
|
+
}
|
|
1752
|
+
|
|
1753
|
+
const backend = requireMuxBackend();
|
|
1754
|
+
|
|
1755
|
+
if (backend === "cmux") {
|
|
1756
|
+
const { stdout } = await execFileAsync(
|
|
1757
|
+
"cmux",
|
|
1758
|
+
["read-screen", "--surface", surface, "--lines", String(lines)],
|
|
1759
|
+
{ encoding: "utf8" },
|
|
1760
|
+
);
|
|
1761
|
+
return stdout;
|
|
1762
|
+
}
|
|
1763
|
+
|
|
1764
|
+
if (backend === "muxy") {
|
|
1765
|
+
const { stdout } = await execFileAsync(
|
|
1766
|
+
"muxy",
|
|
1767
|
+
["read-screen", "--pane", surface, "--lines", String(lines)],
|
|
1768
|
+
{ encoding: "utf8" },
|
|
1769
|
+
);
|
|
1770
|
+
return stdout;
|
|
1771
|
+
}
|
|
1772
|
+
|
|
1773
|
+
if (backend === "tmux") {
|
|
1774
|
+
const { stdout } = await execFileAsync(
|
|
1775
|
+
"tmux",
|
|
1776
|
+
["capture-pane", "-p", "-t", surface, "-S", `-${Math.max(1, lines)}`],
|
|
1777
|
+
{ encoding: "utf8" },
|
|
1778
|
+
);
|
|
1779
|
+
return stdout;
|
|
1780
|
+
}
|
|
1781
|
+
|
|
1782
|
+
if (backend === "wezterm") {
|
|
1783
|
+
const { stdout } = await execFileAsync(
|
|
1784
|
+
"wezterm",
|
|
1785
|
+
["cli", "get-text", "--pane-id", surface],
|
|
1786
|
+
{ encoding: "utf8" },
|
|
1787
|
+
);
|
|
1788
|
+
return tailLines(stdout, lines);
|
|
1789
|
+
}
|
|
1790
|
+
|
|
1791
|
+
if (backend === "herdr") {
|
|
1792
|
+
return readHerdrScreen(surface, lines, "recent");
|
|
1793
|
+
}
|
|
1794
|
+
|
|
1795
|
+
if (backend === "otty") {
|
|
1796
|
+
return readOttyScreen(surface, lines);
|
|
1797
|
+
}
|
|
1798
|
+
|
|
1799
|
+
// Zellij 0.44+: use --pane-id flag + stdout instead of env var + temp file.
|
|
1800
|
+
const paneId = zellijPaneId(surface);
|
|
1801
|
+
const { stdout } = await execFileAsync(
|
|
1802
|
+
"zellij",
|
|
1803
|
+
["action", "dump-screen", "--pane-id", paneId],
|
|
1804
|
+
{ encoding: "utf8" },
|
|
1805
|
+
);
|
|
1806
|
+
return tailLines(stdout, lines);
|
|
1807
|
+
}
|
|
1808
|
+
|
|
1809
|
+
/**
|
|
1810
|
+
* Close a pane.
|
|
1811
|
+
*/
|
|
1812
|
+
export function closeSurface(surface: string): void {
|
|
1813
|
+
if (isHeadlessSurface(surface)) {
|
|
1814
|
+
closeHeadlessSurface(surface);
|
|
1815
|
+
return;
|
|
1816
|
+
}
|
|
1817
|
+
|
|
1818
|
+
const backend = requireMuxBackend();
|
|
1819
|
+
|
|
1820
|
+
if (backend === "cmux") {
|
|
1821
|
+
execSync(`cmux close-surface --surface ${shellEscape(surface)}`, {
|
|
1822
|
+
encoding: "utf8",
|
|
1823
|
+
});
|
|
1824
|
+
return;
|
|
1825
|
+
}
|
|
1826
|
+
|
|
1827
|
+
if (backend === "muxy") {
|
|
1828
|
+
execFileSync("muxy", ["close-pane", "--pane", surface], { encoding: "utf8" });
|
|
1829
|
+
// 从 state.panes 移除已关闭的 subagent,避免僵尸 ID 累积导致
|
|
1830
|
+
// 后续 `muxy split-* --from <死ID>` 走 fallback 行为(从当前焦点 pane 拆出)。
|
|
1831
|
+
// 解析 JSON 状态,splice 掉已关闭的 surface,pos 相应回退;
|
|
1832
|
+
// 全部 subagent 都关闭后删除整个 marker,下次创建走"首次"分支。
|
|
1833
|
+
const markerFile = `/tmp/muxy-subagent-pane-${AGENT_MUXY_PANE_ID || "default"}`;
|
|
1834
|
+
try {
|
|
1835
|
+
const parsed = JSON.parse(readFileSync(markerFile, "utf8"));
|
|
1836
|
+
if (parsed && Array.isArray(parsed.panes)) {
|
|
1837
|
+
const idx = parsed.panes.indexOf(surface);
|
|
1838
|
+
if (idx >= 0) {
|
|
1839
|
+
const beforePanes = [...parsed.panes];
|
|
1840
|
+
const beforePos = parsed.pos;
|
|
1841
|
+
parsed.panes.splice(idx, 1);
|
|
1842
|
+
// pos 指向"下一个要 split 的索引",左侧删除时 pos 需要回退
|
|
1843
|
+
if (typeof parsed.pos === "number" && idx < parsed.pos) {
|
|
1844
|
+
parsed.pos = Math.max(0, parsed.pos - 1);
|
|
1845
|
+
}
|
|
1846
|
+
if (parsed.panes.length === 0) {
|
|
1847
|
+
rmSync(markerFile);
|
|
1848
|
+
muxLog(
|
|
1849
|
+
`[muxy close] pane=${surface} panes=${JSON.stringify(beforePanes)} -> [] pos=${beforePos} -> <marker-removed>\n`,
|
|
1850
|
+
);
|
|
1851
|
+
} else {
|
|
1852
|
+
writeFileSync(markerFile, JSON.stringify(parsed));
|
|
1853
|
+
muxLog(
|
|
1854
|
+
`[muxy close] pane=${surface} panes=${JSON.stringify(beforePanes)} -> ${JSON.stringify(parsed.panes)} pos=${beforePos} -> ${parsed.pos}\n`,
|
|
1855
|
+
);
|
|
1856
|
+
}
|
|
1857
|
+
} else {
|
|
1858
|
+
muxLog(
|
|
1859
|
+
`[muxy close] pane=${surface} (not in marker state.panes, marker unchanged)\n`,
|
|
1860
|
+
);
|
|
1861
|
+
}
|
|
1862
|
+
}
|
|
1863
|
+
} catch (e) {
|
|
1864
|
+
muxLog(
|
|
1865
|
+
`[muxy close] pane=${surface} (no marker found or parse error, nothing to clean)\n`,
|
|
1866
|
+
);
|
|
1867
|
+
}
|
|
1868
|
+
return;
|
|
1869
|
+
}
|
|
1870
|
+
|
|
1871
|
+
if (backend === "tmux") {
|
|
1872
|
+
execFileSync("tmux", ["kill-pane", "-t", surface], { encoding: "utf8" });
|
|
1873
|
+
return;
|
|
1874
|
+
}
|
|
1875
|
+
|
|
1876
|
+
if (backend === "wezterm") {
|
|
1877
|
+
execFileSync("wezterm", ["cli", "kill-pane", "--pane-id", surface], {
|
|
1878
|
+
encoding: "utf8" },
|
|
1879
|
+
);
|
|
1880
|
+
return;
|
|
1881
|
+
}
|
|
1882
|
+
|
|
1883
|
+
if (backend === "herdr") {
|
|
1884
|
+
closeHerdrSurface(surface);
|
|
1885
|
+
return;
|
|
1886
|
+
}
|
|
1887
|
+
|
|
1888
|
+
if (backend === "otty") {
|
|
1889
|
+
closeOttySurface(surface);
|
|
1890
|
+
return;
|
|
1891
|
+
}
|
|
1892
|
+
|
|
1893
|
+
zellijActionSync(["close-pane"], surface);
|
|
1894
|
+
}
|
|
1895
|
+
|
|
1896
|
+
export interface PollResult {
|
|
1897
|
+
/** How the subagent exited */
|
|
1898
|
+
reason: "done" | "ping" | "structured_output" | "sentinel";
|
|
1899
|
+
/** Shell exit code (from sentinel). 0 for file-based exits. */
|
|
1900
|
+
exitCode: number;
|
|
1901
|
+
/** Ping data if reason is "ping" */
|
|
1902
|
+
ping?: { name: string; message: string };
|
|
1903
|
+
/** Validated structured output if reason is "structured_output" */
|
|
1904
|
+
structuredOutput?: unknown;
|
|
1905
|
+
}
|
|
1906
|
+
|
|
1907
|
+
/**
|
|
1908
|
+
* Poll until the subagent exits. Checks for a `.exit` sidecar file first
|
|
1909
|
+
* (written by subagent_done / caller_ping), falling back to the terminal
|
|
1910
|
+
* sentinel for crash detection.
|
|
1911
|
+
*/
|
|
1912
|
+
export async function pollForExit(
|
|
1913
|
+
surface: string,
|
|
1914
|
+
signal: AbortSignal,
|
|
1915
|
+
options: {
|
|
1916
|
+
interval: number;
|
|
1917
|
+
sessionFile?: string;
|
|
1918
|
+
sentinelFile?: string;
|
|
1919
|
+
onTick?: (elapsed: number) => void;
|
|
1920
|
+
},
|
|
1921
|
+
): Promise<PollResult> {
|
|
1922
|
+
const start = Date.now();
|
|
1923
|
+
const isHeadless = isHeadlessSurface(surface);
|
|
1924
|
+
|
|
1925
|
+
for (;;) {
|
|
1926
|
+
if (signal.aborted) {
|
|
1927
|
+
muxLog(`[pollForExit] ABORTED at loop start surface=${surface} elapsed=${Date.now() - start}ms — signal was aborted before/synchronously after watchSubagent entered poll loop (likely stale POLL_ABORT_KEY controller). Throwing "Aborted while waiting..."\n`);
|
|
1928
|
+
throw new Error("Aborted while waiting for subagent to finish");
|
|
1929
|
+
}
|
|
1930
|
+
|
|
1931
|
+
// Fast path: check for .exit sidecar file (written by subagent_done / caller_ping / structured_output)
|
|
1932
|
+
if (options.sessionFile) {
|
|
1933
|
+
const exitFile = `${options.sessionFile}.exit`;
|
|
1934
|
+
try {
|
|
1935
|
+
if (existsSync(exitFile)) {
|
|
1936
|
+
let data: any;
|
|
1937
|
+
try {
|
|
1938
|
+
data = JSON.parse(readFileSync(exitFile, "utf8"));
|
|
1939
|
+
} catch (parseErr: any) {
|
|
1940
|
+
// .exit 文件存在但解析失败 — subagent 可能被 SIGKILL 半截写入。
|
|
1941
|
+
// 不删文件,留着供事后诊断;下次循环再试一次,如果始终半截则由超时兜底。
|
|
1942
|
+
muxLog(
|
|
1943
|
+
`[pollForExit] FAST PATH BUG: ${exitFile} exists but JSON.parse failed: ${parseErr?.message ?? String(parseErr)}\n` +
|
|
1944
|
+
`[pollForExit] contents(raw)=${JSON.stringify((() => { try { return readFileSync(exitFile, "utf8"); } catch { return "<unreadable>"; } })())}\n`,
|
|
1945
|
+
);
|
|
1946
|
+
throw parseErr; // 跳到外层 catch 不静默吞
|
|
1947
|
+
}
|
|
1948
|
+
// 双重确认:子 pi 进程必须已启动(.jsonl 存在 + 非空)。
|
|
1949
|
+
// 防止 herdr 后端在子 pi 根本没启动时 existsSync 假阳性命中
|
|
1950
|
+
// 残留 .exit 文件,导致 6ms/28ms 内 false positive return。
|
|
1951
|
+
const sessionJsonl = options.sessionFile;
|
|
1952
|
+
try {
|
|
1953
|
+
if (!existsSync(sessionJsonl) || statSync(sessionJsonl).size === 0) {
|
|
1954
|
+
rmSync(exitFile, { force: true });
|
|
1955
|
+
muxLog(
|
|
1956
|
+
`[pollForExit] fast path FALSE POSITIVE: ${exitFile} type=${data?.type} but ${sessionJsonl} is ` +
|
|
1957
|
+
`${existsSync(sessionJsonl) ? `empty (0 bytes)` : `missing`} — subagent never started, ` +
|
|
1958
|
+
`deleting stale .exit and continuing poll\n`,
|
|
1959
|
+
);
|
|
1960
|
+
// 跳到外层 sleep + 继续循环(不是 return)
|
|
1961
|
+
throw Object.assign(new Error("subprocess not started"), { code: "SUBPROCESS_NOT_STARTED" });
|
|
1962
|
+
}
|
|
1963
|
+
} catch (e2: any) {
|
|
1964
|
+
if (e2?.code === "SUBPROCESS_NOT_STARTED") throw e2;
|
|
1965
|
+
// stat 失败不影响 — 继续正常 fast path
|
|
1966
|
+
muxLog(`[pollForExit] session jsonl check failed (non-fatal): ${e2?.message ?? String(e2)}\n`);
|
|
1967
|
+
}
|
|
1968
|
+
rmSync(exitFile, { force: true });
|
|
1969
|
+
muxLog(`[pollForExit] fast path hit exitFile=${exitFile} type=${data?.type}\n`);
|
|
1970
|
+
if (data.type === "ping") {
|
|
1971
|
+
return { reason: "ping", exitCode: 0, ping: { name: data.name, message: data.message } };
|
|
1972
|
+
}
|
|
1973
|
+
if (data.type === "structured_output") {
|
|
1974
|
+
return { reason: "structured_output", exitCode: 0, structuredOutput: data.value };
|
|
1975
|
+
}
|
|
1976
|
+
return { reason: "done", exitCode: 0 };
|
|
1977
|
+
}
|
|
1978
|
+
} catch (e: any) {
|
|
1979
|
+
if (e?.code === "SUBPROCESS_NOT_STARTED") {
|
|
1980
|
+
// 不是真正的错误 — .exit 是残留文件,已删除。继续 slow path。
|
|
1981
|
+
// 这里不 return,让循环继续到 slow path 轮询。
|
|
1982
|
+
} else if (e?.code !== "ENOENT") {
|
|
1983
|
+
muxLog(
|
|
1984
|
+
`[pollForExit] fast path error sessionFile=${options.sessionFile} err=${e?.message ?? String(e)}\n`,
|
|
1985
|
+
);
|
|
1986
|
+
}
|
|
1987
|
+
}
|
|
1988
|
+
}
|
|
1989
|
+
|
|
1990
|
+
// Check Claude sentinel file (written by plugin Stop hook)
|
|
1991
|
+
if (options.sentinelFile) {
|
|
1992
|
+
try {
|
|
1993
|
+
if (existsSync(options.sentinelFile)) {
|
|
1994
|
+
muxLog(`[pollForExit] sentinel file hit path=${options.sentinelFile}\n`);
|
|
1995
|
+
return { reason: "sentinel", exitCode: 0 };
|
|
1996
|
+
}
|
|
1997
|
+
} catch (e: any) {
|
|
1998
|
+
muxLog(
|
|
1999
|
+
`[pollForExit] sentinel file check error path=${options.sentinelFile} err=${e?.message ?? String(e)}\n`,
|
|
2000
|
+
);
|
|
2001
|
+
}
|
|
2002
|
+
}
|
|
2003
|
+
|
|
2004
|
+
// Headless mode: check if the child process has exited
|
|
2005
|
+
if (isHeadless) {
|
|
2006
|
+
const headlessExit = getHeadlessProcessExit(surface);
|
|
2007
|
+
if (headlessExit) {
|
|
2008
|
+
const { exitCode } = await Promise.race([
|
|
2009
|
+
headlessExit,
|
|
2010
|
+
new Promise<never>((_, reject) =>
|
|
2011
|
+
setTimeout(() => reject(new Error("timeout")), 100),
|
|
2012
|
+
),
|
|
2013
|
+
]).catch(() => ({ exitCode: null as number | null }));
|
|
2014
|
+
if (exitCode !== null && exitCode !== undefined) {
|
|
2015
|
+
drainHeadlessProcess(surface);
|
|
2016
|
+
return { reason: "sentinel", exitCode };
|
|
2017
|
+
}
|
|
2018
|
+
}
|
|
2019
|
+
}
|
|
2020
|
+
|
|
2021
|
+
// Slow path: read terminal screen for sentinel (crash detection).
|
|
2022
|
+
// 后台 tab 的 herdr 可能节流/陈旧化屏幕 buffer,所以多读一些行提高命中率。
|
|
2023
|
+
if (!isHeadless) {
|
|
2024
|
+
let screen = "";
|
|
2025
|
+
let readErr: unknown = null;
|
|
2026
|
+
try {
|
|
2027
|
+
screen = await readScreenAsync(surface, 200);
|
|
2028
|
+
} catch (e) {
|
|
2029
|
+
readErr = e;
|
|
2030
|
+
}
|
|
2031
|
+
|
|
2032
|
+
if (readErr) {
|
|
2033
|
+
// Surface may have been destroyed — check if .exit file appeared in the meantime
|
|
2034
|
+
muxLog(
|
|
2035
|
+
`[pollForExit] slow path read screen FAILED surface=${surface} err=${(readErr as Error)?.message ?? String(readErr)}\n`,
|
|
2036
|
+
);
|
|
2037
|
+
if (options.sessionFile) {
|
|
2038
|
+
const exitFile = `${options.sessionFile}.exit`;
|
|
2039
|
+
try {
|
|
2040
|
+
if (existsSync(exitFile)) {
|
|
2041
|
+
const data = JSON.parse(readFileSync(exitFile, "utf8"));
|
|
2042
|
+
rmSync(exitFile, { force: true });
|
|
2043
|
+
muxLog(`[pollForExit] recovery via .exit after screen read failure file=${exitFile} type=${data?.type}\n`);
|
|
2044
|
+
if (data.type === "ping") {
|
|
2045
|
+
return { reason: "ping", exitCode: 0, ping: { name: data.name, message: data.message } };
|
|
2046
|
+
}
|
|
2047
|
+
if (data.type === "structured_output") {
|
|
2048
|
+
return { reason: "structured_output", exitCode: 0, structuredOutput: data.value };
|
|
2049
|
+
}
|
|
2050
|
+
return { reason: "done", exitCode: 0 };
|
|
2051
|
+
}
|
|
2052
|
+
} catch (e2: any) {
|
|
2053
|
+
muxLog(
|
|
2054
|
+
`[pollForExit] recovery .exit check FAILED file=${exitFile} err=${e2?.message ?? String(e2)}\n`,
|
|
2055
|
+
);
|
|
2056
|
+
}
|
|
2057
|
+
}
|
|
2058
|
+
} else {
|
|
2059
|
+
const match = screen.match(/__SUBAGENT_DONE_(\d+)__/);
|
|
2060
|
+
if (match) {
|
|
2061
|
+
muxLog(`[pollForExit] slow path sentinel hit surface=${surface} exitCode=${match[1]}\n`);
|
|
2062
|
+
return { reason: "sentinel", exitCode: parseInt(match[1], 10) };
|
|
2063
|
+
}
|
|
2064
|
+
// 每 10 秒记一次屏幕尾部快照,方便事后看到底是屏幕没更新还是 sentinel 滚出。
|
|
2065
|
+
if (Date.now() - start > 0 && Math.floor((Date.now() - start) / 1000) % 10 === 0) {
|
|
2066
|
+
muxLog(
|
|
2067
|
+
`[pollForExit] slow path no sentinel surface=${surface} tail=${JSON.stringify(screen.slice(-200))}\n`,
|
|
2068
|
+
);
|
|
2069
|
+
}
|
|
2070
|
+
}
|
|
2071
|
+
} else if (options.sessionFile) {
|
|
2072
|
+
// Headless mode: check .exit sidecar as fallback in case process exited without sidecar
|
|
2073
|
+
const exitFile = `${options.sessionFile}.exit`;
|
|
2074
|
+
try {
|
|
2075
|
+
if (existsSync(exitFile)) {
|
|
2076
|
+
const data = JSON.parse(readFileSync(exitFile, "utf8"));
|
|
2077
|
+
rmSync(exitFile, { force: true });
|
|
2078
|
+
muxLog(`[pollForExit] headless .exit hit file=${exitFile} type=${data?.type}\n`);
|
|
2079
|
+
if (data.type === "ping") {
|
|
2080
|
+
return { reason: "ping", exitCode: 0, ping: { name: data.name, message: data.message } };
|
|
2081
|
+
}
|
|
2082
|
+
if (data.type === "structured_output") {
|
|
2083
|
+
return { reason: "structured_output", exitCode: 0, structuredOutput: data.value };
|
|
2084
|
+
}
|
|
2085
|
+
return { reason: "done", exitCode: 0 };
|
|
2086
|
+
}
|
|
2087
|
+
} catch (e: any) {
|
|
2088
|
+
muxLog(
|
|
2089
|
+
`[pollForExit] headless .exit check FAILED file=${exitFile} err=${e?.message ?? String(e)}\n`,
|
|
2090
|
+
);
|
|
2091
|
+
}
|
|
2092
|
+
}
|
|
2093
|
+
|
|
2094
|
+
const elapsed = Math.floor((Date.now() - start) / 1000);
|
|
2095
|
+
options.onTick?.(elapsed);
|
|
2096
|
+
|
|
2097
|
+
await new Promise<void>((resolve, reject) => {
|
|
2098
|
+
if (signal.aborted) return reject(new Error("Aborted"));
|
|
2099
|
+
const timer = setTimeout(() => {
|
|
2100
|
+
signal.removeEventListener("abort", onAbort);
|
|
2101
|
+
resolve();
|
|
2102
|
+
}, options.interval);
|
|
2103
|
+
function onAbort() {
|
|
2104
|
+
clearTimeout(timer);
|
|
2105
|
+
reject(new Error("Aborted"));
|
|
2106
|
+
}
|
|
2107
|
+
signal.addEventListener("abort", onAbort, { once: true });
|
|
2108
|
+
});
|
|
2109
|
+
}
|
|
2110
|
+
}
|