aiwcli 0.15.2 → 0.15.4

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.
@@ -22,6 +22,7 @@ export default class LaunchCommand extends BaseCommand {
22
22
  run(): Promise<void>;
23
23
  private buildTmuxShellCommand;
24
24
  private isTmuxAvailable;
25
+ private isWindowsTerminalAvailable;
25
26
  private sanitizeTmuxSessionName;
26
27
  private buildUniqueTmuxSessionName;
27
28
  private enableTmuxMouseIfPossible;
@@ -26,7 +26,7 @@ export default class LaunchCommand extends BaseCommand {
26
26
  ' 0 Success - AI assistant launched and exited successfully\n' +
27
27
  ' 1 General error - unexpected runtime failure\n' +
28
28
  ' 2 Invalid usage - check your arguments and flags\n' +
29
- ' 3 Environment error - CLI/tmux not found (install Claude Code from https://claude.ai/download, Codex from npm, tmux from your package manager)';
29
+ ' 3 Environment error - CLI not found (install Claude Code from https://claude.ai/download, Codex from npm)';
30
30
  static examples = [
31
31
  '<%= config.bin %> <%= command.id %> # Auto-launches tmux with a fresh session when not already in tmux',
32
32
  '<%= config.bin %> <%= command.id %> --codex # Launch Codex with --yolo flag',
@@ -112,11 +112,7 @@ export default class LaunchCommand extends BaseCommand {
112
112
  // Spawn AI CLI with sandbox permissions disabled
113
113
  // AIW hook system provides safety guardrails
114
114
  // Continue launch regardless of version check result (graceful degradation)
115
- if (shouldAutoTmux) {
116
- if (!this.isTmuxAvailable()) {
117
- this.error('tmux is required for default launch mode but was not found on PATH.\n' +
118
- 'Install tmux, or rerun with --no-tmux to launch directly.', { exit: EXIT_CODES.ENVIRONMENT_ERROR });
119
- }
115
+ if (shouldAutoTmux && this.isTmuxAvailable()) {
120
116
  const shellCommand = this.buildTmuxShellCommand(cliCommand, cliArgs);
121
117
  const sessionFromFlag = flags['tmux-session']?.trim();
122
118
  if (sessionFromFlag && sessionFromFlag.length > 0) {
@@ -131,15 +127,24 @@ export default class LaunchCommand extends BaseCommand {
131
127
  exitCode = await spawnProcess('tmux', ['new-session', '-s', sessionName, shellCommand]);
132
128
  }
133
129
  }
130
+ else if (shouldAutoTmux && this.isWindowsTerminalAvailable()) {
131
+ this.logInfo('tmux unavailable; launching in Windows Terminal split pane');
132
+ exitCode = await spawnProcess('wt', ['split-pane', '--', cliCommand, ...cliArgs]);
133
+ }
134
134
  else {
135
- if (disableTmux)
135
+ if (shouldAutoTmux) {
136
+ this.logInfo('No pane launcher available. Launching directly.');
137
+ }
138
+ else if (disableTmux) {
136
139
  this.debug('tmux launch disabled via --no-tmux');
140
+ }
137
141
  else if (insideTmux) {
138
142
  this.debug('Already inside tmux; launching directly in current pane');
139
143
  this.enableTmuxMouseIfPossible();
140
144
  }
141
- else if (!interactiveTty)
145
+ else if (!interactiveTty) {
142
146
  this.debug('Non-interactive terminal detected; launching directly');
147
+ }
143
148
  exitCode = await spawnProcess(cliCommand, cliArgs);
144
149
  }
145
150
  }
@@ -168,6 +173,17 @@ export default class LaunchCommand extends BaseCommand {
168
173
  return false;
169
174
  }
170
175
  }
176
+ isWindowsTerminalAvailable() {
177
+ if (process.platform !== 'win32')
178
+ return false;
179
+ try {
180
+ execSync('where wt', { stdio: 'ignore' });
181
+ return true;
182
+ }
183
+ catch {
184
+ return false;
185
+ }
186
+ }
171
187
  sanitizeTmuxSessionName(input) {
172
188
  const trimmed = input.trim().toLowerCase();
173
189
  const safe = trimmed
@@ -8,17 +8,20 @@
8
8
  */
9
9
 
10
10
  import * as fs from "node:fs";
11
- import * as os from "node:os";
12
11
  import * as path from "node:path";
13
12
 
14
13
  import { execFileAsync } from "../../base/subprocess-utils.js";
14
+ import {
15
+ buildShellCaptureScript,
16
+ cleanupSentinelIpc,
17
+ createSentinelIpcPaths,
18
+ readSentinelExitCode,
19
+ readTextIfExists,
20
+ waitForSentinelFile,
21
+ } from "../../base/sentinel-ipc.js";
15
22
  import { getTmuxAvailability, quoteForSh, normalizeSplitFlag } from "../../base/tmux-driver.js";
16
23
  import type { ExecutionBackend, ExecutionRequest, ExecutionResult } from "../execution-backend.js";
17
24
 
18
- function sleep(ms: number): Promise<void> {
19
- return new Promise((resolve) => setTimeout(resolve, ms));
20
- }
21
-
22
25
  export interface TmuxBackendOptions {
23
26
  splitFlag?: string;
24
27
  splitTarget?: string;
@@ -32,7 +35,7 @@ export class TmuxBackend implements ExecutionBackend {
32
35
  }
33
36
 
34
37
  async execute(request: ExecutionRequest): Promise<ExecutionResult> {
35
- const tmux = getTmuxAvailability();
38
+ const tmux = getTmuxAvailability({ requireSessionEnv: true });
36
39
  if (!tmux.available || !tmux.tmuxPath) {
37
40
  return {
38
41
  stdout: "",
@@ -43,38 +46,23 @@ export class TmuxBackend implements ExecutionBackend {
43
46
  };
44
47
  }
45
48
 
46
- // Create temp directory for IPC files
47
49
  const agentName = path.basename(request.cliPath).replace(/\.[^.]+$/, "");
48
- const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), `aiwcli-agent-${agentName}-`));
49
-
50
- const promptPath = path.join(tmpDir, "prompt.txt");
51
- const stdoutPath = path.join(tmpDir, "stdout.txt");
52
- const stderrPath = path.join(tmpDir, "stderr.txt");
53
- const sentinelPath = path.join(tmpDir, "sentinel.txt");
50
+ const ipc = createSentinelIpcPaths(`aiwcli-agent-${agentName}`);
54
51
 
55
52
  try {
56
- // Write prompt to file for stdin redirection
57
- fs.writeFileSync(promptPath, request.input, "utf-8");
53
+ fs.writeFileSync(ipc.inputPath, request.input, "utf-8");
58
54
 
59
- // Build env prefix
60
55
  const envEntries = Object.entries(request.env).filter(
61
- ([, v]) => v !== undefined,
56
+ ([, value]) => value !== undefined,
62
57
  ) as Array<[string, string]>;
63
58
  const envPrefix = envEntries
64
- .map(([k, v]) => `${k}=${quoteForSh(v)}`)
59
+ .map(([key, value]) => `${key}=${quoteForSh(value)}`)
65
60
  .join(" ");
66
61
 
67
- // Build command
68
- const quotedArgs = request.args.map((a) => quoteForSh(a)).join(" ");
69
- const script = [
70
- `${envPrefix} ${quoteForSh(request.cliPath)} ${quotedArgs}`,
71
- `< ${quoteForSh(promptPath)}`,
72
- `> ${quoteForSh(stdoutPath)}`,
73
- `2> ${quoteForSh(stderrPath)}`,
74
- `; echo $? > ${quoteForSh(sentinelPath)}`,
75
- ].join(" ");
76
-
77
- // Launch tmux pane
62
+ const quotedArgs = request.args.map((arg) => quoteForSh(arg)).join(" ");
63
+ const command = `${envPrefix} ${quoteForSh(request.cliPath)} ${quotedArgs}`.trim();
64
+ const script = buildShellCaptureScript(command, ipc, quoteForSh);
65
+
78
66
  const splitFlag = normalizeSplitFlag(this.options.splitFlag);
79
67
  const tmuxArgs = ["split-window", splitFlag, "-P", "-F", "#{pane_id}"];
80
68
  if (this.options.splitTarget) {
@@ -94,16 +82,9 @@ export class TmuxBackend implements ExecutionBackend {
94
82
  }
95
83
 
96
84
  const paneId = split.stdout.trim().split(/\r?\n/).pop() ?? "";
85
+ const finished = await waitForSentinelFile(ipc.sentinelPath, request.timeoutMs);
97
86
 
98
- // Poll for sentinel file
99
- const deadline = Date.now() + request.timeoutMs;
100
- while (Date.now() < deadline) {
101
- if (fs.existsSync(sentinelPath)) break;
102
- await sleep(250);
103
- }
104
-
105
- if (!fs.existsSync(sentinelPath)) {
106
- // Timeout: kill pane
87
+ if (!finished) {
107
88
  if (paneId) {
108
89
  await execFileAsync(tmux.tmuxPath, ["kill-pane", "-t", paneId], { timeout: 3000 });
109
90
  }
@@ -116,12 +97,10 @@ export class TmuxBackend implements ExecutionBackend {
116
97
  };
117
98
  }
118
99
 
119
- // Read results
120
- const exitCode = parseInt(fs.readFileSync(sentinelPath, "utf-8").trim(), 10) || 1;
121
- const stdout = fs.existsSync(stdoutPath) ? fs.readFileSync(stdoutPath, "utf-8") : "";
122
- const stderr = fs.existsSync(stderrPath) ? fs.readFileSync(stderrPath, "utf-8") : "";
100
+ const exitCode = readSentinelExitCode(ipc.sentinelPath, 1);
101
+ const stdout = readTextIfExists(ipc.stdoutPath);
102
+ const stderr = readTextIfExists(ipc.stderrPath);
123
103
 
124
- // If outputFilePath specified and exists, read from it instead
125
104
  if (request.outputFilePath && fs.existsSync(request.outputFilePath)) {
126
105
  return {
127
106
  stdout: fs.readFileSync(request.outputFilePath, "utf-8"),
@@ -134,12 +113,7 @@ export class TmuxBackend implements ExecutionBackend {
134
113
 
135
114
  return { stdout, stderr, exitCode, killed: false, signal: null };
136
115
  } finally {
137
- // Clean up temp dir
138
- try {
139
- fs.rmSync(tmpDir, { recursive: true, force: true });
140
- } catch {
141
- // Best-effort cleanup
142
- }
116
+ cleanupSentinelIpc(ipc);
143
117
  }
144
118
  }
145
119
  }
@@ -0,0 +1,173 @@
1
+ import { execFileAsync, findExecutable } from "../subprocess-utils.js";
2
+ import { findBestSplit, listPanes } from "../tmux-pane-placement.js";
3
+ import type { PaneLaunchOptions, PaneLaunchResult, PaneLauncher } from "../pane-launcher.js";
4
+
5
+ export type TmuxSplitFlag = "-h" | "-v";
6
+
7
+ export interface TmuxAvailability {
8
+ available: boolean;
9
+ tmuxPath?: string;
10
+ reason?: string;
11
+ }
12
+
13
+ export interface TmuxLauncherOptions {
14
+ requireSessionEnv?: boolean;
15
+ }
16
+
17
+ export function quoteForSh(input: string): string {
18
+ return `'${input.replaceAll("'", "'\"'\"'")}'`;
19
+ }
20
+
21
+ export function normalizeSplitFlag(value: string | undefined): TmuxSplitFlag {
22
+ return value?.trim() === "-v" ? "-v" : "-h";
23
+ }
24
+
25
+ export function getTmuxAvailability(options?: TmuxLauncherOptions): TmuxAvailability {
26
+ const requireSessionEnv = options?.requireSessionEnv ?? true;
27
+ if (requireSessionEnv && !process.env.TMUX) {
28
+ return { available: false, reason: "TMUX is not set" };
29
+ }
30
+
31
+ const tmuxPath = findExecutable("tmux");
32
+ if (!tmuxPath) {
33
+ return { available: false, reason: "tmux not found on PATH" };
34
+ }
35
+
36
+ return { available: true, tmuxPath };
37
+ }
38
+
39
+ function splitFlagFromDimensions(width: number, height: number): TmuxSplitFlag {
40
+ // Terminal cells are ~2x taller than wide. Correct for aspect ratio so BSP
41
+ // splits the visually longer axis, not just the higher character count.
42
+ const CELL_ASPECT_RATIO = 2.0;
43
+ return width >= height * CELL_ASPECT_RATIO ? "-h" : "-v";
44
+ }
45
+
46
+ function getLastLine(text: string): string {
47
+ const lines = text.split(/\r?\n/).map((line) => line.trim()).filter(Boolean);
48
+ return lines.at(-1) ?? "";
49
+ }
50
+
51
+ async function resolveSplitFlagForTargetPane(
52
+ tmuxPath: string,
53
+ splitTarget: string,
54
+ ): Promise<TmuxSplitFlag | null> {
55
+ const size = await execFileAsync(
56
+ tmuxPath,
57
+ ["display-message", "-p", "-t", splitTarget, "#{pane_width} #{pane_height}"],
58
+ { timeout: 3000 },
59
+ );
60
+ if (size.exitCode !== 0) return null;
61
+
62
+ const parts = size.stdout.trim().split(/\s+/);
63
+ if (parts.length < 2) return null;
64
+
65
+ const width = Number.parseInt(parts[0] ?? "", 10);
66
+ const height = Number.parseInt(parts[1] ?? "", 10);
67
+ if (!Number.isFinite(width) || !Number.isFinite(height)) return null;
68
+
69
+ return splitFlagFromDimensions(width, height);
70
+ }
71
+
72
+ async function resolveAutoSplit(
73
+ tmuxPath: string,
74
+ splitTarget?: string,
75
+ ): Promise<{ splitFlag: TmuxSplitFlag; splitTarget?: string }> {
76
+ const explicitTarget = splitTarget?.trim();
77
+ if (explicitTarget) {
78
+ const splitFlag = await resolveSplitFlagForTargetPane(tmuxPath, explicitTarget);
79
+ return {
80
+ splitFlag: splitFlag ?? "-h",
81
+ splitTarget: explicitTarget,
82
+ };
83
+ }
84
+
85
+ const panes = await listPanes(tmuxPath);
86
+ const placement = findBestSplit(panes);
87
+ if (!placement) return { splitFlag: "-h" };
88
+
89
+ return {
90
+ splitFlag: placement.splitFlag,
91
+ splitTarget: placement.targetPane,
92
+ };
93
+ }
94
+
95
+ export class TmuxLauncher implements PaneLauncher {
96
+ readonly backend = "tmux" as const;
97
+ private options: TmuxLauncherOptions;
98
+
99
+ constructor(options?: TmuxLauncherOptions) {
100
+ this.options = options ?? {};
101
+ }
102
+
103
+ async available(): Promise<boolean> {
104
+ return getTmuxAvailability(this.options).available;
105
+ }
106
+
107
+ async launch(options: PaneLaunchOptions): Promise<PaneLaunchResult> {
108
+ const tmux = getTmuxAvailability(this.options);
109
+ if (!tmux.available || !tmux.tmuxPath) {
110
+ return {
111
+ launched: false,
112
+ backend: this.backend,
113
+ reason: tmux.reason ?? "tmux unavailable",
114
+ };
115
+ }
116
+
117
+ const splitDirection = options.splitDirection ?? "h";
118
+ const explicitTarget = options.splitTarget?.trim();
119
+
120
+ let splitFlag: TmuxSplitFlag;
121
+ let splitTarget: string | undefined;
122
+
123
+ if (splitDirection === "auto") {
124
+ try {
125
+ const resolved = await resolveAutoSplit(tmux.tmuxPath, explicitTarget);
126
+ splitFlag = resolved.splitFlag;
127
+ splitTarget = resolved.splitTarget;
128
+ } catch {
129
+ splitFlag = "-h";
130
+ splitTarget = explicitTarget;
131
+ }
132
+ } else {
133
+ splitFlag = splitDirection === "v" ? "-v" : "-h";
134
+ splitTarget = explicitTarget;
135
+ }
136
+
137
+ const body = options.cwd?.trim()
138
+ ? `cd ${quoteForSh(options.cwd.trim())} && ${options.command}`
139
+ : options.command;
140
+
141
+ const tmuxArgs = ["split-window", splitFlag, "-P", "-F", "#{pane_id}"];
142
+ if (splitTarget) tmuxArgs.push("-t", splitTarget);
143
+ tmuxArgs.push(`bash -lc ${quoteForSh(body)}`);
144
+
145
+ const split = await execFileAsync(tmux.tmuxPath, tmuxArgs, { timeout: 5000 });
146
+ if (split.exitCode !== 0) {
147
+ return {
148
+ launched: false,
149
+ backend: this.backend,
150
+ reason: "tmux split-window failed",
151
+ stderr: split.stderr.trim() || undefined,
152
+ };
153
+ }
154
+
155
+ const paneId = getLastLine(split.stdout) || undefined;
156
+ return {
157
+ launched: true,
158
+ backend: this.backend,
159
+ paneId,
160
+ };
161
+ }
162
+
163
+ async kill(paneId: string): Promise<void> {
164
+ if (!paneId) return;
165
+
166
+ const tmux = getTmuxAvailability(this.options);
167
+ if (!tmux.available || !tmux.tmuxPath) return;
168
+
169
+ await execFileAsync(tmux.tmuxPath, ["kill-pane", "-t", paneId], {
170
+ timeout: 3000,
171
+ });
172
+ }
173
+ }
@@ -0,0 +1,93 @@
1
+ import { execFileAsync, findExecutable } from "../subprocess-utils.js";
2
+ import { quoteForSh } from "./tmux-launcher.js";
3
+ import type { PaneLaunchOptions, PaneLaunchResult, PaneLauncher } from "../pane-launcher.js";
4
+
5
+ function quoteForPowerShell(input: string): string {
6
+ return `'${input.replaceAll("'", "''")}'`;
7
+ }
8
+
9
+ function findPowerShell(): string | null {
10
+ return findExecutable("pwsh") ?? findExecutable("powershell");
11
+ }
12
+
13
+ export class WindowLauncher implements PaneLauncher {
14
+ readonly backend = "window" as const;
15
+
16
+ async available(): Promise<boolean> {
17
+ if (process.platform !== "win32") return false;
18
+ return Boolean(findPowerShell() ?? findExecutable("mintty"));
19
+ }
20
+
21
+ async launch(options: PaneLaunchOptions): Promise<PaneLaunchResult> {
22
+ if (process.platform !== "win32") {
23
+ return {
24
+ launched: false,
25
+ backend: this.backend,
26
+ reason: "window launcher only available on Windows",
27
+ };
28
+ }
29
+
30
+ const powershellPath = findPowerShell();
31
+ if (powershellPath) {
32
+ const cwd = options.cwd?.trim() || process.cwd();
33
+ const startProcess = [
34
+ `$cmd = ${quoteForPowerShell(options.command)}`,
35
+ `$wd = ${quoteForPowerShell(cwd)}`,
36
+ `Start-Process -FilePath ${quoteForPowerShell(powershellPath)} -WorkingDirectory $wd -ArgumentList @('-NoProfile','-Command',$cmd) | Out-Null`,
37
+ ].join("; ");
38
+
39
+ const psLaunch = await execFileAsync(
40
+ powershellPath,
41
+ ["-NoProfile", "-Command", startProcess],
42
+ { timeout: 5000, shell: false },
43
+ );
44
+
45
+ if (psLaunch.exitCode === 0) {
46
+ return {
47
+ launched: true,
48
+ backend: this.backend,
49
+ };
50
+ }
51
+
52
+ return {
53
+ launched: false,
54
+ backend: this.backend,
55
+ reason: "Start-Process launch failed",
56
+ stderr: psLaunch.stderr.trim() || undefined,
57
+ };
58
+ }
59
+
60
+ const minttyPath = findExecutable("mintty");
61
+ if (!minttyPath) {
62
+ return {
63
+ launched: false,
64
+ backend: this.backend,
65
+ reason: "PowerShell and mintty are both unavailable",
66
+ };
67
+ }
68
+
69
+ const shellBody = options.cwd?.trim()
70
+ ? `cd ${quoteForSh(options.cwd.trim())} && ${options.command}`
71
+ : options.command;
72
+
73
+ const minttyLaunch = await execFileAsync(
74
+ minttyPath,
75
+ ["-e", "bash", "-lc", shellBody],
76
+ { timeout: 5000, shell: false },
77
+ );
78
+
79
+ if (minttyLaunch.exitCode !== 0) {
80
+ return {
81
+ launched: false,
82
+ backend: this.backend,
83
+ reason: "mintty launch failed",
84
+ stderr: minttyLaunch.stderr.trim() || undefined,
85
+ };
86
+ }
87
+
88
+ return {
89
+ launched: true,
90
+ backend: this.backend,
91
+ };
92
+ }
93
+ }
@@ -0,0 +1,64 @@
1
+ import { execFileAsync, findExecutable } from "../subprocess-utils.js";
2
+ import type { PaneLaunchOptions, PaneLaunchResult, PaneLauncher } from "../pane-launcher.js";
3
+
4
+ function findPowerShell(): string {
5
+ return findExecutable("pwsh") ?? findExecutable("powershell") ?? "powershell";
6
+ }
7
+
8
+ export class WtLauncher implements PaneLauncher {
9
+ readonly backend = "wt" as const;
10
+
11
+ async available(): Promise<boolean> {
12
+ if (process.platform !== "win32") return false;
13
+ return Boolean(findExecutable("wt") ?? findExecutable("wt.exe"));
14
+ }
15
+
16
+ async launch(options: PaneLaunchOptions): Promise<PaneLaunchResult> {
17
+ if (process.platform !== "win32") {
18
+ return {
19
+ launched: false,
20
+ backend: this.backend,
21
+ reason: "wt launcher only available on Windows",
22
+ };
23
+ }
24
+
25
+ const wtPath = findExecutable("wt") ?? findExecutable("wt.exe");
26
+ if (!wtPath) {
27
+ return {
28
+ launched: false,
29
+ backend: this.backend,
30
+ reason: "wt.exe not found on PATH",
31
+ };
32
+ }
33
+
34
+ const args = ["split-pane"];
35
+ const splitDirection = options.splitDirection ?? "auto";
36
+ if (splitDirection === "h") args.push("-H");
37
+ if (splitDirection === "v") args.push("-V");
38
+
39
+ if (options.cwd?.trim()) args.push("-d", options.cwd.trim());
40
+ if (options.title?.trim()) args.push("--title", options.title.trim());
41
+
42
+ const powershellPath = findPowerShell();
43
+ args.push("--", powershellPath, "-NoProfile", "-Command", options.command);
44
+
45
+ const result = await execFileAsync(wtPath, args, {
46
+ timeout: 5000,
47
+ shell: false,
48
+ });
49
+
50
+ if (result.exitCode !== 0) {
51
+ return {
52
+ launched: false,
53
+ backend: this.backend,
54
+ reason: "wt split-pane failed",
55
+ stderr: result.stderr.trim() || undefined,
56
+ };
57
+ }
58
+
59
+ return {
60
+ launched: true,
61
+ backend: this.backend,
62
+ };
63
+ }
64
+ }
@@ -0,0 +1,55 @@
1
+ import { TmuxLauncher } from "./launchers/tmux-launcher.js";
2
+ import { WindowLauncher } from "./launchers/window-launcher.js";
3
+ import { WtLauncher } from "./launchers/wt-launcher.js";
4
+
5
+ export type PaneBackend = "tmux" | "wt" | "window" | "exec";
6
+ export type PaneSplitDirection = "h" | "v" | "auto";
7
+
8
+ export interface PaneLaunchOptions {
9
+ command: string;
10
+ splitDirection?: PaneSplitDirection;
11
+ splitTarget?: string;
12
+ cwd?: string;
13
+ title?: string;
14
+ }
15
+
16
+ export interface PaneLaunchResult {
17
+ launched: boolean;
18
+ backend: PaneBackend;
19
+ paneId?: string;
20
+ reason?: string;
21
+ stderr?: string;
22
+ }
23
+
24
+ export interface PaneLauncher {
25
+ readonly backend: PaneBackend;
26
+ available(): Promise<boolean>;
27
+ launch(options: PaneLaunchOptions): Promise<PaneLaunchResult>;
28
+ kill?(paneId: string): Promise<void>;
29
+ }
30
+
31
+ export interface PaneLauncherFactoryOptions {
32
+ /** Include tmux launcher only when running inside a tmux session. Default: true. */
33
+ requireTmuxSession?: boolean;
34
+ }
35
+
36
+ /**
37
+ * Resolve the first available pane launcher for the current environment.
38
+ * Detection order: tmux -> Windows Terminal -> window fallback.
39
+ */
40
+ export async function createPaneLauncher(
41
+ options?: PaneLauncherFactoryOptions,
42
+ ): Promise<PaneLauncher | null> {
43
+ const requireTmuxSession = options?.requireTmuxSession ?? true;
44
+
45
+ const tmux = new TmuxLauncher({ requireSessionEnv: requireTmuxSession });
46
+ if (await tmux.available()) return tmux;
47
+
48
+ const wt = new WtLauncher();
49
+ if (await wt.available()) return wt;
50
+
51
+ const window = new WindowLauncher();
52
+ if (await window.available()) return window;
53
+
54
+ return null;
55
+ }
@@ -0,0 +1,87 @@
1
+ import * as fs from "node:fs";
2
+ import * as os from "node:os";
3
+ import * as path from "node:path";
4
+
5
+ export interface SentinelIpcPaths {
6
+ tmpDir: string;
7
+ inputPath: string;
8
+ stdoutPath: string;
9
+ stderrPath: string;
10
+ sentinelPath: string;
11
+ }
12
+
13
+ function sanitizePrefix(prefix: string): string {
14
+ return prefix.replaceAll(/[^a-zA-Z0-9_-]/g, "-");
15
+ }
16
+
17
+ export function createSentinelIpcPaths(prefix: string): SentinelIpcPaths {
18
+ const safePrefix = sanitizePrefix(prefix);
19
+ const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), `${safePrefix}-`));
20
+
21
+ return {
22
+ tmpDir,
23
+ inputPath: path.join(tmpDir, "input.txt"),
24
+ stdoutPath: path.join(tmpDir, "stdout.txt"),
25
+ stderrPath: path.join(tmpDir, "stderr.txt"),
26
+ sentinelPath: path.join(tmpDir, "sentinel.txt"),
27
+ };
28
+ }
29
+
30
+ export function buildShellCaptureScript(
31
+ command: string,
32
+ paths: Pick<SentinelIpcPaths, "inputPath" | "stdoutPath" | "stderrPath" | "sentinelPath">,
33
+ quote: (input: string) => string,
34
+ ): string {
35
+ return [
36
+ command,
37
+ `< ${quote(paths.inputPath)}`,
38
+ `> ${quote(paths.stdoutPath)}`,
39
+ `2> ${quote(paths.stderrPath)}`,
40
+ `; echo $? > ${quote(paths.sentinelPath)}`,
41
+ ].join(" ");
42
+ }
43
+
44
+ export async function waitForSentinelFile(
45
+ sentinelPath: string,
46
+ timeoutMs: number,
47
+ pollIntervalMs = 250,
48
+ ): Promise<boolean> {
49
+ const deadline = Date.now() + timeoutMs;
50
+ while (Date.now() < deadline) {
51
+ if (fs.existsSync(sentinelPath)) return true;
52
+ await new Promise((resolve) => setTimeout(resolve, pollIntervalMs));
53
+ }
54
+
55
+ return fs.existsSync(sentinelPath);
56
+ }
57
+
58
+ export function readSentinelExitCode(sentinelPath: string, fallback = 1): number {
59
+ if (!fs.existsSync(sentinelPath)) return fallback;
60
+
61
+ const raw = fs.readFileSync(sentinelPath, "utf-8").trim();
62
+ const parsed = Number.parseInt(raw, 10);
63
+ return Number.isFinite(parsed) ? parsed : fallback;
64
+ }
65
+
66
+ export function readTextIfExists(filePath: string): string {
67
+ if (!filePath || !fs.existsSync(filePath)) return "";
68
+ return fs.readFileSync(filePath, "utf-8");
69
+ }
70
+
71
+ export function cleanupSentinelIpc(paths: Pick<SentinelIpcPaths, "tmpDir">): void {
72
+ try {
73
+ fs.rmSync(paths.tmpDir, { recursive: true, force: true });
74
+ } catch {
75
+ // Best-effort cleanup.
76
+ }
77
+ }
78
+
79
+ export function cleanupSentinelPath(sentinelPath: string | undefined): void {
80
+ if (!sentinelPath) return;
81
+
82
+ try {
83
+ fs.rmSync(path.dirname(sentinelPath), { recursive: true, force: true });
84
+ } catch {
85
+ // Best-effort cleanup.
86
+ }
87
+ }