aiwcli 0.13.7 → 0.14.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/README.md +11 -1
- package/dist/commands/launch.d.ts +8 -0
- package/dist/commands/launch.js +96 -5
- package/dist/templates/_shared/.claude/skills/codex/SKILL.md +42 -0
- package/dist/templates/_shared/.claude/skills/codex/prompt.md +10 -0
- package/dist/templates/_shared/lib-ts/agent-exec/backends/headless.ts +33 -0
- package/dist/templates/_shared/lib-ts/agent-exec/backends/index.ts +6 -0
- package/dist/templates/_shared/lib-ts/agent-exec/backends/tmux.ts +145 -0
- package/dist/templates/_shared/lib-ts/agent-exec/base-agent.ts +229 -0
- package/dist/templates/_shared/lib-ts/agent-exec/execution-backend.ts +50 -0
- package/dist/templates/_shared/lib-ts/agent-exec/index.ts +4 -0
- package/dist/templates/_shared/lib-ts/base/cli-args.ts +283 -0
- package/dist/templates/_shared/lib-ts/base/inference.ts +53 -47
- package/dist/templates/_shared/lib-ts/base/models.ts +16 -0
- package/dist/templates/_shared/lib-ts/base/preflight.ts +98 -0
- package/dist/templates/_shared/lib-ts/base/tmux-driver.ts +381 -0
- package/dist/templates/_shared/lib-ts/base/utils.ts +8 -0
- package/dist/templates/_shared/lib-ts/context/context-formatter.ts +35 -11
- package/dist/templates/_shared/lib-ts/types.ts +17 -0
- package/dist/templates/_shared/scripts/status_line.ts +57 -28
- package/dist/templates/_shared/skills/prompt-codex/CLAUDE.md +46 -0
- package/dist/templates/_shared/skills/prompt-codex/scripts/launch-codex.ts +254 -0
- package/dist/templates/cc-native/.claude/settings.json +121 -1
- package/dist/templates/cc-native/_cc-native/CLAUDE.md +73 -0
- package/dist/templates/cc-native/_cc-native/cc-native.config.json +2 -1
- package/dist/templates/cc-native/_cc-native/lib-ts/CLAUDE.md +70 -0
- package/dist/templates/cc-native/_cc-native/lib-ts/settings.ts +3 -1
- package/dist/templates/cc-native/_cc-native/lib-ts/types.ts +5 -10
- package/dist/templates/cc-native/_cc-native/plan-review/lib/agent-selection.ts +2 -2
- package/dist/templates/cc-native/_cc-native/plan-review/lib/preflight.ts +14 -80
- package/dist/templates/cc-native/_cc-native/plan-review/lib/reviewers/agent.ts +19 -7
- package/dist/templates/cc-native/_cc-native/plan-review/lib/reviewers/base/base-agent.ts +4 -215
- package/dist/templates/cc-native/_cc-native/plan-review/lib/reviewers/index.ts +1 -1
- package/dist/templates/cc-native/_cc-native/plan-review/lib/reviewers/providers/claude-agent.ts +9 -39
- package/dist/templates/cc-native/_cc-native/plan-review/lib/reviewers/providers/codex-agent.ts +19 -21
- package/dist/templates/cc-native/_cc-native/plan-review/lib/reviewers/providers/gemini-agent.ts +2 -1
- package/dist/templates/cc-native/_cc-native/plan-review/lib/reviewers/providers/orchestrator-claude-agent.ts +13 -15
- package/oclif.manifest.json +21 -3
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -38,12 +38,22 @@ After installation, run `aiw init --method cc-native` to set up the template in
|
|
|
38
38
|
AIW CLI provides the following commands:
|
|
39
39
|
|
|
40
40
|
### `aiw launch`
|
|
41
|
-
Launch Claude Code with AIW configuration (sandbox disabled,
|
|
41
|
+
Launch Claude Code with AIW configuration (sandbox disabled, tmux-first by default when outside tmux).
|
|
42
42
|
|
|
43
43
|
```bash
|
|
44
44
|
aiw launch
|
|
45
45
|
aiw launch --debug # Enable verbose logging
|
|
46
46
|
aiw launch --quiet # Suppress informational output
|
|
47
|
+
aiw launch --no-tmux # Bypass tmux auto-launch and run directly
|
|
48
|
+
aiw launch --tmux-session aiw-main # Reuse/attach a specific tmux session name
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
`aiw launch` now creates a fresh tmux session by default when auto-launching tmux.
|
|
52
|
+
|
|
53
|
+
If you prefer typing `codex`, route it through AIW launch behavior with an alias:
|
|
54
|
+
|
|
55
|
+
```bash
|
|
56
|
+
alias codex='aiw launch --codex'
|
|
47
57
|
```
|
|
48
58
|
|
|
49
59
|
### `aiw init`
|
|
@@ -13,9 +13,17 @@ export default class LaunchCommand extends BaseCommand {
|
|
|
13
13
|
static flags: {
|
|
14
14
|
codex: import("@oclif/core/interfaces").BooleanFlag<boolean>;
|
|
15
15
|
new: import("@oclif/core/interfaces").BooleanFlag<boolean>;
|
|
16
|
+
'no-tmux': import("@oclif/core/interfaces").BooleanFlag<boolean>;
|
|
17
|
+
'tmux-session': import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
|
|
16
18
|
debug: import("@oclif/core/interfaces").BooleanFlag<boolean>;
|
|
17
19
|
help: import("@oclif/core/interfaces").BooleanFlag<void>;
|
|
18
20
|
quiet: import("@oclif/core/interfaces").BooleanFlag<boolean>;
|
|
19
21
|
};
|
|
20
22
|
run(): Promise<void>;
|
|
23
|
+
private buildTmuxShellCommand;
|
|
24
|
+
private isTmuxAvailable;
|
|
25
|
+
private sanitizeTmuxSessionName;
|
|
26
|
+
private buildUniqueTmuxSessionName;
|
|
27
|
+
private enableTmuxMouseIfPossible;
|
|
28
|
+
private shellQuote;
|
|
21
29
|
}
|
package/dist/commands/launch.js
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { execSync } from 'node:child_process';
|
|
2
|
+
import { basename } from 'node:path';
|
|
1
3
|
import { Flags } from '@oclif/core';
|
|
2
4
|
import BaseCommand from '../lib/base-command.js';
|
|
3
5
|
import { ProcessSpawnError } from '../lib/errors.js';
|
|
@@ -14,22 +16,26 @@ import { EXIT_CODES } from '../types/index.js';
|
|
|
14
16
|
* Supports multiple parallel sessions.
|
|
15
17
|
*/
|
|
16
18
|
export default class LaunchCommand extends BaseCommand {
|
|
17
|
-
static description = 'Launch Claude Code or Codex with AIW configuration (sandbox disabled,
|
|
19
|
+
static description = 'Launch Claude Code or Codex with AIW configuration (sandbox disabled, tmux-first by default)\n\n' +
|
|
18
20
|
'FLAGS\n' +
|
|
19
21
|
' --codex/-c: Launch Codex instead of Claude Code (uses --yolo flag)\n' +
|
|
20
|
-
' --new/-n: Open a new terminal in the current directory and launch there\n
|
|
22
|
+
' --new/-n: Open a new terminal in the current directory and launch there\n' +
|
|
23
|
+
' --no-tmux/-t: Launch directly in current shell instead of auto-launching tmux\n' +
|
|
24
|
+
' --tmux-session/-s: tmux session name to reuse when auto-launching tmux (default is fresh session per launch)\n\n' +
|
|
21
25
|
'EXIT CODES\n' +
|
|
22
26
|
' 0 Success - AI assistant launched and exited successfully\n' +
|
|
23
27
|
' 1 General error - unexpected runtime failure\n' +
|
|
24
28
|
' 2 Invalid usage - check your arguments and flags\n' +
|
|
25
|
-
' 3 Environment error - CLI not found (install Claude Code from https://claude.ai/download
|
|
29
|
+
' 3 Environment error - CLI/tmux not found (install Claude Code from https://claude.ai/download, Codex from npm, tmux from your package manager)';
|
|
26
30
|
static examples = [
|
|
27
|
-
'<%= config.bin %> <%= command.id %>',
|
|
31
|
+
'<%= config.bin %> <%= command.id %> # Auto-launches tmux with a fresh session when not already in tmux',
|
|
28
32
|
'<%= config.bin %> <%= command.id %> --codex # Launch Codex with --yolo flag',
|
|
29
33
|
'<%= config.bin %> <%= command.id %> -c # Short form for --codex',
|
|
30
34
|
'<%= config.bin %> <%= command.id %> --new # Launch in a new terminal window',
|
|
31
35
|
'<%= config.bin %> <%= command.id %> -n # Short form for --new',
|
|
32
36
|
'<%= config.bin %> <%= command.id %> --codex --new # Launch Codex in new terminal',
|
|
37
|
+
'<%= config.bin %> <%= command.id %> --no-tmux # Run directly in current shell',
|
|
38
|
+
'<%= config.bin %> <%= command.id %> --tmux-session aiw-main # Reuse/attach explicit tmux session name',
|
|
33
39
|
'<%= config.bin %> <%= command.id %> --debug # Enable verbose logging',
|
|
34
40
|
'# Check exit code in Bash\n<%= config.bin %> <%= command.id %>\necho $?',
|
|
35
41
|
'# Check exit code in PowerShell\n<%= config.bin %> <%= command.id %>\necho $LASTEXITCODE',
|
|
@@ -46,6 +52,16 @@ export default class LaunchCommand extends BaseCommand {
|
|
|
46
52
|
description: 'Open a new terminal in the current directory and run aiw launch there',
|
|
47
53
|
default: false,
|
|
48
54
|
}),
|
|
55
|
+
'no-tmux': Flags.boolean({
|
|
56
|
+
char: 't',
|
|
57
|
+
description: 'Launch directly in current shell instead of auto-launching tmux',
|
|
58
|
+
default: false,
|
|
59
|
+
}),
|
|
60
|
+
'tmux-session': Flags.string({
|
|
61
|
+
char: 's',
|
|
62
|
+
description: 'tmux session name to reuse when auto-launching tmux (default: new aiw-<current-dir>-<unique> session)',
|
|
63
|
+
required: false,
|
|
64
|
+
}),
|
|
49
65
|
};
|
|
50
66
|
async run() {
|
|
51
67
|
const { flags } = await this.parse(LaunchCommand);
|
|
@@ -54,6 +70,10 @@ export default class LaunchCommand extends BaseCommand {
|
|
|
54
70
|
const cliCommand = useCodex ? 'codex' : 'claude';
|
|
55
71
|
const cliArgs = useCodex ? ['--yolo'] : ['--dangerously-skip-permissions'];
|
|
56
72
|
const launchFlag = useCodex ? '--codex' : '';
|
|
73
|
+
const disableTmux = flags['no-tmux'];
|
|
74
|
+
const insideTmux = Boolean(process.env.TMUX);
|
|
75
|
+
const interactiveTty = Boolean(process.stdin.isTTY && process.stdout.isTTY);
|
|
76
|
+
const shouldAutoTmux = !flags.new && !disableTmux && !insideTmux && interactiveTty;
|
|
57
77
|
// Handle --new flag: launch in a new terminal
|
|
58
78
|
if (flags.new) {
|
|
59
79
|
const cwd = process.cwd();
|
|
@@ -92,7 +112,36 @@ export default class LaunchCommand extends BaseCommand {
|
|
|
92
112
|
// Spawn AI CLI with sandbox permissions disabled
|
|
93
113
|
// AIW hook system provides safety guardrails
|
|
94
114
|
// Continue launch regardless of version check result (graceful degradation)
|
|
95
|
-
|
|
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
|
+
}
|
|
120
|
+
const shellCommand = this.buildTmuxShellCommand(cliCommand, cliArgs);
|
|
121
|
+
const sessionFromFlag = flags['tmux-session']?.trim();
|
|
122
|
+
if (sessionFromFlag && sessionFromFlag.length > 0) {
|
|
123
|
+
const sessionName = this.sanitizeTmuxSessionName(sessionFromFlag);
|
|
124
|
+
this.logInfo(`Launching in tmux session: ${sessionName} (reuse/attach)`);
|
|
125
|
+
exitCode = await spawnProcess('tmux', ['new-session', '-A', '-s', sessionName, shellCommand]);
|
|
126
|
+
}
|
|
127
|
+
else {
|
|
128
|
+
const sessionBase = `aiw-${basename(process.cwd())}`;
|
|
129
|
+
const sessionName = this.buildUniqueTmuxSessionName(sessionBase);
|
|
130
|
+
this.logInfo(`Launching in new tmux session: ${sessionName}`);
|
|
131
|
+
exitCode = await spawnProcess('tmux', ['new-session', '-s', sessionName, shellCommand]);
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
else {
|
|
135
|
+
if (disableTmux)
|
|
136
|
+
this.debug('tmux launch disabled via --no-tmux');
|
|
137
|
+
else if (insideTmux) {
|
|
138
|
+
this.debug('Already inside tmux; launching directly in current pane');
|
|
139
|
+
this.enableTmuxMouseIfPossible();
|
|
140
|
+
}
|
|
141
|
+
else if (!interactiveTty)
|
|
142
|
+
this.debug('Non-interactive terminal detected; launching directly');
|
|
143
|
+
exitCode = await spawnProcess(cliCommand, cliArgs);
|
|
144
|
+
}
|
|
96
145
|
}
|
|
97
146
|
catch (error) {
|
|
98
147
|
if (error instanceof ProcessSpawnError) {
|
|
@@ -105,4 +154,46 @@ export default class LaunchCommand extends BaseCommand {
|
|
|
105
154
|
// Pass through Claude Code's exit code (outside try-catch to avoid catching exit)
|
|
106
155
|
this.exit(exitCode);
|
|
107
156
|
}
|
|
157
|
+
buildTmuxShellCommand(command, args) {
|
|
158
|
+
const launchCommand = [command, ...args].map((part) => this.shellQuote(part)).join(' ');
|
|
159
|
+
return `tmux set-option -g mouse on >/dev/null 2>&1 || true; exec ${launchCommand}`;
|
|
160
|
+
}
|
|
161
|
+
isTmuxAvailable() {
|
|
162
|
+
try {
|
|
163
|
+
const cmd = process.platform === 'win32' ? 'where tmux' : 'which tmux';
|
|
164
|
+
execSync(cmd, { stdio: 'ignore' });
|
|
165
|
+
return true;
|
|
166
|
+
}
|
|
167
|
+
catch {
|
|
168
|
+
return false;
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
sanitizeTmuxSessionName(input) {
|
|
172
|
+
const trimmed = input.trim().toLowerCase();
|
|
173
|
+
const safe = trimmed
|
|
174
|
+
.replaceAll(/[^a-z0-9_-]/g, '-')
|
|
175
|
+
.replaceAll(/-+/g, '-')
|
|
176
|
+
.replaceAll(/^[-_]+|[-_]+$/g, '');
|
|
177
|
+
return safe || 'aiw';
|
|
178
|
+
}
|
|
179
|
+
buildUniqueTmuxSessionName(base) {
|
|
180
|
+
const safeBase = this.sanitizeTmuxSessionName(base);
|
|
181
|
+
const timestamp = Date.now().toString(36);
|
|
182
|
+
const pid = process.pid.toString(36);
|
|
183
|
+
return this.sanitizeTmuxSessionName(`${safeBase}-${timestamp}-${pid}`);
|
|
184
|
+
}
|
|
185
|
+
enableTmuxMouseIfPossible() {
|
|
186
|
+
if (!this.isTmuxAvailable())
|
|
187
|
+
return;
|
|
188
|
+
try {
|
|
189
|
+
execSync('tmux set-option -g mouse on', { stdio: 'ignore' });
|
|
190
|
+
this.debug('Enabled tmux mouse support (set-option -g mouse on)');
|
|
191
|
+
}
|
|
192
|
+
catch {
|
|
193
|
+
this.debug('Could not enable tmux mouse support automatically');
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
shellQuote(input) {
|
|
197
|
+
return `'${input.replaceAll("'", `'"'"'`)}'`;
|
|
198
|
+
}
|
|
108
199
|
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: codex
|
|
3
|
+
description: Launch Codex CLI in a tmux pane. 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
|
+
user-invocable: true
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
# Codex CLI
|
|
8
|
+
|
|
9
|
+
Launch Codex in a tmux split pane and optionally inject a prompt.
|
|
10
|
+
|
|
11
|
+
## Command
|
|
12
|
+
|
|
13
|
+
`bun .aiwcli/_shared/skills/prompt-codex/scripts/launch-codex.ts [flags] <mode>`
|
|
14
|
+
|
|
15
|
+
**Modes:** `plan` (inject active plan) | `--file <path>` (inject file) | `<text...>` (inject inline text)
|
|
16
|
+
|
|
17
|
+
**Flags:**
|
|
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).
|
|
21
|
+
|
|
22
|
+
## Model Reference
|
|
23
|
+
|
|
24
|
+
| Name | Resolves To |
|
|
25
|
+
|------|-------------|
|
|
26
|
+
| `spark` | `gpt-5.3-codex-spark` (fastest) |
|
|
27
|
+
| `codex` | `gpt-5.3-codex` (default) |
|
|
28
|
+
| `gpt` | `gpt-5.2` (non-Codex GPT) |
|
|
29
|
+
| `fast` | `gpt-5.3-codex-spark` (tier) |
|
|
30
|
+
| `standard` | `gpt-5.3-codex` (tier) |
|
|
31
|
+
| `smart` | `gpt-5.3-codex` (tier) |
|
|
32
|
+
|
|
33
|
+
## Examples
|
|
34
|
+
|
|
35
|
+
- `/codex --model spark --context <context-id> plan` — hand off active plan to Spark with context
|
|
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)
|
|
38
|
+
|
|
39
|
+
## Requirements
|
|
40
|
+
|
|
41
|
+
- Must be running inside tmux
|
|
42
|
+
- `codex` CLI must be on PATH
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
# /codex
|
|
2
|
+
|
|
3
|
+
| Command | Description |
|
|
4
|
+
|---|---|
|
|
5
|
+
| `bun launch-codex.ts [--model fast|standard|smart|<model-id>] [--sandbox read-only|disabled] plan` | Launch Codex REPL with active plan. |
|
|
6
|
+
| `bun launch-codex.ts [--model fast|standard|smart|<model-id>] [--sandbox read-only|disabled] --file <path>` | Launch Codex REPL with file contents. |
|
|
7
|
+
| `bun launch-codex.ts [--model fast|standard|smart|<model-id>] [--sandbox read-only|disabled] <inline text...>` | Launch Codex REPL with inline prompt. |
|
|
8
|
+
|
|
9
|
+
- `--model`: model tier (`fast`/`standard`/`smart`) resolves to `gpt-5.3-codex-spark` / `gpt-5.3-codex-think`, or any explicit Codex model id.
|
|
10
|
+
- `--sandbox`: `read-only` or `disabled` (default). `read-only` restricts file writes; `disabled` allows writes.
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Headless execution backend — wraps execFileAsync() for subprocess execution.
|
|
3
|
+
* Default backend for all CLI agents. If outputFilePath is specified and exists
|
|
4
|
+
* after execution, reads output from file instead of stdout (Codex pattern).
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import * as fs from "node:fs";
|
|
8
|
+
|
|
9
|
+
import { execFileAsync } from "../../base/subprocess-utils.js";
|
|
10
|
+
import type { ExecutionBackend, ExecutionRequest, ExecutionResult } from "../execution-backend.js";
|
|
11
|
+
|
|
12
|
+
export class HeadlessBackend implements ExecutionBackend {
|
|
13
|
+
async execute(request: ExecutionRequest): Promise<ExecutionResult> {
|
|
14
|
+
const result = await execFileAsync(request.cliPath, request.args, {
|
|
15
|
+
input: request.input,
|
|
16
|
+
timeout: request.timeoutMs,
|
|
17
|
+
env: request.env,
|
|
18
|
+
maxBuffer: request.maxBuffer ?? 10 * 1024 * 1024,
|
|
19
|
+
shell: request.shell ?? (process.platform === "win32"),
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
// If outputFilePath specified and exists, read from file instead of stdout
|
|
23
|
+
if (request.outputFilePath && fs.existsSync(request.outputFilePath)) {
|
|
24
|
+
const fileContent = fs.readFileSync(request.outputFilePath, "utf-8");
|
|
25
|
+
return {
|
|
26
|
+
...result,
|
|
27
|
+
stdout: fileContent,
|
|
28
|
+
};
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
return result;
|
|
32
|
+
}
|
|
33
|
+
}
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tmux execution backend — runs CLI agents in visible tmux panes
|
|
3
|
+
* with sentinel-file-based output capture.
|
|
4
|
+
*
|
|
5
|
+
* Delegates pane management to tmux-driver.ts primitives. Does NOT use
|
|
6
|
+
* launchDriverInTmuxOrFallback() because fallback is the caller's concern,
|
|
7
|
+
* not the backend's.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import * as fs from "node:fs";
|
|
11
|
+
import * as os from "node:os";
|
|
12
|
+
import * as path from "node:path";
|
|
13
|
+
|
|
14
|
+
import { execFileAsync } from "../../base/subprocess-utils.js";
|
|
15
|
+
import { getTmuxAvailability, quoteForSh, normalizeSplitFlag } from "../../base/tmux-driver.js";
|
|
16
|
+
import type { ExecutionBackend, ExecutionRequest, ExecutionResult } from "../execution-backend.js";
|
|
17
|
+
|
|
18
|
+
function sleep(ms: number): Promise<void> {
|
|
19
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export interface TmuxBackendOptions {
|
|
23
|
+
splitFlag?: string;
|
|
24
|
+
splitTarget?: string;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export class TmuxBackend implements ExecutionBackend {
|
|
28
|
+
private options: TmuxBackendOptions;
|
|
29
|
+
|
|
30
|
+
constructor(options?: TmuxBackendOptions) {
|
|
31
|
+
this.options = options ?? {};
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
async execute(request: ExecutionRequest): Promise<ExecutionResult> {
|
|
35
|
+
const tmux = getTmuxAvailability();
|
|
36
|
+
if (!tmux.available || !tmux.tmuxPath) {
|
|
37
|
+
return {
|
|
38
|
+
stdout: "",
|
|
39
|
+
stderr: `tmux pane launch failed: ${tmux.reason ?? "tmux unavailable"}`,
|
|
40
|
+
exitCode: 1,
|
|
41
|
+
killed: false,
|
|
42
|
+
signal: null,
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// Create temp directory for IPC files
|
|
47
|
+
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");
|
|
54
|
+
|
|
55
|
+
try {
|
|
56
|
+
// Write prompt to file for stdin redirection
|
|
57
|
+
fs.writeFileSync(promptPath, request.input, "utf-8");
|
|
58
|
+
|
|
59
|
+
// Build env prefix
|
|
60
|
+
const envEntries = Object.entries(request.env).filter(
|
|
61
|
+
([, v]) => v !== undefined,
|
|
62
|
+
) as Array<[string, string]>;
|
|
63
|
+
const envPrefix = envEntries
|
|
64
|
+
.map(([k, v]) => `${k}=${quoteForSh(v)}`)
|
|
65
|
+
.join(" ");
|
|
66
|
+
|
|
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
|
|
78
|
+
const splitFlag = normalizeSplitFlag(this.options.splitFlag);
|
|
79
|
+
const tmuxArgs = ["split-window", splitFlag, "-P", "-F", "#{pane_id}"];
|
|
80
|
+
if (this.options.splitTarget) {
|
|
81
|
+
tmuxArgs.push("-t", this.options.splitTarget);
|
|
82
|
+
}
|
|
83
|
+
tmuxArgs.push(`bash -lc ${quoteForSh(script)}`);
|
|
84
|
+
|
|
85
|
+
const split = await execFileAsync(tmux.tmuxPath, tmuxArgs, { timeout: 5000 });
|
|
86
|
+
if (split.exitCode !== 0) {
|
|
87
|
+
return {
|
|
88
|
+
stdout: "",
|
|
89
|
+
stderr: `tmux pane launch failed: ${split.stderr.trim()}`,
|
|
90
|
+
exitCode: 1,
|
|
91
|
+
killed: false,
|
|
92
|
+
signal: null,
|
|
93
|
+
};
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const paneId = split.stdout.trim().split(/\r?\n/).pop() ?? "";
|
|
97
|
+
|
|
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
|
|
107
|
+
if (paneId) {
|
|
108
|
+
await execFileAsync(tmux.tmuxPath, ["kill-pane", "-t", paneId], { timeout: 3000 });
|
|
109
|
+
}
|
|
110
|
+
return {
|
|
111
|
+
stdout: "",
|
|
112
|
+
stderr: "",
|
|
113
|
+
exitCode: -1,
|
|
114
|
+
killed: true,
|
|
115
|
+
signal: "SIGTERM",
|
|
116
|
+
};
|
|
117
|
+
}
|
|
118
|
+
|
|
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") : "";
|
|
123
|
+
|
|
124
|
+
// If outputFilePath specified and exists, read from it instead
|
|
125
|
+
if (request.outputFilePath && fs.existsSync(request.outputFilePath)) {
|
|
126
|
+
return {
|
|
127
|
+
stdout: fs.readFileSync(request.outputFilePath, "utf-8"),
|
|
128
|
+
stderr,
|
|
129
|
+
exitCode,
|
|
130
|
+
killed: false,
|
|
131
|
+
signal: null,
|
|
132
|
+
};
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
return { stdout, stderr, exitCode, killed: false, signal: null };
|
|
136
|
+
} finally {
|
|
137
|
+
// Clean up temp dir
|
|
138
|
+
try {
|
|
139
|
+
fs.rmSync(tmpDir, { recursive: true, force: true });
|
|
140
|
+
} catch {
|
|
141
|
+
// Best-effort cleanup
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
}
|
|
@@ -0,0 +1,229 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Abstract base class for CLI-based agent subprocess invocations.
|
|
3
|
+
* Lives in _shared so all templates can use it. Provider-specific
|
|
4
|
+
* implementations (Claude, Codex, Gemini) extend this class.
|
|
5
|
+
*
|
|
6
|
+
* Execution strategy is injected via ExecutionBackend (default: HeadlessBackend).
|
|
7
|
+
* Debug logging is injectable via AgentDebugLogger (default: no-op).
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { logDebug, logInfo, logWarn, logError } from "../base/logger.js";
|
|
11
|
+
import { getInternalSubprocessEnv, findExecutable, normalizePathForCli } from "../base/subprocess-utils.js";
|
|
12
|
+
import { HeadlessBackend } from "./backends/headless.js";
|
|
13
|
+
import type { ExecutionBackend, ExecutionResult, AgentDebugLogger } from "./execution-backend.js";
|
|
14
|
+
import type { AgentConfig } from "../types.js";
|
|
15
|
+
|
|
16
|
+
// Re-export for consumers
|
|
17
|
+
export type { ExecutionResult, AgentDebugLogger };
|
|
18
|
+
|
|
19
|
+
/** Configuration object for BaseCliAgent construction. */
|
|
20
|
+
export interface AgentExecutionConfig {
|
|
21
|
+
agent: AgentConfig;
|
|
22
|
+
schema: Record<string, unknown>;
|
|
23
|
+
timeout: number;
|
|
24
|
+
contextPath?: string;
|
|
25
|
+
sessionName?: string;
|
|
26
|
+
debugLogger?: AgentDebugLogger;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Abstract base class for all CLI agent subprocess invocations.
|
|
31
|
+
* Parameterized over return type T — ReviewerResult for reviewers,
|
|
32
|
+
* OrchestratorResult for the orchestrator.
|
|
33
|
+
* Subclasses implement provider-specific details.
|
|
34
|
+
*/
|
|
35
|
+
export abstract class BaseCliAgent<T> {
|
|
36
|
+
protected agent: AgentConfig;
|
|
37
|
+
protected backend: ExecutionBackend;
|
|
38
|
+
protected contextPath?: string;
|
|
39
|
+
protected debugLogger?: AgentDebugLogger;
|
|
40
|
+
protected schema: Record<string, unknown>;
|
|
41
|
+
protected sessionName: string;
|
|
42
|
+
protected timeout: number;
|
|
43
|
+
|
|
44
|
+
constructor(
|
|
45
|
+
config: AgentExecutionConfig,
|
|
46
|
+
backend?: ExecutionBackend,
|
|
47
|
+
) {
|
|
48
|
+
this.agent = config.agent;
|
|
49
|
+
this.schema = config.schema;
|
|
50
|
+
this.timeout = config.timeout;
|
|
51
|
+
this.contextPath = config.contextPath;
|
|
52
|
+
this.sessionName = config.sessionName ?? "unknown";
|
|
53
|
+
this.debugLogger = config.debugLogger;
|
|
54
|
+
this.backend = backend ?? new HeadlessBackend();
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** Build the command-line arguments for the CLI */
|
|
58
|
+
protected abstract buildCliArgs(): string[];
|
|
59
|
+
|
|
60
|
+
// ─── Abstract Methods (Subclass Implements) ────────────────────────────
|
|
61
|
+
|
|
62
|
+
/** Build the stdin prompt for the CLI */
|
|
63
|
+
protected abstract buildPrompt(plan: string): string;
|
|
64
|
+
|
|
65
|
+
/** Optional cleanup after subprocess execution */
|
|
66
|
+
protected async cleanup(): Promise<void> {
|
|
67
|
+
// Default: no-op. Subclasses override if needed (e.g., Codex temp files).
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** Coerce parsed JSON into the result type T */
|
|
71
|
+
protected abstract coerceResult(obj: Record<string, unknown> | null, raw: string, err: string): T;
|
|
72
|
+
|
|
73
|
+
/** Extract stdout/stderr from subprocess result. Override for file-based output (Codex). */
|
|
74
|
+
protected extractOutput(result: ExecutionResult): { raw: string; err: string } {
|
|
75
|
+
return {
|
|
76
|
+
raw: result.stdout.trim(),
|
|
77
|
+
err: result.stderr.trim(),
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** Find the CLI executable. Override for custom search logic. */
|
|
82
|
+
protected findCli(): string | null {
|
|
83
|
+
return findExecutable(this.getCliName());
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// ─── Template Methods (Subclass Can Override) ──────────────────────────
|
|
87
|
+
|
|
88
|
+
/** Get the CLI executable name (e.g., "claude", "codex") */
|
|
89
|
+
protected abstract getCliName(): string;
|
|
90
|
+
|
|
91
|
+
/** Get default error message for coerceToReview */
|
|
92
|
+
protected getDefaultErrorMessage(): string {
|
|
93
|
+
return `Retry or check ${this.getCliName()} configuration.`;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** Handle non-zero exit with no output */
|
|
97
|
+
protected handleExitError(result: ExecutionResult): T {
|
|
98
|
+
const msg = `${this.agent.name} failed to run (exit ${result.exitCode})`;
|
|
99
|
+
logError(this.agent.name, `Process exited with code ${result.exitCode} and no output`);
|
|
100
|
+
return this.makeErrorResult("error", msg);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** Handle timeout scenario */
|
|
104
|
+
protected handleTimeout(): T {
|
|
105
|
+
const msg = `${this.getCliName()} TIMEOUT after ${this.timeout}s`;
|
|
106
|
+
logWarn(this.agent.name, msg);
|
|
107
|
+
return this.makeErrorResult("error", `${this.agent.name} timed out after ${this.timeout}s`);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// ─── Shared Infrastructure ──────────────────────────────────────────────
|
|
111
|
+
|
|
112
|
+
/** Log parsed JSON result */
|
|
113
|
+
protected logParsedResult(obj: Record<string, unknown> | null): void {
|
|
114
|
+
if (this.contextPath && obj) {
|
|
115
|
+
this.debugLogger?.log(this.contextPath, this.sessionName, `agent:${this.agent.name}`, "parsed_result", {
|
|
116
|
+
parsed_keys: Object.keys(obj),
|
|
117
|
+
verdict: obj.verdict ?? null,
|
|
118
|
+
has_summary: Boolean(obj.summary),
|
|
119
|
+
issues_count: Array.isArray(obj.issues) ? (obj.issues as unknown[]).length : 0,
|
|
120
|
+
});
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
if (obj) {
|
|
124
|
+
logInfo(this.agent.name, `Parsed JSON successfully, verdict: ${obj.verdict ?? "N/A"}`);
|
|
125
|
+
} else {
|
|
126
|
+
logWarn(this.agent.name, "Failed to parse JSON from output");
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/** Log subprocess execution results */
|
|
131
|
+
protected logSubprocessResult(result: ExecutionResult, raw: string, err: string): void {
|
|
132
|
+
logDebug(this.agent.name, `Exit code: ${result.exitCode}`);
|
|
133
|
+
logDebug(this.agent.name, `stdout length: ${raw.length} chars`);
|
|
134
|
+
if (err) logDebug(this.agent.name, `stderr: ${err.slice(0, 500)}`);
|
|
135
|
+
|
|
136
|
+
// Debug logging
|
|
137
|
+
if (this.contextPath) {
|
|
138
|
+
this.debugLogger?.raw(this.contextPath, this.sessionName, `agent:${this.agent.name}`, "stdout", raw);
|
|
139
|
+
if (err) {
|
|
140
|
+
this.debugLogger?.raw(this.contextPath, this.sessionName, `agent:${this.agent.name}`, "stderr", err);
|
|
141
|
+
}
|
|
142
|
+
this.debugLogger?.log(this.contextPath, this.sessionName, `agent:${this.agent.name}`, "subprocess_info", {
|
|
143
|
+
exit_code: result.exitCode,
|
|
144
|
+
stdout_len: raw.length,
|
|
145
|
+
stderr_len: err.length,
|
|
146
|
+
model: this.agent.model,
|
|
147
|
+
provider: this.agent.provider,
|
|
148
|
+
timeout: this.timeout,
|
|
149
|
+
});
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
if (raw) logDebug(this.agent.name, `stdout preview: ${raw.slice(0, 500)}`);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/** Construct a T for error/skip/timeout scenarios. Subclasses define shape. */
|
|
156
|
+
protected abstract makeErrorResult(type: "skip" | "error", message: string): T;
|
|
157
|
+
|
|
158
|
+
/** Create skip result when CLI not found */
|
|
159
|
+
protected makeSkipResult(reason: string): T {
|
|
160
|
+
logWarn(this.agent.name, reason);
|
|
161
|
+
return this.makeErrorResult("skip", reason);
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/** Parse JSON from CLI output */
|
|
165
|
+
protected abstract parseOutput(raw: string, result: ExecutionResult): Record<string, unknown> | null;
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* Template method - orchestrates the review flow.
|
|
169
|
+
* Subclasses override abstract methods to customize behavior.
|
|
170
|
+
*/
|
|
171
|
+
async review(plan: string): Promise<T> {
|
|
172
|
+
// 1. Find CLI executable
|
|
173
|
+
const cliPath = this.findCli();
|
|
174
|
+
if (!cliPath) {
|
|
175
|
+
return this.makeSkipResult(`${this.getCliName()} CLI not found on PATH`);
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
logDebug(this.agent.name, `Found ${this.getCliName()} CLI at: ${cliPath}`);
|
|
179
|
+
|
|
180
|
+
// 2. Build prompt and args (provider-specific)
|
|
181
|
+
const prompt = this.buildPrompt(plan);
|
|
182
|
+
const args = this.buildCliArgs();
|
|
183
|
+
|
|
184
|
+
logInfo(this.agent.name, `Running ${this.getCliName()} with model: ${this.agent.model}, timeout: ${this.timeout}s`);
|
|
185
|
+
|
|
186
|
+
// 3. Execute via backend
|
|
187
|
+
const env = getInternalSubprocessEnv();
|
|
188
|
+
const normalizedCliPath = normalizePathForCli(cliPath);
|
|
189
|
+
const result = await this.backend.execute({
|
|
190
|
+
cliPath: normalizedCliPath,
|
|
191
|
+
args,
|
|
192
|
+
input: prompt,
|
|
193
|
+
env: env as Record<string, string>,
|
|
194
|
+
timeoutMs: this.timeout * 1000,
|
|
195
|
+
maxBuffer: 10 * 1024 * 1024,
|
|
196
|
+
shell: process.platform === "win32",
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
// 4. Handle timeout
|
|
200
|
+
if (result.killed || result.signal === "SIGTERM") {
|
|
201
|
+
return this.handleTimeout();
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
// 5. Extract output (provider-specific)
|
|
205
|
+
const { raw, err } = this.extractOutput(result);
|
|
206
|
+
|
|
207
|
+
// 6. Handle exit errors
|
|
208
|
+
if (!raw && !err && result.exitCode !== 0) {
|
|
209
|
+
return this.handleExitError(result);
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
// 7. Log subprocess results
|
|
213
|
+
this.logSubprocessResult(result, raw, err);
|
|
214
|
+
|
|
215
|
+
// 8. Parse JSON output (provider-specific)
|
|
216
|
+
const obj = this.parseOutput(raw, result);
|
|
217
|
+
|
|
218
|
+
// 9. Log parsed result
|
|
219
|
+
this.logParsedResult(obj);
|
|
220
|
+
|
|
221
|
+
// 10. Coerce to result type T (provider-specific)
|
|
222
|
+
const coerced = this.coerceResult(obj, raw, err);
|
|
223
|
+
|
|
224
|
+
// 11. Cleanup (optional override)
|
|
225
|
+
await this.cleanup();
|
|
226
|
+
|
|
227
|
+
return coerced;
|
|
228
|
+
}
|
|
229
|
+
}
|