helloloop 0.2.1 → 0.6.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.
Files changed (58) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/.codex-plugin/plugin.json +3 -3
  3. package/README.md +297 -272
  4. package/hosts/claude/marketplace/plugins/helloloop/.claude-plugin/plugin.json +1 -1
  5. package/hosts/claude/marketplace/plugins/helloloop/commands/helloloop.md +19 -9
  6. package/hosts/claude/marketplace/plugins/helloloop/skills/helloloop/SKILL.md +12 -4
  7. package/hosts/gemini/extension/GEMINI.md +13 -4
  8. package/hosts/gemini/extension/commands/helloloop.toml +19 -8
  9. package/hosts/gemini/extension/gemini-extension.json +1 -1
  10. package/package.json +2 -2
  11. package/scripts/uninstall-home-plugin.ps1 +25 -0
  12. package/skills/helloloop/SKILL.md +42 -7
  13. package/src/analyze_confirmation.mjs +108 -8
  14. package/src/analyze_prompt.mjs +17 -1
  15. package/src/analyze_user_input.mjs +321 -0
  16. package/src/analyzer.mjs +167 -42
  17. package/src/cli.mjs +34 -308
  18. package/src/cli_analyze_command.mjs +248 -0
  19. package/src/cli_args.mjs +106 -0
  20. package/src/cli_command_handlers.mjs +120 -0
  21. package/src/cli_context.mjs +31 -0
  22. package/src/cli_render.mjs +70 -0
  23. package/src/cli_support.mjs +95 -31
  24. package/src/completion_review.mjs +243 -0
  25. package/src/config.mjs +50 -0
  26. package/src/discovery.mjs +243 -9
  27. package/src/discovery_inference.mjs +62 -18
  28. package/src/discovery_paths.mjs +143 -8
  29. package/src/discovery_prompt.mjs +273 -0
  30. package/src/engine_metadata.mjs +79 -0
  31. package/src/engine_selection.mjs +335 -0
  32. package/src/engine_selection_failure.mjs +51 -0
  33. package/src/engine_selection_messages.mjs +119 -0
  34. package/src/engine_selection_probe.mjs +78 -0
  35. package/src/engine_selection_prompt.mjs +48 -0
  36. package/src/engine_selection_settings.mjs +38 -0
  37. package/src/guardrails.mjs +15 -4
  38. package/src/install.mjs +20 -266
  39. package/src/install_claude.mjs +189 -0
  40. package/src/install_codex.mjs +114 -0
  41. package/src/install_gemini.mjs +43 -0
  42. package/src/install_shared.mjs +90 -0
  43. package/src/process.mjs +482 -39
  44. package/src/prompt.mjs +9 -5
  45. package/src/prompt_session.mjs +40 -0
  46. package/src/rebuild.mjs +116 -0
  47. package/src/runner.mjs +3 -341
  48. package/src/runner_execute_task.mjs +301 -0
  49. package/src/runner_execution_support.mjs +155 -0
  50. package/src/runner_loop.mjs +106 -0
  51. package/src/runner_once.mjs +29 -0
  52. package/src/runner_status.mjs +104 -0
  53. package/src/runtime_recovery.mjs +301 -0
  54. package/src/shell_invocation.mjs +16 -0
  55. package/templates/analysis-output.schema.json +58 -1
  56. package/templates/policy.template.json +27 -0
  57. package/templates/project.template.json +2 -0
  58. package/templates/task-review-output.schema.json +70 -0
