helloloop 0.8.6 → 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/.codex-plugin/plugin.json +1 -1
- package/README.md +230 -498
- 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 +51 -1
- package/src/cli_analyze_command.mjs +12 -14
- package/src/cli_args.mjs +106 -32
- package/src/cli_command_handlers.mjs +73 -25
- package/src/cli_support.mjs +2 -0
- package/src/common.mjs +11 -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 +7 -2
- package/src/engine_selection.mjs +24 -0
- package/src/engine_selection_probe.mjs +10 -6
- package/src/engine_selection_settings.mjs +53 -44
- package/src/execution_interactivity.mjs +12 -0
- package/src/host_continuation.mjs +305 -0
- package/src/install_codex.mjs +20 -30
- 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 +15 -1
- package/src/runner_execution_support.mjs +69 -3
- package/src/runner_once.mjs +5 -0
- package/src/runner_status.mjs +72 -4
- package/src/runtime_engine_support.mjs +52 -5
- 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 +49 -0
- package/src/supervisor_guardian.mjs +307 -0
- package/src/supervisor_runtime.mjs +142 -83
- package/src/supervisor_state.mjs +64 -0
- package/src/supervisor_watch.mjs +364 -0
- package/src/terminal_session_limits.mjs +1 -21
- package/src/windows_hidden_shell_proxy.mjs +405 -0
- package/src/workspace_registry.mjs +155 -0
|
@@ -0,0 +1,351 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import http from "node:http";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { fileURLToPath } from "node:url";
|
|
5
|
+
|
|
6
|
+
import {
|
|
7
|
+
ensureDir,
|
|
8
|
+
fileExists,
|
|
9
|
+
nowIso,
|
|
10
|
+
readJson,
|
|
11
|
+
sleep,
|
|
12
|
+
tailText,
|
|
13
|
+
writeJson,
|
|
14
|
+
} from "./common.mjs";
|
|
15
|
+
import { collectDashboardSnapshot, buildDashboardSnapshotSignature } from "./dashboard_command.mjs";
|
|
16
|
+
import { renderDashboardWebHtml } from "./dashboard_web_page.mjs";
|
|
17
|
+
import { resolveUserSettingsHome } from "./engine_selection_settings.mjs";
|
|
18
|
+
import { spawnNodeProcess } from "./node_process_launch.mjs";
|
|
19
|
+
|
|
20
|
+
const DEFAULT_BIND = "127.0.0.1";
|
|
21
|
+
const DEFAULT_PORT = 3210;
|
|
22
|
+
const WEB_SERVER_ENV = "HELLOLOOP_WEB_SERVER_ACTIVE";
|
|
23
|
+
const BUNDLE_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
|
|
24
|
+
|
|
25
|
+
function dashboardRuntimeRoot() {
|
|
26
|
+
return path.join(resolveUserSettingsHome(), "runtime", "web-dashboard");
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function dashboardRuntimeFiles() {
|
|
30
|
+
const root = dashboardRuntimeRoot();
|
|
31
|
+
ensureDir(root);
|
|
32
|
+
return {
|
|
33
|
+
root,
|
|
34
|
+
stateFile: path.join(root, "server.json"),
|
|
35
|
+
stdoutFile: path.join(root, "server-stdout.log"),
|
|
36
|
+
stderrFile: path.join(root, "server-stderr.log"),
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function readJsonIfExists(filePath) {
|
|
41
|
+
try {
|
|
42
|
+
return filePath && fileExists(filePath) ? readJson(filePath) : null;
|
|
43
|
+
} catch {
|
|
44
|
+
return null;
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function isPidAlive(pid) {
|
|
49
|
+
const value = Number(pid || 0);
|
|
50
|
+
if (!Number.isFinite(value) || value <= 0) {
|
|
51
|
+
return false;
|
|
52
|
+
}
|
|
53
|
+
try {
|
|
54
|
+
process.kill(value, 0);
|
|
55
|
+
return true;
|
|
56
|
+
} catch (error) {
|
|
57
|
+
return String(error?.code || "") === "EPERM";
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function normalizeBind(value) {
|
|
62
|
+
const bind = String(value || "").trim();
|
|
63
|
+
return bind || DEFAULT_BIND;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function normalizePort(value) {
|
|
67
|
+
const parsed = Number(value);
|
|
68
|
+
if (!Number.isInteger(parsed) || parsed < 0 || parsed > 65535) {
|
|
69
|
+
return DEFAULT_PORT;
|
|
70
|
+
}
|
|
71
|
+
return parsed;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function writeWebServerState(patch) {
|
|
75
|
+
const files = dashboardRuntimeFiles();
|
|
76
|
+
const current = readJsonIfExists(files.stateFile) || {};
|
|
77
|
+
writeJson(files.stateFile, {
|
|
78
|
+
...current,
|
|
79
|
+
...patch,
|
|
80
|
+
updatedAt: nowIso(),
|
|
81
|
+
});
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function readWebServerState() {
|
|
85
|
+
const files = dashboardRuntimeFiles();
|
|
86
|
+
const state = readJsonIfExists(files.stateFile);
|
|
87
|
+
if (!state) {
|
|
88
|
+
return null;
|
|
89
|
+
}
|
|
90
|
+
if (!isPidAlive(state.pid)) {
|
|
91
|
+
try {
|
|
92
|
+
fs.rmSync(files.stateFile, { force: true });
|
|
93
|
+
} catch {
|
|
94
|
+
// ignore stale state cleanup failure
|
|
95
|
+
}
|
|
96
|
+
return null;
|
|
97
|
+
}
|
|
98
|
+
return state;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function removeWebServerStateIfOwned(pid) {
|
|
102
|
+
const files = dashboardRuntimeFiles();
|
|
103
|
+
const state = readJsonIfExists(files.stateFile);
|
|
104
|
+
if (state?.pid === pid) {
|
|
105
|
+
try {
|
|
106
|
+
fs.rmSync(files.stateFile, { force: true });
|
|
107
|
+
} catch {
|
|
108
|
+
// ignore cleanup failure
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function buildWebUrl(bind, port) {
|
|
114
|
+
return `http://${bind}:${port}`;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function renderStartSummary(state) {
|
|
118
|
+
return [
|
|
119
|
+
"HelloLoop Web Dashboard 已启动",
|
|
120
|
+
`- 地址:${state.url}`,
|
|
121
|
+
`- PID:${state.pid}`,
|
|
122
|
+
`- 监听:${state.bind}:${state.port}`,
|
|
123
|
+
].join("\n");
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function renderExistingSummary(state) {
|
|
127
|
+
return [
|
|
128
|
+
"HelloLoop Web Dashboard 已在运行",
|
|
129
|
+
`- 地址:${state.url}`,
|
|
130
|
+
`- PID:${state.pid}`,
|
|
131
|
+
].join("\n");
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
async function waitForWebServerLaunch(launchId, timeoutMs = 8000) {
|
|
135
|
+
const files = dashboardRuntimeFiles();
|
|
136
|
+
const deadline = Date.now() + timeoutMs;
|
|
137
|
+
while (Date.now() < deadline) {
|
|
138
|
+
const state = readJsonIfExists(files.stateFile);
|
|
139
|
+
if (state?.launchId === launchId && isPidAlive(state.pid)) {
|
|
140
|
+
return state;
|
|
141
|
+
}
|
|
142
|
+
await sleep(150);
|
|
143
|
+
}
|
|
144
|
+
const stderr = fileExists(files.stderrFile) ? fs.readFileSync(files.stderrFile, "utf8") : "";
|
|
145
|
+
throw new Error(tailText(stderr, 40) || "HelloLoop Web Dashboard 启动超时。");
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
async function stopWebDashboardServer() {
|
|
149
|
+
const state = readWebServerState();
|
|
150
|
+
if (!state) {
|
|
151
|
+
console.log("HelloLoop Web Dashboard 当前未运行。");
|
|
152
|
+
return 0;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
try {
|
|
156
|
+
process.kill(state.pid);
|
|
157
|
+
} catch {
|
|
158
|
+
// ignore if already down
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
const deadline = Date.now() + 5000;
|
|
162
|
+
while (Date.now() < deadline) {
|
|
163
|
+
if (!isPidAlive(state.pid)) {
|
|
164
|
+
removeWebServerStateIfOwned(state.pid);
|
|
165
|
+
console.log(`HelloLoop Web Dashboard 已停止:${state.url}`);
|
|
166
|
+
return 0;
|
|
167
|
+
}
|
|
168
|
+
await sleep(150);
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
throw new Error(`HelloLoop Web Dashboard 停止超时:pid=${state.pid}`);
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
function sendJson(response, statusCode, payload) {
|
|
175
|
+
response.writeHead(statusCode, {
|
|
176
|
+
"Content-Type": "application/json; charset=utf-8",
|
|
177
|
+
"Cache-Control": "no-store",
|
|
178
|
+
});
|
|
179
|
+
response.end(`${JSON.stringify(payload, null, 2)}\n`);
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
function createSseClient(response, snapshot) {
|
|
183
|
+
let previousSignature = "";
|
|
184
|
+
response.writeHead(200, {
|
|
185
|
+
"Content-Type": "text/event-stream; charset=utf-8",
|
|
186
|
+
"Cache-Control": "no-cache, no-transform",
|
|
187
|
+
Connection: "keep-alive",
|
|
188
|
+
});
|
|
189
|
+
response.write(`data: ${JSON.stringify(snapshot)}\n\n`);
|
|
190
|
+
previousSignature = buildDashboardSnapshotSignature(snapshot);
|
|
191
|
+
return {
|
|
192
|
+
push(nextSnapshot) {
|
|
193
|
+
const nextSignature = buildDashboardSnapshotSignature(nextSnapshot);
|
|
194
|
+
if (nextSignature === previousSignature) {
|
|
195
|
+
return;
|
|
196
|
+
}
|
|
197
|
+
previousSignature = nextSignature;
|
|
198
|
+
response.write(`data: ${JSON.stringify(nextSnapshot)}\n\n`);
|
|
199
|
+
},
|
|
200
|
+
};
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
async function startWebDashboardServer(options = {}) {
|
|
204
|
+
const files = dashboardRuntimeFiles();
|
|
205
|
+
const bind = normalizeBind(options.bind);
|
|
206
|
+
const preferredPort = normalizePort(options.port);
|
|
207
|
+
const pollMs = Math.max(500, Number(options.pollMs || options.watchPollMs || 1500));
|
|
208
|
+
const initialSnapshot = collectDashboardSnapshot();
|
|
209
|
+
const clients = new Set();
|
|
210
|
+
let lastSnapshot = initialSnapshot;
|
|
211
|
+
|
|
212
|
+
const server = http.createServer((request, response) => {
|
|
213
|
+
const url = new URL(request.url || "/", buildWebUrl(bind, preferredPort));
|
|
214
|
+
if (url.pathname === "/api/snapshot") {
|
|
215
|
+
sendJson(response, 200, collectDashboardSnapshot());
|
|
216
|
+
return;
|
|
217
|
+
}
|
|
218
|
+
if (url.pathname === "/events") {
|
|
219
|
+
const client = createSseClient(response, lastSnapshot);
|
|
220
|
+
clients.add(client);
|
|
221
|
+
request.on("close", () => clients.delete(client));
|
|
222
|
+
return;
|
|
223
|
+
}
|
|
224
|
+
if (url.pathname === "/healthz") {
|
|
225
|
+
sendJson(response, 200, { ok: true, generatedAt: nowIso() });
|
|
226
|
+
return;
|
|
227
|
+
}
|
|
228
|
+
response.writeHead(200, {
|
|
229
|
+
"Content-Type": "text/html; charset=utf-8",
|
|
230
|
+
"Cache-Control": "no-store",
|
|
231
|
+
});
|
|
232
|
+
response.end(renderDashboardWebHtml({
|
|
233
|
+
initialSnapshot: lastSnapshot,
|
|
234
|
+
}));
|
|
235
|
+
});
|
|
236
|
+
|
|
237
|
+
let settledPort = preferredPort;
|
|
238
|
+
await new Promise((resolve, reject) => {
|
|
239
|
+
const onError = (error) => {
|
|
240
|
+
if (String(error?.code || "") === "EADDRINUSE" && !options.port) {
|
|
241
|
+
server.off("error", onError);
|
|
242
|
+
server.listen(0, bind, resolve);
|
|
243
|
+
return;
|
|
244
|
+
}
|
|
245
|
+
reject(error);
|
|
246
|
+
};
|
|
247
|
+
server.once("error", onError);
|
|
248
|
+
server.listen(preferredPort, bind, resolve);
|
|
249
|
+
});
|
|
250
|
+
const address = server.address();
|
|
251
|
+
settledPort = Number(address?.port || preferredPort);
|
|
252
|
+
const state = {
|
|
253
|
+
pid: process.pid,
|
|
254
|
+
bind,
|
|
255
|
+
port: settledPort,
|
|
256
|
+
url: buildWebUrl(bind, settledPort),
|
|
257
|
+
startedAt: nowIso(),
|
|
258
|
+
launchId: String(options.launchId || "").trim(),
|
|
259
|
+
pollMs,
|
|
260
|
+
};
|
|
261
|
+
writeWebServerState(state);
|
|
262
|
+
|
|
263
|
+
const timer = setInterval(() => {
|
|
264
|
+
lastSnapshot = collectDashboardSnapshot();
|
|
265
|
+
writeWebServerState({
|
|
266
|
+
...state,
|
|
267
|
+
generatedAt: lastSnapshot.generatedAt,
|
|
268
|
+
});
|
|
269
|
+
for (const client of clients) {
|
|
270
|
+
client.push(lastSnapshot);
|
|
271
|
+
}
|
|
272
|
+
}, pollMs);
|
|
273
|
+
|
|
274
|
+
const shutdown = () => {
|
|
275
|
+
clearInterval(timer);
|
|
276
|
+
server.close(() => {
|
|
277
|
+
removeWebServerStateIfOwned(process.pid);
|
|
278
|
+
process.exit(0);
|
|
279
|
+
});
|
|
280
|
+
};
|
|
281
|
+
process.on("SIGINT", shutdown);
|
|
282
|
+
process.on("SIGTERM", shutdown);
|
|
283
|
+
|
|
284
|
+
console.log(renderStartSummary({
|
|
285
|
+
...state,
|
|
286
|
+
url: buildWebUrl(bind, settledPort),
|
|
287
|
+
}));
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
async function launchWebDashboardServer(options = {}) {
|
|
291
|
+
const existing = readWebServerState();
|
|
292
|
+
if (existing) {
|
|
293
|
+
console.log(renderExistingSummary(existing));
|
|
294
|
+
return 0;
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
const files = dashboardRuntimeFiles();
|
|
298
|
+
const launchId = `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
|
299
|
+
const stdoutFd = fs.openSync(files.stdoutFile, "w");
|
|
300
|
+
const stderrFd = fs.openSync(files.stderrFile, "w");
|
|
301
|
+
|
|
302
|
+
try {
|
|
303
|
+
const args = [
|
|
304
|
+
path.join(BUNDLE_ROOT, "bin", "helloloop.js"),
|
|
305
|
+
"__web-server",
|
|
306
|
+
"--bind",
|
|
307
|
+
normalizeBind(options.bind),
|
|
308
|
+
"--launch-id",
|
|
309
|
+
launchId,
|
|
310
|
+
"--poll-ms",
|
|
311
|
+
String(Math.max(500, Number(options.pollMs || options.watchPollMs || 1500))),
|
|
312
|
+
];
|
|
313
|
+
if (options.port !== undefined && options.port !== null && options.port !== "") {
|
|
314
|
+
args.push("--port", String(options.port));
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
const child = spawnNodeProcess({
|
|
318
|
+
args,
|
|
319
|
+
cwd: process.cwd(),
|
|
320
|
+
detached: true,
|
|
321
|
+
stdio: ["ignore", stdoutFd, stderrFd],
|
|
322
|
+
env: {
|
|
323
|
+
[WEB_SERVER_ENV]: "1",
|
|
324
|
+
},
|
|
325
|
+
});
|
|
326
|
+
child.unref();
|
|
327
|
+
} finally {
|
|
328
|
+
fs.closeSync(stdoutFd);
|
|
329
|
+
fs.closeSync(stderrFd);
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
const state = await waitForWebServerLaunch(launchId);
|
|
333
|
+
console.log(renderStartSummary(state));
|
|
334
|
+
return 0;
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
export async function runDashboardWebCommand(options = {}) {
|
|
338
|
+
if (options.stop === true) {
|
|
339
|
+
return stopWebDashboardServer();
|
|
340
|
+
}
|
|
341
|
+
const existing = readWebServerState();
|
|
342
|
+
if (existing) {
|
|
343
|
+
console.log(renderExistingSummary(existing));
|
|
344
|
+
return 0;
|
|
345
|
+
}
|
|
346
|
+
if (options.foreground === true || process.env[WEB_SERVER_ENV] === "1") {
|
|
347
|
+
await startWebDashboardServer(options);
|
|
348
|
+
return 0;
|
|
349
|
+
}
|
|
350
|
+
return launchWebDashboardServer(options);
|
|
351
|
+
}
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
export const DASHBOARD_WEB_CSS = `
|
|
2
|
+
:root {
|
|
3
|
+
color-scheme: dark;
|
|
4
|
+
--bg: #081122;
|
|
5
|
+
--panel: #0f1a2f;
|
|
6
|
+
--panel-2: #13213c;
|
|
7
|
+
--border: rgba(148, 163, 184, 0.2);
|
|
8
|
+
--text: #e5eefc;
|
|
9
|
+
--muted: #9fb2d1;
|
|
10
|
+
--accent: #5eead4;
|
|
11
|
+
--warn: #fbbf24;
|
|
12
|
+
--danger: #f87171;
|
|
13
|
+
--ok: #34d399;
|
|
14
|
+
--shadow: 0 16px 40px rgba(0, 0, 0, 0.28);
|
|
15
|
+
font-family: Inter, "Segoe UI", system-ui, sans-serif;
|
|
16
|
+
}
|
|
17
|
+
* { box-sizing: border-box; }
|
|
18
|
+
body { margin: 0; background: radial-gradient(circle at top, #13284a 0%, var(--bg) 40%, #050b16 100%); color: var(--text); }
|
|
19
|
+
header { position: sticky; top: 0; z-index: 10; backdrop-filter: blur(14px); background: rgba(8, 17, 34, 0.86); border-bottom: 1px solid var(--border); padding: 20px 24px 16px; }
|
|
20
|
+
.title-row, .stats-row, .repo-header-top, .repo-meta { display: flex; gap: 12px; align-items: center; justify-content: space-between; flex-wrap: wrap; }
|
|
21
|
+
.title h1 { margin: 0; font-size: 24px; font-weight: 700; }
|
|
22
|
+
.title p { margin: 6px 0 0; color: var(--muted); font-size: 14px; }
|
|
23
|
+
.pill, .badge, .drawer-close { border-radius: 999px; border: 1px solid var(--border); background: rgba(19, 33, 60, 0.9); color: var(--text); }
|
|
24
|
+
.pill { display: inline-flex; align-items: center; gap: 8px; padding: 8px 14px; font-size: 13px; }
|
|
25
|
+
.stats-row { margin-top: 16px; }
|
|
26
|
+
.stats { display: grid; grid-template-columns: repeat(auto-fit, minmax(110px, 1fr)); gap: 12px; width: min(100%, 760px); }
|
|
27
|
+
.stat, .repo-board { background: rgba(15, 26, 47, 0.9); border: 1px solid var(--border); box-shadow: var(--shadow); }
|
|
28
|
+
.stat { border-radius: 16px; padding: 14px; }
|
|
29
|
+
.stat-label, .column-count, .repo-meta, .empty, .card-meta, .drawer-section h4 { color: var(--muted); font-size: 12px; }
|
|
30
|
+
.stat-value { margin-top: 6px; font-size: 22px; font-weight: 700; }
|
|
31
|
+
main { padding: 20px 24px 80px; display: grid; gap: 20px; }
|
|
32
|
+
.repo-board { border-radius: 22px; overflow: hidden; }
|
|
33
|
+
.repo-header { padding: 18px 20px 14px; border-bottom: 1px solid var(--border); background: linear-gradient(180deg, rgba(19, 33, 60, 0.96) 0%, rgba(15, 26, 47, 0.96) 100%); }
|
|
34
|
+
.repo-header h2 { margin: 0; font-size: 20px; }
|
|
35
|
+
.repo-meta { margin-top: 12px; font-size: 13px; }
|
|
36
|
+
.columns { display: grid; grid-template-columns: repeat(5, minmax(220px, 1fr)); gap: 16px; padding: 18px; overflow-x: auto; }
|
|
37
|
+
.column { min-height: 180px; background: rgba(9, 16, 29, 0.72); border: 1px solid rgba(148, 163, 184, 0.14); border-radius: 18px; padding: 14px; display: flex; flex-direction: column; gap: 12px; }
|
|
38
|
+
.column h3, .drawer h3 { margin: 0; font-size: 15px; }
|
|
39
|
+
.cards { display: grid; gap: 10px; align-content: start; }
|
|
40
|
+
.card { width: 100%; text-align: left; background: rgba(19, 33, 60, 0.96); border: 1px solid rgba(148, 163, 184, 0.16); border-radius: 14px; padding: 12px; cursor: pointer; color: inherit; }
|
|
41
|
+
.card:hover { border-color: rgba(94, 234, 212, 0.42); transform: translateY(-1px); }
|
|
42
|
+
.card-title { font-size: 14px; font-weight: 600; line-height: 1.45; }
|
|
43
|
+
.badges, .repo-badges { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 10px; }
|
|
44
|
+
.badge { padding: 4px 10px; font-size: 11px; border-color: transparent; background: rgba(148, 163, 184, 0.14); }
|
|
45
|
+
.badge.ok { background: rgba(52, 211, 153, 0.15); color: var(--ok); }
|
|
46
|
+
.badge.warn { background: rgba(251, 191, 36, 0.14); color: var(--warn); }
|
|
47
|
+
.badge.danger { background: rgba(248, 113, 113, 0.14); color: var(--danger); }
|
|
48
|
+
.badge.accent { background: rgba(94, 234, 212, 0.12); color: var(--accent); }
|
|
49
|
+
.card-meta { margin-top: 10px; line-height: 1.5; }
|
|
50
|
+
aside.drawer { position: fixed; top: 0; right: 0; width: min(480px, 100vw); height: 100vh; background: rgba(6, 13, 26, 0.98); border-left: 1px solid var(--border); box-shadow: var(--shadow); transform: translateX(100%); transition: transform 180ms ease; padding: 24px; overflow-y: auto; z-index: 20; }
|
|
51
|
+
aside.drawer.open { transform: translateX(0); }
|
|
52
|
+
.drawer h3 { font-size: 20px; }
|
|
53
|
+
.drawer-section { margin-top: 18px; }
|
|
54
|
+
.drawer-section h4 { margin: 0 0 8px; text-transform: uppercase; }
|
|
55
|
+
.drawer-list { margin: 0; padding-left: 18px; line-height: 1.6; }
|
|
56
|
+
.drawer-close { position: sticky; top: 0; float: right; padding: 6px 12px; cursor: pointer; }
|
|
57
|
+
@media (max-width: 1100px) { .columns { grid-template-columns: repeat(2, minmax(240px, 1fr)); } }
|
|
58
|
+
@media (max-width: 760px) { header, main { padding-left: 14px; padding-right: 14px; } .columns { grid-template-columns: 1fr; } }
|
|
59
|
+
`;
|
|
60
|
+
|
|
61
|
+
export const DASHBOARD_WEB_JS = `
|
|
62
|
+
const STATUS_COLUMNS = [
|
|
63
|
+
{ key: "pending", label: "待处理" },
|
|
64
|
+
{ key: "in_progress", label: "进行中" },
|
|
65
|
+
{ key: "done", label: "已完成" },
|
|
66
|
+
{ key: "blocked", label: "阻塞" },
|
|
67
|
+
{ key: "failed", label: "失败" },
|
|
68
|
+
];
|
|
69
|
+
const boardEl = document.getElementById("board");
|
|
70
|
+
const statsEl = document.getElementById("stats");
|
|
71
|
+
const sessionPillEl = document.getElementById("session-pill");
|
|
72
|
+
const updatePillEl = document.getElementById("update-pill");
|
|
73
|
+
const drawerEl = document.getElementById("drawer");
|
|
74
|
+
const drawerContentEl = document.getElementById("drawer-content");
|
|
75
|
+
const drawerCloseEl = document.getElementById("drawer-close");
|
|
76
|
+
let currentSnapshot = window.__HELLOLOOP_INITIAL_SNAPSHOT__;
|
|
77
|
+
|
|
78
|
+
function badgeClass(kind) {
|
|
79
|
+
if (kind === "done" || kind === "running" || kind === "ready") return "ok";
|
|
80
|
+
if (kind === "blocked" || kind === "failed") return "danger";
|
|
81
|
+
if (kind === "retry_waiting" || kind === "watchdog_waiting") return "warn";
|
|
82
|
+
return "accent";
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function formatRuntime(session) {
|
|
86
|
+
const runtime = session.runtime || {};
|
|
87
|
+
const bits = [runtime.status || "idle"];
|
|
88
|
+
if (Number.isFinite(Number(runtime.recoveryCount)) && Number(runtime.recoveryCount) > 0) bits.push("recovery=" + runtime.recoveryCount);
|
|
89
|
+
if (Number.isFinite(Number(runtime?.heartbeat?.idleSeconds)) && Number(runtime.heartbeat.idleSeconds) > 0) bits.push("idle=" + runtime.heartbeat.idleSeconds + "s");
|
|
90
|
+
return bits.join(" | ");
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function groupTasks(tasks) {
|
|
94
|
+
const grouped = Object.fromEntries(STATUS_COLUMNS.map((column) => [column.key, []]));
|
|
95
|
+
for (const task of Array.isArray(tasks) ? tasks : []) {
|
|
96
|
+
const key = task.status || "pending";
|
|
97
|
+
if (!grouped[key]) grouped[key] = [];
|
|
98
|
+
grouped[key].push(task);
|
|
99
|
+
}
|
|
100
|
+
return grouped;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function renderStats(snapshot) {
|
|
104
|
+
const totals = snapshot.taskTotals || {};
|
|
105
|
+
const items = [["仓库总数", snapshot.repoCount || 0], ["活跃会话", snapshot.activeCount || 0], ["任务总计", totals.total || 0], ["待处理", totals.pending || 0], ["进行中", totals.inProgress || 0], ["已完成", totals.done || 0], ["阻塞", totals.blocked || 0], ["失败", totals.failed || 0]];
|
|
106
|
+
statsEl.innerHTML = items.map(([label, value]) => '<div class="stat"><div class="stat-label">' + label + '</div><div class="stat-value">' + value + '</div></div>').join("");
|
|
107
|
+
sessionPillEl.textContent = "仓库 " + (snapshot.repoCount || 0) + " · 活跃会话 " + (snapshot.activeCount || 0);
|
|
108
|
+
updatePillEl.textContent = "最近刷新 " + (snapshot.generatedAt || "unknown");
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function renderTaskCard(task, session) {
|
|
112
|
+
const isCurrent = task.id && task.id === session.latestStatus?.taskId;
|
|
113
|
+
const docsCount = Array.isArray(task.docs) ? task.docs.length : 0;
|
|
114
|
+
const pathsCount = Array.isArray(task.paths) ? task.paths.length : 0;
|
|
115
|
+
return '<button class="card" data-session="' + encodeURIComponent(session.sessionId || "") + '" data-task="' + encodeURIComponent(task.id || "") + '"><div class="card-title">' + (task.title || task.id || "未命名任务") + '</div><div class="badges"><span class="badge accent">' + (task.priority || "P2") + '</span><span class="badge ' + badgeClass(task.status || "pending") + '">' + (task.status || "pending") + '</span><span class="badge ' + badgeClass(task.risk || "low") + '">' + (task.risk || "low") + '</span>' + (isCurrent ? '<span class="badge warn">当前执行</span>' : "") + '</div><div class="card-meta">docs ' + docsCount + ' · paths ' + pathsCount + '</div></button>';
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function renderSessionBoard(session) {
|
|
119
|
+
const grouped = groupTasks(session.tasks || []);
|
|
120
|
+
const repoBadges = ['<span class="badge ' + badgeClass(session.supervisor?.status || "running") + '">supervisor ' + (session.supervisor?.status || "unknown") + '</span>', '<span class="badge ' + badgeClass(session.runtime?.status || "idle") + '">runtime ' + formatRuntime(session) + '</span>', (session.latestStatus?.taskTitle ? '<span class="badge accent">当前任务 ' + session.latestStatus.taskTitle + '</span>' : "")].filter(Boolean).join("");
|
|
121
|
+
const columnsHtml = STATUS_COLUMNS.map((column) => {
|
|
122
|
+
const tasks = grouped[column.key] || [];
|
|
123
|
+
return '<section class="column"><div><h3>' + column.label + '</h3><div class="column-count">' + tasks.length + ' 个任务</div></div><div class="cards">' + (tasks.length ? tasks.map((task) => renderTaskCard(task, session)).join("") : '<div class="empty">当前列为空</div>') + '</div></section>';
|
|
124
|
+
}).join("");
|
|
125
|
+
return '<section class="repo-board"><div class="repo-header"><div class="repo-header-top"><div><h2>' + session.repoName + '</h2><div class="repo-meta"><span>仓库:' + session.repoRoot + '</span><span>会话:' + session.sessionId + '</span></div></div><div class="repo-badges">' + repoBadges + '</div></div><div class="repo-meta"><span>当前动作:' + (session.activity?.current?.label || session.latestStatus?.message || session.runtime?.failureReason || "等待新事件") + '</span><span>宿主续跑:' + (session.hostResume?.issue?.label || (session.hostResume?.supervisorActive ? "后台仍在运行,可直接接续观察" : "需要按续跑提示继续")) + '</span></div></div><div class="columns">' + columnsHtml + '</div></section>';
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function renderSnapshot(snapshot) {
|
|
129
|
+
currentSnapshot = snapshot;
|
|
130
|
+
renderStats(snapshot);
|
|
131
|
+
if (!Array.isArray(snapshot.sessions) || !snapshot.sessions.length) {
|
|
132
|
+
boardEl.innerHTML = '<section class="repo-board"><div class="repo-header"><h2>当前没有已登记仓库或后台会话</h2></div></section>';
|
|
133
|
+
return;
|
|
134
|
+
}
|
|
135
|
+
boardEl.innerHTML = snapshot.sessions.map(renderSessionBoard).join("");
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function openDrawer(task, session) {
|
|
139
|
+
const docs = Array.isArray(task.docs) ? task.docs : [];
|
|
140
|
+
const paths = Array.isArray(task.paths) ? task.paths : [];
|
|
141
|
+
const acceptance = Array.isArray(task.acceptance) ? task.acceptance : [];
|
|
142
|
+
drawerContentEl.innerHTML = '<h3>' + (task.title || task.id || "未命名任务") + '</h3><div class="badges"><span class="badge accent">仓库 ' + session.repoName + '</span><span class="badge accent">优先级 ' + (task.priority || "P2") + '</span><span class="badge ' + badgeClass(task.status || "pending") + '">状态 ' + (task.status || "pending") + '</span><span class="badge ' + badgeClass(task.risk || "low") + '">风险 ' + (task.risk || "low") + '</span></div><div class="drawer-section"><h4>目标</h4><div>' + (task.goal || "无") + '</div></div><div class="drawer-section"><h4>文档</h4><ul class="drawer-list">' + (docs.length ? docs.map((item) => '<li>' + item + '</li>').join("") : "<li>无</li>") + '</ul></div><div class="drawer-section"><h4>路径</h4><ul class="drawer-list">' + (paths.length ? paths.map((item) => '<li>' + item + '</li>').join("") : "<li>无</li>") + '</ul></div><div class="drawer-section"><h4>验收</h4><ul class="drawer-list">' + (acceptance.length ? acceptance.map((item) => '<li>' + item + '</li>').join("") : "<li>无</li>") + '</ul></div>';
|
|
143
|
+
drawerEl.classList.add("open");
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
boardEl.addEventListener("click", (event) => {
|
|
147
|
+
const button = event.target.closest(".card");
|
|
148
|
+
if (!button) return;
|
|
149
|
+
const sessionId = decodeURIComponent(button.dataset.session || "");
|
|
150
|
+
const taskId = decodeURIComponent(button.dataset.task || "");
|
|
151
|
+
const session = (currentSnapshot.sessions || []).find((item) => item.sessionId === sessionId);
|
|
152
|
+
const task = (session?.tasks || []).find((item) => item.id === taskId);
|
|
153
|
+
if (session && task) openDrawer(task, session);
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
drawerCloseEl.addEventListener("click", () => drawerEl.classList.remove("open"));
|
|
157
|
+
window.addEventListener("keydown", (event) => { if (event.key === "Escape") drawerEl.classList.remove("open"); });
|
|
158
|
+
|
|
159
|
+
function connectEvents() {
|
|
160
|
+
const source = new EventSource("/events");
|
|
161
|
+
source.onmessage = (event) => { try { renderSnapshot(JSON.parse(event.data)); } catch (error) { console.error("snapshot parse failed", error); } };
|
|
162
|
+
source.onerror = () => { source.close(); setTimeout(connectEvents, 1500); };
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
renderSnapshot(currentSnapshot);
|
|
166
|
+
connectEvents();
|
|
167
|
+
`;
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { DASHBOARD_WEB_CSS, DASHBOARD_WEB_JS } from "./dashboard_web_client.mjs";
|
|
2
|
+
|
|
3
|
+
function escapeInlineJson(value) {
|
|
4
|
+
return JSON.stringify(value)
|
|
5
|
+
.replace(/</gu, "\\u003c")
|
|
6
|
+
.replace(/>/gu, "\\u003e")
|
|
7
|
+
.replace(/&/gu, "\\u0026");
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export function renderDashboardWebHtml(options = {}) {
|
|
11
|
+
const initialSnapshot = options.initialSnapshot || {
|
|
12
|
+
generatedAt: "",
|
|
13
|
+
activeCount: 0,
|
|
14
|
+
taskTotals: {},
|
|
15
|
+
sessions: [],
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
return `<!doctype html>
|
|
19
|
+
<html lang="zh-CN">
|
|
20
|
+
<head>
|
|
21
|
+
<meta charset="utf-8" />
|
|
22
|
+
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
23
|
+
<title>HelloLoop Dashboard</title>
|
|
24
|
+
<style>${DASHBOARD_WEB_CSS}</style>
|
|
25
|
+
</head>
|
|
26
|
+
<body>
|
|
27
|
+
<header>
|
|
28
|
+
<div class="title-row">
|
|
29
|
+
<div class="title">
|
|
30
|
+
<h1>HelloLoop Dashboard</h1>
|
|
31
|
+
<p>本地实时多仓开发看板。页面会持续订阅后台会话状态,不依赖宿主聊天流刷新。</p>
|
|
32
|
+
</div>
|
|
33
|
+
<div class="pill" id="update-pill">等待首帧</div>
|
|
34
|
+
</div>
|
|
35
|
+
<div class="stats-row">
|
|
36
|
+
<div class="stats" id="stats"></div>
|
|
37
|
+
<div class="pill" id="session-pill">仓库 0 · 活跃会话 0</div>
|
|
38
|
+
</div>
|
|
39
|
+
</header>
|
|
40
|
+
<main id="board"></main>
|
|
41
|
+
<aside class="drawer" id="drawer">
|
|
42
|
+
<button class="drawer-close" id="drawer-close">关闭</button>
|
|
43
|
+
<div id="drawer-content"></div>
|
|
44
|
+
</aside>
|
|
45
|
+
<script>window.__HELLOLOOP_INITIAL_SNAPSHOT__ = ${escapeInlineJson(initialSnapshot)};</script>
|
|
46
|
+
<script>${DASHBOARD_WEB_JS}</script>
|
|
47
|
+
</body>
|
|
48
|
+
</html>`;
|
|
49
|
+
}
|