helloloop 0.6.1 → 0.7.1
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/.claude-plugin/plugin.json +1 -1
- package/.codex-plugin/plugin.json +4 -4
- package/README.md +49 -11
- package/hosts/claude/marketplace/plugins/helloloop/.claude-plugin/plugin.json +1 -1
- package/hosts/claude/marketplace/plugins/helloloop/commands/helloloop.md +5 -4
- package/hosts/claude/marketplace/plugins/helloloop/skills/helloloop/SKILL.md +4 -2
- package/hosts/gemini/extension/GEMINI.md +3 -1
- package/hosts/gemini/extension/commands/helloloop.toml +5 -4
- package/hosts/gemini/extension/gemini-extension.json +1 -1
- package/package.json +1 -1
- package/skills/helloloop/SKILL.md +5 -3
- package/src/config.mjs +9 -8
- package/src/discovery.mjs +21 -2
- package/src/discovery_paths.mjs +65 -1
- package/src/email_notification.mjs +343 -0
- package/src/engine_process_support.mjs +294 -0
- package/src/engine_selection_settings.mjs +75 -9
- package/src/global_config.mjs +21 -0
- package/src/install_shared.mjs +50 -2
- package/src/process.mjs +452 -428
- package/src/runner_execute_task.mjs +20 -66
- package/src/runner_execution_support.mjs +0 -9
- package/src/runtime_recovery.mjs +61 -60
- package/templates/policy.template.json +3 -5
|
@@ -0,0 +1,343 @@
|
|
|
1
|
+
import net from "node:net";
|
|
2
|
+
import tls from "node:tls";
|
|
3
|
+
|
|
4
|
+
import { tailText } from "./common.mjs";
|
|
5
|
+
|
|
6
|
+
function resolveSecret(configValue = "", envKey = "") {
|
|
7
|
+
if (String(configValue || "").trim()) {
|
|
8
|
+
return String(configValue || "").trim();
|
|
9
|
+
}
|
|
10
|
+
if (String(envKey || "").trim()) {
|
|
11
|
+
return String(process.env[String(envKey).trim()] || "").trim();
|
|
12
|
+
}
|
|
13
|
+
return "";
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function dotStuff(text = "") {
|
|
17
|
+
return String(text || "")
|
|
18
|
+
.replaceAll("\r\n", "\n")
|
|
19
|
+
.split("\n")
|
|
20
|
+
.map((line) => (line.startsWith(".") ? `.${line}` : line))
|
|
21
|
+
.join("\r\n");
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function formatAddressList(items = []) {
|
|
25
|
+
return items.join(", ");
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function buildMessage({ from, to, subject, text }) {
|
|
29
|
+
return [
|
|
30
|
+
`From: ${from}`,
|
|
31
|
+
`To: ${formatAddressList(to)}`,
|
|
32
|
+
`Subject: ${subject}`,
|
|
33
|
+
"MIME-Version: 1.0",
|
|
34
|
+
"Content-Type: text/plain; charset=utf-8",
|
|
35
|
+
"Content-Transfer-Encoding: 8bit",
|
|
36
|
+
"",
|
|
37
|
+
dotStuff(text),
|
|
38
|
+
].join("\r\n");
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function createLineReader(socket) {
|
|
42
|
+
let buffer = "";
|
|
43
|
+
const lines = [];
|
|
44
|
+
const waiters = [];
|
|
45
|
+
|
|
46
|
+
const flush = () => {
|
|
47
|
+
while (true) {
|
|
48
|
+
const separatorIndex = buffer.indexOf("\n");
|
|
49
|
+
if (separatorIndex < 0) {
|
|
50
|
+
return;
|
|
51
|
+
}
|
|
52
|
+
const line = buffer.slice(0, separatorIndex + 1).replace(/\r?\n$/, "");
|
|
53
|
+
buffer = buffer.slice(separatorIndex + 1);
|
|
54
|
+
if (waiters.length) {
|
|
55
|
+
waiters.shift()?.resolve(line);
|
|
56
|
+
} else {
|
|
57
|
+
lines.push(line);
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
socket.setEncoding("utf8");
|
|
63
|
+
socket.on("data", (chunk) => {
|
|
64
|
+
buffer += chunk;
|
|
65
|
+
flush();
|
|
66
|
+
});
|
|
67
|
+
socket.on("error", (error) => {
|
|
68
|
+
while (waiters.length) {
|
|
69
|
+
waiters.shift()?.reject(error);
|
|
70
|
+
}
|
|
71
|
+
});
|
|
72
|
+
socket.on("close", () => {
|
|
73
|
+
const error = new Error("SMTP 连接已关闭。");
|
|
74
|
+
while (waiters.length) {
|
|
75
|
+
waiters.shift()?.reject(error);
|
|
76
|
+
}
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
return async function readLine() {
|
|
80
|
+
if (lines.length) {
|
|
81
|
+
return lines.shift();
|
|
82
|
+
}
|
|
83
|
+
return new Promise((resolve, reject) => {
|
|
84
|
+
waiters.push({ resolve, reject });
|
|
85
|
+
});
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
async function readResponse(readLine) {
|
|
90
|
+
const firstLine = await readLine();
|
|
91
|
+
const lines = [firstLine];
|
|
92
|
+
while (/^\d{3}-/.test(lines.at(-1) || "")) {
|
|
93
|
+
lines.push(await readLine());
|
|
94
|
+
}
|
|
95
|
+
const lastLine = lines.at(-1) || "";
|
|
96
|
+
const code = Number(lastLine.slice(0, 3));
|
|
97
|
+
return {
|
|
98
|
+
code,
|
|
99
|
+
message: lines.join("\n"),
|
|
100
|
+
lines,
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
async function expectResponse(readLine, expectedCodes) {
|
|
105
|
+
const response = await readResponse(readLine);
|
|
106
|
+
if (!expectedCodes.includes(response.code)) {
|
|
107
|
+
throw new Error(`SMTP 返回异常:${response.message}`);
|
|
108
|
+
}
|
|
109
|
+
return response;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function writeCommand(socket, command) {
|
|
113
|
+
socket.write(`${command}\r\n`);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function createSocket(host, port, options = {}) {
|
|
117
|
+
const timeoutMs = Math.max(1000, Number(options.timeoutSeconds || 30) * 1000);
|
|
118
|
+
const commonOptions = {
|
|
119
|
+
host,
|
|
120
|
+
port,
|
|
121
|
+
rejectUnauthorized: options.rejectUnauthorized !== false,
|
|
122
|
+
};
|
|
123
|
+
|
|
124
|
+
return new Promise((resolve, reject) => {
|
|
125
|
+
const socket = options.secure
|
|
126
|
+
? tls.connect(commonOptions, () => resolve(socket))
|
|
127
|
+
: net.createConnection({ host, port }, () => resolve(socket));
|
|
128
|
+
socket.setTimeout(timeoutMs);
|
|
129
|
+
socket.on("timeout", () => {
|
|
130
|
+
socket.destroy(new Error("SMTP 连接超时。"));
|
|
131
|
+
});
|
|
132
|
+
socket.on("error", reject);
|
|
133
|
+
});
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
async function upgradeToStartTls(socket, host, options = {}) {
|
|
137
|
+
const timeoutMs = Math.max(1000, Number(options.timeoutSeconds || 30) * 1000);
|
|
138
|
+
return new Promise((resolve, reject) => {
|
|
139
|
+
const tlsSocket = tls.connect({
|
|
140
|
+
socket,
|
|
141
|
+
servername: host,
|
|
142
|
+
rejectUnauthorized: options.rejectUnauthorized !== false,
|
|
143
|
+
}, () => resolve(tlsSocket));
|
|
144
|
+
tlsSocket.setTimeout(timeoutMs);
|
|
145
|
+
tlsSocket.on("timeout", () => {
|
|
146
|
+
tlsSocket.destroy(new Error("SMTP STARTTLS 升级超时。"));
|
|
147
|
+
});
|
|
148
|
+
tlsSocket.on("error", reject);
|
|
149
|
+
});
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
async function authenticateIfNeeded(socket, readLine, smtp, capabilities = "") {
|
|
153
|
+
const username = resolveSecret(smtp.username, smtp.usernameEnv);
|
|
154
|
+
const password = resolveSecret(smtp.password, smtp.passwordEnv);
|
|
155
|
+
if (!username && !password) {
|
|
156
|
+
return;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
const supported = String(capabilities || "").toUpperCase();
|
|
160
|
+
if (supported.includes("AUTH LOGIN")) {
|
|
161
|
+
writeCommand(socket, "AUTH LOGIN");
|
|
162
|
+
await expectResponse(readLine, [334]);
|
|
163
|
+
writeCommand(socket, Buffer.from(username, "utf8").toString("base64"));
|
|
164
|
+
await expectResponse(readLine, [334]);
|
|
165
|
+
writeCommand(socket, Buffer.from(password, "utf8").toString("base64"));
|
|
166
|
+
await expectResponse(readLine, [235]);
|
|
167
|
+
return;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
const payload = Buffer.from(`\u0000${username}\u0000${password}`, "utf8").toString("base64");
|
|
171
|
+
writeCommand(socket, `AUTH PLAIN ${payload}`);
|
|
172
|
+
await expectResponse(readLine, [235]);
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
async function connectAndSend({ smtp, from, to, subject, text }) {
|
|
176
|
+
let socket = await createSocket(smtp.host, smtp.port, smtp);
|
|
177
|
+
let readLine = createLineReader(socket);
|
|
178
|
+
|
|
179
|
+
try {
|
|
180
|
+
await expectResponse(readLine, [220]);
|
|
181
|
+
writeCommand(socket, "EHLO helloloop.local");
|
|
182
|
+
let hello = await expectResponse(readLine, [250]);
|
|
183
|
+
|
|
184
|
+
if (!smtp.secure && smtp.starttls) {
|
|
185
|
+
writeCommand(socket, "STARTTLS");
|
|
186
|
+
await expectResponse(readLine, [220]);
|
|
187
|
+
socket = await upgradeToStartTls(socket, smtp.host, smtp);
|
|
188
|
+
readLine = createLineReader(socket);
|
|
189
|
+
writeCommand(socket, "EHLO helloloop.local");
|
|
190
|
+
hello = await expectResponse(readLine, [250]);
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
await authenticateIfNeeded(socket, readLine, smtp, hello.message);
|
|
194
|
+
writeCommand(socket, `MAIL FROM:<${from}>`);
|
|
195
|
+
await expectResponse(readLine, [250]);
|
|
196
|
+
for (const recipient of to) {
|
|
197
|
+
writeCommand(socket, `RCPT TO:<${recipient}>`);
|
|
198
|
+
await expectResponse(readLine, [250, 251]);
|
|
199
|
+
}
|
|
200
|
+
writeCommand(socket, "DATA");
|
|
201
|
+
await expectResponse(readLine, [354]);
|
|
202
|
+
socket.write(`${buildMessage({ from, to, subject, text })}\r\n.\r\n`);
|
|
203
|
+
await expectResponse(readLine, [250]);
|
|
204
|
+
writeCommand(socket, "QUIT");
|
|
205
|
+
await expectResponse(readLine, [221]);
|
|
206
|
+
} finally {
|
|
207
|
+
socket.end();
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
function buildNotificationBody({
|
|
212
|
+
context,
|
|
213
|
+
engine,
|
|
214
|
+
phase,
|
|
215
|
+
failure,
|
|
216
|
+
result,
|
|
217
|
+
recoveryHistory,
|
|
218
|
+
runDir,
|
|
219
|
+
}) {
|
|
220
|
+
const lines = [
|
|
221
|
+
"HelloLoop 已暂停本轮自动恢复,等待人工介入。",
|
|
222
|
+
"",
|
|
223
|
+
`仓库:${context.repoRoot}`,
|
|
224
|
+
`运行目录:${runDir}`,
|
|
225
|
+
`执行引擎:${engine}`,
|
|
226
|
+
`阶段:${phase}`,
|
|
227
|
+
`错误分类:${failure?.family || "unknown"}`,
|
|
228
|
+
`错误代码:${failure?.code || "unknown_failure"}`,
|
|
229
|
+
"",
|
|
230
|
+
"恢复记录:",
|
|
231
|
+
...(Array.isArray(recoveryHistory) && recoveryHistory.length
|
|
232
|
+
? recoveryHistory.map((item) => (
|
|
233
|
+
`- 第 ${item.recoveryIndex} 次:等待 ${item.delaySeconds} 秒;探测 ${item.probeStatus || "unknown"};任务 ${item.taskStatus || "unknown"}`
|
|
234
|
+
))
|
|
235
|
+
: ["- 无"]),
|
|
236
|
+
"",
|
|
237
|
+
"错误原因:",
|
|
238
|
+
failure?.reason || "未提供。",
|
|
239
|
+
"",
|
|
240
|
+
"stderr 尾部:",
|
|
241
|
+
tailText(result.stderr, 80) || "无",
|
|
242
|
+
"",
|
|
243
|
+
"stdout 尾部:",
|
|
244
|
+
tailText(result.stdout, 80) || "无",
|
|
245
|
+
];
|
|
246
|
+
return lines.join("\n").trim();
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
export function resolveEmailNotificationConfig(globalConfig = {}) {
|
|
250
|
+
const email = globalConfig?.notifications?.email || {};
|
|
251
|
+
const smtp = email.smtp || {};
|
|
252
|
+
const to = Array.isArray(email.to) ? email.to.filter(Boolean) : [];
|
|
253
|
+
const from = String(email.from || "").trim() || resolveSecret(smtp.username, smtp.usernameEnv);
|
|
254
|
+
|
|
255
|
+
if (email.enabled !== true) {
|
|
256
|
+
return {
|
|
257
|
+
enabled: false,
|
|
258
|
+
reason: "邮件通知未启用。",
|
|
259
|
+
};
|
|
260
|
+
}
|
|
261
|
+
if (!to.length) {
|
|
262
|
+
return {
|
|
263
|
+
enabled: false,
|
|
264
|
+
reason: "邮件通知已启用,但未配置收件人。",
|
|
265
|
+
};
|
|
266
|
+
}
|
|
267
|
+
if (!from) {
|
|
268
|
+
return {
|
|
269
|
+
enabled: false,
|
|
270
|
+
reason: "邮件通知已启用,但未配置发件人。",
|
|
271
|
+
};
|
|
272
|
+
}
|
|
273
|
+
if (!String(smtp.host || "").trim()) {
|
|
274
|
+
return {
|
|
275
|
+
enabled: false,
|
|
276
|
+
reason: "邮件通知已启用,但未配置 SMTP host。",
|
|
277
|
+
};
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
return {
|
|
281
|
+
enabled: true,
|
|
282
|
+
to,
|
|
283
|
+
from,
|
|
284
|
+
smtp: {
|
|
285
|
+
host: String(smtp.host || "").trim(),
|
|
286
|
+
port: Number(smtp.port || (smtp.secure ? 465 : 25)),
|
|
287
|
+
secure: smtp.secure === true,
|
|
288
|
+
starttls: smtp.starttls === true,
|
|
289
|
+
username: String(smtp.username || "").trim(),
|
|
290
|
+
usernameEnv: String(smtp.usernameEnv || "").trim(),
|
|
291
|
+
password: String(smtp.password || "").trim(),
|
|
292
|
+
passwordEnv: String(smtp.passwordEnv || "").trim(),
|
|
293
|
+
timeoutSeconds: Number(smtp.timeoutSeconds || 30),
|
|
294
|
+
rejectUnauthorized: smtp.rejectUnauthorized !== false,
|
|
295
|
+
},
|
|
296
|
+
};
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
export async function sendRuntimeStopNotification({
|
|
300
|
+
globalConfig,
|
|
301
|
+
context,
|
|
302
|
+
engine,
|
|
303
|
+
phase,
|
|
304
|
+
failure,
|
|
305
|
+
result,
|
|
306
|
+
recoveryHistory,
|
|
307
|
+
runDir,
|
|
308
|
+
}) {
|
|
309
|
+
const resolved = resolveEmailNotificationConfig(globalConfig);
|
|
310
|
+
if (!resolved.enabled) {
|
|
311
|
+
return {
|
|
312
|
+
attempted: false,
|
|
313
|
+
delivered: false,
|
|
314
|
+
reason: resolved.reason,
|
|
315
|
+
};
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
const subject = `[HelloLoop] 自动恢复已暂停:${engine} / ${phase}`;
|
|
319
|
+
const text = buildNotificationBody({
|
|
320
|
+
context,
|
|
321
|
+
engine,
|
|
322
|
+
phase,
|
|
323
|
+
failure,
|
|
324
|
+
result,
|
|
325
|
+
recoveryHistory,
|
|
326
|
+
runDir,
|
|
327
|
+
});
|
|
328
|
+
|
|
329
|
+
await connectAndSend({
|
|
330
|
+
smtp: resolved.smtp,
|
|
331
|
+
from: resolved.from,
|
|
332
|
+
to: resolved.to,
|
|
333
|
+
subject,
|
|
334
|
+
text,
|
|
335
|
+
});
|
|
336
|
+
|
|
337
|
+
return {
|
|
338
|
+
attempted: true,
|
|
339
|
+
delivered: true,
|
|
340
|
+
subject,
|
|
341
|
+
recipients: resolved.to,
|
|
342
|
+
};
|
|
343
|
+
}
|
|
@@ -0,0 +1,294 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import { spawn } from "node:child_process";
|
|
3
|
+
|
|
4
|
+
import {
|
|
5
|
+
resolveCliInvocation,
|
|
6
|
+
resolveCodexInvocation,
|
|
7
|
+
resolveVerifyShellInvocation,
|
|
8
|
+
} from "./shell_invocation.mjs";
|
|
9
|
+
|
|
10
|
+
export function runChild(command, args, options = {}) {
|
|
11
|
+
return new Promise((resolve) => {
|
|
12
|
+
const child = spawn(command, args, {
|
|
13
|
+
cwd: options.cwd,
|
|
14
|
+
env: {
|
|
15
|
+
...process.env,
|
|
16
|
+
...(options.env || {}),
|
|
17
|
+
},
|
|
18
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
19
|
+
shell: Boolean(options.shell),
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
let stdout = "";
|
|
23
|
+
let stderr = "";
|
|
24
|
+
let stdoutBytes = 0;
|
|
25
|
+
let stderrBytes = 0;
|
|
26
|
+
const startedAt = Date.now();
|
|
27
|
+
let lastOutputAt = startedAt;
|
|
28
|
+
let watchdogTriggered = false;
|
|
29
|
+
let watchdogReason = "";
|
|
30
|
+
let stallWarned = false;
|
|
31
|
+
let killTimer = null;
|
|
32
|
+
|
|
33
|
+
const emitHeartbeat = (status, extra = {}) => {
|
|
34
|
+
options.onHeartbeat?.({
|
|
35
|
+
status,
|
|
36
|
+
pid: child.pid ?? null,
|
|
37
|
+
startedAt: new Date(startedAt).toISOString(),
|
|
38
|
+
lastOutputAt: new Date(lastOutputAt).toISOString(),
|
|
39
|
+
stdoutBytes,
|
|
40
|
+
stderrBytes,
|
|
41
|
+
idleSeconds: Math.max(0, Math.floor((Date.now() - lastOutputAt) / 1000)),
|
|
42
|
+
watchdogTriggered,
|
|
43
|
+
watchdogReason,
|
|
44
|
+
...extra,
|
|
45
|
+
});
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
const heartbeatIntervalMs = Math.max(100, Number(options.heartbeatIntervalMs || 0));
|
|
49
|
+
const stallWarningMs = Math.max(0, Number(options.stallWarningMs || 0));
|
|
50
|
+
const maxIdleMs = Math.max(0, Number(options.maxIdleMs || 0));
|
|
51
|
+
const killGraceMs = Math.max(100, Number(options.killGraceMs || 1000));
|
|
52
|
+
|
|
53
|
+
const heartbeatTimer = heartbeatIntervalMs > 0
|
|
54
|
+
? setInterval(() => {
|
|
55
|
+
const idleMs = Date.now() - lastOutputAt;
|
|
56
|
+
if (stallWarningMs > 0 && idleMs >= stallWarningMs && !stallWarned) {
|
|
57
|
+
stallWarned = true;
|
|
58
|
+
emitHeartbeat("suspected_stall", {
|
|
59
|
+
message: `当前子进程已连续 ${Math.floor(idleMs / 1000)} 秒没有可见输出,继续观察。`,
|
|
60
|
+
});
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
if (maxIdleMs > 0 && idleMs >= maxIdleMs && !watchdogTriggered) {
|
|
64
|
+
watchdogTriggered = true;
|
|
65
|
+
watchdogReason = `当前子进程已连续 ${Math.floor(idleMs / 1000)} 秒没有可见输出。`;
|
|
66
|
+
stderr = [
|
|
67
|
+
stderr.trim(),
|
|
68
|
+
`[HelloLoop watchdog] ${watchdogReason}`,
|
|
69
|
+
].filter(Boolean).join("\n");
|
|
70
|
+
emitHeartbeat("watchdog_terminating", {
|
|
71
|
+
message: "已达到无人值守恢复阈值,准备终止当前子进程并发起同引擎恢复。",
|
|
72
|
+
});
|
|
73
|
+
child.kill();
|
|
74
|
+
killTimer = setTimeout(() => {
|
|
75
|
+
child.kill("SIGKILL");
|
|
76
|
+
}, killGraceMs);
|
|
77
|
+
return;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
emitHeartbeat(watchdogTriggered ? "watchdog_waiting" : "running");
|
|
81
|
+
}, heartbeatIntervalMs)
|
|
82
|
+
: null;
|
|
83
|
+
|
|
84
|
+
emitHeartbeat("running");
|
|
85
|
+
|
|
86
|
+
child.stdout.on("data", (chunk) => {
|
|
87
|
+
stdout += chunk.toString();
|
|
88
|
+
stdoutBytes += chunk.length;
|
|
89
|
+
lastOutputAt = Date.now();
|
|
90
|
+
stallWarned = false;
|
|
91
|
+
emitHeartbeat("running");
|
|
92
|
+
});
|
|
93
|
+
child.stderr.on("data", (chunk) => {
|
|
94
|
+
stderr += chunk.toString();
|
|
95
|
+
stderrBytes += chunk.length;
|
|
96
|
+
lastOutputAt = Date.now();
|
|
97
|
+
stallWarned = false;
|
|
98
|
+
emitHeartbeat("running");
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
if (options.stdin) {
|
|
102
|
+
child.stdin.write(options.stdin);
|
|
103
|
+
}
|
|
104
|
+
child.stdin.end();
|
|
105
|
+
|
|
106
|
+
child.on("error", (error) => {
|
|
107
|
+
if (heartbeatTimer) {
|
|
108
|
+
clearInterval(heartbeatTimer);
|
|
109
|
+
}
|
|
110
|
+
if (killTimer) {
|
|
111
|
+
clearTimeout(killTimer);
|
|
112
|
+
}
|
|
113
|
+
emitHeartbeat("failed", {
|
|
114
|
+
code: 1,
|
|
115
|
+
signal: "",
|
|
116
|
+
});
|
|
117
|
+
resolve({
|
|
118
|
+
ok: false,
|
|
119
|
+
code: 1,
|
|
120
|
+
stdout,
|
|
121
|
+
stderr: String(error?.stack || error || ""),
|
|
122
|
+
signal: "",
|
|
123
|
+
startedAt: new Date(startedAt).toISOString(),
|
|
124
|
+
finishedAt: new Date().toISOString(),
|
|
125
|
+
idleTimeout: watchdogTriggered,
|
|
126
|
+
watchdogTriggered,
|
|
127
|
+
watchdogReason,
|
|
128
|
+
});
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
child.on("close", (code, signal) => {
|
|
132
|
+
if (heartbeatTimer) {
|
|
133
|
+
clearInterval(heartbeatTimer);
|
|
134
|
+
}
|
|
135
|
+
if (killTimer) {
|
|
136
|
+
clearTimeout(killTimer);
|
|
137
|
+
}
|
|
138
|
+
emitHeartbeat(code === 0 ? "completed" : "failed", {
|
|
139
|
+
code: code ?? 1,
|
|
140
|
+
signal: signal || "",
|
|
141
|
+
});
|
|
142
|
+
resolve({
|
|
143
|
+
ok: code === 0,
|
|
144
|
+
code: code ?? 1,
|
|
145
|
+
stdout,
|
|
146
|
+
stderr,
|
|
147
|
+
signal: signal || "",
|
|
148
|
+
startedAt: new Date(startedAt).toISOString(),
|
|
149
|
+
finishedAt: new Date().toISOString(),
|
|
150
|
+
idleTimeout: watchdogTriggered,
|
|
151
|
+
watchdogTriggered,
|
|
152
|
+
watchdogReason,
|
|
153
|
+
});
|
|
154
|
+
});
|
|
155
|
+
});
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
export function readSchemaText(outputSchemaFile = "") {
|
|
159
|
+
return outputSchemaFile && fs.existsSync(outputSchemaFile)
|
|
160
|
+
? fs.readFileSync(outputSchemaFile, "utf8").trim()
|
|
161
|
+
: "";
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
export function resolveEngineInvocation(engine, explicitExecutable = "") {
|
|
165
|
+
const envExecutable = String(process.env[`HELLOLOOP_${String(engine || "").toUpperCase()}_EXECUTABLE`] || "").trim();
|
|
166
|
+
const executable = envExecutable || explicitExecutable;
|
|
167
|
+
if (engine === "codex") {
|
|
168
|
+
return resolveCodexInvocation({ explicitExecutable: executable });
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
const meta = {
|
|
172
|
+
claude: {
|
|
173
|
+
commandName: "claude",
|
|
174
|
+
displayName: "Claude",
|
|
175
|
+
},
|
|
176
|
+
gemini: {
|
|
177
|
+
commandName: "gemini",
|
|
178
|
+
displayName: "Gemini",
|
|
179
|
+
},
|
|
180
|
+
}[engine];
|
|
181
|
+
|
|
182
|
+
if (!meta) {
|
|
183
|
+
return {
|
|
184
|
+
command: "",
|
|
185
|
+
argsPrefix: [],
|
|
186
|
+
shell: false,
|
|
187
|
+
error: `不支持的执行引擎:${engine}`,
|
|
188
|
+
};
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
return resolveCliInvocation({
|
|
192
|
+
commandName: meta.commandName,
|
|
193
|
+
toolDisplayName: meta.displayName,
|
|
194
|
+
explicitExecutable: executable,
|
|
195
|
+
});
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
export function buildCodexArgs({
|
|
199
|
+
context,
|
|
200
|
+
model = "",
|
|
201
|
+
sandbox = "workspace-write",
|
|
202
|
+
dangerouslyBypassSandbox = false,
|
|
203
|
+
jsonOutput = true,
|
|
204
|
+
outputSchemaFile = "",
|
|
205
|
+
ephemeral = false,
|
|
206
|
+
skipGitRepoCheck = false,
|
|
207
|
+
lastMessageFile,
|
|
208
|
+
}) {
|
|
209
|
+
const codexArgs = ["exec", "-C", context.repoRoot];
|
|
210
|
+
|
|
211
|
+
if (model) {
|
|
212
|
+
codexArgs.push("--model", model);
|
|
213
|
+
}
|
|
214
|
+
if (dangerouslyBypassSandbox) {
|
|
215
|
+
codexArgs.push("--dangerously-bypass-approvals-and-sandbox");
|
|
216
|
+
} else {
|
|
217
|
+
codexArgs.push("--sandbox", sandbox);
|
|
218
|
+
}
|
|
219
|
+
if (skipGitRepoCheck) {
|
|
220
|
+
codexArgs.push("--skip-git-repo-check");
|
|
221
|
+
}
|
|
222
|
+
if (ephemeral) {
|
|
223
|
+
codexArgs.push("--ephemeral");
|
|
224
|
+
}
|
|
225
|
+
if (outputSchemaFile) {
|
|
226
|
+
codexArgs.push("--output-schema", outputSchemaFile);
|
|
227
|
+
}
|
|
228
|
+
if (jsonOutput) {
|
|
229
|
+
codexArgs.push("--json");
|
|
230
|
+
}
|
|
231
|
+
codexArgs.push("-o", lastMessageFile, "-");
|
|
232
|
+
return codexArgs;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
export function buildClaudeArgs({
|
|
236
|
+
model = "",
|
|
237
|
+
outputSchemaFile = "",
|
|
238
|
+
executionMode = "analyze",
|
|
239
|
+
policy = {},
|
|
240
|
+
}) {
|
|
241
|
+
const args = [
|
|
242
|
+
"-p",
|
|
243
|
+
executionMode === "analyze"
|
|
244
|
+
? "请读取标准输入中的完整分析任务并直接输出最终结果。"
|
|
245
|
+
: "请读取标准输入中的完整开发任务并直接完成它。",
|
|
246
|
+
"--output-format",
|
|
247
|
+
policy.outputFormat || "text",
|
|
248
|
+
"--permission-mode",
|
|
249
|
+
executionMode === "analyze"
|
|
250
|
+
? (policy.analysisPermissionMode || "plan")
|
|
251
|
+
: (policy.permissionMode || "bypassPermissions"),
|
|
252
|
+
"--no-session-persistence",
|
|
253
|
+
];
|
|
254
|
+
|
|
255
|
+
if (model) {
|
|
256
|
+
args.push("--model", model);
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
const schemaText = readSchemaText(outputSchemaFile);
|
|
260
|
+
if (schemaText) {
|
|
261
|
+
args.push("--json-schema", schemaText);
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
return args;
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
export function buildGeminiArgs({
|
|
268
|
+
model = "",
|
|
269
|
+
executionMode = "analyze",
|
|
270
|
+
policy = {},
|
|
271
|
+
}) {
|
|
272
|
+
const args = [
|
|
273
|
+
"-p",
|
|
274
|
+
executionMode === "analyze"
|
|
275
|
+
? "请读取标准输入中的完整分析任务并直接输出最终结果。"
|
|
276
|
+
: "请读取标准输入中的完整开发任务并直接完成它。",
|
|
277
|
+
"--output-format",
|
|
278
|
+
policy.outputFormat || "text",
|
|
279
|
+
"--approval-mode",
|
|
280
|
+
executionMode === "analyze"
|
|
281
|
+
? (policy.analysisApprovalMode || "plan")
|
|
282
|
+
: (policy.approvalMode || "yolo"),
|
|
283
|
+
];
|
|
284
|
+
|
|
285
|
+
if (model) {
|
|
286
|
+
args.push("--model", model);
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
return args;
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
export function resolveVerifyInvocation() {
|
|
293
|
+
return resolveVerifyShellInvocation();
|
|
294
|
+
}
|