@@ -0,0 +1,90 @@
1
+ import fs from "node:fs";
2
+ import os from "node:os";
3
+ import path from "node:path";
4
+
5
+ import { ensureDir, fileExists, readJson, writeJson } from "./common.mjs";
6
+
7
+ export const runtimeBundleEntries = [
8
+ ".claude-plugin",
9
+ ".codex-plugin",
10
+ "LICENSE",
11
+ "README.md",
12
+ "bin",
13
+ "hosts",
14
+ "package.json",
15
+ "scripts",
16
+ "skills",
17
+ "src",
18
+ "templates",
19
+ ];
20
+
21
+ export const codexBundleEntries = runtimeBundleEntries.filter((entry) => ![
22
+ ".claude-plugin",
23
+ "hosts",
24
+ ].includes(entry));
25
+
26
+ export const supportedHosts = ["codex", "claude", "gemini"];
27
+ export const CLAUDE_MARKETPLACE_NAME = "helloloop-local";
28
+ export const CLAUDE_PLUGIN_KEY = "helloloop@helloloop-local";
29
+
30
+ export function resolveHomeDir(homeDir, defaultDirName) {
31
+ return path.resolve(homeDir || path.join(os.homedir(), defaultDirName));
32
+ }
33
+
34
+ export function assertPathInside(parentDir, targetDir, label) {
35
+ const relative = path.relative(parentDir, targetDir);
36
+ if (!relative || relative.startsWith("..") || path.isAbsolute(relative)) {
37
+ throw new Error(`${label} 超出允许范围:${targetDir}`);
38
+ }
39
+ }
40
+
41
+ export function removeTargetIfNeeded(targetPath, force) {
42
+ if (!fileExists(targetPath)) {
43
+ return;
44
+ }
45
+ if (!force) {
46
+ throw new Error(`目标目录已存在:${targetPath}。若要覆盖,请追加 --force。`);
47
+ }
48
+ fs.rmSync(targetPath, { recursive: true, force: true });
49
+ }
50
+
51
+ export function removePathIfExists(targetPath) {
52
+ if (!fileExists(targetPath)) {
53
+ return false;
54
+ }
55
+ fs.rmSync(targetPath, { recursive: true, force: true });
56
+ return true;
57
+ }
58
+
59
+ export function copyBundleEntries(bundleRoot, targetRoot, entries) {
60
+ for (const entry of entries) {
61
+ const sourcePath = path.join(bundleRoot, entry);
62
+ if (!fileExists(sourcePath)) {
63
+ continue;
64
+ }
65
+
66
+ fs.cpSync(sourcePath, path.join(targetRoot, entry), {
67
+ force: true,
68
+ recursive: true,
69
+ });
70
+ }
71
+ }
72
+
73
+ export function copyDirectory(sourceRoot, targetRoot) {
74
+ fs.cpSync(sourceRoot, targetRoot, {
75
+ force: true,
76
+ recursive: true,
77
+ });
78
+ }
79
+
80
+ export function loadOrInitJson(filePath, fallbackValue) {
81
+ if (!fileExists(filePath)) {
82
+ return fallbackValue;
83
+ }
84
+ return readJson(filePath);
85
+ }
86
+
87
+ export function writeJsonFile(filePath, value) {
88
+ ensureDir(path.dirname(filePath));
89
+ writeJson(filePath, value);
90
+ }
package/src/process.mjs CHANGED
@@ -2,8 +2,33 @@ import fs from "node:fs";
2
2
  import path from "node:path";
3
3
  import { spawn } from "node:child_process";
4
4
 
5
- import { ensureDir, nowIso, tailText, writeText } from "./common.mjs";
6
- import { resolveCodexInvocation, resolveVerifyShellInvocation } from "./shell_invocation.mjs";
5
+ import { ensureDir, nowIso, tailText, writeJson, writeText } from "./common.mjs";
6
+ import { getEngineDisplayName, normalizeEngineName } from "./engine_metadata.mjs";
7
+ import { resolveCliInvocation, resolveCodexInvocation, resolveVerifyShellInvocation } from "./shell_invocation.mjs";
8
+ import {
9
+ buildRuntimeRecoveryPrompt,
10
+ classifyRuntimeRecoveryFailure,
11
+ renderRuntimeRecoverySummary,
12
+ resolveRuntimeRecoveryPolicy,
13
+ selectRuntimeRecoveryDelayMs,
14
+ } from "./runtime_recovery.mjs";
15
+
16
+ function sleep(ms) {
17
+ return new Promise((resolve) => {
18
+ setTimeout(resolve, ms);
19
+ });
20
+ }
21
+
22
+ function createRuntimeStatusWriter(runtimeStatusFile, baseState) {
23
+ return function writeRuntimeStatus(status, extra = {}) {
24
+ writeJson(runtimeStatusFile, {
25
+ ...baseState,
26
+ ...extra,
27
+ status,
28
+ updatedAt: nowIso(),
29
+ });
30
+ };
31
+ }
7
32
 
