helloloop 0.8.4 → 0.8.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/cli.mjs CHANGED
@@ -13,42 +13,66 @@ import {
13
13
  } from "./cli_command_handlers.mjs";
14
14
  import { resolveContextFromOptions, resolveStandardCommandOptions } from "./cli_context.mjs";
15
15
  import { runDoctor } from "./cli_support.mjs";
16
+ import { runSupervisedCommandFromSessionFile } from "./supervisor_runtime.mjs";
17
+ import {
18
+ acquireVisibleTerminalSession,
19
+ releaseCurrentTerminalSession,
20
+ shouldTrackVisibleTerminalCommand,
21
+ } from "./terminal_session_limits.mjs";
16
22
 
17
23
  export async function runCli(argv) {
18
- const parsed = parseArgs(argv);
19
- const command = parsed.command;
20
- if (command === "help" || command === "--help" || command === "-h") {
21
- printHelp();
22
- return;
23
- }
24
+ try {
25
+ const parsed = parseArgs(argv);
26
+ const command = parsed.command;
27
+ if (command === "help" || command === "--help" || command === "-h") {
28
+ printHelp();
29
+ return;
30
+ }
31
+ if (command === "__supervise") {
32
+ if (!parsed.options.sessionFile) {
33
+ throw new Error("缺少 --session-file,无法启动 HelloLoop supervisor。");
34
+ }
35
+ await runSupervisedCommandFromSessionFile(parsed.options.sessionFile);
36
+ return;
37
+ }
24
38
 
25
- if (command === "analyze") {
26
- process.exitCode = await handleAnalyzeCommand(normalizeAnalyzeOptions(parsed.options, process.cwd()));
27
- return;
28
- }
39
+ if (process.env.HELLOLOOP_SUPERVISOR_ACTIVE !== "1" && shouldTrackVisibleTerminalCommand(command)) {
40
+ acquireVisibleTerminalSession({
41
+ command,
42
+ repoRoot: process.cwd(),
43
+ });
44
+ }
29
45
 
30
- const options = resolveStandardCommandOptions(parsed.options);
31
- if (command === "install") {
32
- process.exitCode = handleInstallCommand(options);
33
- return;
34
- }
35
- if (command === "uninstall") {
36
- process.exitCode = handleUninstallCommand(options);
37
- return;
38
- }
46
+ if (command === "analyze") {
47
+ process.exitCode = await handleAnalyzeCommand(normalizeAnalyzeOptions(parsed.options, process.cwd()));
48
+ return;
49
+ }
39
50
 
40
- const context = resolveContextFromOptions(options);
41
- const handlers = {
42
- doctor: () => handleDoctorCommand(context, options, runDoctor),
43
- init: () => handleInitCommand(context),
44
- next: () => handleNextCommand(context, options),
45
- "run-loop": () => handleRunLoopCommand(context, options),
46
- "run-once": () => handleRunOnceCommand(context, options),
47
- status: () => handleStatusCommand(context, options),
48
- };
49
- if (!handlers[command]) {
50
- throw new Error(`未知命令:${command}`);
51
- }
51
+ const options = resolveStandardCommandOptions(parsed.options);
52
+ if (command === "install") {
53
+ process.exitCode = handleInstallCommand(options);
54
+ return;
55
+ }
56
+ if (command === "uninstall") {
57
+ process.exitCode = handleUninstallCommand(options);
58
+ return;
59
+ }
52
60
 
53
- process.exitCode = await handlers[command]();
61
+ const context = resolveContextFromOptions(options);
62
+ const handlers = {
63
+ doctor: () => handleDoctorCommand(context, options, runDoctor),
64
+ init: () => handleInitCommand(context),
65
+ next: () => handleNextCommand(context, options),
66
+ "run-loop": () => handleRunLoopCommand(context, options),
67
+ "run-once": () => handleRunOnceCommand(context, options),
68
+ status: () => handleStatusCommand(context, options),
69
+ };
70
+ if (!handlers[command]) {
71
+ throw new Error(`未知命令:${command}`);
72
+ }
73
+
74
+ process.exitCode = await handlers[command]();
75
+ } finally {
76
+ releaseCurrentTerminalSession();
77
+ }
54
78
  }
