helloloop 0.9.1 → 0.10.0

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 (50) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/README.md +230 -506
  3. package/hosts/claude/marketplace/plugins/helloloop/.claude-plugin/plugin.json +1 -1
  4. package/hosts/gemini/extension/gemini-extension.json +1 -1
  5. package/native/windows-hidden-shell-proxy/HelloLoopHiddenShellProxy.csproj +11 -0
  6. package/native/windows-hidden-shell-proxy/Program.cs +498 -0
  7. package/package.json +4 -2
  8. package/src/activity_projection.mjs +294 -0
  9. package/src/analyze_confirmation.mjs +3 -1
  10. package/src/analyzer.mjs +2 -1
  11. package/src/auto_execution_options.mjs +13 -0
  12. package/src/background_launch.mjs +73 -0
  13. package/src/cli.mjs +49 -1
  14. package/src/cli_analyze_command.mjs +9 -5
  15. package/src/cli_args.mjs +102 -37
  16. package/src/cli_command_handlers.mjs +44 -4
  17. package/src/cli_support.mjs +2 -0
  18. package/src/dashboard_command.mjs +371 -0
  19. package/src/dashboard_tui.mjs +289 -0
  20. package/src/dashboard_web.mjs +351 -0
  21. package/src/dashboard_web_client.mjs +167 -0
  22. package/src/dashboard_web_page.mjs +49 -0
  23. package/src/engine_event_parser_codex.mjs +167 -0
  24. package/src/engine_process_support.mjs +1 -0
  25. package/src/engine_selection.mjs +24 -0
  26. package/src/engine_selection_probe.mjs +10 -6
  27. package/src/engine_selection_settings.mjs +12 -19
  28. package/src/execution_interactivity.mjs +12 -0
  29. package/src/host_continuation.mjs +305 -0
  30. package/src/install_codex.mjs +20 -8
  31. package/src/install_shared.mjs +9 -0
  32. package/src/node_process_launch.mjs +28 -0
  33. package/src/process.mjs +2 -0
  34. package/src/runner_execute_task.mjs +4 -0
  35. package/src/runner_execution_support.mjs +69 -3
  36. package/src/runner_once.mjs +4 -0
  37. package/src/runner_status.mjs +63 -7
  38. package/src/runtime_engine_support.mjs +41 -4
  39. package/src/runtime_engine_task.mjs +7 -0
  40. package/src/runtime_settings.mjs +105 -0
  41. package/src/runtime_settings_loader.mjs +19 -0
  42. package/src/shell_invocation.mjs +227 -9
  43. package/src/supervisor_cli_support.mjs +3 -2
  44. package/src/supervisor_guardian.mjs +307 -0
  45. package/src/supervisor_runtime.mjs +138 -82
  46. package/src/supervisor_state.mjs +64 -0
  47. package/src/supervisor_watch.mjs +92 -48
  48. package/src/terminal_session_limits.mjs +1 -21
  49. package/src/windows_hidden_shell_proxy.mjs +405 -0
  50. package/src/workspace_registry.mjs +155 -0
