@johnnygreco/pizza-pi 0.1.1

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.
Files changed (27) hide show
  1. package/LICENSE +191 -0
  2. package/README.md +82 -0
  3. package/extensions/context.ts +578 -0
  4. package/extensions/control.ts +1782 -0
  5. package/extensions/loop.ts +454 -0
  6. package/extensions/pizza-ui.ts +93 -0
  7. package/extensions/todos.ts +2066 -0
  8. package/node_modules/pi-interactive-subagents/.pi/settings.json +13 -0
  9. package/node_modules/pi-interactive-subagents/.pi/skills/release/SKILL.md +133 -0
  10. package/node_modules/pi-interactive-subagents/LICENSE +21 -0
  11. package/node_modules/pi-interactive-subagents/README.md +362 -0
  12. package/node_modules/pi-interactive-subagents/agents/planner.md +270 -0
  13. package/node_modules/pi-interactive-subagents/agents/reviewer.md +153 -0
  14. package/node_modules/pi-interactive-subagents/agents/scout.md +103 -0
  15. package/node_modules/pi-interactive-subagents/agents/spec.md +339 -0
  16. package/node_modules/pi-interactive-subagents/agents/visual-tester.md +202 -0
  17. package/node_modules/pi-interactive-subagents/agents/worker.md +104 -0
  18. package/node_modules/pi-interactive-subagents/package.json +34 -0
  19. package/node_modules/pi-interactive-subagents/pi-extension/session-artifacts/index.ts +252 -0
  20. package/node_modules/pi-interactive-subagents/pi-extension/subagents/cmux.ts +647 -0
  21. package/node_modules/pi-interactive-subagents/pi-extension/subagents/index.ts +1343 -0
  22. package/node_modules/pi-interactive-subagents/pi-extension/subagents/plan-skill.md +225 -0
  23. package/node_modules/pi-interactive-subagents/pi-extension/subagents/session.ts +124 -0
  24. package/node_modules/pi-interactive-subagents/pi-extension/subagents/subagent-done.ts +166 -0
  25. package/package.json +62 -0
  26. package/prompts/.gitkeep +0 -0
  27. package/skills/.gitkeep +0 -0
