cc-pipeline 0.5.2 → 0.6.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/bin/cc-pipeline.js +15 -5
- package/package.json +19 -2
- package/src/agents/{base.js → base.ts} +35 -13
- package/src/agents/bash.ts +63 -0
- package/src/agents/claudecode.ts +142 -0
- package/src/agents/codex.ts +132 -0
- package/src/claudecode.test.ts +260 -0
- package/src/{cli.js → cli.ts} +32 -5
- package/src/codex.test.ts +230 -0
- package/src/commands/{init.js → init.ts} +1 -1
- package/src/commands/{reset.js → reset.ts} +1 -1
- package/src/commands/run.ts +5 -0
- package/src/commands/{status.js → status.ts} +4 -3
- package/src/commands/{update.js → update.ts} +26 -5
- package/src/config.test.ts +318 -0
- package/src/{config.js → config.ts} +38 -9
- package/src/{engine.js → engine.ts} +100 -48
- package/src/events.ts +15 -0
- package/src/init.test.ts +274 -0
- package/src/logger.test.ts +165 -0
- package/src/{logger.js → logger.ts} +3 -3
- package/src/prompts.test.ts +173 -0
- package/src/{prompts.js → prompts.ts} +2 -2
- package/src/resume.test.ts +247 -0
- package/src/signal.test.ts +190 -0
- package/src/smoke.test.ts +88 -0
- package/src/state.test.ts +375 -0
- package/src/{state.js → state.ts} +5 -5
- package/src/tui/App.ts +356 -0
- package/src/tui/index.ts +38 -0
- package/src/update.test.ts +61 -0
- package/src/usage.ts +121 -0
- package/templates/pipeline/CLAUDE.md +3 -3
- package/templates/pipeline/prompts/commit.md +51 -11
- package/templates/pipeline/workflow.yaml +27 -20
- package/BUILD_SUMMARY.md +0 -104
- package/docs/AGENT-TEAMS-RESEARCH.md +0 -84
- package/docs/ATTRACTOR-COMPARISON.md +0 -195
- package/docs/IDEAS.md +0 -24
- package/docs/SYSTEM-PROMPTS-RESEARCH.md +0 -86
- package/docs/brief-example.png +0 -0
- package/src/agents/bash.js +0 -50
- package/src/agents/claude-interactive.js +0 -268
- package/src/agents/claude-piped.js +0 -81
- package/src/commands/run.js +0 -5
package/bin/cc-pipeline.js
CHANGED
|
@@ -1,8 +1,18 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
+
// Register tsx as an in-process ESM loader, then import and run the TypeScript CLI
|
|
3
|
+
// directly in this process. This keeps everything single-process so signal handlers
|
|
4
|
+
// registered by the engine work correctly (no subprocess forwarding needed).
|
|
5
|
+
import { fileURLToPath } from 'node:url';
|
|
6
|
+
import { dirname, join } from 'node:path';
|
|
2
7
|
|
|
3
|
-
|
|
8
|
+
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
4
9
|
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
10
|
+
// Load tsx's programmatic ESM registration API
|
|
11
|
+
const { register } = await import(
|
|
12
|
+
join(__dirname, '../node_modules/tsx/dist/esm/api/index.mjs')
|
|
13
|
+
);
|
|
14
|
+
register();
|
|
15
|
+
|
|
16
|
+
// Now TypeScript imports resolve correctly in this process
|
|
17
|
+
const { run } = await import('../src/cli.ts');
|
|
18
|
+
await run(process.argv.slice(2));
|
package/package.json
CHANGED
|
@@ -1,13 +1,20 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "cc-pipeline",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.6.0",
|
|
4
4
|
"description": "Autonomous Claude Code pipeline engine. Install into any repo, write a BRIEF.md, and let Claude build your project phase by phase.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
7
7
|
"cc-pipeline": "bin/cc-pipeline.js"
|
|
8
8
|
},
|
|
9
|
+
"files": [
|
|
10
|
+
"bin/",
|
|
11
|
+
"src/",
|
|
12
|
+
"templates/"
|
|
13
|
+
],
|
|
9
14
|
"scripts": {
|
|
10
|
-
"
|
|
15
|
+
"start": "tsx src/cli.ts",
|
|
16
|
+
"test": "node --import tsx/esm --test 'src/**/*.test.ts'",
|
|
17
|
+
"build": "tsc"
|
|
11
18
|
},
|
|
12
19
|
"repository": {
|
|
13
20
|
"type": "git",
|
|
@@ -31,6 +38,16 @@
|
|
|
31
38
|
"node": ">=18"
|
|
32
39
|
},
|
|
33
40
|
"dependencies": {
|
|
41
|
+
"@anthropic-ai/claude-agent-sdk": "^0.2.50",
|
|
42
|
+
"ink": "^6.8.0",
|
|
43
|
+
"react": "^19.2.4",
|
|
44
|
+
"tsx": "^4.21.0",
|
|
34
45
|
"yaml": "^2.7.0"
|
|
46
|
+
},
|
|
47
|
+
"devDependencies": {
|
|
48
|
+
"@types/node": "^25.3.0",
|
|
49
|
+
"@types/react": "^19.2.14",
|
|
50
|
+
"@types/react-dom": "^19.2.3",
|
|
51
|
+
"typescript": "^5.9.3"
|
|
35
52
|
}
|
|
36
53
|
}
|
|
@@ -1,36 +1,65 @@
|
|
|
1
1
|
import { EventEmitter } from 'node:events';
|
|
2
2
|
|
|
3
|
+
export interface AgentContext {
|
|
4
|
+
projectDir: string;
|
|
5
|
+
config: any;
|
|
6
|
+
logFile: string | null;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export interface AgentResult {
|
|
10
|
+
exitCode: number;
|
|
11
|
+
outputPath: string | null;
|
|
12
|
+
error?: string;
|
|
13
|
+
usage?: { costUSD: number };
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export interface StepDef {
|
|
17
|
+
name: string;
|
|
18
|
+
agent: string;
|
|
19
|
+
prompt?: string;
|
|
20
|
+
model?: string;
|
|
21
|
+
command?: string;
|
|
22
|
+
skipUnless?: string;
|
|
23
|
+
output?: string;
|
|
24
|
+
testGate?: boolean;
|
|
25
|
+
description?: string;
|
|
26
|
+
continueOnError?: boolean;
|
|
27
|
+
}
|
|
28
|
+
|
|
3
29
|
/**
|
|
4
30
|
* Shared state for tracking the current child process
|
|
5
31
|
* The engine signal handler needs access to this to kill the process on Ctrl-C
|
|
6
32
|
*/
|
|
7
33
|
class AgentState extends EventEmitter {
|
|
34
|
+
currentChild: any;
|
|
35
|
+
interrupted: boolean;
|
|
36
|
+
|
|
8
37
|
constructor() {
|
|
9
38
|
super();
|
|
10
39
|
this.currentChild = null;
|
|
11
40
|
this.interrupted = false;
|
|
12
41
|
}
|
|
13
42
|
|
|
14
|
-
setChild(child) {
|
|
43
|
+
setChild(child: any): void {
|
|
15
44
|
this.currentChild = child;
|
|
16
45
|
}
|
|
17
46
|
|
|
18
|
-
getChild() {
|
|
47
|
+
getChild(): any {
|
|
19
48
|
return this.currentChild;
|
|
20
49
|
}
|
|
21
50
|
|
|
22
|
-
clearChild() {
|
|
51
|
+
clearChild(): void {
|
|
23
52
|
this.currentChild = null;
|
|
24
53
|
}
|
|
25
54
|
|
|
26
|
-
setInterrupted(value) {
|
|
55
|
+
setInterrupted(value: boolean): void {
|
|
27
56
|
this.interrupted = value;
|
|
28
57
|
if (value) {
|
|
29
58
|
this.emit('interrupt');
|
|
30
59
|
}
|
|
31
60
|
}
|
|
32
61
|
|
|
33
|
-
isInterrupted() {
|
|
62
|
+
isInterrupted(): boolean {
|
|
34
63
|
return this.interrupted;
|
|
35
64
|
}
|
|
36
65
|
}
|
|
@@ -41,16 +70,9 @@ export const agentState = new AgentState();
|
|
|
41
70
|
/**
|
|
42
71
|
* Base interface for all agents.
|
|
43
72
|
* All agents must implement the run() method with this signature.
|
|
44
|
-
*
|
|
45
|
-
* @param {number} phase - Current phase number
|
|
46
|
-
* @param {object} step - Step definition from workflow.yaml
|
|
47
|
-
* @param {string} promptPath - Relative path to prompt file (for claude agents)
|
|
48
|
-
* @param {string} model - Model name to use
|
|
49
|
-
* @param {object} context - { projectDir, config, logFile }
|
|
50
|
-
* @returns {Promise<{exitCode: number, outputPath: string|null}>}
|
|
51
73
|
*/
|
|
52
74
|
export class BaseAgent {
|
|
53
|
-
async run(phase, step, promptPath, model, context) {
|
|
75
|
+
async run(phase: number, step: StepDef, promptPath: string | null, model: string, context: AgentContext): Promise<AgentResult> {
|
|
54
76
|
throw new Error('Agent must implement run() method');
|
|
55
77
|
}
|
|
56
78
|
}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import { spawn } from 'node:child_process';
|
|
2
|
+
import { appendFileSync, writeFileSync } from 'node:fs';
|
|
3
|
+
import { join } from 'node:path';
|
|
4
|
+
import { BaseAgent, agentState, AgentContext, AgentResult, StepDef } from './base.js';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Bash agent - executes shell commands
|
|
8
|
+
* Ports run_bash from run.sh: substitute {{PHASE}} and execute via spawn
|
|
9
|
+
*/
|
|
10
|
+
export class BashAgent extends BaseAgent {
|
|
11
|
+
async run(phase: number, step: StepDef, promptPath: string | null, model: string, context: AgentContext): Promise<AgentResult> {
|
|
12
|
+
const { command } = step;
|
|
13
|
+
|
|
14
|
+
if (!command) {
|
|
15
|
+
throw new Error('Bash agent requires a command in step definition');
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
// Substitute {{PHASE}} placeholder
|
|
19
|
+
const cmd = command.replace(/\{\{PHASE\}\}/g, phase.toString());
|
|
20
|
+
|
|
21
|
+
console.log(` Executing: ${cmd}`);
|
|
22
|
+
|
|
23
|
+
const outputPath = join(context.projectDir, '.pipeline', 'step-output.log');
|
|
24
|
+
writeFileSync(outputPath, `$ ${cmd}\n`, 'utf-8');
|
|
25
|
+
|
|
26
|
+
return new Promise((resolve) => {
|
|
27
|
+
// Pipe stdout/stderr so output is captured for TUI and not swallowed
|
|
28
|
+
const child = spawn(cmd, {
|
|
29
|
+
shell: true,
|
|
30
|
+
stdio: ['inherit', 'pipe', 'pipe'],
|
|
31
|
+
cwd: context.projectDir
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
// Track child process for signal handling
|
|
35
|
+
agentState.setChild(child);
|
|
36
|
+
|
|
37
|
+
const onData = (chunk: Buffer) => {
|
|
38
|
+
try { appendFileSync(outputPath, chunk.toString(), 'utf-8'); } catch (_) {}
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
child.stdout?.on('data', onData);
|
|
42
|
+
child.stderr?.on('data', onData);
|
|
43
|
+
|
|
44
|
+
child.on('close', (code) => {
|
|
45
|
+
agentState.clearChild();
|
|
46
|
+
resolve({
|
|
47
|
+
exitCode: code ?? 1,
|
|
48
|
+
outputPath
|
|
49
|
+
});
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
child.on('error', (err) => {
|
|
53
|
+
agentState.clearChild();
|
|
54
|
+
const msg = `Bash agent error: ${err.message}\n`;
|
|
55
|
+
try { appendFileSync(outputPath, msg, 'utf-8'); } catch (_) {}
|
|
56
|
+
resolve({
|
|
57
|
+
exitCode: 1,
|
|
58
|
+
outputPath
|
|
59
|
+
});
|
|
60
|
+
});
|
|
61
|
+
});
|
|
62
|
+
}
|
|
63
|
+
}
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
import { writeFileSync, appendFileSync } from 'node:fs';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
import { query } from '@anthropic-ai/claude-agent-sdk';
|
|
4
|
+
import { BaseAgent, agentState, AgentContext, AgentResult, StepDef } from './base.js';
|
|
5
|
+
import { generatePrompt } from '../prompts.js';
|
|
6
|
+
import { pipelineEvents } from '../events.js';
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* ClaudeCode Agent
|
|
10
|
+
* Runs all AI pipeline steps via the Agent SDK query() API.
|
|
11
|
+
* Handles spec/research/plan/review/reflect (text streaming) and
|
|
12
|
+
* build/fix (tool-heavy) steps with the same implementation.
|
|
13
|
+
*/
|
|
14
|
+
export class ClaudeCodeAgent extends BaseAgent {
|
|
15
|
+
async run(phase: number, step: StepDef, promptPath: string | null, model: string, context: AgentContext): Promise<AgentResult> {
|
|
16
|
+
const { projectDir, config, logFile } = context;
|
|
17
|
+
const pipelineDir = join(projectDir, '.pipeline');
|
|
18
|
+
const outputPath = join(pipelineDir, 'step-output.log');
|
|
19
|
+
|
|
20
|
+
const promptText = generatePrompt(projectDir, config, phase, promptPath);
|
|
21
|
+
writeFileSync(join(pipelineDir, 'current-prompt.md'), promptText, 'utf-8');
|
|
22
|
+
|
|
23
|
+
if (agentState.interrupted) {
|
|
24
|
+
return { exitCode: 130, outputPath };
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const controller = new AbortController();
|
|
28
|
+
const onInterrupt = () => controller.abort();
|
|
29
|
+
agentState.on('interrupt', onInterrupt);
|
|
30
|
+
|
|
31
|
+
// Clear output file so TUI file-tailer sees only this step's content
|
|
32
|
+
writeFileSync(outputPath, '', 'utf-8');
|
|
33
|
+
|
|
34
|
+
const logLine = (msg: string) => {
|
|
35
|
+
if (logFile) {
|
|
36
|
+
try { appendFileSync(logFile, msg + '\n', 'utf-8'); } catch (_) {}
|
|
37
|
+
}
|
|
38
|
+
};
|
|
39
|
+
const appendOutput = (line: string) => {
|
|
40
|
+
try { appendFileSync(outputPath, line + '\n', 'utf-8'); } catch (_) {}
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
const outputChunks: string[] = [];
|
|
44
|
+
let stepCostUSD = 0;
|
|
45
|
+
|
|
46
|
+
try {
|
|
47
|
+
const queryOptions: any = {
|
|
48
|
+
maxTurns: 500,
|
|
49
|
+
permissionMode: 'bypassPermissions',
|
|
50
|
+
// Unset CLAUDECODE to prevent SDK conflict when running inside Claude Code
|
|
51
|
+
env: { ...process.env, CLAUDECODE: undefined },
|
|
52
|
+
hooks: {
|
|
53
|
+
PreToolUse: [{ hooks: [async (data: any) => {
|
|
54
|
+
const line = `[tool:start] ${data.tool_name} ${JSON.stringify(data.tool_input ?? {}).slice(0, 120)}`;
|
|
55
|
+
logLine(line);
|
|
56
|
+
appendOutput(line);
|
|
57
|
+
}] }],
|
|
58
|
+
PostToolUse: [{ hooks: [async (data: any) => {
|
|
59
|
+
const success = !data.tool_response?.is_error;
|
|
60
|
+
const line = `[tool:done] ${data.tool_name} ${success ? '✓' : '✗'}`;
|
|
61
|
+
logLine(line);
|
|
62
|
+
appendOutput(line);
|
|
63
|
+
}] }],
|
|
64
|
+
SubagentStart: [{ hooks: [async (data: any) => {
|
|
65
|
+
const line = `[subagent:start] ${data.agent_id ?? ''}`;
|
|
66
|
+
logLine(line);
|
|
67
|
+
appendOutput(line);
|
|
68
|
+
}] }],
|
|
69
|
+
SubagentStop: [{ hooks: [async (data: any) => {
|
|
70
|
+
const line = `[subagent:done] ${data.agent_id ?? ''}`;
|
|
71
|
+
logLine(line);
|
|
72
|
+
appendOutput(line);
|
|
73
|
+
}] }],
|
|
74
|
+
Stop: [{ hooks: [async (_data: any) => {
|
|
75
|
+
logLine(`[session:stop]`);
|
|
76
|
+
}] }],
|
|
77
|
+
},
|
|
78
|
+
};
|
|
79
|
+
|
|
80
|
+
if (model && model !== 'default') {
|
|
81
|
+
queryOptions.model = model;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
queryOptions.abortController = controller;
|
|
85
|
+
for await (const event of query({
|
|
86
|
+
prompt: promptText,
|
|
87
|
+
options: queryOptions,
|
|
88
|
+
})) {
|
|
89
|
+
if ((event as any).type === 'assistant' && (event as any).message?.role === 'assistant') {
|
|
90
|
+
for (const block of (event as any).message.content ?? []) {
|
|
91
|
+
if ((block as any).type === 'text') {
|
|
92
|
+
const text: string = (block as any).text;
|
|
93
|
+
outputChunks.push(text);
|
|
94
|
+
// Stream text lines to output file for TUI file-tailing
|
|
95
|
+
const textLines = text
|
|
96
|
+
.split('\n')
|
|
97
|
+
.map(l => l.trim())
|
|
98
|
+
.filter(l => l.length > 0)
|
|
99
|
+
.map(l => '[text] ' + l)
|
|
100
|
+
.join('\n');
|
|
101
|
+
if (textLines) appendOutput(textLines);
|
|
102
|
+
pipelineEvents.emit('text:chunk', { phase, step: step.name, text });
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
if ((event as any).type === 'result') {
|
|
107
|
+
stepCostUSD = (event as any).total_cost_usd ?? 0;
|
|
108
|
+
const reason = (event as any).stop_reason ?? 'end_turn';
|
|
109
|
+
pipelineEvents.emit('session:stop', { phase, step: step.name, reason });
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
} catch (err: any) {
|
|
113
|
+
agentState.off('interrupt', onInterrupt);
|
|
114
|
+
|
|
115
|
+
if (agentState.interrupted || controller.signal.aborted) {
|
|
116
|
+
appendOutput(outputChunks.join('\n'));
|
|
117
|
+
return { exitCode: 130, outputPath };
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
const errorText = `Error: ${err.message}\n${err.stack ?? ''}`;
|
|
121
|
+
writeFileSync(outputPath, errorText, 'utf-8');
|
|
122
|
+
return { exitCode: 1, outputPath };
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
agentState.off('interrupt', onInterrupt);
|
|
126
|
+
|
|
127
|
+
// Write final collected output (for steps without streaming, e.g. build)
|
|
128
|
+
if (outputChunks.length > 0) {
|
|
129
|
+
writeFileSync(outputPath, outputChunks.join('\n'), 'utf-8');
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
if (agentState.interrupted) {
|
|
133
|
+
return { exitCode: 130, outputPath };
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
return { exitCode: 0, outputPath, usage: { costUSD: stepCostUSD } };
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
export function createAgent(): ClaudeCodeAgent {
|
|
141
|
+
return new ClaudeCodeAgent();
|
|
142
|
+
}
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
import { spawn, ChildProcess } from 'node:child_process';
|
|
2
|
+
import { appendFileSync, writeFileSync } from 'node:fs';
|
|
3
|
+
import { join } from 'node:path';
|
|
4
|
+
import { BaseAgent, agentState, AgentContext, AgentResult, StepDef } from './base.js';
|
|
5
|
+
import { generatePrompt } from '../prompts.js';
|
|
6
|
+
|
|
7
|
+
const MAX_ATTEMPTS = 3;
|
|
8
|
+
const INACTIVITY_MS = 5 * 60 * 1000; // 5 minutes
|
|
9
|
+
const INACTIVITY_CHECK_MS = 30_000; // poll every 30 s
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Codex agent — shells out to the `codex` CLI with --yolo.
|
|
13
|
+
* Always runs in auto-approval mode. Model can be set per-step or left to
|
|
14
|
+
* the codex CLI default.
|
|
15
|
+
*
|
|
16
|
+
* An inactivity timeout kills and retries the process if no output is seen
|
|
17
|
+
* for INACTIVITY_MS. After MAX_ATTEMPTS consecutive inactivity failures the
|
|
18
|
+
* step is marked as an error with all accumulated output preserved.
|
|
19
|
+
*
|
|
20
|
+
* Usage in workflow.yaml:
|
|
21
|
+
* agent: codex
|
|
22
|
+
* model: o4-mini # optional — omit to use codex's default model
|
|
23
|
+
*/
|
|
24
|
+
export class CodexAgent extends BaseAgent {
|
|
25
|
+
async run(phase: number, step: StepDef, promptPath: string | null, model: string, context: AgentContext): Promise<AgentResult> {
|
|
26
|
+
const { projectDir } = context;
|
|
27
|
+
const pipelineDir = join(projectDir, '.pipeline');
|
|
28
|
+
const outputPath = join(pipelineDir, 'step-output.log');
|
|
29
|
+
|
|
30
|
+
const promptText = generatePrompt(projectDir, context.config, phase, promptPath);
|
|
31
|
+
writeFileSync(join(pipelineDir, 'current-prompt.md'), promptText, 'utf-8');
|
|
32
|
+
writeFileSync(outputPath, '', 'utf-8');
|
|
33
|
+
|
|
34
|
+
if (agentState.interrupted) {
|
|
35
|
+
return { exitCode: 130, outputPath };
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const args = ['exec', '--yolo'];
|
|
39
|
+
if (model && model !== 'default') {
|
|
40
|
+
args.push('--model', model);
|
|
41
|
+
}
|
|
42
|
+
args.push(promptText);
|
|
43
|
+
|
|
44
|
+
const header = `$ codex exec --yolo${model && model !== 'default' ? ` --model ${model}` : ''} "<prompt>"\n`;
|
|
45
|
+
writeFileSync(outputPath, header, 'utf-8');
|
|
46
|
+
|
|
47
|
+
for (let attempt = 1; attempt <= MAX_ATTEMPTS; attempt++) {
|
|
48
|
+
if (agentState.interrupted) {
|
|
49
|
+
return { exitCode: 130, outputPath };
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
if (attempt > 1) {
|
|
53
|
+
const sep = `\n--- codex: attempt ${attempt}/${MAX_ATTEMPTS} (previous attempt had no activity for ${INACTIVITY_MS / 60000} min) ---\n\n`;
|
|
54
|
+
try { appendFileSync(outputPath, sep, 'utf-8'); } catch (_) {}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const { exitCode, timedOut } = await this.spawnOnce(args, projectDir, outputPath);
|
|
58
|
+
|
|
59
|
+
if (!timedOut) {
|
|
60
|
+
// Normal exit (success or non-inactivity error) — return immediately.
|
|
61
|
+
return { exitCode, outputPath };
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// Inactivity timeout — log and loop to next attempt.
|
|
65
|
+
const msg = `[codex: no output for ${INACTIVITY_MS / 60000} min — attempt ${attempt}/${MAX_ATTEMPTS} aborted]\n`;
|
|
66
|
+
try { appendFileSync(outputPath, msg, 'utf-8'); } catch (_) {}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// All attempts exhausted.
|
|
70
|
+
try {
|
|
71
|
+
appendFileSync(outputPath, `\n[codex: all ${MAX_ATTEMPTS} attempts timed out due to inactivity — giving up]\n`, 'utf-8');
|
|
72
|
+
} catch (_) {}
|
|
73
|
+
return { exitCode: 1, outputPath };
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
private spawnOnce(
|
|
77
|
+
args: string[],
|
|
78
|
+
projectDir: string,
|
|
79
|
+
outputPath: string,
|
|
80
|
+
): Promise<{ exitCode: number; timedOut: boolean }> {
|
|
81
|
+
return new Promise((resolve) => {
|
|
82
|
+
let settled = false;
|
|
83
|
+
let lastActivity = Date.now();
|
|
84
|
+
|
|
85
|
+
const settle = (result: { exitCode: number; timedOut: boolean }) => {
|
|
86
|
+
if (settled) return;
|
|
87
|
+
settled = true;
|
|
88
|
+
clearInterval(inactivityTimer);
|
|
89
|
+
agentState.clearChild();
|
|
90
|
+
resolve(result);
|
|
91
|
+
};
|
|
92
|
+
|
|
93
|
+
const child: ChildProcess = spawn('codex', args, {
|
|
94
|
+
shell: false,
|
|
95
|
+
stdio: ['inherit', 'pipe', 'pipe'],
|
|
96
|
+
cwd: projectDir,
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
agentState.setChild(child);
|
|
100
|
+
|
|
101
|
+
const onData = (chunk: Buffer) => {
|
|
102
|
+
lastActivity = Date.now();
|
|
103
|
+
try { appendFileSync(outputPath, chunk.toString(), 'utf-8'); } catch (_) {}
|
|
104
|
+
};
|
|
105
|
+
|
|
106
|
+
child.stdout?.on('data', onData);
|
|
107
|
+
child.stderr?.on('data', onData);
|
|
108
|
+
|
|
109
|
+
const inactivityTimer = setInterval(() => {
|
|
110
|
+
if (Date.now() - lastActivity < INACTIVITY_MS) return;
|
|
111
|
+
// No output for too long — kill the process.
|
|
112
|
+
try { process.kill(child.pid!, 'SIGTERM'); } catch (_) {}
|
|
113
|
+
setTimeout(() => {
|
|
114
|
+
try { process.kill(child.pid!, 'SIGKILL'); } catch (_) {}
|
|
115
|
+
}, 2000);
|
|
116
|
+
settle({ exitCode: 1, timedOut: true });
|
|
117
|
+
}, INACTIVITY_CHECK_MS);
|
|
118
|
+
|
|
119
|
+
child.on('close', (code) => settle({ exitCode: code ?? 1, timedOut: false }));
|
|
120
|
+
|
|
121
|
+
child.on('error', (err) => {
|
|
122
|
+
const msg = `Codex agent error: ${err.message}\n`;
|
|
123
|
+
try { appendFileSync(outputPath, msg, 'utf-8'); } catch (_) {}
|
|
124
|
+
settle({ exitCode: 1, timedOut: false });
|
|
125
|
+
});
|
|
126
|
+
});
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
export function createAgent(): CodexAgent {
|
|
131
|
+
return new CodexAgent();
|
|
132
|
+
}
|