@@ -0,0 +1,305 @@
1
+ import path from "node:path";
2
+
3
+ import { ensureDir, fileExists, nowIso, readJson, sleep, writeJson, writeText } from "./common.mjs";
4
+ import { createContext } from "./context.mjs";
5
+ import { collectRepoStatusSnapshot } from "./runner_status.mjs";
6
+
7
+ const RESUME_DIR_NAME = "host-resume";
8
+ const SNAPSHOT_FILE_NAME = "resume.json";
9
+ const PROMPT_FILE_NAME = "resume-prompt.md";
10
+
11
+ const ISSUE_MATCHERS = [
12
+ {
13
+ code: "rate_limit",
14
+ label: "429 / 限流或临时容量不足",
15
+ patterns: ["429", "too many requests", "rate limit", "retry limit", "capacity"],
16
+ },
17
+ {
18
+ code: "auth",
19
+ label: "403 / 鉴权、订阅或权限异常",
20
+ patterns: ["403", "forbidden", "subscription_not_found", "subscription", "not authenticated"],
21
+ },
22
+ {
23
+ code: "server",
24
+ label: "5xx / 服务端暂时异常",
25
+ patterns: ["500", "502", "503", "504", "server error", "service unavailable", "bad gateway"],
26
+ },
27
+ {
28
+ code: "network",
29
+ label: "网络或流中断",
30
+ patterns: ["network", "timeout", "timed out", "connection reset", "stream closed", "socket"],
31
+ },
32
+ ];
33
+
34
+ function resumeRoot(context) {
35
+ return path.join(context.configRoot, RESUME_DIR_NAME);
36
+ }
37
+
38
+ function resumeFiles(context) {
39
+ const root = resumeRoot(context);
40
+ return {
41
+ root,
42
+ snapshotFile: path.join(root, SNAPSHOT_FILE_NAME),
43
+ promptFile: path.join(root, PROMPT_FILE_NAME),
44
+ };
45
+ }
46
+
47
+ function normalizeText(value) {
48
+ return String(value || "").trim();
49
+ }
50
+
51
+ function findIssue(summaryText) {
52
+ const normalized = normalizeText(summaryText).toLowerCase();
53
+ if (!normalized) {
54
+ return null;
55
+ }
56
+
57
+ for (const matcher of ISSUE_MATCHERS) {
58
+ if (matcher.patterns.some((pattern) => normalized.includes(String(pattern).toLowerCase()))) {
59
+ return {
60
+ code: matcher.code,
61
+ label: matcher.label,
62
+ summary: normalizeText(summaryText),
63
+ };
64
+ }
65
+ }
66
+
67
+ return {
68
+ code: "unknown",
69
+ label: "宿主中断或未知异常",
70
+ summary: normalizeText(summaryText),
71
+ };
72
+ }
73
+
74
+ function shouldInferIssue(snapshot, summaryText) {
75
+ const normalized = normalizeText(summaryText).toLowerCase();
76
+ if (!normalized) {
77
+ return false;
78
+ }
79
+
80
+ if (["failed", "paused_manual", "probe_failed", "retry_waiting", "stopped_host_closed"].includes(
81
+ normalizeText(snapshot?.runtime?.status),
82
+ )) {
83
+ return true;
84
+ }
85
+ if (["failed", "stopped"].includes(normalizeText(snapshot?.supervisor?.status))) {
86
+ return true;
87
+ }
88
+
89
+ return ISSUE_MATCHERS.some((matcher) => matcher.patterns.some((pattern) => normalized.includes(String(pattern).toLowerCase())));
90
+ }
91
+
92
+ function buildCurrentAction(snapshot) {
93
+ return normalizeText(
94
+ snapshot?.activity?.current?.label
95
+ || snapshot?.runtime?.failureReason
96
+ || snapshot?.latestStatus?.message
97
+ || snapshot?.runtime?.status
98
+ || snapshot?.supervisor?.status
99
+ || "等待新事件",
100
+ );
101
+ }
102
+
103
+ function buildRecentFiles(snapshot) {
104
+ const changes = Array.isArray(snapshot?.activity?.recentFileChanges)
105
+ ? snapshot.activity.recentFileChanges
106
+ : [];
107
+ const firstGroup = changes.find((item) => Array.isArray(item?.changes) && item.changes.length);
108
+ if (!firstGroup) {
109
+ return [];
110
+ }
111
+ return firstGroup.changes.slice(0, 5).map((item) => `${item.kind}:${item.path}`);
112
+ }
113
+
114
+ function buildPromptLines(context, hostResume) {
115
+ const lines = [
116
+ "继续刚才被主 CLI / 主终端中断的 HelloLoop 会话,不要从头重新分析,不要忘记刚才中断前的任务。",
117
+ `目标仓库:${context.repoRoot}`,
118
+ `后台会话 ID:${hostResume.sessionId || "unknown"}`,
119
+ `后台命令:${hostResume.command || "unknown"}`,
120
+ `后台状态:${hostResume.supervisorStatus || "unknown"}`,
121
+ `当前任务:${hostResume.taskTitle || "无"}`,
122
+ `当前阶段:${hostResume.stage || "unknown"}`,
123
+ `当前动作:${hostResume.currentAction || "等待新事件"}`,
124
+ `当前运行状态:${hostResume.runtimeStatus || "idle"}`,
125
+ ];
126
+
127
+ if (hostResume.issue?.label) {
128
+ lines.push(`最近宿主异常:${hostResume.issue.label}`);
129
+ }
130
+ if (hostResume.issue?.summary) {
131
+ lines.push(`异常摘要:${hostResume.issue.summary}`);
132
+ }
133
+ if (hostResume.todoLabel) {
134
+ lines.push(`当前待办进度:${hostResume.todoLabel}`);
135
+ }
136
+ if (hostResume.nextTaskTitle) {
137
+ lines.push(`下一任务:${hostResume.nextTaskTitle}`);
138
+ }
139
+ if (hostResume.recentFiles.length) {
140
+ lines.push(`最近文件变化:${hostResume.recentFiles.join(" | ")}`);
141
+ }
142
+
143
+ lines.push("");
144
+ lines.push("接续规则:");
145
+ lines.push("1. 先读取 HelloLoop 当前状态;优先运行 `helloloop dashboard --json`,或至少运行 `helloloop status`。");
146
+ if (hostResume.supervisorActive) {
147
+ lines.push("2. 如果后台 supervisor 仍在运行,不要重复启动新的主线,只接续观察、汇报和必要控制。");
148
+ } else {
149
+ lines.push("2. 如果后台 supervisor 已停止,基于当前仓库和 backlog 从中断位置继续,不要重建无关任务。");
150
+ }
151
+ lines.push("3. 如果刚才是因为 429 / 403 / 网络抖动等宿主级异常中断,本轮要直接承接原任务,不要要求用户重复描述。");
152
+ lines.push("4. 只有发现后台已停且需要恢复时,才重新执行 `helloloop run-loop` / `helloloop run-once` / `helloloop analyze -y`。");
153
+ lines.push("5. 用户当前明确要求:继续刚才被中断之前的任务,并持续推进上面的开发工作。");
154
+
155
+ return lines;
156
+ }
157
+
158
+ export function buildHostContinuationSnapshot(context, options = {}) {
159
+ const snapshot = options.snapshot || collectRepoStatusSnapshot(context, options);
160
+ const sessionId = normalizeText(options.sessionId || snapshot?.supervisor?.sessionId);
161
+ const supervisorStatus = normalizeText(snapshot?.supervisor?.status);
162
+ const runtimeStatus = normalizeText(snapshot?.runtime?.status || snapshot?.latestStatus?.stage || "idle");
163
+ const currentAction = buildCurrentAction(snapshot);
164
+ const todoCompleted = Number(snapshot?.activity?.todo?.completed || 0);
165
+ const todoTotal = Number(snapshot?.activity?.todo?.total || 0);
166
+ const todoLabel = todoTotal > 0 ? `${todoCompleted}/${todoTotal}` : "";
167
+ const issueSummary = [
168
+ snapshot?.runtime?.failureReason,
169
+ snapshot?.latestStatus?.message,
170
+ snapshot?.runtime?.status,
171
+ currentAction,
172
+ ].filter(Boolean).join("\n");
173
+ const issue = shouldInferIssue(snapshot, issueSummary)
174
+ ? findIssue(issueSummary)
175
+ : null;
176
+ const recentFiles = buildRecentFiles(snapshot);
177
+
178
+ const hostResume = {
179
+ schemaVersion: 1,
180
+ generatedAt: nowIso(),
181
+ repoRoot: context.repoRoot,
182
+ repoName: path.basename(context.repoRoot),
183
+ sessionId,
184
+ command: normalizeText(snapshot?.supervisor?.command),
185
+ supervisorStatus,
186
+ supervisorActive: ["launching", "running"].includes(supervisorStatus),
187
+ stage: normalizeText(snapshot?.latestStatus?.stage),
188
+ taskId: snapshot?.latestStatus?.taskId || "",
189
+ taskTitle: normalizeText(snapshot?.latestStatus?.taskTitle),
190
+ runtimeStatus,
191
+ currentAction,
192
+ todoLabel,
193
+ issue,
194
+ nextTaskId: snapshot?.nextTask?.id || "",
195
+ nextTaskTitle: normalizeText(snapshot?.nextTask?.title),
196
+ recentFiles,
197
+ summary: snapshot?.summary || null,
198
+ prompt: "",
199
+ };
200
+
201
+ hostResume.prompt = buildPromptLines(context, hostResume).join("\n");
202
+ return hostResume;
203
+ }
204
+
205
+ export function refreshHostContinuationArtifacts(context, options = {}) {
206
+ const hostResume = buildHostContinuationSnapshot(context, options);
207
+ const files = resumeFiles(context);
208
+ ensureDir(files.root);
209
+ writeJson(files.snapshotFile, hostResume);
210
+ writeText(files.promptFile, `${hostResume.prompt}\n`);
211
+ return {
212
+ ...hostResume,
213
+ snapshotFile: files.snapshotFile,
214
+ promptFile: files.promptFile,
215
+ };
216
+ }
217
+
218
+ export function readHostContinuationSnapshot(context, options = {}) {
219
+ const files = resumeFiles(context);
220
+ if (!options.refresh && fileExists(files.snapshotFile)) {
221
+ try {
222
+ const loaded = readJson(files.snapshotFile);
223
+ return {
224
+ ...loaded,
225
+ snapshotFile: files.snapshotFile,
226
+ promptFile: files.promptFile,
227
+ };
228
+ } catch {
229
+ // fallback to rebuilding below
230
+ }
231
+ }
232
+ return refreshHostContinuationArtifacts(context, options);
233
+ }
234
+
235
+ export function renderHostContinuationText(hostResume) {
236
+ return [
237
+ "HelloLoop 宿主续跑提示",
238
+ "======================",
239
+ `仓库:${hostResume.repoRoot}`,
240
+ `后台会话:${hostResume.sessionId || "unknown"}`,
241
+ `后台命令:${hostResume.command || "unknown"}`,
242
+ `后台状态:${hostResume.supervisorStatus || "unknown"}`,
243
+ `当前任务:${hostResume.taskTitle || "无"}`,
244
+ `当前阶段:${hostResume.stage || "unknown"}`,
245
+ `当前动作:${hostResume.currentAction || "等待新事件"}`,
246
+ ...(hostResume.issue?.label ? [`最近异常:${hostResume.issue.label}`] : []),
247
+ ...(hostResume.todoLabel ? [`当前待办:${hostResume.todoLabel}`] : []),
248
+ ...(hostResume.nextTaskTitle ? [`下一任务:${hostResume.nextTaskTitle}`] : []),
249
+ "",
250
+ hostResume.prompt,
251
+ ].filter(Boolean).join("\n");
252
+ }
253
+
254
+ function buildResumeSignature(hostResume) {
255
+ return JSON.stringify({
256
+ sessionId: hostResume.sessionId,
257
+ supervisorStatus: hostResume.supervisorStatus,
258
+ taskId: hostResume.taskId,
259
+ stage: hostResume.stage,
260
+ runtimeStatus: hostResume.runtimeStatus,
261
+ currentAction: hostResume.currentAction,
262
+ todoLabel: hostResume.todoLabel,
263
+ issueCode: hostResume.issue?.code || "",
264
+ nextTaskId: hostResume.nextTaskId,
265
+ });
266
+ }
267
+
268
+ export async function runHostContinuationCommand(context, options = {}) {
269
+ const pollMs = Math.max(500, Number(options.pollMs || options.watchPollMs || 2000));
270
+ let previousSignature = "";
271
+
272
+ while (true) {
273
+ const hostResume = refreshHostContinuationArtifacts(context, options);
274
+ const signature = buildResumeSignature(hostResume);
275
+
276
+ if (signature !== previousSignature || !options.watch) {
277
+ previousSignature = signature;
278
+ if (options.json) {
279
+ console.log(JSON.stringify(hostResume));
280
+ } else {
281
+ if (options.watch && process.stdout.isTTY) {
282
+ process.stdout.write("\x1bc");
283
+ }
284
+ console.log(renderHostContinuationText(hostResume));
285
+ }
286
+ }
287
+
288
+ if (!options.watch) {
289
+ return 0;
290
+ }
291
+
292
+ await sleep(pollMs);
293
+ }
294
+ }
295
+
296
+ export function buildDashboardHostContinuation(entry, repoSnapshot) {
297
+ const context = createContext({
298
+ repoRoot: entry.repoRoot,
299
+ configDirName: entry.configDirName,
300
+ });
301
+ return buildHostContinuationSnapshot(context, {
302
+ snapshot: repoSnapshot,
303
+ sessionId: entry.sessionId,
304
+ });
305
+ }
@@ -5,9 +5,11 @@ import {
5
5
  assertPathInside,
6
6
  codexBundleEntries,
7
7
  copyBundleEntries,
8
+ isSamePath,
8
9
  readExistingJsonOrThrow,
9
10
  removePathIfExists,
10
11
  removeTargetIfNeeded,
12
+ runtimeBundleEntries,
11
13
  resolveCodexLocalRoot,
12
14
  resolveHomeDir,
13
15
  } from "./install_shared.mjs";
