@bastani/atomic 0.6.0-0 → 0.6.1-0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/lib/spawn.ts CHANGED
@@ -5,12 +5,21 @@
5
5
  * eliminating duplication across postinstall-playwright, postinstall-liteparse, etc.
6
6
  */
7
7
 
8
+ import {
9
+ copyFileSync,
10
+ existsSync,
11
+ mkdirSync,
12
+ mkdtempSync,
13
+ rmSync,
14
+ } from "node:fs";
8
15
  import { join } from "node:path";
9
- import { homedir } from "node:os";
16
+ import { homedir, tmpdir } from "node:os";
10
17
 
11
18
  export interface SpawnResult {
12
19
  success: boolean;
13
20
  details: string;
21
+ stdout?: string;
22
+ stderr?: string;
14
23
  }
15
24
 
16
25
  export interface RunCommandOptions {
@@ -49,14 +58,19 @@ export async function runCommand(cmd: string[], options?: RunCommandOptions): Pr
49
58
  new Response(proc.stdout).text(),
50
59
  proc.exited,
51
60
  ]);
61
+ const trimmedStdout = stdout.trim();
62
+ const trimmedStderr = stderr.trim();
52
63
  return {
53
64
  success: exitCode === 0,
54
- details: stderr.trim().length > 0 ? stderr.trim() : stdout.trim(),
65
+ details: trimmedStderr.length > 0 ? trimmedStderr : trimmedStdout,
66
+ stdout: trimmedStdout,
67
+ stderr: trimmedStderr,
55
68
  };
56
69
  } catch (error) {
57
70
  return {
58
71
  success: false,
59
72
  details: error instanceof Error ? error.message : String(error),
73
+ stderr: error instanceof Error ? error.message : String(error),
60
74
  };
61
75
  }
62
76
  }
