bosun 0.41.2 → 0.41.3

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 (71) hide show
  1. package/.env.example +1 -1
  2. package/agent/agent-prompt-catalog.mjs +971 -0
  3. package/agent/agent-prompts.mjs +2 -970
  4. package/agent/agent-supervisor.mjs +6 -3
  5. package/agent/autofix-git.mjs +33 -0
  6. package/agent/autofix-prompts.mjs +151 -0
  7. package/agent/autofix.mjs +11 -175
  8. package/agent/bosun-skills.mjs +3 -2
  9. package/bosun.config.example.json +17 -0
  10. package/bosun.schema.json +87 -188
  11. package/cli.mjs +34 -1
  12. package/config/config-doctor.mjs +5 -250
  13. package/config/config-file-names.mjs +5 -0
  14. package/config/config.mjs +89 -493
  15. package/config/executor-config.mjs +493 -0
  16. package/config/repo-root.mjs +1 -2
  17. package/config/workspace-health.mjs +242 -0
  18. package/git/git-safety.mjs +15 -0
  19. package/github/github-oauth-portal.mjs +46 -0
  20. package/infra/library-manager-utils.mjs +22 -0
  21. package/infra/library-manager-well-known-sources.mjs +578 -0
  22. package/infra/library-manager.mjs +512 -1030
  23. package/infra/monitor.mjs +28 -9
  24. package/infra/session-tracker.mjs +10 -7
  25. package/kanban/kanban-adapter.mjs +17 -1
  26. package/lib/codebase-audit-manifests.mjs +117 -0
  27. package/lib/codebase-audit.mjs +18 -115
  28. package/package.json +18 -3
  29. package/server/ui-server.mjs +1194 -79
  30. package/shell/codex-config-file.mjs +178 -0
  31. package/shell/codex-config.mjs +538 -575
  32. package/task/task-cli.mjs +54 -3
  33. package/task/task-executor.mjs +143 -13
  34. package/task/task-store.mjs +409 -1
  35. package/telegram/telegram-bot.mjs +127 -0
  36. package/tools/apply-pr-suggestions.mjs +401 -0
  37. package/tools/syntax-check.mjs +21 -9
  38. package/ui/app.js +3 -14
  39. package/ui/components/kanban-board.js +227 -4
  40. package/ui/components/session-list.js +85 -5
  41. package/ui/demo-defaults.js +334 -80
  42. package/ui/demo.html +155 -0
  43. package/ui/modules/session-api.js +96 -0
  44. package/ui/modules/settings-schema.js +1 -2
  45. package/ui/modules/state.js +21 -3
  46. package/ui/setup.html +4 -5
  47. package/ui/styles/components.css +58 -4
  48. package/ui/tabs/agents.js +12 -15
  49. package/ui/tabs/control.js +1 -0
  50. package/ui/tabs/library.js +484 -22
  51. package/ui/tabs/manual-flows.js +105 -29
  52. package/ui/tabs/tasks.js +785 -140
  53. package/ui/tabs/telemetry.js +129 -11
  54. package/ui/tabs/workflow-canvas-utils.mjs +130 -0
  55. package/ui/tabs/workflows.js +293 -23
  56. package/voice/voice-tool-definitions.mjs +757 -0
  57. package/voice/voice-tools.mjs +34 -778
  58. package/workflow/manual-flow-audit.mjs +165 -0
  59. package/workflow/manual-flows.mjs +164 -259
  60. package/workflow/workflow-engine.mjs +147 -58
  61. package/workflow/workflow-nodes/definitions.mjs +1207 -0
  62. package/workflow/workflow-nodes/transforms.mjs +612 -0
  63. package/workflow/workflow-nodes.mjs +304 -52
  64. package/workflow/workflow-templates.mjs +313 -191
  65. package/workflow-templates/_helpers.mjs +154 -0
  66. package/workflow-templates/agents.mjs +61 -4
  67. package/workflow-templates/code-quality.mjs +7 -7
  68. package/workflow-templates/github.mjs +20 -10
  69. package/workflow-templates/task-batch.mjs +20 -9
  70. package/workflow-templates/task-lifecycle.mjs +31 -6
  71. package/workspace/worktree-manager.mjs +277 -3
