helloloop 0.8.5 → 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 +46 -4
- 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/cli.mjs +54 -38
- package/src/cli_analyze_command.mjs +4 -22
- package/src/cli_args.mjs +2 -2
- package/src/cli_command_handlers.mjs +10 -57
- package/src/engine_selection_settings.mjs +12 -0
- package/src/supervisor_runtime.mjs +29 -0
- package/src/terminal_session_limits.mjs +394 -0
package/README.md
CHANGED
|
@@ -99,8 +99,9 @@ npx helloloop gemini <PATH> 接续完成剩余开发
|
|
|
99
99
|
|
|
100
100
|
## 后台监督执行
|
|
101
101
|
|
|
102
|
-
|
|
102
|
+
从当前版本开始,`HelloLoop` 的自动执行主线统一走 **detached supervisor + host lease** 模式:
|
|
103
103
|
|
|
104
|
+
- `analyze` 确认后的自动执行、`run-once`、`run-loop` 都默认切到后台 supervisor
|
|
104
105
|
- 当前对话 turn 就算被误按 `Esc`、被宿主暂停、或当前工具调用被中断,后台 supervisor 仍会继续
|
|
105
106
|
- 原有的 15 分钟级恢复、健康探测、同引擎自动恢复,也会继续由这个后台 supervisor 接管
|
|
106
107
|
- 这不是“当前进程死掉后每 15 分钟重新拉起一遍主进程”,而是 supervisor 本身持续存活,所以恢复链不会因为当前 turn 消失而断掉
|
|
@@ -109,7 +110,8 @@ npx helloloop gemini <PATH> 接续完成剩余开发
|
|
|
109
110
|
常见场景:
|
|
110
111
|
|
|
111
112
|
- 在 `Codex` / `Claude` / `Gemini` 宿主里运行 `helloloop`:确认后默认转入后台执行,可用 `helloloop status` 查看进度
|
|
112
|
-
- 在普通终端里运行 `npx helloloop run-once
|
|
113
|
+
- 在普通终端里运行 `npx helloloop`、`npx helloloop run-once`、`npx helloloop run-loop`:默认也会转入后台执行
|
|
114
|
+
- `--supervised` 仍然保留,但现在只是兼容参数,不再是开启后台 supervisor 的前提
|
|
113
115
|
|
|
114
116
|
## 无人值守恢复
|
|
115
117
|
|
|
@@ -175,6 +177,46 @@ npx helloloop gemini <PATH> 接续完成剩余开发
|
|
|
175
177
|
- 建议把 SMTP 密码放在环境变量里,不要明文写进配置文件
|
|
176
178
|
- 邮件只在“本轮不再继续自动重试”时发送,不会每次失败都刷屏
|
|
177
179
|
|
|
180
|
+
## 终端并发上限
|
|
181
|
+
|
|
182
|
+
`HelloLoop` 不会主动再额外弹出一堆可见终端窗口;这里的:
|
|
183
|
+
|
|
184
|
+
- **显示终端** 指当前前台运行中的 `helloloop` 会话
|
|
185
|
+
- **背景终端** 指 detached supervisor 后台会话
|
|
186
|
+
|
|
187
|
+
默认限制为:
|
|
188
|
+
|
|
189
|
+
- 显示终端最多 `8`
|
|
190
|
+
- 背景终端最多 `8`
|
|
191
|
+
- 显示 + 背景合计最多 `8`
|
|
192
|
+
|
|
193
|
+
可在:
|
|
194
|
+
|
|
195
|
+
```text
|
|
196
|
+
~/.helloloop/settings.json
|
|
197
|
+
```
|
|
198
|
+
|
|
199
|
+
中自行调整,例如:
|
|
200
|
+
|
|
201
|
+
```json
|
|
202
|
+
{
|
|
203
|
+
"runtime": {
|
|
204
|
+
"terminalConcurrency": {
|
|
205
|
+
"enabled": true,
|
|
206
|
+
"visibleMax": 8,
|
|
207
|
+
"backgroundMax": 8,
|
|
208
|
+
"totalMax": 8
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
```
|
|
213
|
+
|
|
214
|
+
说明:
|
|
215
|
+
|
|
216
|
+
- 只要合计并发达到 `totalMax`,新前台会话或新的后台 supervisor 都不会再继续启动
|
|
217
|
+
- 如果只是想临时完全关闭这个保护,可把 `enabled` 设为 `false`
|
|
218
|
+
- 这个限制主要用于避免同时占用过多本机终端资源,或因为并发过高触发模型 / API 限速
|
|
219
|
+
|
|
178
220
|
## 自动发现与交互逻辑
|
|
179
221
|
|
|
180
222
|
### 1. 只输入 `npx helloloop`
|
|
@@ -414,7 +456,7 @@ npx helloloop
|
|
|
414
456
|
| `-y` / `--yes` | 跳过执行确认直接开始;但如果未显式指定引擎,会直接报错而不是自动选引擎 |
|
|
415
457
|
| `--repo <dir>` | 高级覆盖:显式指定项目仓库 |
|
|
416
458
|
| `--docs <dir|file>` | 高级覆盖:显式指定开发文档 |
|
|
417
|
-
| `--supervised` |
|
|
459
|
+
| `--supervised` | 兼容保留;当前版本默认已启用 detached supervisor |
|
|
418
460
|
| `--rebuild-existing` | 项目与文档冲突时,自动清理现有项目后重建 |
|
|
419
461
|
| `--host <name>` | 安装宿主:`codex` / `claude` / `gemini` / `all` |
|
|
420
462
|
| `--config-dir <dir>` | 状态目录名,默认 `.helloloop` |
|
|
@@ -485,7 +527,7 @@ git push origin vX.Y.Z-beta.N
|
|
|
485
527
|
|
|
486
528
|
- 正式版本使用 npm `latest` 渠道,beta 版本使用 npm `beta` 渠道
|
|
487
529
|
- 如果测试、版本校验或打包检查失败,npm 发布与 GitHub Release 都不会继续执行
|
|
488
|
-
- `0.8.
|
|
530
|
+
- `0.8.6` 起已统一为全流程后台 supervisor:`analyze` 确认后的自动执行、`run-once`、`run-loop` 默认都后台化,不再要求普通终端显式追加 `--supervised`
|
|
489
531
|
- GitHub Release 阶段现已改为使用官方 `gh` CLI + `generate-notes` API 创建 / 更新 release,不再依赖会触发 Node runtime deprecation warning 的第三方 action
|
|
490
532
|
|
|
491
533
|
## 宿主写入范围
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "helloloop",
|
|
3
|
-
"version": "0.8.
|
|
3
|
+
"version": "0.8.6",
|
|
4
4
|
"description": "面向 Codex CLI、Claude Code、Gemini CLI 的多宿主开发工作流插件",
|
|
5
5
|
"author": "HelloLoop",
|
|
6
6
|
"license": "Apache-2.0",
|
|
@@ -30,7 +30,7 @@
|
|
|
30
30
|
"templates"
|
|
31
31
|
],
|
|
32
32
|
"scripts": {
|
|
33
|
-
"test": "node --test tests/analyze_cli.test.mjs tests/analyze_intent_cli.test.mjs tests/analyze_runtime_failure.test.mjs tests/output_schema_contract.test.mjs tests/engine_process_support.test.mjs tests/engine_selection_cli.test.mjs tests/cli_surface.test.mjs tests/cli_doctor_surface.test.mjs tests/host_lifecycle_integrity.test.mjs tests/host_single_host_integrity.test.mjs tests/install_script.test.mjs tests/mainline_continuation.test.mjs tests/multi_host_runtime.test.mjs tests/process_shell.test.mjs tests/prompt_guardrails.test.mjs tests/ralph_loop.test.mjs tests/runtime_recovery.test.mjs tests/plugin_bundle.test.mjs tests/supervisor_runtime.test.mjs"
|
|
33
|
+
"test": "node --test tests/analyze_cli.test.mjs tests/analyze_cli_path_resolution.test.mjs tests/analyze_intent_cli.test.mjs tests/analyze_runtime_failure.test.mjs tests/output_schema_contract.test.mjs tests/engine_process_support.test.mjs tests/engine_selection_cli.test.mjs tests/cli_surface.test.mjs tests/cli_doctor_surface.test.mjs tests/host_lifecycle_integrity.test.mjs tests/user_settings_lifecycle.test.mjs tests/host_single_host_integrity.test.mjs tests/install_script.test.mjs tests/mainline_continuation.test.mjs tests/multi_host_runtime.test.mjs tests/multi_host_runtime_recovery.test.mjs tests/process_shell.test.mjs tests/prompt_guardrails.test.mjs tests/ralph_loop.test.mjs tests/runtime_recovery.test.mjs tests/plugin_bundle.test.mjs tests/supervisor_runtime.test.mjs tests/terminal_session_limits.test.mjs"
|
|
34
34
|
},
|
|
35
35
|
"engines": {
|
|
36
36
|
"node": ">=20"
|
package/src/cli.mjs
CHANGED
|
@@ -14,49 +14,65 @@ import {
|
|
|
14
14
|
import { resolveContextFromOptions, resolveStandardCommandOptions } from "./cli_context.mjs";
|
|
15
15
|
import { runDoctor } from "./cli_support.mjs";
|
|
16
16
|
import { runSupervisedCommandFromSessionFile } from "./supervisor_runtime.mjs";
|
|
17
|
+
import {
|
|
18
|
+
acquireVisibleTerminalSession,
|
|
19
|
+
releaseCurrentTerminalSession,
|
|
20
|
+
shouldTrackVisibleTerminalCommand,
|
|
21
|
+
} from "./terminal_session_limits.mjs";
|
|
17
22
|
|
|
18
23
|
export async function runCli(argv) {
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
if (
|
|
27
|
-
|
|
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;
|
|
28
37
|
}
|
|
29
|
-
await runSupervisedCommandFromSessionFile(parsed.options.sessionFile);
|
|
30
|
-
return;
|
|
31
|
-
}
|
|
32
38
|
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
39
|
+
if (process.env.HELLOLOOP_SUPERVISOR_ACTIVE !== "1" && shouldTrackVisibleTerminalCommand(command)) {
|
|
40
|
+
acquireVisibleTerminalSession({
|
|
41
|
+
command,
|
|
42
|
+
repoRoot: process.cwd(),
|
|
43
|
+
});
|
|
44
|
+
}
|
|
37
45
|
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
}
|
|
43
|
-
if (command === "uninstall") {
|
|
44
|
-
process.exitCode = handleUninstallCommand(options);
|
|
45
|
-
return;
|
|
46
|
-
}
|
|
46
|
+
if (command === "analyze") {
|
|
47
|
+
process.exitCode = await handleAnalyzeCommand(normalizeAnalyzeOptions(parsed.options, process.cwd()));
|
|
48
|
+
return;
|
|
49
|
+
}
|
|
47
50
|
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
if (!handlers[command]) {
|
|
58
|
-
throw new Error(`未知命令:${command}`);
|
|
59
|
-
}
|
|
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
|
+
}
|
|
60
60
|
|
|
61
|
-
|
|
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
|
+
}
|
|
62
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,17 +11,16 @@ 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
|
-
import { resolveEngineSelection
|
|
17
|
+
import { resolveEngineSelection } from "./engine_selection.mjs";
|
|
19
18
|
import { resetRepoForRebuild } from "./rebuild.mjs";
|
|
20
19
|
import { renderRebuildSummary } from "./cli_render.mjs";
|
|
21
20
|
import { shouldConfirmRepoRebuild } from "./cli_support.mjs";
|
|
22
21
|
import {
|
|
23
22
|
launchSupervisedCommand,
|
|
24
23
|
renderSupervisorLaunchSummary,
|
|
25
|
-
waitForSupervisedResult,
|
|
26
24
|
} from "./supervisor_runtime.mjs";
|
|
27
25
|
|
|
28
26
|
async function resolveAnalyzeEngineSelection(options) {
|
|
@@ -206,11 +204,6 @@ async function prepareAnalyzeExecution(initialOptions) {
|
|
|
206
204
|
}
|
|
207
205
|
}
|
|
208
206
|
|
|
209
|
-
function shouldDetachSupervisor(options = {}) {
|
|
210
|
-
return process.env.HELLOLOOP_SUPERVISOR_ACTIVE !== "1"
|
|
211
|
-
&& resolveHostContext(options) !== "terminal";
|
|
212
|
-
}
|
|
213
|
-
|
|
214
207
|
async function maybeRunAutoExecution(result, activeOptions) {
|
|
215
208
|
const execution = analyzeExecution(result.backlog, activeOptions);
|
|
216
209
|
|
|
@@ -239,19 +232,8 @@ async function maybeRunAutoExecution(result, activeOptions) {
|
|
|
239
232
|
fullAutoMainline: true,
|
|
240
233
|
});
|
|
241
234
|
console.log(renderSupervisorLaunchSummary(session));
|
|
242
|
-
|
|
243
|
-
|
|
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
|
-
}
|
|
252
|
-
const refreshedBacklog = loadBacklog(result.context);
|
|
253
|
-
console.log(renderAutoRunSummary(result.context, refreshedBacklog, results, activeOptions));
|
|
254
|
-
return supervisorPayload.exitCode || 0;
|
|
235
|
+
console.log("- 已切换为后台执行;可稍后运行 `helloloop status` 查看进度。");
|
|
236
|
+
return 0;
|
|
255
237
|
}
|
|
256
238
|
|
|
257
239
|
export async function handleAnalyzeCommand(options) {
|
package/src/cli_args.mjs
CHANGED
|
@@ -90,7 +90,7 @@ function helpText() {
|
|
|
90
90
|
" --max-tasks <n> run-loop 最多执行 n 个任务",
|
|
91
91
|
" --max-attempts <n> 每种策略内最多重试 n 次",
|
|
92
92
|
" --max-strategies <n> 单任务最多切换 n 种策略继续重试",
|
|
93
|
-
" --supervised
|
|
93
|
+
" --supervised 兼容保留;当前版本默认就会通过独立 supervisor 后台执行",
|
|
94
94
|
" --allow-high-risk 允许执行 medium/high/critical 风险任务",
|
|
95
95
|
" --rebuild-existing 分析判断当前项目与文档冲突时,自动清理当前项目后按文档重建",
|
|
96
96
|
" --required-doc <p> 增加一个全局必读文档(AGENTS.md 会被自动忽略)",
|
|
@@ -99,7 +99,7 @@ function helpText() {
|
|
|
99
99
|
"补充说明:",
|
|
100
100
|
" analyze 默认支持在命令后混合传入引擎、路径和自然语言要求。",
|
|
101
101
|
" 如果同时检测到多个可用引擎且没有明确指定,会先询问你选择。",
|
|
102
|
-
"
|
|
102
|
+
" 当前版本默认会把自动执行 / run-once / run-loop 切到后台 supervisor。",
|
|
103
103
|
" 示例:npx helloloop claude <DOCS_PATH> <PROJECT_ROOT> 先分析偏差,不要执行",
|
|
104
104
|
].join("\n");
|
|
105
105
|
}
|
|
@@ -2,7 +2,6 @@ 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";
|
|
6
5
|
import { syncUserSettingsFile } from "./engine_selection_settings.mjs";
|
|
7
6
|
import { installPluginBundle, uninstallPluginBundle } from "./install.mjs";
|
|
8
7
|
import { runLoop, runOnce, renderStatusText } from "./runner.mjs";
|
|
@@ -10,32 +9,23 @@ import { renderInstallSummary, renderUninstallSummary } from "./cli_render.mjs";
|
|
|
10
9
|
import {
|
|
11
10
|
launchSupervisedCommand,
|
|
12
11
|
renderSupervisorLaunchSummary,
|
|
13
|
-
waitForSupervisedResult,
|
|
14
12
|
} from "./supervisor_runtime.mjs";
|
|
15
13
|
|
|
16
14
|
function shouldUseSupervisor(options = {}) {
|
|
17
15
|
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";
|
|
16
|
+
&& process.env.HELLOLOOP_SUPERVISOR_ACTIVE !== "1";
|
|
24
17
|
}
|
|
25
18
|
|
|
26
19
|
async function runSupervised(context, command, options = {}) {
|
|
27
20
|
const session = launchSupervisedCommand(context, command, options);
|
|
28
21
|
console.log(renderSupervisorLaunchSummary(session));
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
};
|
|
37
|
-
}
|
|
38
|
-
return waitForSupervisedResult(context, session);
|
|
22
|
+
console.log("- 已切换为后台执行;可稍后运行 `helloloop status` 查看进度。");
|
|
23
|
+
return {
|
|
24
|
+
detached: true,
|
|
25
|
+
exitCode: 0,
|
|
26
|
+
ok: true,
|
|
27
|
+
session,
|
|
28
|
+
};
|
|
39
29
|
}
|
|
40
30
|
|
|
41
31
|
export function handleInstallCommand(options) {
|
|
@@ -120,24 +110,7 @@ export async function handleNextCommand(context, options) {
|
|
|
120
110
|
export async function handleRunOnceCommand(context, options) {
|
|
121
111
|
if (shouldUseSupervisor(options)) {
|
|
122
112
|
const payload = await runSupervised(context, "run-once", options);
|
|
123
|
-
|
|
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;
|
|
113
|
+
return payload.exitCode || 0;
|
|
141
114
|
}
|
|
142
115
|
|
|
143
116
|
const result = await runOnce(context, options);
|
|
@@ -161,27 +134,7 @@ export async function handleRunOnceCommand(context, options) {
|
|
|
161
134
|
export async function handleRunLoopCommand(context, options) {
|
|
162
135
|
if (shouldUseSupervisor(options)) {
|
|
163
136
|
const payload = await runSupervised(context, "run-loop", options);
|
|
164
|
-
|
|
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;
|
|
137
|
+
return payload.exitCode || 0;
|
|
185
138
|
}
|
|
186
139
|
|
|
187
140
|
const results = await runLoop(context, options);
|
|
@@ -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
|
|
|
@@ -6,6 +6,12 @@ import { createContext } from "./context.mjs";
|
|
|
6
6
|
import { nowIso, readJson, writeJson, readTextIfExists, timestampForFile } from "./common.mjs";
|
|
7
7
|
import { isHostLeaseAlive, renderHostLeaseLabel, resolveHostLease } from "./host_lease.mjs";
|
|
8
8
|
import { runLoop, runOnce } from "./runner.mjs";
|
|
9
|
+
import {
|
|
10
|
+
bindBackgroundTerminalSession,
|
|
11
|
+
cancelPreparedTerminalSessionBackground,
|
|
12
|
+
finalizePreparedTerminalSessionBackground,
|
|
13
|
+
prepareCurrentTerminalSessionForBackground,
|
|
14
|
+
} from "./terminal_session_limits.mjs";
|
|
9
15
|
|
|
10
16
|
const ACTIVE_STATUSES = new Set(["launching", "running"]);
|
|
11
17
|
const FINAL_STATUSES = new Set(["completed", "failed", "stopped"]);
|
|
@@ -78,6 +84,11 @@ export function launchSupervisedCommand(context, command, options = {}) {
|
|
|
78
84
|
|
|
79
85
|
const sessionId = timestampForFile();
|
|
80
86
|
const lease = resolveHostLease({ hostContext: options.hostContext });
|
|
87
|
+
const terminalSession = prepareCurrentTerminalSessionForBackground({
|
|
88
|
+
command,
|
|
89
|
+
repoRoot: context.repoRoot,
|
|
90
|
+
sessionId,
|
|
91
|
+
});
|
|
81
92
|
const request = {
|
|
82
93
|
sessionId,
|
|
83
94
|
command,
|
|
@@ -87,6 +98,7 @@ export function launchSupervisedCommand(context, command, options = {}) {
|
|
|
87
98
|
},
|
|
88
99
|
options: toSerializableOptions(options),
|
|
89
100
|
lease,
|
|
101
|
+
terminalSessionFile: terminalSession?.file || "",
|
|
90
102
|
};
|
|
91
103
|
|
|
92
104
|
fs.mkdirSync(context.supervisorRoot, { recursive: true });
|
|
@@ -122,6 +134,11 @@ export function launchSupervisedCommand(context, command, options = {}) {
|
|
|
122
134
|
HELLOLOOP_SUPERVISOR_ACTIVE: "1",
|
|
123
135
|
},
|
|
124
136
|
});
|
|
137
|
+
finalizePreparedTerminalSessionBackground(child.pid ?? 0, {
|
|
138
|
+
command,
|
|
139
|
+
repoRoot: context.repoRoot,
|
|
140
|
+
sessionId,
|
|
141
|
+
});
|
|
125
142
|
child.unref();
|
|
126
143
|
writeState(context, {
|
|
127
144
|
sessionId,
|
|
@@ -137,6 +154,13 @@ export function launchSupervisedCommand(context, command, options = {}) {
|
|
|
137
154
|
pid: child.pid ?? 0,
|
|
138
155
|
lease,
|
|
139
156
|
};
|
|
157
|
+
} catch (error) {
|
|
158
|
+
cancelPreparedTerminalSessionBackground({
|
|
159
|
+
command,
|
|
160
|
+
repoRoot: context.repoRoot,
|
|
161
|
+
sessionId,
|
|
162
|
+
});
|
|
163
|
+
throw error;
|
|
140
164
|
} finally {
|
|
141
165
|
fs.closeSync(stdoutFd);
|
|
142
166
|
fs.closeSync(stderrFd);
|
|
@@ -184,6 +208,11 @@ export async function runSupervisedCommandFromSessionFile(sessionFile) {
|
|
|
184
208
|
const context = createContext(request.context || {});
|
|
185
209
|
const command = String(request.command || "").trim();
|
|
186
210
|
const lease = request.lease || {};
|
|
211
|
+
bindBackgroundTerminalSession(request.terminalSessionFile || "", {
|
|
212
|
+
command,
|
|
213
|
+
repoRoot: context.repoRoot,
|
|
214
|
+
sessionId: request.sessionId,
|
|
215
|
+
});
|
|
187
216
|
const commandOptions = {
|
|
188
217
|
...(request.options || {}),
|
|
189
218
|
hostLease: lease,
|
|
@@ -0,0 +1,394 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
|
|
4
|
+
import { ensureDir, fileExists, nowIso, readJson, timestampForFile, writeJson } from "./common.mjs";
|
|
5
|
+
import { loadGlobalConfig } from "./global_config.mjs";
|
|
6
|
+
|
|
7
|
+
const SESSION_DIR_NAME = "terminal-sessions";
|
|
8
|
+
const RUNTIME_DIR_NAME = "runtime";
|
|
9
|
+
const LOCK_FILE_NAME = ".lock";
|
|
10
|
+
const LOCK_RETRY_DELAYS_MS = [0, 20, 50, 100, 200, 300, 500];
|
|
11
|
+
const STALE_PREPARED_SESSION_MS = 60_000;
|
|
12
|
+
const TRACKED_VISIBLE_COMMANDS = new Set(["analyze", "next", "run-loop", "run-once"]);
|
|
13
|
+
|
|
14
|
+
let currentTerminalSession = null;
|
|
15
|
+
let cleanupRegistered = false;
|
|
16
|
+
|
|
17
|
+
function sleepSync(ms) {
|
|
18
|
+
const shared = new SharedArrayBuffer(4);
|
|
19
|
+
const view = new Int32Array(shared);
|
|
20
|
+
Atomics.wait(view, 0, 0, Math.max(0, ms));
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function isPidAlive(pid) {
|
|
24
|
+
const value = Number(pid || 0);
|
|
25
|
+
if (!Number.isFinite(value) || value <= 0) {
|
|
26
|
+
return false;
|
|
27
|
+
}
|
|
28
|
+
try {
|
|
29
|
+
process.kill(value, 0);
|
|
30
|
+
return true;
|
|
31
|
+
} catch (error) {
|
|
32
|
+
return String(error?.code || "") === "EPERM";
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function normalizeNonNegativeInteger(value, fallbackValue) {
|
|
37
|
+
if (value === null || value === undefined || value === "") {
|
|
38
|
+
return fallbackValue;
|
|
39
|
+
}
|
|
40
|
+
const parsed = Number(value);
|
|
41
|
+
if (!Number.isFinite(parsed) || parsed < 0) {
|
|
42
|
+
return fallbackValue;
|
|
43
|
+
}
|
|
44
|
+
return Math.floor(parsed);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export function normalizeTerminalConcurrencySettings(settings = {}) {
|
|
48
|
+
const source = settings && typeof settings === "object" ? settings : {};
|
|
49
|
+
return {
|
|
50
|
+
enabled: source.enabled !== false,
|
|
51
|
+
visibleMax: normalizeNonNegativeInteger(source.visibleMax, 8),
|
|
52
|
+
backgroundMax: normalizeNonNegativeInteger(source.backgroundMax, 8),
|
|
53
|
+
totalMax: normalizeNonNegativeInteger(source.totalMax, 8),
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function resolveTerminalRuntimeConfig(options = {}) {
|
|
58
|
+
const globalConfig = loadGlobalConfig({
|
|
59
|
+
globalConfigFile: options.globalConfigFile,
|
|
60
|
+
});
|
|
61
|
+
const settingsFile = globalConfig?._meta?.configFile || "";
|
|
62
|
+
const settingsHome = settingsFile ? path.dirname(settingsFile) : process.cwd();
|
|
63
|
+
return {
|
|
64
|
+
settingsFile,
|
|
65
|
+
registryRoot: path.join(settingsHome, RUNTIME_DIR_NAME, SESSION_DIR_NAME),
|
|
66
|
+
limits: normalizeTerminalConcurrencySettings(globalConfig?.runtime?.terminalConcurrency || {}),
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function withRegistryLock(registryRoot, callback) {
|
|
71
|
+
ensureDir(registryRoot);
|
|
72
|
+
const lockFile = path.join(registryRoot, LOCK_FILE_NAME);
|
|
73
|
+
let lastError = null;
|
|
74
|
+
|
|
75
|
+
for (const delayMs of LOCK_RETRY_DELAYS_MS) {
|
|
76
|
+
if (delayMs > 0) {
|
|
77
|
+
sleepSync(delayMs);
|
|
78
|
+
}
|
|
79
|
+
let lockFd = null;
|
|
80
|
+
try {
|
|
81
|
+
lockFd = fs.openSync(lockFile, "wx");
|
|
82
|
+
const result = callback();
|
|
83
|
+
fs.closeSync(lockFd);
|
|
84
|
+
fs.rmSync(lockFile, { force: true });
|
|
85
|
+
return result;
|
|
86
|
+
} catch (error) {
|
|
87
|
+
lastError = error;
|
|
88
|
+
if (lockFd !== null) {
|
|
89
|
+
try {
|
|
90
|
+
fs.closeSync(lockFd);
|
|
91
|
+
} catch {
|
|
92
|
+
// ignore lock close failure
|
|
93
|
+
}
|
|
94
|
+
fs.rmSync(lockFile, { force: true });
|
|
95
|
+
throw error;
|
|
96
|
+
}
|
|
97
|
+
if (String(error?.code || "").toUpperCase() !== "EEXIST") {
|
|
98
|
+
throw error;
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
throw lastError || new Error(`无法获取 HelloLoop 终端会话锁:${registryRoot}`);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function listSessionFiles(registryRoot) {
|
|
107
|
+
if (!fileExists(registryRoot)) {
|
|
108
|
+
return [];
|
|
109
|
+
}
|
|
110
|
+
return fs.readdirSync(registryRoot)
|
|
111
|
+
.filter((item) => item.endsWith(".json"))
|
|
112
|
+
.map((item) => path.join(registryRoot, item));
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function readSessionRecord(sessionFile) {
|
|
116
|
+
try {
|
|
117
|
+
return readJson(sessionFile);
|
|
118
|
+
} catch {
|
|
119
|
+
return null;
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function isPreparedSessionExpired(record) {
|
|
124
|
+
const updatedAt = Date.parse(String(record?.updatedAt || record?.createdAt || ""));
|
|
125
|
+
if (!Number.isFinite(updatedAt)) {
|
|
126
|
+
return true;
|
|
127
|
+
}
|
|
128
|
+
return Date.now() - updatedAt > STALE_PREPARED_SESSION_MS;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function isStaleSession(record) {
|
|
132
|
+
const kind = String(record?.kind || "");
|
|
133
|
+
if (!["visible", "background"].includes(kind)) {
|
|
134
|
+
return true;
|
|
135
|
+
}
|
|
136
|
+
if (Number(record?.pid || 0) > 0) {
|
|
137
|
+
return !isPidAlive(record.pid);
|
|
138
|
+
}
|
|
139
|
+
if (Number(record?.ownerPid || 0) > 0 && !isPidAlive(record.ownerPid)) {
|
|
140
|
+
return true;
|
|
141
|
+
}
|
|
142
|
+
return isPreparedSessionExpired(record);
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
function cleanupStaleSessions(registryRoot) {
|
|
146
|
+
const activeSessions = [];
|
|
147
|
+
for (const sessionFile of listSessionFiles(registryRoot)) {
|
|
148
|
+
const record = readSessionRecord(sessionFile);
|
|
149
|
+
if (!record || isStaleSession(record)) {
|
|
150
|
+
fs.rmSync(sessionFile, { force: true });
|
|
151
|
+
continue;
|
|
152
|
+
}
|
|
153
|
+
activeSessions.push({
|
|
154
|
+
...record,
|
|
155
|
+
file: sessionFile,
|
|
156
|
+
});
|
|
157
|
+
}
|
|
158
|
+
return activeSessions;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
function countSessions(sessions, excludingId = "") {
|
|
162
|
+
return sessions
|
|
163
|
+
.filter((session) => session.id !== excludingId)
|
|
164
|
+
.reduce((counts, session) => {
|
|
165
|
+
if (session.kind === "visible") {
|
|
166
|
+
counts.visible += 1;
|
|
167
|
+
}
|
|
168
|
+
if (session.kind === "background") {
|
|
169
|
+
counts.background += 1;
|
|
170
|
+
}
|
|
171
|
+
counts.total += 1;
|
|
172
|
+
return counts;
|
|
173
|
+
}, { visible: 0, background: 0, total: 0 });
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
function throwSessionLimitError(kind, counts, limits, settingsFile, reason) {
|
|
177
|
+
const kindLabel = kind === "background" ? "背景终端" : "显示终端";
|
|
178
|
+
const scope = [
|
|
179
|
+
`显示终端 ${counts.visible}/${limits.visibleMax}`,
|
|
180
|
+
`背景终端 ${counts.background}/${limits.backgroundMax}`,
|
|
181
|
+
`总并发 ${counts.total}/${limits.totalMax}`,
|
|
182
|
+
].join(",");
|
|
183
|
+
throw new Error(
|
|
184
|
+
`${kindLabel}${reason},当前 ${scope}。`
|
|
185
|
+
+ ` 如需调整,请修改 ${settingsFile} 中的 runtime.terminalConcurrency.visibleMax / backgroundMax / totalMax。`,
|
|
186
|
+
);
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
function assertSessionLimit(kind, sessions, limits, settingsFile, excludingId = "") {
|
|
190
|
+
if (!limits.enabled) {
|
|
191
|
+
return;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
const counts = countSessions(sessions, excludingId);
|
|
195
|
+
const nextCounts = {
|
|
196
|
+
visible: counts.visible + (kind === "visible" ? 1 : 0),
|
|
197
|
+
background: counts.background + (kind === "background" ? 1 : 0),
|
|
198
|
+
total: counts.total + 1,
|
|
199
|
+
};
|
|
200
|
+
|
|
201
|
+
if (kind === "visible" && nextCounts.visible > limits.visibleMax) {
|
|
202
|
+
throwSessionLimitError(kind, nextCounts, limits, settingsFile, "并发已达上限");
|
|
203
|
+
}
|
|
204
|
+
if (kind === "background" && nextCounts.background > limits.backgroundMax) {
|
|
205
|
+
throwSessionLimitError(kind, nextCounts, limits, settingsFile, "并发已达上限");
|
|
206
|
+
}
|
|
207
|
+
if (nextCounts.total > limits.totalMax) {
|
|
208
|
+
throwSessionLimitError(kind, nextCounts, limits, settingsFile, "启动被阻止:显示终端与背景终端合计并发已达上限");
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
function buildSessionRecord(kind, options = {}) {
|
|
213
|
+
return {
|
|
214
|
+
id: options.id || `${timestampForFile()}-${kind}-${process.pid}`,
|
|
215
|
+
kind,
|
|
216
|
+
pid: Number(options.pid || process.pid),
|
|
217
|
+
ownerPid: Number(options.ownerPid || process.pid),
|
|
218
|
+
command: String(options.command || "").trim(),
|
|
219
|
+
sessionId: String(options.sessionId || "").trim(),
|
|
220
|
+
repoRoot: String(options.repoRoot || "").trim(),
|
|
221
|
+
createdAt: options.createdAt || nowIso(),
|
|
222
|
+
updatedAt: nowIso(),
|
|
223
|
+
};
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
function writeSessionRecord(sessionFile, record) {
|
|
227
|
+
writeJson(sessionFile, record);
|
|
228
|
+
return {
|
|
229
|
+
...record,
|
|
230
|
+
file: sessionFile,
|
|
231
|
+
};
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
function ensureCleanupRegistration() {
|
|
235
|
+
if (cleanupRegistered) {
|
|
236
|
+
return;
|
|
237
|
+
}
|
|
238
|
+
cleanupRegistered = true;
|
|
239
|
+
process.on("exit", () => {
|
|
240
|
+
try {
|
|
241
|
+
releaseCurrentTerminalSession();
|
|
242
|
+
} catch {
|
|
243
|
+
// ignore exit cleanup failure
|
|
244
|
+
}
|
|
245
|
+
});
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
function setCurrentTerminalSession(record) {
|
|
249
|
+
if (!record) {
|
|
250
|
+
return null;
|
|
251
|
+
}
|
|
252
|
+
currentTerminalSession = {
|
|
253
|
+
...record,
|
|
254
|
+
preparedForBackground: false,
|
|
255
|
+
handedOff: false,
|
|
256
|
+
};
|
|
257
|
+
ensureCleanupRegistration();
|
|
258
|
+
return currentTerminalSession;
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
export function shouldTrackVisibleTerminalCommand(command) {
|
|
262
|
+
return TRACKED_VISIBLE_COMMANDS.has(String(command || "").trim());
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
export function acquireVisibleTerminalSession(options = {}) {
|
|
266
|
+
if (currentTerminalSession) {
|
|
267
|
+
return currentTerminalSession;
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
const runtime = resolveTerminalRuntimeConfig(options);
|
|
271
|
+
if (!runtime.limits.enabled) {
|
|
272
|
+
return null;
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
return withRegistryLock(runtime.registryRoot, () => {
|
|
276
|
+
const sessions = cleanupStaleSessions(runtime.registryRoot);
|
|
277
|
+
assertSessionLimit("visible", sessions, runtime.limits, runtime.settingsFile);
|
|
278
|
+
const record = buildSessionRecord("visible", options);
|
|
279
|
+
const sessionFile = path.join(runtime.registryRoot, `${record.id}.json`);
|
|
280
|
+
return setCurrentTerminalSession(writeSessionRecord(sessionFile, record));
|
|
281
|
+
});
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
export function prepareCurrentTerminalSessionForBackground(options = {}) {
|
|
285
|
+
const runtime = resolveTerminalRuntimeConfig(options);
|
|
286
|
+
if (!runtime.limits.enabled) {
|
|
287
|
+
return null;
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
return withRegistryLock(runtime.registryRoot, () => {
|
|
291
|
+
const sessions = cleanupStaleSessions(runtime.registryRoot);
|
|
292
|
+
|
|
293
|
+
if (!currentTerminalSession) {
|
|
294
|
+
assertSessionLimit("background", sessions, runtime.limits, runtime.settingsFile);
|
|
295
|
+
const record = buildSessionRecord("background", {
|
|
296
|
+
...options,
|
|
297
|
+
pid: 0,
|
|
298
|
+
});
|
|
299
|
+
const sessionFile = path.join(runtime.registryRoot, `${record.id}.json`);
|
|
300
|
+
const next = writeSessionRecord(sessionFile, record);
|
|
301
|
+
currentTerminalSession = {
|
|
302
|
+
...next,
|
|
303
|
+
preparedForBackground: true,
|
|
304
|
+
handedOff: false,
|
|
305
|
+
};
|
|
306
|
+
ensureCleanupRegistration();
|
|
307
|
+
return currentTerminalSession;
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
assertSessionLimit("background", sessions, runtime.limits, runtime.settingsFile, currentTerminalSession.id);
|
|
311
|
+
const record = buildSessionRecord("background", {
|
|
312
|
+
...currentTerminalSession,
|
|
313
|
+
...options,
|
|
314
|
+
pid: 0,
|
|
315
|
+
ownerPid: process.pid,
|
|
316
|
+
});
|
|
317
|
+
const next = writeSessionRecord(currentTerminalSession.file, record);
|
|
318
|
+
currentTerminalSession = {
|
|
319
|
+
...next,
|
|
320
|
+
preparedForBackground: true,
|
|
321
|
+
handedOff: false,
|
|
322
|
+
};
|
|
323
|
+
return currentTerminalSession;
|
|
324
|
+
});
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
export function finalizePreparedTerminalSessionBackground(pid, options = {}) {
|
|
328
|
+
if (!currentTerminalSession?.file) {
|
|
329
|
+
return null;
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
const runtime = resolveTerminalRuntimeConfig(options);
|
|
333
|
+
return withRegistryLock(runtime.registryRoot, () => {
|
|
334
|
+
const record = buildSessionRecord("background", {
|
|
335
|
+
...currentTerminalSession,
|
|
336
|
+
...options,
|
|
337
|
+
pid,
|
|
338
|
+
ownerPid: pid,
|
|
339
|
+
createdAt: currentTerminalSession.createdAt,
|
|
340
|
+
});
|
|
341
|
+
const next = writeSessionRecord(currentTerminalSession.file, record);
|
|
342
|
+
currentTerminalSession = {
|
|
343
|
+
...next,
|
|
344
|
+
preparedForBackground: false,
|
|
345
|
+
handedOff: true,
|
|
346
|
+
};
|
|
347
|
+
return currentTerminalSession;
|
|
348
|
+
});
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
export function cancelPreparedTerminalSessionBackground(options = {}) {
|
|
352
|
+
if (!currentTerminalSession?.file || !currentTerminalSession.preparedForBackground) {
|
|
353
|
+
return false;
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
const runtime = resolveTerminalRuntimeConfig(options);
|
|
357
|
+
return withRegistryLock(runtime.registryRoot, () => {
|
|
358
|
+
fs.rmSync(currentTerminalSession.file, { force: true });
|
|
359
|
+
currentTerminalSession = null;
|
|
360
|
+
return true;
|
|
361
|
+
});
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
export function bindBackgroundTerminalSession(sessionFile, options = {}) {
|
|
365
|
+
if (!sessionFile) {
|
|
366
|
+
return null;
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
const registryRoot = path.dirname(sessionFile);
|
|
370
|
+
return withRegistryLock(registryRoot, () => {
|
|
371
|
+
const existing = fileExists(sessionFile) ? readSessionRecord(sessionFile) : null;
|
|
372
|
+
const record = buildSessionRecord("background", {
|
|
373
|
+
...existing,
|
|
374
|
+
...options,
|
|
375
|
+
pid: process.pid,
|
|
376
|
+
ownerPid: process.pid,
|
|
377
|
+
});
|
|
378
|
+
return setCurrentTerminalSession(writeSessionRecord(sessionFile, record));
|
|
379
|
+
});
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
export function releaseCurrentTerminalSession() {
|
|
383
|
+
if (!currentTerminalSession?.file || currentTerminalSession.handedOff) {
|
|
384
|
+
return false;
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
const sessionFile = currentTerminalSession.file;
|
|
388
|
+
const registryRoot = path.dirname(sessionFile);
|
|
389
|
+
withRegistryLock(registryRoot, () => {
|
|
390
|
+
fs.rmSync(sessionFile, { force: true });
|
|
391
|
+
});
|
|
392
|
+
currentTerminalSession = null;
|
|
393
|
+
return true;
|
|
394
|
+
}
|