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.
Files changed (50) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/README.md +230 -506
  3. package/hosts/claude/marketplace/plugins/helloloop/.claude-plugin/plugin.json +1 -1
  4. package/hosts/gemini/extension/gemini-extension.json +1 -1
  5. package/native/windows-hidden-shell-proxy/HelloLoopHiddenShellProxy.csproj +11 -0
  6. package/native/windows-hidden-shell-proxy/Program.cs +498 -0
  7. package/package.json +4 -2
  8. package/src/activity_projection.mjs +294 -0
  9. package/src/analyze_confirmation.mjs +3 -1
  10. package/src/analyzer.mjs +2 -1
  11. package/src/auto_execution_options.mjs +13 -0
  12. package/src/background_launch.mjs +73 -0
  13. package/src/cli.mjs +49 -1
  14. package/src/cli_analyze_command.mjs +9 -5
  15. package/src/cli_args.mjs +102 -37
  16. package/src/cli_command_handlers.mjs +44 -4
  17. package/src/cli_support.mjs +2 -0
  18. package/src/dashboard_command.mjs +371 -0
  19. package/src/dashboard_tui.mjs +289 -0
  20. package/src/dashboard_web.mjs +351 -0
  21. package/src/dashboard_web_client.mjs +167 -0
  22. package/src/dashboard_web_page.mjs +49 -0
  23. package/src/engine_event_parser_codex.mjs +167 -0
  24. package/src/engine_process_support.mjs +1 -0
  25. package/src/engine_selection.mjs +24 -0
  26. package/src/engine_selection_probe.mjs +10 -6
  27. package/src/engine_selection_settings.mjs +12 -19
  28. package/src/execution_interactivity.mjs +12 -0
  29. package/src/host_continuation.mjs +305 -0
  30. package/src/install_codex.mjs +20 -8
  31. package/src/install_shared.mjs +9 -0
  32. package/src/node_process_launch.mjs +28 -0
  33. package/src/process.mjs +2 -0
  34. package/src/runner_execute_task.mjs +4 -0
  35. package/src/runner_execution_support.mjs +69 -3
  36. package/src/runner_once.mjs +4 -0
  37. package/src/runner_status.mjs +63 -7
  38. package/src/runtime_engine_support.mjs +41 -4
  39. package/src/runtime_engine_task.mjs +7 -0
  40. package/src/runtime_settings.mjs +105 -0
  41. package/src/runtime_settings_loader.mjs +19 -0
  42. package/src/shell_invocation.mjs +227 -9
  43. package/src/supervisor_cli_support.mjs +3 -2
  44. package/src/supervisor_guardian.mjs +307 -0
  45. package/src/supervisor_runtime.mjs +138 -82
  46. package/src/supervisor_state.mjs +64 -0
  47. package/src/supervisor_watch.mjs +92 -48
  48. package/src/terminal_session_limits.mjs +1 -21
  49. package/src/windows_hidden_shell_proxy.mjs +405 -0
  50. package/src/workspace_registry.mjs +155 -0
