aiwcli 0.13.8 → 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.
Files changed (37) hide show
  1. package/README.md +11 -1
  2. package/dist/commands/launch.d.ts +8 -0
  3. package/dist/commands/launch.js +96 -5
  4. package/dist/templates/_shared/.claude/skills/codex/SKILL.md +42 -0
  5. package/dist/templates/_shared/.claude/skills/codex/prompt.md +10 -0
  6. package/dist/templates/_shared/lib-ts/agent-exec/backends/headless.ts +33 -0
  7. package/dist/templates/_shared/lib-ts/agent-exec/backends/index.ts +6 -0
  8. package/dist/templates/_shared/lib-ts/agent-exec/backends/tmux.ts +145 -0
  9. package/dist/templates/_shared/lib-ts/agent-exec/base-agent.ts +229 -0
  10. package/dist/templates/_shared/lib-ts/agent-exec/execution-backend.ts +50 -0
  11. package/dist/templates/_shared/lib-ts/agent-exec/index.ts +4 -0
  12. package/dist/templates/_shared/lib-ts/base/cli-args.ts +283 -0
  13. package/dist/templates/_shared/lib-ts/base/inference.ts +53 -47
  14. package/dist/templates/_shared/lib-ts/base/models.ts +16 -0
  15. package/dist/templates/_shared/lib-ts/base/preflight.ts +98 -0
  16. package/dist/templates/_shared/lib-ts/base/tmux-driver.ts +381 -0
  17. package/dist/templates/_shared/lib-ts/base/utils.ts +8 -0
  18. package/dist/templates/_shared/lib-ts/context/context-formatter.ts +35 -11
  19. package/dist/templates/_shared/lib-ts/types.ts +17 -0
  20. package/dist/templates/_shared/scripts/status_line.ts +57 -28
  21. package/dist/templates/_shared/skills/prompt-codex/CLAUDE.md +46 -0
  22. package/dist/templates/_shared/skills/prompt-codex/scripts/launch-codex.ts +254 -0
  23. package/dist/templates/cc-native/.claude/settings.json +121 -1
  24. package/dist/templates/cc-native/_cc-native/CLAUDE.md +73 -0
  25. package/dist/templates/cc-native/_cc-native/lib-ts/CLAUDE.md +70 -0
  26. package/dist/templates/cc-native/_cc-native/lib-ts/settings.ts +2 -1
  27. package/dist/templates/cc-native/_cc-native/lib-ts/types.ts +5 -12
  28. package/dist/templates/cc-native/_cc-native/plan-review/lib/preflight.ts +14 -80
  29. package/dist/templates/cc-native/_cc-native/plan-review/lib/reviewers/agent.ts +19 -7
  30. package/dist/templates/cc-native/_cc-native/plan-review/lib/reviewers/base/base-agent.ts +4 -215
  31. package/dist/templates/cc-native/_cc-native/plan-review/lib/reviewers/index.ts +1 -1
  32. package/dist/templates/cc-native/_cc-native/plan-review/lib/reviewers/providers/claude-agent.ts +9 -39
  33. package/dist/templates/cc-native/_cc-native/plan-review/lib/reviewers/providers/codex-agent.ts +19 -22
  34. package/dist/templates/cc-native/_cc-native/plan-review/lib/reviewers/providers/gemini-agent.ts +2 -1
  35. package/dist/templates/cc-native/_cc-native/plan-review/lib/reviewers/providers/orchestrator-claude-agent.ts +13 -15
  36. package/oclif.manifest.json +21 -3
  37. 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, supports parallel sessions).
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
  }
@@ -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, supports parallel sessions)\n\n' +
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\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 or Codex from npm)';
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
- exitCode = await spawnProcess(cliCommand, cliArgs);
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,6 @@
1
+ /**
2
+ * Barrel re-export for execution backends.
3
+ */
4
+
5
+ export { HeadlessBackend } from "./headless.js";
6
+ export { TmuxBackend, type TmuxBackendOptions } from "./tmux.js";
@@ -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
+ }