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/.claude-plugin/plugin.json +1 -1
- package/.codex-plugin/plugin.json +1 -1
- package/README.md +66 -1
- package/hosts/claude/marketplace/plugins/helloloop/.claude-plugin/plugin.json +1 -1
- package/hosts/gemini/extension/gemini-extension.json +1 -1
- package/package.json +2 -2
- package/src/analyzer.mjs +20 -230
- package/src/analyzer_support.mjs +232 -0
- package/src/cli.mjs +56 -32
- package/src/cli_analyze_command.mjs +10 -7
- package/src/cli_args.mjs +5 -0
- package/src/cli_command_handlers.mjs +31 -0
- package/src/completion_review.mjs +2 -0
- package/src/context.mjs +6 -0
- package/src/engine_process_support.mjs +32 -0
- package/src/engine_selection_settings.mjs +12 -0
- package/src/host_lease.mjs +204 -0
- package/src/process.mjs +7 -654
- package/src/runner_execute_task.mjs +55 -1
- package/src/runner_execution_support.mjs +14 -0
- package/src/runner_status.mjs +12 -1
- package/src/runtime_engine_support.mjs +342 -0
- package/src/runtime_engine_task.mjs +395 -0
- package/src/supervisor_runtime.mjs +314 -0
- package/src/terminal_session_limits.mjs +394 -0
- package/src/verify_runner.mjs +84 -0
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
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
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
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
39
|
+
if (process.env.HELLOLOOP_SUPERVISOR_ACTIVE !== "1" && shouldTrackVisibleTerminalCommand(command)) {
|
|
40
|
+
acquireVisibleTerminalSession({
|
|
41
|
+
command,
|
|
42
|
+
repoRoot: process.cwd(),
|
|
43
|
+
});
|
|
44
|
+
}
|
|
29
45
|
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
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
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
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
|
-
|
|
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 {
|
|
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
|
|
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
|
-
|
|
232
|
-
console.log(
|
|
233
|
-
return
|
|
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
|
+
}
|