helloloop 0.9.1 → 0.10.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/plugin.json +1 -1
- package/README.md +230 -506
- package/hosts/claude/marketplace/plugins/helloloop/.claude-plugin/plugin.json +1 -1
- package/hosts/gemini/extension/gemini-extension.json +1 -1
- package/native/windows-hidden-shell-proxy/HelloLoopHiddenShellProxy.csproj +11 -0
- package/native/windows-hidden-shell-proxy/Program.cs +498 -0
- package/package.json +4 -2
- package/src/activity_projection.mjs +294 -0
- package/src/analyze_confirmation.mjs +3 -1
- package/src/analyzer.mjs +2 -1
- package/src/auto_execution_options.mjs +13 -0
- package/src/background_launch.mjs +73 -0
- package/src/cli.mjs +49 -1
- package/src/cli_analyze_command.mjs +9 -5
- package/src/cli_args.mjs +102 -37
- package/src/cli_command_handlers.mjs +44 -4
- package/src/cli_support.mjs +2 -0
- package/src/dashboard_command.mjs +371 -0
- package/src/dashboard_tui.mjs +289 -0
- package/src/dashboard_web.mjs +351 -0
- package/src/dashboard_web_client.mjs +167 -0
- package/src/dashboard_web_page.mjs +49 -0
- package/src/engine_event_parser_codex.mjs +167 -0
- package/src/engine_process_support.mjs +1 -0
- package/src/engine_selection.mjs +24 -0
- package/src/engine_selection_probe.mjs +10 -6
- package/src/engine_selection_settings.mjs +12 -19
- package/src/execution_interactivity.mjs +12 -0
- package/src/host_continuation.mjs +305 -0
- package/src/install_codex.mjs +20 -8
- package/src/install_shared.mjs +9 -0
- package/src/node_process_launch.mjs +28 -0
- package/src/process.mjs +2 -0
- package/src/runner_execute_task.mjs +4 -0
- package/src/runner_execution_support.mjs +69 -3
- package/src/runner_once.mjs +4 -0
- package/src/runner_status.mjs +63 -7
- package/src/runtime_engine_support.mjs +41 -4
- package/src/runtime_engine_task.mjs +7 -0
- package/src/runtime_settings.mjs +105 -0
- package/src/runtime_settings_loader.mjs +19 -0
- package/src/shell_invocation.mjs +227 -9
- package/src/supervisor_cli_support.mjs +3 -2
- package/src/supervisor_guardian.mjs +307 -0
- package/src/supervisor_runtime.mjs +138 -82
- package/src/supervisor_state.mjs +64 -0
- package/src/supervisor_watch.mjs +92 -48
- package/src/terminal_session_limits.mjs +1 -21
- package/src/windows_hidden_shell_proxy.mjs +405 -0
- package/src/workspace_registry.mjs +155 -0
package/src/cli_args.mjs
CHANGED
|
@@ -1,30 +1,60 @@
|
|
|
1
1
|
const REPO_ROOT_PLACEHOLDER = "<REPO_ROOT>";
|
|
2
2
|
const DOCS_PATH_PLACEHOLDER = "<DOCS_PATH>";
|
|
3
|
-
const
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
"
|
|
7
|
-
"uninstall",
|
|
8
|
-
"init",
|
|
9
|
-
"status",
|
|
10
|
-
"watch",
|
|
11
|
-
"next",
|
|
12
|
-
"run-once",
|
|
13
|
-
"run-loop",
|
|
14
|
-
"doctor",
|
|
15
|
-
"help",
|
|
16
|
-
"--help",
|
|
17
|
-
"-h",
|
|
3
|
+
const KNOWN_ENGINES = new Set([
|
|
4
|
+
"codex",
|
|
5
|
+
"claude",
|
|
6
|
+
"gemini",
|
|
18
7
|
]);
|
|
8
|
+
const COMMAND_ALIASES = Object.freeze({
|
|
9
|
+
"__supervise": "__supervise",
|
|
10
|
+
"__supervise-worker": "__supervise-worker",
|
|
11
|
+
"__web-server": "__web-server",
|
|
12
|
+
analyze: "analyze",
|
|
13
|
+
a: "analyze",
|
|
14
|
+
dashboard: "dashboard",
|
|
15
|
+
dash: "dashboard",
|
|
16
|
+
db: "dashboard",
|
|
17
|
+
tui: "tui",
|
|
18
|
+
web: "web",
|
|
19
|
+
install: "install",
|
|
20
|
+
i: "install",
|
|
21
|
+
uninstall: "uninstall",
|
|
22
|
+
un: "uninstall",
|
|
23
|
+
init: "init",
|
|
24
|
+
resume: "resume-host",
|
|
25
|
+
rh: "resume-host",
|
|
26
|
+
"resume-host": "resume-host",
|
|
27
|
+
status: "status",
|
|
28
|
+
st: "status",
|
|
29
|
+
watch: "watch",
|
|
30
|
+
w: "watch",
|
|
31
|
+
next: "next",
|
|
32
|
+
n: "next",
|
|
33
|
+
"run-once": "run-once",
|
|
34
|
+
once: "run-once",
|
|
35
|
+
"run-loop": "run-loop",
|
|
36
|
+
loop: "run-loop",
|
|
37
|
+
doctor: "doctor",
|
|
38
|
+
dr: "doctor",
|
|
39
|
+
help: "help",
|
|
40
|
+
"--help": "help",
|
|
41
|
+
"-h": "help",
|
|
42
|
+
});
|
|
43
|
+
const KNOWN_COMMANDS = new Set(Object.keys(COMMAND_ALIASES));
|
|
44
|
+
|
|
45
|
+
function normalizeCommand(value = "") {
|
|
46
|
+
return COMMAND_ALIASES[String(value || "").trim()] || "";
|
|
47
|
+
}
|
|
19
48
|
|
|
20
49
|
export function parseArgs(argv) {
|
|
21
50
|
const [first = "", ...restArgs] = argv;
|
|
51
|
+
const normalizedFirstCommand = normalizeCommand(first);
|
|
22
52
|
const command = !first
|
|
23
53
|
? "analyze"
|
|
24
|
-
: (
|
|
54
|
+
: (normalizedFirstCommand || "analyze");
|
|
25
55
|
const rest = !first
|
|
26
56
|
? []
|
|
27
|
-
: (
|
|
57
|
+
: (normalizedFirstCommand ? restArgs : argv);
|
|
28
58
|
const options = {
|
|
29
59
|
requiredDocs: [],
|
|
30
60
|
constraints: [],
|
|
@@ -35,11 +65,11 @@ export function parseArgs(argv) {
|
|
|
35
65
|
const arg = rest[index];
|
|
36
66
|
if (arg === "--dry-run") options.dryRun = true;
|
|
37
67
|
else if (arg === "--yes" || arg === "-y") options.yes = true;
|
|
38
|
-
else if (arg === "--allow-high-risk") options.allowHighRisk = true;
|
|
68
|
+
else if (arg === "--allow-high-risk" || arg === "-r") options.allowHighRisk = true;
|
|
39
69
|
else if (arg === "--rebuild-existing") options.rebuildExisting = true;
|
|
40
70
|
else if (arg === "--force") options.force = true;
|
|
41
|
-
else if (arg === "--task-id") { options.taskId = rest[index + 1]; index += 1; }
|
|
42
|
-
else if (arg === "--max-tasks") { options.maxTasks = Number(rest[index + 1]); index += 1; }
|
|
71
|
+
else if (arg === "--task-id" || arg === "-t") { options.taskId = rest[index + 1]; index += 1; }
|
|
72
|
+
else if (arg === "--max-tasks" || arg === "-m") { options.maxTasks = Number(rest[index + 1]); index += 1; }
|
|
43
73
|
else if (arg === "--max-attempts") { options.maxAttempts = Number(rest[index + 1]); index += 1; }
|
|
44
74
|
else if (arg === "--max-strategies") { options.maxStrategies = Number(rest[index + 1]); index += 1; }
|
|
45
75
|
else if (arg === "--repo") { options.repoRoot = rest[index + 1]; index += 1; }
|
|
@@ -52,15 +82,32 @@ export function parseArgs(argv) {
|
|
|
52
82
|
else if (arg === "--gemini-home") { options.geminiHome = rest[index + 1]; index += 1; }
|
|
53
83
|
else if (arg === "--config-dir") { options.configDirName = rest[index + 1]; index += 1; }
|
|
54
84
|
else if (arg === "--session-file") { options.sessionFile = rest[index + 1]; index += 1; }
|
|
55
|
-
else if (arg === "--
|
|
56
|
-
else if (arg === "--
|
|
85
|
+
else if (arg === "--launch-id") { options.launchId = rest[index + 1]; index += 1; }
|
|
86
|
+
else if (arg === "--watch" || arg === "-w") options.watch = true;
|
|
87
|
+
else if (arg === "--detach" || arg === "-d") options.detach = true;
|
|
88
|
+
else if (arg === "--foreground") options.foreground = true;
|
|
89
|
+
else if (arg === "--stop") options.stop = true;
|
|
90
|
+
else if (arg === "--json" || arg === "-j") options.json = true;
|
|
91
|
+
else if (arg === "--compact" || arg === "-c") options.compact = true;
|
|
92
|
+
else if (arg === "--events" || arg === "-e") options.events = true;
|
|
57
93
|
else if (arg === "--session-id") { options.sessionId = rest[index + 1]; index += 1; }
|
|
58
94
|
else if (arg === "--watch-poll-ms") { options.watchPollMs = Number(rest[index + 1]); index += 1; }
|
|
95
|
+
else if (arg === "--poll-ms" || arg === "-p") { options.pollMs = Number(rest[index + 1]); index += 1; }
|
|
96
|
+
else if (arg === "--port") { options.port = Number(rest[index + 1]); index += 1; }
|
|
97
|
+
else if (arg === "--bind") { options.bind = rest[index + 1]; index += 1; }
|
|
59
98
|
else if (arg === "--required-doc") { options.requiredDocs.push(rest[index + 1]); index += 1; }
|
|
60
99
|
else if (arg === "--constraint") { options.constraints.push(rest[index + 1]); index += 1; }
|
|
61
100
|
else { options.positionalArgs.push(arg); }
|
|
62
101
|
}
|
|
63
102
|
|
|
103
|
+
const [leadingPositional = "", ...remainingPositionals] = options.positionalArgs;
|
|
104
|
+
const normalizedLeadingEngine = String(leadingPositional || "").trim().toLowerCase();
|
|
105
|
+
if (!options.engine && KNOWN_ENGINES.has(normalizedLeadingEngine)) {
|
|
106
|
+
options.engine = normalizedLeadingEngine;
|
|
107
|
+
options.engineSource = "leading_positional";
|
|
108
|
+
options.positionalArgs = remainingPositionals;
|
|
109
|
+
}
|
|
110
|
+
|
|
64
111
|
return { command, options };
|
|
65
112
|
}
|
|
66
113
|
|
|
@@ -69,16 +116,21 @@ function helpText() {
|
|
|
69
116
|
"用法:helloloop [command] [engine] [path|需求说明...] [options]",
|
|
70
117
|
"",
|
|
71
118
|
"命令:",
|
|
72
|
-
" analyze
|
|
73
|
-
"
|
|
74
|
-
"
|
|
119
|
+
" analyze | a 自动分析并生成执行确认单;确认后继续自动接续开发(默认)",
|
|
120
|
+
" dashboard | dash | db 在当前终端启动动态 TUI 看板(默认总控台)",
|
|
121
|
+
" tui dashboard 的显式别名,适合从主终端直接打开实时任务看板",
|
|
122
|
+
" web 启动本地 Web 看板;默认后台启动并输出访问地址",
|
|
123
|
+
" install | i 安装插件到 Codex Home(适合 npx / npm bin 分发)",
|
|
124
|
+
" uninstall | un 从所选宿主卸载插件并清理注册信息",
|
|
75
125
|
" init 初始化 .helloloop 配置",
|
|
76
|
-
"
|
|
77
|
-
"
|
|
78
|
-
"
|
|
79
|
-
"
|
|
80
|
-
"
|
|
81
|
-
"
|
|
126
|
+
" resume-host | resume | rh",
|
|
127
|
+
" 输出当前仓库的宿主续跑自然语言提示,供主 CLI / 主代理中断后继续输入",
|
|
128
|
+
" status | st 查看 backlog 与下一任务",
|
|
129
|
+
" watch | w 附着到后台 supervisor,持续查看实时进度",
|
|
130
|
+
" next | n 生成下一任务干跑预览",
|
|
131
|
+
" run-once | once 执行一个任务",
|
|
132
|
+
" run-loop | loop 连续执行多个任务",
|
|
133
|
+
" doctor | dr 检查 Codex、当前插件 bundle 与目标仓库 .helloloop 配置是否可用",
|
|
82
134
|
"",
|
|
83
135
|
"选项:",
|
|
84
136
|
" --host <name> 安装宿主:codex | claude | gemini | all(默认 codex)",
|
|
@@ -91,14 +143,20 @@ function helpText() {
|
|
|
91
143
|
" --config-dir <dir> 配置目录,默认 .helloloop",
|
|
92
144
|
" -y, --yes 跳过交互确认,分析后直接开始自动执行",
|
|
93
145
|
" --dry-run 只分析并输出确认单,不真正开始自动执行",
|
|
94
|
-
" --task-id <id>
|
|
95
|
-
" --max-tasks <n>
|
|
146
|
+
" -t, --task-id <id> 指定任务 id",
|
|
147
|
+
" -m, --max-tasks <n> run-loop 最多执行 n 个任务",
|
|
96
148
|
" --max-attempts <n> 每种策略内最多重试 n 次",
|
|
97
149
|
" --max-strategies <n> 单任务最多切换 n 种策略继续重试",
|
|
98
|
-
" --watch
|
|
99
|
-
" --detach
|
|
150
|
+
" -w, --watch 启动后台 supervisor 后,当前终端继续附着观察实时输出",
|
|
151
|
+
" -d, --detach 仅启动后台 supervisor 后立即返回,不进入观察模式",
|
|
152
|
+
" --foreground web 命令以前台模式运行本地服务",
|
|
153
|
+
" --stop web 命令停止当前本地看板服务",
|
|
154
|
+
" -j, --json 以 JSON / NDJSON 形式输出结构化状态",
|
|
100
155
|
" --session-id <id> status/watch 指定附着的后台会话 ID",
|
|
101
|
-
" --allow-high-risk
|
|
156
|
+
" -r, --allow-high-risk 手动 run-once/run-loop 时允许执行 medium/high/critical 风险任务",
|
|
157
|
+
" -p, --poll-ms <n> dashboard 轮询间隔(毫秒)",
|
|
158
|
+
" --port <n> web 命令指定监听端口,默认优先尝试 3210",
|
|
159
|
+
" --bind <addr> web 命令指定监听地址,默认 127.0.0.1",
|
|
102
160
|
" --rebuild-existing 分析判断当前项目与文档冲突时,自动清理当前项目后按文档重建",
|
|
103
161
|
" --required-doc <p> 增加一个全局必读文档(AGENTS.md 会被自动忽略)",
|
|
104
162
|
" --constraint <text> 增加一个全局实现约束",
|
|
@@ -107,8 +165,15 @@ function helpText() {
|
|
|
107
165
|
" analyze 默认支持在命令后混合传入引擎、路径和自然语言要求。",
|
|
108
166
|
" 如果同时检测到多个可用引擎且没有明确指定,会先询问你选择。",
|
|
109
167
|
" 当前版本默认会把自动执行 / run-once / run-loop 切到后台 supervisor。",
|
|
168
|
+
" `helloloop dashboard` / `helloloop tui` 会打开终端实时总控台,不再把文本 dashboard 作为默认交互形态。",
|
|
169
|
+
" `helloloop dashboard --json --watch` 仍可为宿主主代理提供结构化状态流。",
|
|
170
|
+
" `helloloop web` 会启动本地 Web 看板,适合长时间像 Jira 一样查看多仓任务板。",
|
|
171
|
+
" `helloloop resume-host --json` 会输出结构化续跑提示;不带 `--json` 时直接输出自然语言续跑内容。",
|
|
172
|
+
" Windows 下 `analyze -y --detach` 会直接切到隐藏后台引导,不再保留前台空白控制台等待分析完成。",
|
|
173
|
+
" Windows 下运行 Codex 时会优先绕过 codex.ps1,直接以隐藏原生 codex.exe 启动,并把内部 pwsh/powershell 调度重定向到隐藏代理,尽量避免额外 PowerShell 窗口闪烁。",
|
|
174
|
+
" `watch` / `status --watch` / `dashboard --json --watch` 会按全局设置自动重试附着或重连;后台守护进程也会按设置保活重拉起。",
|
|
110
175
|
" 交互终端默认会自动附着观察;如只想立即返回,请显式加 --detach。",
|
|
111
|
-
" 任何时候都可运行 `helloloop watch` 或 `helloloop status --watch` 重新附着观察。",
|
|
176
|
+
" 任何时候都可运行 `helloloop watch` / `helloloop w` 或 `helloloop status --watch` / `helloloop st -w` 重新附着观察。",
|
|
112
177
|
" 示例:npx helloloop claude <DOCS_PATH> <PROJECT_ROOT> 先分析偏差,不要执行",
|
|
113
178
|
].join("\n");
|
|
114
179
|
}
|
|
@@ -1,16 +1,21 @@
|
|
|
1
1
|
import path from "node:path";
|
|
2
2
|
|
|
3
3
|
import { createContext } from "./context.mjs";
|
|
4
|
+
import { runDashboardCommand } from "./dashboard_command.mjs";
|
|
5
|
+
import { runDashboardTuiCommand } from "./dashboard_tui.mjs";
|
|
6
|
+
import { runDashboardWebCommand } from "./dashboard_web.mjs";
|
|
4
7
|
import { loadBacklog, scaffoldIfMissing } from "./config.mjs";
|
|
5
8
|
import { syncUserSettingsFile } from "./engine_selection_settings.mjs";
|
|
9
|
+
import { readHostContinuationSnapshot, runHostContinuationCommand } from "./host_continuation.mjs";
|
|
6
10
|
import { installPluginBundle, uninstallPluginBundle } from "./install.mjs";
|
|
7
11
|
import { runLoop, runOnce, renderStatusText } from "./runner.mjs";
|
|
12
|
+
import { collectRepoStatusSnapshot } from "./runner_status.mjs";
|
|
8
13
|
import { renderInstallSummary, renderUninstallSummary } from "./cli_render.mjs";
|
|
9
14
|
import {
|
|
10
15
|
launchAndMaybeWatchSupervisedCommand,
|
|
11
16
|
shouldUseSupervisor,
|
|
12
17
|
} from "./supervisor_cli_support.mjs";
|
|
13
|
-
import {
|
|
18
|
+
import { watchSupervisorSessionWithRecovery } from "./supervisor_watch.mjs";
|
|
14
19
|
|
|
15
20
|
export function handleInstallCommand(options) {
|
|
16
21
|
const userSettings = syncUserSettingsFile({
|
|
@@ -64,14 +69,29 @@ export async function handleDoctorCommand(context, options, runDoctor) {
|
|
|
64
69
|
}
|
|
65
70
|
|
|
66
71
|
export async function handleStatusCommand(context, options) {
|
|
67
|
-
|
|
72
|
+
const hostResume = readHostContinuationSnapshot(context, {
|
|
73
|
+
refresh: true,
|
|
74
|
+
sessionId: options.sessionId || "",
|
|
75
|
+
});
|
|
76
|
+
if (options.json) {
|
|
77
|
+
console.log(JSON.stringify({
|
|
78
|
+
...collectRepoStatusSnapshot(context, options),
|
|
79
|
+
hostResume,
|
|
80
|
+
}, null, 2));
|
|
81
|
+
} else {
|
|
82
|
+
console.log(renderStatusText(context, {
|
|
83
|
+
...options,
|
|
84
|
+
hostResume,
|
|
85
|
+
}));
|
|
86
|
+
}
|
|
68
87
|
if (!options.watch) {
|
|
69
88
|
return 0;
|
|
70
89
|
}
|
|
71
90
|
|
|
72
|
-
const result = await
|
|
91
|
+
const result = await watchSupervisorSessionWithRecovery(context, {
|
|
73
92
|
sessionId: options.sessionId,
|
|
74
93
|
pollMs: options.watchPollMs,
|
|
94
|
+
globalConfigFile: options.globalConfigFile,
|
|
75
95
|
});
|
|
76
96
|
if (result.empty) {
|
|
77
97
|
console.log("当前没有正在运行的后台 supervisor。");
|
|
@@ -80,10 +100,30 @@ export async function handleStatusCommand(context, options) {
|
|
|
80
100
|
return result.exitCode || 0;
|
|
81
101
|
}
|
|
82
102
|
|
|
103
|
+
export async function handleDashboardCommand(options) {
|
|
104
|
+
if (options.json === true) {
|
|
105
|
+
return runDashboardCommand(options);
|
|
106
|
+
}
|
|
107
|
+
return runDashboardTuiCommand(options);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
export async function handleTuiCommand(options) {
|
|
111
|
+
return runDashboardTuiCommand(options);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
export async function handleWebCommand(options) {
|
|
115
|
+
return runDashboardWebCommand(options);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
export async function handleResumeHostCommand(context, options) {
|
|
119
|
+
return runHostContinuationCommand(context, options);
|
|
120
|
+
}
|
|
121
|
+
|
|
83
122
|
export async function handleWatchCommand(context, options) {
|
|
84
|
-
const result = await
|
|
123
|
+
const result = await watchSupervisorSessionWithRecovery(context, {
|
|
85
124
|
sessionId: options.sessionId,
|
|
86
125
|
pollMs: options.watchPollMs,
|
|
126
|
+
globalConfigFile: options.globalConfigFile,
|
|
87
127
|
});
|
|
88
128
|
if (result.empty) {
|
|
89
129
|
console.log("当前没有正在运行的后台 supervisor。");
|
package/src/cli_support.mjs
CHANGED
|
@@ -19,6 +19,7 @@ function probeCodexVersion() {
|
|
|
19
19
|
const codexVersion = spawnSync(invocation.command, [...invocation.argsPrefix, "--version"], {
|
|
20
20
|
encoding: "utf8",
|
|
21
21
|
shell: invocation.shell,
|
|
22
|
+
windowsHide: process.platform === "win32",
|
|
22
23
|
});
|
|
23
24
|
const ok = codexVersion.status === 0;
|
|
24
25
|
return {
|
|
@@ -113,6 +114,7 @@ function probeNamedCliVersion(commandName, toolDisplayName) {
|
|
|
113
114
|
const result = spawnSync(invocation.command, [...invocation.argsPrefix, "--version"], {
|
|
114
115
|
encoding: "utf8",
|
|
115
116
|
shell: invocation.shell,
|
|
117
|
+
windowsHide: process.platform === "win32",
|
|
116
118
|
});
|
|
117
119
|
const ok = result.status === 0;
|
|
118
120
|
return {
|
|
@@ -0,0 +1,371 @@
|
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
|
|
3
|
+
import { nowIso, sleep } from "./common.mjs";
|
|
4
|
+
import { createContext } from "./context.mjs";
|
|
5
|
+
import { analyzeExecution } from "./backlog.mjs";
|
|
6
|
+
import { loadBacklog } from "./config.mjs";
|
|
7
|
+
import { buildDashboardHostContinuation } from "./host_continuation.mjs";
|
|
8
|
+
import { loadRuntimeSettings } from "./runtime_settings_loader.mjs";
|
|
9
|
+
import { collectRepoStatusSnapshot } from "./runner_status.mjs";
|
|
10
|
+
import { hasRetryBudget, pickRetryDelaySeconds } from "./runtime_settings.mjs";
|
|
11
|
+
import { isTrackedPidAlive } from "./supervisor_state.mjs";
|
|
12
|
+
import { listActiveSessionEntries, listKnownWorkspaceEntries } from "./workspace_registry.mjs";
|
|
13
|
+
|
|
14
|
+
function formatRuntimeLabel(runtime) {
|
|
15
|
+
if (!runtime?.status) {
|
|
16
|
+
return "idle";
|
|
17
|
+
}
|
|
18
|
+
const details = [runtime.status];
|
|
19
|
+
if (Number.isFinite(Number(runtime.recoveryCount)) && Number(runtime.recoveryCount) > 0) {
|
|
20
|
+
details.push(`recovery=${runtime.recoveryCount}`);
|
|
21
|
+
}
|
|
22
|
+
if (Number.isFinite(Number(runtime?.heartbeat?.idleSeconds)) && Number(runtime.heartbeat.idleSeconds) > 0) {
|
|
23
|
+
details.push(`idle=${runtime.heartbeat.idleSeconds}s`);
|
|
24
|
+
}
|
|
25
|
+
return details.join(" | ");
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function formatCurrentAction(session) {
|
|
29
|
+
return session.activity?.current?.label
|
|
30
|
+
|| session.runtime?.failureReason
|
|
31
|
+
|| session.latestStatus?.message
|
|
32
|
+
|| session.runtime?.status
|
|
33
|
+
|| "等待新事件";
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function formatTodoLabel(activity) {
|
|
37
|
+
if (!activity?.todo?.total) {
|
|
38
|
+
return "";
|
|
39
|
+
}
|
|
40
|
+
return `${activity.todo.completed}/${activity.todo.total}`;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function readBacklogSnapshot(context) {
|
|
44
|
+
try {
|
|
45
|
+
const backlog = loadBacklog(context);
|
|
46
|
+
return {
|
|
47
|
+
tasks: Array.isArray(backlog.tasks) ? backlog.tasks : [],
|
|
48
|
+
execution: analyzeExecution(backlog),
|
|
49
|
+
};
|
|
50
|
+
} catch {
|
|
51
|
+
return {
|
|
52
|
+
tasks: [],
|
|
53
|
+
execution: null,
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function normalizeSupervisorSnapshot(supervisor) {
|
|
59
|
+
if (!supervisor?.status) {
|
|
60
|
+
return supervisor;
|
|
61
|
+
}
|
|
62
|
+
if (["launching", "running"].includes(String(supervisor.status)) && !isTrackedPidAlive(supervisor.guardianPid || supervisor.pid)) {
|
|
63
|
+
return {
|
|
64
|
+
...supervisor,
|
|
65
|
+
status: "stopped",
|
|
66
|
+
message: supervisor.message || "后台会话当前未运行。",
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
return supervisor;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function normalizeRuntimeSnapshot(supervisor, runtime) {
|
|
73
|
+
if (!runtime) {
|
|
74
|
+
return runtime;
|
|
75
|
+
}
|
|
76
|
+
if (!supervisor?.status || supervisor.status !== "stopped") {
|
|
77
|
+
return runtime;
|
|
78
|
+
}
|
|
79
|
+
if (!["launching", "running", "recovering", "probe_running", "probe_waiting", "retry_waiting"].includes(String(runtime.status || ""))) {
|
|
80
|
+
return runtime;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
const stoppedReason = "后台 supervisor 当前未运行;以下展示的是最近一次执行快照。";
|
|
84
|
+
return {
|
|
85
|
+
...runtime,
|
|
86
|
+
status: "stopped",
|
|
87
|
+
failureReason: runtime.failureReason || stoppedReason,
|
|
88
|
+
heartbeat: runtime.heartbeat
|
|
89
|
+
? {
|
|
90
|
+
...runtime.heartbeat,
|
|
91
|
+
status: "stopped",
|
|
92
|
+
leaseExpired: true,
|
|
93
|
+
leaseReason: runtime.heartbeat.leaseReason || stoppedReason,
|
|
94
|
+
}
|
|
95
|
+
: runtime.heartbeat,
|
|
96
|
+
};
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function normalizeActivitySnapshot(supervisor, activity) {
|
|
100
|
+
if (!activity) {
|
|
101
|
+
return activity;
|
|
102
|
+
}
|
|
103
|
+
if (!supervisor?.status || supervisor.status !== "stopped") {
|
|
104
|
+
return activity;
|
|
105
|
+
}
|
|
106
|
+
if (String(activity.status || "") !== "running") {
|
|
107
|
+
return activity;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
return {
|
|
111
|
+
...activity,
|
|
112
|
+
status: "stopped",
|
|
113
|
+
runtime: activity.runtime
|
|
114
|
+
? {
|
|
115
|
+
...activity.runtime,
|
|
116
|
+
status: "stopped",
|
|
117
|
+
}
|
|
118
|
+
: activity.runtime,
|
|
119
|
+
};
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function buildSessionSnapshot(entry) {
|
|
123
|
+
const context = createContext({
|
|
124
|
+
repoRoot: entry.repoRoot,
|
|
125
|
+
configDirName: entry.configDirName,
|
|
126
|
+
});
|
|
127
|
+
let repoSnapshot;
|
|
128
|
+
try {
|
|
129
|
+
repoSnapshot = collectRepoStatusSnapshot(context, {
|
|
130
|
+
sessionId: entry.sessionId,
|
|
131
|
+
});
|
|
132
|
+
} catch (error) {
|
|
133
|
+
repoSnapshot = {
|
|
134
|
+
supervisor: null,
|
|
135
|
+
latestStatus: {
|
|
136
|
+
message: String(error?.message || error || "状态读取失败。"),
|
|
137
|
+
},
|
|
138
|
+
runtime: null,
|
|
139
|
+
activity: null,
|
|
140
|
+
summary: null,
|
|
141
|
+
nextTask: null,
|
|
142
|
+
};
|
|
143
|
+
}
|
|
144
|
+
const backlogSnapshot = readBacklogSnapshot(context);
|
|
145
|
+
const supervisor = normalizeSupervisorSnapshot(repoSnapshot.supervisor);
|
|
146
|
+
const runtime = normalizeRuntimeSnapshot(supervisor, repoSnapshot.runtime);
|
|
147
|
+
const activity = normalizeActivitySnapshot(supervisor, repoSnapshot.activity);
|
|
148
|
+
const snapshotForContinuation = {
|
|
149
|
+
...repoSnapshot,
|
|
150
|
+
supervisor,
|
|
151
|
+
runtime,
|
|
152
|
+
activity,
|
|
153
|
+
};
|
|
154
|
+
|
|
155
|
+
return {
|
|
156
|
+
repoRoot: entry.repoRoot,
|
|
157
|
+
repoName: path.basename(entry.repoRoot),
|
|
158
|
+
sessionId: entry.sessionId || repoSnapshot.latestStatus?.sessionId || "",
|
|
159
|
+
command: entry.command || supervisor?.command || "",
|
|
160
|
+
supervisor,
|
|
161
|
+
latestStatus: repoSnapshot.latestStatus,
|
|
162
|
+
runtime,
|
|
163
|
+
activity,
|
|
164
|
+
summary: repoSnapshot.summary,
|
|
165
|
+
nextTask: repoSnapshot.nextTask,
|
|
166
|
+
tasks: backlogSnapshot.tasks,
|
|
167
|
+
execution: backlogSnapshot.execution,
|
|
168
|
+
isActive: entry.isActive === true,
|
|
169
|
+
hostResume: buildDashboardHostContinuation(entry, snapshotForContinuation),
|
|
170
|
+
updatedAt: repoSnapshot.activity?.updatedAt
|
|
171
|
+
|| repoSnapshot.runtime?.updatedAt
|
|
172
|
+
|| repoSnapshot.latestStatus?.updatedAt
|
|
173
|
+
|| supervisor?.updatedAt
|
|
174
|
+
|| entry.updatedAt
|
|
175
|
+
|| "",
|
|
176
|
+
};
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
function buildTaskTotals(sessions) {
|
|
180
|
+
const totals = {
|
|
181
|
+
total: 0,
|
|
182
|
+
pending: 0,
|
|
183
|
+
inProgress: 0,
|
|
184
|
+
done: 0,
|
|
185
|
+
failed: 0,
|
|
186
|
+
blocked: 0,
|
|
187
|
+
};
|
|
188
|
+
|
|
189
|
+
for (const session of sessions) {
|
|
190
|
+
totals.total += Number(session.summary?.total || 0);
|
|
191
|
+
totals.pending += Number(session.summary?.pending || 0);
|
|
192
|
+
totals.inProgress += Number(session.summary?.inProgress || 0);
|
|
193
|
+
totals.done += Number(session.summary?.done || 0);
|
|
194
|
+
totals.failed += Number(session.summary?.failed || 0);
|
|
195
|
+
totals.blocked += Number(session.summary?.blocked || 0);
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
return totals;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
export function collectDashboardSnapshot() {
|
|
202
|
+
const activeEntries = listActiveSessionEntries()
|
|
203
|
+
.map((entry) => ({ ...entry, isActive: true }));
|
|
204
|
+
const knownEntries = listKnownWorkspaceEntries()
|
|
205
|
+
.map((entry) => ({ ...entry, isActive: false }));
|
|
206
|
+
const mergedEntries = new Map();
|
|
207
|
+
|
|
208
|
+
for (const entry of [...knownEntries, ...activeEntries]) {
|
|
209
|
+
const key = `${entry.repoRoot}::${entry.configDirName || ""}`;
|
|
210
|
+
const current = mergedEntries.get(key) || {};
|
|
211
|
+
mergedEntries.set(key, {
|
|
212
|
+
...current,
|
|
213
|
+
...entry,
|
|
214
|
+
isActive: entry.isActive === true || current.isActive === true,
|
|
215
|
+
});
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
const sessions = [...mergedEntries.values()]
|
|
219
|
+
.map((entry) => buildSessionSnapshot(entry))
|
|
220
|
+
.sort((left, right) => String(right.updatedAt || "").localeCompare(String(left.updatedAt || "")));
|
|
221
|
+
|
|
222
|
+
return {
|
|
223
|
+
schemaVersion: 1,
|
|
224
|
+
generatedAt: nowIso(),
|
|
225
|
+
activeCount: activeEntries.length,
|
|
226
|
+
repoCount: sessions.length,
|
|
227
|
+
taskTotals: buildTaskTotals(sessions),
|
|
228
|
+
primaryHostResume: sessions[0]?.hostResume || null,
|
|
229
|
+
sessions,
|
|
230
|
+
};
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
function renderCompactSession(session, index) {
|
|
234
|
+
return [
|
|
235
|
+
`${index + 1}. ${session.repoName}`,
|
|
236
|
+
` session=${session.sessionId}`,
|
|
237
|
+
` task=${session.latestStatus?.taskTitle || "无"}`,
|
|
238
|
+
` runtime=${formatRuntimeLabel(session.runtime)}`,
|
|
239
|
+
` action=${formatCurrentAction(session)}`,
|
|
240
|
+
...(formatTodoLabel(session.activity) ? [` todo=${formatTodoLabel(session.activity)}`] : []),
|
|
241
|
+
].join("\n");
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
function renderDetailedSession(session, index, options) {
|
|
245
|
+
const lines = [
|
|
246
|
+
`[${index + 1}] ${session.repoName}`,
|
|
247
|
+
`- 仓库:${session.repoRoot}`,
|
|
248
|
+
`- 会话:${session.sessionId}`,
|
|
249
|
+
`- supervisor:${session.supervisor?.status || "unknown"}`,
|
|
250
|
+
`- 命令:${session.command || "unknown"}`,
|
|
251
|
+
`- 当前任务:${session.latestStatus?.taskTitle || "无"}`,
|
|
252
|
+
`- 当前阶段:${session.latestStatus?.stage || "unknown"}`,
|
|
253
|
+
`- backlog:已完成 ${session.summary?.done || 0} / 总计 ${session.summary?.total || 0} / 待处理 ${session.summary?.pending || 0}`,
|
|
254
|
+
`- 运行状态:${formatRuntimeLabel(session.runtime)}`,
|
|
255
|
+
`- 当前动作:${formatCurrentAction(session)}`,
|
|
256
|
+
`- 宿主续跑:${session.hostResume?.issue?.label || (session.hostResume?.supervisorActive ? "后台仍在运行,可直接接续观察" : "需要按续跑提示继续")}`,
|
|
257
|
+
];
|
|
258
|
+
|
|
259
|
+
if (formatTodoLabel(session.activity)) {
|
|
260
|
+
lines.push(`- 当前待办:${formatTodoLabel(session.activity)}`);
|
|
261
|
+
}
|
|
262
|
+
if (Array.isArray(session.activity?.activeCommands) && session.activity.activeCommands[0]?.label) {
|
|
263
|
+
lines.push(`- 活动命令:${session.activity.activeCommands[0].label}`);
|
|
264
|
+
}
|
|
265
|
+
if (Array.isArray(session.activity?.recentFileChanges) && session.activity.recentFileChanges[0]?.changes?.length) {
|
|
266
|
+
const fileLabels = session.activity.recentFileChanges[0].changes
|
|
267
|
+
.slice(0, 3)
|
|
268
|
+
.map((item) => `${item.kind}:${item.path}`);
|
|
269
|
+
lines.push(`- 最近文件:${fileLabels.join(" | ")}`);
|
|
270
|
+
}
|
|
271
|
+
if (options.events && Array.isArray(session.activity?.recentEvents) && session.activity.recentEvents.length) {
|
|
272
|
+
lines.push("- 最近事件:");
|
|
273
|
+
for (const event of session.activity.recentEvents.slice(-5)) {
|
|
274
|
+
lines.push(` - [${event.status || "info"}] ${event.kind}: ${event.label}`);
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
return lines.join("\n");
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
export function renderDashboardText(snapshot, options = {}) {
|
|
282
|
+
const lines = [
|
|
283
|
+
"HelloLoop Dashboard",
|
|
284
|
+
"===================",
|
|
285
|
+
`仓库总数:${snapshot.repoCount}`,
|
|
286
|
+
`活跃会话:${snapshot.activeCount}`,
|
|
287
|
+
`更新时间:${snapshot.generatedAt}`,
|
|
288
|
+
];
|
|
289
|
+
|
|
290
|
+
if (!snapshot.sessions.length) {
|
|
291
|
+
lines.push("");
|
|
292
|
+
lines.push("当前没有已登记仓库或后台会话。");
|
|
293
|
+
return lines.join("\n");
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
for (const [index, session] of snapshot.sessions.entries()) {
|
|
297
|
+
lines.push("");
|
|
298
|
+
lines.push(options.compact
|
|
299
|
+
? renderCompactSession(session, index)
|
|
300
|
+
: renderDetailedSession(session, index, options));
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
return lines.join("\n");
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
export function buildDashboardSnapshotSignature(snapshot) {
|
|
307
|
+
return JSON.stringify(snapshot.sessions.map((session) => ({
|
|
308
|
+
repoRoot: session.repoRoot,
|
|
309
|
+
sessionId: session.sessionId,
|
|
310
|
+
taskId: session.latestStatus?.taskId || "",
|
|
311
|
+
stage: session.latestStatus?.stage || "",
|
|
312
|
+
runtimeStatus: session.runtime?.status || "",
|
|
313
|
+
idleSeconds: session.runtime?.heartbeat?.idleSeconds || 0,
|
|
314
|
+
action: session.activity?.current?.label || "",
|
|
315
|
+
todoCompleted: session.activity?.todo?.completed || 0,
|
|
316
|
+
todoTotal: session.activity?.todo?.total || 0,
|
|
317
|
+
eventLabel: session.activity?.recentEvents?.at(-1)?.label || "",
|
|
318
|
+
resumeIssue: session.hostResume?.issue?.code || "",
|
|
319
|
+
taskFingerprint: (session.tasks || [])
|
|
320
|
+
.map((task) => `${task.id}:${task.status || "pending"}:${task.title}`)
|
|
321
|
+
.join("|"),
|
|
322
|
+
})));
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
export async function runDashboardCommand(options = {}) {
|
|
326
|
+
const pollMs = Math.max(500, Number(options.pollMs || options.watchPollMs || 2000));
|
|
327
|
+
const observerRetry = loadRuntimeSettings({
|
|
328
|
+
globalConfigFile: options.globalConfigFile,
|
|
329
|
+
}).observerRetry;
|
|
330
|
+
let previousSignature = "";
|
|
331
|
+
let retryCount = 0;
|
|
332
|
+
|
|
333
|
+
while (true) {
|
|
334
|
+
try {
|
|
335
|
+
const snapshot = collectDashboardSnapshot();
|
|
336
|
+
const signature = buildDashboardSnapshotSignature(snapshot);
|
|
337
|
+
retryCount = 0;
|
|
338
|
+
|
|
339
|
+
if (signature !== previousSignature || !options.watch) {
|
|
340
|
+
previousSignature = signature;
|
|
341
|
+
if (options.json) {
|
|
342
|
+
console.log(JSON.stringify(snapshot));
|
|
343
|
+
} else {
|
|
344
|
+
if (options.watch && process.stdout.isTTY) {
|
|
345
|
+
process.stdout.write("\x1bc");
|
|
346
|
+
}
|
|
347
|
+
console.log(renderDashboardText(snapshot, options));
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
} catch (error) {
|
|
351
|
+
const nextAttempt = retryCount + 1;
|
|
352
|
+
if (!options.watch || !observerRetry.enabled || !hasRetryBudget(observerRetry.maxRetryCount, nextAttempt)) {
|
|
353
|
+
throw error;
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
const delaySeconds = pickRetryDelaySeconds(observerRetry.retryDelaysSeconds, nextAttempt);
|
|
357
|
+
process.stderr.write(
|
|
358
|
+
`[HelloLoop dashboard] 看板采集失败,将在 ${delaySeconds} 秒后自动重试(第 ${nextAttempt} 次):${String(error?.message || error || "unknown error")}\n`,
|
|
359
|
+
);
|
|
360
|
+
retryCount = nextAttempt;
|
|
361
|
+
await sleep(delaySeconds * 1000);
|
|
362
|
+
continue;
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
if (!options.watch) {
|
|
366
|
+
return 0;
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
await sleep(pollMs);
|
|
370
|
+
}
|
|
371
|
+
}
|