8
33
  function runChild(command, args, options = {}) {
9
34
  return new Promise((resolve) => {
@@ -19,12 +44,81 @@ function runChild(command, args, options = {}) {
19
44
 
20
45
  let stdout = "";
21
46
  let stderr = "";
47
+ let stdoutBytes = 0;
48
+ let stderrBytes = 0;
49
+ const startedAt = Date.now();
50
+ let lastOutputAt = startedAt;
51
+ let watchdogTriggered = false;
52
+ let watchdogReason = "";
53
+ let stallWarned = false;
54
+ let killTimer = null;
55
+
56
+ const emitHeartbeat = (status, extra = {}) => {
57
+ options.onHeartbeat?.({
58
+ status,
59
+ pid: child.pid ?? null,
60
+ startedAt: new Date(startedAt).toISOString(),
61
+ lastOutputAt: new Date(lastOutputAt).toISOString(),
62
+ stdoutBytes,
63
+ stderrBytes,
64
+ idleSeconds: Math.max(0, Math.floor((Date.now() - lastOutputAt) / 1000)),
65
+ watchdogTriggered,
66
+ watchdogReason,
67
+ ...extra,
68
+ });
69
+ };
70
+
71
+ const heartbeatIntervalMs = Math.max(100, Number(options.heartbeatIntervalMs || 0));
72
+ const stallWarningMs = Math.max(0, Number(options.stallWarningMs || 0));
73
+ const maxIdleMs = Math.max(0, Number(options.maxIdleMs || 0));
74
+ const killGraceMs = Math.max(100, Number(options.killGraceMs || 1000));
75
+
76
+ const heartbeatTimer = heartbeatIntervalMs > 0
77
+ ? setInterval(() => {
78
+ const idleMs = Date.now() - lastOutputAt;
79
+ if (stallWarningMs > 0 && idleMs >= stallWarningMs && !stallWarned) {
80
+ stallWarned = true;
81
+ emitHeartbeat("suspected_stall", {
82
+ message: `当前子进程已连续 ${Math.floor(idleMs / 1000)} 秒没有可见输出,继续观察。`,
83
+ });
84
+ }
85
+
86
+ if (maxIdleMs > 0 && idleMs >= maxIdleMs && !watchdogTriggered) {
87
+ watchdogTriggered = true;
88
+ watchdogReason = `当前子进程已连续 ${Math.floor(idleMs / 1000)} 秒没有可见输出。`;
89
+ stderr = [
90
+ stderr.trim(),
91
+ `[HelloLoop watchdog] ${watchdogReason}`,
92
+ ].filter(Boolean).join("\n");
93
+ emitHeartbeat("watchdog_terminating", {
94
+ message: "已达到无人值守恢复阈值,准备终止当前子进程并发起同引擎恢复。",
95
+ });
96
+ child.kill();
97
+ killTimer = setTimeout(() => {
98
+ child.kill("SIGKILL");
99
+ }, killGraceMs);
100
+ return;
101
+ }
102
+
103
+ emitHeartbeat(watchdogTriggered ? "watchdog_waiting" : "running");
104
+ }, heartbeatIntervalMs)
105
+ : null;
106
+
107
+ emitHeartbeat("running");
22
108
 
23
109
  child.stdout.on("data", (chunk) => {
24
110
  stdout += chunk.toString();
111
+ stdoutBytes += chunk.length;
112
+ lastOutputAt = Date.now();
113
+ stallWarned = false;
114
+ emitHeartbeat("running");
25
115
  });
26
116
  child.stderr.on("data", (chunk) => {
27
117
  stderr += chunk.toString();
118
+ stderrBytes += chunk.length;
119
+ lastOutputAt = Date.now();
120
+ stallWarned = false;
121
+ emitHeartbeat("running");
28
122
  });
29
123
 
30
124
  if (options.stdin) {
@@ -33,26 +127,58 @@ function runChild(command, args, options = {}) {
33
127
  child.stdin.end();
34
128
 
35
129
  child.on("error", (error) => {
130
+ if (heartbeatTimer) {
131
+ clearInterval(heartbeatTimer);
132
+ }
133
+ if (killTimer) {
134
+ clearTimeout(killTimer);
135
+ }
136
+ emitHeartbeat("failed", {
137
+ code: 1,
138
+ signal: "",
139
+ });
36
140
  resolve({
37
141
  ok: false,
38
142
  code: 1,
39
143
  stdout,
40
144
  stderr: String(error?.stack || error || ""),
145
+ signal: "",
146
+ startedAt: new Date(startedAt).toISOString(),
147
+ finishedAt: nowIso(),
148
+ idleTimeout: watchdogTriggered,
149
+ watchdogTriggered,
150
+ watchdogReason,
41
151
  });
42
152
  });
43
153
 
44
- child.on("close", (code) => {
154
+ child.on("close", (code, signal) => {
155
+ if (heartbeatTimer) {
156
+ clearInterval(heartbeatTimer);
157
+ }
158
+ if (killTimer) {
159
+ clearTimeout(killTimer);
160
+ }
161
+ emitHeartbeat(code === 0 ? "completed" : "failed", {
162
+ code: code ?? 1,
163
+ signal: signal || "",
164
+ });
45
165
  resolve({
46
166
  ok: code === 0,
47
167
  code: code ?? 1,
48
168
  stdout,
49
169
  stderr,
170
+ signal: signal || "",
171
+ startedAt: new Date(startedAt).toISOString(),
172
+ finishedAt: nowIso(),
173
+ idleTimeout: watchdogTriggered,
174
+ watchdogTriggered,
175
+ watchdogReason,
50
176
  });
51
177
  });
52
178
  });
53
179
  }
54
180
 
55
- function writeCodexRunArtifacts(runDir, prefix, result, finalMessage) {
181
+ function writeEngineRunArtifacts(runDir, prefix, result, finalMessage) {
56
182
  writeText(path.join(runDir, `${prefix}-stdout.log`), result.stdout);
57
183
  writeText(path.join(runDir, `${prefix}-stderr.log`), result.stderr);
58
184
  writeText(path.join(runDir, `${prefix}-summary.txt`), [
@@ -64,25 +190,57 @@ function writeCodexRunArtifacts(runDir, prefix, result, finalMessage) {
64
190
  ].join("\n"));
65
191
  }
66
192
 
67
- export async function runCodexTask({
193
+ function readSchemaText(outputSchemaFile = "") {
194
+ return outputSchemaFile && fs.existsSync(outputSchemaFile)
195
+ ? fs.readFileSync(outputSchemaFile, "utf8").trim()
196
+ : "";
197
+ }
198
+
199
+ function resolveEngineInvocation(engine, explicitExecutable = "") {
200
+ const envExecutable = String(process.env[`HELLOLOOP_${String(engine || "").toUpperCase()}_EXECUTABLE`] || "").trim();
201
+ const executable = envExecutable || explicitExecutable;
202
+ if (engine === "codex") {
203
+ return resolveCodexInvocation({ explicitExecutable: executable });
204
+ }
205
+
206
+ const meta = {
207
+ claude: {
208
+ commandName: "claude",
209
+ displayName: "Claude",
210
+ },
211
+ gemini: {
212
+ commandName: "gemini",
213
+ displayName: "Gemini",
214
+ },
215
+ }[engine];
216
+
217
+ if (!meta) {
218
+ return {
219
+ command: "",
220
+ argsPrefix: [],
221
+ shell: false,
222
+ error: `不支持的执行引擎:${engine}`,
223
+ };
224
+ }
225
+
226
+ return resolveCliInvocation({
227
+ commandName: meta.commandName,
228
+ toolDisplayName: meta.displayName,
229
+ explicitExecutable: executable,
230
+ });
231
+ }
232
+
233
+ function buildCodexArgs({
68
234
  context,
69
- prompt,
70
- runDir,
71
235
  model = "",
72
- executable = "",
73
236
  sandbox = "workspace-write",
74
237
  dangerouslyBypassSandbox = false,
75
238
  jsonOutput = true,
76
239
  outputSchemaFile = "",
77
- outputPrefix = "codex",
78
240
  ephemeral = false,
79
241
  skipGitRepoCheck = false,
80
- env = {},
242
+ lastMessageFile,
81
243
  }) {
82
- ensureDir(runDir);
83
-
84
- const lastMessageFile = path.join(runDir, `${outputPrefix}-last-message.txt`);
85
- const invocation = resolveCodexInvocation({ explicitExecutable: executable });
86
244
  const codexArgs = ["exec", "-C", context.repoRoot];
87
245
 
88
246
  if (model) {
@@ -106,6 +264,123 @@ export async function runCodexTask({
106
264
  codexArgs.push("--json");
107
265
  }
108
266
  codexArgs.push("-o", lastMessageFile, "-");
267
+ return codexArgs;
268
+ }
269
+
270
+ function buildClaudeArgs({
271
+ model = "",
272
+ outputSchemaFile = "",
273
+ executionMode = "analyze",
274
+ policy = {},
275
+ }) {
276
+ const args = [
277
+ "-p",
278
+ executionMode === "analyze"
279
+ ? "请读取标准输入中的完整分析任务并直接输出最终结果。"
280
+ : "请读取标准输入中的完整开发任务并直接完成它。",
281
+ "--output-format",
282
+ policy.outputFormat || "text",
283
+ "--permission-mode",
284
+ executionMode === "analyze"
285
+ ? (policy.analysisPermissionMode || "plan")
286
+ : (policy.permissionMode || "bypassPermissions"),
287
+ "--no-session-persistence",
288
+ ];
289
+
290
+ if (model) {
291
+ args.push("--model", model);
292
+ }
293
+
294
+ const schemaText = readSchemaText(outputSchemaFile);
295
+ if (schemaText) {
296
+ args.push("--json-schema", schemaText);
297
+ }
298
+
299
+ return args;
300
+ }
301
+
302
+ function buildGeminiArgs({
303
+ model = "",
304
+ executionMode = "analyze",
305
+ policy = {},
306
+ }) {
307
+ const args = [
308
+ "-p",
309
+ executionMode === "analyze"
310
+ ? "请读取标准输入中的完整分析任务并直接输出最终结果。"
311
+ : "请读取标准输入中的完整开发任务并直接完成它。",
312
+ "--output-format",
313
+ policy.outputFormat || "text",
314
+ "--approval-mode",
315
+ executionMode === "analyze"
316
+ ? (policy.analysisApprovalMode || "plan")
317
+ : (policy.approvalMode || "yolo"),
318
+ ];
319
+
320
+ if (model) {
321
+ args.push("--model", model);
322
+ }
323
+
324
+ return args;
325
+ }
326
+
327
+ function resolveEnginePolicy(policy = {}, engine) {
328
+ if (engine === "codex") {
329
+ return policy.codex || {};
330
+ }
331
+ if (engine === "claude") {
332
+ return policy.claude || {};
333
+ }
334
+ if (engine === "gemini") {
335
+ return policy.gemini || {};
336
+ }
337
+ return {};
338
+ }
339
+
340
+ export async function runEngineTask({
341
+ engine = "codex",
342
+ context,
343
+ prompt,
344
+ runDir,
345
+ policy = {},
346
+ executionMode = "analyze",
347
+ outputSchemaFile = "",
348
+ outputPrefix = "",
349
+ ephemeral = false,
350
+ skipGitRepoCheck = false,
351
+ env = {},
352
+ }) {
353
+ ensureDir(runDir);
354
+
355
+ const normalizedEngine = normalizeEngineName(engine) || "codex";
356
+ const resolvedPolicy = resolveEnginePolicy(policy, normalizedEngine);
357
+ const prefix = outputPrefix || normalizedEngine;
358
+ const invocation = resolveEngineInvocation(normalizedEngine, resolvedPolicy.executable);
359
+ const recoveryPolicy = resolveRuntimeRecoveryPolicy(policy);
360
+ const runtimeStatusFile = path.join(runDir, `${prefix}-runtime.json`);
361
+ const writeRuntimeStatus = createRuntimeStatusWriter(runtimeStatusFile, {
362
+ engine: normalizedEngine,
363
+ engineDisplayName: getEngineDisplayName(normalizedEngine),
364
+ phase: executionMode,
365
+ outputPrefix: prefix,
366
+ maxPhaseRecoveries: recoveryPolicy.maxPhaseRecoveries,
367
+ });
368
+
369
+ let args = [];
370
+ if (normalizedEngine === "claude") {
371
+ args = buildClaudeArgs({
372
+ model: resolvedPolicy.model,
373
+ outputSchemaFile,
374
+ executionMode,
375
+ policy: resolvedPolicy,
376
+ });
377
+ } else if (normalizedEngine === "gemini") {
378
+ args = buildGeminiArgs({
379
+ model: resolvedPolicy.model,
380
+ executionMode,
381
+ policy: resolvedPolicy,
382
+ });
383
+ }
109
384
 
110
385
  if (invocation.error) {
111
386
  const result = {
@@ -114,43 +389,211 @@ export async function runCodexTask({
114
389
  stdout: "",
115
390
  stderr: invocation.error,
116
391
  };
117
- writeText(path.join(runDir, `${outputPrefix}-prompt.md`), prompt);
118
- writeCodexRunArtifacts(runDir, outputPrefix, result, "");
392
+ writeText(path.join(runDir, `${prefix}-prompt.md`), prompt);
393
+ writeEngineRunArtifacts(runDir, prefix, result, "");
394
+ writeRuntimeStatus("failed", {
395
+ code: result.code,
396
+ message: invocation.error,
397
+ recoveryCount: 0,
398
+ recoveryHistory: [],
399
+ });
119
400
  return { ...result, finalMessage: "" };
120
401
  }
121
402
 
122
- const args = [...invocation.argsPrefix, ...codexArgs];
403
+ const recoveryHistory = [];
404
+ let currentPrompt = prompt;
405
+ let currentRecoveryCount = 0;
123
406
 
124
- const result = await runChild(invocation.command, args, {
125
- cwd: context.repoRoot,
126
- stdin: prompt,
127
- env,
128
- shell: invocation.shell,
129
- });
130
- const finalMessage = fs.existsSync(lastMessageFile)
131
- ? fs.readFileSync(lastMessageFile, "utf8").trim()
132
- : "";
407
+ while (true) {
408
+ const attemptPrefix = currentRecoveryCount === 0
409
+ ? prefix
410
+ : `${prefix}-recovery-${String(currentRecoveryCount).padStart(2, "0")}`;
411
+ const attemptPromptFile = path.join(runDir, `${attemptPrefix}-prompt.md`);
412
+ const attemptLastMessageFile = path.join(runDir, `${attemptPrefix}-last-message.txt`);
413
+ const finalArgs = normalizedEngine === "codex"
414
+ ? [
415
+ ...invocation.argsPrefix,
416
+ ...buildCodexArgs({
417
+ context,
418
+ model: resolvedPolicy.model,
419
+ sandbox: resolvedPolicy.sandbox,
420
+ dangerouslyBypassSandbox: resolvedPolicy.dangerouslyBypassSandbox,
421
+ jsonOutput: resolvedPolicy.jsonOutput !== false,
422
+ outputSchemaFile,
423
+ ephemeral,
424
+ skipGitRepoCheck,
425
+ lastMessageFile: attemptLastMessageFile,
426
+ }),
427
+ ]
428
+ : [...invocation.argsPrefix, ...args];
133
429
 
134
- writeText(path.join(runDir, `${outputPrefix}-prompt.md`), prompt);
135
- writeCodexRunArtifacts(runDir, outputPrefix, result, finalMessage);
430
+ writeRuntimeStatus(currentRecoveryCount > 0 ? "recovering" : "running", {
431
+ attemptPrefix,
432
+ recoveryCount: currentRecoveryCount,
433
+ recoveryHistory,
434
+ });
435
+
436
+ const result = await runChild(invocation.command, finalArgs, {
437
+ cwd: context.repoRoot,
438
+ stdin: currentPrompt,
439
+ env,
440
+ shell: invocation.shell,
441
+ heartbeatIntervalMs: recoveryPolicy.heartbeatIntervalSeconds * 1000,
442
+ stallWarningMs: recoveryPolicy.stallWarningSeconds * 1000,
443
+ maxIdleMs: recoveryPolicy.maxIdleSeconds * 1000,
444
+ killGraceMs: recoveryPolicy.killGraceSeconds * 1000,
445
+ onHeartbeat(payload) {
446
+ writeRuntimeStatus(payload.status, {
447
+ attemptPrefix,
448
+ recoveryCount: currentRecoveryCount,
449
+ recoveryHistory,
450
+ heartbeat: payload,
451
+ });
452
+ },
453
+ });
454
+ const finalMessage = normalizedEngine === "codex"
455
+ ? (fs.existsSync(attemptLastMessageFile) ? fs.readFileSync(attemptLastMessageFile, "utf8").trim() : "")
456
+ : String(result.stdout || "").trim();
136
457
 
137
- return { ...result, finalMessage };
458
+ writeText(attemptPromptFile, currentPrompt);
459
+ writeEngineRunArtifacts(runDir, attemptPrefix, result, finalMessage);
460
+
461
+ const failure = classifyRuntimeRecoveryFailure({
462
+ result: {
463
+ ...result,
464
+ finalMessage,
465
+ },
466
+ recoveryPolicy,
467
+ recoveryCount: currentRecoveryCount,
468
+ });
469
+
470
+ if (
471
+ result.ok
472
+ || !recoveryPolicy.enabled
473
+ || !failure.recoverable
474
+ || currentRecoveryCount >= recoveryPolicy.maxPhaseRecoveries
475
+ ) {
476
+ const finalRecoverySummary = renderRuntimeRecoverySummary(recoveryHistory);
477
+ const finalizedResult = result.ok || !finalRecoverySummary
478
+ ? result
479
+ : {
480
+ ...result,
481
+ stderr: [result.stderr, "", finalRecoverySummary].filter(Boolean).join("\n").trim(),
482
+ };
483
+
484
+ writeText(path.join(runDir, `${prefix}-prompt.md`), currentPrompt);
485
+ writeEngineRunArtifacts(runDir, prefix, finalizedResult, finalMessage);
486
+ if (normalizedEngine === "codex" && finalMessage) {
487
+ writeText(path.join(runDir, `${prefix}-last-message.txt`), finalMessage);
488
+ }
489
+ writeRuntimeStatus(result.ok ? "completed" : "failed", {
490
+ attemptPrefix,
491
+ recoveryCount: currentRecoveryCount,
492
+ recoveryHistory,
493
+ recoverySummary: finalRecoverySummary,
494
+ finalMessage,
495
+ code: finalizedResult.code,
496
+ failureCode: failure.code,
497
+ failureReason: failure.reason,
498
+ });
499
+
500
+ return {
501
+ ...finalizedResult,
502
+ finalMessage,
503
+ recoveryCount: currentRecoveryCount,
504
+ recoveryHistory,
505
+ recoverySummary: finalRecoverySummary,
506
+ recoveryFailure: failure,
507
+ };
508
+ }
509
+
510
+ const nextRecoveryIndex = currentRecoveryCount + 1;
511
+ const delayMs = selectRuntimeRecoveryDelayMs(recoveryPolicy, nextRecoveryIndex);
512
+ const recoveryPrompt = buildRuntimeRecoveryPrompt({
513
+ basePrompt: prompt,
514
+ engine: normalizedEngine,
515
+ phaseLabel: executionMode === "analyze" ? "分析/复核" : "执行",
516
+ failure,
517
+ result: {
518
+ ...result,
519
+ finalMessage,
520
+ },
521
+ nextRecoveryIndex,
522
+ maxRecoveries: recoveryPolicy.maxPhaseRecoveries,
523
+ });
524
+ const recoveryRecord = {
525
+ recoveryIndex: nextRecoveryIndex,
526
+ code: failure.code,
527
+ reason: failure.reason,
528
+ delaySeconds: Math.floor(delayMs / 1000),
529
+ sourceCode: result.code,
530
+ watchdogTriggered: result.watchdogTriggered === true,
531
+ attemptPrefix,
532
+ };
533
+ recoveryHistory.push(recoveryRecord);
534
+ writeJson(path.join(
535
+ runDir,
536
+ `${prefix}-auto-recovery-${String(nextRecoveryIndex).padStart(2, "0")}.json`,
537
+ ), {
538
+ ...recoveryRecord,
539
+ engine: normalizedEngine,
540
+ phase: executionMode,
541
+ stdoutTail: tailText(result.stdout, 20),
542
+ stderrTail: tailText(result.stderr, 20),
543
+ finalMessageTail: tailText(finalMessage, 20),
544
+ createdAt: nowIso(),
545
+ });
546
+ writeText(
547
+ path.join(runDir, `${prefix}-auto-recovery-${String(nextRecoveryIndex).padStart(2, "0")}-prompt.md`),
548
+ recoveryPrompt,
549
+ );
550
+ writeRuntimeStatus("retry_waiting", {
551
+ attemptPrefix,
552
+ recoveryCount: nextRecoveryIndex,
553
+ recoveryHistory,
554
+ nextRetryDelayMs: delayMs,
555
+ failureCode: failure.code,
556
+ failureReason: failure.reason,
557
+ });
558
+ if (delayMs > 0) {
559
+ await sleep(delayMs);
560
+ }
561
+ currentPrompt = recoveryPrompt;
562
+ currentRecoveryCount = nextRecoveryIndex;
563
+ }
564
+ }
565
+
566
+ export async function runCodexTask(options) {
567
+ return runEngineTask({
568
+ ...options,
569
+ engine: "codex",
570
+ });
138
571
  }
139
572
 
140
573
  export async function runCodexExec({ context, prompt, runDir, policy }) {
141
- return runCodexTask({
574
+ return runEngineTask({
575
+ engine: "codex",
142
576
  context,
143
577
  prompt,
144
578
  runDir,
145
- model: policy.codex.model,
146
- executable: policy.codex.executable,
147
- sandbox: policy.codex.sandbox,
148
- dangerouslyBypassSandbox: policy.codex.dangerouslyBypassSandbox,
149
- jsonOutput: policy.codex.jsonOutput,
579
+ policy,
580
+ executionMode: "execute",
150
581
  outputPrefix: "codex",
151
582
  });
152
583
  }
153
584
 
585
+ export async function runEngineExec({ engine, context, prompt, runDir, policy }) {
586
+ return runEngineTask({
587
+ engine,
588
+ context,
589
+ prompt,
590
+ runDir,
591
+ policy,
592
+ executionMode: "execute",
593
+ outputPrefix: engine,
594
+ });
595
+ }
596
+
154
597
  export async function runShellCommand(context, commandLine, runDir, index) {
155
598
  const shellInvocation = resolveVerifyShellInvocation();
156
599
  if (shellInvocation.error) {
@@ -195,10 +638,10 @@ export async function runVerifyCommands(context, commands, runDir) {
195
638
  ok: false,
196
639
  results,
197
640
  failed: result,
198
- summary: [
199
- `验证失败:${result.command}`,
200
- "",
201
- "stdout 尾部:",
641
+ summary: [
642
+ `验证失败:${result.command}`,
643
+ "",
644
+ "stdout 尾部:",
202
645
  tailText(result.stdout, 40),
203
646
  "",
204
647
  "stderr 尾部:",