aiwcli 0.15.1 → 0.15.3

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 (23) hide show
  1. package/dist/commands/launch.d.ts +1 -0
  2. package/dist/commands/launch.js +24 -8
  3. package/dist/templates/_shared/.claude/skills/codex/SKILL.md +19 -26
  4. package/dist/templates/_shared/.codex/workflows/codex.md +11 -0
  5. package/dist/templates/_shared/lib-ts/agent-exec/backends/tmux.ts +23 -49
  6. package/dist/templates/_shared/lib-ts/base/launchers/tmux-launcher.ts +173 -0
  7. package/dist/templates/_shared/lib-ts/base/launchers/window-launcher.ts +93 -0
  8. package/dist/templates/_shared/lib-ts/base/launchers/wt-launcher.ts +64 -0
  9. package/dist/templates/_shared/lib-ts/base/pane-launcher.ts +55 -0
  10. package/dist/templates/_shared/lib-ts/base/sentinel-ipc.ts +87 -0
  11. package/dist/templates/_shared/lib-ts/base/tmux-driver.ts +160 -200
  12. package/dist/templates/_shared/lib-ts/base/tmux-pane-placement.ts +78 -0
  13. package/dist/templates/_shared/lib-ts/context/context-formatter.ts +0 -3
  14. package/dist/templates/_shared/scripts/resolve-run.ts +1 -1
  15. package/dist/templates/_shared/skills/codex/CLAUDE.md +70 -0
  16. package/dist/templates/_shared/skills/codex/SKILL.md +71 -0
  17. package/dist/templates/_shared/skills/{prompt-codex/scripts/watch-codex.ts → codex/lib/codex-watcher.ts} +78 -63
  18. package/dist/templates/_shared/skills/{prompt-codex → codex}/scripts/launch-codex.ts +106 -61
  19. package/dist/templates/_shared/skills/codex/scripts/watch-codex.ts +42 -0
  20. package/oclif.manifest.json +2 -2
  21. package/package.json +1 -1
  22. package/dist/templates/_shared/.claude/skills/codex/prompt.md +0 -30
  23. package/dist/templates/_shared/skills/prompt-codex/CLAUDE.md +0 -71
@@ -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
+ }
@@ -1,20 +1,33 @@
1
1
  /**
2
- * Shared tmux pane driver helpers for launching CLI tools in split panes.
2
+ * Shared pane driver helpers for launching CLI tools in visible panes.
3
3
  *
4
- * This module centralizes:
5
- * - tmux/session availability checks
6
- * - split-pane command launch behavior (auto-close vs hold-open)
7
- * - REPL prompt injection into tmux panes
8
- * - optional direct exec fallback when tmux is unavailable
9
- * - optional provider/model preflight hook point
4
+ * This module now delegates pane creation to platform-specific launchers:
5
+ * - tmux (Linux/macOS in tmux sessions)
6
+ * - Windows Terminal split-pane
7
+ * - Windows new-window fallback
8
+ *
9
+ * Prompt delivery is handled at launch time by passing prompt text as a CLI
10
+ * argument for REPL mode, rather than injecting into an already-running pane.
10
11
  */
11
12
 
12
13
  import * as fs from "node:fs";
13
14
 
15
+ import { createPaneLauncher, type PaneBackend, type PaneSplitDirection } from "./pane-launcher.js";
16
+ import { cleanupSentinelIpc, createSentinelIpcPaths } from "./sentinel-ipc.js";
14
17
  import { execFileAsync, findExecutable } from "./subprocess-utils.js";
18
+ import {
19
+ getTmuxAvailability,
20
+ normalizeSplitFlag,
21
+ quoteForSh,
22
+ type TmuxAvailability,
23
+ type TmuxSplitFlag,
24
+ } from "./launchers/tmux-launcher.js";
25
+
26
+ export type { TmuxAvailability, TmuxSplitFlag };
27
+ export { getTmuxAvailability, normalizeSplitFlag, quoteForSh };
15
28
 
16
29
  export type DriverMode = "exec" | "repl";