@@ -4,7 +4,6 @@ import {
4
4
  confirmAutoExecution,
5
5
  confirmRepoConflictResolution,
6
6
  renderAnalyzeStopMessage,
7
- renderAutoRunSummary,
8
7
  renderRepoConflictStopMessage,
9
8
  } from "./cli_support.mjs";
10
9
  import { analyzeWorkspace } from "./analyzer.mjs";
@@ -12,14 +11,17 @@ import {
12
11
  hasBlockingInputIssues,
13
12
  renderBlockingInputIssueMessage,
14
13
  } from "./analyze_user_input.mjs";
15
- import { loadBacklog, loadPolicy } from "./config.mjs";
14
+ import { loadPolicy } from "./config.mjs";
16
15
  import { createContext } from "./context.mjs";
17
16
  import { createDiscoveryPromptSession, resolveDiscoveryFailureInteractively } from "./discovery_prompt.mjs";
18
17
  import { resolveEngineSelection } from "./engine_selection.mjs";
19
18
  import { resetRepoForRebuild } from "./rebuild.mjs";
20
- import { runLoop } from "./runner.mjs";
21
19
  import { renderRebuildSummary } from "./cli_render.mjs";
22
20
  import { shouldConfirmRepoRebuild } from "./cli_support.mjs";
21
+ import {
22
+ launchSupervisedCommand,
23
+ renderSupervisorLaunchSummary,
24
+ } from "./supervisor_runtime.mjs";
23
25
 
