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.
- package/dist/commands/launch.d.ts +1 -0
- package/dist/commands/launch.js +24 -8
- package/dist/templates/_shared/.claude/skills/codex/SKILL.md +19 -26
- package/dist/templates/_shared/.codex/workflows/codex.md +11 -0
- package/dist/templates/_shared/lib-ts/agent-exec/backends/tmux.ts +23 -49
- package/dist/templates/_shared/lib-ts/base/launchers/tmux-launcher.ts +173 -0
- package/dist/templates/_shared/lib-ts/base/launchers/window-launcher.ts +93 -0
- package/dist/templates/_shared/lib-ts/base/launchers/wt-launcher.ts +64 -0
- package/dist/templates/_shared/lib-ts/base/pane-launcher.ts +55 -0
- package/dist/templates/_shared/lib-ts/base/sentinel-ipc.ts +87 -0
- package/dist/templates/_shared/lib-ts/base/tmux-driver.ts +160 -200
- package/dist/templates/_shared/lib-ts/base/tmux-pane-placement.ts +78 -0
- package/dist/templates/_shared/lib-ts/context/context-formatter.ts +0 -3
- package/dist/templates/_shared/scripts/resolve-run.ts +1 -1
- package/dist/templates/_shared/skills/codex/CLAUDE.md +70 -0
- package/dist/templates/_shared/skills/codex/SKILL.md +71 -0
- package/dist/templates/_shared/skills/{prompt-codex/scripts/watch-codex.ts → codex/lib/codex-watcher.ts} +78 -63
- package/dist/templates/_shared/skills/{prompt-codex → codex}/scripts/launch-codex.ts +106 -61
- package/dist/templates/_shared/skills/codex/scripts/watch-codex.ts +42 -0
- package/oclif.manifest.json +2 -2
- package/package.json +1 -1
- package/dist/templates/_shared/.claude/skills/codex/prompt.md +0 -30
- package/dist/templates/_shared/skills/prompt-codex/CLAUDE.md +0 -71
|
@@ -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;
|
package/dist/commands/launch.js
CHANGED
|
@@ -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
|
|
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 (
|
|
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
|
|
@@ -1,42 +1,35 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: codex
|
|
3
|
-
description:
|
|
3
|
+
description: Delegate implementation to Codex sub-agents. USE WHEN codex OR send to codex OR codex implement OR hand off to codex OR launch codex OR codex plan OR run codex.
|
|
4
4
|
user-invocable: true
|
|
5
5
|
---
|
|
6
6
|
|
|
7
|
-
|
|
7
|
+
Read `.aiwcli/_shared/skills/codex/SKILL.md` for delegation patterns and examples.
|
|
8
8
|
|
|
9
|
-
|
|
9
|
+
## Role
|
|
10
10
|
|
|
11
|
-
|
|
11
|
+
You are the orchestrator. Codex instances are your implementation sub-agents. Decide what to delegate, how to split work, and review results when summaries arrive.
|
|
12
12
|
|
|
13
|
-
|
|
13
|
+
## Command
|
|
14
14
|
|
|
15
|
-
|
|
15
|
+
```
|
|
16
|
+
bun ~/.aiwcli/bin/resolve-run.ts .aiwcli/_shared/skills/codex/scripts/launch-codex.ts [flags] <mode>
|
|
17
|
+
```
|
|
16
18
|
|
|
17
|
-
|
|
18
|
-
- `--model <name>` — Model aliases: `spark`, `codex`, `gpt`. Tiers: `fast`, `standard`, `smart`. Or any full model ID.
|
|
19
|
-
- `--sandbox <mode>` — `read-only`, `workspace-write`, `danger-full-access`. Default: Codex default.
|
|
20
|
-
- `--context <id>` — Pass the active context ID so Codex receives project orientation (context folder, notes path). **Always pass this when an active context exists** (check the `Active Context:` system reminder for the ID).
|
|
19
|
+
The script blocks until Codex exits and prints a summary — run with Bash `run_in_background: true` so you stay unblocked.
|
|
21
20
|
|
|
22
|
-
|
|
21
|
+
**Modes:** `plan` | `--file <path>` | `<inline text...>`
|
|
23
22
|
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
| `fast` | `gpt-5.3-codex-spark` (tier) |
|
|
30
|
-
| `standard` | `gpt-5.3-codex` (tier) |
|
|
31
|
-
| `smart` | `gpt-5.3-codex` (tier) |
|
|
23
|
+
**Key flags:**
|
|
24
|
+
- `--context <id>` — Project orientation. Pass when implementing a plan.
|
|
25
|
+
- `--prompt <text>` — Scope the agent's work to a specific plan section or task.
|
|
26
|
+
- `--model <name>` — `spark`, `codex`, `gpt`, or tier: `fast`, `standard`, `smart`.
|
|
27
|
+
- `--no-watch` — Fire-and-forget (skip waiting for summary).
|
|
32
28
|
|
|
33
|
-
##
|
|
29
|
+
## Delegation Decision
|
|
34
30
|
|
|
35
|
-
-
|
|
36
|
-
- `/codex --model codex --context <context-id> Refactor auth to use JWT` — inline prompt with context
|
|
37
|
-
- `/codex --file ./spec.md` — inject file contents (no context)
|
|
31
|
+
**One-shot:** Plan is small or tightly coupled → launch one Codex with `plan` mode. Wait for the summary, then review.
|
|
38
32
|
|
|
39
|
-
|
|
33
|
+
**Parallel:** Plan has independent sections → launch multiple Codex instances, each scoped with `--prompt` to its section. All share the same `--context`. Review when summaries arrive, check for conflicts.
|
|
40
34
|
|
|
41
|
-
-
|
|
42
|
-
- `codex` CLI must be on PATH
|
|
35
|
+
**Ad-hoc:** No plan, just a task → pass inline text (e.g., `"Fix the failing test in auth.ts"`).
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
# Codex Workflow
|
|
2
|
+
|
|
3
|
+
Use Codex CLI handoff instructions from `.aiwcli/_shared/skills/codex/SKILL.md`.
|
|
4
|
+
|
|
5
|
+
## Command
|
|
6
|
+
|
|
7
|
+
`bun ~/.aiwcli/bin/resolve-run.ts .aiwcli/_shared/skills/codex/scripts/launch-codex.ts [flags] <mode>`
|
|
8
|
+
|
|
9
|
+
**Modes:** `plan` | `--file <path>` | `<inline text...>`
|
|
10
|
+
|
|
11
|
+
**Common flags:** `--model <name>`, `--sandbox <mode>`, `--context <id>`, `--prompt <text>`, `--no-yolo`, `--capture`
|
|
@@ -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
|
|
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
|
-
|
|
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
|
-
([,
|
|
56
|
+
([, value]) => value !== undefined,
|
|
62
57
|
) as Array<[string, string]>;
|
|
63
58
|
const envPrefix = envEntries
|
|
64
|
-
.map(([
|
|
59
|
+
.map(([key, value]) => `${key}=${quoteForSh(value)}`)
|
|
65
60
|
.join(" ");
|
|
66
61
|
|
|
67
|
-
|
|
68
|
-
const
|
|
69
|
-
const script =
|
|
70
|
-
|
|
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
|
-
|
|
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
|
-
|
|
120
|
-
const
|
|
121
|
-
const
|
|
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
|
-
|
|
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
|
+
}
|