17
- export type TmuxSplitFlag = "-h" | "-v";
30
+ export type TmuxSplitOption = TmuxSplitFlag | "auto";
18
31
 
19
32
  export interface DriverPreflightResult {
20
33
  available: boolean;
@@ -24,27 +37,6 @@ export interface DriverPreflightResult {
24
37
  export type DriverPreflight =
25
38
  (toolPath: string) => Promise<DriverPreflightResult> | DriverPreflightResult;
26
39
 
27
- export interface SendToPaneOptions {
28
- waitForPromptMs?: number;
29
- postPasteDelayMs?: number;
30
- retryEnter?: boolean;
31
- retryDelayMs?: number;
32
- }
33
-
34
- export interface SendToPaneResult {
35
- success: boolean;
36
- /** Which step failed, if any */
37
- failedAt?: "prompt-wait" | "load-buffer" | "paste-buffer" | "send-enter";
38
- /** ms spent waiting for REPL prompt */
39
- promptWaitMs?: number;
40
- /** Last 200 chars of pane content at time of prompt-wait timeout */
41
- paneTailOnTimeout?: string;
42
- /** stderr from the failed tmux command, if any */
43
- tmuxStderr?: string;
44
- /** Whether retry Enter was sent */
45
- retrySent?: boolean;
46
- }
47
-
48
40
  export interface LaunchDriverOptions {
49
41
  toolName: string;
50
42
  toolBin?: string;
@@ -52,8 +44,7 @@ export interface LaunchDriverOptions {
52
44
  args?: string[];
53
45
  env?: Record<string, string>;
54
46
  promptPath?: string;
55
- sendPromptInRepl?: boolean;
56
- splitFlag?: string;
47
+ splitFlag?: TmuxSplitOption;
57
48
  splitTarget?: string;
58
49
  autoClose?: boolean;
59
50
  holdPane?: boolean;
@@ -66,31 +57,15 @@ export interface LaunchDriverOptions {
66
57
  export interface LaunchDriverResult {
67
58
  launched: boolean;
68
59
  usedTmux: boolean;
60
+ backend: PaneBackend;
69
61
  mode: DriverMode;
70
62
  toolPath?: string;
71
63
  paneId?: string;
64
+ sentinelPath?: string;
72
65
  exitCode?: number;
66
+ stdout?: string;
73
67
  reason?: string;
74
68
  stderr?: string;
75
- sendDiagnostics?: SendToPaneResult;
76
- }
77
-
78
- export interface TmuxAvailability {
79
- available: boolean;
80
- tmuxPath?: string;
81
- reason?: string;
82
- }
83
-
84
- const REPL_PROMPT_REGEX = /(^|\n)\s*[›>]\s/;
85
- const REPL_ACTIVITY_HINT = "\n• ";
86
-
87
- function sleep(ms: number): Promise<void> {
88
- return new Promise((resolve) => setTimeout(resolve, ms));
89
- }
90
-
91
- function getLastLine(text: string): string {
92
- const lines = text.split(/\r?\n/).map((line) => line.trim()).filter(Boolean);
93
- return lines.at(-1) ?? "";
94
69
  }
95
70
 
96
71
  function buildEnvPrefix(env: Record<string, string>): string {
@@ -99,7 +74,21 @@ function buildEnvPrefix(env: Record<string, string>): string {
99
74
  .join(" ");
100
75
  }
101
76
 
102
- function buildToolCommand(
77
+ function quoteForPowerShell(input: string): string {
78
+ return `'${input.replaceAll("'", "''")}'`;
79
+ }
80
+
81
+ function buildCommandArgs(
82
+ args: string[],
83
+ mode: DriverMode,
84
+ promptPath?: string,
85
+ ): string[] {
86
+ if (mode !== "repl" || !promptPath) return args;
87
+ const promptText = fs.readFileSync(promptPath, "utf-8");
88
+ return [...args, promptText];
89
+ }
90
+
91
+ function buildShToolCommand(
103
92
  toolPath: string,
104
93
  args: string[],
105
94
  env: Record<string, string>,
@@ -107,7 +96,8 @@ function buildToolCommand(
107
96
  promptPath?: string,
108
97
  ): string {
109
98
  const envPrefix = buildEnvPrefix(env);
110
- const argPart = args.map((arg) => quoteForSh(arg)).join(" ");
99
+ const commandArgs = buildCommandArgs(args, mode, promptPath);
100
+ const argPart = commandArgs.map((arg) => quoteForSh(arg)).join(" ");
111
101
  const base = [envPrefix, quoteForSh(toolPath), argPart]
112
102
  .filter(Boolean)
113
103
  .join(" ");
@@ -119,50 +109,63 @@ function buildToolCommand(
119
109
  return base;
120
110
  }
121
111
 
112
+ function buildPowerShellToolCommand(
113
+ toolPath: string,
114
+ args: string[],
115
+ env: Record<string, string>,
116
+ mode: DriverMode,
117
+ promptPath?: string,
118
+ ): string {
119
+ const envPrefix = Object.entries(env)
120
+ .map(([key, value]) => `$env:${key}=${quoteForPowerShell(value)}`)
121
+ .join("; ");
122
+
123
+ const commandArgs = buildCommandArgs(args, mode, promptPath);
124
+ const argArray = commandArgs.map((arg) => quoteForPowerShell(arg)).join(", ");
125
+ const invocation = `& ${quoteForPowerShell(toolPath)}${argArray ? ` @(${argArray})` : ""}`;
126
+
127
+ const body = mode === "exec" && promptPath
128
+ ? `Get-Content -Raw -Path ${quoteForPowerShell(promptPath)} | ${invocation}`
129
+ : invocation;
130
+
131
+ return [envPrefix, body].filter(Boolean).join("; ");
132
+ }
133
+
122
134
  function wrapPaneCommand(
135
+ backend: PaneBackend,
123
136
  command: string,
137
+ sentinelPath: string,
124
138
  autoClose: boolean,
125
139
  holdPane: boolean,
126
140
  holdMessage: string,
127
141
  ): string {
128
- if (autoClose) {
129
- return `${command}; code=$?; tmux kill-pane -t "$TMUX_PANE" >/dev/null 2>&1 || true; exit $code`;
130
- }
142
+ if (backend === "tmux") {
143
+ const base = `${command}; code=$?; printf '%s' "$code" > ${quoteForSh(sentinelPath)}`;
131
144
 
132
- if (holdPane) {
133
- return `${command}; code=$?; echo; echo ${quoteForSh(holdMessage)}; exec bash`;
134
- }
145
+ if (autoClose) {
146
+ return `${base}; tmux kill-pane -t "$TMUX_PANE" >/dev/null 2>&1 || true; exit $code`;
147
+ }
135
148
 
136
- return command;
137
- }
149
+ if (holdPane) {
150
+ return `${base}; echo; echo ${quoteForSh(holdMessage)}; exec bash`;
151
+ }
138
152
 
139
- async function capturePaneText(tmuxPath: string, paneId: string): Promise<string> {
140
- const result = await execFileAsync(tmuxPath, ["capture-pane", "-p", "-t", paneId], {
141
- timeout: 3000,
142
- });
143
- return result.exitCode === 0 ? result.stdout : "";
144
- }
153
+ return `${base}; exit $code`;
154
+ }
155
+
156
+ const base = `${command}; $code = $LASTEXITCODE; Set-Content -Path ${quoteForPowerShell(sentinelPath)} -Value $code -NoNewline`;
145
157
 
146
- async function waitForReplPrompt(
147
- tmuxPath: string,
148
- paneId: string,
149
- timeoutMs: number,
150
- ): Promise<boolean> {
151
- const deadline = Date.now() + timeoutMs;
152
- while (Date.now() < deadline) {
153
- const snapshot = await capturePaneText(tmuxPath, paneId);
154
- if (REPL_PROMPT_REGEX.test(snapshot)) return true;
155
- await sleep(250);
158
+ if (holdPane && !autoClose) {
159
+ return `${base}; Write-Host ''; Write-Host ${quoteForPowerShell(holdMessage)}; Read-Host -Prompt 'Press Enter to close' | Out-Null`;
156
160
  }
157
- return false;
158
- }
159
161
 
160
- export function quoteForSh(input: string): string {
161
- return `'${input.replaceAll("'", "'\"'\"'")}'`;
162
+ return `${base}; exit $code`;
162
163
  }
163
164
 
164
- export function normalizeSplitFlag(value: string | undefined): TmuxSplitFlag {
165
- return value?.trim() === "-v" ? "-v" : "-h";
165
+ function mapSplitDirection(splitFlag: TmuxSplitOption | undefined): PaneSplitDirection {
166
+ if (splitFlag === "auto") return "auto";
167
+ if (splitFlag === "-v") return "v";
168
+ return "h";
166
169
  }
167
170
 
168
171
  export function isTruthy(value: string | undefined): boolean {
@@ -171,77 +174,24 @@ export function isTruthy(value: string | undefined): boolean {
171
174
  return normalized === "1" || normalized === "true" || normalized === "yes" || normalized === "on";
172
175
  }
173
176
 
174
- export function getTmuxAvailability(): TmuxAvailability {
175
- if (!process.env.TMUX) {
176
- return { available: false, reason: "TMUX is not set" };
177
- }
178
-
179
- const tmuxPath = findExecutable("tmux");
180
- if (!tmuxPath) {
181
- return { available: false, reason: "tmux not found on PATH" };
182
- }
183
-
184
- return { available: true, tmuxPath };
185
- }
186
-
187
177
  export function resolveToolPath(toolName: string, toolBin?: string): string | null {
188
178
  const bin = toolBin?.trim() || toolName;
189
179
  return findExecutable(bin);
190
180
  }
191
181
 
192
- export async function sendFileToPane(
193
- tmuxPath: string,
194
- paneId: string,
195
- filePath: string,
196
- options?: SendToPaneOptions,
197
- ): Promise<SendToPaneResult> {
198
- if (!fs.existsSync(filePath)) return { success: false, failedAt: "load-buffer", tmuxStderr: "file not found" };
199
-
200
- const startTime = Date.now();
201
- const promptReady = await waitForReplPrompt(tmuxPath, paneId, options?.waitForPromptMs ?? 12000);
202
- const promptWaitMs = Date.now() - startTime;
203
-
204
- if (!promptReady) {
205
- const snapshot = await capturePaneText(tmuxPath, paneId);
206
- const paneTailOnTimeout = snapshot.slice(-200);
207
- return { success: false, failedAt: "prompt-wait", promptWaitMs, paneTailOnTimeout };
208
- }
209
-
210
- const bufferName = `aiwcli-pane-${Date.now()}`;
211
- const load = await execFileAsync(tmuxPath, ["load-buffer", "-b", bufferName, filePath], {
212
- timeout: 3000,
213
- });
214
- if (load.exitCode !== 0) return { success: false, failedAt: "load-buffer", promptWaitMs, tmuxStderr: load.stderr };
215
-
216
- const paste = await execFileAsync(tmuxPath, ["paste-buffer", "-d", "-p", "-b", bufferName, "-t", paneId], {
217
- timeout: 3000,
218
- });
219
- if (paste.exitCode !== 0) return { success: false, failedAt: "paste-buffer", promptWaitMs, tmuxStderr: paste.stderr };
220
-
221
- await sleep(options?.postPasteDelayMs ?? 500);
222
-
223
- const firstEnter = await execFileAsync(tmuxPath, ["send-keys", "-t", paneId, "Enter"], {
224
- timeout: 3000,
225
- });
226
- if (firstEnter.exitCode !== 0) return { success: false, failedAt: "send-enter", promptWaitMs, tmuxStderr: firstEnter.stderr };
227
-
228
- if (options?.retryEnter === false) {
229
- return { success: true, promptWaitMs, retrySent: false };
230
- }
231
-
232
- let retrySent = false;
233
- await sleep(options?.retryDelayMs ?? 1200);
234
- const snapshot = await capturePaneText(tmuxPath, paneId);
235
- const lastFewLines = snapshot.split(/\r?\n/).slice(-5).join("\n");
236
- if (REPL_PROMPT_REGEX.test(lastFewLines) && !lastFewLines.includes(REPL_ACTIVITY_HINT)) {
237
- const secondEnter = await execFileAsync(tmuxPath, ["send-keys", "-t", paneId, "Enter"], {
238
- timeout: 3000,
239
- });
240
- if (secondEnter.exitCode !== 0) return { success: false, failedAt: "send-enter", promptWaitMs, tmuxStderr: secondEnter.stderr };
241
- retrySent = true;
182
+ function buildCommandForBackend(
183
+ backend: PaneBackend,
184
+ toolPath: string,
185
+ args: string[],
186
+ envVars: Record<string, string>,
187
+ mode: DriverMode,
188
+ promptPath?: string,
189
+ ): string {
190
+ if (backend === "tmux") {
191
+ return buildShToolCommand(toolPath, args, envVars, mode, promptPath);
242
192
  }
243
193
 
244
- return { success: true, promptWaitMs, retrySent };
194
+ return buildPowerShellToolCommand(toolPath, args, envVars, mode, promptPath);
245
195
  }
246
196
 
247
197
  export async function launchDriverInTmuxOrFallback(
@@ -257,6 +207,7 @@ export async function launchDriverInTmuxOrFallback(
257
207
  return {
258
208
  launched: false,
259
209
  usedTmux: false,
210
+ backend: "exec",
260
211
  mode,
261
212
  reason: `${options.toolBin ?? options.toolName} not found on PATH`,
262
213
  };
@@ -268,6 +219,7 @@ export async function launchDriverInTmuxOrFallback(
268
219
  return {
269
220
  launched: false,
270
221
  usedTmux: false,
222
+ backend: "exec",
271
223
  mode,
272
224
  toolPath,
273
225
  reason: preflight.error ?? "driver preflight failed",
@@ -275,85 +227,89 @@ export async function launchDriverInTmuxOrFallback(
275
227
  }
276
228
  }
277
229
 
278
- if (mode === "exec" && options.promptPath && !fs.existsSync(options.promptPath)) {
230
+ if (options.promptPath && !fs.existsSync(options.promptPath)) {
279
231
  return {
280
232
  launched: false,
281
233
  usedTmux: false,
234
+ backend: "exec",
282
235
  mode,
283
236
  toolPath,
284
237
  reason: `prompt file not found: ${options.promptPath}`,
285
238
  };
286
239
  }
287
240
 
288
- const tmux = getTmuxAvailability();
289
- if (tmux.available && tmux.tmuxPath) {
290
- const splitFlag = normalizeSplitFlag(options.splitFlag);
291
- const splitTarget = options.splitTarget?.trim();
292
- const baseCmd = buildToolCommand(toolPath, args, envVars, mode, options.promptPath);
293
- const holdMessage = options.holdMessage ?? "[aiwcli] Driver exited. Pane held open.";
294
- const paneBody = wrapPaneCommand(
295
- baseCmd,
296
- Boolean(options.autoClose),
297
- Boolean(options.holdPane) && !Boolean(options.autoClose),
298
- holdMessage,
299
- );
300
- const paneCmd = `bash -lc ${quoteForSh(paneBody)}`;
301
-
302
- const tmuxArgs = ["split-window", splitFlag, "-P", "-F", "#{pane_id}"];
303
- if (splitTarget) tmuxArgs.push("-t", splitTarget);
304
- tmuxArgs.push(paneCmd);
305
-
306
- const split = await execFileAsync(tmux.tmuxPath, tmuxArgs, { timeout: 3000 });
307
- if (split.exitCode !== 0) {
308
- return {
309
- launched: false,
310
- usedTmux: true,
311
- mode,
312
- toolPath,
313
- reason: "tmux split-window failed",
314
- stderr: split.stderr.trim() || undefined,
315
- };
316
- }
241
+ const paneLauncher = await createPaneLauncher({ requireTmuxSession: true });
242
+ if (paneLauncher) {
243
+ const sentinel = createSentinelIpcPaths(`aiwcli-pane-${options.toolName}`);
317
244
 
318
- const paneId = getLastLine(split.stdout);
319
- if (mode === "repl" && options.sendPromptInRepl !== false && paneId && options.promptPath) {
320
- const sendResult = await sendFileToPane(tmux.tmuxPath, paneId, options.promptPath);
321
- if (!sendResult.success) {
245
+ try {
246
+ const baseCommand = buildCommandForBackend(
247
+ paneLauncher.backend,
248
+ toolPath,
249
+ args,
250
+ envVars,
251
+ mode,
252
+ options.promptPath,
253
+ );
254
+
255
+ const holdMessage = options.holdMessage ?? "[aiwcli] Driver exited. Pane held open.";
256
+ const paneCommand = wrapPaneCommand(
257
+ paneLauncher.backend,
258
+ baseCommand,
259
+ sentinel.sentinelPath,
260
+ Boolean(options.autoClose),
261
+ Boolean(options.holdPane) && !Boolean(options.autoClose),
262
+ holdMessage,
263
+ );
264
+
265
+ const paneResult = await paneLauncher.launch({
266
+ command: paneCommand,
267
+ splitDirection: mapSplitDirection(options.splitFlag),
268
+ splitTarget: options.splitTarget,
269
+ });
270
+
271
+ if (!paneResult.launched) {
272
+ cleanupSentinelIpc(sentinel);
322
273
  return {
323
- launched: true,
324
- usedTmux: true,
274
+ launched: false,
275
+ usedTmux: paneLauncher.backend === "tmux",
276
+ backend: paneLauncher.backend,
325
277
  mode,
326
278
  toolPath,
327
- paneId,
328
- reason: `launched, but prompt injection failed at ${sendResult.failedAt}`,
329
- sendDiagnostics: sendResult,
279
+ reason: paneResult.reason ?? "pane launch failed",
280
+ stderr: paneResult.stderr,
330
281
  };
331
282
  }
283
+
332
284
  return {
333
285
  launched: true,
334
- usedTmux: true,
286
+ usedTmux: paneLauncher.backend === "tmux",
287
+ backend: paneLauncher.backend,
288
+ mode,
289
+ toolPath,
290
+ paneId: paneResult.paneId,
291
+ sentinelPath: sentinel.sentinelPath,
292
+ };
293
+ } catch (error) {
294
+ cleanupSentinelIpc(sentinel);
295
+ return {
296
+ launched: false,
297
+ usedTmux: paneLauncher.backend === "tmux",
298
+ backend: paneLauncher.backend,
335
299
  mode,
336
300
  toolPath,
337
- paneId: paneId || undefined,
338
- sendDiagnostics: sendResult,
301
+ reason: `pane launch failed: ${String(error)}`,
339
302
  };
340
303
  }
341
-
342
- return {
343
- launched: true,
344
- usedTmux: true,
345
- mode,
346
- toolPath,
347
- paneId: paneId || undefined,
348
- };
349
304
  }
350
305
 
351
- if (mode === "exec" && options.allowExecFallback) {
352
- const input = options.promptPath
306
+ if (options.allowExecFallback) {
307
+ const commandArgs = buildCommandArgs(args, mode, options.promptPath);
308
+ const input = mode === "exec" && options.promptPath
353
309
  ? fs.readFileSync(options.promptPath, "utf-8")
354
310
  : undefined;
355
311
 
356
- const result = await execFileAsync(toolPath, args, {
312
+ const result = await execFileAsync(toolPath, commandArgs, {
357
313
  input,
358
314
  timeout: timeoutMs,
359
315
  env: { ...process.env, ...envVars },
@@ -363,19 +319,23 @@ export async function launchDriverInTmuxOrFallback(
363
319
  return {
364
320
  launched: true,
365
321
  usedTmux: false,
322
+ backend: "exec",
366
323
  mode,
367
324
  toolPath,
368
325
  exitCode: result.exitCode,
326
+ stdout: result.stdout,
369
327
  stderr: result.stderr,
370
328
  reason: result.exitCode === 0 ? undefined : `fallback exec exited ${result.exitCode}`,
371
329
  };
372
330
  }
373
331
 
332
+ const tmux = getTmuxAvailability({ requireSessionEnv: true });
374
333
  return {
375
334
  launched: false,
376
335
  usedTmux: false,
336
+ backend: "exec",
377
337
  mode,
378
338
  toolPath,
379
- reason: `${tmux.reason ?? "tmux unavailable"}; fallback disabled`,
339
+ reason: `${tmux.reason ?? "no available pane launcher"}; fallback disabled`,
380
340
  };
381
341
  }
@@ -0,0 +1,78 @@
1
+ import { execFileAsync } from "./subprocess-utils.js";
2
+
3
+ export type TmuxSplitFlag = "-h" | "-v";
4
+
5
+ export interface TmuxPaneInfo {
6
+ paneId: string;
7
+ width: number;
8
+ height: number;
9
+ active: boolean;
10
+ }
11
+
12
+ export interface PlacementResult {
13
+ targetPane: string;
14
+ splitFlag: TmuxSplitFlag;
15
+ }
16
+
17
+ const LIST_PANES_FORMAT = "#{pane_id} #{pane_width} #{pane_height} #{pane_active}";
18
+
19
+ export async function listPanes(tmuxPath: string): Promise<TmuxPaneInfo[]> {
20
+ const result = await execFileAsync(tmuxPath, ["list-panes", "-F", LIST_PANES_FORMAT], {
21
+ timeout: 3000,
22
+ });
23
+ if (result.exitCode !== 0) return [];
24
+
25
+ const panes: TmuxPaneInfo[] = [];
26
+ for (const rawLine of result.stdout.split(/\r?\n/)) {
27
+ const line = rawLine.trim();
28
+ if (!line) continue;
29
+
30
+ const parts = line.split(/\s+/);
31
+ if (parts.length < 4) continue;
32
+
33
+ const paneId = parts[0] ?? "";
34
+ const width = Number.parseInt(parts[1] ?? "", 10);
35
+ const height = Number.parseInt(parts[2] ?? "", 10);
36
+ const activeRaw = parts[3] ?? "";
37
+
38
+ if (!paneId || !Number.isFinite(width) || !Number.isFinite(height)) continue;
39
+ panes.push({
40
+ paneId,
41
+ width,
42
+ height,
43
+ active: activeRaw === "1",
44
+ });
45
+ }
46
+
47
+ return panes;
48
+ }
49
+
50
+ export function findBestSplit(panes: TmuxPaneInfo[]): PlacementResult | null {
51
+ if (panes.length === 0) return null;
52
+
53
+ let best = panes[0];
54
+ let bestArea = best.width * best.height;
55
+
56
+ for (let i = 1; i < panes.length; i++) {
57
+ const pane = panes[i];
58
+ if (!pane) continue;
59
+ const area = pane.width * pane.height;
60
+ if (area > bestArea) {
61
+ best = pane;
62
+ bestArea = area;
63
+ }
64
+ }
65
+
66
+ // Terminal cells are ~2x taller than wide (typical monospace font aspect ratio).
67
+ // Correct for this so BSP splits the visually longer axis, not just the higher
68
+ // character count. Without this, a 77x68 pane looks "wider" in chars but is
69
+ // actually much taller in pixels, and should split top/bottom (-v), not left/right.
70
+ const CELL_ASPECT_RATIO = 2.0;
71
+ const visualWidth = best.width;
72
+ const visualHeight = best.height * CELL_ASPECT_RATIO;
73
+
74
+ return {
75
+ targetPane: best.paneId,
76
+ splitFlag: visualWidth >= visualHeight ? "-h" : "-v",
77
+ };
78
+ }
@@ -255,9 +255,6 @@ export function buildExternalAgentContext(
255
255
  `- **Context folder:** ${displayPath(contextDir)}`,
256
256
  `- **Notes folder:** ${displayPath(notesDir)}`,
257
257
  ];
258
- if (ctx.summary) {
259
- lines.push(`- **Summary:** ${ctx.summary}`);
260
- }
261
258
  return lines.join("\n");
262
259
  }
263
260