ctx-cc 4.1.1 → 4.1.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/README.md +5 -5
- package/hooks/pre-tool-use.js +4 -2
- package/package.json +3 -3
- package/plugin.json +1 -1
- package/src/install.js +37 -20
- package/src/auto.js +0 -287
- package/src/commits.js +0 -94
- package/src/context.js +0 -241
- package/src/handoff.js +0 -156
- package/src/hooks.js +0 -218
- package/src/lifecycle.js +0 -194
- package/src/metrics.js +0 -198
- package/src/pipeline.js +0 -269
- package/src/review-gate.js +0 -338
- package/src/runner.js +0 -120
- package/src/state.js +0 -267
- package/src/worktree.js +0 -244
package/src/review-gate.js
DELETED
|
@@ -1,338 +0,0 @@
|
|
|
1
|
-
import fs from 'fs';
|
|
2
|
-
import path from 'path';
|
|
3
|
-
import { readState, writeState, logAgentInvocation, completeAgentInvocation } from './state.js';
|
|
4
|
-
import { buildContext } from './context.js';
|
|
5
|
-
import { runAgent } from './runner.js';
|
|
6
|
-
|
|
7
|
-
const MAX_REVIEW_CYCLES = 3;
|
|
8
|
-
|
|
9
|
-
/**
|
|
10
|
-
* Run the three-stage review gate.
|
|
11
|
-
*
|
|
12
|
-
* Stage 1: ctx-reviewer checks spec compliance (acceptance criteria)
|
|
13
|
-
* Stage 2: ctx-reviewer (quality framing) checks code quality (security, performance, style)
|
|
14
|
-
* Stage 3: ctx-codex-reviewer performs cross-model review via OpenAI Codex MCP
|
|
15
|
-
* (only runs if Stages 1 and 2 pass, and `config.codexReview !== false`).
|
|
16
|
-
*
|
|
17
|
-
* If any stage fails, returns feedback for re-execution.
|
|
18
|
-
* Max cycles before requiring human intervention.
|
|
19
|
-
*
|
|
20
|
-
* Options:
|
|
21
|
-
* ctxDir, projectDir, agentsDir, streaming, timeout, config
|
|
22
|
-
*
|
|
23
|
-
* Returns:
|
|
24
|
-
* { passed, stage1, stage2, stage3, cycle, feedback, escalated }
|
|
25
|
-
*/
|
|
26
|
-
export async function runReviewGate({ ctxDir, projectDir, agentsDir, streaming = true, timeout = 300000, config = {} }) {
|
|
27
|
-
const state = readState(ctxDir);
|
|
28
|
-
if (!state) throw new Error('No STATE.json found.');
|
|
29
|
-
|
|
30
|
-
const maxCycles = config.maxReviewCycles || MAX_REVIEW_CYCLES;
|
|
31
|
-
const reviewState = state.reviewGate || { cycle: 0, history: [] };
|
|
32
|
-
reviewState.cycle += 1;
|
|
33
|
-
|
|
34
|
-
if (reviewState.cycle > maxCycles) {
|
|
35
|
-
return {
|
|
36
|
-
passed: false,
|
|
37
|
-
stage1: null,
|
|
38
|
-
stage2: null,
|
|
39
|
-
cycle: reviewState.cycle,
|
|
40
|
-
feedback: `Review loop exceeded (${maxCycles} cycles). Human review required.`,
|
|
41
|
-
escalated: true,
|
|
42
|
-
};
|
|
43
|
-
}
|
|
44
|
-
|
|
45
|
-
// Stage 1: Spec compliance (reviewer)
|
|
46
|
-
const stage1 = await runReviewStage({
|
|
47
|
-
stageName: 'spec-compliance',
|
|
48
|
-
agentFile: 'ctx-reviewer.md',
|
|
49
|
-
agentCommand: 'review',
|
|
50
|
-
prompt: buildReviewPrompt(state, 'spec'),
|
|
51
|
-
ctxDir, projectDir, agentsDir, streaming, timeout,
|
|
52
|
-
});
|
|
53
|
-
|
|
54
|
-
// Stage 2: Code quality — only if Stage 1 passes. Reuses ctx-reviewer with quality framing;
|
|
55
|
-
// ctx-auditor is an audit-trail agent, not a code reviewer, so using it here was a miscast.
|
|
56
|
-
let stage2 = null;
|
|
57
|
-
if (stage1.passed) {
|
|
58
|
-
stage2 = await runReviewStage({
|
|
59
|
-
stageName: 'code-quality',
|
|
60
|
-
agentFile: 'ctx-reviewer.md',
|
|
61
|
-
agentCommand: 'review',
|
|
62
|
-
prompt: buildReviewPrompt(state, 'quality'),
|
|
63
|
-
ctxDir, projectDir, agentsDir, streaming, timeout,
|
|
64
|
-
});
|
|
65
|
-
}
|
|
66
|
-
|
|
67
|
-
// Stage 3: Cross-model review via Codex — only if Stages 1 and 2 pass and not disabled.
|
|
68
|
-
// The agent may return VERDICT: SKIP (trivial changes, MCP unavailable, rate-limited);
|
|
69
|
-
// SKIP is treated as pass-through so infrastructure issues never block the gate.
|
|
70
|
-
// Across retry cycles we pipe the prior Codex threadId forward so the agent can
|
|
71
|
-
// reuse the cheaper codex-reply path instead of starting a fresh session.
|
|
72
|
-
let stage3 = null;
|
|
73
|
-
if (stage1.passed && stage2 && stage2.passed && config.codexReview !== false) {
|
|
74
|
-
const priorThreadId = priorCodexThreadId(reviewState);
|
|
75
|
-
stage3 = await runReviewStage({
|
|
76
|
-
stageName: 'codex-review',
|
|
77
|
-
agentFile: 'ctx-codex-reviewer.md',
|
|
78
|
-
agentCommand: 'review',
|
|
79
|
-
prompt: buildReviewPrompt(state, 'codex', { priorThreadId }),
|
|
80
|
-
ctxDir, projectDir, agentsDir, streaming, timeout,
|
|
81
|
-
});
|
|
82
|
-
const { skipped, threadId } = parseStage3Markers(stage3.output);
|
|
83
|
-
stage3.threadId = threadId;
|
|
84
|
-
if (skipped) {
|
|
85
|
-
stage3.passed = true;
|
|
86
|
-
stage3.skipped = true;
|
|
87
|
-
stage3.issues = null;
|
|
88
|
-
}
|
|
89
|
-
}
|
|
90
|
-
|
|
91
|
-
// stage2 defaults to false when null (stage1 failed → never ran → not passed).
|
|
92
|
-
// stage3 defaults to true when null (disabled or earlier stage failed → absence is not a fail).
|
|
93
|
-
const passed =
|
|
94
|
-
stage1.passed &&
|
|
95
|
-
(stage2 ? stage2.passed : false) &&
|
|
96
|
-
(stage3 ? stage3.passed : true);
|
|
97
|
-
|
|
98
|
-
// Build feedback for re-execution if failed
|
|
99
|
-
let feedback = null;
|
|
100
|
-
if (!passed) {
|
|
101
|
-
const issues = [];
|
|
102
|
-
if (!stage1.passed) issues.push(`Spec compliance: ${stage1.issues}`);
|
|
103
|
-
if (stage2 && !stage2.passed) issues.push(`Code quality: ${stage2.issues}`);
|
|
104
|
-
if (stage3 && !stage3.passed) issues.push(`Codex review: ${stage3.issues}`);
|
|
105
|
-
feedback = issues.join('\n');
|
|
106
|
-
}
|
|
107
|
-
|
|
108
|
-
const stage3History = stage3
|
|
109
|
-
? {
|
|
110
|
-
passed: stage3.passed,
|
|
111
|
-
issues: stage3.issues,
|
|
112
|
-
skipped: stage3.skipped || false,
|
|
113
|
-
threadId: stage3.threadId || null,
|
|
114
|
-
}
|
|
115
|
-
: null;
|
|
116
|
-
|
|
117
|
-
// Record in state
|
|
118
|
-
reviewState.history.push({
|
|
119
|
-
cycle: reviewState.cycle,
|
|
120
|
-
timestamp: new Date().toISOString(),
|
|
121
|
-
stage1: { passed: stage1.passed, issues: stage1.issues },
|
|
122
|
-
stage2: stage2 ? { passed: stage2.passed, issues: stage2.issues } : null,
|
|
123
|
-
stage3: stage3History,
|
|
124
|
-
result: passed ? 'pass' : 'fail',
|
|
125
|
-
});
|
|
126
|
-
|
|
127
|
-
state.reviewGate = reviewState;
|
|
128
|
-
writeState(ctxDir, state);
|
|
129
|
-
|
|
130
|
-
// Save review results
|
|
131
|
-
saveReviewResult(ctxDir, state.activeStory, reviewState);
|
|
132
|
-
|
|
133
|
-
return {
|
|
134
|
-
passed,
|
|
135
|
-
stage1: { passed: stage1.passed, issues: stage1.issues },
|
|
136
|
-
stage2: stage2 ? { passed: stage2.passed, issues: stage2.issues } : null,
|
|
137
|
-
stage3: stage3History,
|
|
138
|
-
cycle: reviewState.cycle,
|
|
139
|
-
feedback,
|
|
140
|
-
escalated: false,
|
|
141
|
-
};
|
|
142
|
-
}
|
|
143
|
-
|
|
144
|
-
/**
|
|
145
|
-
* Check if the review gate is enabled in config.
|
|
146
|
-
*/
|
|
147
|
-
export function isReviewGateEnabled(config) {
|
|
148
|
-
return config.reviewGate !== false;
|
|
149
|
-
}
|
|
150
|
-
|
|
151
|
-
/**
|
|
152
|
-
* Parse Stage 3 output markers.
|
|
153
|
-
* - `skipped` is true when the agent emitted `VERDICT: SKIP` (trivial change,
|
|
154
|
-
* MCP unavailable, auth expired, rate-limited).
|
|
155
|
-
* - `threadId` is the value after `THREAD: <id>`, used to resume cheaper
|
|
156
|
-
* `codex-reply` sessions across review cycles.
|
|
157
|
-
*
|
|
158
|
-
* Exported for unit testing; consumed by runReviewGate internally.
|
|
159
|
-
*/
|
|
160
|
-
export function parseStage3Markers(output) {
|
|
161
|
-
const text = output || '';
|
|
162
|
-
const skipped = /verdict:\s*skip/i.test(text);
|
|
163
|
-
const threadMatch = /THREAD:\s*([^\s]+)/i.exec(text);
|
|
164
|
-
return { skipped, threadId: threadMatch ? threadMatch[1] : null };
|
|
165
|
-
}
|
|
166
|
-
|
|
167
|
-
/**
|
|
168
|
-
* Get review history from state.
|
|
169
|
-
*/
|
|
170
|
-
export function getReviewHistory(ctxDir) {
|
|
171
|
-
const state = readState(ctxDir);
|
|
172
|
-
return state?.reviewGate?.history || [];
|
|
173
|
-
}
|
|
174
|
-
|
|
175
|
-
/**
|
|
176
|
-
* Format review gate result for display.
|
|
177
|
-
*/
|
|
178
|
-
export function formatReviewResult(result) {
|
|
179
|
-
if (!result) return ' No review results.';
|
|
180
|
-
|
|
181
|
-
const lines = [];
|
|
182
|
-
const icon = result.passed ? '✓' : '✗';
|
|
183
|
-
lines.push(` Review cycle ${result.cycle}: ${icon} ${result.passed ? 'PASSED' : 'FAILED'}`);
|
|
184
|
-
|
|
185
|
-
if (result.stage1) {
|
|
186
|
-
const s1Icon = result.stage1.passed ? '✓' : '✗';
|
|
187
|
-
lines.push(` ${s1Icon} Stage 1 (spec compliance): ${result.stage1.passed ? 'pass' : result.stage1.issues || 'fail'}`);
|
|
188
|
-
}
|
|
189
|
-
if (result.stage2) {
|
|
190
|
-
const s2Icon = result.stage2.passed ? '✓' : '✗';
|
|
191
|
-
lines.push(` ${s2Icon} Stage 2 (code quality): ${result.stage2.passed ? 'pass' : result.stage2.issues || 'fail'}`);
|
|
192
|
-
}
|
|
193
|
-
if (result.stage3) {
|
|
194
|
-
if (result.stage3.skipped) {
|
|
195
|
-
lines.push(` ○ Stage 3 (codex review): skipped`);
|
|
196
|
-
} else {
|
|
197
|
-
const s3Icon = result.stage3.passed ? '✓' : '✗';
|
|
198
|
-
lines.push(` ${s3Icon} Stage 3 (codex review): ${result.stage3.passed ? 'pass' : result.stage3.issues || 'fail'}`);
|
|
199
|
-
}
|
|
200
|
-
}
|
|
201
|
-
|
|
202
|
-
if (result.escalated) {
|
|
203
|
-
lines.push('');
|
|
204
|
-
lines.push(' ⚠ Review loop exceeded — human review required.');
|
|
205
|
-
}
|
|
206
|
-
|
|
207
|
-
if (result.feedback) {
|
|
208
|
-
lines.push('');
|
|
209
|
-
lines.push(' Feedback for re-execution:');
|
|
210
|
-
for (const line of result.feedback.split('\n')) {
|
|
211
|
-
lines.push(` ${line}`);
|
|
212
|
-
}
|
|
213
|
-
}
|
|
214
|
-
|
|
215
|
-
return lines.join('\n');
|
|
216
|
-
}
|
|
217
|
-
|
|
218
|
-
// --- internal ---
|
|
219
|
-
|
|
220
|
-
async function runReviewStage({ stageName, agentFile, agentCommand, prompt, ctxDir, projectDir, agentsDir, streaming, timeout }) {
|
|
221
|
-
const agentPath = path.join(agentsDir, agentFile);
|
|
222
|
-
|
|
223
|
-
if (!fs.existsSync(agentPath)) {
|
|
224
|
-
return { passed: true, issues: null, output: 'Agent not found — skipping.' };
|
|
225
|
-
}
|
|
226
|
-
|
|
227
|
-
logAgentInvocation(ctxDir, agentFile, `Review gate: ${stageName}`);
|
|
228
|
-
|
|
229
|
-
const { context } = buildContext(agentCommand, projectDir, ctxDir);
|
|
230
|
-
|
|
231
|
-
try {
|
|
232
|
-
const result = await runAgent({
|
|
233
|
-
agentPath,
|
|
234
|
-
message: prompt,
|
|
235
|
-
streaming,
|
|
236
|
-
timeout,
|
|
237
|
-
context,
|
|
238
|
-
});
|
|
239
|
-
|
|
240
|
-
completeAgentInvocation(ctxDir, agentFile);
|
|
241
|
-
|
|
242
|
-
const output = result.stdout || '';
|
|
243
|
-
const passed = parseReviewVerdict(output);
|
|
244
|
-
const issues = passed ? null : extractIssues(output);
|
|
245
|
-
|
|
246
|
-
return { passed, issues, output: output.slice(0, 2000) };
|
|
247
|
-
} catch (err) {
|
|
248
|
-
completeAgentInvocation(ctxDir, agentFile);
|
|
249
|
-
return { passed: false, issues: `Agent error: ${err.message}`, output: '' };
|
|
250
|
-
}
|
|
251
|
-
}
|
|
252
|
-
|
|
253
|
-
function priorCodexThreadId(reviewState) {
|
|
254
|
-
const hist = reviewState?.history;
|
|
255
|
-
if (!Array.isArray(hist)) return null;
|
|
256
|
-
for (let i = hist.length - 1; i >= 0; i--) {
|
|
257
|
-
const tid = hist[i]?.stage3?.threadId;
|
|
258
|
-
if (tid) return tid;
|
|
259
|
-
}
|
|
260
|
-
return null;
|
|
261
|
-
}
|
|
262
|
-
|
|
263
|
-
function buildReviewPrompt(state, type, opts = {}) {
|
|
264
|
-
if (type === 'spec') {
|
|
265
|
-
return [
|
|
266
|
-
'Review the recent code changes for SPEC COMPLIANCE.',
|
|
267
|
-
'Check against the acceptance criteria for the current story.',
|
|
268
|
-
`Active story: ${state.activeStory || 'unknown'}`,
|
|
269
|
-
'',
|
|
270
|
-
'Output format:',
|
|
271
|
-
'VERDICT: PASS or FAIL',
|
|
272
|
-
'ISSUES: (list if FAIL)',
|
|
273
|
-
'',
|
|
274
|
-
'Be strict but fair. Focus on acceptance criteria satisfaction.',
|
|
275
|
-
].join('\n');
|
|
276
|
-
}
|
|
277
|
-
|
|
278
|
-
if (type === 'codex') {
|
|
279
|
-
const lines = [
|
|
280
|
-
'Stage 3 — cross-model review via OpenAI Codex.',
|
|
281
|
-
'Stages 1 (spec) and 2 (quality) already passed under Claude review.',
|
|
282
|
-
`Active story: ${state.activeStory || 'unknown'}`,
|
|
283
|
-
];
|
|
284
|
-
if (opts.priorThreadId) {
|
|
285
|
-
lines.push(`Prior Codex thread: ${opts.priorThreadId} — reuse via mcp__codex__codex-reply if context is still relevant.`);
|
|
286
|
-
}
|
|
287
|
-
lines.push(
|
|
288
|
-
'',
|
|
289
|
-
'Run your playbook and output VERDICT: PASS | FAIL | SKIP on the final line. Append `THREAD: <id>` if a new thread was opened.',
|
|
290
|
-
);
|
|
291
|
-
return lines.join('\n');
|
|
292
|
-
}
|
|
293
|
-
|
|
294
|
-
return [
|
|
295
|
-
'Review the recent code changes for CODE QUALITY.',
|
|
296
|
-
'Check: security vulnerabilities, performance issues, code style, error handling.',
|
|
297
|
-
'',
|
|
298
|
-
'Output format:',
|
|
299
|
-
'VERDICT: PASS or FAIL',
|
|
300
|
-
'ISSUES: (list if FAIL)',
|
|
301
|
-
'',
|
|
302
|
-
'Be strict on security. Be reasonable on style.',
|
|
303
|
-
].join('\n');
|
|
304
|
-
}
|
|
305
|
-
|
|
306
|
-
function parseReviewVerdict(output) {
|
|
307
|
-
if (!output) return false;
|
|
308
|
-
const lower = output.toLowerCase();
|
|
309
|
-
// Look for explicit PASS verdict
|
|
310
|
-
if (/verdict:\s*pass/i.test(output)) return true;
|
|
311
|
-
// Look for explicit FAIL verdict
|
|
312
|
-
if (/verdict:\s*fail/i.test(output)) return false;
|
|
313
|
-
// Heuristic: if no critical issues mentioned, assume pass
|
|
314
|
-
if (lower.includes('all criteria') && lower.includes('pass')) return true;
|
|
315
|
-
if (lower.includes('no issues found')) return true;
|
|
316
|
-
// Default: fail (conservative)
|
|
317
|
-
return false;
|
|
318
|
-
}
|
|
319
|
-
|
|
320
|
-
function extractIssues(output) {
|
|
321
|
-
if (!output) return 'No output from reviewer.';
|
|
322
|
-
// Try to extract issues section
|
|
323
|
-
const issueMatch = output.match(/ISSUES?:\s*([\s\S]*?)(?=\n\n|$)/i);
|
|
324
|
-
if (issueMatch) return issueMatch[1].trim().slice(0, 500);
|
|
325
|
-
// Fallback: first 300 chars
|
|
326
|
-
return output.slice(0, 300);
|
|
327
|
-
}
|
|
328
|
-
|
|
329
|
-
function saveReviewResult(ctxDir, storyId, reviewState) {
|
|
330
|
-
const reviewDir = path.join(ctxDir, 'reviews');
|
|
331
|
-
if (!fs.existsSync(reviewDir)) fs.mkdirSync(reviewDir, { recursive: true });
|
|
332
|
-
|
|
333
|
-
const filename = `${storyId || 'unknown'}-${new Date().toISOString().replace(/[:.]/g, '-')}.json`;
|
|
334
|
-
fs.writeFileSync(
|
|
335
|
-
path.join(reviewDir, filename),
|
|
336
|
-
JSON.stringify(reviewState, null, 2) + '\n'
|
|
337
|
-
);
|
|
338
|
-
}
|
package/src/runner.js
DELETED
|
@@ -1,120 +0,0 @@
|
|
|
1
|
-
import { spawn, execSync } from 'child_process';
|
|
2
|
-
import fs from 'fs';
|
|
3
|
-
import path from 'path';
|
|
4
|
-
|
|
5
|
-
const DEFAULT_TIMEOUT_MS = 300_000; // 5 minutes
|
|
6
|
-
|
|
7
|
-
/**
|
|
8
|
-
* Check if the claude CLI is available.
|
|
9
|
-
* Returns the path to the binary, or null.
|
|
10
|
-
*/
|
|
11
|
-
export function findClaudeBinary() {
|
|
12
|
-
try {
|
|
13
|
-
const result = execSync('which claude 2>/dev/null || where claude 2>/dev/null', {
|
|
14
|
-
encoding: 'utf-8',
|
|
15
|
-
timeout: 5000,
|
|
16
|
-
}).trim();
|
|
17
|
-
return result || null;
|
|
18
|
-
} catch {
|
|
19
|
-
return null;
|
|
20
|
-
}
|
|
21
|
-
}
|
|
22
|
-
|
|
23
|
-
/**
|
|
24
|
-
* Run an agent by reading its markdown file and invoking claude.
|
|
25
|
-
*
|
|
26
|
-
* Options:
|
|
27
|
-
* agentPath - absolute path to the agent .md file
|
|
28
|
-
* message - user message to pass to the agent
|
|
29
|
-
* streaming - stream output to stdout (default true)
|
|
30
|
-
* timeout - timeout in ms (default 300000)
|
|
31
|
-
* context - optional project context string to prepend
|
|
32
|
-
* onStderr - callback for stderr lines
|
|
33
|
-
*
|
|
34
|
-
* Returns { exitCode, stdout, stderr } (stdout only populated when !streaming)
|
|
35
|
-
*/
|
|
36
|
-
export function runAgent({ agentPath, message, streaming = true, timeout = DEFAULT_TIMEOUT_MS, context = '', onStderr = null }) {
|
|
37
|
-
return new Promise((resolve, reject) => {
|
|
38
|
-
// Validate agent file exists
|
|
39
|
-
if (!fs.existsSync(agentPath)) {
|
|
40
|
-
reject(new Error(`Agent file not found: ${agentPath}`));
|
|
41
|
-
return;
|
|
42
|
-
}
|
|
43
|
-
|
|
44
|
-
// Check claude binary
|
|
45
|
-
const claudeBin = findClaudeBinary();
|
|
46
|
-
if (!claudeBin) {
|
|
47
|
-
reject(new Error(
|
|
48
|
-
'Claude Code CLI not found. Install from https://claude.ai/download\n' +
|
|
49
|
-
'Then ensure "claude" is available in your PATH.'
|
|
50
|
-
));
|
|
51
|
-
return;
|
|
52
|
-
}
|
|
53
|
-
|
|
54
|
-
const instructions = fs.readFileSync(agentPath, 'utf-8');
|
|
55
|
-
const fullPrompt = context
|
|
56
|
-
? `${instructions}\n\n---\nProject Context:\n${context}\n\n---\nUser Request: ${message}`
|
|
57
|
-
: `${instructions}\n\n---\nUser Request: ${message}`;
|
|
58
|
-
|
|
59
|
-
const args = ['--print', '-p', fullPrompt];
|
|
60
|
-
if (streaming) args.unshift('--stream');
|
|
61
|
-
|
|
62
|
-
const child = spawn(claudeBin, args, {
|
|
63
|
-
stdio: streaming ? ['ignore', 'inherit', 'pipe'] : ['ignore', 'pipe', 'pipe'],
|
|
64
|
-
env: { ...process.env },
|
|
65
|
-
});
|
|
66
|
-
|
|
67
|
-
let stdout = '';
|
|
68
|
-
let stderr = '';
|
|
69
|
-
|
|
70
|
-
if (!streaming && child.stdout) {
|
|
71
|
-
child.stdout.on('data', (chunk) => { stdout += chunk.toString(); });
|
|
72
|
-
}
|
|
73
|
-
|
|
74
|
-
if (child.stderr) {
|
|
75
|
-
child.stderr.on('data', (chunk) => {
|
|
76
|
-
const text = chunk.toString();
|
|
77
|
-
stderr += text;
|
|
78
|
-
if (onStderr) onStderr(text);
|
|
79
|
-
});
|
|
80
|
-
}
|
|
81
|
-
|
|
82
|
-
// Timeout handling
|
|
83
|
-
const timer = setTimeout(() => {
|
|
84
|
-
child.kill('SIGTERM');
|
|
85
|
-
// Give 5s for graceful shutdown, then force kill
|
|
86
|
-
setTimeout(() => {
|
|
87
|
-
if (!child.killed) child.kill('SIGKILL');
|
|
88
|
-
}, 5000);
|
|
89
|
-
reject(new Error(`Agent timed out after ${timeout / 1000}s. Increase timeout with: ctx config set agentTimeout ${timeout / 1000 * 2}`));
|
|
90
|
-
}, timeout);
|
|
91
|
-
|
|
92
|
-
// Forward SIGINT to child
|
|
93
|
-
const sigintHandler = () => {
|
|
94
|
-
child.kill('SIGTERM');
|
|
95
|
-
clearTimeout(timer);
|
|
96
|
-
};
|
|
97
|
-
process.on('SIGINT', sigintHandler);
|
|
98
|
-
|
|
99
|
-
child.on('close', (code) => {
|
|
100
|
-
clearTimeout(timer);
|
|
101
|
-
process.removeListener('SIGINT', sigintHandler);
|
|
102
|
-
|
|
103
|
-
if (code !== 0 && code !== null) {
|
|
104
|
-
const errMsg = stderr.trim();
|
|
105
|
-
reject(new Error(
|
|
106
|
-
`Agent exited with code ${code}${errMsg ? `\n[stderr] ${errMsg}` : ''}`
|
|
107
|
-
));
|
|
108
|
-
return;
|
|
109
|
-
}
|
|
110
|
-
|
|
111
|
-
resolve({ exitCode: code ?? 0, stdout, stderr });
|
|
112
|
-
});
|
|
113
|
-
|
|
114
|
-
child.on('error', (err) => {
|
|
115
|
-
clearTimeout(timer);
|
|
116
|
-
process.removeListener('SIGINT', sigintHandler);
|
|
117
|
-
reject(new Error(`Failed to spawn claude: ${err.message}`));
|
|
118
|
-
});
|
|
119
|
-
});
|
|
120
|
-
}
|