@@ -68,11 +82,290 @@ export function prependPath(directory: string): void {
68
82
  const pathDelimiter = process.platform === "win32" ? ";" : ":";
69
83
  const currentPath = process.env.PATH ?? "";
70
84
  const entries = currentPath.split(pathDelimiter);
71
- if (!entries.includes(directory)) {
85
+ const alreadyPresent = process.platform === "win32"
86
+ ? entries.some((entry) => entry.toLowerCase() === directory.toLowerCase())
87
+ : entries.includes(directory);
88
+ if (!alreadyPresent) {
72
89
  process.env.PATH = directory + pathDelimiter + currentPath;
73
90
  }
74
91
  }
75
92
 
93
+ function windowsAtomicBinDir(): string {
94
+ return join(getHomeDir(), ".atomic", "bin");
95
+ }
96
+
97
+ export function resolveCommandFromCurrentPath(cmd: string): string | null {
98
+ return Bun.which(cmd, { PATH: process.env.PATH ?? "" });
99
+ }
100
+
101
+ export type MuxBinaryName = "tmux" | "psmux" | "pmux";
102
+
103
+ export function requiredMuxBinaryCandidatesForPlatform(
104
+ platform: NodeJS.Platform = process.platform,
105
+ ): MuxBinaryName[] {
106
+ return platform === "win32" ? ["psmux", "pmux"] : ["tmux"];
107
+ }
108
+
109
+ export function isMuxBinaryRequiredForPlatform(
110
+ binary: MuxBinaryName,
111
+ platform: NodeJS.Platform = process.platform,
112
+ ): boolean {
113
+ return requiredMuxBinaryCandidatesForPlatform(platform).includes(binary);
114
+ }
115
+
116
+ export function hasRequiredMuxBinary(): boolean {
117
+ return requiredMuxBinaryCandidatesForPlatform().some(
118
+ (candidate) => resolveCommandFromCurrentPath(candidate),
119
+ );
120
+ }
121
+
122
+ function prependPathIfDirectory(directory: string | undefined): void {
123
+ if (!directory || !existsSync(directory)) return;
124
+ prependPath(directory);
125
+ }
126
+
127
+ function prependWindowsMuxInstallPaths(): void {
128
+ if (process.platform !== "win32") return;
129
+
130
+ const home = getHomeDir();
131
+ prependPathIfDirectory(
132
+ process.env.SCOOP ? join(process.env.SCOOP, "shims") : undefined,
133
+ );
134
+ prependPathIfDirectory(home ? join(home, "scoop", "shims") : undefined);
135
+ prependPathIfDirectory(
136
+ process.env.LOCALAPPDATA
137
+ ? join(process.env.LOCALAPPDATA, "Microsoft", "WinGet", "Links")
138
+ : undefined,
139
+ );
140
+ prependPathIfDirectory(
141
+ process.env.LOCALAPPDATA
142
+ ? join(process.env.LOCALAPPDATA, "Microsoft", "WindowsApps")
143
+ : undefined,
144
+ );
145
+ prependPathIfDirectory(
146
+ process.env.ChocolateyInstall
147
+ ? join(process.env.ChocolateyInstall, "bin")
148
+ : undefined,
149
+ );
150
+ prependPathIfDirectory("C:\\ProgramData\\chocolatey\\bin");
151
+ prependPathIfDirectory(home ? join(home, ".cargo", "bin") : undefined);
152
+ prependPathIfDirectory(windowsAtomicBinDir());
153
+ }
154
+
155
+ function prependBunInstallPaths(): void {
156
+ const home = getHomeDir();
157
+ prependPathIfDirectory(process.env.BUN_INSTALL_BIN);
158
+ prependPathIfDirectory(
159
+ process.env.BUN_INSTALL ? join(process.env.BUN_INSTALL, "bin") : undefined,
160
+ );
161
+ prependPathIfDirectory(home ? join(home, ".bun", "bin") : undefined);
162
+
163
+ if (process.platform !== "win32") return;
164
+
165
+ prependPathIfDirectory(
166
+ process.env.SCOOP ? join(process.env.SCOOP, "shims") : undefined,
167
+ );
168
+ prependPathIfDirectory(home ? join(home, "scoop", "shims") : undefined);
169
+ prependPathIfDirectory(
170
+ process.env.LOCALAPPDATA
171
+ ? join(process.env.LOCALAPPDATA, "Microsoft", "WinGet", "Links")
172
+ : undefined,
173
+ );
174
+ prependPathIfDirectory(
175
+ process.env.LOCALAPPDATA
176
+ ? join(process.env.LOCALAPPDATA, "Microsoft", "WindowsApps")
177
+ : undefined,
178
+ );
179
+ }
180
+
181
+ function mergePath(pathValue: string): void {
182
+ const delimiter = process.platform === "win32" ? ";" : ":";
183
+ for (const entry of pathValue.split(delimiter)) {
184
+ const trimmed = entry.trim();
185
+ if (trimmed) prependPath(trimmed);
186
+ }
187
+ }
188
+
189
+ async function refreshWindowsPathFromRegistry(): Promise<void> {
190
+ if (process.platform !== "win32") return;
191
+
192
+ const shell = resolveCommandFromCurrentPath("powershell") ??
193
+ resolveCommandFromCurrentPath("pwsh");
194
+ if (!shell) return;
195
+
196
+ const readRegistryPath =
197
+ "$paths = @([Environment]::GetEnvironmentVariable('Path','Process'), " +
198
+ "[Environment]::GetEnvironmentVariable('Path','User'), " +
199
+ "[Environment]::GetEnvironmentVariable('Path','Machine')) | " +
200
+ "Where-Object { $_ }; $paths -join ';'";
201
+
202
+ const result = await runCommand([
203
+ shell,
204
+ "-NoProfile",
205
+ "-Command",
206
+ readRegistryPath,
207
+ ]);
208
+ if (result.success && result.stdout) {
209
+ mergePath(result.stdout);
210
+ }
211
+ }
212
+
213
+ async function refreshWindowsMuxPath(): Promise<void> {
214
+ prependWindowsMuxInstallPaths();
215
+ await refreshWindowsPathFromRegistry();
216
+ prependWindowsMuxInstallPaths();
217
+ }
218
+
219
+ async function refreshWindowsBunPath(): Promise<void> {
220
+ prependBunInstallPaths();
221
+ await refreshWindowsPathFromRegistry();
222
+ prependBunInstallPaths();
223
+ }
224
+
225
+ interface GitHubReleaseAsset {
226
+ name: string;
227
+ browser_download_url: string;
228
+ }
229
+
230
+ interface GitHubRelease {
231
+ assets: GitHubReleaseAsset[];
232
+ }
233
+
234
+ export function psmuxReleaseAssetSuffix(
235
+ arch: NodeJS.Architecture = process.arch,
236
+ ): string | null {
237
+ switch (arch) {
238
+ case "x64":
239
+ return "windows-x64.zip";
240
+ case "ia32":
241
+ return "windows-x86.zip";
242
+ case "arm64":
243
+ return "windows-arm64.zip";
244
+ default:
245
+ return null;
246
+ }
247
+ }
248
+
249
+ function powershellLiteral(value: string): string {
250
+ return `'${value.replaceAll("'", "''")}'`;
251
+ }
252
+
253
+ async function persistWindowsUserPath(directory: string): Promise<SpawnResult> {
254
+ const shell = resolveCommandFromCurrentPath("powershell") ??
255
+ resolveCommandFromCurrentPath("pwsh");
256
+ if (!shell) return { success: true, details: "" };
257
+
258
+ const script =
259
+ `$dir = ${powershellLiteral(directory)}; ` +
260
+ "$current = [Environment]::GetEnvironmentVariable('Path','User'); " +
261
+ "$entries = if ([string]::IsNullOrWhiteSpace($current)) { @() } else { $current -split ';' }; " +
262
+ "$expandedDir = [Environment]::ExpandEnvironmentVariables($dir) -replace '[\\\\/]+$',''; " +
263
+ "$hasDir = $false; " +
264
+ "foreach ($entry in $entries) { " +
265
+ " $expandedEntry = [Environment]::ExpandEnvironmentVariables($entry).Trim().Trim('\"') -replace '[\\\\/]+$',''; " +
266
+ " if ($expandedEntry -ieq $expandedDir) { $hasDir = $true; break } " +
267
+ "} " +
268
+ "if (-not $hasDir) { " +
269
+ " $next = (@($entries) + @($dir) | Where-Object { -not [string]::IsNullOrWhiteSpace($_) }) -join ';'; " +
270
+ " [Environment]::SetEnvironmentVariable('Path', $next, 'User'); " +
271
+ "}";
272
+
273
+ return runCommand([shell, "-NoProfile", "-Command", script]);
274
+ }
275
+
276
+ async function installPsmuxFromGitHubRelease(): Promise<SpawnResult> {
277
+ try {
278
+ const suffix = psmuxReleaseAssetSuffix();
279
+ if (!suffix) {
280
+ return {
281
+ success: false,
282
+ details: `No psmux release asset is available for ${process.arch}.`,
283
+ };
284
+ }
285
+
286
+ const response = await fetch(
287
+ "https://api.github.com/repos/psmux/psmux/releases/latest",
288
+ { headers: { "Accept": "application/vnd.github+json" } },
289
+ );
290
+ if (!response.ok) {
291
+ return {
292
+ success: false,
293
+ details: `Could not fetch latest psmux release: ${response.status} ${response.statusText}`,
294
+ };
295
+ }
296
+
297
+ const release = await response.json() as GitHubRelease;
298
+ const asset = release.assets.find((item) => item.name.endsWith(suffix));
299
+ if (!asset) {
300
+ return {
301
+ success: false,
302
+ details: `Latest psmux release does not include a ${suffix} asset.`,
303
+ };
304
+ }
305
+
306
+ const archiveResponse = await fetch(asset.browser_download_url);
307
+ if (!archiveResponse.ok) {
308
+ return {
309
+ success: false,
310
+ details: `Could not download ${asset.name}: ${archiveResponse.status} ${archiveResponse.statusText}`,
311
+ };
312
+ }
313
+
314
+ const tempDir = mkdtempSync(join(tmpdir(), "atomic-psmux-"));
315
+ const zipPath = join(tempDir, asset.name);
316
+ const extractDir = join(tempDir, "extract");
317
+ const installDir = windowsAtomicBinDir();
318
+
319
+ try {
320
+ await Bun.write(zipPath, await archiveResponse.arrayBuffer());
321
+ mkdirSync(extractDir, { recursive: true });
322
+ mkdirSync(installDir, { recursive: true });
323
+
324
+ const shell = resolveCommandFromCurrentPath("powershell") ??
325
+ resolveCommandFromCurrentPath("pwsh");
326
+ if (!shell) {
327
+ return {
328
+ success: false,
329
+ details: "PowerShell is required to expand the psmux release archive.",
330
+ };
331
+ }
332
+
333
+ const expand = await runCommand([
334
+ shell,
335
+ "-NoProfile",
336
+ "-Command",
337
+ `Expand-Archive -LiteralPath ${powershellLiteral(zipPath)} -DestinationPath ${powershellLiteral(extractDir)} -Force`,
338
+ ]);
339
+ if (!expand.success) return expand;
340
+
341
+ for (const binary of ["psmux.exe", "pmux.exe", "tmux.exe"]) {
342
+ const source = join(extractDir, binary);
343
+ if (existsSync(source)) {
344
+ copyFileSync(source, join(installDir, binary));
345
+ }
346
+ }
347
+
348
+ prependPath(installDir);
349
+ const persistResult = await persistWindowsUserPath(installDir);
350
+ if (!persistResult.success) return persistResult;
351
+
352
+ return hasRequiredMuxBinary()
353
+ ? { success: true, details: "" }
354
+ : {
355
+ success: false,
356
+ details: `Downloaded psmux but no psmux binary was found in ${installDir}.`,
357
+ };
358
+ } finally {
359
+ rmSync(tempDir, { force: true, recursive: true });
360
+ }
361
+ } catch (error) {
362
+ return {
363
+ success: false,
364
+ details: error instanceof Error ? error.message : String(error),
365
+ };
366
+ }
367
+ }
368
+
76
369
  /**
77
370
  * Get the user's home directory.
78
371
  * Uses Node.js os.homedir() which handles cross-platform resolution
@@ -138,62 +431,85 @@ export async function ensureTmuxInstalled(options: EnsureOptions = {}): Promise<
138
431
  const quiet = options.quiet ?? false;
139
432
  const inherit = !quiet;
140
433
 
141
- // Check for any multiplexer binary
142
- if (Bun.which("tmux") || Bun.which("psmux") || Bun.which("pmux")) return;
434
+ // Check for the platform-native multiplexer binary.
435
+ if (hasRequiredMuxBinary()) return;
143
436
 
144
437
  let capturedDetails = "";
145
438
  const record = (result: SpawnResult) => {
146
- if (quiet && !result.success && result.details) {
439
+ if (!result.success && result.details) {
147
440
  capturedDetails = result.details;
148
441
  }
149
442
  };
150
443
 
151
444
  if (process.platform === "win32") {
152
445
  // Windows: install psmux
153
- const winget = Bun.which("winget");
446
+ const winget = resolveCommandFromCurrentPath("winget");
154
447
  if (winget) {
155
- const result = await runCommand([winget, "install", "psmux", "--accept-source-agreements", "--accept-package-agreements"], { inherit });
448
+ const result = await runCommand([
449
+ winget,
450
+ "install",
451
+ "--id",
452
+ "marlocarlo.psmux",
453
+ "--exact",
454
+ "--accept-source-agreements",
455
+ "--accept-package-agreements",
456
+ ], { inherit });
156
457
  record(result);
157
- if (result.success && (Bun.which("psmux") || Bun.which("tmux"))) return;
458
+ if (result.success) {
459
+ await refreshWindowsMuxPath();
460
+ if (hasRequiredMuxBinary()) return;
461
+ }
158
462
  }
159
463
 
160
- const scoop = Bun.which("scoop");
464
+ const scoop = resolveCommandFromCurrentPath("scoop");
161
465
  if (scoop) {
162
466
  await runCommand([scoop, "bucket", "add", "psmux", "https://github.com/psmux/scoop-psmux"], { inherit });
163
467
  const result = await runCommand([scoop, "install", "psmux"], { inherit });
164
468
  record(result);
165
- if (result.success && (Bun.which("psmux") || Bun.which("tmux"))) return;
469
+ if (result.success) {
470
+ await refreshWindowsMuxPath();
471
+ if (hasRequiredMuxBinary()) return;
472
+ }
166
473
  }
167
474
 
168
- const choco = Bun.which("choco");
475
+ const choco = resolveCommandFromCurrentPath("choco");
169
476
  if (choco) {
170
477
  const result = await runCommand([choco, "install", "psmux", "-y", "--no-progress"], { inherit });
171
478
  record(result);
172
- if (result.success && (Bun.which("psmux") || Bun.which("tmux"))) return;
479
+ if (result.success) {
480
+ await refreshWindowsMuxPath();
481
+ if (hasRequiredMuxBinary()) return;
482
+ }
173
483
  }
174
484
 
175
- const cargo = Bun.which("cargo");
485
+ const cargo = resolveCommandFromCurrentPath("cargo");
176
486
  if (cargo) {
177
487
  const result = await runCommand([cargo, "install", "psmux"], { inherit });
178
488
  record(result);
179
489
  if (result.success) {
180
490
  const home = getHomeDir();
181
491
  if (home) prependPath(join(home, ".cargo", "bin"));
182
- if (Bun.which("psmux") || Bun.which("tmux")) return;
492
+ await refreshWindowsMuxPath();
493
+ if (hasRequiredMuxBinary()) return;
183
494
  }
184
495
  }
496
+
497
+ const directResult = await installPsmuxFromGitHubRelease();
498
+ record(directResult);
499
+ if (directResult.success) return;
500
+
185
501
  throw new Error(
186
- capturedDetails || "Could not install psmux — no supported Windows package manager succeeded.",
502
+ capturedDetails || "Could not install psmux automatically.",
187
503
  );
188
504
  }
189
505
 
190
506
  // Unix / macOS
191
507
  if (process.platform === "darwin") {
192
- const brew = Bun.which("brew");
508
+ const brew = resolveCommandFromCurrentPath("brew");
193
509
  if (brew) {
194
510
  const result = await runCommand([brew, "install", "tmux"], { inherit });
195
511
  record(result);
196
- if (result.success && Bun.which("tmux")) return;
512
+ if (result.success && resolveCommandFromCurrentPath("tmux")) return;
197
513
  }
198
514
  }
199
515
 
@@ -214,7 +530,7 @@ export async function ensureTmuxInstalled(options: EnsureOptions = {}): Promise<
214
530
 
215
531
  for (const script of managers) {
216
532
  record(await runCommand([shell, "-lc", script], { inherit }));
217
- if (Bun.which("tmux")) return;
533
+ if (resolveCommandFromCurrentPath("tmux")) return;
218
534
  }
219
535
 
220
536
  throw new Error(
@@ -227,51 +543,73 @@ export async function ensureTmuxInstalled(options: EnsureOptions = {}): Promise<
227
543
  * No-op when already present.
228
544
  */
229
545
  export async function ensureBunInstalled(): Promise<void> {
230
- if (Bun.which("bun")) return;
231
-
232
- const home = getHomeDir();
546
+ if (resolveCommandFromCurrentPath("bun")) return;
233
547
 
234
548
  if (process.platform === "win32") {
235
549
  // Windows
236
- const winget = Bun.which("winget");
550
+ const winget = resolveCommandFromCurrentPath("winget");
237
551
  if (winget) {
238
552
  const result = await runCommand([winget, "install", "Oven-sh.Bun", "--accept-source-agreements", "--accept-package-agreements"], { inherit: true });
239
553
  if (result.success) {
240
- if (home) prependPath(join(home, ".bun", "bin"));
241
- if (Bun.which("bun")) return;
554
+ await refreshWindowsBunPath();
555
+ if (resolveCommandFromCurrentPath("bun")) return;
242
556
  }
243
557
  }
244
558
 
245
- const scoop = Bun.which("scoop");
559
+ const scoop = resolveCommandFromCurrentPath("scoop");
246
560
  if (scoop) {
247
561
  const result = await runCommand([scoop, "install", "bun"], { inherit: true });
248
- if (result.success && Bun.which("bun")) return;
562
+ if (result.success) {
563
+ await refreshWindowsBunPath();
564
+ if (resolveCommandFromCurrentPath("bun")) return;
565
+ }
249
566
  }
250
567
 
251
- return;
568
+ const shell = resolveCommandFromCurrentPath("powershell") ??
569
+ resolveCommandFromCurrentPath("pwsh");
570
+ if (shell) {
571
+ const result = await runCommand([
572
+ shell,
573
+ "-NoProfile",
574
+ "-Command",
575
+ "irm bun.sh/install.ps1 | iex",
576
+ ], { inherit: true });
577
+ if (result.success) {
578
+ await refreshWindowsBunPath();
579
+ if (resolveCommandFromCurrentPath("bun")) return;
580
+ }
581
+ }
582
+
583
+ throw new Error("Could not install bun automatically.");
252
584
  }
253
585
 
254
586
  // Unix / macOS
255
- const shell = Bun.which("bash") ?? Bun.which("sh");
587
+ const shell = resolveCommandFromCurrentPath("bash") ??
588
+ resolveCommandFromCurrentPath("sh");
256
589
  if (shell) {
257
590
  const result = await runCommand(
258
591
  [shell, "-lc", "curl -fsSL https://bun.sh/install | bash"],
259
592
  { inherit: true },
260
593
  );
261
594
  if (result.success) {
262
- if (home) prependPath(join(home, ".bun", "bin"));
263
- if (Bun.which("bun")) return;
595
+ prependBunInstallPaths();
596
+ if (resolveCommandFromCurrentPath("bun")) return;
264
597
  }
265
598
  }
266
599
 
267
600
  // macOS Homebrew fallback
268
601
  if (process.platform === "darwin") {
269
- const brew = Bun.which("brew");
602
+ const brew = resolveCommandFromCurrentPath("brew");
270
603
  if (brew) {
271
604
  const result = await runCommand([brew, "install", "oven-sh/bun/bun"], { inherit: true });
272
- if (result.success && Bun.which("bun")) return;
605
+ if (result.success) {
606
+ prependBunInstallPaths();
607
+ if (resolveCommandFromCurrentPath("bun")) return;
608
+ }
273
609
  }
274
610
  }
611
+
612
+ throw new Error("Could not install bun automatically.");
275
613
  }
276
614
 
277
615
  /**
@@ -112,6 +112,16 @@ function buildWorkflowHookCommand(subcommand: string, extraArgs: readonly string
112
112
  }
113
113
  const runtime = process.execPath;
114
114
  const cliPath = join(import.meta.dir, "..", "..", "cli.ts");
115
+ if (process.platform === "win32") {
116
+ const script = [
117
+ quotePwshLiteral(runtime),
118
+ quotePwshLiteral(cliPath),
119
+ quotePwshLiteral(subcommand),
120
+ ...extraArgs.map(quotePwshLiteral),
121
+ ].join(" ");
122
+ const encoded = Buffer.from(`& ${script}`, "utf16le").toString("base64");
123
+ return `pwsh -NoProfile -EncodedCommand ${encoded}`;
124
+ }
115
125
  return [
116
126
  `"${escBash(runtime)}"`,
117
127
  `"${escBash(cliPath)}"`,
@@ -120,6 +130,13 @@ function buildWorkflowHookCommand(subcommand: string, extraArgs: readonly string
120
130
  ].join(" ");
121
131
  }
122
132
 
133
+ function quotePwshLiteral(s: string): string {
134
+ return `'${s
135
+ .replace(/\x00/g, "")
136
+ .replace(/[\n\r]+/g, " ")
137
+ .replace(/'/g, "''")}'`;
138
+ }
139
+
123
140
  /**
124
141
  * Effectively-unbounded timeout (in seconds) for the Stop hook command.
125
142
  *
@@ -13,9 +13,9 @@
13
13
  * so it can't be used here.
14
14
  */
15
15
 
16
- import { join } from "node:path";
16
+ import { posix, win32 } from "node:path";
17
17
  import type { AgentType } from "../types.ts";
18
- import { tmuxRun } from "./tmux.ts";
18
+ import { getMuxBinary, tmuxRun } from "./tmux.ts";
19
19
 
20
20
  /**
21
21
  * Rows reserved for the footer pane. Matches the single-row height of
@@ -31,6 +31,82 @@ function escBash(s: string): string {
31
31
  .replace(/[\\"$`!]/g, "\\$&");
32
32
  }
33
33
 
34
+ /** Escape a string as a PowerShell single-quoted literal. */
35
+ function quotePwshLiteral(s: string): string {
36
+ return `'${s
37
+ .replace(/\x00/g, "")
38
+ .replace(/[\n\r]+/g, " ")
39
+ .replace(/'/g, "''")}'`;
40
+ }
41
+
42
+ function encodePwshCommand(script: string): string {
43
+ return Buffer.from(script, "utf16le").toString("base64");
44
+ }
45
+
46
+ export function resolveAttachedFooterCliPath(
47
+ runtimeDir = import.meta.dir,
48
+ platform: NodeJS.Platform = process.platform,
49
+ ): string {
50
+ return platform === "win32"
51
+ ? win32.join(runtimeDir, "..", "..", "cli.ts")
52
+ : posix.join(runtimeDir, "..", "..", "cli.ts");
53
+ }
54
+
55
+ export function buildAttachedFooterCommand({
56
+ runtime,
57
+ cliPath,
58
+ windowName,
59
+ agentType,
60
+ platform = process.platform,
61
+ }: {
62
+ runtime: string;
63
+ cliPath: string;
64
+ windowName: string;
65
+ agentType?: AgentType;
66
+ platform?: NodeJS.Platform;
67
+ }): string {
68
+ if (platform === "win32") {
69
+ const script = [
70
+ quotePwshLiteral(runtime),
71
+ quotePwshLiteral(cliPath),
72
+ quotePwshLiteral("_footer"),
73
+ quotePwshLiteral("--name"),
74
+ quotePwshLiteral(windowName),
75
+ ...(agentType
76
+ ? [quotePwshLiteral("--agent"), quotePwshLiteral(agentType)]
77
+ : []),
78
+ ].join(" ");
79
+ return `pwsh -NoProfile -EncodedCommand ${encodePwshCommand(`& ${script}`)}`;
80
+ }
81
+
82
+ const agentFlag = agentType ? ` --agent "${escBash(agentType)}"` : "";
83
+ return (
84
+ `"${escBash(runtime)}" "${escBash(cliPath)}" _footer ` +
85
+ `--name "${escBash(windowName)}"${agentFlag}`
86
+ );
87
+ }
88
+
89
+ export function buildAttachedFooterCloseHooks(
90
+ agentPaneId: string,
91
+ footerPaneId: string,
92
+ options: { guardAgentPane?: boolean } = {},
93
+ ): Array<{ event: string; command: string }> {
94
+ const killFooter = `kill-pane -t ${footerPaneId}`;
95
+ const paneExitedCommand = options.guardAgentPane === false
96
+ ? killFooter
97
+ : `if -F '#{==:#{hook_pane},${agentPaneId}}' '${killFooter}'`;
98
+
99
+ return [
100
+ { event: "pane-exited", command: paneExitedCommand },
101
+ { event: "after-kill-pane", command: killFooter },
102
+ ];
103
+ }
104
+
105
+ function muxSupportsHookPaneFormat(): boolean {
106
+ const binary = getMuxBinary();
107
+ return binary !== "psmux" && binary !== "pmux";
108
+ }
109
+
34
110
  export function spawnAttachedFooter(
35
111
  windowName: string,
36
112
  paneId: string,
@@ -38,11 +114,13 @@ export function spawnAttachedFooter(
38
114
  ): void {
39
115
  const runtime = process.execPath;
40
116
  if (!runtime) return;
41
- const cliPath = join(import.meta.dir, "..", "..", "cli.ts");
42
- const agentFlag = agentType ? ` --agent "${escBash(agentType)}"` : "";
43
- const cmd =
44
- `"${escBash(runtime)}" "${escBash(cliPath)}" _footer ` +
45
- `--name "${escBash(windowName)}"${agentFlag}`;
117
+ const cliPath = resolveAttachedFooterCliPath();
118
+ const cmd = buildAttachedFooterCommand({
119
+ runtime,
120
+ cliPath,
121
+ windowName,
122
+ agentType,
123
+ });
46
124
  const split = tmuxRun([
47
125
  "split-window",
48
126
  "-t", paneId,
@@ -53,6 +131,17 @@ export function spawnAttachedFooter(
53
131
  if (!split.ok) return;
54
132
  const footerPaneId = split.stdout.trim();
55
133
  if (!footerPaneId) return;
134
+ tmuxRun(["select-pane", "-t", paneId]);
135
+ for (const hook of buildAttachedFooterCloseHooks(paneId, footerPaneId, {
136
+ guardAgentPane: muxSupportsHookPaneFormat(),
137
+ })) {
138
+ tmuxRun([
139
+ "set-hook",
140
+ "-w", "-t", footerPaneId,
141
+ hook.event,
142
+ hook.command,
143
+ ]);
144
+ }
56
145
  // Pin the footer to FOOTER_PANE_LINES on every resize so the agent pane
57
146
  // absorbs all new space. Tmux's default proportional redistribution
58
147
  // would otherwise grow the footer on larger windows. Window-scoped