24
26
  async function resolveAnalyzeEngineSelection(options) {
25
27
  if (options.engineResolution?.ok) {
@@ -222,15 +224,16 @@ async function maybeRunAutoExecution(result, activeOptions) {
222
224
 
223
225
  console.log("");
224
226
  console.log("开始自动接续执行...");
225
- const results = await runLoop(result.context, {
227
+ const session = launchSupervisedCommand(result.context, "run-loop", {
226
228
  ...activeOptions,
229
+ supervised: false,
227
230
  engineResolution: result.engineResolution?.ok ? result.engineResolution : activeOptions.engineResolution,
228
231
  maxTasks: resolveAutoRunMaxTasks(result.backlog, activeOptions) || undefined,
229
232
  fullAutoMainline: true,
230
233
  });
231
- const refreshedBacklog = loadBacklog(result.context);
232
- console.log(renderAutoRunSummary(result.context, refreshedBacklog, results, activeOptions));
233
- return results.some((item) => !item.ok) ? 1 : 0;
234
+ console.log(renderSupervisorLaunchSummary(session));
235
+ console.log("- 已切换为后台执行;可稍后运行 `helloloop status` 查看进度。");
236
+ return 0;
234
237
  }
235
238
 
236
239
  export async function handleAnalyzeCommand(options) {
package/src/cli_args.mjs CHANGED
@@ -1,6 +1,7 @@
1
1
  const REPO_ROOT_PLACEHOLDER = "<REPO_ROOT>";
2
2
  const DOCS_PATH_PLACEHOLDER = "<DOCS_PATH>";
3
3
  const KNOWN_COMMANDS = new Set([
4
+ "__supervise",
4
5
  "analyze",
5
6
  "install",
6
7
  "uninstall",
@@ -49,6 +50,8 @@ export function parseArgs(argv) {
49
50
  else if (arg === "--claude-home") { options.claudeHome = rest[index + 1]; index += 1; }
50
51
  else if (arg === "--gemini-home") { options.geminiHome = rest[index + 1]; index += 1; }
51
52
  else if (arg === "--config-dir") { options.configDirName = rest[index + 1]; index += 1; }
53
+ else if (arg === "--session-file") { options.sessionFile = rest[index + 1]; index += 1; }
54
+ else if (arg === "--supervised") options.supervised = true;
52
55
  else if (arg === "--required-doc") { options.requiredDocs.push(rest[index + 1]); index += 1; }
53
56
  else if (arg === "--constraint") { options.constraints.push(rest[index + 1]); index += 1; }
54
57
  else { options.positionalArgs.push(arg); }
@@ -87,6 +90,7 @@ function helpText() {
87
90
  " --max-tasks <n> run-loop 最多执行 n 个任务",
88
91
  " --max-attempts <n> 每种策略内最多重试 n 次",
89
92
  " --max-strategies <n> 单任务最多切换 n 种策略继续重试",
93
+ " --supervised 兼容保留;当前版本默认就会通过独立 supervisor 后台执行",
90
94
  " --allow-high-risk 允许执行 medium/high/critical 风险任务",
91
95
  " --rebuild-existing 分析判断当前项目与文档冲突时,自动清理当前项目后按文档重建",
92
96
  " --required-doc <p> 增加一个全局必读文档(AGENTS.md 会被自动忽略)",
@@ -95,6 +99,7 @@ function helpText() {
95
99
  "补充说明:",
96
100
  " analyze 默认支持在命令后混合传入引擎、路径和自然语言要求。",
97
101
  " 如果同时检测到多个可用引擎且没有明确指定,会先询问你选择。",
102
+ " 当前版本默认会把自动执行 / run-once / run-loop 切到后台 supervisor。",
98
103
  " 示例:npx helloloop claude <DOCS_PATH> <PROJECT_ROOT> 先分析偏差,不要执行",
99
104
  ].join("\n");
100
105
  }
@@ -6,6 +6,27 @@ import { syncUserSettingsFile } from "./engine_selection_settings.mjs";
6
6
  import { installPluginBundle, uninstallPluginBundle } from "./install.mjs";
7
7
  import { runLoop, runOnce, renderStatusText } from "./runner.mjs";
8
8
  import { renderInstallSummary, renderUninstallSummary } from "./cli_render.mjs";
9
+ import {
10
+ launchSupervisedCommand,
11
+ renderSupervisorLaunchSummary,
12
+ } from "./supervisor_runtime.mjs";
13
+
14
+ function shouldUseSupervisor(options = {}) {
15
+ return !options.dryRun
16
+ && process.env.HELLOLOOP_SUPERVISOR_ACTIVE !== "1";
17
+ }
18
+
19
+ async function runSupervised(context, command, options = {}) {
20
+ const session = launchSupervisedCommand(context, command, options);
21
+ console.log(renderSupervisorLaunchSummary(session));
22
+ console.log("- 已切换为后台执行;可稍后运行 `helloloop status` 查看进度。");
23
+ return {
24
+ detached: true,
25
+ exitCode: 0,
26
+ ok: true,
27
+ session,
28
+ };
29
+ }
9
30
 
10
31
  export function handleInstallCommand(options) {
11
32
  const userSettings = syncUserSettingsFile({
@@ -87,6 +108,11 @@ export async function handleNextCommand(context, options) {
87
108
  }
88
109
 
89
110
  export async function handleRunOnceCommand(context, options) {
111
+ if (shouldUseSupervisor(options)) {
112
+ const payload = await runSupervised(context, "run-once", options);
113
+ return payload.exitCode || 0;
114
+ }
115
+
90
116
  const result = await runOnce(context, options);
91
117
  if (!result.ok) {
92
118
  console.error(result.summary || "执行失败。");
@@ -106,6 +132,11 @@ export async function handleRunOnceCommand(context, options) {
106
132
  }
107
133
 
108
134
  export async function handleRunLoopCommand(context, options) {
135
+ if (shouldUseSupervisor(options)) {
136
+ const payload = await runSupervised(context, "run-loop", options);
137
+ return payload.exitCode || 0;
138
+ }
139
+
109
140
  const results = await runLoop(context, options);
110
141
  const failed = results.find((item) => !item.ok);
111
142
 
@@ -189,6 +189,7 @@ export async function reviewTaskCompletion({
189
189
  verifyResult = null,
190
190
  runDir,
191
191
  policy = {},
192
+ hostLease = null,
192
193
  }) {
193
194
  const prompt = buildTaskReviewPrompt({
194
195
  task,
@@ -209,6 +210,7 @@ export async function reviewTaskCompletion({
209
210
  outputSchemaFile: schemaFile,
210
211
  outputPrefix: `${engine}-task-review`,
211
212
  skipGitRepoCheck: true,
213
+ hostLease,
212
214
  });
213
215
 
214
216
  if (!reviewResult.ok) {
package/src/context.mjs CHANGED
@@ -30,6 +30,12 @@ export function createContext(options = {}) {
30
30
  statusFile: resolveFrom(configRoot, "status.json"),
31
31
  stateFile: resolveFrom(configRoot, "STATE.md"),
32
32
  runsDir: resolveFrom(configRoot, "runs"),
33
+ supervisorRoot: resolveFrom(configRoot, "supervisor"),
34
+ supervisorRequestFile: resolveFrom(configRoot, "supervisor", "request.json"),
35
+ supervisorStateFile: resolveFrom(configRoot, "supervisor", "state.json"),
36
+ supervisorResultFile: resolveFrom(configRoot, "supervisor", "result.json"),
37
+ supervisorStdoutFile: resolveFrom(configRoot, "supervisor", "stdout.log"),
38
+ supervisorStderrFile: resolveFrom(configRoot, "supervisor", "stderr.log"),
33
39
  repoVerifyFile: resolveFrom(repoRoot, ".helloagents", "verify.yaml"),
34
40
  };
35
41
  }
@@ -38,6 +38,27 @@ export function runChild(command, args, options = {}) {
38
38
  let watchdogReason = "";
39
39
  let stallWarned = false;
40
40
  let killTimer = null;
41
+ let leaseExpired = false;
42
+ let leaseReason = "";
43
+
44
+ const requestLeaseTermination = () => {
45
+ if (leaseExpired) {
46
+ return;
47
+ }
48
+ leaseExpired = true;
49
+ leaseReason = String(options.leaseStopReason || "检测到宿主窗口已关闭,HelloLoop 已停止当前子进程。").trim();
50
+ stderr = [
51
+ stderr.trim(),
52
+ `[HelloLoop host-lease] ${leaseReason}`,
53
+ ].filter(Boolean).join("\n");
54
+ emitHeartbeat("lease_terminating", {
55
+ message: leaseReason,
56
+ });
57
+ child.kill();
58
+ killTimer = setTimeout(() => {
59
+ child.kill("SIGKILL");
60
+ }, killGraceMs);
61
+ };
41
62
 
42
63
  const emitHeartbeat = (status, extra = {}) => {
43
64
  options.onHeartbeat?.({
@@ -50,6 +71,8 @@ export function runChild(command, args, options = {}) {
50
71
  idleSeconds: Math.max(0, Math.floor((Date.now() - lastOutputAt) / 1000)),
51
72
  watchdogTriggered,
52
73
  watchdogReason,
74
+ leaseExpired,
75
+ leaseReason,
53
76
  ...extra,
54
77
  });
55
78
  };
@@ -61,6 +84,11 @@ export function runChild(command, args, options = {}) {
61
84
 
62
85
  const heartbeatTimer = heartbeatIntervalMs > 0
63
86
  ? setInterval(() => {
87
+ if (typeof options.shouldKeepRunning === "function" && !options.shouldKeepRunning()) {
88
+ requestLeaseTermination();
89
+ return;
90
+ }
91
+
64
92
  const idleMs = Date.now() - lastOutputAt;
65
93
  if (stallWarningMs > 0 && idleMs >= stallWarningMs && !stallWarned) {
66
94
  stallWarned = true;
@@ -153,6 +181,8 @@ export function runChild(command, args, options = {}) {
153
181
  idleTimeout: watchdogTriggered,
154
182
  watchdogTriggered,
155
183
  watchdogReason,
184
+ leaseExpired,
185
+ leaseReason,
156
186
  });
157
187
  });
158
188
 
@@ -178,6 +208,8 @@ export function runChild(command, args, options = {}) {
178
208
  idleTimeout: watchdogTriggered,
179
209
  watchdogTriggered,
180
210
  watchdogReason,
211
+ leaseExpired,
212
+ leaseReason,
181
213
  });
182
214
  });
183
215
  });
@@ -24,6 +24,15 @@ function defaultEmailNotificationSettings() {
24
24
  };
25
25
  }
26
26
 
27
+ function defaultTerminalConcurrencySettings() {
28
+ return {
29
+ enabled: true,
30
+ visibleMax: 8,
31
+ backgroundMax: 8,
32
+ totalMax: 8,
33
+ };
34
+ }
35
+
27
36
  function defaultUserSettings() {
28
37
  return {
29
38
  defaultEngine: "",
@@ -31,6 +40,9 @@ function defaultUserSettings() {
31
40
  notifications: {
32
41
  email: defaultEmailNotificationSettings(),
33
42
  },
43
+ runtime: {
44
+ terminalConcurrency: defaultTerminalConcurrencySettings(),
45
+ },
34
46
  };
35
47
  }
36
48
 
@@ -0,0 +1,204 @@
1
+ import path from "node:path";
2
+ import { execFileSync } from "node:child_process";
3
+
4
+ import { getHostDisplayName, normalizeHostContext } from "./engine_metadata.mjs";
5
+
6
+ const HOST_PROCESS_NAMES = Object.freeze({
7
+ codex: ["codex", "codex.exe"],
8
+ claude: ["claude", "claude.exe"],
9
+ gemini: ["gemini", "gemini.exe"],
10
+ });
11
+
12
+ const SHELL_PROCESS_NAMES = new Set([
13
+ "bash",
14
+ "cmd",
15
+ "cmd.exe",
16
+ "fish",
17
+ "nu",
18
+ "nu.exe",
19
+ "powershell",
20
+ "powershell.exe",
21
+ "pwsh",
22
+ "pwsh.exe",
23
+ "sh",
24
+ "tmux",
25
+ "tmux.exe",
26
+ "zsh",
27
+ ]);
28
+
29
+ function normalizeProcessName(value) {
30
+ const raw = path.basename(String(value || "").trim()).toLowerCase();
31
+ return raw;
32
+ }
33
+
34
+ function parseUnixProcessTable(text) {
35
+ return String(text || "")
36
+ .split(/\r?\n/)
37
+ .map((line) => line.trim())
38
+ .filter(Boolean)
39
+ .map((line) => {
40
+ const match = line.match(/^(\d+)\s+(\d+)\s+(.+)$/);
41
+ if (!match) {
42
+ return null;
43
+ }
44
+ return {
45
+ pid: Number(match[1]),
46
+ parentPid: Number(match[2]),
47
+ name: normalizeProcessName(match[3]),
48
+ };
49
+ })
50
+ .filter(Boolean);
51
+ }
52
+
53
+ function loadProcessTable() {
54
+ if (process.platform === "win32") {
55
+ const command = "Get-CimInstance Win32_Process | Select-Object ProcessId,ParentProcessId,Name | ConvertTo-Json -Compress";
56
+ const text = execFileSync("powershell.exe", [
57
+ "-NoLogo",
58
+ "-NoProfile",
59
+ "-Command",
60
+ command,
61
+ ], {
62
+ encoding: "utf8",
63
+ stdio: ["ignore", "pipe", "ignore"],
64
+ windowsHide: true,
65
+ }).trim();
66
+ const payload = JSON.parse(text || "[]");
67
+ const rows = Array.isArray(payload) ? payload : [payload];
68
+ return rows.map((item) => ({
69
+ pid: Number(item?.ProcessId || 0),
70
+ parentPid: Number(item?.ParentProcessId || 0),
71
+ name: normalizeProcessName(item?.Name || ""),
72
+ })).filter((item) => item.pid > 0);
73
+ }
74
+
75
+ const text = execFileSync("ps", [
76
+ "-eo",
77
+ "pid=,ppid=,comm=",
78
+ ], {
79
+ encoding: "utf8",
80
+ stdio: ["ignore", "pipe", "ignore"],
81
+ });
82
+ return parseUnixProcessTable(text);
83
+ }
84
+
85
+ function buildAncestry(currentPid = process.pid) {
86
+ const rows = loadProcessTable();
87
+ const byPid = new Map(rows.map((item) => [item.pid, item]));
88
+ const ancestry = [];
89
+ const seen = new Set();
90
+ let cursor = byPid.get(Number(currentPid)) || {
91
+ pid: Number(currentPid),
92
+ parentPid: Number(process.ppid || 0),
93
+ name: normalizeProcessName(process.argv[0] || "node"),
94
+ };
95
+
96
+ while (cursor && cursor.pid > 0 && !seen.has(cursor.pid)) {
97
+ ancestry.push(cursor);
98
+ seen.add(cursor.pid);
99
+ cursor = byPid.get(cursor.parentPid);
100
+ }
101
+
102
+ return ancestry;
103
+ }
104
+
105
+ function matchesHostContext(name, hostContext) {
106
+ return (HOST_PROCESS_NAMES[hostContext] || []).includes(name);
107
+ }
108
+
109
+ function pickLeaseCandidate(ancestry, hostContext) {
110
+ const parents = ancestry.slice(1);
111
+ if (!parents.length) {
112
+ return null;
113
+ }
114
+
115
+ if (hostContext !== "terminal") {
116
+ const matchedHost = parents.find((item) => matchesHostContext(item.name, hostContext));
117
+ if (matchedHost) {
118
+ return { ...matchedHost, kind: "host" };
119
+ }
120
+ }
121
+
122
+ const anyKnownHost = parents.find((item) => (
123
+ matchesHostContext(item.name, "codex")
124
+ || matchesHostContext(item.name, "claude")
125
+ || matchesHostContext(item.name, "gemini")
126
+ ));
127
+ if (anyKnownHost) {
128
+ return { ...anyKnownHost, kind: "host" };
129
+ }
130
+
131
+ const shell = parents.find((item) => SHELL_PROCESS_NAMES.has(item.name));
132
+ if (shell) {
133
+ return { ...shell, kind: "shell" };
134
+ }
135
+
136
+ return {
137
+ ...parents[0],
138
+ kind: "parent",
139
+ };
140
+ }
141
+
142
+ export function resolveHostLease({ hostContext, env = process.env, currentPid = process.pid } = {}) {
143
+ const normalizedHostContext = normalizeHostContext(hostContext);
144
+ const overridePid = Number(env.HELLOLOOP_HOST_LEASE_PID || 0);
145
+ if (Number.isFinite(overridePid) && overridePid > 0) {
146
+ return {
147
+ pid: overridePid,
148
+ name: normalizeProcessName(env.HELLOLOOP_HOST_LEASE_NAME || normalizedHostContext || "host"),
149
+ kind: "override",
150
+ hostContext: normalizedHostContext,
151
+ hostDisplayName: getHostDisplayName(normalizedHostContext),
152
+ };
153
+ }
154
+
155
+ try {
156
+ const candidate = pickLeaseCandidate(buildAncestry(currentPid), normalizedHostContext);
157
+ if (candidate?.pid > 0) {
158
+ return {
159
+ pid: candidate.pid,
160
+ name: candidate.name,
161
+ kind: candidate.kind,
162
+ hostContext: normalizedHostContext,
163
+ hostDisplayName: getHostDisplayName(normalizedHostContext),
164
+ };
165
+ }
166
+ } catch {
167
+ // ignore host lease discovery failures and fall back to immediate parent
168
+ }
169
+
170
+ return {
171
+ pid: Number(process.ppid || 0),
172
+ name: "",
173
+ kind: "fallback_parent",
174
+ hostContext: normalizedHostContext,
175
+ hostDisplayName: getHostDisplayName(normalizedHostContext),
176
+ };
177
+ }
178
+
179
+ export function isHostLeaseAlive(lease = {}) {
180
+ const pid = Number(lease?.pid || 0);
181
+ if (!Number.isFinite(pid) || pid <= 0) {
182
+ return true;
183
+ }
184
+
185
+ try {
186
+ process.kill(pid, 0);
187
+ return true;
188
+ } catch (error) {
189
+ return String(error?.code || "") === "EPERM";
190
+ }
191
+ }
192
+
193
+ export function renderHostLeaseLabel(lease = {}) {
194
+ const pid = Number(lease?.pid || 0);
195
+ const name = String(lease?.name || "").trim();
196
+ const displayName = String(lease?.hostDisplayName || getHostDisplayName(lease?.hostContext)).trim() || "当前宿主";
197
+ if (pid > 0 && name) {
198
+ return `${displayName}(${name} / pid=${pid})`;
199
+ }
200
+ if (pid > 0) {
201
+ return `${displayName}(pid=${pid})`;
202
+ }
203
+ return `${displayName}(未检测到稳定宿主进程)`;
204
+ }