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.
- package/.claude-plugin/plugin.json +1 -1
- package/.codex-plugin/plugin.json +1 -1
- package/README.md +67 -0
- 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 +60 -154
- package/src/analyzer_support.mjs +232 -0
- package/src/cli.mjs +8 -0
- package/src/cli_analyze_command.mjs +25 -4
- package/src/cli_args.mjs +5 -0
- package/src/cli_command_handlers.mjs +78 -0
- package/src/completion_review.mjs +2 -0
- package/src/context.mjs +6 -0
- package/src/engine_process_support.mjs +63 -3
- 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/runtime_recovery.mjs +3 -0
- package/src/supervisor_runtime.mjs +285 -0
- package/src/verify_runner.mjs +84 -0
- package/templates/analysis-output.schema.json +18 -4
- package/templates/task-review-output.schema.json +5 -1
|
@@ -15,11 +15,15 @@ import {
|
|
|
15
15
|
import { loadBacklog, loadPolicy } from "./config.mjs";
|
|
16
16
|
import { createContext } from "./context.mjs";
|
|
17
17
|
import { createDiscoveryPromptSession, resolveDiscoveryFailureInteractively } from "./discovery_prompt.mjs";
|
|
18
|
-
import { resolveEngineSelection } from "./engine_selection.mjs";
|
|
18
|
+
import { resolveEngineSelection, resolveHostContext } from "./engine_selection.mjs";
|
|
19
19
|
import { resetRepoForRebuild } from "./rebuild.mjs";
|
|
20
|
-
import { runLoop } from "./runner.mjs";
|
|
21
20
|
import { renderRebuildSummary } from "./cli_render.mjs";
|
|
22
21
|
import { shouldConfirmRepoRebuild } from "./cli_support.mjs";
|
|
22
|
+
import {
|
|
23
|
+
launchSupervisedCommand,
|
|
24
|
+
renderSupervisorLaunchSummary,
|
|
25
|
+
waitForSupervisedResult,
|
|
26
|
+
} from "./supervisor_runtime.mjs";
|
|
23
27
|
|
|
24
28
|
async function resolveAnalyzeEngineSelection(options) {
|
|
25
29
|
if (options.engineResolution?.ok) {
|
|
@@ -202,6 +206,11 @@ async function prepareAnalyzeExecution(initialOptions) {
|
|
|
202
206
|
}
|
|
203
207
|
}
|
|
204
208
|
|
|
209
|
+
function shouldDetachSupervisor(options = {}) {
|
|
210
|
+
return process.env.HELLOLOOP_SUPERVISOR_ACTIVE !== "1"
|
|
211
|
+
&& resolveHostContext(options) !== "terminal";
|
|
212
|
+
}
|
|
213
|
+
|
|
205
214
|
async function maybeRunAutoExecution(result, activeOptions) {
|
|
206
215
|
const execution = analyzeExecution(result.backlog, activeOptions);
|
|
207
216
|
|
|
@@ -222,15 +231,27 @@ async function maybeRunAutoExecution(result, activeOptions) {
|
|
|
222
231
|
|
|
223
232
|
console.log("");
|
|
224
233
|
console.log("开始自动接续执行...");
|
|
225
|
-
const
|
|
234
|
+
const session = launchSupervisedCommand(result.context, "run-loop", {
|
|
226
235
|
...activeOptions,
|
|
236
|
+
supervised: false,
|
|
227
237
|
engineResolution: result.engineResolution?.ok ? result.engineResolution : activeOptions.engineResolution,
|
|
228
238
|
maxTasks: resolveAutoRunMaxTasks(result.backlog, activeOptions) || undefined,
|
|
229
239
|
fullAutoMainline: true,
|
|
230
240
|
});
|
|
241
|
+
console.log(renderSupervisorLaunchSummary(session));
|
|
242
|
+
if (shouldDetachSupervisor(activeOptions)) {
|
|
243
|
+
console.log("- 已切换为后台执行;可稍后运行 `helloloop status` 查看进度。");
|
|
244
|
+
return 0;
|
|
245
|
+
}
|
|
246
|
+
const supervisorPayload = await waitForSupervisedResult(result.context, session);
|
|
247
|
+
const results = supervisorPayload.results;
|
|
248
|
+
if (!results) {
|
|
249
|
+
console.error(supervisorPayload.error || "HelloLoop supervisor 执行失败。");
|
|
250
|
+
return supervisorPayload.exitCode || 1;
|
|
251
|
+
}
|
|
231
252
|
const refreshedBacklog = loadBacklog(result.context);
|
|
232
253
|
console.log(renderAutoRunSummary(result.context, refreshedBacklog, results, activeOptions));
|
|
233
|
-
return
|
|
254
|
+
return supervisorPayload.exitCode || 0;
|
|
234
255
|
}
|
|
235
256
|
|
|
236
257
|
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 执行;当前 turn 被中断时任务仍可继续",
|
|
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
|
+
" 在 Codex / Claude / Gemini 宿主内,确认后的自动执行会默认切到后台 supervisor。",
|
|
98
103
|
" 示例:npx helloloop claude <DOCS_PATH> <PROJECT_ROOT> 先分析偏差,不要执行",
|
|
99
104
|
].join("\n");
|
|
100
105
|
}
|
|
@@ -2,10 +2,41 @@ import path from "node:path";
|
|
|
2
2
|
|
|
3
3
|
import { createContext } from "./context.mjs";
|
|
4
4
|
import { loadBacklog, scaffoldIfMissing } from "./config.mjs";
|
|
5
|
+
import { resolveHostContext } from "./engine_selection.mjs";
|
|
5
6
|
import { syncUserSettingsFile } from "./engine_selection_settings.mjs";
|
|
6
7
|
import { installPluginBundle, uninstallPluginBundle } from "./install.mjs";
|
|
7
8
|
import { runLoop, runOnce, renderStatusText } from "./runner.mjs";
|
|
8
9
|
import { renderInstallSummary, renderUninstallSummary } from "./cli_render.mjs";
|
|
10
|
+
import {
|
|
11
|
+
launchSupervisedCommand,
|
|
12
|
+
renderSupervisorLaunchSummary,
|
|
13
|
+
waitForSupervisedResult,
|
|
14
|
+
} from "./supervisor_runtime.mjs";
|
|
15
|
+
|
|
16
|
+
function shouldUseSupervisor(options = {}) {
|
|
17
|
+
return !options.dryRun
|
|
18
|
+
&& process.env.HELLOLOOP_SUPERVISOR_ACTIVE !== "1"
|
|
19
|
+
&& (Boolean(options.supervised) || resolveHostContext(options) !== "terminal");
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function shouldDetachSupervisor(options = {}) {
|
|
23
|
+
return resolveHostContext(options) !== "terminal";
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
async function runSupervised(context, command, options = {}) {
|
|
27
|
+
const session = launchSupervisedCommand(context, command, options);
|
|
28
|
+
console.log(renderSupervisorLaunchSummary(session));
|
|
29
|
+
if (shouldDetachSupervisor(options)) {
|
|
30
|
+
console.log("- 已切换为后台执行;可稍后运行 `helloloop status` 查看进度。");
|
|
31
|
+
return {
|
|
32
|
+
detached: true,
|
|
33
|
+
exitCode: 0,
|
|
34
|
+
ok: true,
|
|
35
|
+
session,
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
return waitForSupervisedResult(context, session);
|
|
39
|
+
}
|
|
9
40
|
|
|
10
41
|
export function handleInstallCommand(options) {
|
|
11
42
|
const userSettings = syncUserSettingsFile({
|
|
@@ -87,6 +118,28 @@ export async function handleNextCommand(context, options) {
|
|
|
87
118
|
}
|
|
88
119
|
|
|
89
120
|
export async function handleRunOnceCommand(context, options) {
|
|
121
|
+
if (shouldUseSupervisor(options)) {
|
|
122
|
+
const payload = await runSupervised(context, "run-once", options);
|
|
123
|
+
if (payload.detached) {
|
|
124
|
+
return payload.exitCode || 0;
|
|
125
|
+
}
|
|
126
|
+
if (!payload.result) {
|
|
127
|
+
console.error(payload.error || "HelloLoop supervisor 执行失败。");
|
|
128
|
+
return payload.exitCode || 1;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
const result = payload.result;
|
|
132
|
+
if (!result.ok) {
|
|
133
|
+
console.error(result.summary || "执行失败。");
|
|
134
|
+
return payload.exitCode || 1;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
console.log(result.task
|
|
138
|
+
? `完成任务:${result.task.title}\n运行目录:${result.runDir}`
|
|
139
|
+
: "没有可执行任务。");
|
|
140
|
+
return 0;
|
|
141
|
+
}
|
|
142
|
+
|
|
90
143
|
const result = await runOnce(context, options);
|
|
91
144
|
if (!result.ok) {
|
|
92
145
|
console.error(result.summary || "执行失败。");
|
|
@@ -106,6 +159,31 @@ export async function handleRunOnceCommand(context, options) {
|
|
|
106
159
|
}
|
|
107
160
|
|
|
108
161
|
export async function handleRunLoopCommand(context, options) {
|
|
162
|
+
if (shouldUseSupervisor(options)) {
|
|
163
|
+
const payload = await runSupervised(context, "run-loop", options);
|
|
164
|
+
if (payload.detached) {
|
|
165
|
+
return payload.exitCode || 0;
|
|
166
|
+
}
|
|
167
|
+
if (!payload.results) {
|
|
168
|
+
console.error(payload.error || "HelloLoop supervisor 执行失败。");
|
|
169
|
+
return payload.exitCode || 1;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
const failed = payload.results.find((item) => !item.ok);
|
|
173
|
+
for (const item of payload.results) {
|
|
174
|
+
if (!item.task) {
|
|
175
|
+
console.log("没有更多可执行任务。");
|
|
176
|
+
break;
|
|
177
|
+
}
|
|
178
|
+
console.log(`${item.ok ? "成功" : "失败"}:${item.task.title}`);
|
|
179
|
+
}
|
|
180
|
+
if (failed) {
|
|
181
|
+
console.error(failed.summary || "连续执行中断。");
|
|
182
|
+
return payload.exitCode || 1;
|
|
183
|
+
}
|
|
184
|
+
return 0;
|
|
185
|
+
}
|
|
186
|
+
|
|
109
187
|
const results = await runLoop(context, options);
|
|
110
188
|
const failed = results.find((item) => !item.ok);
|
|
111
189
|
|
|
@@ -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
|
}
|
|
@@ -7,6 +7,15 @@ import {
|
|
|
7
7
|
resolveVerifyShellInvocation,
|
|
8
8
|
} from "./shell_invocation.mjs";
|
|
9
9
|
|
|
10
|
+
export function isIgnorableStdinError(error) {
|
|
11
|
+
const code = String(error?.code || "").trim();
|
|
12
|
+
const message = String(error?.message || "").toLowerCase();
|
|
13
|
+
return code === "EPIPE"
|
|
14
|
+
|| code === "ERR_STREAM_DESTROYED"
|
|
15
|
+
|| message.includes("broken pipe")
|
|
16
|
+
|| message.includes("write after end");
|
|
17
|
+
}
|
|
18
|
+
|
|
10
19
|
export function runChild(command, args, options = {}) {
|
|
11
20
|
return new Promise((resolve) => {
|
|
12
21
|
const child = spawn(command, args, {
|
|
@@ -29,6 +38,27 @@ export function runChild(command, args, options = {}) {
|
|
|
29
38
|
let watchdogReason = "";
|
|
30
39
|
let stallWarned = false;
|
|
31
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
|
+
};
|
|
32
62
|
|
|
33
63
|
const emitHeartbeat = (status, extra = {}) => {
|
|
34
64
|
options.onHeartbeat?.({
|
|
@@ -41,6 +71,8 @@ export function runChild(command, args, options = {}) {
|
|
|
41
71
|
idleSeconds: Math.max(0, Math.floor((Date.now() - lastOutputAt) / 1000)),
|
|
42
72
|
watchdogTriggered,
|
|
43
73
|
watchdogReason,
|
|
74
|
+
leaseExpired,
|
|
75
|
+
leaseReason,
|
|
44
76
|
...extra,
|
|
45
77
|
});
|
|
46
78
|
};
|
|
@@ -52,6 +84,11 @@ export function runChild(command, args, options = {}) {
|
|
|
52
84
|
|
|
53
85
|
const heartbeatTimer = heartbeatIntervalMs > 0
|
|
54
86
|
? setInterval(() => {
|
|
87
|
+
if (typeof options.shouldKeepRunning === "function" && !options.shouldKeepRunning()) {
|
|
88
|
+
requestLeaseTermination();
|
|
89
|
+
return;
|
|
90
|
+
}
|
|
91
|
+
|
|
55
92
|
const idleMs = Date.now() - lastOutputAt;
|
|
56
93
|
if (stallWarningMs > 0 && idleMs >= stallWarningMs && !stallWarned) {
|
|
57
94
|
stallWarned = true;
|
|
@@ -98,10 +135,29 @@ export function runChild(command, args, options = {}) {
|
|
|
98
135
|
emitHeartbeat("running");
|
|
99
136
|
});
|
|
100
137
|
|
|
101
|
-
|
|
102
|
-
|
|
138
|
+
child.stdin.on("error", (error) => {
|
|
139
|
+
if (isIgnorableStdinError(error)) {
|
|
140
|
+
return;
|
|
141
|
+
}
|
|
142
|
+
stderr = [
|
|
143
|
+
stderr.trim(),
|
|
144
|
+
`[HelloLoop stdin] ${String(error?.stack || error || "")}`,
|
|
145
|
+
].filter(Boolean).join("\n");
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
try {
|
|
149
|
+
if (options.stdin) {
|
|
150
|
+
child.stdin.write(options.stdin);
|
|
151
|
+
}
|
|
152
|
+
child.stdin.end();
|
|
153
|
+
} catch (error) {
|
|
154
|
+
if (!isIgnorableStdinError(error)) {
|
|
155
|
+
stderr = [
|
|
156
|
+
stderr.trim(),
|
|
157
|
+
`[HelloLoop stdin] ${String(error?.stack || error || "")}`,
|
|
158
|
+
].filter(Boolean).join("\n");
|
|
159
|
+
}
|
|
103
160
|
}
|
|
104
|
-
child.stdin.end();
|
|
105
161
|
|
|
106
162
|
child.on("error", (error) => {
|
|
107
163
|
if (heartbeatTimer) {
|
|
@@ -125,6 +181,8 @@ export function runChild(command, args, options = {}) {
|
|
|
125
181
|
idleTimeout: watchdogTriggered,
|
|
126
182
|
watchdogTriggered,
|
|
127
183
|
watchdogReason,
|
|
184
|
+
leaseExpired,
|
|
185
|
+
leaseReason,
|
|
128
186
|
});
|
|
129
187
|
});
|
|
130
188
|
|
|
@@ -150,6 +208,8 @@ export function runChild(command, args, options = {}) {
|
|
|
150
208
|
idleTimeout: watchdogTriggered,
|
|
151
209
|
watchdogTriggered,
|
|
152
210
|
watchdogReason,
|
|
211
|
+
leaseExpired,
|
|
212
|
+
leaseReason,
|
|
153
213
|
});
|
|
154
214
|
});
|
|
155
215
|
});
|
|
@@ -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
|
+
}
|