@@ -0,0 +1,647 @@
1
+ import { execSync, execFile, execFileSync } from "node:child_process";
2
+ import { promisify } from "node:util";
3
+ import { existsSync, readFileSync, rmSync } from "node:fs";
4
+ import { tmpdir } from "node:os";
5
+ import { basename, join } from "node:path";
6
+
7
+ const execFileAsync = promisify(execFile);
8
+
9
+ export type MuxBackend = "cmux" | "tmux" | "zellij" | "wezterm";
10
+
11
+ const commandAvailability = new Map<string, boolean>();
12
+
13
+ function hasCommand(command: string): boolean {
14
+ if (commandAvailability.has(command)) {
15
+ return commandAvailability.get(command)!;
16
+ }
17
+
18
+ let available = false;
19
+ try {
20
+ execSync(`command -v ${command}`, { stdio: "ignore" });
21
+ available = true;
22
+ } catch {
23
+ available = false;
24
+ }
25
+
26
+ commandAvailability.set(command, available);
27
+ return available;
28
+ }
29
+
30
+ function muxPreference(): MuxBackend | null {
31
+ const pref = (process.env.PI_SUBAGENT_MUX ?? "").trim().toLowerCase();
32
+ if (pref === "cmux" || pref === "tmux" || pref === "zellij" || pref === "wezterm") return pref;
33
+ return null;
34
+ }
35
+
36
+ function isCmuxRuntimeAvailable(): boolean {
37
+ return !!process.env.CMUX_SOCKET_PATH && hasCommand("cmux");
38
+ }
39
+
40
+ function isTmuxRuntimeAvailable(): boolean {
41
+ return !!process.env.TMUX && hasCommand("tmux");
42
+ }
43
+
44
+ function isZellijRuntimeAvailable(): boolean {
45
+ return !!(process.env.ZELLIJ || process.env.ZELLIJ_SESSION_NAME) && hasCommand("zellij");
46
+ }
47
+
48
+ function isWezTermRuntimeAvailable(): boolean {
49
+ return !!process.env.WEZTERM_UNIX_SOCKET && hasCommand("wezterm");
50
+ }
51
+
52
+ export function isCmuxAvailable(): boolean {
53
+ return isCmuxRuntimeAvailable();
54
+ }
55
+
56
+ export function isTmuxAvailable(): boolean {
57
+ return isTmuxRuntimeAvailable();
58
+ }
59
+
60
+ export function isZellijAvailable(): boolean {
61
+ return isZellijRuntimeAvailable();
62
+ }
63
+
64
+ export function isWezTermAvailable(): boolean {
65
+ return isWezTermRuntimeAvailable();
66
+ }
67
+
68
+ export function getMuxBackend(): MuxBackend | null {
69
+ const pref = muxPreference();
70
+ if (pref === "cmux") return isCmuxRuntimeAvailable() ? "cmux" : null;
71
+ if (pref === "tmux") return isTmuxRuntimeAvailable() ? "tmux" : null;
72
+ if (pref === "zellij") return isZellijRuntimeAvailable() ? "zellij" : null;
73
+ if (pref === "wezterm") return isWezTermRuntimeAvailable() ? "wezterm" : null;
74
+
75
+ if (isCmuxRuntimeAvailable()) return "cmux";
76
+ if (isTmuxRuntimeAvailable()) return "tmux";
77
+ if (isZellijRuntimeAvailable()) return "zellij";
78
+ if (isWezTermRuntimeAvailable()) return "wezterm";
79
+ return null;
80
+ }
81
+
82
+ export function isMuxAvailable(): boolean {
83
+ return getMuxBackend() !== null;
84
+ }
85
+
86
+ export function muxSetupHint(): string {
87
+ const pref = muxPreference();
88
+ if (pref === "cmux") {
89
+ return "Start pi inside cmux (`cmux pi`).";
90
+ }
91
+ if (pref === "tmux") {
92
+ return "Start pi inside tmux (`tmux new -A -s pi 'pi'`).";
93
+ }
94
+ if (pref === "zellij") {
95
+ return "Start pi inside zellij (`zellij --session pi`, then run `pi`).";
96
+ }
97
+ if (pref === "wezterm") {
98
+ return "Start pi inside WezTerm.";
99
+ }
100
+ return "Start pi inside cmux (`cmux pi`), tmux (`tmux new -A -s pi 'pi'`), zellij (`zellij --session pi`, then run `pi`), or WezTerm.";
101
+ }
102
+
103
+ function requireMuxBackend(): MuxBackend {
104
+ const backend = getMuxBackend();
105
+ if (!backend) {
106
+ throw new Error(`No supported terminal multiplexer found. ${muxSetupHint()}`);
107
+ }
108
+ return backend;
109
+ }
110
+
111
+ /**
112
+ * Detect if the user's default shell is fish.
113
+ * Fish uses $status instead of $? for exit codes.
114
+ */
115
+ export function isFishShell(): boolean {
116
+ const shell = process.env.SHELL ?? "";
117
+ return basename(shell) === "fish";
118
+ }
119
+
120
+ /**
121
+ * Return the shell-appropriate exit status variable ($? for bash/zsh, $status for fish).
122
+ */
123
+ export function exitStatusVar(): string {
124
+ return isFishShell() ? "$status" : "$?";
125
+ }
126
+
127
+ export function shellEscape(s: string): string {
128
+ return "'" + s.replace(/'/g, "'\\''") + "'";
129
+ }
130
+
131
+ function tailLines(text: string, lines: number): string {
132
+ const split = text.split("\n");
133
+ if (split.length <= lines) return text;
134
+ return split.slice(-lines).join("\n");
135
+ }
136
+
137
+ function zellijPaneId(surface: string): string {
138
+ return surface.startsWith("pane:") ? surface.slice("pane:".length) : surface;
139
+ }
140
+
141
+ function zellijEnv(surface?: string): NodeJS.ProcessEnv {
142
+ const env: NodeJS.ProcessEnv = { ...process.env };
143
+ if (surface) {
144
+ env.ZELLIJ_PANE_ID = zellijPaneId(surface);
145
+ }
146
+ return env;
147
+ }
148
+
149
+ function waitForFile(path: string, timeoutMs = 5000): string {
150
+ const sleeper = new Int32Array(new SharedArrayBuffer(4));
151
+ const start = Date.now();
152
+ while (Date.now() - start < timeoutMs) {
153
+ if (existsSync(path)) {
154
+ return readFileSync(path, "utf8").trim();
155
+ }
156
+ Atomics.wait(sleeper, 0, 0, 20);
157
+ }
158
+ throw new Error(`Timed out waiting for zellij pane id file: ${path}`);
159
+ }
160
+
161
+ function zellijActionSync(args: string[], surface?: string): string {
162
+ return execFileSync("zellij", ["action", ...args], {
163
+ encoding: "utf8",
164
+ env: zellijEnv(surface),
165
+ });
166
+ }
167
+
168
+ async function zellijActionAsync(args: string[], surface?: string): Promise<string> {
169
+ const { stdout } = await execFileAsync("zellij", ["action", ...args], {
170
+ encoding: "utf8",
171
+ env: zellijEnv(surface),
172
+ });
173
+ return stdout;
174
+ }
175
+
176
+ /** Tracked subagent pane for cmux — reused across subagent launches. */
177
+ let cmuxSubagentPane: string | null = null;
178
+
179
+ /**
180
+ * Create a new terminal surface for a subagent.
181
+ *
182
+ * For cmux: the first call creates a right-split pane; subsequent calls add
183
+ * tabs to that same pane (avoiding ever-narrower splits).
184
+ * For tmux/zellij/wezterm: falls back to split behavior.
185
+ *
186
+ * Returns an identifier (`surface:42` in cmux, `%12` in tmux, `pane:7` in zellij, `42` in wezterm).
187
+ */
188
+ export function createSurface(name: string): string {
189
+ const backend = getMuxBackend();
190
+
191
+ if (backend === "cmux" && cmuxSubagentPane) {
192
+ // Verify the pane still exists before adding a tab to it
193
+ try {
194
+ const tree = execSync(`cmux tree`, { encoding: "utf8" });
195
+ if (tree.includes(cmuxSubagentPane)) {
196
+ return createSurfaceInPane(name, cmuxSubagentPane);
197
+ }
198
+ } catch {}
199
+ // Pane is gone — fall through to create a new split
200
+ cmuxSubagentPane = null;
201
+ }
202
+
203
+ const surface = createSurfaceSplit(name, "right");
204
+
205
+ // For cmux, remember the pane so future subagents become tabs in it
206
+ if (backend === "cmux") {
207
+ try {
208
+ const info = execSync(`cmux identify --surface ${shellEscape(surface)}`, {
209
+ encoding: "utf8",
210
+ });
211
+ const parsed = JSON.parse(info);
212
+ const paneRef = parsed?.caller?.pane_ref;
213
+ if (paneRef) {
214
+ cmuxSubagentPane = paneRef;
215
+ }
216
+ } catch {}
217
+ }
218
+
219
+ return surface;
220
+ }
221
+
222
+ /**
223
+ * Create a new surface (tab) in an existing cmux pane.
224
+ */
225
+ function createSurfaceInPane(name: string, pane: string): string {
226
+ const out = execSync(`cmux new-surface --pane ${shellEscape(pane)}`, {
227
+ encoding: "utf8",
228
+ }).trim();
229
+ const match = out.match(/surface:\d+/);
230
+ if (!match) {
231
+ throw new Error(`Unexpected cmux new-surface output: ${out}`);
232
+ }
233
+ const surface = match[0];
234
+ execSync(`cmux rename-tab --surface ${shellEscape(surface)} ${shellEscape(name)}`, {
235
+ encoding: "utf8",
236
+ });
237
+ return surface;
238
+ }
239
+
240
+ /**
241
+ * Create a new split in the given direction from an optional source pane.
242
+ * Returns an identifier (`surface:42` in cmux, `%12` in tmux, `pane:7` in zellij, `42` in wezterm).
243
+ */
244
+ export function createSurfaceSplit(
245
+ name: string,
246
+ direction: "left" | "right" | "up" | "down",
247
+ fromSurface?: string,
248
+ ): string {
249
+ const backend = requireMuxBackend();
250
+
251
+ if (backend === "cmux") {
252
+ const surfaceArg = fromSurface ? ` --surface ${shellEscape(fromSurface)}` : "";
253
+ const out = execSync(`cmux new-split ${direction}${surfaceArg}`, {
254
+ encoding: "utf8",
255
+ }).trim();
256
+ const match = out.match(/surface:\d+/);
257
+ if (!match) {
258
+ throw new Error(`Unexpected cmux new-split output: ${out}`);
259
+ }
260
+ const surface = match[0];
261
+ execSync(`cmux rename-tab --surface ${shellEscape(surface)} ${shellEscape(name)}`, {
262
+ encoding: "utf8",
263
+ });
264
+ return surface;
265
+ }
266
+
267
+ if (backend === "tmux") {
268
+ const args = ["split-window"];
269
+ if (direction === "left" || direction === "right") {
270
+ args.push("-h");
271
+ } else {
272
+ args.push("-v");
273
+ }
274
+ if (direction === "left" || direction === "up") {
275
+ args.push("-b");
276
+ }
277
+ if (fromSurface) {
278
+ args.push("-t", fromSurface);
279
+ }
280
+ args.push("-P", "-F", "#{pane_id}");
281
+
282
+ const pane = execFileSync("tmux", args, { encoding: "utf8" }).trim();
283
+ if (!pane.startsWith("%")) {
284
+ throw new Error(`Unexpected tmux split-window output: ${pane}`);
285
+ }
286
+
287
+ try {
288
+ execFileSync("tmux", ["select-pane", "-t", pane, "-T", name], { encoding: "utf8" });
289
+ } catch {
290
+ // Optional.
291
+ }
292
+ return pane;
293
+ }
294
+
295
+ if (backend === "wezterm") {
296
+ const args = ["cli", "split-pane"];
297
+ if (direction === "left") args.push("--left");
298
+ else if (direction === "right") args.push("--right");
299
+ else if (direction === "up") args.push("--top");
300
+ else args.push("--bottom");
301
+ args.push("--cwd", process.cwd());
302
+ if (fromSurface) {
303
+ args.push("--pane-id", fromSurface);
304
+ }
305
+ const paneId = execFileSync("wezterm", args, { encoding: "utf8" }).trim();
306
+ if (!paneId || !/^\d+$/.test(paneId)) {
307
+ throw new Error(`Unexpected wezterm split-pane output: ${paneId || "(empty)"}`);
308
+ }
309
+ try {
310
+ execFileSync("wezterm", ["cli", "set-tab-title", "--pane-id", paneId, name], {
311
+ encoding: "utf8",
312
+ });
313
+ } catch {
314
+ // Optional — tab title is cosmetic.
315
+ }
316
+ return paneId;
317
+ }
318
+
319
+ // zellij
320
+ const directionArg = direction === "left" || direction === "right" ? "right" : "down";
321
+ const tokenPath = join(
322
+ tmpdir(),
323
+ `pi-subagent-zellij-pane-${Date.now()}-${Math.random().toString(36).slice(2)}.txt`,
324
+ );
325
+ const args = ["new-pane", "--direction", directionArg, "--name", name, "--cwd", process.cwd()];
326
+
327
+ try {
328
+ zellijActionSync(args, fromSurface);
329
+ } catch {
330
+ if (!fromSurface) throw new Error("Failed to create zellij pane");
331
+ zellijActionSync(args);
332
+ }
333
+
334
+ // IMPORTANT: do not pass a long-running command to `new-pane`.
335
+ // zellij keeps the `action new-pane -- <cmd>` process attached until <cmd>
336
+ // exits. If <cmd> is an interactive shell, the parent call hangs forever.
337
+ // Instead, create a normal shell pane first, then ask the focused pane
338
+ // to print its own $ZELLIJ_PANE_ID into a temp file.
339
+ const captureIdCmd = `echo "$ZELLIJ_PANE_ID" > ${shellEscape(tokenPath)}`;
340
+ zellijActionSync(["write-chars", captureIdCmd]);
341
+ zellijActionSync(["write", "13"]);
342
+
343
+ const paneId = waitForFile(tokenPath);
344
+ try {
345
+ rmSync(tokenPath, { force: true });
346
+ } catch {}
347
+
348
+ if (!paneId || !/^\d+$/.test(paneId)) {
349
+ throw new Error(`Unexpected zellij pane id: ${paneId || "(empty)"}`);
350
+ }
351
+
352
+ const surface = `pane:${paneId}`;
353
+
354
+ if (direction === "left" || direction === "up") {
355
+ try {
356
+ zellijActionSync(["move-pane", direction], surface);
357
+ } catch {
358
+ // Optional layout polish.
359
+ }
360
+ }
361
+
362
+ try {
363
+ zellijActionSync(["rename-pane", name], surface);
364
+ } catch {
365
+ // Optional.
366
+ }
367
+
368
+ return surface;
369
+ }
370
+
371
+ /**
372
+ * Rename the current tab/window.
373
+ */
374
+ export function renameCurrentTab(title: string): void {
375
+ const backend = requireMuxBackend();
376
+
377
+ if (backend === "cmux") {
378
+ const surfaceId = process.env.CMUX_SURFACE_ID;
379
+ if (!surfaceId) throw new Error("CMUX_SURFACE_ID not set");
380
+ execSync(`cmux rename-tab --surface ${shellEscape(surfaceId)} ${shellEscape(title)}`, {
381
+ encoding: "utf8",
382
+ });
383
+ return;
384
+ }
385
+
386
+ if (backend === "tmux") {
387
+ if (process.env.PI_SUBAGENT_RENAME_TMUX_WINDOW !== "1") {
388
+ return;
389
+ }
390
+ const paneId = process.env.TMUX_PANE;
391
+ if (!paneId) throw new Error("TMUX_PANE not set");
392
+ const windowId = execFileSync("tmux", ["display-message", "-p", "-t", paneId, "#{window_id}"], {
393
+ encoding: "utf8",
394
+ }).trim();
395
+ execFileSync("tmux", ["rename-window", "-t", windowId, title], { encoding: "utf8" });
396
+ return;
397
+ }
398
+
399
+ if (backend === "wezterm") {
400
+ const paneId = process.env.WEZTERM_PANE;
401
+ const args = ["cli", "set-tab-title"];
402
+ if (paneId) args.push("--pane-id", paneId);
403
+ args.push(title);
404
+ execFileSync("wezterm", args, { encoding: "utf8" });
405
+ return;
406
+ }
407
+
408
+ zellijActionSync(["rename-tab", title]);
409
+ }
410
+
411
+ /**
412
+ * Rename the current workspace/session where supported.
413
+ */
414
+ export function renameWorkspace(title: string): void {
415
+ const backend = requireMuxBackend();
416
+
417
+ if (backend === "cmux") {
418
+ execSync(`cmux workspace-action --action rename --title ${shellEscape(title)}`, {
419
+ encoding: "utf8",
420
+ });
421
+ return;
422
+ }
423
+
424
+ if (backend === "tmux") {
425
+ if (process.env.PI_SUBAGENT_RENAME_TMUX_SESSION !== "1") {
426
+ return;
427
+ }
428
+
429
+ const paneId = process.env.TMUX_PANE;
430
+ if (!paneId) throw new Error("TMUX_PANE not set");
431
+ const sessionId = execFileSync(
432
+ "tmux",
433
+ ["display-message", "-p", "-t", paneId, "#{session_id}"],
434
+ {
435
+ encoding: "utf8",
436
+ },
437
+ ).trim();
438
+ execFileSync("tmux", ["rename-session", "-t", sessionId, title], { encoding: "utf8" });
439
+ return;
440
+ }
441
+
442
+ if (backend === "wezterm") {
443
+ const paneId = process.env.WEZTERM_PANE;
444
+ const args = ["cli", "set-window-title"];
445
+ if (paneId) args.push("--pane-id", paneId);
446
+ args.push(title);
447
+ try {
448
+ execFileSync("wezterm", args, { encoding: "utf8" });
449
+ } catch {
450
+ // Optional — window title is cosmetic.
451
+ }
452
+ return;
453
+ }
454
+
455
+ // Skip session rename for zellij. rename-session renames the socket file
456
+ // but the ZELLIJ_SESSION_NAME env var in the parent process keeps the old
457
+ // name, so all subsequent `zellij action ...` CLI calls fail with
458
+ // "There is no active session!" because the CLI can't find the socket.
459
+ // Additionally, pi titles often contain special characters (em dashes,
460
+ // spaces) that fail zellij's session name validation on lookup.
461
+ // rename-tab (called separately) is sufficient for user-visible naming.
462
+ }
463
+
464
+ /**
465
+ * Send a command string to a pane and execute it.
466
+ */
467
+ export function sendCommand(surface: string, command: string): void {
468
+ const backend = requireMuxBackend();
469
+
470
+ if (backend === "cmux") {
471
+ execSync(`cmux send --surface ${shellEscape(surface)} ${shellEscape(command + "\n")}`, {
472
+ encoding: "utf8",
473
+ });
474
+ return;
475
+ }
476
+
477
+ if (backend === "tmux") {
478
+ execFileSync("tmux", ["send-keys", "-t", surface, "-l", command], { encoding: "utf8" });
479
+ execFileSync("tmux", ["send-keys", "-t", surface, "Enter"], { encoding: "utf8" });
480
+ return;
481
+ }
482
+
483
+ if (backend === "wezterm") {
484
+ execFileSync("wezterm", ["cli", "send-text", "--pane-id", surface, "--no-paste", command + "\n"], {
485
+ encoding: "utf8",
486
+ });
487
+ return;
488
+ }
489
+
490
+ zellijActionSync(["write-chars", command], surface);
491
+ zellijActionSync(["write", "13"], surface);
492
+ }
493
+
494
+ /**
495
+ * Read the screen contents of a pane (sync).
496
+ */
497
+ export function readScreen(surface: string, lines = 50): string {
498
+ const backend = requireMuxBackend();
499
+
500
+ if (backend === "cmux") {
501
+ return execSync(`cmux read-screen --surface ${shellEscape(surface)} --lines ${lines}`, {
502
+ encoding: "utf8",
503
+ });
504
+ }
505
+
506
+ if (backend === "tmux") {
507
+ return execFileSync(
508
+ "tmux",
509
+ ["capture-pane", "-p", "-t", surface, "-S", `-${Math.max(1, lines)}`],
510
+ {
511
+ encoding: "utf8",
512
+ },
513
+ );
514
+ }
515
+
516
+ if (backend === "wezterm") {
517
+ const raw = execFileSync(
518
+ "wezterm",
519
+ ["cli", "get-text", "--pane-id", surface],
520
+ { encoding: "utf8" },
521
+ );
522
+ return tailLines(raw, lines);
523
+ }
524
+
525
+ // Zellij 0.44+: use --pane-id flag + stdout instead of env var + temp file.
526
+ // The ZELLIJ_PANE_ID env var doesn't reliably target other panes for dump-screen,
527
+ // and --path may silently fail to create the file. Stdout capture is robust.
528
+ const paneId = zellijPaneId(surface);
529
+ const raw = execFileSync(
530
+ "zellij",
531
+ ["action", "dump-screen", "--pane-id", paneId],
532
+ { encoding: "utf8" },
533
+ );
534
+ return tailLines(raw, lines);
535
+ }
536
+
537
+ /**
538
+ * Read the screen contents of a pane (async).
539
+ */
540
+ export async function readScreenAsync(surface: string, lines = 50): Promise<string> {
541
+ const backend = requireMuxBackend();
542
+
543
+ if (backend === "cmux") {
544
+ const { stdout } = await execFileAsync(
545
+ "cmux",
546
+ ["read-screen", "--surface", surface, "--lines", String(lines)],
547
+ { encoding: "utf8" },
548
+ );
549
+ return stdout;
550
+ }
551
+
552
+ if (backend === "tmux") {
553
+ const { stdout } = await execFileAsync(
554
+ "tmux",
555
+ ["capture-pane", "-p", "-t", surface, "-S", `-${Math.max(1, lines)}`],
556
+ { encoding: "utf8" },
557
+ );
558
+ return stdout;
559
+ }
560
+
561
+ if (backend === "wezterm") {
562
+ const { stdout } = await execFileAsync(
563
+ "wezterm",
564
+ ["cli", "get-text", "--pane-id", surface],
565
+ { encoding: "utf8" },
566
+ );
567
+ return tailLines(stdout, lines);
568
+ }
569
+
570
+ // Zellij 0.44+: use --pane-id flag + stdout instead of env var + temp file.
571
+ const paneId = zellijPaneId(surface);
572
+ const { stdout } = await execFileAsync(
573
+ "zellij",
574
+ ["action", "dump-screen", "--pane-id", paneId],
575
+ { encoding: "utf8" },
576
+ );
577
+ return tailLines(stdout, lines);
578
+ }
579
+
580
+ /**
581
+ * Close a pane.
582
+ */
583
+ export function closeSurface(surface: string): void {
584
+ const backend = requireMuxBackend();
585
+
586
+ if (backend === "cmux") {
587
+ execSync(`cmux close-surface --surface ${shellEscape(surface)}`, {
588
+ encoding: "utf8",
589
+ });
590
+ return;
591
+ }
592
+
593
+ if (backend === "tmux") {
594
+ execFileSync("tmux", ["kill-pane", "-t", surface], { encoding: "utf8" });
595
+ return;
596
+ }
597
+
598
+ if (backend === "wezterm") {
599
+ execFileSync("wezterm", ["cli", "kill-pane", "--pane-id", surface], {
600
+ encoding: "utf8",
601
+ });
602
+ return;
603
+ }
604
+
605
+ zellijActionSync(["close-pane"], surface);
606
+ }
607
+
608
+ /**
609
+ * Poll a pane until the __SUBAGENT_DONE_N__ sentinel appears.
610
+ * Returns the process exit code embedded in the sentinel.
611
+ * Throws if the signal is aborted before the sentinel is found.
612
+ */
613
+ export async function pollForExit(
614
+ surface: string,
615
+ signal: AbortSignal,
616
+ options: { interval: number; onTick?: (elapsed: number) => void },
617
+ ): Promise<number> {
618
+ const start = Date.now();
619
+
620
+ while (true) {
621
+ if (signal.aborted) {
622
+ throw new Error("Aborted while waiting for subagent to finish");
623
+ }
624
+
625
+ const screen = await readScreenAsync(surface, 5);
626
+ const match = screen.match(/__SUBAGENT_DONE_(\d+)__/);
627
+ if (match) {
628
+ return parseInt(match[1], 10);
629
+ }
630
+
631
+ const elapsed = Math.floor((Date.now() - start) / 1000);
632
+ options.onTick?.(elapsed);
633
+
634
+ await new Promise<void>((resolve, reject) => {
635
+ if (signal.aborted) return reject(new Error("Aborted"));
636
+ const timer = setTimeout(() => {
637
+ signal.removeEventListener("abort", onAbort);
638
+ resolve();
639
+ }, options.interval);
640
+ function onAbort() {
641
+ clearTimeout(timer);
642
+ reject(new Error("Aborted"));
643
+ }
644
+ signal.addEventListener("abort", onAbort, { once: true });
645
+ });
646
+ }
647
+ }