@@ -0,0 +1,401 @@
1
+ /**
2
+ * apply-pr-suggestions.mjs — Batch-apply pending code-review suggestions on a PR.
3
+ *
4
+ * Fetches all review comments containing ```suggestion blocks, applies them to
5
+ * the affected files, and creates a single commit on the PR branch via the
6
+ * GitHub Git Data API (blobs → tree → commit → ref update).
7
+ *
8
+ * Usage:
9
+ * node tools/apply-pr-suggestions.mjs [--owner virtengine] [--repo bosun] <pr-number>
10
+ * bosun apply-suggestions <pr-number>
11
+ *
12
+ * Options:
13
+ * --owner Repository owner (default: auto-detect from git remote)
14
+ * --repo Repository name (default: auto-detect from git remote)
15
+ * --dry-run Show what would be applied without committing
16
+ * --author Only apply suggestions from this author (e.g. "copilot[bot]")
17
+ * --json Output result as JSON
18
+ */
19
+
20
+ import { execSync } from "node:child_process";
21
+
22
+ // ── GitHub API helper ─────────────────────────────────────────────────────────
23
+
24
+ function getToken() {
25
+ // Try gh CLI first, then env vars
26
+ try {
27
+ return execSync("gh auth token", { encoding: "utf8", stdio: ["pipe", "pipe", "pipe"] }).trim();
28
+ } catch {
29
+ return process.env.GITHUB_TOKEN || process.env.GH_TOKEN || "";
30
+ }
31
+ }
32
+
33
+ async function ghApiFetch(path, options = {}) {
34
+ const token = getToken();
35
+ if (!token) throw new Error("No GitHub token found. Run `gh auth login` or set GITHUB_TOKEN.");
36
+ const url = path.startsWith("http") ? path : `https://api.github.com/${path}`;
37
+ const resp = await fetch(url, {
38
+ method: options.method || "GET",
39
+ headers: {
40
+ Authorization: `Bearer ${token}`,
41
+ Accept: "application/vnd.github+json",
42
+ "X-GitHub-Api-Version": "2022-11-28",
43
+ ...(options.body ? { "Content-Type": "application/json" } : {}),
44
+ },
45
+ body: options.body ? JSON.stringify(options.body) : undefined,
46
+ });
47
+ if (!resp.ok) {
48
+ const text = await resp.text();
49
+ throw new Error(`GitHub API ${resp.status} on ${path}: ${text}`);
50
+ }
51
+ return resp.json();
52
+ }
53
+
54
+ /** Paginate a list endpoint, collecting all pages. */
55
+ async function ghApiPaginate(path) {
56
+ const results = [];
57
+ let page = 1;
58
+ while (true) {
59
+ const sep = path.includes("?") ? "&" : "?";
60
+ const data = await ghApiFetch(`${path}${sep}per_page=100&page=${page}`);
61
+ if (!Array.isArray(data) || data.length === 0) break;
62
+ results.push(...data);
63
+ if (data.length < 100) break;
64
+ page++;
65
+ }
66
+ return results;
67
+ }
68
+
69
+ // ── Suggestion parser ─────────────────────────────────────────────────────────
70
+
71
+ const SUGGESTION_RE = /```suggestion\r?\n([\s\S]*?)```/g;
72
+
73
+ /**
74
+ * Parse review comments into structured suggestion objects.
75
+ * @param {Array} comments - PR review comments from GitHub API
76
+ * @param {string} [authorFilter] - Only include suggestions from this author
77
+ * @returns {Array<{commentId, path, startLine, endLine, suggestedCode, author, url}>}
78
+ */
79
+ function parseSuggestions(comments, authorFilter) {
80
+ const suggestions = [];
81
+ for (const comment of comments) {
82
+ if (authorFilter && comment.user?.login !== authorFilter) continue;
83
+
84
+ const body = comment.body || "";
85
+ const matches = [...body.matchAll(SUGGESTION_RE)];
86
+ if (matches.length === 0) continue;
87
+
88
+ for (const match of matches) {
89
+ const suggestedCode = match[1];
90
+ // Single-line suggestions have line only; multi-line have start_line + line
91
+ const endLine = comment.line ?? comment.original_line;
92
+ const startLine = comment.start_line ?? comment.original_start_line ?? endLine;
93
+ if (!endLine || !comment.path) continue;
94
+
95
+ suggestions.push({
96
+ commentId: comment.id,
97
+ path: comment.path,
98
+ startLine,
99
+ endLine,
100
+ suggestedCode,
101
+ author: comment.user?.login || "unknown",
102
+ url: comment.html_url,
103
+ });
104
+ }
105
+ }
106
+ return suggestions;
107
+ }
108
+
109
+ /**
110
+ * Group suggestions by file path and sort within each file by endLine descending
111
+ * so we can apply from bottom-to-top without shifting line numbers.
112
+ */
113
+ function groupByFile(suggestions) {
114
+ const groups = new Map();
115
+ for (const s of suggestions) {
116
+ if (!groups.has(s.path)) groups.set(s.path, []);
117
+ groups.get(s.path).push(s);
118
+ }
119
+ // Sort each group by endLine DESC so bottom-up application preserves indices
120
+ for (const [, arr] of groups) {
121
+ arr.sort((a, b) => b.endLine - a.endLine);
122
+ }
123
+ return groups;
124
+ }
125
+
126
+ /**
127
+ * Check for overlapping suggestions in a sorted-descending list.
128
+ * Returns only non-overlapping suggestions (keeps first = highest line number).
129
+ */
130
+ function removeOverlaps(sortedDesc) {
131
+ const kept = [];
132
+ let minLine = Infinity;
133
+ for (const s of sortedDesc) {
134
+ if (s.endLine < minLine) {
135
+ kept.push(s);
136
+ minLine = s.startLine;
137
+ }
138
+ }
139
+ return kept;
140
+ }
141
+
142
+ /**
143
+ * Apply suggestion replacements to file content.
144
+ * Suggestions must be sorted by endLine descending.
145
+ */
146
+ function applyToContent(content, sortedSuggestions) {
147
+ const lines = content.split("\n");
148
+ for (const s of sortedSuggestions) {
149
+ // Lines are 1-indexed; convert to 0-indexed for splice
150
+ const startIdx = s.startLine - 1;
151
+ const count = s.endLine - s.startLine + 1;
152
+ // Suggestion code may end with trailing newline from the code fence; strip it
153
+ let code = s.suggestedCode;
154
+ if (code.endsWith("\n")) code = code.slice(0, -1);
155
+ const newLines = code.split("\n");
156
+ lines.splice(startIdx, count, ...newLines);
157
+ }
158
+ return lines.join("\n");
159
+ }
160
+
161
+ // ── Git Data API commit helper ────────────────────────────────────────────────
162
+
163
+ /**
164
+ * Create a single commit with all file changes on a branch using the Git Data API.
165
+ * @returns {string} New commit SHA
166
+ */
167
+ async function createBatchCommit(owner, repo, branch, fileChanges, message) {
168
+ // 1. Get current branch head
169
+ const ref = await ghApiFetch(`repos/${owner}/${repo}/git/refs/heads/${branch}`);
170
+ const headSha = ref.object.sha;
171
+
172
+ // 2. Get the tree SHA of the head commit
173
+ const headCommit = await ghApiFetch(`repos/${owner}/${repo}/git/commits/${headSha}`);
174
+ const baseTreeSha = headCommit.tree.sha;
175
+
176
+ // 3. Create blobs for each changed file
177
+ const treeItems = [];
178
+ for (const [path, content] of fileChanges) {
179
+ const blob = await ghApiFetch(`repos/${owner}/${repo}/git/blobs`, {
180
+ method: "POST",
181
+ body: { content, encoding: "utf-8" },
182
+ });
183
+ treeItems.push({ path, mode: "100644", type: "blob", sha: blob.sha });
184
+ }
185
+
186
+ // 4. Create new tree
187
+ const newTree = await ghApiFetch(`repos/${owner}/${repo}/git/trees`, {
188
+ method: "POST",
189
+ body: { base_tree: baseTreeSha, tree: treeItems },
190
+ });
191
+
192
+ // 5. Create commit
193
+ const newCommit = await ghApiFetch(`repos/${owner}/${repo}/git/commits`, {
194
+ method: "POST",
195
+ body: { message, tree: newTree.sha, parents: [headSha] },
196
+ });
197
+
198
+ // 6. Update the branch ref
199
+ await ghApiFetch(`repos/${owner}/${repo}/git/refs/heads/${branch}`, {
200
+ method: "PATCH",
201
+ body: { sha: newCommit.sha, force: false },
202
+ });
203
+
204
+ return newCommit.sha;
205
+ }
206
+
207
+ // ── Repo detection ────────────────────────────────────────────────────────────
208
+
209
+ function detectOwnerRepo() {
210
+ try {
211
+ const url = execSync("git config --get remote.origin.url", {
212
+ encoding: "utf8",
213
+ stdio: ["pipe", "pipe", "pipe"],
214
+ }).trim();
215
+ // https://github.com/owner/repo.git or git@github.com:owner/repo.git
216
+ const m = url.match(/github\.com[:/]([^/]+)\/([^/.]+)/);
217
+ if (m) return { owner: m[1], repo: m[2] };
218
+ } catch { /* ignore */ }
219
+ return { owner: "", repo: "" };
220
+ }
221
+
222
+ // ── Main ──────────────────────────────────────────────────────────────────────
223
+
224
+ /**
225
+ * Apply all pending review suggestions on a PR.
226
+ * @param {Object} opts
227
+ * @param {string} opts.owner - Repo owner
228
+ * @param {string} opts.repo - Repo name
229
+ * @param {number} opts.prNumber - PR number
230
+ * @param {boolean} [opts.dryRun=false] - Don't commit, just show what would be applied
231
+ * @param {string} [opts.author] - Only apply suggestions from this author
232
+ * @returns {Object} { applied, skipped, commitSha }
233
+ */
234
+ export async function applyPrSuggestions({ owner, repo, prNumber, dryRun = false, author }) {
235
+ // 1. Fetch PR info to get the branch name
236
+ const pr = await ghApiFetch(`repos/${owner}/${repo}/pulls/${prNumber}`);
237
+ const branch = pr.head.ref;
238
+ const headSha = pr.head.sha;
239
+
240
+ // 2. Fetch all review comments
241
+ const comments = await ghApiPaginate(`repos/${owner}/${repo}/pulls/${prNumber}/comments`);
242
+
243
+ // 3. Parse suggestions
244
+ const suggestions = parseSuggestions(comments, author);
245
+ if (suggestions.length === 0) {
246
+ return { applied: 0, skipped: 0, commitSha: null, message: "No pending suggestions found." };
247
+ }
248
+
249
+ // 4. Group by file and remove overlaps
250
+ const groups = groupByFile(suggestions);
251
+ const fileChanges = new Map(); // path → new content
252
+ let applied = 0;
253
+ let skipped = 0;
254
+ const appliedDetails = [];
255
+
256
+ for (const [path, fileSuggestions] of groups) {
257
+ // Fetch current file content from the PR branch head
258
+ let content;
259
+ try {
260
+ const fileData = await ghApiFetch(
261
+ `repos/${owner}/${repo}/contents/${encodeURIComponent(path)}?ref=${headSha}`
262
+ );
263
+ content = Buffer.from(fileData.content, "base64").toString("utf8");
264
+ } catch (err) {
265
+ console.error(` ⚠ Could not fetch ${path}: ${err.message}`);
266
+ skipped += fileSuggestions.length;
267
+ continue;
268
+ }
269
+
270
+ // Remove overlapping suggestions
271
+ const valid = removeOverlaps(fileSuggestions);
272
+ skipped += fileSuggestions.length - valid.length;
273
+
274
+ // Check if suggestions are still applicable (lines haven't changed)
275
+ const contentLines = content.split("\n");
276
+ const applicable = valid.filter((s) => {
277
+ if (s.endLine > contentLines.length) return false;
278
+ return true;
279
+ });
280
+ skipped += valid.length - applicable.length;
281
+
282
+ if (applicable.length === 0) continue;
283
+
284
+ // Apply changes
285
+ const newContent = applyToContent(content, applicable);
286
+ if (newContent === content) {
287
+ skipped += applicable.length;
288
+ continue;
289
+ }
290
+
291
+ fileChanges.set(path, newContent);
292
+ applied += applicable.length;
293
+ for (const s of applicable) {
294
+ appliedDetails.push({
295
+ path: s.path,
296
+ lines: `${s.startLine}-${s.endLine}`,
297
+ author: s.author,
298
+ url: s.url,
299
+ });
300
+ }
301
+ }
302
+
303
+ if (applied === 0) {
304
+ return { applied: 0, skipped, commitSha: null, message: "All suggestions were already applied or inapplicable." };
305
+ }
306
+
307
+ if (dryRun) {
308
+ return { applied, skipped, commitSha: null, dryRun: true, details: appliedDetails };
309
+ }
310
+
311
+ // 5. Create batch commit
312
+ const paths = [...fileChanges.keys()];
313
+ const commitMsg = applied === 1
314
+ ? `Apply code review suggestion\n\nApply suggestion in ${paths[0]}`
315
+ : `Apply ${applied} code review suggestions\n\nFiles: ${paths.join(", ")}`;
316
+
317
+ const commitSha = await createBatchCommit(owner, repo, branch, fileChanges, commitMsg);
318
+
319
+ return { applied, skipped, commitSha, details: appliedDetails };
320
+ }
321
+
322
+ // ── CLI entry point ───────────────────────────────────────────────────────────
323
+
324
+ async function main() {
325
+ const args = process.argv.slice(2);
326
+
327
+ let owner = "";
328
+ let repo = "";
329
+ let prNumber = 0;
330
+ let dryRun = false;
331
+ let author = "";
332
+ let jsonOut = false;
333
+
334
+ for (let i = 0; i < args.length; i++) {
335
+ const arg = args[i];
336
+ if (arg === "--owner" && args[i + 1]) { owner = args[++i]; continue; }
337
+ if (arg === "--repo" && args[i + 1]) { repo = args[++i]; continue; }
338
+ if (arg === "--author" && args[i + 1]) { author = args[++i]; continue; }
339
+ if (arg === "--dry-run") { dryRun = true; continue; }
340
+ if (arg === "--json") { jsonOut = true; continue; }
341
+ if (arg === "--help" || arg === "-h") {
342
+ console.log("Usage: node tools/apply-pr-suggestions.mjs [options] <pr-number>");
343
+ console.log("");
344
+ console.log("Options:");
345
+ console.log(" --owner <owner> Repo owner (default: auto-detect)");
346
+ console.log(" --repo <repo> Repo name (default: auto-detect)");
347
+ console.log(" --author <login> Only apply from this author (e.g. copilot[bot])");
348
+ console.log(" --dry-run Show what would be applied without committing");
349
+ console.log(" --json Output result as JSON");
350
+ process.exit(0);
351
+ }
352
+ if (/^\d+$/.test(arg)) { prNumber = parseInt(arg, 10); continue; }
353
+ }
354
+
355
+ if (!prNumber) {
356
+ console.error("Error: PR number is required.");
357
+ console.error("Usage: node tools/apply-pr-suggestions.mjs <pr-number>");
358
+ process.exit(1);
359
+ }
360
+
361
+ if (!owner || !repo) {
362
+ const detected = detectOwnerRepo();
363
+ owner = owner || detected.owner;
364
+ repo = repo || detected.repo;
365
+ }
366
+ if (!owner || !repo) {
367
+ console.error("Error: Could not detect owner/repo. Use --owner and --repo flags.");
368
+ process.exit(1);
369
+ }
370
+
371
+ try {
372
+ const result = await applyPrSuggestions({ owner, repo, prNumber, dryRun, author: author || undefined });
373
+
374
+ if (jsonOut) {
375
+ console.log(JSON.stringify(result, null, 2));
376
+ } else {
377
+ if (result.dryRun) {
378
+ console.log(`\n 🔍 Dry run — ${result.applied} suggestion(s) would be applied:`);
379
+ for (const d of result.details || []) {
380
+ console.log(` ${d.path}:${d.lines} (by ${d.author})`);
381
+ }
382
+ } else if (result.commitSha) {
383
+ console.log(`\n ✅ Applied ${result.applied} suggestion(s) in commit ${result.commitSha.slice(0, 8)}`);
384
+ if (result.skipped) console.log(` (${result.skipped} skipped — overlapping or inapplicable)`);
385
+ for (const d of result.details || []) {
386
+ console.log(` ${d.path}:${d.lines}`);
387
+ }
388
+ } else {
389
+ console.log(`\n ℹ ${result.message}`);
390
+ }
391
+ console.log("");
392
+ }
393
+ } catch (err) {
394
+ console.error(`Error: ${err.message}`);
395
+ process.exit(1);
396
+ }
397
+ }
398
+
399
+ // Run as CLI script
400
+ const isDirectRun = process.argv[1]?.replace(/\\/g, "/").endsWith("tools/apply-pr-suggestions.mjs");
401
+ if (isDirectRun) main();
@@ -51,14 +51,23 @@ function validateModuleSyntax(filePath) {
51
51
  new vm.SourceTextModule(source, { identifier: filePath });
52
52
  }
