chainlesschain 0.45.4 → 0.45.6

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.
@@ -0,0 +1,353 @@
1
+ /**
2
+ * Claude Code Bridge — spawns Claude Code CLI processes as execution agents.
3
+ *
4
+ * ChainlessChain (orchestrator) dispatches coding tasks to one or more
5
+ * `claude` CLI sub-processes that run non-interactively, capturing their
6
+ * output and returning structured results.
7
+ *
8
+ * Usage:
9
+ * const pool = new ClaudeCodePool({ maxParallel: 3 });
10
+ * const results = await pool.dispatch([
11
+ * { id: "t1", description: "Fix the null check in auth.js" },
12
+ * { id: "t2", description: "Add unit tests for login flow" },
13
+ * ], { cwd: "/path/to/project" });
14
+ */
15
+
16
+ import { spawn, execSync } from "child_process";
17
+ import { EventEmitter } from "events";
18
+
19
+ /* ---------- _deps injection (Vitest CJS mock pattern) ---------- */
20
+ export const _deps = { spawn, execSync };
21
+
22
+ // ─── Agent status constants ───────────────────────────────────────
23
+
24
+ export const AGENT_STATUS = {
25
+ IDLE: "idle",
26
+ RUNNING: "running",
27
+ COMPLETED: "completed",
28
+ FAILED: "failed",
29
+ TIMEOUT: "timeout",
30
+ };
31
+
32
+ // ─── Detection ───────────────────────────────────────────────────
33
+
34
+ /**
35
+ * Check if the `claude` CLI is installed and return version info.
36
+ * Returns { found: boolean, version?: string, path?: string }.
37
+ */
38
+ export function detectClaudeCode() {
39
+ try {
40
+ const version = _deps
41
+ .execSync("claude --version", { encoding: "utf-8", timeout: 5000 })
42
+ .trim();
43
+ return { found: true, version };
44
+ } catch (_err) {
45
+ return { found: false };
46
+ }
47
+ }
48
+
49
+ /**
50
+ * Check if the `codex` CLI (GitHub Copilot Coding Agent) is installed.
51
+ * Returns { found: boolean, version?: string }.
52
+ */
53
+ export function detectCodex() {
54
+ try {
55
+ const version = _deps
56
+ .execSync("codex --version", { encoding: "utf-8", timeout: 5000 })
57
+ .trim();
58
+ return { found: true, version };
59
+ } catch (_err) {
60
+ return { found: false };
61
+ }
62
+ }
63
+
64
+ // ─── Single Agent ─────────────────────────────────────────────────
65
+
66
+ /**
67
+ * A single Claude Code CLI execution agent.
68
+ * Wraps `claude -p "<task>" --output-format stream-json` as a child process.
69
+ */
70
+ export class ClaudeCodeAgent extends EventEmitter {
71
+ constructor(options = {}) {
72
+ super();
73
+ this.id =
74
+ options.id ||
75
+ `cc-agent-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`;
76
+ this.cliCommand = options.cliCommand || "claude"; // "claude" or "codex"
77
+ this.model = options.model || null;
78
+ this.status = AGENT_STATUS.IDLE;
79
+ this.currentTask = null;
80
+ this._proc = null;
81
+ }
82
+
83
+ /**
84
+ * Execute a coding task non-interactively.
85
+ *
86
+ * @param {string} taskDescription - Natural language task for Claude Code
87
+ * @param {object} options
88
+ * @param {string} options.cwd - Project root directory
89
+ * @param {number} options.timeout - Max ms to wait (default 300_000 = 5 min)
90
+ * @param {string} options.context - Extra context prepended to task
91
+ * @param {string} options.allowedTools - Comma-separated tool allow-list
92
+ * @returns {Promise<{success, output, exitCode, duration, taskId}>}
93
+ */
94
+ async executeTask(taskDescription, options = {}) {
95
+ const {
96
+ cwd = process.cwd(),
97
+ timeout = 300_000,
98
+ context = "",
99
+ allowedTools = null,
100
+ } = options;
101
+
102
+ const fullPrompt = context
103
+ ? `Context:\n${context}\n\nTask:\n${taskDescription}`
104
+ : taskDescription;
105
+
106
+ this.status = AGENT_STATUS.RUNNING;
107
+ this.currentTask = taskDescription;
108
+ this.emit("task:start", { agentId: this.id, task: taskDescription });
109
+
110
+ const args = ["-p", fullPrompt, "--output-format", "stream-json"];
111
+ if (this.model) {
112
+ args.push("--model", this.model);
113
+ }
114
+ if (allowedTools) {
115
+ args.push("--allowedTools", allowedTools);
116
+ }
117
+
118
+ return new Promise((resolve) => {
119
+ const startTime = Date.now();
120
+ const outputChunks = [];
121
+ const errorChunks = [];
122
+ let timedOut = false;
123
+
124
+ const proc = _deps.spawn(this.cliCommand, args, {
125
+ cwd,
126
+ env: { ...process.env },
127
+ windowsHide: true,
128
+ });
129
+ this._proc = proc;
130
+
131
+ const timer = setTimeout(() => {
132
+ timedOut = true;
133
+ proc.kill("SIGTERM");
134
+ setTimeout(() => proc.kill("SIGKILL"), 3000);
135
+ }, timeout);
136
+
137
+ proc.stdout.on("data", (data) => {
138
+ const chunk = data.toString("utf8");
139
+ outputChunks.push(chunk);
140
+ this.emit("output", { agentId: this.id, chunk });
141
+ });
142
+
143
+ proc.stderr.on("data", (data) => {
144
+ errorChunks.push(data.toString("utf8"));
145
+ });
146
+
147
+ proc.on("close", (code) => {
148
+ clearTimeout(timer);
149
+ this._proc = null;
150
+ const duration = Date.now() - startTime;
151
+ const rawOutput = outputChunks.join("");
152
+ const status = timedOut
153
+ ? AGENT_STATUS.TIMEOUT
154
+ : code === 0
155
+ ? AGENT_STATUS.COMPLETED
156
+ : AGENT_STATUS.FAILED;
157
+
158
+ this.status = status;
159
+ this.currentTask = null;
160
+
161
+ // Parse stream-json output: last assistant message is the result
162
+ const parsedOutput = _parseStreamJson(rawOutput);
163
+
164
+ const result = {
165
+ success: code === 0 && !timedOut,
166
+ output: parsedOutput || rawOutput.slice(-4000), // last 4K chars fallback
167
+ rawOutput,
168
+ exitCode: code,
169
+ duration,
170
+ timedOut,
171
+ agentId: this.id,
172
+ stderr: errorChunks.join("").slice(-2000),
173
+ };
174
+
175
+ this.emit("task:complete", result);
176
+ resolve(result);
177
+ });
178
+
179
+ proc.on("error", (err) => {
180
+ clearTimeout(timer);
181
+ this._proc = null;
182
+ this.status = AGENT_STATUS.FAILED;
183
+ this.currentTask = null;
184
+ const result = {
185
+ success: false,
186
+ output: "",
187
+ exitCode: -1,
188
+ duration: Date.now() - startTime,
189
+ timedOut: false,
190
+ agentId: this.id,
191
+ error: err.message,
192
+ };
193
+ this.emit("task:complete", result);
194
+ resolve(result);
195
+ });
196
+ });
197
+ }
198
+
199
+ /** Abort the currently running task. */
200
+ abort() {
201
+ if (this._proc) {
202
+ this._proc.kill("SIGTERM");
203
+ }
204
+ }
205
+
206
+ toJSON() {
207
+ return {
208
+ id: this.id,
209
+ status: this.status,
210
+ cliCommand: this.cliCommand,
211
+ currentTask: this.currentTask,
212
+ };
213
+ }
214
+ }
215
+
216
+ // ─── Agent Pool ───────────────────────────────────────────────────
217
+
218
+ /**
219
+ * Manages a pool of Claude Code agents for parallel task dispatch.
220
+ */
221
+ export class ClaudeCodePool extends EventEmitter {
222
+ /**
223
+ * @param {object} options
224
+ * @param {number} options.maxParallel - Max concurrent agents (default 3)
225
+ * @param {string} options.cliCommand - CLI to use: "claude" or "codex"
226
+ * @param {string} options.model - Model override
227
+ * @param {number} options.agentTimeout - Per-agent timeout ms
228
+ */
229
+ constructor(options = {}) {
230
+ super();
231
+ this.maxParallel = options.maxParallel || 3;
232
+ this.cliCommand = options.cliCommand || "claude";
233
+ this.model = options.model || null;
234
+ this.agentTimeout = options.agentTimeout || 300_000;
235
+
236
+ /** @type {Map<string, ClaudeCodeAgent>} */
237
+ this._agents = new Map();
238
+ this._completed = [];
239
+ }
240
+
241
+ /**
242
+ * Dispatch an array of tasks to agents in parallel batches.
243
+ *
244
+ * @param {Array<{id, description, context?, allowedTools?}>} tasks
245
+ * @param {object} options
246
+ * @param {string} options.cwd - Shared working directory for all tasks
247
+ * @returns {Promise<Array<{taskId, agentId, success, output, duration}>>}
248
+ */
249
+ async dispatch(tasks, options = {}) {
250
+ const { cwd = process.cwd() } = options;
251
+ const results = [];
252
+
253
+ // Process in batches of maxParallel
254
+ for (let i = 0; i < tasks.length; i += this.maxParallel) {
255
+ const batch = tasks.slice(i, i + this.maxParallel);
256
+ this.emit("batch:start", {
257
+ batchIndex: i / this.maxParallel,
258
+ count: batch.length,
259
+ });
260
+
261
+ const batchResults = await Promise.all(
262
+ batch.map((task) => this._runTask(task, { cwd })),
263
+ );
264
+
265
+ results.push(...batchResults);
266
+ this.emit("batch:complete", {
267
+ batchIndex: i / this.maxParallel,
268
+ results: batchResults,
269
+ });
270
+ }
271
+
272
+ return results;
273
+ }
274
+
275
+ async _runTask(task, { cwd }) {
276
+ const agent = new ClaudeCodeAgent({
277
+ id: `agent-${task.id}`,
278
+ cliCommand: this.cliCommand,
279
+ model: this.model,
280
+ });
281
+
282
+ this._agents.set(agent.id, agent);
283
+ agent.on("output", (ev) => this.emit("agent:output", ev));
284
+
285
+ const result = await agent.executeTask(task.description, {
286
+ cwd,
287
+ timeout: this.agentTimeout,
288
+ context: task.context || "",
289
+ allowedTools: task.allowedTools || null,
290
+ });
291
+
292
+ this._agents.delete(agent.id);
293
+ this._completed.push({ ...result, taskId: task.id });
294
+
295
+ this.emit("agent:complete", { taskId: task.id, ...result });
296
+ return { taskId: task.id, ...result };
297
+ }
298
+
299
+ /** Current pool status snapshot. */
300
+ status() {
301
+ const active = [...this._agents.values()].map((a) => a.toJSON());
302
+ return {
303
+ active,
304
+ activeCount: active.length,
305
+ maxParallel: this.maxParallel,
306
+ cliCommand: this.cliCommand,
307
+ };
308
+ }
309
+
310
+ /** Abort all running agents. */
311
+ abortAll() {
312
+ for (const agent of this._agents.values()) {
313
+ agent.abort();
314
+ }
315
+ }
316
+ }
317
+
318
+ // ─── Helpers ──────────────────────────────────────────────────────
319
+
320
+ /**
321
+ * Parse Claude Code stream-json output and extract the last assistant text.
322
+ * Stream-json lines look like: {"type":"assistant","message":{...}}
323
+ */
324
+ function _parseStreamJson(raw) {
325
+ if (!raw) return "";
326
+ const lines = raw.split("\n").filter(Boolean);
327
+ let lastText = "";
328
+
329
+ for (const line of lines) {
330
+ try {
331
+ const obj = JSON.parse(line);
332
+ // stream-json: result message has type "result"
333
+ if (obj.type === "result" && obj.result) {
334
+ return typeof obj.result === "string"
335
+ ? obj.result
336
+ : JSON.stringify(obj.result);
337
+ }
338
+ // assistant message blocks
339
+ if (obj.type === "assistant" && obj.message?.content) {
340
+ const blocks = obj.message.content;
341
+ const textBlocks = blocks
342
+ .filter((b) => b.type === "text")
343
+ .map((b) => b.text)
344
+ .join("\n");
345
+ if (textBlocks) lastText = textBlocks;
346
+ }
347
+ } catch (_err) {
348
+ // Non-JSON line (progress text) — ignore
349
+ }
350
+ }
351
+
352
+ return lastText;
353
+ }
@@ -0,0 +1,99 @@
1
+ /**
2
+ * DingTalk (钉钉) Notifier — sends notifications via 钉钉群机器人 Webhook.
3
+ *
4
+ * Configure via env:
5
+ * DINGTALK_WEBHOOK_URL=https://oapi.dingtalk.com/robot/send?access_token=xxx
6
+ * DINGTALK_SECRET=SECxxx (optional, if "加签" is enabled)
7
+ *
8
+ * If DINGTALK_SECRET is set, each request is signed with HMAC-SHA256.
9
+ */
10
+
11
+ import crypto from "crypto";
12
+
13
+ export class DingTalkNotifier {
14
+ constructor(options = {}) {
15
+ this.webhookUrl =
16
+ options.webhookUrl || process.env.DINGTALK_WEBHOOK_URL || "";
17
+ this.secret = options.secret || process.env.DINGTALK_SECRET || "";
18
+ }
19
+
20
+ get isConfigured() {
21
+ return Boolean(this.webhookUrl);
22
+ }
23
+
24
+ /** Compute timestamp + sign query params (required when 加签 is on). */
25
+ _signedUrl() {
26
+ if (!this.secret) return this.webhookUrl;
27
+ const timestamp = Date.now();
28
+ const stringToSign = `${timestamp}\n${this.secret}`;
29
+ const sign = crypto
30
+ .createHmac("sha256", this.secret)
31
+ .update(stringToSign)
32
+ .digest("base64");
33
+ const encodedSign = encodeURIComponent(sign);
34
+ const sep = this.webhookUrl.includes("?") ? "&" : "?";
35
+ return `${this.webhookUrl}${sep}timestamp=${timestamp}&sign=${encodedSign}`;
36
+ }
37
+
38
+ /**
39
+ * Send a markdown card message.
40
+ * @param {string} title
41
+ * @param {string} text - Markdown body
42
+ */
43
+ async send(title, text) {
44
+ if (!this.isConfigured) return { ok: false, reason: "not configured" };
45
+ try {
46
+ const res = await fetch(this._signedUrl(), {
47
+ method: "POST",
48
+ headers: { "Content-Type": "application/json" },
49
+ body: JSON.stringify({
50
+ msgtype: "markdown",
51
+ markdown: { title, text },
52
+ at: { isAtAll: false },
53
+ }),
54
+ });
55
+ const data = await res.json();
56
+ return { ok: data.errcode === 0, data };
57
+ } catch (err) {
58
+ return { ok: false, reason: err.message };
59
+ }
60
+ }
61
+
62
+ async notifySuccess(summary) {
63
+ const { taskId, description, agentCount = 1, duration } = summary;
64
+ const mins = duration ? Math.round(duration / 60_000) : "?";
65
+ return this.send(
66
+ "✅ CI 通过",
67
+ `## ✅ CI 通过\n` +
68
+ `**任务**: ${description.slice(0, 100)}\n` +
69
+ `**Agent**: ${agentCount} 个 **耗时**: ${mins}m\n` +
70
+ `**ID**: ${taskId}`,
71
+ );
72
+ }
73
+
74
+ async notifyFailure(summary) {
75
+ const { taskId, description, errors = [], retryNumber = 1 } = summary;
76
+ const errLines = errors
77
+ .slice(0, 3)
78
+ .map((e) => `- ${e.slice(0, 120)}`)
79
+ .join("\n");
80
+ return this.send(
81
+ `❌ CI 失败 (重试 #${retryNumber})`,
82
+ `## ❌ CI 失败\n` +
83
+ `**任务**: ${description.slice(0, 100)}\n` +
84
+ (errLines ? `**错误**:\n${errLines}\n` : "") +
85
+ `**ID**: ${taskId}`,
86
+ );
87
+ }
88
+
89
+ async notifyStart(summary) {
90
+ const { taskId, description, subtaskCount = 1 } = summary;
91
+ return this.send(
92
+ "🚀 开始编排",
93
+ `## 🚀 开始编排\n` +
94
+ `**任务**: ${description.slice(0, 100)}\n` +
95
+ `**子任务数**: ${subtaskCount}\n` +
96
+ `**ID**: ${taskId}`,
97
+ );
98
+ }
99
+ }
@@ -0,0 +1,112 @@
1
+ /**
2
+ * Feishu (飞书) Notifier — sends notifications via 飞书群机器人 Webhook.
3
+ *
4
+ * Configure via env:
5
+ * FEISHU_WEBHOOK_URL=https://open.feishu.cn/open-apis/bot/v2/hook/xxx
6
+ * FEISHU_SECRET=xxx (optional, if 签名校验 is enabled)
7
+ *
8
+ * Sends rich "interactive card" messages for good formatting.
9
+ */
10
+
11
+ import crypto from "crypto";
12
+
13
+ export class FeishuNotifier {
14
+ constructor(options = {}) {
15
+ this.webhookUrl =
16
+ options.webhookUrl || process.env.FEISHU_WEBHOOK_URL || "";
17
+ this.secret = options.secret || process.env.FEISHU_SECRET || "";
18
+ }
19
+
20
+ get isConfigured() {
21
+ return Boolean(this.webhookUrl);
22
+ }
23
+
24
+ /** Compute sign for 签名校验 mode. */
25
+ _sign(timestamp) {
26
+ if (!this.secret) return undefined;
27
+ const str = `${timestamp}\n${this.secret}`;
28
+ return crypto.createHmac("sha256", str).update("").digest("base64");
29
+ }
30
+
31
+ /**
32
+ * Send an interactive card message.
33
+ * @param {string} header - Card header title
34
+ * @param {string[]} lines - Content lines (markdown-ish)
35
+ * @param {"green"|"red"|"blue"|"yellow"} color - Header color
36
+ */
37
+ async send(header, lines, color = "blue") {
38
+ if (!this.isConfigured) return { ok: false, reason: "not configured" };
39
+
40
+ const timestamp = String(Math.floor(Date.now() / 1000));
41
+ const sign = this._sign(timestamp);
42
+
43
+ const payload = {
44
+ timestamp,
45
+ ...(sign ? { sign } : {}),
46
+ msg_type: "interactive",
47
+ card: {
48
+ header: {
49
+ title: { tag: "plain_text", content: header },
50
+ template: color,
51
+ },
52
+ elements: lines.map((line) => ({
53
+ tag: "div",
54
+ text: { tag: "lark_md", content: line },
55
+ })),
56
+ },
57
+ };
58
+
59
+ try {
60
+ const res = await fetch(this.webhookUrl, {
61
+ method: "POST",
62
+ headers: { "Content-Type": "application/json" },
63
+ body: JSON.stringify(payload),
64
+ });
65
+ const data = await res.json();
66
+ return { ok: data.code === 0 || data.StatusCode === 0, data };
67
+ } catch (err) {
68
+ return { ok: false, reason: err.message };
69
+ }
70
+ }
71
+
72
+ async notifySuccess(summary) {
73
+ const { taskId, description, agentCount = 1, duration } = summary;
74
+ const mins = duration ? Math.round(duration / 60_000) : "?";
75
+ return this.send(
76
+ "✅ CI 通过",
77
+ [
78
+ `**任务**: ${description.slice(0, 100)}`,
79
+ `**Agent 数**: ${agentCount} **耗时**: ${mins}m`,
80
+ `**ID**: \`${taskId}\``,
81
+ ],
82
+ "green",
83
+ );
84
+ }
85
+
86
+ async notifyFailure(summary) {
87
+ const { taskId, description, errors = [], retryNumber = 1 } = summary;
88
+ const errLines = errors.slice(0, 3).map((e) => `- ${e.slice(0, 120)}`);
89
+ return this.send(
90
+ `❌ CI 失败(第 ${retryNumber} 次重试)`,
91
+ [
92
+ `**任务**: ${description.slice(0, 100)}`,
93
+ errors.length ? `**错误**:\n${errLines.join("\n")}` : "",
94
+ `**ID**: \`${taskId}\``,
95
+ ].filter(Boolean),
96
+ "red",
97
+ );
98
+ }
99
+
100
+ async notifyStart(summary) {
101
+ const { taskId, description, subtaskCount = 1 } = summary;
102
+ return this.send(
103
+ "🚀 开始编排",
104
+ [
105
+ `**任务**: ${description.slice(0, 100)}`,
106
+ `**子任务数**: ${subtaskCount}`,
107
+ `**ID**: \`${taskId}\``,
108
+ ],
109
+ "blue",
110
+ );
111
+ }
112
+ }