@@ -0,0 +1,289 @@
1
+ import { collectDashboardSnapshot } from "./dashboard_command.mjs";
2
+ import { sleep } from "./common.mjs";
3
+ import { loadRuntimeSettings } from "./runtime_settings_loader.mjs";
4
+ import { hasRetryBudget, pickRetryDelaySeconds } from "./runtime_settings.mjs";
5
+
6
+ const STATUS_GROUPS = [
7
+ { key: "in_progress", label: "进行中" },
8
+ { key: "pending", label: "待处理" },
9
+ { key: "done", label: "已完成" },
10
+ { key: "blocked", label: "阻塞" },
11
+ { key: "failed", label: "失败" },
12
+ ];
13
+
14
+ const KEYBOARD_HELP = [
15
+ "←/→ 切换仓库",
16
+ "↑/↓ 滚动",
17
+ "1-9 直达仓库",
18
+ "r 刷新",
19
+ "q 退出",
20
+ ];
21
+
22
+ function truncateText(text, maxWidth) {
23
+ if (maxWidth <= 0) {
24
+ return "";
25
+ }
26
+ const normalized = String(text || "").replace(/\s+/gu, " ").trim();
27
+ if (normalized.length <= maxWidth) {
28
+ return normalized.padEnd(maxWidth, " ");
29
+ }
30
+ if (maxWidth <= 1) {
31
+ return normalized.slice(0, maxWidth);
32
+ }
33
+ return `${normalized.slice(0, Math.max(0, maxWidth - 1))}…`;
34
+ }
35
+
36
+ function repeat(char, count) {
37
+ return count > 0 ? char.repeat(count) : "";
38
+ }
39
+
40
+ function normalizeSelectedIndex(snapshot, currentIndex) {
41
+ if (!snapshot.sessions.length) {
42
+ return 0;
43
+ }
44
+ const bounded = Number(currentIndex || 0);
45
+ if (!Number.isFinite(bounded) || bounded < 0) {
46
+ return 0;
47
+ }
48
+ return Math.min(bounded, snapshot.sessions.length - 1);
49
+ }
50
+
51
+ function groupTasks(session) {
52
+ const grouped = new Map(STATUS_GROUPS.map((item) => [item.key, []]));
53
+ for (const task of Array.isArray(session?.tasks) ? session.tasks : []) {
54
+ const status = String(task.status || "pending");
55
+ const bucket = grouped.get(status) || grouped.get("pending");
56
+ bucket.push(task);
57
+ }
58
+ return grouped;
59
+ }
60
+
61
+ function renderRepoTabs(snapshot, selectedIndex, width) {
62
+ const tabs = snapshot.sessions.map((session, index) => {
63
+ const active = index === selectedIndex ? "*" : " ";
64
+ const task = session.latestStatus?.taskTitle || session.nextTask?.title || "无任务";
65
+ return `${active}${index + 1}.${session.repoName} ${session.runtime?.status || "idle"} ${task}`;
66
+ });
67
+
68
+ const line = tabs.join(" | ");
69
+ return truncateText(line, width);
70
+ }
71
+
72
+ function buildSessionMetaLines(session, width) {
73
+ const lines = [
74
+ `仓库:${session.repoRoot}`,
75
+ `会话:${session.sessionId}`,
76
+ `当前任务:${session.latestStatus?.taskTitle || "无"}`,
77
+ `运行状态:${session.runtime?.status || "idle"}${Number.isFinite(Number(session.runtime?.heartbeat?.idleSeconds)) && Number(session.runtime?.heartbeat?.idleSeconds) > 0 ? ` | idle=${session.runtime.heartbeat.idleSeconds}s` : ""}`,
78
+ `当前动作:${session.activity?.current?.label || session.latestStatus?.message || session.runtime?.failureReason || "等待新事件"}`,
79
+ `backlog:总计 ${session.summary?.total || 0} / 待处理 ${session.summary?.pending || 0} / 进行中 ${session.summary?.inProgress || 0} / 已完成 ${session.summary?.done || 0} / 阻塞 ${session.summary?.blocked || 0} / 失败 ${session.summary?.failed || 0}`,
80
+ ];
81
+
82
+ return lines.map((line) => truncateText(line, width));
83
+ }
84
+
85
+ function renderTaskLine(task, width) {
86
+ const meta = `[${task.priority || "P2"}|${task.risk || "low"}]`;
87
+ return truncateText(`${meta} ${task.title}`, width);
88
+ }
89
+
90
+ function renderTaskSections(session, width) {
91
+ const sections = [];
92
+ const grouped = groupTasks(session);
93
+
94
+ for (const statusGroup of STATUS_GROUPS) {
95
+ const tasks = grouped.get(statusGroup.key) || [];
96
+ sections.push(`${statusGroup.label} (${tasks.length})`);
97
+ if (!tasks.length) {
98
+ sections.push(" - 无");
99
+ sections.push("");
100
+ continue;
101
+ }
102
+
103
+ for (const task of tasks) {
104
+ sections.push(` - ${renderTaskLine(task, Math.max(10, width - 4))}`);
105
+ }
106
+ sections.push("");
107
+ }
108
+
109
+ return sections;
110
+ }
111
+
112
+ function renderEmptyState(width, height, message) {
113
+ const lines = [
114
+ "HelloLoop TUI Dashboard",
115
+ repeat("=", Math.max(20, Math.min(width, 40))),
116
+ "",
117
+ message,
118
+ ];
119
+ while (lines.length < height) {
120
+ lines.push("");
121
+ }
122
+ return lines.slice(0, height).map((line) => truncateText(line, width)).join("\n");
123
+ }
124
+
125
+ function renderTui(snapshot, state) {
126
+ const width = Math.max(60, Number(process.stdout.columns || 120));
127
+ const height = Math.max(24, Number(process.stdout.rows || 40));
128
+
129
+ if (!snapshot.sessions.length) {
130
+ return renderEmptyState(width, height, "当前没有已登记仓库或后台会话。");
131
+ }
132
+
133
+ const selectedIndex = normalizeSelectedIndex(snapshot, state.selectedIndex);
134
+ const selectedSession = snapshot.sessions[selectedIndex];
135
+ const lines = [
136
+ truncateText(`HelloLoop TUI Dashboard | 仓库 ${snapshot.repoCount || 0} | 活跃会话 ${snapshot.activeCount || 0} | 任务总计 ${snapshot.taskTotals?.total || 0} | 待处理 ${snapshot.taskTotals?.pending || 0} | 进行中 ${snapshot.taskTotals?.inProgress || 0} | 已完成 ${snapshot.taskTotals?.done || 0}`, width),
137
+ truncateText(`更新时间:${snapshot.generatedAt} | 键位:${KEYBOARD_HELP.join(" / ")}`, width),
138
+ repeat("─", width),
139
+ renderRepoTabs(snapshot, selectedIndex, width),
140
+ repeat("─", width),
141
+ ...buildSessionMetaLines(selectedSession, width),
142
+ repeat("─", width),
143
+ ];
144
+
145
+ const contentLines = renderTaskSections(selectedSession, width);
146
+ const availableContentHeight = Math.max(1, height - lines.length - 1);
147
+ const maxOffset = Math.max(0, contentLines.length - availableContentHeight);
148
+ const scrollOffset = Math.min(Math.max(0, Number(state.scrollOffset || 0)), maxOffset);
149
+
150
+ const visibleContent = contentLines.slice(scrollOffset, scrollOffset + availableContentHeight);
151
+ const footer = truncateText(
152
+ `仓库 ${selectedIndex + 1}/${snapshot.sessions.length} | 滚动 ${scrollOffset + 1}-${Math.min(contentLines.length, scrollOffset + visibleContent.length)}/${contentLines.length}`,
153
+ width,
154
+ );
155
+
156
+ while (visibleContent.length < availableContentHeight) {
157
+ visibleContent.push("");
158
+ }
159
+
160
+ return [...lines, ...visibleContent, footer]
161
+ .slice(0, height)
162
+ .map((line) => truncateText(line, width))
163
+ .join("\n");
164
+ }
165
+
166
+ function applyInputKey(buffer, state, snapshot) {
167
+ const input = String(buffer || "");
168
+ if (!input) {
169
+ return false;
170
+ }
171
+
172
+ if (input === "\u0003" || input === "q") {
173
+ state.running = false;
174
+ return true;
175
+ }
176
+ if (input === "r") {
177
+ state.forceRefresh = true;
178
+ return true;
179
+ }
180
+ if (input === "\u001b[C") {
181
+ state.selectedIndex = Math.min(snapshot.sessions.length - 1, state.selectedIndex + 1);
182
+ state.scrollOffset = 0;
183
+ return true;
184
+ }
185
+ if (input === "\u001b[D") {
186
+ state.selectedIndex = Math.max(0, state.selectedIndex - 1);
187
+ state.scrollOffset = 0;
188
+ return true;
189
+ }
190
+ if (input === "\u001b[A") {
191
+ state.scrollOffset = Math.max(0, state.scrollOffset - 1);
192
+ return true;
193
+ }
194
+ if (input === "\u001b[B") {
195
+ state.scrollOffset += 1;
196
+ return true;
197
+ }
198
+
199
+ const digit = Number.parseInt(input, 10);
200
+ if (Number.isInteger(digit) && digit > 0 && digit <= snapshot.sessions.length) {
201
+ state.selectedIndex = digit - 1;
202
+ state.scrollOffset = 0;
203
+ return true;
204
+ }
205
+
206
+ return false;
207
+ }
208
+
209
+ function enterAlternateScreen() {
210
+ process.stdout.write("\x1b[?1049h\x1b[?25l");
211
+ }
212
+
213
+ function leaveAlternateScreen() {
214
+ process.stdout.write("\x1b[?25h\x1b[?1049l");
215
+ }
216
+
217
+ export async function runDashboardTuiCommand(options = {}) {
218
+ if (!process.stdin.isTTY || !process.stdout.isTTY) {
219
+ throw new Error("HelloLoop TUI 需要在真实终端中运行。");
220
+ }
221
+
222
+ const pollMs = Math.max(500, Number(options.pollMs || options.watchPollMs || 1500));
223
+ const observerRetry = loadRuntimeSettings({
224
+ globalConfigFile: options.globalConfigFile,
225
+ }).observerRetry;
226
+
227
+ const state = {
228
+ running: true,
229
+ selectedIndex: 0,
230
+ scrollOffset: 0,
231
+ forceRefresh: true,
232
+ };
233
+ let lastFrame = "";
234
+ let retryCount = 0;
235
+ let lastSnapshot = collectDashboardSnapshot();
236
+
237
+ const onData = (buffer) => {
238
+ applyInputKey(buffer, state, lastSnapshot);
239
+ };
240
+
241
+ enterAlternateScreen();
242
+ process.stdin.setEncoding("utf8");
243
+ process.stdin.setRawMode(true);
244
+ process.stdin.resume();
245
+ process.stdin.on("data", onData);
246
+
247
+ try {
248
+ while (state.running) {
249
+ try {
250
+ lastSnapshot = collectDashboardSnapshot();
251
+ retryCount = 0;
252
+ } catch (error) {
253
+ const nextAttempt = retryCount + 1;
254
+ if (!observerRetry.enabled || !hasRetryBudget(observerRetry.maxRetryCount, nextAttempt)) {
255
+ throw error;
256
+ }
257
+
258
+ const delaySeconds = pickRetryDelaySeconds(observerRetry.retryDelaysSeconds, nextAttempt);
259
+ const frame = renderEmptyState(
260
+ Math.max(60, Number(process.stdout.columns || 120)),
261
+ Math.max(24, Number(process.stdout.rows || 40)),
262
+ `看板采集失败,将在 ${delaySeconds} 秒后自动重试(第 ${nextAttempt} 次):${String(error?.message || error || "unknown error")}`,
263
+ );
264
+ process.stdout.write("\x1b[H\x1b[2J");
265
+ process.stdout.write(frame);
266
+ retryCount = nextAttempt;
267
+ await sleep(delaySeconds * 1000);
268
+ continue;
269
+ }
270
+
271
+ const frame = renderTui(lastSnapshot, state);
272
+ if (frame !== lastFrame || state.forceRefresh) {
273
+ process.stdout.write("\x1b[H\x1b[2J");
274
+ process.stdout.write(frame);
275
+ lastFrame = frame;
276
+ state.forceRefresh = false;
277
+ }
278
+
279
+ await sleep(pollMs);
280
+ }
281
+ } finally {
282
+ process.stdin.off("data", onData);
283
+ process.stdin.setRawMode(false);
284
+ process.stdin.pause();
285
+ leaveAlternateScreen();
286
+ }
287
+
288
+ return 0;
289
+ }
@@ -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
+ }