53
53
 
54
+ function validateBrowserModuleSyntax(filePath) {
55
+ const source = readFileSync(filePath, "utf8");
56
+ const mod = new vm.SourceTextModule(source, { identifier: filePath });
57
+ if (typeof mod.hasTopLevelAwait === "function" && mod.hasTopLevelAwait()) {
58
+ throw new Error(
59
+ "Top-level await is not allowed in browser-served modules because embedded WebViews can fail with 'Unexpected reserved word'.",
60
+ );
61
+ }
62
+ }
63
+
54
64
  /**
55
65
  * Parse a JS file using the Module compiler.
56
66
  * Catches syntax errors such as unterminated statements or bad tokens.
57
67
  * UI files use ES module syntax (import/export) via browser importmaps.
58
68
  */
59
69
  function validateScriptSyntax(filePath) {
60
- const source = readFileSync(filePath, "utf8");
61
- new vm.SourceTextModule(source, { identifier: filePath });
70
+ validateBrowserModuleSyntax(filePath);
62
71
  }
63
72
 
64
73
  async function main() {
@@ -87,14 +96,17 @@ async function main() {
87
96
  process.exit(1);
88
97
  }
89
98
 
90
- // ── Phase 2: Parse-check UI JavaScript files ──────────────────────────
91
- // These are classic scripts (not ESM modules) loaded via importmap in the
92
- // browser. We use vm.Script to catch syntax errors.
93
- const uiDir = resolve(process.cwd(), "ui");
94
- const uiFiles = listJsFilesRecursive(uiDir);
99
+ // ── Phase 2: Parse-check browser JavaScript files ─────────────────────
100
+ // These files are loaded directly in the browser via import maps. Keep
101
+ // them free of syntax that older embedded WebViews reject at parse time.
102
+ const browserRoots = [
103
+ resolve(process.cwd(), "ui"),
104
+ resolve(process.cwd(), "site", "ui"),
105
+ ];
106
+ const browserFiles = [...new Set(browserRoots.flatMap((dir) => listJsFilesRecursive(dir)))];
95
107
  let uiFailed = false;
96
108
 
97
- for (const filePath of uiFiles) {
109
+ for (const filePath of browserFiles) {
98
110
  try {
99
111
  validateScriptSyntax(filePath);
100
112
  } catch (error) {
@@ -110,7 +122,7 @@ async function main() {
110
122
  process.exit(1);
111
123
  }
112
124
 
113
- console.log(`Syntax OK: ${files.length} modules + ${uiFiles.length} UI files checked`);
125
+ console.log(`Syntax OK: ${files.length} modules + ${browserFiles.length} browser files checked`);
114
126
  }
115
127
 
116
128
  main().catch((error) => {
package/ui/app.js CHANGED
@@ -249,6 +249,8 @@ import {
249
249
  apiFetch,
250
250
  connectWebSocket,
251
251
  disconnectWebSocket,
252
+ wsLatency,
253
+ wsReconnectIn,
252
254
  wsConnected,
253
255
  loadingCount,
254
256
  } from "./modules/api.js";
@@ -262,6 +264,7 @@ import {
262
264
  initWsInvalidationListener,
263
265
  loadNotificationPrefs,
264
266
  applyStoredDefaults,
267
+ dataFreshness,
265
268
  hasPendingChanges,
266
269
  } from "./modules/state.js";
267
270
  import {
@@ -314,20 +317,6 @@ import { WorkflowsTab } from "./tabs/workflows.js";
314
317
  import { LibraryTab } from "./tabs/library.js";
315
318
  import { ManualFlowsTab } from "./tabs/manual-flows.js";
316
319
 
317
- /* ── Placeholder signals for connection quality (may be provided by api.js) ── */
318
- let wsLatency = signal(null);
319
- let wsReconnectIn = signal(null);
320
- let dataFreshness = signal(null);
321
- try {
322
- const apiMod = await import("./modules/api.js");
323
- if (apiMod.wsLatency) wsLatency = apiMod.wsLatency;
324
- if (apiMod.wsReconnectIn) wsReconnectIn = apiMod.wsReconnectIn;
325
- } catch { /* use placeholder signals */ }
326
- try {
327
- const stateMod = await import("./modules/state.js");
328
- if (stateMod.dataFreshness) dataFreshness = stateMod.dataFreshness;
329
- } catch { /* use placeholder signals */ }
330
-
331
320
  /* ── Shared components ── */
332
321
 
333
322
  /**