helloloop 0.8.2 → 0.8.5

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,395 @@
1
+ import path from "node:path";
2
+
3
+ import { ensureDir, nowIso, tailText, writeJson, writeText } from "./common.mjs";
4
+ import { getEngineDisplayName, normalizeEngineName } from "./engine_metadata.mjs";
5
+ import { resolveEngineInvocation } from "./engine_process_support.mjs";
6
+ import { isHostLeaseAlive } from "./host_lease.mjs";
7
+ import {
8
+ buildRuntimeRecoveryPrompt,
9
+ classifyRuntimeRecoveryFailure,
10
+ renderRuntimeRecoverySummary,
11
+ resolveRuntimeRecoveryPolicy,
12
+ selectRuntimeRecoveryDelayMs,
13
+ } from "./runtime_recovery.mjs";
14
+ import {
15
+ buildHostLeaseStoppedResult,
16
+ buildNotificationNote,
17
+ createRuntimeStatusWriter,
18
+ maybeSendStopNotification,
19
+ resolveEnginePolicy,
20
+ runEngineAttempt,
21
+ runEngineHealthProbe,
22
+ sleepWithLease,
23
+ writeEngineRunArtifacts,
24
+ } from "./runtime_engine_support.mjs";
25
+
26
+ export async function runEngineTask({
27
+ engine = "codex",
28
+ context,
29
+ prompt,
30
+ runDir,
31
+ policy = {},
32
+ executionMode = "analyze",
33
+ outputSchemaFile = "",
34
+ outputPrefix = "",
35
+ ephemeral = false,
36
+ skipGitRepoCheck = false,
37
+ env = {},
38
+ hostLease = null,
39
+ }) {
40
+ ensureDir(runDir);
41
+
42
+ const normalizedEngine = normalizeEngineName(engine) || "codex";
43
+ const resolvedPolicy = resolveEnginePolicy(policy, normalizedEngine);
44
+ const prefix = outputPrefix || normalizedEngine;
45
+ const invocation = resolveEngineInvocation(normalizedEngine, resolvedPolicy.executable);
46
+ const recoveryPolicy = resolveRuntimeRecoveryPolicy(policy);
47
+ const runtimeStatusFile = path.join(runDir, `${prefix}-runtime.json`);
48
+ const writeRuntimeStatus = createRuntimeStatusWriter(runtimeStatusFile, {
49
+ engine: normalizedEngine,
50
+ engineDisplayName: getEngineDisplayName(normalizedEngine),
51
+ phase: executionMode,
52
+ outputPrefix: prefix,
53
+ hardRetryBudget: recoveryPolicy.hardRetryDelaysSeconds.length,
54
+ softRetryBudget: recoveryPolicy.softRetryDelaysSeconds.length,
55
+ });
56
+
57
+ const recoveryHistory = [];
58
+ let currentPrompt = prompt;
59
+ let currentRecoveryCount = 0;
60
+ let activeFailure = null;
61
+
62
+ while (true) {
63
+ if (!isHostLeaseAlive(hostLease)) {
64
+ const stopped = buildHostLeaseStoppedResult("检测到宿主窗口已关闭,HelloLoop 已停止本轮自动执行。");
65
+ writeRuntimeStatus("stopped_host_closed", {
66
+ attemptPrefix: prefix,
67
+ recoveryCount: recoveryHistory.length,
68
+ recoveryHistory,
69
+ code: stopped.code,
70
+ failureCode: "host_closed",
71
+ failureFamily: "host_lease",
72
+ failureReason: stopped.leaseReason,
73
+ });
74
+ return {
75
+ ...stopped,
76
+ finalMessage: "",
77
+ recoveryCount: recoveryHistory.length,
78
+ recoveryHistory,
79
+ recoverySummary: "",
80
+ recoveryFailure: null,
81
+ notification: null,
82
+ };
83
+ }
84
+
85
+ const attemptPrefix = currentRecoveryCount === 0
86
+ ? prefix
87
+ : `${prefix}-recovery-${String(currentRecoveryCount).padStart(2, "0")}`;
88
+ const taskAttempt = await runEngineAttempt({
89
+ engine: normalizedEngine,
90
+ invocation,
91
+ context,
92
+ prompt: currentPrompt,
93
+ runDir,
94
+ attemptPrefix,
95
+ resolvedPolicy,
96
+ executionMode,
97
+ outputSchemaFile,
98
+ env,
99
+ recoveryPolicy,
100
+ writeRuntimeStatus,
101
+ recoveryCount: currentRecoveryCount,
102
+ recoveryHistory,
103
+ hostLease,
104
+ ephemeral,
105
+ skipGitRepoCheck,
106
+ probeMode: false,
107
+ });
108
+ if (taskAttempt.result.leaseExpired) {
109
+ writeText(path.join(runDir, `${prefix}-prompt.md`), currentPrompt);
110
+ writeEngineRunArtifacts(runDir, prefix, taskAttempt.result, taskAttempt.finalMessage);
111
+ writeRuntimeStatus("stopped_host_closed", {
112
+ attemptPrefix,
113
+ recoveryCount: recoveryHistory.length,
114
+ recoveryHistory,
115
+ finalMessage: taskAttempt.finalMessage,
116
+ code: taskAttempt.result.code,
117
+ failureCode: "host_closed",
118
+ failureFamily: "host_lease",
119
+ failureReason: taskAttempt.result.leaseReason,
120
+ });
121
+ return {
122
+ ...taskAttempt.result,
123
+ finalMessage: taskAttempt.finalMessage,
124
+ recoveryCount: recoveryHistory.length,
125
+ recoveryHistory,
126
+ recoverySummary: "",
127
+ recoveryFailure: null,
128
+ notification: null,
129
+ };
130
+ }
131
+
132
+ const taskFailure = classifyRuntimeRecoveryFailure({
133
+ result: {
134
+ ...taskAttempt.result,
135
+ finalMessage: taskAttempt.finalMessage,
136
+ },
137
+ });
138
+
139
+ if (taskAttempt.result.ok || !recoveryPolicy.enabled) {
140
+ const finalRecoverySummary = taskAttempt.result.ok
141
+ ? ""
142
+ : renderRuntimeRecoverySummary(recoveryHistory, taskFailure);
143
+ const notification = taskAttempt.result.ok
144
+ ? null
145
+ : await maybeSendStopNotification({
146
+ context,
147
+ runDir,
148
+ engine: normalizedEngine,
149
+ executionMode,
150
+ failure: taskFailure,
151
+ result: taskAttempt.result,
152
+ recoveryHistory,
153
+ });
154
+ const notificationNote = taskAttempt.result.ok ? "" : buildNotificationNote(notification);
155
+ const finalizedResult = taskAttempt.result.ok
156
+ ? taskAttempt.result
157
+ : {
158
+ ...taskAttempt.result,
159
+ stderr: [
160
+ taskAttempt.result.stderr,
161
+ "",
162
+ finalRecoverySummary,
163
+ notificationNote,
164
+ ].filter(Boolean).join("\n").trim(),
165
+ };
166
+
167
+ writeText(path.join(runDir, `${prefix}-prompt.md`), currentPrompt);
168
+ writeEngineRunArtifacts(runDir, prefix, finalizedResult, taskAttempt.finalMessage);
169
+ if (normalizedEngine === "codex" && taskAttempt.finalMessage) {
170
+ writeText(path.join(runDir, `${prefix}-last-message.txt`), taskAttempt.finalMessage);
171
+ }
172
+ writeRuntimeStatus(taskAttempt.result.ok ? "completed" : "paused_manual", {
173
+ attemptPrefix,
174
+ recoveryCount: recoveryHistory.length,
175
+ recoveryHistory,
176
+ recoverySummary: finalRecoverySummary,
177
+ finalMessage: taskAttempt.finalMessage,
178
+ code: finalizedResult.code,
179
+ failureCode: taskFailure.code,
180
+ failureFamily: taskFailure.family,
181
+ failureReason: taskFailure.reason,
182
+ notification,
183
+ });
184
+
185
+ return {
186
+ ...finalizedResult,
187
+ finalMessage: taskAttempt.finalMessage,
188
+ recoveryCount: recoveryHistory.length,
189
+ recoveryHistory,
190
+ recoverySummary: finalRecoverySummary,
191
+ recoveryFailure: taskAttempt.result.ok
192
+ ? null
193
+ : {
194
+ ...taskFailure,
195
+ shouldStopTask: true,
196
+ exhausted: true,
197
+ },
198
+ notification,
199
+ };
200
+ }
201
+
202
+ activeFailure = taskFailure;
203
+ while (true) {
204
+ const nextRecoveryIndex = recoveryHistory.length + 1;
205
+ const recoveryPrompt = buildRuntimeRecoveryPrompt({
206
+ basePrompt: prompt,
207
+ engine: normalizedEngine,
208
+ phaseLabel: executionMode === "analyze" ? "分析/复核" : "执行",
209
+ failure: activeFailure,
210
+ result: {
211
+ ...taskAttempt.result,
212
+ finalMessage: taskAttempt.finalMessage,
213
+ },
214
+ nextRecoveryIndex,
215
+ maxRecoveries: recoveryPolicy[activeFailure.family === "hard" ? "hardRetryDelaysSeconds" : "softRetryDelaysSeconds"].length,
216
+ });
217
+ writeText(
218
+ path.join(runDir, `${prefix}-auto-recovery-${String(nextRecoveryIndex).padStart(2, "0")}-prompt.md`),
219
+ recoveryPrompt,
220
+ );
221
+ const delayMs = selectRuntimeRecoveryDelayMs(recoveryPolicy, activeFailure.family, nextRecoveryIndex);
222
+ if (delayMs < 0) {
223
+ const finalRecoverySummary = renderRuntimeRecoverySummary(recoveryHistory, activeFailure);
224
+ const notification = await maybeSendStopNotification({
225
+ context,
226
+ runDir,
227
+ engine: normalizedEngine,
228
+ executionMode,
229
+ failure: activeFailure,
230
+ result: taskAttempt.result,
231
+ recoveryHistory,
232
+ });
233
+ const notificationNote = buildNotificationNote(notification);
234
+ const finalizedResult = {
235
+ ...taskAttempt.result,
236
+ stderr: [
237
+ taskAttempt.result.stderr,
238
+ "",
239
+ finalRecoverySummary,
240
+ notificationNote,
241
+ ].filter(Boolean).join("\n").trim(),
242
+ };
243
+
244
+ writeText(path.join(runDir, `${prefix}-prompt.md`), currentPrompt);
245
+ writeEngineRunArtifacts(runDir, prefix, finalizedResult, taskAttempt.finalMessage);
246
+ writeRuntimeStatus("paused_manual", {
247
+ attemptPrefix,
248
+ recoveryCount: recoveryHistory.length,
249
+ recoveryHistory,
250
+ recoverySummary: finalRecoverySummary,
251
+ finalMessage: taskAttempt.finalMessage,
252
+ code: finalizedResult.code,
253
+ failureCode: activeFailure.code,
254
+ failureFamily: activeFailure.family,
255
+ failureReason: activeFailure.reason,
256
+ notification,
257
+ });
258
+
259
+ return {
260
+ ...finalizedResult,
261
+ finalMessage: taskAttempt.finalMessage,
262
+ recoveryCount: recoveryHistory.length,
263
+ recoveryHistory,
264
+ recoverySummary: finalRecoverySummary,
265
+ recoveryFailure: {
266
+ ...activeFailure,
267
+ shouldStopTask: true,
268
+ exhausted: true,
269
+ },
270
+ notification,
271
+ };
272
+ }
273
+
274
+ writeRuntimeStatus("retry_waiting", {
275
+ attemptPrefix,
276
+ recoveryCount: nextRecoveryIndex,
277
+ recoveryHistory,
278
+ nextRetryDelayMs: delayMs,
279
+ nextRetryAt: new Date(Date.now() + delayMs).toISOString(),
280
+ failureCode: activeFailure.code,
281
+ failureFamily: activeFailure.family,
282
+ failureReason: activeFailure.reason,
283
+ });
284
+ if (delayMs > 0) {
285
+ const canContinue = await sleepWithLease(delayMs, hostLease);
286
+ if (!canContinue) {
287
+ const stopped = buildHostLeaseStoppedResult("检测到宿主窗口已关闭,HelloLoop 已停止等待中的自动恢复。");
288
+ writeRuntimeStatus("stopped_host_closed", {
289
+ attemptPrefix,
290
+ recoveryCount: recoveryHistory.length,
291
+ recoveryHistory,
292
+ code: stopped.code,
293
+ failureCode: "host_closed",
294
+ failureFamily: "host_lease",
295
+ failureReason: stopped.leaseReason,
296
+ });
297
+ return {
298
+ ...stopped,
299
+ finalMessage: taskAttempt.finalMessage,
300
+ recoveryCount: recoveryHistory.length,
301
+ recoveryHistory,
302
+ recoverySummary: "",
303
+ recoveryFailure: null,
304
+ notification: null,
305
+ };
306
+ }
307
+ }
308
+
309
+ const probeAttempt = await runEngineHealthProbe({
310
+ engine: normalizedEngine,
311
+ invocation,
312
+ context,
313
+ runDir,
314
+ resolvedPolicy,
315
+ recoveryPolicy,
316
+ writeRuntimeStatus,
317
+ recoveryCount: nextRecoveryIndex,
318
+ recoveryHistory,
319
+ hostLease,
320
+ env,
321
+ probeIndex: nextRecoveryIndex,
322
+ });
323
+ if (probeAttempt.result.leaseExpired) {
324
+ writeRuntimeStatus("stopped_host_closed", {
325
+ attemptPrefix: probeAttempt.attemptPrefix,
326
+ recoveryCount: recoveryHistory.length,
327
+ recoveryHistory,
328
+ code: probeAttempt.result.code,
329
+ failureCode: "host_closed",
330
+ failureFamily: "host_lease",
331
+ failureReason: probeAttempt.result.leaseReason,
332
+ });
333
+ return {
334
+ ...probeAttempt.result,
335
+ finalMessage: probeAttempt.finalMessage,
336
+ recoveryCount: recoveryHistory.length,
337
+ recoveryHistory,
338
+ recoverySummary: "",
339
+ recoveryFailure: null,
340
+ notification: null,
341
+ };
342
+ }
343
+ const recoveryRecord = {
344
+ recoveryIndex: nextRecoveryIndex,
345
+ family: activeFailure.family,
346
+ code: activeFailure.code,
347
+ reason: activeFailure.reason,
348
+ delaySeconds: Math.floor(delayMs / 1000),
349
+ taskStatus: "failed",
350
+ taskCode: taskAttempt.result.code,
351
+ taskAttemptPrefix: attemptPrefix,
352
+ probeStatus: probeAttempt.result.ok ? "ok" : "failed",
353
+ probeCode: probeAttempt.result.code,
354
+ probeAttemptPrefix: probeAttempt.attemptPrefix,
355
+ probeFailureCode: probeAttempt.failure?.code || "",
356
+ probeFailureFamily: probeAttempt.failure?.family || "",
357
+ probeFailureReason: probeAttempt.failure?.reason || "",
358
+ watchdogTriggered: taskAttempt.result.watchdogTriggered === true || probeAttempt.result.watchdogTriggered === true,
359
+ };
360
+ recoveryHistory.push(recoveryRecord);
361
+ writeJson(path.join(
362
+ runDir,
363
+ `${prefix}-auto-recovery-${String(nextRecoveryIndex).padStart(2, "0")}.json`,
364
+ ), {
365
+ ...recoveryRecord,
366
+ engine: normalizedEngine,
367
+ phase: executionMode,
368
+ stdoutTail: tailText(taskAttempt.result.stdout, 20),
369
+ stderrTail: tailText(taskAttempt.result.stderr, 20),
370
+ finalMessageTail: tailText(taskAttempt.finalMessage, 20),
371
+ probeStdoutTail: tailText(probeAttempt.result.stdout, 20),
372
+ probeStderrTail: tailText(probeAttempt.result.stderr, 20),
373
+ probeFinalMessageTail: tailText(probeAttempt.finalMessage, 20),
374
+ createdAt: nowIso(),
375
+ });
376
+
377
+ if (!probeAttempt.result.ok) {
378
+ activeFailure = probeAttempt.failure;
379
+ writeRuntimeStatus("probe_failed", {
380
+ attemptPrefix: probeAttempt.attemptPrefix,
381
+ recoveryCount: nextRecoveryIndex,
382
+ recoveryHistory,
383
+ failureCode: activeFailure.code,
384
+ failureFamily: activeFailure.family,
385
+ failureReason: activeFailure.reason,
386
+ });
387
+ continue;
388
+ }
389
+
390
+ currentPrompt = recoveryPrompt;
391
+ currentRecoveryCount = nextRecoveryIndex;
392
+ break;
393
+ }
394
+ }
395
+ }
@@ -21,6 +21,8 @@ const HARD_STOP_MATCHERS = [
21
21
  "400 bad request",
22
22
  "bad request",
23
23
  "invalid request",
24
+ "invalid schema",
25
+ "invalid_json_schema",
24
26
  "invalid argument",
25
27
  "invalid_argument",
26
28
  "failed to parse",
@@ -28,6 +30,7 @@ const HARD_STOP_MATCHERS = [
28
30
  "malformed",
29
31
  "schema validation",
30
32
  "json schema",
33
+ "response_format",
31
34
  "unexpected argument",
32
35
  "unknown option",
33
36
  ],
@@ -0,0 +1,285 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ import { spawn } from "node:child_process";
4
+
5
+ import { createContext } from "./context.mjs";
6
+ import { nowIso, readJson, writeJson, readTextIfExists, timestampForFile } from "./common.mjs";
7
+ import { isHostLeaseAlive, renderHostLeaseLabel, resolveHostLease } from "./host_lease.mjs";
8
+ import { runLoop, runOnce } from "./runner.mjs";
9
+
10
+ const ACTIVE_STATUSES = new Set(["launching", "running"]);
11
+ const FINAL_STATUSES = new Set(["completed", "failed", "stopped"]);
12
+
13
+ function removeIfExists(filePath) {
14
+ try {
15
+ fs.rmSync(filePath, { force: true });
16
+ } catch {
17
+ // ignore cleanup failures
18
+ }
19
+ }
20
+
21
+ function readJsonIfExists(filePath) {
22
+ return fs.existsSync(filePath) ? readJson(filePath) : null;
23
+ }
24
+
25
+ function isPidAlive(pid) {
26
+ const numberPid = Number(pid || 0);
27
+ if (!Number.isFinite(numberPid) || numberPid <= 0) {
28
+ return false;
29
+ }
30
+ try {
31
+ process.kill(numberPid, 0);
32
+ return true;
33
+ } catch (error) {
34
+ return String(error?.code || "") === "EPERM";
35
+ }
36
+ }
37
+
38
+ function buildState(context, patch = {}) {
39
+ const current = readJsonIfExists(context.supervisorStateFile) || {};
40
+ return {
41
+ ...current,
42
+ ...patch,
43
+ updatedAt: nowIso(),
44
+ };
45
+ }
46
+
47
+ function writeState(context, patch) {
48
+ writeJson(context.supervisorStateFile, buildState(context, patch));
49
+ }
50
+
51
+ function toSerializableOptions(options = {}) {
52
+ return JSON.parse(JSON.stringify(options));
53
+ }
54
+
55
+ export function readSupervisorState(context) {
56
+ return readJsonIfExists(context.supervisorStateFile);
57
+ }
58
+
59
+ export function hasActiveSupervisor(context) {
60
+ const state = readSupervisorState(context);
61
+ return Boolean(state?.status && ACTIVE_STATUSES.has(String(state.status)) && isPidAlive(state.pid));
62
+ }
63
+
64
+ export function renderSupervisorLaunchSummary(session) {
65
+ return [
66
+ `HelloLoop supervisor 已启动:${session.sessionId}`,
67
+ `- 宿主租约:${renderHostLeaseLabel(session.lease)}`,
68
+ "- 当前 turn 若被中断,只要当前宿主窗口仍存活,本轮自动执行会继续。",
69
+ "- 如需主动停止,直接关闭当前 CLI 窗口即可。",
70
+ ].join("\n");
71
+ }
72
+
73
+ export function launchSupervisedCommand(context, command, options = {}) {
74
+ const existing = readSupervisorState(context);
75
+ if (existing?.status && ACTIVE_STATUSES.has(String(existing.status)) && isPidAlive(existing.pid)) {
76
+ throw new Error(`已有 HelloLoop supervisor 正在运行:${existing.sessionId || "unknown"}`);
77
+ }
78
+
79
+ const sessionId = timestampForFile();
80
+ const lease = resolveHostLease({ hostContext: options.hostContext });
81
+ const request = {
82
+ sessionId,
83
+ command,
84
+ context: {
85
+ repoRoot: context.repoRoot,
86
+ configDirName: context.configDirName,
87
+ },
88
+ options: toSerializableOptions(options),
89
+ lease,
90
+ };
91
+
92
+ fs.mkdirSync(context.supervisorRoot, { recursive: true });
93
+ removeIfExists(context.supervisorResultFile);
94
+ removeIfExists(context.supervisorStdoutFile);
95
+ removeIfExists(context.supervisorStderrFile);
96
+ writeJson(context.supervisorRequestFile, request);
97
+ writeState(context, {
98
+ sessionId,
99
+ status: "launching",
100
+ command,
101
+ lease,
102
+ startedAt: nowIso(),
103
+ pid: 0,
104
+ });
105
+
106
+ const stdoutFd = fs.openSync(context.supervisorStdoutFile, "w");
107
+ const stderrFd = fs.openSync(context.supervisorStderrFile, "w");
108
+ try {
109
+ const child = spawn(process.execPath, [
110
+ path.join(context.bundleRoot, "bin", "helloloop.js"),
111
+ "__supervise",
112
+ "--session-file",
113
+ context.supervisorRequestFile,
114
+ ], {
115
+ cwd: context.repoRoot,
116
+ detached: true,
117
+ shell: false,
118
+ windowsHide: true,
119
+ stdio: ["ignore", stdoutFd, stderrFd],
120
+ env: {
121
+ ...process.env,
122
+ HELLOLOOP_SUPERVISOR_ACTIVE: "1",
123
+ },
124
+ });
125
+ child.unref();
126
+ writeState(context, {
127
+ sessionId,
128
+ status: "running",
129
+ command,
130
+ lease,
131
+ startedAt: nowIso(),
132
+ pid: child.pid ?? 0,
133
+ });
134
+
135
+ return {
136
+ sessionId,
137
+ pid: child.pid ?? 0,
138
+ lease,
139
+ };
140
+ } finally {
141
+ fs.closeSync(stdoutFd);
142
+ fs.closeSync(stderrFd);
143
+ }
144
+ }
145
+
146
+ export async function waitForSupervisedResult(context, session, options = {}) {
147
+ const pollMs = Math.max(100, Number(options.pollMs || 500));
148
+
149
+ while (true) {
150
+ const result = readJsonIfExists(context.supervisorResultFile);
151
+ if (result?.sessionId === session.sessionId) {
152
+ return result;
153
+ }
154
+
155
+ const state = readSupervisorState(context);
156
+ if (state?.sessionId === session.sessionId && FINAL_STATUSES.has(String(state.status || ""))) {
157
+ return {
158
+ sessionId: session.sessionId,
159
+ command: state.command || "",
160
+ exitCode: Number(state.exitCode || 1),
161
+ ok: state.status === "completed",
162
+ error: state.message || readTextIfExists(context.supervisorStderrFile, "").trim() || "HelloLoop supervisor 异常结束。",
163
+ };
164
+ }
165
+
166
+ if (!isPidAlive(session.pid)) {
167
+ return {
168
+ sessionId: session.sessionId,
169
+ command: state?.command || "",
170
+ exitCode: 1,
171
+ ok: false,
172
+ error: readTextIfExists(context.supervisorStderrFile, "").trim() || "HelloLoop supervisor 已退出,但未生成结果文件。",
173
+ };
174
+ }
175
+
176
+ await new Promise((resolve) => {
177
+ setTimeout(resolve, pollMs);
178
+ });
179
+ }
180
+ }
181
+
182
+ export async function runSupervisedCommandFromSessionFile(sessionFile) {
183
+ const request = readJson(sessionFile);
184
+ const context = createContext(request.context || {});
185
+ const command = String(request.command || "").trim();
186
+ const lease = request.lease || {};
187
+ const commandOptions = {
188
+ ...(request.options || {}),
189
+ hostLease: lease,
190
+ };
191
+
192
+ writeState(context, {
193
+ sessionId: request.sessionId,
194
+ command,
195
+ status: "running",
196
+ lease,
197
+ pid: process.pid,
198
+ startedAt: nowIso(),
199
+ });
200
+
201
+ try {
202
+ if (!isHostLeaseAlive(lease)) {
203
+ const stopped = {
204
+ sessionId: request.sessionId,
205
+ command,
206
+ exitCode: 1,
207
+ ok: false,
208
+ stopped: true,
209
+ error: "检测到宿主窗口已关闭,HelloLoop supervisor 未继续执行。",
210
+ };
211
+ writeJson(context.supervisorResultFile, stopped);
212
+ writeState(context, {
213
+ sessionId: request.sessionId,
214
+ command,
215
+ status: "stopped",
216
+ exitCode: stopped.exitCode,
217
+ message: stopped.error,
218
+ completedAt: nowIso(),
219
+ });
220
+ return;
221
+ }
222
+
223
+ if (command === "run-loop") {
224
+ const results = await runLoop(context, commandOptions);
225
+ const exitCode = results.some((item) => !item.ok) ? 1 : 0;
226
+ const payload = {
227
+ sessionId: request.sessionId,
228
+ command,
229
+ ok: exitCode === 0,
230
+ exitCode,
231
+ results,
232
+ };
233
+ writeJson(context.supervisorResultFile, payload);
234
+ writeState(context, {
235
+ sessionId: request.sessionId,
236
+ command,
237
+ status: payload.ok ? "completed" : (payload.results.some((item) => item.kind === "host-lease-stopped") ? "stopped" : "failed"),
238
+ exitCode,
239
+ message: payload.ok ? "" : (payload.results.find((item) => !item.ok)?.summary || ""),
240
+ completedAt: nowIso(),
241
+ });
242
+ return;
243
+ }
244
+
245
+ if (command === "run-once") {
246
+ const result = await runOnce(context, commandOptions);
247
+ const payload = {
248
+ sessionId: request.sessionId,
249
+ command,
250
+ ok: result.ok,
251
+ exitCode: result.ok ? 0 : 1,
252
+ result,
253
+ };
254
+ writeJson(context.supervisorResultFile, payload);
255
+ writeState(context, {
256
+ sessionId: request.sessionId,
257
+ command,
258
+ status: payload.ok ? "completed" : (result.kind === "host-lease-stopped" ? "stopped" : "failed"),
259
+ exitCode: payload.exitCode,
260
+ message: result.summary || result.finalMessage || "",
261
+ completedAt: nowIso(),
262
+ });
263
+ return;
264
+ }
265
+
266
+ throw new Error(`不支持的 supervisor 命令:${command}`);
267
+ } catch (error) {
268
+ const payload = {
269
+ sessionId: request.sessionId,
270
+ command,
271
+ ok: false,
272
+ exitCode: 1,
273
+ error: String(error?.stack || error || ""),
274
+ };
275
+ writeJson(context.supervisorResultFile, payload);
276
+ writeState(context, {
277
+ sessionId: request.sessionId,
278
+ command,
279
+ status: "failed",
280
+ exitCode: 1,
281
+ message: payload.error,
282
+ completedAt: nowIso(),
283
+ });
284
+ }
285
+ }