@@ -145,6 +147,8 @@ export function installCodexHost(bundleRoot, options = {}) {
145
147
  const configFile = path.join(resolvedCodexHome, "config.toml");
146
148
  const manifestFile = path.join(bundleRoot, ".codex-plugin", "plugin.json");
147
149
  const existingMarketplace = readExistingJsonOrThrow(marketplaceFile, "Codex marketplace 配置");
150
+ const bundleIsPluginRoot = isSamePath(bundleRoot, targetPluginRoot);
151
+ const bundleIsInstalledRoot = isSamePath(bundleRoot, targetInstalledPluginRoot);
148
152
 
149
153
  if (!fileExists(manifestFile)) {
150
154
  throw new Error(`未找到 Codex 插件 manifest:${manifestFile}`);
@@ -152,16 +156,24 @@ export function installCodexHost(bundleRoot, options = {}) {
152
156
 
153
157
  assertPathInside(resolvedLocalRoot, targetPluginRoot, "Codex 本地插件目录");
154
158
  assertPathInside(resolvedCodexHome, targetPluginCacheRoot, "Codex 插件缓存目录");
155
- removeTargetIfNeeded(targetPluginRoot, options.force);
156
- removeTargetIfNeeded(targetPluginCacheRoot, options.force);
159
+ if (!bundleIsPluginRoot) {
160
+ removeTargetIfNeeded(targetPluginRoot, options.force);
161
+ }
162
+ if (!bundleIsInstalledRoot) {
163
+ removeTargetIfNeeded(targetPluginCacheRoot, options.force);
164
+ }
157
165
 
158
166
  ensureDir(targetPluginsRoot);
159
- ensureDir(targetPluginRoot);
160
- ensureDir(targetInstalledPluginRoot);
161
- copyBundleEntries(bundleRoot, targetPluginRoot, codexBundleEntries);
162
- copyBundleEntries(bundleRoot, targetInstalledPluginRoot, codexBundleEntries);
163
- removePathIfExists(path.join(targetPluginRoot, ".git"));
164
- removePathIfExists(path.join(targetInstalledPluginRoot, ".git"));
167
+ if (!bundleIsPluginRoot) {
168
+ ensureDir(targetPluginRoot);
169
+ copyBundleEntries(bundleRoot, targetPluginRoot, runtimeBundleEntries);
170
+ removePathIfExists(path.join(targetPluginRoot, ".git"));
171
+ }
172
+ if (!bundleIsInstalledRoot) {
173
+ ensureDir(targetInstalledPluginRoot);
174
+ copyBundleEntries(bundleRoot, targetInstalledPluginRoot, codexBundleEntries);
175
+ removePathIfExists(path.join(targetInstalledPluginRoot, ".git"));
176
+ }
165
177
 
166
178
  ensureDir(path.dirname(marketplaceFile));
167
179
  updateCodexMarketplace(marketplaceFile, existingMarketplace);
@@ -11,6 +11,7 @@ export const runtimeBundleEntries = [
11
11
  "README.md",
12
12
  "bin",
13
13
  "hosts",
14
+ "native",
14
15
  "package.json",
15
16
  "scripts",
16
17
  "skills",
@@ -46,6 +47,14 @@ export function assertPathInside(parentDir, targetDir, label) {
46
47
  }
47
48
  }
48
49
 
50
+ export function isSamePath(leftPath, rightPath) {
51
+ if (!leftPath || !rightPath) {
52
+ return false;
53
+ }
54
+ const normalize = (value) => path.resolve(value).replace(/[\\\/]+$/u, "").toLowerCase();
55
+ return normalize(leftPath) === normalize(rightPath);
56
+ }
57
+
49
58
  function sleepSync(ms) {
50
59
  const shared = new SharedArrayBuffer(4);
51
60
  const view = new Int32Array(shared);
@@ -0,0 +1,28 @@
1
+ import { spawn } from "node:child_process";
2
+
3
+ import {
4
+ HIDDEN_PROCESS_PROXY_TARGET_ENV,
5
+ resolveWindowsHiddenProcessProxyExecutable,
6
+ } from "./windows_hidden_shell_proxy.mjs";
7
+
8
+ export function spawnNodeProcess(options = {}) {
9
+ const useWindowsHiddenProxy = process.platform === "win32";
10
+ const command = useWindowsHiddenProxy
11
+ ? resolveWindowsHiddenProcessProxyExecutable()
12
+ : process.execPath;
13
+
14
+ return spawn(command, Array.isArray(options.args) ? options.args : [], {
15
+ cwd: options.cwd || process.cwd(),
16
+ detached: options.detached === true,
17
+ shell: false,
18
+ windowsHide: true,
19
+ stdio: options.stdio || "pipe",
20
+ env: {
21
+ ...process.env,
22
+ ...(options.env || {}),
23
+ ...(useWindowsHiddenProxy
24
+ ? { [HIDDEN_PROCESS_PROXY_TARGET_ENV]: process.execPath }
25
+ : {}),
26
+ },
27
+ });
28
+ }
package/src/process.mjs CHANGED
@@ -19,6 +19,7 @@ export async function runCodexExec({ context, prompt, runDir, policy, hostLease
19
19
  policy,
20
20
  executionMode: "execute",
21
21
  outputPrefix: "codex",
22
+ skipGitRepoCheck: true,
22
23
  hostLease,
23
24
  });
24
25
  }
@@ -32,6 +33,7 @@ export async function runEngineExec({ engine, context, prompt, runDir, policy, h
32
33
  policy,
33
34
  executionMode: "execute",
34
35
  outputPrefix: engine,
36
+ skipGitRepoCheck: engine === "codex",
35
37
  hostLease,
36
38
  });
37
39
  }
@@ -3,6 +3,7 @@ import path from "node:path";
3
3
  import { rememberEngineSelection } from "./engine_selection.mjs";
4
4
  import { getEngineDisplayName } from "./engine_metadata.mjs";
5
5
  import { ensureDir, nowIso, writeText } from "./common.mjs";
6
+ import { refreshHostContinuationArtifacts } from "./host_continuation.mjs";
6
7
  import { isHostLeaseAlive } from "./host_lease.mjs";
7
8
  import { saveBacklog, writeStatus } from "./config.mjs";
8
9
  import { reviewTaskCompletion } from "./completion_review.mjs";
@@ -277,6 +278,9 @@ export async function executeSingleTask(context, options = {}) {
277
278
  summary: "",
278
279
  message: `开始执行任务:${execution.task.title}`,
279
280
  });
281
+ refreshHostContinuationArtifacts(context, {
282
+ sessionId: options.supervisorSessionId || "",
283
+ });
280
284
 
281
285
  const state = {
282
286
  engineResolution: execution.engineResolution,
@@ -2,7 +2,7 @@ import {
2
2
  rememberEngineSelection,
3
3
  resolveEngineSelection,
4
4
  } from "./engine_selection.mjs";
5
- import { nowIso } from "./common.mjs";
5
+ import { fileExists, nowIso, readJson } from "./common.mjs";
6
6
  import {
7
7
  loadBacklog,
8
8
  loadPolicy,
@@ -13,6 +13,72 @@ import {
13
13
  } from "./config.mjs";
14
14
  import { getTask, selectNextTask, unresolvedDependencies, updateTask } from "./backlog.mjs";
15
15
  import { makeRunDir } from "./runner_status.mjs";
16
+ import { shouldPromptForEngineSelection } from "./execution_interactivity.mjs";
17
+
18
+ function isPidAlive(pid) {
19
+ const value = Number(pid || 0);
20
+ if (!Number.isFinite(value) || value <= 0) {
21
+ return false;
22
+ }
23
+ try {
24
+ process.kill(value, 0);
25
+ return true;
26
+ } catch (error) {
27
+ return String(error?.code || "") === "EPERM";
28
+ }
29
+ }
30
+
31
+ function hasLiveSupervisor(context) {
32
+ if (!fileExists(context.supervisorStateFile)) {
33
+ return false;
34
+ }
35
+ try {
36
+ const supervisor = readJson(context.supervisorStateFile);
37
+ const status = String(supervisor?.status || "").trim();
38
+ if (!["launching", "running"].includes(status)) {
39
+ return false;
40
+ }
41
+ return isPidAlive(supervisor?.pid);
42
+ } catch {
43
+ return false;
44
+ }
45
+ }
46
+
47
+ function recoverStaleInProgressTasks(context, backlog, options = {}) {
48
+ const staleTasks = Array.isArray(backlog?.tasks)
49
+ ? backlog.tasks.filter((task) => task?.status === "in_progress")
50
+ : [];
51
+ if (!staleTasks.length) {
52
+ return backlog;
53
+ }
54
+
55
+ let shouldRecover = !hasLiveSupervisor(context);
56
+ if (!shouldRecover && fileExists(context.statusFile)) {
57
+ try {
58
+ const latestStatus = readJson(context.statusFile);
59
+ const currentSessionId = String(options.supervisorSessionId || "").trim();
60
+ const recordedSessionId = String(latestStatus?.sessionId || "").trim();
61
+ if (currentSessionId && recordedSessionId && currentSessionId !== recordedSessionId) {
62
+ shouldRecover = true;
63
+ }
64
+ } catch {
65
+ // ignore malformed status files and keep current decision
66
+ }
67
+ }
68
+ if (!shouldRecover) {
69
+ return backlog;
70
+ }
71
+
72
+ for (const task of staleTasks) {
73
+ updateTask(backlog, task.id, {
74
+ status: "pending",
75
+ startedAt: "",
76
+ finishedAt: "",
77
+ });
78
+ }
79
+ saveBacklog(context, backlog);
80
+ return backlog;
81
+ }
16
82
 
17
83
  function resolveTask(backlog, options) {
18
84
  if (options.taskId) {
@@ -28,7 +94,7 @@ function resolveTask(backlog, options) {
28
94
  export async function resolveExecutionSetup(context, options = {}) {
29
95
  const policy = loadPolicy(context);
30
96
  const projectConfig = loadProjectConfig(context);
31
- const backlog = loadBacklog(context);
97
+ const backlog = recoverStaleInProgressTasks(context, loadBacklog(context), options);
32
98
  const task = resolveTask(backlog, options);
33
99
  if (!task) {
34
100
  return {
@@ -52,7 +118,7 @@ export async function resolveExecutionSetup(context, options = {}) {
52
118
  context,
53
119
  policy,
54
120
  options,
55
- interactive: !options.yes,
121
+ interactive: shouldPromptForEngineSelection(options),
56
122
  });
57
123
 
58
124
  return {
@@ -1,5 +1,6 @@
1
1
  import { selectNextTask, summarizeBacklog } from "./backlog.mjs";
2
2
  import { loadBacklog, writeStateMarkdown, writeStatus } from "./config.mjs";
3
+ import { refreshHostContinuationArtifacts } from "./host_continuation.mjs";
3
4
  import { executeSingleTask } from "./runner_execute_task.mjs";
4
5
  import { renderStatusMarkdown } from "./runner_status.mjs";
5
6
 
@@ -25,6 +26,9 @@ export async function runOnce(context, options = {}) {
25
26
  lastResult: result.ok ? "本轮成功" : (result.summary || result.kind),
26
27
  nextTask,
27
28
  }));
29
+ refreshHostContinuationArtifacts(context, {
30
+ sessionId: options.supervisorSessionId || "",
31
+ });
28
32
 
29
33
  return result;
30
34
  }
@@ -1,5 +1,10 @@
1
1
  import path from "node:path";
2
2
 
3
+ import {
4
+ readJsonIfExists,
5
+ selectLatestActivityFile,
6
+ selectLatestRuntimeFile,
7
+ } from "./activity_projection.mjs";
3
8
  import { fileExists, readJson, sanitizeId, tailText, timestampForFile } from "./common.mjs";
4
9
  import { renderTaskSummary, selectNextTask, summarizeBacklog } from "./backlog.mjs";
5
10
  import { loadBacklog } from "./config.mjs";
@@ -83,16 +88,42 @@ export function renderStatusMarkdown(context, { summary, currentTask, lastResult
83
88
  ].join("\n");
84
89
  }
85
90
 
86
- export function renderStatusText(context, options = {}) {
91
+ export function collectRepoStatusSnapshot(context, options = {}) {
87
92
  const backlog = loadBacklog(context);
88
93
  const summary = summarizeBacklog(backlog);
89
94
  const nextTask = selectNextTask(backlog, options);
90
- const supervisor = fileExists(context.supervisorStateFile)
91
- ? readJson(context.supervisorStateFile)
92
- : null;
93
- const latestStatus = fileExists(context.statusFile)
94
- ? readJson(context.statusFile)
95
- : null;
95
+ const supervisor = fileExists(context.supervisorStateFile) ? readJson(context.supervisorStateFile) : null;
96
+ const latestStatus = fileExists(context.statusFile) ? readJson(context.statusFile) : null;
97
+ const runtimeFile = latestStatus?.runDir ? selectLatestRuntimeFile(latestStatus.runDir) : "";
98
+ const runtime = readJsonIfExists(runtimeFile);
99
+ const activityFile = runtime?.activityFile && fileExists(runtime.activityFile)
100
+ ? runtime.activityFile
101
+ : (latestStatus?.runDir ? selectLatestActivityFile(latestStatus.runDir, runtime?.attemptPrefix || "") : "");
102
+ const activity = readJsonIfExists(activityFile);
103
+
104
+ return {
105
+ summary,
106
+ nextTask,
107
+ supervisor,
108
+ latestStatus,
109
+ runtimeFile,
110
+ runtime,
111
+ activityFile,
112
+ activity,
113
+ };
114
+ }
115
+
116
+ export function renderStatusText(context, options = {}) {
117
+ const snapshot = collectRepoStatusSnapshot(context, options);
118
+ const {
119
+ summary,
120
+ nextTask,
121
+ supervisor,
122
+ latestStatus,
123
+ runtime,
124
+ activity,
125
+ } = snapshot;
126
+ const hostResume = options.hostResume || null;
96
127
 
97
128
  return [
98
129
  "HelloLoop 状态",
@@ -109,6 +140,9 @@ export function renderStatusText(context, options = {}) {
109
140
  `后台会话:${supervisor.status}`,
110
141
  `后台会话 ID:${supervisor.sessionId || "unknown"}`,
111
142
  `后台租约:${renderHostLeaseLabel(supervisor.lease)}`,
143
+ ...(Number.isFinite(Number(supervisor.guardianRestartCount)) && Number(supervisor.guardianRestartCount) > 0
144
+ ? [`守护重拉起次数:${supervisor.guardianRestartCount}`]
145
+ : []),
112
146
  ]
113
147
  : []),
114
148
  ...(latestStatus?.taskTitle
@@ -118,10 +152,32 @@ export function renderStatusText(context, options = {}) {
118
152
  `当前运行阶段:${latestStatus.stage || "unknown"}`,
119
153
  ]
120
154
  : []),
155
+ ...(runtime?.status
156
+ ? [
157
+ `当前引擎状态:${runtime.status}`,
158
+ ...(Number.isFinite(Number(runtime.recoveryCount))
159
+ ? [`自动恢复次数:${runtime.recoveryCount}`]
160
+ : []),
161
+ ]
162
+ : []),
163
+ ...(activity?.current?.label
164
+ ? [`当前动作:${activity.current.label}`]
165
+ : []),
166
+ ...(activity?.todo?.total
167
+ ? [`当前待办:${activity.todo.completed}/${activity.todo.total}`]
168
+ : []),
169
+ ...(Array.isArray(activity?.activeCommands) && activity.activeCommands[0]?.label
170
+ ? [`活动命令:${activity.activeCommands[0].label}`]
171
+ : []),
172
+ ...(hostResume?.issue?.label
173
+ ? [`宿主续跑:${hostResume.issue.label}`]
174
+ : (hostResume?.supervisorActive ? ["宿主续跑:后台仍在运行,可直接接续观察"] : [])),
121
175
  "",
122
176
  nextTask ? "下一任务:" : "下一任务:无",
123
177
  nextTask ? renderTaskSummary(nextTask) : "",
124
178
  "",
179
+ "聚合看板:helloloop dashboard",
180
+ "续跑提示:helloloop resume-host",
125
181
  "实时观察:helloloop watch",
126
182
  ].filter(Boolean).join("\n");
127
183
  }