@virtengine/openfleet 0.25.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 (120) hide show
  1. package/.env.example +914 -0
  2. package/LICENSE +190 -0
  3. package/README.md +500 -0
  4. package/agent-endpoint.mjs +918 -0
  5. package/agent-hook-bridge.mjs +230 -0
  6. package/agent-hooks.mjs +1188 -0
  7. package/agent-pool.mjs +2403 -0
  8. package/agent-prompts.mjs +689 -0
  9. package/agent-sdk.mjs +141 -0
  10. package/anomaly-detector.mjs +1195 -0
  11. package/autofix.mjs +1294 -0
  12. package/claude-shell.mjs +708 -0
  13. package/cli.mjs +906 -0
  14. package/codex-config.mjs +1274 -0
  15. package/codex-model-profiles.mjs +135 -0
  16. package/codex-shell.mjs +762 -0
  17. package/config-doctor.mjs +613 -0
  18. package/config.mjs +1720 -0
  19. package/conflict-resolver.mjs +248 -0
  20. package/container-runner.mjs +450 -0
  21. package/copilot-shell.mjs +827 -0
  22. package/daemon-restart-policy.mjs +56 -0
  23. package/diff-stats.mjs +282 -0
  24. package/error-detector.mjs +829 -0
  25. package/fetch-runtime.mjs +34 -0
  26. package/fleet-coordinator.mjs +838 -0
  27. package/get-telegram-chat-id.mjs +71 -0
  28. package/git-safety.mjs +170 -0
  29. package/github-reconciler.mjs +403 -0
  30. package/hook-profiles.mjs +651 -0
  31. package/kanban-adapter.mjs +4491 -0
  32. package/lib/logger.mjs +645 -0
  33. package/maintenance.mjs +828 -0
  34. package/merge-strategy.mjs +1171 -0
  35. package/monitor.mjs +12207 -0
  36. package/openfleet.config.example.json +115 -0
  37. package/openfleet.schema.json +465 -0
  38. package/package.json +203 -0
  39. package/postinstall.mjs +187 -0
  40. package/pr-cleanup-daemon.mjs +978 -0
  41. package/preflight.mjs +408 -0
  42. package/prepublish-check.mjs +90 -0
  43. package/presence.mjs +328 -0
  44. package/primary-agent.mjs +282 -0
  45. package/publish.mjs +151 -0
  46. package/repo-root.mjs +29 -0
  47. package/restart-controller.mjs +100 -0
  48. package/review-agent.mjs +557 -0
  49. package/rotate-agent-logs.sh +133 -0
  50. package/sdk-conflict-resolver.mjs +973 -0
  51. package/session-tracker.mjs +880 -0
  52. package/setup.mjs +3937 -0
  53. package/shared-knowledge.mjs +410 -0
  54. package/shared-state-manager.mjs +841 -0
  55. package/shared-workspace-cli.mjs +199 -0
  56. package/shared-workspace-registry.mjs +537 -0
  57. package/shared-workspaces.json +18 -0
  58. package/startup-service.mjs +1070 -0
  59. package/sync-engine.mjs +1063 -0
  60. package/task-archiver.mjs +801 -0
  61. package/task-assessment.mjs +550 -0
  62. package/task-claims.mjs +924 -0
  63. package/task-complexity.mjs +581 -0
  64. package/task-executor.mjs +5111 -0
  65. package/task-store.mjs +753 -0
  66. package/telegram-bot.mjs +9281 -0
  67. package/telegram-sentinel.mjs +2010 -0
  68. package/ui/app.js +867 -0
  69. package/ui/app.legacy.js +1464 -0
  70. package/ui/app.monolith.js +2488 -0
  71. package/ui/components/charts.js +226 -0
  72. package/ui/components/chat-view.js +567 -0
  73. package/ui/components/command-palette.js +587 -0
  74. package/ui/components/diff-viewer.js +190 -0
  75. package/ui/components/forms.js +327 -0
  76. package/ui/components/kanban-board.js +451 -0
  77. package/ui/components/session-list.js +305 -0
  78. package/ui/components/shared.js +473 -0
  79. package/ui/index.html +70 -0
  80. package/ui/modules/api.js +297 -0
  81. package/ui/modules/icons.js +461 -0
  82. package/ui/modules/router.js +81 -0
  83. package/ui/modules/settings-schema.js +261 -0
  84. package/ui/modules/state.js +679 -0
  85. package/ui/modules/telegram.js +331 -0
  86. package/ui/modules/utils.js +270 -0
  87. package/ui/styles/animations.css +140 -0
  88. package/ui/styles/base.css +98 -0
  89. package/ui/styles/components.css +1915 -0
  90. package/ui/styles/kanban.css +286 -0
  91. package/ui/styles/layout.css +809 -0
  92. package/ui/styles/sessions.css +827 -0
  93. package/ui/styles/variables.css +188 -0
  94. package/ui/styles.css +141 -0
  95. package/ui/styles.monolith.css +1046 -0
  96. package/ui/tabs/agents.js +1417 -0
  97. package/ui/tabs/chat.js +74 -0
  98. package/ui/tabs/control.js +887 -0
  99. package/ui/tabs/dashboard.js +515 -0
  100. package/ui/tabs/infra.js +537 -0
  101. package/ui/tabs/logs.js +783 -0
  102. package/ui/tabs/settings.js +1487 -0
  103. package/ui/tabs/tasks.js +1385 -0
  104. package/ui-server.mjs +4073 -0
  105. package/update-check.mjs +465 -0
  106. package/utils.mjs +172 -0
  107. package/ve-kanban.mjs +654 -0
  108. package/ve-kanban.ps1 +1365 -0
  109. package/ve-kanban.sh +18 -0
  110. package/ve-orchestrator.mjs +340 -0
  111. package/ve-orchestrator.ps1 +6546 -0
  112. package/ve-orchestrator.sh +18 -0
  113. package/vibe-kanban-wrapper.mjs +41 -0
  114. package/vk-error-resolver.mjs +470 -0
  115. package/vk-log-stream.mjs +914 -0
  116. package/whatsapp-channel.mjs +520 -0
  117. package/workspace-monitor.mjs +581 -0
  118. package/workspace-reaper.mjs +405 -0
  119. package/workspace-registry.mjs +238 -0
  120. package/worktree-manager.mjs +1266 -0
package/ui-server.mjs ADDED
@@ -0,0 +1,4073 @@
1
+ import { execSync, spawn } from "node:child_process";
2
+ import { createHmac, randomBytes, timingSafeEqual, X509Certificate } from "node:crypto";
3
+ import { existsSync, mkdirSync, readFileSync, chmodSync, createWriteStream, writeFileSync, watchFile, unwatchFile } from "node:fs";
4
+ import { open, readFile, readdir, stat, writeFile } from "node:fs/promises";
5
+ import { createServer } from "node:http";
6
+ import { get as httpsGet } from "node:https";
7
+ import { createServer as createHttpsServer } from "node:https";
8
+ import { networkInterfaces } from "node:os";
9
+ import { connect as netConnect } from "node:net";
10
+ import { resolve, extname, dirname } from "node:path";
11
+ import { fileURLToPath } from "node:url";
12
+ import { arch as osArch, platform as osPlatform } from "node:os";
13
+ import Ajv2020 from "ajv/dist/2020.js";
14
+
15
+ function getLocalLanIp() {
16
+ const nets = networkInterfaces();
17
+ for (const name of Object.keys(nets)) {
18
+ for (const net of nets[name]) {
19
+ if (net.family === "IPv4" && !net.internal) {
20
+ return net.address;
21
+ }
22
+ }
23
+ }
24
+ return "localhost";
25
+ }
26
+ import { WebSocketServer } from "ws";
27
+ import { getKanbanAdapter } from "./kanban-adapter.mjs";
28
+ import { getActiveThreads } from "./agent-pool.mjs";
29
+ import {
30
+ listActiveWorktrees,
31
+ getWorktreeStats,
32
+ pruneStaleWorktrees,
33
+ releaseWorktree,
34
+ releaseWorktreeByBranch,
35
+ } from "./worktree-manager.mjs";
36
+ import {
37
+ loadSharedWorkspaceRegistry,
38
+ sweepExpiredLeases,
39
+ getSharedAvailabilityMap,
40
+ claimSharedWorkspace,
41
+ releaseSharedWorkspace,
42
+ renewSharedWorkspaceLease,
43
+ } from "./shared-workspace-registry.mjs";
44
+ import {
45
+ initPresence,
46
+ listActiveInstances,
47
+ selectCoordinator,
48
+ } from "./presence.mjs";
49
+ import {
50
+ loadWorkspaceRegistry,
51
+ getLocalWorkspace,
52
+ } from "./workspace-registry.mjs";
53
+ import {
54
+ getSessionTracker,
55
+ } from "./session-tracker.mjs";
56
+ import {
57
+ collectDiffStats,
58
+ getCompactDiffSummary,
59
+ getRecentCommits,
60
+ } from "./diff-stats.mjs";
61
+ import { resolveRepoRoot } from "./repo-root.mjs";
62
+ import {
63
+ SETTINGS_SCHEMA,
64
+ validateSetting,
65
+ } from "./ui/modules/settings-schema.js";
66
+
67
+ const __dirname = resolve(fileURLToPath(new URL(".", import.meta.url)));
68
+ const repoRoot = resolveRepoRoot();
69
+ const uiRoot = resolve(__dirname, "ui");
70
+ const statusPath = resolve(repoRoot, ".cache", "ve-orchestrator-status.json");
71
+ const logsDir = resolve(__dirname, "logs");
72
+ const agentLogsDir = resolve(repoRoot, ".cache", "agent-logs");
73
+ const CONFIG_SCHEMA_PATH = resolve(__dirname, "openfleet.schema.json");
74
+ let _configSchema = null;
75
+ let _configValidator = null;
76
+
77
+ function resolveConfigPath() {
78
+ return process.env.CODEX_MONITOR_CONFIG_PATH
79
+ ? resolve(process.env.CODEX_MONITOR_CONFIG_PATH)
80
+ : resolve(__dirname, "openfleet.config.json");
81
+ }
82
+
83
+ function getConfigSchema() {
84
+ if (_configSchema) return _configSchema;
85
+ try {
86
+ const raw = readFileSync(CONFIG_SCHEMA_PATH, "utf8");
87
+ _configSchema = JSON.parse(raw);
88
+ } catch {
89
+ _configSchema = null;
90
+ }
91
+ return _configSchema;
92
+ }
93
+
94
+ function getConfigValidator() {
95
+ if (_configValidator) return _configValidator;
96
+ const schema = getConfigSchema();
97
+ if (!schema) return null;
98
+ try {
99
+ const ajv = new Ajv2020({ allErrors: true, strict: false, allowUnionTypes: true });
100
+ _configValidator = ajv.compile(schema);
101
+ } catch {
102
+ _configValidator = null;
103
+ }
104
+ return _configValidator;
105
+ }
106
+
107
+ function isUnsetValue(value) {
108
+ if (value == null) return true;
109
+ if (Array.isArray(value)) return false;
110
+ return typeof value === "string" && value === "";
111
+ }
112
+
113
+ function toCamelCaseFromEnv(key) {
114
+ const parts = String(key || "").toLowerCase().split("_").filter(Boolean);
115
+ return parts
116
+ .map((part, idx) => (idx === 0 ? part : part.charAt(0).toUpperCase() + part.slice(1)))
117
+ .join("");
118
+ }
119
+
120
+ function parseExecutorsValue(value) {
121
+ if (Array.isArray(value)) return value;
122
+ const raw = String(value || "").trim();
123
+ if (!raw) return [];
124
+ if (raw.startsWith("[") || raw.startsWith("{")) {
125
+ try {
126
+ const parsed = JSON.parse(raw);
127
+ return Array.isArray(parsed) ? parsed : value;
128
+ } catch {
129
+ return value;
130
+ }
131
+ }
132
+ const entries = raw.split(",").map((entry) => entry.trim()).filter(Boolean);
133
+ const roles = ["primary", "backup", "tertiary"];
134
+ const executors = [];
135
+ for (let i = 0; i < entries.length; i += 1) {
136
+ const parts = entries[i].split(":").map((part) => part.trim());
137
+ if (parts.length < 2) continue;
138
+ const weight = parts[2] ? Number(parts[2]) : Math.floor(100 / entries.length);
139
+ executors.push({
140
+ name: `${parts[0].toLowerCase()}-${parts[1].toLowerCase()}`,
141
+ executor: parts[0].toUpperCase(),
142
+ variant: parts[1],
143
+ weight: Number.isFinite(weight) ? weight : 0,
144
+ role: roles[i] || `executor-${i + 1}`,
145
+ enabled: true,
146
+ });
147
+ }
148
+ return executors.length ? executors : value;
149
+ }
150
+
151
+ function coerceSettingValue(def, value, propSchema) {
152
+ if (value == null) return value;
153
+ const types = [];
154
+ if (propSchema?.type) {
155
+ if (Array.isArray(propSchema.type)) types.push(...propSchema.type);
156
+ else types.push(propSchema.type);
157
+ }
158
+
159
+ if (types.includes("array")) {
160
+ if (def?.key === "EXECUTORS") {
161
+ return parseExecutorsValue(value);
162
+ }
163
+ if (Array.isArray(value)) return value;
164
+ let parts = String(value)
165
+ .split(",")
166
+ .map((part) => part.trim())
167
+ .filter(Boolean);
168
+ if (def?.key === "CODEX_MONITOR_HOOK_TARGETS") {
169
+ parts = parts.map((part) => part.toLowerCase());
170
+ if (parts.includes("all")) {
171
+ const allowed = Array.isArray(propSchema?.items?.enum)
172
+ ? propSchema.items.enum
173
+ : ["codex", "claude", "copilot"];
174
+ parts = [...allowed];
175
+ }
176
+ }
177
+ return parts;
178
+ }
179
+
180
+ if (types.includes("boolean")) {
181
+ if (typeof value === "boolean") return value;
182
+ const normalized = String(value).toLowerCase();
183
+ if (["1", "true", "yes", "on"].includes(normalized)) return true;
184
+ if (["0", "false", "no", "off"].includes(normalized)) return false;
185
+ }
186
+
187
+ if (types.includes("number") || types.includes("integer")) {
188
+ const n = Number(value);
189
+ if (Number.isFinite(n)) return n;
190
+ }
191
+
192
+ if (def?.type === "number") {
193
+ const n = Number(value);
194
+ return Number.isFinite(n) ? n : value;
195
+ }
196
+ if (def?.type === "boolean") {
197
+ if (typeof value === "boolean") return value;
198
+ const normalized = String(value).toLowerCase();
199
+ if (["1", "true", "yes", "on"].includes(normalized)) return true;
200
+ if (["0", "false", "no", "off"].includes(normalized)) return false;
201
+ }
202
+ return value;
203
+ }
204
+
205
+ function getSchemaProperty(schema, pathParts) {
206
+ let current = schema;
207
+ for (const part of pathParts) {
208
+ if (!current || !current.properties) return null;
209
+ current = current.properties[part];
210
+ }
211
+ return current || null;
212
+ }
213
+
214
+ const ROOT_SKIP_ENV_KEYS = new Set([]);
215
+ const ROOT_OVERRIDE_MAP = {
216
+ CODEX_MONITOR_MODE: "mode",
217
+ TASK_PLANNER_MODE: "plannerMode",
218
+ EXECUTOR_DISTRIBUTION: "distribution",
219
+ };
220
+ const INTERNAL_EXECUTOR_MAP = {
221
+ PARALLEL: ["internalExecutor", "maxParallel"],
222
+ SDK: ["internalExecutor", "sdk"],
223
+ TIMEOUT_MS: ["internalExecutor", "taskTimeoutMs"],
224
+ MAX_RETRIES: ["internalExecutor", "maxRetries"],
225
+ POLL_MS: ["internalExecutor", "pollIntervalMs"],
226
+ REVIEW_AGENT_ENABLED: ["internalExecutor", "reviewAgentEnabled"],
227
+ REPLENISH_ENABLED: ["internalExecutor", "backlogReplenishment", "enabled"],
228
+ };
229
+ const CONFIG_PATH_OVERRIDES = {
230
+ EXECUTOR_MODE: ["internalExecutor", "mode"],
231
+ PROJECT_REQUIREMENTS_PROFILE: ["projectRequirements", "profile"],
232
+ TASK_PLANNER_DEDUP_HOURS: ["plannerDedupHours"],
233
+ };
234
+ const ROOT_PREFIX_ALLOWLIST = [
235
+ "TELEGRAM_",
236
+ "FLEET_",
237
+ "VE_",
238
+ ];
239
+
240
+ const AUTH_ENV_PREFIX_MAP = {
241
+ CODEX: "codex",
242
+ CLAUDE: "claude",
243
+ COPILOT: "copilot",
244
+ };
245
+
246
+ function buildConfigPath(pathParts, allowUnknownSchema = false) {
247
+ return { pathParts, allowUnknownSchema };
248
+ }
249
+
250
+ function mapEnvKeyToConfigPath(key, schema) {
251
+ if (!schema?.properties) return null;
252
+ const envKey = String(key || "").toUpperCase();
253
+
254
+ if (ROOT_SKIP_ENV_KEYS.has(envKey)) return null;
255
+
256
+ const overridePath = CONFIG_PATH_OVERRIDES[envKey];
257
+ if (overridePath) {
258
+ return buildConfigPath(overridePath, true);
259
+ }
260
+
261
+ if (envKey.startsWith("INTERNAL_EXECUTOR_")) {
262
+ const rest = envKey.slice("INTERNAL_EXECUTOR_".length);
263
+ const internalPath = INTERNAL_EXECUTOR_MAP[rest];
264
+ if (internalPath) {
265
+ return buildConfigPath(internalPath, true);
266
+ }
267
+ }
268
+
269
+ if (ROOT_OVERRIDE_MAP[envKey] && schema.properties[ROOT_OVERRIDE_MAP[envKey]]) {
270
+ return buildConfigPath([ROOT_OVERRIDE_MAP[envKey]]);
271
+ }
272
+ if (envKey.startsWith("FAILOVER_") && schema.properties.failover?.properties) {
273
+ const rest = envKey.slice("FAILOVER_".length);
274
+ const sub = toCamelCaseFromEnv(rest);
275
+ if (schema.properties.failover.properties[sub]) return buildConfigPath(["failover", sub]);
276
+ }
277
+ if (envKey.startsWith("CODEX_MONITOR_PROMPT_") && schema.properties.agentPrompts?.properties) {
278
+ const rest = envKey.slice("CODEX_MONITOR_PROMPT_".length);
279
+ const sub = toCamelCaseFromEnv(rest);
280
+ if (schema.properties.agentPrompts.properties[sub]) {
281
+ return buildConfigPath(["agentPrompts", sub]);
282
+ }
283
+ }
284
+ if (envKey.endsWith("_AUTH_SOURCES") || envKey.endsWith("_AUTH_FALLBACK_INTERACTIVE")) {
285
+ const match = envKey.match(/^(CODEX|CLAUDE|COPILOT)_AUTH_(SOURCES|FALLBACK_INTERACTIVE)$/);
286
+ if (match && schema.properties.auth?.properties) {
287
+ const provider = AUTH_ENV_PREFIX_MAP[match[1]];
288
+ const sub = match[2] === "SOURCES" ? "sources" : "fallbackToInteractive";
289
+ const propSchema = schema.properties.auth?.properties?.[provider]?.properties;
290
+ if (propSchema?.[sub]) {
291
+ return buildConfigPath(["auth", provider, sub]);
292
+ }
293
+ }
294
+ }
295
+ const hookProfileMap = {
296
+ CODEX_MONITOR_HOOK_PROFILE: ["hookProfiles", "profile"],
297
+ CODEX_MONITOR_HOOK_TARGETS: ["hookProfiles", "targets"],
298
+ CODEX_MONITOR_HOOKS_ENABLED: ["hookProfiles", "enabled"],
299
+ CODEX_MONITOR_HOOKS_OVERWRITE: ["hookProfiles", "overwriteExisting"],
300
+ };
301
+ if (hookProfileMap[envKey]) {
302
+ const pathParts = hookProfileMap[envKey];
303
+ const propSchema = getSchemaProperty(schema, pathParts);
304
+ if (propSchema) return buildConfigPath(pathParts);
305
+ }
306
+ const rootKey = toCamelCaseFromEnv(envKey);
307
+ if (schema.properties[rootKey]) return buildConfigPath([rootKey]);
308
+ if (ROOT_PREFIX_ALLOWLIST.some((prefix) => envKey.startsWith(prefix))) {
309
+ return buildConfigPath([rootKey], true);
310
+ }
311
+ if (envKey.startsWith("KANBAN_") && schema.properties.kanban?.properties) {
312
+ const rest = envKey.slice("KANBAN_".length);
313
+ const sub = toCamelCaseFromEnv(rest);
314
+ if (schema.properties.kanban.properties[sub]) return buildConfigPath(["kanban", sub]);
315
+ }
316
+ if (envKey.startsWith("JIRA_STATUS_")) {
317
+ const jiraSchema = schema.properties.kanban?.properties?.jira?.properties?.statusMapping?.properties;
318
+ const rest = envKey.slice("JIRA_STATUS_".length);
319
+ const sub = toCamelCaseFromEnv(rest);
320
+ if (jiraSchema?.[sub]) return buildConfigPath(["kanban", "jira", "statusMapping", sub]);
321
+ }
322
+ if (envKey.startsWith("JIRA_LABEL_")) {
323
+ const jiraSchema = schema.properties.kanban?.properties?.jira?.properties?.labels?.properties;
324
+ const rest = envKey.slice("JIRA_LABEL_".length);
325
+ const sub = toCamelCaseFromEnv(rest);
326
+ if (jiraSchema?.[sub]) return buildConfigPath(["kanban", "jira", "labels", sub]);
327
+ }
328
+ if (envKey.startsWith("JIRA_")) {
329
+ const jiraSchema = schema.properties.kanban?.properties?.jira?.properties;
330
+ const rest = envKey.slice("JIRA_".length);
331
+ const sub = toCamelCaseFromEnv(rest);
332
+ if (jiraSchema?.[sub]) return buildConfigPath(["kanban", "jira", sub]);
333
+ }
334
+ if (envKey.startsWith("GITHUB_PROJECT_")) {
335
+ const projectSchema = schema.properties.kanban?.properties?.github?.properties?.project?.properties;
336
+ const rest = envKey.slice("GITHUB_PROJECT_".length);
337
+ const sub = toCamelCaseFromEnv(rest);
338
+ if (projectSchema?.[sub]) return buildConfigPath(["kanban", "github", "project", sub]);
339
+ }
340
+ if (envKey.startsWith("GITHUB_PROJECT_WEBHOOK_")) {
341
+ const webhookSchema = schema.properties.kanban?.properties?.github?.properties?.project?.properties?.webhook?.properties;
342
+ const rest = envKey.slice("GITHUB_PROJECT_WEBHOOK_".length);
343
+ const sub = toCamelCaseFromEnv(rest);
344
+ if (webhookSchema?.[sub]) return buildConfigPath(["kanban", "github", "project", "webhook", sub]);
345
+ }
346
+ if (envKey.startsWith("GITHUB_PROJECT_SYNC_")) {
347
+ const syncSchema = schema.properties.kanban?.properties?.github?.properties?.project?.properties?.syncMonitoring?.properties;
348
+ const rest = envKey.slice("GITHUB_PROJECT_SYNC_".length);
349
+ const sub = toCamelCaseFromEnv(rest);
350
+ if (syncSchema?.[sub]) return buildConfigPath(["kanban", "github", "project", "syncMonitoring", sub]);
351
+ }
352
+ return null;
353
+ }
354
+
355
+ function setConfigPathValue(obj, pathParts, value) {
356
+ let cursor = obj;
357
+ for (let i = 0; i < pathParts.length; i += 1) {
358
+ const part = pathParts[i];
359
+ if (i === pathParts.length - 1) {
360
+ cursor[part] = value;
361
+ return;
362
+ }
363
+ if (!cursor[part] || typeof cursor[part] !== "object") cursor[part] = {};
364
+ cursor = cursor[part];
365
+ }
366
+ }
367
+
368
+ function unsetConfigPathValue(obj, pathParts) {
369
+ let cursor = obj;
370
+ const stack = [];
371
+ for (let i = 0; i < pathParts.length; i += 1) {
372
+ const part = pathParts[i];
373
+ if (!cursor || typeof cursor !== "object") return;
374
+ if (i === pathParts.length - 1) {
375
+ delete cursor[part];
376
+ break;
377
+ }
378
+ stack.push({ parent: cursor, key: part });
379
+ cursor = cursor[part];
380
+ }
381
+ for (let i = stack.length - 1; i >= 0; i -= 1) {
382
+ const { parent, key } = stack[i];
383
+ if (
384
+ parent[key] &&
385
+ typeof parent[key] === "object" &&
386
+ Object.keys(parent[key]).length === 0
387
+ ) {
388
+ delete parent[key];
389
+ } else {
390
+ break;
391
+ }
392
+ }
393
+ }
394
+
395
+ // Read port lazily — .env may not be loaded at module import time
396
+ function getDefaultPort() {
397
+ return Number(process.env.TELEGRAM_UI_PORT || "0") || 0;
398
+ }
399
+ const DEFAULT_HOST = process.env.TELEGRAM_UI_HOST || "0.0.0.0";
400
+ // Lazy evaluation — .env may not be loaded yet when this module is first imported
401
+ function isAllowUnsafe() {
402
+ return ["1", "true", "yes"].includes(
403
+ String(process.env.TELEGRAM_UI_ALLOW_UNSAFE || "").toLowerCase(),
404
+ );
405
+ }
406
+ const AUTH_MAX_AGE_SEC = Number(
407
+ process.env.TELEGRAM_UI_AUTH_MAX_AGE_SEC || "86400",
408
+ );
409
+ const PRESENCE_TTL_MS =
410
+ Number(process.env.TELEGRAM_PRESENCE_TTL_SEC || "180") * 1000;
411
+
412
+ const MIME_TYPES = {
413
+ ".html": "text/html; charset=utf-8",
414
+ ".css": "text/css; charset=utf-8",
415
+ ".js": "application/javascript; charset=utf-8",
416
+ ".mjs": "application/javascript; charset=utf-8",
417
+ ".json": "application/json; charset=utf-8",
418
+ ".svg": "image/svg+xml",
419
+ ".ico": "image/x-icon",
420
+ };
421
+
422
+ let uiServer = null;
423
+ let uiServerUrl = null;
424
+ let uiServerTls = false;
425
+ let wsServer = null;
426
+ const wsClients = new Set();
427
+ /** @type {ReturnType<typeof setInterval>|null} */
428
+ let wsHeartbeatTimer = null;
429
+
430
+ /* ─── Log Streaming State ─── */
431
+ /** Map<string, { sockets: Set<WebSocket>, offset: number, pollTimer }> keyed by filePath */
432
+ const logStreamers = new Map();
433
+ let uiDeps = {};
434
+ const projectSyncWebhookMetrics = {
435
+ received: 0,
436
+ processed: 0,
437
+ ignored: 0,
438
+ failed: 0,
439
+ invalidSignature: 0,
440
+ syncTriggered: 0,
441
+ syncSuccess: 0,
442
+ syncFailure: 0,
443
+ rateLimitObserved: 0,
444
+ alertsTriggered: 0,
445
+ consecutiveFailures: 0,
446
+ lastEventAt: null,
447
+ lastSuccessAt: null,
448
+ lastFailureAt: null,
449
+ lastError: null,
450
+ };
451
+
452
+ // ── Settings API: Known env keys from settings schema ──
453
+ const SETTINGS_KNOWN_KEYS = [
454
+ "TELEGRAM_BOT_TOKEN", "TELEGRAM_CHAT_ID", "TELEGRAM_ALLOWED_CHAT_IDS",
455
+ "TELEGRAM_INTERVAL_MIN", "TELEGRAM_COMMAND_POLL_TIMEOUT_SEC", "TELEGRAM_AGENT_TIMEOUT_MIN",
456
+ "TELEGRAM_COMMAND_CONCURRENCY", "TELEGRAM_VERBOSITY", "TELEGRAM_BATCH_NOTIFICATIONS",
457
+ "TELEGRAM_BATCH_INTERVAL_SEC", "TELEGRAM_BATCH_MAX_SIZE", "TELEGRAM_IMMEDIATE_PRIORITY",
458
+ "TELEGRAM_API_BASE_URL", "TELEGRAM_HTTP_TIMEOUT_MS", "TELEGRAM_RETRY_ATTEMPTS",
459
+ "PROJECT_NAME", "TELEGRAM_MINIAPP_ENABLED", "TELEGRAM_UI_PORT", "TELEGRAM_UI_HOST",
460
+ "TELEGRAM_UI_PUBLIC_HOST", "TELEGRAM_UI_BASE_URL", "TELEGRAM_UI_ALLOW_UNSAFE",
461
+ "TELEGRAM_UI_AUTH_MAX_AGE_SEC", "TELEGRAM_UI_TUNNEL",
462
+ "EXECUTOR_MODE", "INTERNAL_EXECUTOR_PARALLEL", "INTERNAL_EXECUTOR_SDK",
463
+ "INTERNAL_EXECUTOR_TIMEOUT_MS", "INTERNAL_EXECUTOR_MAX_RETRIES", "INTERNAL_EXECUTOR_POLL_MS",
464
+ "INTERNAL_EXECUTOR_REVIEW_AGENT_ENABLED", "INTERNAL_EXECUTOR_REPLENISH_ENABLED",
465
+ "PRIMARY_AGENT", "EXECUTORS", "EXECUTOR_DISTRIBUTION", "FAILOVER_STRATEGY",
466
+ "COMPLEXITY_ROUTING_ENABLED", "PROJECT_REQUIREMENTS_PROFILE",
467
+ "OPENAI_API_KEY", "AZURE_OPENAI_API_KEY", "CODEX_MODEL",
468
+ "CODEX_MODEL_PROFILE", "CODEX_MODEL_PROFILE_SUBAGENT",
469
+ "CODEX_MODEL_PROFILE_XL_PROVIDER", "CODEX_MODEL_PROFILE_XL_MODEL", "CODEX_MODEL_PROFILE_XL_BASE_URL", "CODEX_MODEL_PROFILE_XL_API_KEY",
470
+ "CODEX_MODEL_PROFILE_M_PROVIDER", "CODEX_MODEL_PROFILE_M_MODEL", "CODEX_MODEL_PROFILE_M_BASE_URL", "CODEX_MODEL_PROFILE_M_API_KEY",
471
+ "CODEX_SUBAGENT_MODEL", "ANTHROPIC_API_KEY", "CLAUDE_MODEL",
472
+ "COPILOT_MODEL", "COPILOT_CLI_TOKEN",
473
+ "KANBAN_BACKEND", "KANBAN_SYNC_POLICY", "CODEX_MONITOR_TASK_LABEL",
474
+ "CODEX_MONITOR_ENFORCE_TASK_LABEL", "STALE_TASK_AGE_HOURS",
475
+ "TASK_PLANNER_MODE", "TASK_PLANNER_DEDUP_HOURS",
476
+ "CODEX_MONITOR_PROMPT_PLANNER",
477
+ "GITHUB_TOKEN", "GITHUB_REPOSITORY", "GITHUB_PROJECT_MODE",
478
+ "GITHUB_PROJECT_NUMBER", "GITHUB_DEFAULT_ASSIGNEE", "GITHUB_AUTO_ASSIGN_CREATOR",
479
+ "GITHUB_PROJECT_WEBHOOK_PATH", "GITHUB_PROJECT_WEBHOOK_SECRET", "GITHUB_PROJECT_WEBHOOK_REQUIRE_SIGNATURE",
480
+ "GITHUB_PROJECT_SYNC_ALERT_FAILURE_THRESHOLD", "GITHUB_PROJECT_SYNC_RATE_LIMIT_ALERT_THRESHOLD",
481
+ "VK_TARGET_BRANCH", "CODEX_ANALYZE_MERGE_STRATEGY", "DEPENDABOT_AUTO_MERGE",
482
+ "GH_RECONCILE_ENABLED",
483
+ "CLOUDFLARE_TUNNEL_NAME", "CLOUDFLARE_TUNNEL_CREDENTIALS",
484
+ "TELEGRAM_PRESENCE_INTERVAL_SEC", "TELEGRAM_PRESENCE_DISABLED",
485
+ "VE_INSTANCE_LABEL", "VE_COORDINATOR_ELIGIBLE", "VE_COORDINATOR_PRIORITY",
486
+ "FLEET_ENABLED", "FLEET_BUFFER_MULTIPLIER", "FLEET_SYNC_INTERVAL_MS",
487
+ "FLEET_PRESENCE_TTL_MS", "FLEET_KNOWLEDGE_ENABLED", "FLEET_KNOWLEDGE_FILE",
488
+ "CODEX_SANDBOX", "CODEX_FEATURES_BWRAP", "CODEX_SANDBOX_PERMISSIONS", "CODEX_SANDBOX_WRITABLE_ROOTS",
489
+ "CONTAINER_ENABLED", "CONTAINER_RUNTIME", "CONTAINER_IMAGE",
490
+ "CONTAINER_TIMEOUT_MS", "MAX_CONCURRENT_CONTAINERS", "CONTAINER_MEMORY_LIMIT", "CONTAINER_CPU_LIMIT",
491
+ "CODEX_MONITOR_SENTINEL_AUTO_START", "SENTINEL_AUTO_RESTART_MONITOR",
492
+ "SENTINEL_CRASH_LOOP_THRESHOLD", "SENTINEL_CRASH_LOOP_WINDOW_MIN",
493
+ "SENTINEL_REPAIR_AGENT_ENABLED", "SENTINEL_REPAIR_TIMEOUT_MIN",
494
+ "CODEX_MONITOR_HOOK_PROFILE", "CODEX_MONITOR_HOOK_TARGETS",
495
+ "CODEX_MONITOR_HOOKS_ENABLED", "CODEX_MONITOR_HOOKS_OVERWRITE",
496
+ "CODEX_MONITOR_HOOKS_BUILTINS_MODE",
497
+ "AGENT_WORK_LOGGING_ENABLED", "AGENT_WORK_ANALYZER_ENABLED",
498
+ "AGENT_SESSION_LOG_RETENTION", "AGENT_ERROR_LOOP_THRESHOLD",
499
+ "AGENT_STUCK_THRESHOLD_MS", "LOG_MAX_SIZE_MB",
500
+ "DEVMODE", "SELF_RESTART_WATCH_ENABLED", "MAX_PARALLEL",
501
+ "RESTART_DELAY_MS", "SHARED_STATE_ENABLED", "SHARED_STATE_STALE_THRESHOLD_MS",
502
+ "VE_CI_SWEEP_EVERY",
503
+ ];
504
+
505
+ const SETTINGS_SENSITIVE_KEYS = new Set([
506
+ "TELEGRAM_BOT_TOKEN", "TELEGRAM_CHAT_ID", "GITHUB_TOKEN",
507
+ "OPENAI_API_KEY", "AZURE_OPENAI_API_KEY", "CODEX_MODEL_PROFILE_XL_API_KEY", "CODEX_MODEL_PROFILE_M_API_KEY",
508
+ "ANTHROPIC_API_KEY", "COPILOT_CLI_TOKEN", "GITHUB_PROJECT_WEBHOOK_SECRET",
509
+ "CLOUDFLARE_TUNNEL_CREDENTIALS",
510
+ ]);
511
+
512
+ const SETTINGS_KNOWN_SET = new Set(SETTINGS_KNOWN_KEYS);
513
+ let _settingsLastUpdateTime = 0;
514
+
515
+ function updateEnvFile(changes) {
516
+ const envPath = resolve(__dirname, '.env');
517
+ let content = '';
518
+ try { content = readFileSync(envPath, 'utf8'); } catch { content = ''; }
519
+
520
+ const lines = content.split('\n');
521
+ const updated = new Set();
522
+
523
+ for (const [key, value] of Object.entries(changes)) {
524
+ const pattern = new RegExp(`^(#\\s*)?${key.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\s*=`);
525
+ let found = false;
526
+ for (let i = 0; i < lines.length; i++) {
527
+ if (pattern.test(lines[i])) {
528
+ lines[i] = `${key}=${value}`;
529
+ found = true;
530
+ updated.add(key);
531
+ break;
532
+ }
533
+ }
534
+ if (!found) {
535
+ lines.push(`${key}=${value}`);
536
+ updated.add(key);
537
+ }
538
+ }
539
+
540
+ writeFileSync(envPath, lines.join('\n'), 'utf8');
541
+ return Array.from(updated);
542
+ }
543
+
544
+ function updateConfigFile(changes) {
545
+ const schema = getConfigSchema();
546
+ const configPath = resolveConfigPath();
547
+ if (!schema) return { updated: [], path: configPath };
548
+ let configData = { $schema: "./openfleet.schema.json" };
549
+ if (existsSync(configPath)) {
550
+ try {
551
+ const raw = readFileSync(configPath, "utf8");
552
+ configData = JSON.parse(raw);
553
+ } catch {
554
+ configData = { $schema: "./openfleet.schema.json" };
555
+ }
556
+ }
557
+
558
+ const updated = new Set();
559
+ for (const [key, value] of Object.entries(changes)) {
560
+ const pathInfo = mapEnvKeyToConfigPath(key, schema);
561
+ if (!pathInfo) continue;
562
+ const { pathParts, allowUnknownSchema } = pathInfo;
563
+ const propSchema = getSchemaProperty(schema, pathParts);
564
+ if (!propSchema && !allowUnknownSchema) continue;
565
+ if (isUnsetValue(value)) {
566
+ unsetConfigPathValue(configData, pathParts);
567
+ updated.add(key);
568
+ continue;
569
+ }
570
+ const def = SETTINGS_SCHEMA.find((s) => s.key === key);
571
+ const coerced = coerceSettingValue(def, value, propSchema);
572
+ setConfigPathValue(configData, pathParts, coerced);
573
+ updated.add(key);
574
+ }
575
+
576
+ if (updated.size === 0) {
577
+ return { updated: [], path: configPath };
578
+ }
579
+
580
+ if (!configData.$schema) {
581
+ configData.$schema = "./openfleet.schema.json";
582
+ }
583
+ writeFileSync(configPath, JSON.stringify(configData, null, 2) + "\n", "utf8");
584
+ return { updated: Array.from(updated), path: configPath };
585
+ }
586
+
587
+ function validateConfigSchemaChanges(changes) {
588
+ try {
589
+ const schema = getConfigSchema();
590
+ const validator = getConfigValidator();
591
+ if (!schema || !validator) return {};
592
+
593
+ const configPath = resolveConfigPath();
594
+ let configData = {};
595
+ if (existsSync(configPath)) {
596
+ try {
597
+ const raw = readFileSync(configPath, "utf8");
598
+ configData = JSON.parse(raw);
599
+ } catch {
600
+ configData = {};
601
+ }
602
+ }
603
+
604
+ const candidate = JSON.parse(JSON.stringify(configData || {}));
605
+ const pathMap = new Map();
606
+ for (const [key, value] of Object.entries(changes)) {
607
+ const pathInfo = mapEnvKeyToConfigPath(key, schema);
608
+ if (!pathInfo) continue;
609
+ const { pathParts, allowUnknownSchema } = pathInfo;
610
+ const propSchema = getSchemaProperty(schema, pathParts);
611
+ if (!propSchema && !allowUnknownSchema) continue;
612
+ if (isUnsetValue(value)) {
613
+ unsetConfigPathValue(candidate, pathParts);
614
+ pathMap.set(pathParts.join("."), key);
615
+ continue;
616
+ }
617
+ const def = SETTINGS_SCHEMA.find((s) => s.key === key);
618
+ const coerced = coerceSettingValue(def, value, propSchema);
619
+ setConfigPathValue(candidate, pathParts, coerced);
620
+ pathMap.set(pathParts.join("."), key);
621
+ }
622
+
623
+ if (pathMap.size === 0) return {};
624
+ const valid = validator(candidate);
625
+ if (valid) return {};
626
+
627
+ const fieldErrors = {};
628
+ const errors = validator.errors || [];
629
+ for (const err of errors) {
630
+ let path = String(err.instancePath || "").replace(/^\//, "");
631
+ if (!path && err.params?.missingProperty) {
632
+ const missing = String(err.params.missingProperty);
633
+ path = path ? `${path}/${missing}` : missing;
634
+ }
635
+ if (!path && err.params?.additionalProperty) {
636
+ const extra = String(err.params.additionalProperty);
637
+ path = path ? `${path}/${extra}` : extra;
638
+ }
639
+ if (!path) continue;
640
+ const parts = path.split("/").filter(Boolean);
641
+ let envKey = pathMap.get(parts.join("."));
642
+ if (!envKey) {
643
+ for (let i = parts.length; i > 0; i -= 1) {
644
+ const candidatePath = parts.slice(0, i).join(".");
645
+ if (pathMap.has(candidatePath)) {
646
+ envKey = pathMap.get(candidatePath);
647
+ break;
648
+ }
649
+ }
650
+ }
651
+ if (envKey && !fieldErrors[envKey]) {
652
+ fieldErrors[envKey] = err.message || "Invalid value";
653
+ }
654
+ }
655
+ if (Object.keys(fieldErrors).length === 0) {
656
+ for (const envKey of pathMap.values()) {
657
+ fieldErrors[envKey] = "Invalid value (config schema)";
658
+ }
659
+ }
660
+ return fieldErrors;
661
+ } catch {
662
+ return {};
663
+ }
664
+ }
665
+
666
+ // ── Simple rate limiter for mutation endpoints ──
667
+ const _rateLimitMap = new Map();
668
+ function checkRateLimit(req, maxPerMin = 30) {
669
+ const key = req.headers["x-telegram-initdata"] || req.socket?.remoteAddress || "unknown";
670
+ const now = Date.now();
671
+ let bucket = _rateLimitMap.get(key);
672
+ if (!bucket || now - bucket.windowStart > 60000) {
673
+ bucket = { windowStart: now, count: 0 };
674
+ _rateLimitMap.set(key, bucket);
675
+ }
676
+ bucket.count++;
677
+ if (bucket.count > maxPerMin) return false;
678
+ return true;
679
+ }
680
+ // Cleanup stale entries every 5 minutes
681
+ setInterval(() => {
682
+ const now = Date.now();
683
+ for (const [k, v] of _rateLimitMap) {
684
+ if (now - v.windowStart > 120000) _rateLimitMap.delete(k);
685
+ }
686
+ }, 300000).unref();
687
+
688
+ // ── Session token (auto-generated per startup for browser access) ────
689
+ let sessionToken = "";
690
+
691
+ /** Return the current session token (for logging the browser URL). */
692
+ export function getSessionToken() {
693
+ return sessionToken;
694
+ }
695
+
696
+ // ── Auto-TLS self-signed certificate generation ──────────────────────
697
+ const TLS_CACHE_DIR = resolve(__dirname, ".cache", "tls");
698
+ const TLS_CERT_PATH = resolve(TLS_CACHE_DIR, "server.crt");
699
+ const TLS_KEY_PATH = resolve(TLS_CACHE_DIR, "server.key");
700
+ function isTlsDisabled() {
701
+ return ["1", "true", "yes"].includes(
702
+ String(process.env.TELEGRAM_UI_TLS_DISABLE || "").toLowerCase(),
703
+ );
704
+ }
705
+
706
+ /**
707
+ * Ensures a self-signed TLS certificate exists in .cache/tls/.
708
+ * Generates one via openssl if missing or expired (valid for 825 days).
709
+ * Returns { key, cert } buffers or null if generation fails.
710
+ */
711
+ function ensureSelfSignedCert() {
712
+ try {
713
+ if (!existsSync(TLS_CACHE_DIR)) {
714
+ mkdirSync(TLS_CACHE_DIR, { recursive: true });
715
+ }
716
+
717
+ // Reuse existing cert if still valid
718
+ if (existsSync(TLS_CERT_PATH) && existsSync(TLS_KEY_PATH)) {
719
+ try {
720
+ const certPem = readFileSync(TLS_CERT_PATH, "utf8");
721
+ const cert = new X509Certificate(certPem);
722
+ const notAfter = new Date(cert.validTo);
723
+ if (notAfter > new Date(Date.now() + 7 * 24 * 60 * 60 * 1000)) {
724
+ return {
725
+ key: readFileSync(TLS_KEY_PATH),
726
+ cert: readFileSync(TLS_CERT_PATH),
727
+ };
728
+ }
729
+ } catch {
730
+ // Cert parse failed or expired — regenerate
731
+ }
732
+ }
733
+
734
+ // Generate self-signed cert via openssl
735
+ const lanIp = getLocalLanIp();
736
+ const subjectAltName = `DNS:localhost,IP:127.0.0.1,IP:${lanIp}`;
737
+ execSync(
738
+ `openssl req -x509 -newkey ec -pkeyopt ec_paramgen_curve:prime256v1 ` +
739
+ `-keyout "${TLS_KEY_PATH}" -out "${TLS_CERT_PATH}" ` +
740
+ `-days 825 -nodes -batch ` +
741
+ `-subj "/CN=openfleet" ` +
742
+ `-addext "subjectAltName=${subjectAltName}"`,
743
+ { stdio: "pipe", timeout: 10_000 },
744
+ );
745
+
746
+ console.log(
747
+ `[telegram-ui] auto-generated self-signed TLS cert (SAN: ${subjectAltName})`,
748
+ );
749
+ return {
750
+ key: readFileSync(TLS_KEY_PATH),
751
+ cert: readFileSync(TLS_CERT_PATH),
752
+ };
753
+ } catch (err) {
754
+ console.warn(
755
+ `[telegram-ui] TLS cert generation failed, falling back to HTTP: ${err.message}`,
756
+ );
757
+ return null;
758
+ }
759
+ }
760
+
761
+ // ── Firewall detection and management ────────────────────────────────
762
+
763
+ /** Detected firewall state — populated by checkFirewall() */
764
+ let firewallState = null;
765
+
766
+ /** Return the last firewall check result (or null). */
767
+ export function getFirewallState() {
768
+ return firewallState;
769
+ }
770
+
771
+ /**
772
+ * Detect the active firewall and check if a given TCP port is allowed.
773
+ * Uses a TCP self-connect probe as the ground truth, then identifies the
774
+ * firewall for the fix command.
775
+ * Returns { firewall, blocked, allowCmd, status } or null if no firewall.
776
+ */
777
+ async function checkFirewall(port) {
778
+ const lanIp = getLocalLanIp();
779
+ if (!lanIp) return null;
780
+
781
+ // Ground truth: try connecting to ourselves on the LAN IP
782
+ const reachable = await new Promise((resolve) => {
783
+ const sock = netConnect({ host: lanIp, port, timeout: 3000 });
784
+ sock.once("connect", () => { sock.destroy(); resolve(true); });
785
+ sock.once("error", () => { sock.destroy(); resolve(false); });
786
+ sock.once("timeout", () => { sock.destroy(); resolve(false); });
787
+ });
788
+
789
+ // Detect which firewall is active (for the fix command)
790
+ const fwInfo = detectFirewallType(port);
791
+
792
+ if (reachable) {
793
+ return fwInfo
794
+ ? { ...fwInfo, blocked: false, status: "allowed" }
795
+ : null;
796
+ }
797
+
798
+ // Port is not reachable — report as blocked
799
+ return fwInfo
800
+ ? { ...fwInfo, blocked: true, status: "blocked" }
801
+ : { firewall: "unknown", blocked: true, allowCmd: `# Check your firewall settings for port ${port}/tcp`, status: "blocked" };
802
+ }
803
+
804
+ /**
805
+ * Identify the active firewall and build the fix command (without needing root).
806
+ */
807
+ function detectFirewallType(port) {
808
+ const platform = process.platform;
809
+ try {
810
+ if (platform === "linux") {
811
+ // Check ufw
812
+ try {
813
+ const active = execSync("systemctl is-active ufw 2>/dev/null", { encoding: "utf8", timeout: 3000 }).trim();
814
+ if (active === "active") {
815
+ return {
816
+ firewall: "ufw",
817
+ allowCmd: `sudo ufw allow ${port}/tcp comment "openfleet UI"`,
818
+ };
819
+ }
820
+ } catch { /* not active */ }
821
+
822
+ // Check firewalld
823
+ try {
824
+ const active = execSync("systemctl is-active firewalld 2>/dev/null", { encoding: "utf8", timeout: 3000 }).trim();
825
+ if (active === "active") {
826
+ return {
827
+ firewall: "firewalld",
828
+ allowCmd: `sudo firewall-cmd --add-port=${port}/tcp --permanent && sudo firewall-cmd --reload`,
829
+ };
830
+ }
831
+ } catch { /* not active */ }
832
+
833
+ // Fallback: iptables
834
+ return {
835
+ firewall: "iptables",
836
+ allowCmd: `sudo iptables -I INPUT -p tcp --dport ${port} -j ACCEPT`,
837
+ };
838
+ }
839
+
840
+ if (platform === "win32") {
841
+ return {
842
+ firewall: "windows",
843
+ allowCmd: `netsh advfirewall firewall add rule name="openfleet UI" dir=in action=allow protocol=tcp localport=${port}`,
844
+ };
845
+ }
846
+
847
+ if (platform === "darwin") {
848
+ return {
849
+ firewall: "pf",
850
+ allowCmd: `echo 'pass in proto tcp from any to any port ${port}' | sudo pfctl -ef -`,
851
+ };
852
+ }
853
+ } catch { /* detection failed */ }
854
+ return null;
855
+ }
856
+
857
+ /**
858
+ * Attempt to open a firewall port. Uses pkexec for GUI prompt, falls back to sudo.
859
+ * Returns { success, message }.
860
+ */
861
+ export async function openFirewallPort(port) {
862
+ const state = firewallState || await checkFirewall(port);
863
+ if (!state || !state.blocked) {
864
+ return { success: true, message: "Port already allowed or no firewall detected." };
865
+ }
866
+
867
+ const { firewall, allowCmd } = state;
868
+
869
+ // Try pkexec first (GUI sudo prompt — works on Linux desktop)
870
+ if (process.platform === "linux") {
871
+ // Build the actual command for pkexec (it doesn't support shell pipelines)
872
+ let pkexecCmd;
873
+ if (firewall === "ufw") {
874
+ pkexecCmd = `pkexec ufw allow ${port}/tcp comment "openfleet UI"`;
875
+ } else if (firewall === "firewalld") {
876
+ pkexecCmd = `pkexec bash -c 'firewall-cmd --add-port=${port}/tcp --permanent && firewall-cmd --reload'`;
877
+ } else {
878
+ pkexecCmd = `pkexec iptables -I INPUT -p tcp --dport ${port} -j ACCEPT`;
879
+ }
880
+
881
+ try {
882
+ execSync(pkexecCmd, { encoding: "utf8", timeout: 60000, stdio: "pipe" });
883
+ // Re-check after opening
884
+ firewallState = await checkFirewall(port);
885
+ return { success: true, message: `Firewall rule added via ${firewall}.` };
886
+ } catch (err) {
887
+ // pkexec failed (user dismissed, not available, etc.)
888
+ return {
889
+ success: false,
890
+ message: `Could not auto-open port. Run manually:\n\`${allowCmd}\``,
891
+ };
892
+ }
893
+ }
894
+
895
+ if (process.platform === "win32") {
896
+ try {
897
+ execSync(allowCmd, { encoding: "utf8", timeout: 30000, stdio: "pipe" });
898
+ firewallState = await checkFirewall(port);
899
+ return { success: true, message: "Windows firewall rule added." };
900
+ } catch {
901
+ return {
902
+ success: false,
903
+ message: `Could not auto-open port. Run as admin:\n\`${allowCmd}\``,
904
+ };
905
+ }
906
+ }
907
+
908
+ return {
909
+ success: false,
910
+ message: `Run manually:\n\`${allowCmd}\``,
911
+ };
912
+ }
913
+
914
+ // ── Cloudflared tunnel for trusted TLS ──────────────────────────────
915
+
916
+ let tunnelUrl = null;
917
+ let tunnelProcess = null;
918
+
919
+ /** Return the tunnel URL (e.g. https://xxx.trycloudflare.com) or null. */
920
+ export function getTunnelUrl() {
921
+ return tunnelUrl;
922
+ }
923
+
924
+ // ── Cloudflared binary auto-download ─────────────────────────────────
925
+
926
+ const CF_CACHE_DIR = resolve(__dirname, ".cache", "bin");
927
+ const CF_BIN_NAME = osPlatform() === "win32" ? "cloudflared.exe" : "cloudflared";
928
+ const CF_CACHED_PATH = resolve(CF_CACHE_DIR, CF_BIN_NAME);
929
+
930
+ /**
931
+ * Get the cloudflared download URL for the current platform+arch.
932
+ * Uses GitHub releases (no account needed).
933
+ */
934
+ function getCloudflaredDownloadUrl() {
935
+ const plat = osPlatform();
936
+ const ar = osArch();
937
+ const base = "https://github.com/cloudflare/cloudflared/releases/latest/download";
938
+ if (plat === "linux") {
939
+ if (ar === "arm64" || ar === "aarch64") return `${base}/cloudflared-linux-arm64`;
940
+ return `${base}/cloudflared-linux-amd64`;
941
+ }
942
+ if (plat === "win32") {
943
+ return `${base}/cloudflared-windows-amd64.exe`;
944
+ }
945
+ if (plat === "darwin") {
946
+ if (ar === "arm64") return `${base}/cloudflared-darwin-arm64.tgz`;
947
+ return `${base}/cloudflared-darwin-amd64.tgz`;
948
+ }
949
+ return null;
950
+ }
951
+
952
+ /**
953
+ * Download a file from URL, following redirects (GitHub releases use 302).
954
+ * Returns a promise that resolves when the file is fully written and closed.
955
+ */
956
+ function downloadFile(url, destPath, maxRedirects = 5) {
957
+ return new Promise((res, rej) => {
958
+ if (maxRedirects <= 0) return rej(new Error("Too many redirects"));
959
+ httpsGet(url, (response) => {
960
+ if (response.statusCode >= 300 && response.statusCode < 400 && response.headers.location) {
961
+ response.resume();
962
+ return downloadFile(response.headers.location, destPath, maxRedirects - 1).then(res, rej);
963
+ }
964
+ if (response.statusCode !== 200) {
965
+ response.resume();
966
+ return rej(new Error(`HTTP ${response.statusCode}`));
967
+ }
968
+ const stream = createWriteStream(destPath);
969
+ response.pipe(stream);
970
+ // Wait for 'close' not 'finish' — ensures file descriptor is fully released
971
+ stream.on("close", () => res());
972
+ stream.on("error", (err) => {
973
+ stream.close();
974
+ rej(err);
975
+ });
976
+ }).on("error", rej);
977
+ });
978
+ }
979
+
980
+ /**
981
+ * Find cloudflared binary — checks system PATH first, then cached download.
982
+ * If not found anywhere and mode=auto, auto-downloads to .cache/bin/.
983
+ */
984
+ async function findCloudflared() {
985
+ // 1. Check system PATH
986
+ try {
987
+ const cmd = osPlatform() === "win32"
988
+ ? "where cloudflared 2>nul"
989
+ : "which cloudflared 2>/dev/null";
990
+ const found = execSync(cmd, { encoding: "utf8", timeout: 5000, stdio: ["pipe", "pipe", "pipe"] }).trim();
991
+ if (found) return found.split(/\r?\n/)[0]; // `where` may return multiple lines
992
+ } catch { /* not on PATH */ }
993
+
994
+ // 2. Check cached binary
995
+ if (existsSync(CF_CACHED_PATH)) {
996
+ return CF_CACHED_PATH;
997
+ }
998
+
999
+ // 3. Auto-download
1000
+ const dlUrl = getCloudflaredDownloadUrl();
1001
+ if (!dlUrl) {
1002
+ console.warn("[telegram-ui] cloudflared: unsupported platform/arch for auto-download");
1003
+ return null;
1004
+ }
1005
+
1006
+ console.log("[telegram-ui] cloudflared not found — auto-downloading...");
1007
+ try {
1008
+ mkdirSync(CF_CACHE_DIR, { recursive: true });
1009
+ await downloadFile(dlUrl, CF_CACHED_PATH);
1010
+ if (osPlatform() !== "win32") {
1011
+ chmodSync(CF_CACHED_PATH, 0o755);
1012
+ // Small delay to ensure OS fully releases file locks after chmod
1013
+ await new Promise((r) => setTimeout(r, 100));
1014
+ }
1015
+ console.log(`[telegram-ui] cloudflared downloaded to ${CF_CACHED_PATH}`);
1016
+ return CF_CACHED_PATH;
1017
+ } catch (err) {
1018
+ console.warn(`[telegram-ui] cloudflared auto-download failed: ${err.message}`);
1019
+ return null;
1020
+ }
1021
+ }
1022
+
1023
+ /**
1024
+ * Start a cloudflared tunnel for the given local URL.
1025
+ *
1026
+ * Two modes:
1027
+ * 1. **Quick tunnel** (default): Free, no account, random *.trycloudflare.com domain.
1028
+ * Pros: Zero setup. Cons: URL changes on each restart.
1029
+ * 2. **Named tunnel**: Persistent custom domain (e.g., myapp.example.com).
1030
+ * Pros: Stable URL, custom domain. Cons: Requires cloudflare account + tunnel setup.
1031
+ *
1032
+ * Named tunnel setup:
1033
+ * 1. Create a tunnel: `cloudflared tunnel create <name>`
1034
+ * 2. Create DNS record: `cloudflared tunnel route dns <name> <subdomain.yourdomain.com>`
1035
+ * 3. Set env vars:
1036
+ * - CLOUDFLARE_TUNNEL_NAME=<name>
1037
+ * - CLOUDFLARE_TUNNEL_CREDENTIALS=/path/to/<tunnel-id>.json
1038
+ *
1039
+ * Returns the assigned public URL or null on failure.
1040
+ */
1041
+ async function startTunnel(localPort) {
1042
+ const tunnelMode = (process.env.TELEGRAM_UI_TUNNEL || "auto").toLowerCase();
1043
+ if (tunnelMode === "disabled" || tunnelMode === "off" || tunnelMode === "0") {
1044
+ console.log("[telegram-ui] tunnel disabled via TELEGRAM_UI_TUNNEL=disabled");
1045
+ return null;
1046
+ }
1047
+
1048
+ const cfBin = await findCloudflared();
1049
+ if (!cfBin) {
1050
+ if (tunnelMode === "auto") {
1051
+ console.log(
1052
+ "[telegram-ui] cloudflared unavailable — Telegram Mini App will use self-signed cert (may be rejected by Telegram webview).",
1053
+ );
1054
+ return null;
1055
+ }
1056
+ console.warn("[telegram-ui] cloudflared not found but TELEGRAM_UI_TUNNEL=cloudflared requested");
1057
+ return null;
1058
+ }
1059
+
1060
+ // Check for named tunnel configuration (persistent URL)
1061
+ const namedTunnel = process.env.CLOUDFLARE_TUNNEL_NAME || process.env.CF_TUNNEL_NAME;
1062
+ const tunnelCreds = process.env.CLOUDFLARE_TUNNEL_CREDENTIALS || process.env.CF_TUNNEL_CREDENTIALS;
1063
+
1064
+ if (namedTunnel && tunnelCreds) {
1065
+ return startNamedTunnel(cfBin, namedTunnel, tunnelCreds, localPort);
1066
+ }
1067
+
1068
+ // Fall back to quick tunnel (random URL, no persistence)
1069
+ return startQuickTunnel(cfBin, localPort);
1070
+ }
1071
+
1072
+ /**
1073
+ * Spawn cloudflared with ETXTBSY retry (race condition after fresh download).
1074
+ * Returns the child process or throws after max retries.
1075
+ */
1076
+ function spawnCloudflared(cfBin, args, maxRetries = 3) {
1077
+ for (let attempt = 1; attempt <= maxRetries; attempt++) {
1078
+ try {
1079
+ return spawn(cfBin, args, {
1080
+ stdio: ["ignore", "pipe", "pipe"],
1081
+ detached: false,
1082
+ });
1083
+ } catch (err) {
1084
+ if (err.code === "ETXTBSY" && attempt < maxRetries) {
1085
+ // File still locked from download — wait and retry
1086
+ const delayMs = attempt * 100;
1087
+ console.warn(`[telegram-ui] spawn ETXTBSY (attempt ${attempt}/${maxRetries}) — retrying in ${delayMs}ms`);
1088
+ // Sync sleep (rare case, acceptable here)
1089
+ execSync(`sleep 0.${delayMs / 100}`, { stdio: "ignore" });
1090
+ continue;
1091
+ }
1092
+ throw err;
1093
+ }
1094
+ }
1095
+ throw new Error("spawn failed after retries");
1096
+ }
1097
+
1098
+ /**
1099
+ * Start a cloudflared **named tunnel** with persistent URL.
1100
+ * Requires: cloudflared tunnel create + DNS setup.
1101
+ */
1102
+ async function startNamedTunnel(cfBin, tunnelName, credentialsPath, localPort) {
1103
+ if (!existsSync(credentialsPath)) {
1104
+ console.warn(`[telegram-ui] named tunnel credentials not found: ${credentialsPath}`);
1105
+ console.warn("[telegram-ui] falling back to quick tunnel (random URL)");
1106
+ return startQuickTunnel(cfBin, localPort);
1107
+ }
1108
+
1109
+ // Named tunnels require config file with ingress rules.
1110
+ // We'll create a temporary config on the fly.
1111
+ const configPath = resolve(__dirname, ".cache", "cloudflared-config.yml");
1112
+ mkdirSync(dirname(configPath), { recursive: true });
1113
+
1114
+ const configYaml = `
1115
+ tunnel: ${tunnelName}
1116
+ credentials-file: ${credentialsPath}
1117
+
1118
+ ingress:
1119
+ - hostname: "*"
1120
+ service: https://localhost:${localPort}
1121
+ originRequest:
1122
+ noTLSVerify: true
1123
+ - service: http_status:404
1124
+ `.trim();
1125
+
1126
+ writeFileSync(configPath, configYaml, "utf8");
1127
+
1128
+ // Read the tunnel ID from credentials to construct the public URL
1129
+ let publicUrl = null;
1130
+ try {
1131
+ const creds = JSON.parse(readFileSync(credentialsPath, "utf8"));
1132
+ const tunnelId = creds.TunnelID || creds.tunnel_id;
1133
+ if (tunnelId) {
1134
+ publicUrl = `https://${tunnelId}.cfargotunnel.com`;
1135
+ }
1136
+ } catch (err) {
1137
+ console.warn(`[telegram-ui] failed to parse tunnel credentials: ${err.message}`);
1138
+ }
1139
+
1140
+ return new Promise((resolvePromise) => {
1141
+ const args = ["tunnel", "--config", configPath, "run"];
1142
+ console.log(`[telegram-ui] starting named tunnel: ${tunnelName} → https://localhost:${localPort}`);
1143
+
1144
+ let child;
1145
+ try {
1146
+ child = spawnCloudflared(cfBin, args);
1147
+ } catch (err) {
1148
+ console.warn(`[telegram-ui] named tunnel spawn failed: ${err.message}`);
1149
+ return resolvePromise(null);
1150
+ }
1151
+
1152
+ let resolved = false;
1153
+ let output = "";
1154
+ // Named tunnels emit "Connection <UUID> registered" when ready
1155
+ const readyPattern = /Connection [a-f0-9-]+ registered/;
1156
+ const timeout = setTimeout(() => {
1157
+ if (!resolved) {
1158
+ resolved = true;
1159
+ console.warn("[telegram-ui] named tunnel timed out after 60s");
1160
+ resolvePromise(null);
1161
+ }
1162
+ }, 60_000);
1163
+
1164
+ function parseOutput(chunk) {
1165
+ output += chunk;
1166
+ if (readyPattern.test(output) && !resolved) {
1167
+ resolved = true;
1168
+ clearTimeout(timeout);
1169
+ tunnelUrl = publicUrl;
1170
+ tunnelProcess = child;
1171
+ console.log(`[telegram-ui] named tunnel active: ${publicUrl || tunnelName}`);
1172
+ resolvePromise(publicUrl);
1173
+ }
1174
+ }
1175
+
1176
+ child.stdout.on("data", (d) => parseOutput(d.toString()));
1177
+ child.stderr.on("data", (d) => parseOutput(d.toString()));
1178
+
1179
+ child.on("error", (err) => {
1180
+ if (!resolved) {
1181
+ resolved = true;
1182
+ clearTimeout(timeout);
1183
+ console.warn(`[telegram-ui] named tunnel failed: ${err.message}`);
1184
+ resolvePromise(null);
1185
+ }
1186
+ });
1187
+
1188
+ child.on("exit", (code) => {
1189
+ tunnelProcess = null;
1190
+ tunnelUrl = null;
1191
+ if (!resolved) {
1192
+ resolved = true;
1193
+ clearTimeout(timeout);
1194
+ console.warn(`[telegram-ui] named tunnel exited with code ${code}`);
1195
+ resolvePromise(null);
1196
+ } else if (code !== 0 && code !== null) {
1197
+ console.warn(`[telegram-ui] named tunnel exited (code ${code})`);
1198
+ }
1199
+ });
1200
+ });
1201
+ }
1202
+
1203
+ /**
1204
+ * Start a cloudflared **quick tunnel** (random *.trycloudflare.com URL).
1205
+ * Quick tunnels are free, require no account, but the URL changes on each restart.
1206
+ */
1207
+ async function startQuickTunnel(cfBin, localPort) {
1208
+ return new Promise((resolvePromise) => {
1209
+ const localUrl = `https://localhost:${localPort}`;
1210
+ const args = ["tunnel", "--url", localUrl, "--no-autoupdate", "--no-tls-verify"];
1211
+ console.log(`[telegram-ui] starting quick tunnel → ${localUrl}`);
1212
+
1213
+ let child;
1214
+ try {
1215
+ child = spawnCloudflared(cfBin, args);
1216
+ } catch (err) {
1217
+ console.warn(`[telegram-ui] quick tunnel spawn failed: ${err.message}`);
1218
+ return resolvePromise(null);
1219
+ }
1220
+
1221
+ let resolved = false;
1222
+ let output = "";
1223
+ const urlPattern = /https:\/\/[a-z0-9-]+\.trycloudflare\.com/;
1224
+ const timeout = setTimeout(() => {
1225
+ if (!resolved) {
1226
+ resolved = true;
1227
+ console.warn("[telegram-ui] quick tunnel timed out after 30s");
1228
+ resolvePromise(null);
1229
+ }
1230
+ }, 30_000);
1231
+
1232
+ function parseOutput(chunk) {
1233
+ output += chunk;
1234
+ const match = output.match(urlPattern);
1235
+ if (match && !resolved) {
1236
+ resolved = true;
1237
+ clearTimeout(timeout);
1238
+ tunnelUrl = match[0];
1239
+ tunnelProcess = child;
1240
+ console.log(`[telegram-ui] quick tunnel active: ${tunnelUrl}`);
1241
+ resolvePromise(tunnelUrl);
1242
+ }
1243
+ }
1244
+
1245
+ child.stdout.on("data", (d) => parseOutput(d.toString()));
1246
+ child.stderr.on("data", (d) => parseOutput(d.toString()));
1247
+
1248
+ child.on("error", (err) => {
1249
+ if (!resolved) {
1250
+ resolved = true;
1251
+ clearTimeout(timeout);
1252
+ console.warn(`[telegram-ui] quick tunnel failed: ${err.message}`);
1253
+ resolvePromise(null);
1254
+ }
1255
+ });
1256
+
1257
+ child.on("exit", (code) => {
1258
+ tunnelProcess = null;
1259
+ tunnelUrl = null;
1260
+ if (!resolved) {
1261
+ resolved = true;
1262
+ clearTimeout(timeout);
1263
+ console.warn(`[telegram-ui] quick tunnel exited with code ${code}`);
1264
+ resolvePromise(null);
1265
+ } else if (code !== 0 && code !== null) {
1266
+ console.warn(`[telegram-ui] quick tunnel exited (code ${code})`);
1267
+ }
1268
+ });
1269
+ });
1270
+ }
1271
+
1272
+ /** Stop the tunnel if running. */
1273
+ export function stopTunnel() {
1274
+ if (tunnelProcess) {
1275
+ try {
1276
+ tunnelProcess.kill("SIGTERM");
1277
+ } catch { /* ignore */ }
1278
+ tunnelProcess = null;
1279
+ tunnelUrl = null;
1280
+ }
1281
+ }
1282
+
1283
+ export function injectUiDependencies(deps = {}) {
1284
+ uiDeps = { ...uiDeps, ...deps };
1285
+ }
1286
+
1287
+ export function getTelegramUiUrl() {
1288
+ const explicit =
1289
+ process.env.TELEGRAM_UI_BASE_URL || process.env.TELEGRAM_WEBAPP_URL;
1290
+ if (explicit) {
1291
+ // Auto-upgrade explicit HTTP URL to HTTPS when the server is running TLS
1292
+ if (uiServerTls && explicit.startsWith("http://")) {
1293
+ let upgraded = explicit.replace(/^http:\/\//, "https://");
1294
+ // Ensure the port is present (the explicit URL may omit it)
1295
+ try {
1296
+ const parsed = new URL(upgraded);
1297
+ if (!parsed.port && uiServer) {
1298
+ const actualPort = uiServer.address()?.port;
1299
+ if (actualPort) parsed.port = String(actualPort);
1300
+ upgraded = parsed.href;
1301
+ }
1302
+ } catch {
1303
+ // URL parse failed — use as-is
1304
+ }
1305
+ return upgraded.replace(/\/+$/, "");
1306
+ }
1307
+ return explicit.replace(/\/+$/, "");
1308
+ }
1309
+ return uiServerUrl;
1310
+ }
1311
+
1312
+ function jsonResponse(res, statusCode, payload) {
1313
+ const body = JSON.stringify(payload, null, 2);
1314
+ res.writeHead(statusCode, {
1315
+ "Content-Type": "application/json; charset=utf-8",
1316
+ "Access-Control-Allow-Origin": "*",
1317
+ });
1318
+ res.end(body);
1319
+ }
1320
+
1321
+ function textResponse(res, statusCode, body, contentType = "text/plain") {
1322
+ res.writeHead(statusCode, {
1323
+ "Content-Type": `${contentType}; charset=utf-8`,
1324
+ "Access-Control-Allow-Origin": "*",
1325
+ });
1326
+ res.end(body);
1327
+ }
1328
+
1329
+ function parseInitData(initData) {
1330
+ const params = new URLSearchParams(initData);
1331
+ const data = {};
1332
+ for (const [key, value] of params.entries()) {
1333
+ data[key] = value;
1334
+ }
1335
+ return data;
1336
+ }
1337
+
1338
+ function validateInitData(initData, botToken) {
1339
+ if (!initData || !botToken) return false;
1340
+ const params = new URLSearchParams(initData);
1341
+ const hash = params.get("hash");
1342
+ if (!hash) return false;
1343
+ params.delete("hash");
1344
+ const entries = Array.from(params.entries()).sort(([a], [b]) =>
1345
+ a.localeCompare(b),
1346
+ );
1347
+ const dataCheckString = entries.map(([k, v]) => `${k}=${v}`).join("\n");
1348
+ const secret = createHmac("sha256", "WebAppData").update(botToken).digest();
1349
+ const signature = createHmac("sha256", secret)
1350
+ .update(dataCheckString)
1351
+ .digest("hex");
1352
+ if (signature !== hash) return false;
1353
+ const authDate = Number(params.get("auth_date") || 0);
1354
+ if (Number.isFinite(authDate) && authDate > 0 && AUTH_MAX_AGE_SEC > 0) {
1355
+ const ageSec = Math.max(0, Math.floor(Date.now() / 1000) - authDate);
1356
+ if (ageSec > AUTH_MAX_AGE_SEC) return false;
1357
+ }
1358
+ return true;
1359
+ }
1360
+
1361
+ function parseCookie(req, name) {
1362
+ const header = req.headers.cookie || "";
1363
+ for (const part of header.split(";")) {
1364
+ const [k, ...rest] = part.split("=");
1365
+ if (k.trim() === name) return rest.join("=").trim();
1366
+ }
1367
+ return "";
1368
+ }
1369
+
1370
+ function checkSessionToken(req) {
1371
+ if (!sessionToken) return false;
1372
+ // Bearer header
1373
+ const authHeader = req.headers.authorization || "";
1374
+ if (authHeader.startsWith("Bearer ")) {
1375
+ const provided = Buffer.from(authHeader.slice(7));
1376
+ const expected = Buffer.from(sessionToken);
1377
+ if (provided.length === expected.length && timingSafeEqual(provided, expected)) {
1378
+ return true;
1379
+ }
1380
+ }
1381
+ // Cookie
1382
+ const cookieVal = parseCookie(req, "ve_session");
1383
+ if (cookieVal) {
1384
+ const provided = Buffer.from(cookieVal);
1385
+ const expected = Buffer.from(sessionToken);
1386
+ if (provided.length === expected.length && timingSafeEqual(provided, expected)) return true;
1387
+ }
1388
+ return false;
1389
+ }
1390
+
1391
+ function requireAuth(req) {
1392
+ if (isAllowUnsafe()) return true;
1393
+ // Session token (browser access)
1394
+ if (checkSessionToken(req)) return true;
1395
+ // Telegram initData HMAC
1396
+ const initData =
1397
+ req.headers["x-telegram-initdata"] ||
1398
+ req.headers["x-telegram-init-data"] ||
1399
+ req.headers["x-telegram-init"] ||
1400
+ req.headers["x-telegram-webapp"] ||
1401
+ req.headers["x-telegram-webapp-data"] ||
1402
+ "";
1403
+ const token = process.env.TELEGRAM_BOT_TOKEN || "";
1404
+ if (!initData) return false;
1405
+ return validateInitData(String(initData), token);
1406
+ }
1407
+
1408
+ function requireWsAuth(req, url) {
1409
+ if (isAllowUnsafe()) return true;
1410
+ // Session token (query param or cookie)
1411
+ if (checkSessionToken(req)) return true;
1412
+ if (sessionToken) {
1413
+ const qTokenVal = url.searchParams.get("token") || "";
1414
+ if (qTokenVal) {
1415
+ const provided = Buffer.from(qTokenVal);
1416
+ const expected = Buffer.from(sessionToken);
1417
+ if (provided.length === expected.length && timingSafeEqual(provided, expected)) return true;
1418
+ }
1419
+ }
1420
+ // Telegram initData HMAC
1421
+ const initData =
1422
+ req.headers["x-telegram-initdata"] ||
1423
+ req.headers["x-telegram-init-data"] ||
1424
+ req.headers["x-telegram-init"] ||
1425
+ url.searchParams.get("initData") ||
1426
+ "";
1427
+ const token = process.env.TELEGRAM_BOT_TOKEN || "";
1428
+ if (!initData) return false;
1429
+ return validateInitData(String(initData), token);
1430
+ }
1431
+
1432
+ function sendWsMessage(socket, payload) {
1433
+ try {
1434
+ if (socket?.readyState === 1) {
1435
+ socket.send(JSON.stringify(payload));
1436
+ }
1437
+ } catch {
1438
+ // best effort
1439
+ }
1440
+ }
1441
+
1442
+ function broadcastUiEvent(channels, type, payload = {}) {
1443
+ const required = new Set(Array.isArray(channels) ? channels : [channels]);
1444
+ const message = {
1445
+ type,
1446
+ channels: Array.from(required),
1447
+ payload,
1448
+ ts: Date.now(),
1449
+ };
1450
+ for (const socket of wsClients) {
1451
+ const subscribed = socket.__channels || new Set(["*"]);
1452
+ const shouldSend =
1453
+ subscribed.has("*") ||
1454
+ Array.from(required).some((channel) => subscribed.has(channel));
1455
+ if (shouldSend) {
1456
+ sendWsMessage(socket, message);
1457
+ }
1458
+ }
1459
+ }
1460
+
1461
+ /* ─── Log Streaming Helpers ─── */
1462
+
1463
+ /**
1464
+ * Resolve the log file path for a given logType and optional query.
1465
+ * Returns null if no matching file found.
1466
+ */
1467
+ async function resolveLogPath(logType, query) {
1468
+ if (logType === "system") {
1469
+ const files = await readdir(logsDir).catch(() => []);
1470
+ const logFile = files.filter((f) => f.endsWith(".log")).sort().pop();
1471
+ return logFile ? resolve(logsDir, logFile) : null;
1472
+ }
1473
+ if (logType === "agent") {
1474
+ const files = await readdir(agentLogsDir).catch(() => []);
1475
+ let candidates = files.filter((f) => f.endsWith(".log")).sort().reverse();
1476
+ if (query) {
1477
+ const q = query.toLowerCase();
1478
+ const filtered = candidates.filter((f) => f.toLowerCase().includes(q));
1479
+ if (filtered.length) candidates = filtered;
1480
+ }
1481
+ return candidates.length ? resolve(agentLogsDir, candidates[0]) : null;
1482
+ }
1483
+ return null;
1484
+ }
1485
+
1486
+ /**
1487
+ * Start streaming a log file to a socket. Uses polling (every 2s) to detect
1488
+ * new content. Handles file rotation and missing files gracefully.
1489
+ */
1490
+ function startLogStream(socket, logType, query) {
1491
+ // Clean up any previous stream for this socket
1492
+ stopLogStream(socket);
1493
+
1494
+ const streamState = { logType, query, filePath: null, offset: 0, pollTimer: null, active: true };
1495
+ socket.__logStream = streamState;
1496
+
1497
+ async function poll() {
1498
+ if (!streamState.active) return;
1499
+ try {
1500
+ const filePath = await resolveLogPath(logType, query);
1501
+ if (!filePath || !existsSync(filePath)) return;
1502
+
1503
+ // Detect file rotation (path changed or file shrank)
1504
+ const info = await stat(filePath).catch(() => null);
1505
+ if (!info) return;
1506
+ const size = info.size || 0;
1507
+
1508
+ if (filePath !== streamState.filePath) {
1509
+ // New file or first poll — start from end to avoid dumping history
1510
+ streamState.filePath = filePath;
1511
+ streamState.offset = size;
1512
+ return;
1513
+ }
1514
+
1515
+ if (size < streamState.offset) {
1516
+ // File was truncated/rotated — reset
1517
+ streamState.offset = 0;
1518
+ }
1519
+
1520
+ if (size <= streamState.offset) return;
1521
+
1522
+ // Read only new bytes
1523
+ const readLen = Math.min(size - streamState.offset, 512_000);
1524
+ const handle = await open(filePath, "r");
1525
+ try {
1526
+ const buffer = Buffer.alloc(readLen);
1527
+ await handle.read(buffer, 0, readLen, streamState.offset);
1528
+ streamState.offset += readLen;
1529
+ const text = buffer.toString("utf8");
1530
+ const lines = text.split("\n").filter(Boolean);
1531
+ if (lines.length > 0) {
1532
+ sendWsMessage(socket, { type: "log-lines", lines });
1533
+ }
1534
+ } finally {
1535
+ await handle.close();
1536
+ }
1537
+ } catch {
1538
+ // Ignore transient errors — next poll will retry
1539
+ }
1540
+ }
1541
+
1542
+ // First poll immediately, then every 2 seconds
1543
+ poll();
1544
+ streamState.pollTimer = setInterval(poll, 2000);
1545
+ }
1546
+
1547
+ /**
1548
+ * Stop streaming logs for a given socket.
1549
+ */
1550
+ function stopLogStream(socket) {
1551
+ const stream = socket.__logStream;
1552
+ if (stream) {
1553
+ stream.active = false;
1554
+ if (stream.pollTimer) clearInterval(stream.pollTimer);
1555
+ socket.__logStream = null;
1556
+ }
1557
+ }
1558
+
1559
+ /* ─── Server-side Heartbeat ─── */
1560
+
1561
+ function startWsHeartbeat() {
1562
+ if (wsHeartbeatTimer) clearInterval(wsHeartbeatTimer);
1563
+ wsHeartbeatTimer = setInterval(() => {
1564
+ const now = Date.now();
1565
+ for (const socket of wsClients) {
1566
+ // Check for missed pongs (2 consecutive pings = 60s)
1567
+ if (socket.__lastPing && !socket.__lastPong) {
1568
+ socket.__missedPongs = (socket.__missedPongs || 0) + 1;
1569
+ } else if (socket.__lastPing && socket.__lastPong && socket.__lastPong < socket.__lastPing) {
1570
+ socket.__missedPongs = (socket.__missedPongs || 0) + 1;
1571
+ } else {
1572
+ socket.__missedPongs = 0;
1573
+ }
1574
+
1575
+ if ((socket.__missedPongs || 0) >= 2) {
1576
+ try { socket.close(); } catch { /* noop */ }
1577
+ wsClients.delete(socket);
1578
+ stopLogStream(socket);
1579
+ continue;
1580
+ }
1581
+
1582
+ // Send ping
1583
+ socket.__lastPing = now;
1584
+ sendWsMessage(socket, { type: "ping", ts: now });
1585
+ }
1586
+ }, 30_000);
1587
+ }
1588
+
1589
+ function stopWsHeartbeat() {
1590
+ if (wsHeartbeatTimer) {
1591
+ clearInterval(wsHeartbeatTimer);
1592
+ wsHeartbeatTimer = null;
1593
+ }
1594
+ }
1595
+
1596
+ function parseBooleanEnv(value, fallback = false) {
1597
+ if (value === undefined || value === null || value === "") return fallback;
1598
+ const raw = String(value).trim().toLowerCase();
1599
+ if (["1", "true", "yes", "on"].includes(raw)) return true;
1600
+ if (["0", "false", "no", "off"].includes(raw)) return false;
1601
+ return fallback;
1602
+ }
1603
+
1604
+ function getGitHubWebhookPath() {
1605
+ return (
1606
+ process.env.GITHUB_PROJECT_WEBHOOK_PATH ||
1607
+ "/api/webhooks/github/project-sync"
1608
+ );
1609
+ }
1610
+
1611
+ function getGitHubWebhookSecret() {
1612
+ return (
1613
+ process.env.GITHUB_PROJECT_WEBHOOK_SECRET ||
1614
+ process.env.GITHUB_WEBHOOK_SECRET ||
1615
+ ""
1616
+ );
1617
+ }
1618
+
1619
+ function shouldRequireGitHubWebhookSignature() {
1620
+ const secret = getGitHubWebhookSecret();
1621
+ return parseBooleanEnv(
1622
+ process.env.GITHUB_PROJECT_WEBHOOK_REQUIRE_SIGNATURE,
1623
+ Boolean(secret),
1624
+ );
1625
+ }
1626
+
1627
+ function getWebhookFailureAlertThreshold() {
1628
+ return Math.max(
1629
+ 1,
1630
+ Number(process.env.GITHUB_PROJECT_SYNC_ALERT_FAILURE_THRESHOLD || 3),
1631
+ );
1632
+ }
1633
+
1634
+ async function emitProjectSyncAlert(message, context = {}) {
1635
+ projectSyncWebhookMetrics.alertsTriggered++;
1636
+ console.warn(
1637
+ `[project-sync-webhook] alert: ${message} ${JSON.stringify(context)}`,
1638
+ );
1639
+ if (typeof uiDeps.onProjectSyncAlert === "function") {
1640
+ try {
1641
+ await uiDeps.onProjectSyncAlert({
1642
+ message,
1643
+ context,
1644
+ timestamp: new Date().toISOString(),
1645
+ });
1646
+ } catch {
1647
+ // best effort
1648
+ }
1649
+ }
1650
+ }
1651
+
1652
+ function verifyGitHubWebhookSignature(rawBody, signatureHeader, secret) {
1653
+ if (!secret) return false;
1654
+ const expectedDigest = createHmac("sha256", secret)
1655
+ .update(rawBody)
1656
+ .digest("hex");
1657
+ const providedRaw = String(signatureHeader || "");
1658
+ if (!providedRaw.startsWith("sha256=")) return false;
1659
+ const providedDigest = providedRaw.slice("sha256=".length).trim();
1660
+ if (!providedDigest || providedDigest.length !== expectedDigest.length) {
1661
+ return false;
1662
+ }
1663
+ const expected = Buffer.from(expectedDigest, "utf8");
1664
+ const provided = Buffer.from(providedDigest, "utf8");
1665
+ return timingSafeEqual(expected, provided);
1666
+ }
1667
+
1668
+ function extractIssueNumberFromWebhook(payload) {
1669
+ const item = payload?.projects_v2_item || {};
1670
+ const content = item.content || payload?.content || {};
1671
+ const candidates = [
1672
+ item.content_number,
1673
+ item.issue_number,
1674
+ content.number,
1675
+ content.issue?.number,
1676
+ payload?.issue?.number,
1677
+ ];
1678
+ for (const candidate of candidates) {
1679
+ const numeric = Number(candidate);
1680
+ if (Number.isInteger(numeric) && numeric > 0) {
1681
+ return String(numeric);
1682
+ }
1683
+ }
1684
+ const urlCandidates = [
1685
+ item.content_url,
1686
+ item.url,
1687
+ content.url,
1688
+ payload?.issue?.html_url,
1689
+ payload?.issue?.url,
1690
+ ];
1691
+ for (const value of urlCandidates) {
1692
+ const match = String(value || "").match(/\/issues\/(\d+)(?:$|[/?#])/);
1693
+ if (match) return match[1];
1694
+ }
1695
+ return null;
1696
+ }
1697
+
1698
+ export function getProjectSyncWebhookMetrics() {
1699
+ return { ...projectSyncWebhookMetrics };
1700
+ }
1701
+
1702
+ export function resetProjectSyncWebhookMetrics() {
1703
+ for (const key of Object.keys(projectSyncWebhookMetrics)) {
1704
+ if (
1705
+ key === "lastEventAt" ||
1706
+ key === "lastSuccessAt" ||
1707
+ key === "lastFailureAt" ||
1708
+ key === "lastError"
1709
+ ) {
1710
+ projectSyncWebhookMetrics[key] = null;
1711
+ continue;
1712
+ }
1713
+ projectSyncWebhookMetrics[key] = 0;
1714
+ }
1715
+ }
1716
+
1717
+ async function readRawBody(req) {
1718
+ return new Promise((resolveBody, rejectBody) => {
1719
+ const chunks = [];
1720
+ let size = 0;
1721
+ req.on("data", (chunk) => {
1722
+ const buf = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
1723
+ chunks.push(buf);
1724
+ size += buf.length;
1725
+ if (size > 1_000_000) {
1726
+ rejectBody(new Error("payload too large"));
1727
+ req.destroy();
1728
+ }
1729
+ });
1730
+ req.on("end", () => {
1731
+ resolveBody(Buffer.concat(chunks).toString("utf8"));
1732
+ });
1733
+ req.on("error", rejectBody);
1734
+ });
1735
+ }
1736
+
1737
+ async function handleGitHubProjectWebhook(req, res) {
1738
+ if (req.method === "OPTIONS") {
1739
+ res.writeHead(204, {
1740
+ "Access-Control-Allow-Origin": "*",
1741
+ "Access-Control-Allow-Methods": "POST,OPTIONS",
1742
+ "Access-Control-Allow-Headers":
1743
+ "Content-Type,X-GitHub-Event,X-Hub-Signature-256,X-GitHub-Delivery",
1744
+ });
1745
+ res.end();
1746
+ return;
1747
+ }
1748
+ if (req.method !== "POST") {
1749
+ jsonResponse(res, 405, { ok: false, error: "Method not allowed" });
1750
+ return;
1751
+ }
1752
+
1753
+ projectSyncWebhookMetrics.received++;
1754
+ projectSyncWebhookMetrics.lastEventAt = new Date().toISOString();
1755
+
1756
+ const deliveryId = String(req.headers["x-github-delivery"] || "");
1757
+ const eventType = String(req.headers["x-github-event"] || "").toLowerCase();
1758
+ const secret = getGitHubWebhookSecret();
1759
+ const requireSignature = shouldRequireGitHubWebhookSignature();
1760
+
1761
+ try {
1762
+ const rawBody = await readRawBody(req);
1763
+ if (requireSignature) {
1764
+ const signature = req.headers["x-hub-signature-256"];
1765
+ if (
1766
+ !verifyGitHubWebhookSignature(rawBody, signature, secret)
1767
+ ) {
1768
+ projectSyncWebhookMetrics.invalidSignature++;
1769
+ projectSyncWebhookMetrics.failed++;
1770
+ projectSyncWebhookMetrics.consecutiveFailures++;
1771
+ projectSyncWebhookMetrics.lastFailureAt = new Date().toISOString();
1772
+ projectSyncWebhookMetrics.lastError = "invalid webhook signature";
1773
+ const threshold = getWebhookFailureAlertThreshold();
1774
+ if (
1775
+ projectSyncWebhookMetrics.consecutiveFailures % threshold ===
1776
+ 0
1777
+ ) {
1778
+ await emitProjectSyncAlert(
1779
+ `GitHub project webhook signature failures: ${projectSyncWebhookMetrics.consecutiveFailures}`,
1780
+ { deliveryId, eventType },
1781
+ );
1782
+ }
1783
+ jsonResponse(res, 401, { ok: false, error: "Invalid webhook signature" });
1784
+ return;
1785
+ }
1786
+ }
1787
+
1788
+ let payload = {};
1789
+ try {
1790
+ payload = rawBody ? JSON.parse(rawBody) : {};
1791
+ } catch {
1792
+ projectSyncWebhookMetrics.failed++;
1793
+ projectSyncWebhookMetrics.consecutiveFailures++;
1794
+ projectSyncWebhookMetrics.lastFailureAt = new Date().toISOString();
1795
+ projectSyncWebhookMetrics.lastError = "invalid JSON payload";
1796
+ jsonResponse(res, 400, { ok: false, error: "Invalid JSON payload" });
1797
+ return;
1798
+ }
1799
+
1800
+ if (eventType !== "projects_v2_item") {
1801
+ projectSyncWebhookMetrics.ignored++;
1802
+ projectSyncWebhookMetrics.processed++;
1803
+ jsonResponse(res, 202, {
1804
+ ok: true,
1805
+ ignored: true,
1806
+ reason: `Unsupported event: ${eventType || "unknown"}`,
1807
+ });
1808
+ return;
1809
+ }
1810
+
1811
+ const syncEngine = uiDeps.getSyncEngine?.() || null;
1812
+ if (!syncEngine) {
1813
+ projectSyncWebhookMetrics.failed++;
1814
+ projectSyncWebhookMetrics.consecutiveFailures++;
1815
+ projectSyncWebhookMetrics.lastFailureAt = new Date().toISOString();
1816
+ projectSyncWebhookMetrics.lastError = "sync engine unavailable";
1817
+ const threshold = getWebhookFailureAlertThreshold();
1818
+ if (
1819
+ projectSyncWebhookMetrics.consecutiveFailures % threshold ===
1820
+ 0
1821
+ ) {
1822
+ await emitProjectSyncAlert(
1823
+ `GitHub project webhook sync failures: ${projectSyncWebhookMetrics.consecutiveFailures}`,
1824
+ { deliveryId, reason: "sync engine unavailable" },
1825
+ );
1826
+ }
1827
+ jsonResponse(res, 503, { ok: false, error: "Sync engine unavailable" });
1828
+ return;
1829
+ }
1830
+
1831
+ const beforeRateLimitEvents =
1832
+ Number(syncEngine.getStatus?.()?.metrics?.rateLimitEvents || 0);
1833
+ const issueNumber = extractIssueNumberFromWebhook(payload);
1834
+ const action = String(payload?.action || "");
1835
+
1836
+ projectSyncWebhookMetrics.syncTriggered++;
1837
+ if (issueNumber && typeof syncEngine.syncTask === "function") {
1838
+ await syncEngine.syncTask(issueNumber);
1839
+ console.log(
1840
+ `[project-sync-webhook] delivery=${deliveryId} action=${action} task=${issueNumber} synced`,
1841
+ );
1842
+ } else if (typeof syncEngine.fullSync === "function") {
1843
+ await syncEngine.fullSync();
1844
+ console.log(
1845
+ `[project-sync-webhook] delivery=${deliveryId} action=${action} full-sync triggered`,
1846
+ );
1847
+ } else {
1848
+ throw new Error("sync engine does not expose syncTask/fullSync");
1849
+ }
1850
+
1851
+ const afterRateLimitEvents =
1852
+ Number(syncEngine.getStatus?.()?.metrics?.rateLimitEvents || 0);
1853
+ if (afterRateLimitEvents > beforeRateLimitEvents) {
1854
+ projectSyncWebhookMetrics.rateLimitObserved +=
1855
+ afterRateLimitEvents - beforeRateLimitEvents;
1856
+ }
1857
+ projectSyncWebhookMetrics.processed++;
1858
+ projectSyncWebhookMetrics.syncSuccess++;
1859
+ projectSyncWebhookMetrics.consecutiveFailures = 0;
1860
+ projectSyncWebhookMetrics.lastSuccessAt = new Date().toISOString();
1861
+ projectSyncWebhookMetrics.lastError = null;
1862
+ jsonResponse(res, 202, {
1863
+ ok: true,
1864
+ deliveryId,
1865
+ eventType,
1866
+ action,
1867
+ issueNumber,
1868
+ synced: true,
1869
+ });
1870
+ } catch (err) {
1871
+ projectSyncWebhookMetrics.failed++;
1872
+ projectSyncWebhookMetrics.syncFailure++;
1873
+ projectSyncWebhookMetrics.consecutiveFailures++;
1874
+ projectSyncWebhookMetrics.lastFailureAt = new Date().toISOString();
1875
+ projectSyncWebhookMetrics.lastError = err.message;
1876
+ const threshold = getWebhookFailureAlertThreshold();
1877
+ if (
1878
+ projectSyncWebhookMetrics.consecutiveFailures % threshold === 0
1879
+ ) {
1880
+ await emitProjectSyncAlert(
1881
+ `GitHub project webhook sync failures: ${projectSyncWebhookMetrics.consecutiveFailures}`,
1882
+ { deliveryId, eventType, error: err.message },
1883
+ );
1884
+ }
1885
+ console.warn(
1886
+ `[project-sync-webhook] delivery=${deliveryId} failed: ${err.message}`,
1887
+ );
1888
+ jsonResponse(res, 500, { ok: false, error: err.message });
1889
+ }
1890
+ }
1891
+
1892
+ async function readStatusSnapshot() {
1893
+ try {
1894
+ const raw = await readFile(statusPath, "utf8");
1895
+ return JSON.parse(raw);
1896
+ } catch {
1897
+ return null;
1898
+ }
1899
+ }
1900
+
1901
+ function runGit(args, timeoutMs = 10000) {
1902
+ return execSync(`git ${args}`, {
1903
+ cwd: repoRoot,
1904
+ encoding: "utf8",
1905
+ timeout: timeoutMs,
1906
+ }).trim();
1907
+ }
1908
+
1909
+ async function readJsonBody(req) {
1910
+ return new Promise((resolveBody, rejectBody) => {
1911
+ let data = "";
1912
+ req.on("data", (chunk) => {
1913
+ data += chunk;
1914
+ if (data.length > 1_000_000) {
1915
+ rejectBody(new Error("payload too large"));
1916
+ req.destroy();
1917
+ }
1918
+ });
1919
+ req.on("end", () => {
1920
+ if (!data) return resolveBody(null);
1921
+ try {
1922
+ resolveBody(JSON.parse(data));
1923
+ } catch (err) {
1924
+ rejectBody(err);
1925
+ }
1926
+ });
1927
+ });
1928
+ }
1929
+
1930
+ function normalizeTagsInput(input) {
1931
+ if (!input) return [];
1932
+ const values = Array.isArray(input)
1933
+ ? input
1934
+ : String(input || "")
1935
+ .split(",")
1936
+ .map((entry) => entry.trim())
1937
+ .filter(Boolean);
1938
+ const seen = new Set();
1939
+ const tags = [];
1940
+ for (const value of values) {
1941
+ const normalized = String(value || "").trim().toLowerCase();
1942
+ if (!normalized || seen.has(normalized)) continue;
1943
+ seen.add(normalized);
1944
+ tags.push(normalized);
1945
+ }
1946
+ return tags;
1947
+ }
1948
+
1949
+ function normalizeBranchInput(input) {
1950
+ const trimmed = String(input ?? "").trim();
1951
+ return trimmed ? trimmed : null;
1952
+ }
1953
+
1954
+ async function getLatestLogTail(lineCount) {
1955
+ const files = await readdir(logsDir).catch(() => []);
1956
+ const logFile = files
1957
+ .filter((f) => f.endsWith(".log"))
1958
+ .sort()
1959
+ .pop();
1960
+ if (!logFile) return { file: null, lines: [] };
1961
+ const logPath = resolve(logsDir, logFile);
1962
+ const content = await readFile(logPath, "utf8");
1963
+ const lines = content.split("\n").filter(Boolean);
1964
+ const tail = lines.slice(-lineCount);
1965
+ return { file: logFile, lines: tail };
1966
+ }
1967
+
1968
+ async function tailFile(filePath, lineCount, maxBytes = 1_000_000) {
1969
+ const info = await stat(filePath);
1970
+ const size = info.size || 0;
1971
+ const start = Math.max(0, size - maxBytes);
1972
+ const length = Math.max(0, size - start);
1973
+ const handle = await open(filePath, "r");
1974
+ const buffer = Buffer.alloc(length);
1975
+ try {
1976
+ if (length > 0) {
1977
+ await handle.read(buffer, 0, length, start);
1978
+ }
1979
+ } finally {
1980
+ await handle.close();
1981
+ }
1982
+ const text = buffer.toString("utf8");
1983
+ const lines = text.split("\n").filter(Boolean);
1984
+ const tail = lines.slice(-lineCount);
1985
+ return {
1986
+ file: filePath,
1987
+ lines: tail,
1988
+ size,
1989
+ truncated: size > maxBytes,
1990
+ };
1991
+ }
1992
+
1993
+ async function listAgentLogFiles(query = "", limit = 60) {
1994
+ const entries = [];
1995
+ const files = await readdir(agentLogsDir).catch(() => []);
1996
+ for (const name of files) {
1997
+ if (!name.endsWith(".log")) continue;
1998
+ if (query && !name.toLowerCase().includes(query.toLowerCase())) continue;
1999
+ try {
2000
+ const info = await stat(resolve(agentLogsDir, name));
2001
+ entries.push({
2002
+ name,
2003
+ size: info.size,
2004
+ mtime:
2005
+ info.mtime?.toISOString?.() || new Date(info.mtime).toISOString(),
2006
+ mtimeMs: info.mtimeMs,
2007
+ });
2008
+ } catch {
2009
+ // ignore
2010
+ }
2011
+ }
2012
+ entries.sort((a, b) => b.mtimeMs - a.mtimeMs);
2013
+ return entries.slice(0, limit);
2014
+ }
2015
+
2016
+ async function ensurePresenceLoaded() {
2017
+ const loaded = await loadWorkspaceRegistry().catch(() => null);
2018
+ const registry = loaded?.registry || loaded || null;
2019
+ const localWorkspace = registry
2020
+ ? getLocalWorkspace(registry, process.env.VE_WORKSPACE_ID || "")
2021
+ : null;
2022
+ await initPresence({ repoRoot, localWorkspace });
2023
+ }
2024
+
2025
+ async function handleApi(req, res, url) {
2026
+ if (req.method === "OPTIONS") {
2027
+ res.writeHead(204, {
2028
+ "Access-Control-Allow-Origin": "*",
2029
+ "Access-Control-Allow-Methods": "GET,POST,OPTIONS",
2030
+ "Access-Control-Allow-Headers": "Content-Type,X-Telegram-InitData",
2031
+ });
2032
+ res.end();
2033
+ return;
2034
+ }
2035
+
2036
+ if (!requireAuth(req)) {
2037
+ jsonResponse(res, 401, {
2038
+ ok: false,
2039
+ error: "Unauthorized. Telegram init data missing or invalid.",
2040
+ });
2041
+ return;
2042
+ }
2043
+
2044
+ if (req.method === "POST" && !checkRateLimit(req, 30)) {
2045
+ jsonResponse(res, 429, { ok: false, error: "Rate limit exceeded. Try again later." });
2046
+ return;
2047
+ }
2048
+
2049
+ const path = url.pathname;
2050
+ if (path === "/api/status") {
2051
+ const data = await readStatusSnapshot();
2052
+ jsonResponse(res, 200, { ok: true, data });
2053
+ return;
2054
+ }
2055
+
2056
+ if (path === "/api/executor") {
2057
+ const executor = uiDeps.getInternalExecutor?.();
2058
+ const mode = uiDeps.getExecutorMode?.() || "internal";
2059
+ jsonResponse(res, 200, {
2060
+ ok: true,
2061
+ data: executor?.getStatus?.() || null,
2062
+ mode,
2063
+ paused: executor?.isPaused?.() || false,
2064
+ });
2065
+ return;
2066
+ }
2067
+
2068
+ if (path === "/api/executor/pause") {
2069
+ const executor = uiDeps.getInternalExecutor?.();
2070
+ if (!executor) {
2071
+ jsonResponse(res, 400, {
2072
+ ok: false,
2073
+ error: "Internal executor not enabled.",
2074
+ });
2075
+ return;
2076
+ }
2077
+ executor.pause();
2078
+ jsonResponse(res, 200, { ok: true, paused: true });
2079
+ broadcastUiEvent(["executor", "overview", "agents"], "invalidate", {
2080
+ reason: "executor-paused",
2081
+ });
2082
+ return;
2083
+ }
2084
+
2085
+ if (path === "/api/executor/resume") {
2086
+ const executor = uiDeps.getInternalExecutor?.();
2087
+ if (!executor) {
2088
+ jsonResponse(res, 400, {
2089
+ ok: false,
2090
+ error: "Internal executor not enabled.",
2091
+ });
2092
+ return;
2093
+ }
2094
+ executor.resume();
2095
+ jsonResponse(res, 200, { ok: true, paused: false });
2096
+ broadcastUiEvent(["executor", "overview", "agents"], "invalidate", {
2097
+ reason: "executor-resumed",
2098
+ });
2099
+ return;
2100
+ }
2101
+
2102
+ if (path === "/api/executor/maxparallel") {
2103
+ try {
2104
+ const executor = uiDeps.getInternalExecutor?.();
2105
+ if (!executor) {
2106
+ jsonResponse(res, 400, {
2107
+ ok: false,
2108
+ error: "Internal executor not enabled.",
2109
+ });
2110
+ return;
2111
+ }
2112
+ const body = await readJsonBody(req);
2113
+ const value = Number(body?.value ?? body?.maxParallel);
2114
+ if (!Number.isFinite(value) || value < 0 || value > 20) {
2115
+ jsonResponse(res, 400, {
2116
+ ok: false,
2117
+ error: "value must be between 0 and 20",
2118
+ });
2119
+ return;
2120
+ }
2121
+ executor.maxParallel = value;
2122
+ if (value === 0) {
2123
+ executor.pause();
2124
+ } else if (executor.isPaused?.()) {
2125
+ executor.resume();
2126
+ }
2127
+ jsonResponse(res, 200, { ok: true, maxParallel: executor.maxParallel });
2128
+ broadcastUiEvent(["executor", "overview", "agents"], "invalidate", {
2129
+ reason: "executor-maxparallel",
2130
+ maxParallel: executor.maxParallel,
2131
+ });
2132
+ } catch (err) {
2133
+ jsonResponse(res, 500, { ok: false, error: err.message });
2134
+ }
2135
+ return;
2136
+ }
2137
+
2138
+ if (path === "/api/projects") {
2139
+ try {
2140
+ const adapter = getKanbanAdapter();
2141
+ const projects = await adapter.listProjects();
2142
+ jsonResponse(res, 200, { ok: true, data: projects });
2143
+ } catch (err) {
2144
+ jsonResponse(res, 500, { ok: false, error: err.message });
2145
+ }
2146
+ return;
2147
+ }
2148
+
2149
+ if (path === "/api/tasks") {
2150
+ const status = url.searchParams.get("status") || "";
2151
+ const projectId = url.searchParams.get("project") || "";
2152
+ const page = Math.max(0, Number(url.searchParams.get("page") || "0"));
2153
+ const pageSize = Math.min(
2154
+ 50,
2155
+ Math.max(5, Number(url.searchParams.get("pageSize") || "15")),
2156
+ );
2157
+ try {
2158
+ const adapter = getKanbanAdapter();
2159
+ const projects = await adapter.listProjects();
2160
+ const activeProject =
2161
+ projectId || projects[0]?.id || projects[0]?.project_id || "";
2162
+ if (!activeProject) {
2163
+ jsonResponse(res, 200, {
2164
+ ok: true,
2165
+ data: [],
2166
+ page,
2167
+ pageSize,
2168
+ total: 0,
2169
+ });
2170
+ return;
2171
+ }
2172
+ const tasks = await adapter.listTasks(
2173
+ activeProject,
2174
+ status ? { status } : {},
2175
+ );
2176
+ const search = (url.searchParams.get("search") || "").trim().toLowerCase();
2177
+ const filtered = search
2178
+ ? tasks.filter((t) => {
2179
+ const hay = `${t.title || ""} ${t.description || ""} ${t.id || ""}`.toLowerCase();
2180
+ return hay.includes(search);
2181
+ })
2182
+ : tasks;
2183
+ const total = filtered.length;
2184
+ const start = page * pageSize;
2185
+ const slice = filtered.slice(start, start + pageSize);
2186
+ jsonResponse(res, 200, {
2187
+ ok: true,
2188
+ data: slice,
2189
+ page,
2190
+ pageSize,
2191
+ total,
2192
+ projectId: activeProject,
2193
+ });
2194
+ } catch (err) {
2195
+ jsonResponse(res, 500, { ok: false, error: err.message });
2196
+ }
2197
+ return;
2198
+ }
2199
+
2200
+ if (path === "/api/tasks/detail") {
2201
+ try {
2202
+ const taskId =
2203
+ url.searchParams.get("taskId") || url.searchParams.get("id") || "";
2204
+ if (!taskId) {
2205
+ jsonResponse(res, 400, { ok: false, error: "taskId required" });
2206
+ return;
2207
+ }
2208
+ const adapter = getKanbanAdapter();
2209
+ const task = await adapter.getTask(taskId);
2210
+ jsonResponse(res, 200, { ok: true, data: task || null });
2211
+ } catch (err) {
2212
+ jsonResponse(res, 500, { ok: false, error: err.message });
2213
+ }
2214
+ return;
2215
+ }
2216
+
2217
+ if (path === "/api/tasks/start") {
2218
+ try {
2219
+ const body = await readJsonBody(req);
2220
+ const taskId = body?.taskId || body?.id;
2221
+ const sdk = typeof body?.sdk === "string" ? body.sdk.trim() : "";
2222
+ const model = typeof body?.model === "string" ? body.model.trim() : "";
2223
+ if (!taskId) {
2224
+ jsonResponse(res, 400, { ok: false, error: "taskId is required" });
2225
+ return;
2226
+ }
2227
+ const executor = uiDeps.getInternalExecutor?.();
2228
+ if (!executor) {
2229
+ jsonResponse(res, 400, {
2230
+ ok: false,
2231
+ error:
2232
+ "Internal executor not enabled. Set EXECUTOR_MODE=internal or hybrid.",
2233
+ });
2234
+ return;
2235
+ }
2236
+ const adapter = getKanbanAdapter();
2237
+ const task = await adapter.getTask(taskId);
2238
+ if (!task) {
2239
+ jsonResponse(res, 404, { ok: false, error: "Task not found." });
2240
+ return;
2241
+ }
2242
+ try {
2243
+ if (typeof adapter.updateTaskStatus === "function") {
2244
+ await adapter.updateTaskStatus(taskId, "inprogress");
2245
+ } else if (typeof adapter.updateTask === "function") {
2246
+ await adapter.updateTask(taskId, { status: "inprogress" });
2247
+ }
2248
+ } catch (err) {
2249
+ console.warn(
2250
+ `[telegram-ui] failed to mark task ${taskId} inprogress: ${err.message}`,
2251
+ );
2252
+ }
2253
+ executor.executeTask(task, {
2254
+ ...(sdk ? { sdk } : {}),
2255
+ ...(model ? { model } : {}),
2256
+ }).catch((error) => {
2257
+ console.warn(
2258
+ `[telegram-ui] failed to execute task ${taskId}: ${error.message}`,
2259
+ );
2260
+ });
2261
+ jsonResponse(res, 200, { ok: true, taskId });
2262
+ broadcastUiEvent(
2263
+ ["tasks", "overview", "executor", "agents"],
2264
+ "invalidate",
2265
+ {
2266
+ reason: "task-started",
2267
+ taskId,
2268
+ },
2269
+ );
2270
+ } catch (err) {
2271
+ jsonResponse(res, 500, { ok: false, error: err.message });
2272
+ }
2273
+ return;
2274
+ }
2275
+
2276
+ if (path === "/api/tasks/update") {
2277
+ try {
2278
+ const body = await readJsonBody(req);
2279
+ const taskId = body?.taskId || body?.id;
2280
+ if (!taskId) {
2281
+ jsonResponse(res, 400, { ok: false, error: "taskId required" });
2282
+ return;
2283
+ }
2284
+ const adapter = getKanbanAdapter();
2285
+ const tagsProvided = body && Object.prototype.hasOwnProperty.call(body, "tags");
2286
+ const tags = tagsProvided ? normalizeTagsInput(body?.tags) : undefined;
2287
+ const draftProvided = body && Object.prototype.hasOwnProperty.call(body, "draft");
2288
+ const baseBranchProvided =
2289
+ body &&
2290
+ (Object.prototype.hasOwnProperty.call(body, "baseBranch") ||
2291
+ Object.prototype.hasOwnProperty.call(body, "base_branch"));
2292
+ const baseBranch = baseBranchProvided
2293
+ ? normalizeBranchInput(body?.baseBranch ?? body?.base_branch)
2294
+ : undefined;
2295
+ const patch = {
2296
+ status: body?.status,
2297
+ title: body?.title,
2298
+ description: body?.description,
2299
+ priority: body?.priority,
2300
+ ...(tagsProvided ? { tags } : {}),
2301
+ ...(draftProvided ? { draft: Boolean(body?.draft) } : {}),
2302
+ ...(baseBranchProvided ? { baseBranch } : {}),
2303
+ };
2304
+ const hasPatch = Object.values(patch).some(
2305
+ (value) => typeof value === "string" && value.trim(),
2306
+ );
2307
+ const hasTags = Array.isArray(patch.tags);
2308
+ const hasDraft = typeof patch.draft === "boolean";
2309
+ const hasBaseBranch = baseBranchProvided;
2310
+ if (!hasPatch && !hasTags && !hasDraft && !hasBaseBranch) {
2311
+ jsonResponse(res, 400, {
2312
+ ok: false,
2313
+ error: "No update fields provided",
2314
+ });
2315
+ return;
2316
+ }
2317
+ const updated =
2318
+ typeof adapter.updateTask === "function"
2319
+ ? await adapter.updateTask(taskId, patch)
2320
+ : await adapter.updateTaskStatus(taskId, patch.status);
2321
+ jsonResponse(res, 200, { ok: true, data: updated });
2322
+ broadcastUiEvent(["tasks", "overview"], "invalidate", {
2323
+ reason: "task-updated",
2324
+ taskId,
2325
+ status: updated?.status || patch.status || null,
2326
+ });
2327
+ } catch (err) {
2328
+ jsonResponse(res, 500, { ok: false, error: err.message });
2329
+ }
2330
+ return;
2331
+ }
2332
+
2333
+ if (path === "/api/tasks/edit") {
2334
+ try {
2335
+ const body = await readJsonBody(req);
2336
+ const taskId = body?.taskId || body?.id;
2337
+ if (!taskId) {
2338
+ jsonResponse(res, 400, { ok: false, error: "taskId required" });
2339
+ return;
2340
+ }
2341
+ const adapter = getKanbanAdapter();
2342
+ const tagsProvided = body && Object.prototype.hasOwnProperty.call(body, "tags");
2343
+ const tags = tagsProvided ? normalizeTagsInput(body?.tags) : undefined;
2344
+ const draftProvided = body && Object.prototype.hasOwnProperty.call(body, "draft");
2345
+ const baseBranchProvided =
2346
+ body &&
2347
+ (Object.prototype.hasOwnProperty.call(body, "baseBranch") ||
2348
+ Object.prototype.hasOwnProperty.call(body, "base_branch"));
2349
+ const baseBranch = baseBranchProvided
2350
+ ? normalizeBranchInput(body?.baseBranch ?? body?.base_branch)
2351
+ : undefined;
2352
+ const patch = {
2353
+ title: body?.title,
2354
+ description: body?.description,
2355
+ priority: body?.priority,
2356
+ status: body?.status,
2357
+ ...(tagsProvided ? { tags } : {}),
2358
+ ...(draftProvided ? { draft: Boolean(body?.draft) } : {}),
2359
+ ...(baseBranchProvided ? { baseBranch } : {}),
2360
+ };
2361
+ const hasPatch = Object.values(patch).some(
2362
+ (value) => typeof value === "string" && value.trim(),
2363
+ );
2364
+ const hasTags = Array.isArray(patch.tags);
2365
+ const hasDraft = typeof patch.draft === "boolean";
2366
+ const hasBaseBranch = baseBranchProvided;
2367
+ if (!hasPatch && !hasTags && !hasDraft && !hasBaseBranch) {
2368
+ jsonResponse(res, 400, {
2369
+ ok: false,
2370
+ error: "No edit fields provided",
2371
+ });
2372
+ return;
2373
+ }
2374
+ const updated =
2375
+ typeof adapter.updateTask === "function"
2376
+ ? await adapter.updateTask(taskId, patch)
2377
+ : await adapter.updateTaskStatus(taskId, patch.status);
2378
+ jsonResponse(res, 200, { ok: true, data: updated });
2379
+ broadcastUiEvent(["tasks", "overview"], "invalidate", {
2380
+ reason: "task-edited",
2381
+ taskId,
2382
+ status: updated?.status || patch.status || null,
2383
+ });
2384
+ } catch (err) {
2385
+ jsonResponse(res, 500, { ok: false, error: err.message });
2386
+ }
2387
+ return;
2388
+ }
2389
+
2390
+ if (path === "/api/tasks/create") {
2391
+ try {
2392
+ const body = await readJsonBody(req);
2393
+ const title = body?.title;
2394
+ if (!title || !String(title).trim()) {
2395
+ jsonResponse(res, 400, { ok: false, error: "title is required" });
2396
+ return;
2397
+ }
2398
+ const projectId = body?.project || "";
2399
+ const adapter = getKanbanAdapter();
2400
+ const tags = normalizeTagsInput(body?.tags);
2401
+ const wantsDraft = Boolean(body?.draft) || body?.status === "draft";
2402
+ const baseBranch = normalizeBranchInput(body?.baseBranch ?? body?.base_branch);
2403
+ const taskData = {
2404
+ title: String(title).trim(),
2405
+ description: body?.description || "",
2406
+ status: body?.status || (wantsDraft ? "draft" : "todo"),
2407
+ priority: body?.priority || undefined,
2408
+ ...(tags.length ? { tags } : {}),
2409
+ ...(tags.length ? { labels: tags } : {}),
2410
+ ...(baseBranch ? { baseBranch } : {}),
2411
+ meta: {
2412
+ ...(tags.length ? { tags } : {}),
2413
+ ...(wantsDraft ? { draft: true } : {}),
2414
+ ...(baseBranch ? { base_branch: baseBranch, baseBranch } : {}),
2415
+ },
2416
+ };
2417
+ const created = await adapter.createTask(projectId, taskData);
2418
+ jsonResponse(res, 200, { ok: true, data: created });
2419
+ broadcastUiEvent(["tasks", "overview"], "invalidate", {
2420
+ reason: "task-created",
2421
+ taskId: created?.id || null,
2422
+ });
2423
+ } catch (err) {
2424
+ jsonResponse(res, 500, { ok: false, error: err.message });
2425
+ }
2426
+ return;
2427
+ }
2428
+
2429
+ if (path === "/api/logs") {
2430
+ const lines = Math.min(
2431
+ 1000,
2432
+ Math.max(10, Number(url.searchParams.get("lines") || "200")),
2433
+ );
2434
+ try {
2435
+ const tail = await getLatestLogTail(lines);
2436
+ jsonResponse(res, 200, { ok: true, data: tail });
2437
+ } catch (err) {
2438
+ jsonResponse(res, 500, { ok: false, error: err.message });
2439
+ }
2440
+ return;
2441
+ }
2442
+
2443
+ if (path === "/api/threads") {
2444
+ try {
2445
+ const threads = getActiveThreads();
2446
+ jsonResponse(res, 200, { ok: true, data: threads });
2447
+ } catch (err) {
2448
+ jsonResponse(res, 500, { ok: false, error: err.message });
2449
+ }
2450
+ return;
2451
+ }
2452
+
2453
+ if (path === "/api/worktrees") {
2454
+ try {
2455
+ const worktrees = listActiveWorktrees();
2456
+ const stats = await getWorktreeStats();
2457
+ jsonResponse(res, 200, { ok: true, data: worktrees, stats });
2458
+ } catch (err) {
2459
+ jsonResponse(res, 500, { ok: false, error: err.message });
2460
+ }
2461
+ return;
2462
+ }
2463
+
2464
+ if (path === "/api/worktrees/prune") {
2465
+ try {
2466
+ const result = await pruneStaleWorktrees({ actor: "telegram-ui" });
2467
+ jsonResponse(res, 200, { ok: true, data: result });
2468
+ broadcastUiEvent(["worktrees"], "invalidate", {
2469
+ reason: "worktrees-pruned",
2470
+ });
2471
+ } catch (err) {
2472
+ jsonResponse(res, 500, { ok: false, error: err.message });
2473
+ }
2474
+ return;
2475
+ }
2476
+
2477
+ if (path === "/api/worktrees/release") {
2478
+ try {
2479
+ const body = await readJsonBody(req);
2480
+ const taskKey = body?.taskKey || body?.key;
2481
+ const branch = body?.branch;
2482
+ let released = null;
2483
+ if (taskKey) {
2484
+ released = await releaseWorktree(taskKey);
2485
+ } else if (branch) {
2486
+ released = await releaseWorktreeByBranch(branch);
2487
+ } else {
2488
+ jsonResponse(res, 400, {
2489
+ ok: false,
2490
+ error: "taskKey or branch required",
2491
+ });
2492
+ return;
2493
+ }
2494
+ jsonResponse(res, 200, { ok: true, data: released });
2495
+ broadcastUiEvent(["worktrees"], "invalidate", {
2496
+ reason: "worktree-released",
2497
+ });
2498
+ } catch (err) {
2499
+ jsonResponse(res, 500, { ok: false, error: err.message });
2500
+ }
2501
+ return;
2502
+ }
2503
+
2504
+ if (path === "/api/presence") {
2505
+ try {
2506
+ await ensurePresenceLoaded();
2507
+ const instances = listActiveInstances({ ttlMs: PRESENCE_TTL_MS });
2508
+ const coordinator = selectCoordinator({ ttlMs: PRESENCE_TTL_MS });
2509
+ jsonResponse(res, 200, { ok: true, data: { instances, coordinator } });
2510
+ } catch (err) {
2511
+ jsonResponse(res, 500, { ok: false, error: err.message });
2512
+ }
2513
+ return;
2514
+ }
2515
+
2516
+ if (path === "/api/shared-workspaces") {
2517
+ try {
2518
+ const registry = await loadSharedWorkspaceRegistry();
2519
+ const sweep = await sweepExpiredLeases({
2520
+ registry,
2521
+ actor: "telegram-ui",
2522
+ });
2523
+ const availability = getSharedAvailabilityMap(sweep.registry);
2524
+ jsonResponse(res, 200, {
2525
+ ok: true,
2526
+ data: sweep.registry,
2527
+ availability,
2528
+ expired: sweep.expired || [],
2529
+ });
2530
+ } catch (err) {
2531
+ jsonResponse(res, 500, { ok: false, error: err.message });
2532
+ }
2533
+ return;
2534
+ }
2535
+
2536
+ if (path === "/api/shared-workspaces/claim") {
2537
+ try {
2538
+ const body = await readJsonBody(req);
2539
+ const workspaceId = body?.workspaceId || body?.id;
2540
+ if (!workspaceId) {
2541
+ jsonResponse(res, 400, { ok: false, error: "workspaceId required" });
2542
+ return;
2543
+ }
2544
+ const result = await claimSharedWorkspace({
2545
+ workspaceId,
2546
+ owner: body?.owner,
2547
+ ttlMinutes: body?.ttlMinutes,
2548
+ note: body?.note,
2549
+ actor: "telegram-ui",
2550
+ });
2551
+ if (result.error) {
2552
+ jsonResponse(res, 400, { ok: false, error: result.error });
2553
+ return;
2554
+ }
2555
+ jsonResponse(res, 200, {
2556
+ ok: true,
2557
+ data: result.workspace,
2558
+ lease: result.lease,
2559
+ });
2560
+ broadcastUiEvent(["workspaces"], "invalidate", {
2561
+ reason: "workspace-claimed",
2562
+ workspaceId,
2563
+ });
2564
+ } catch (err) {
2565
+ jsonResponse(res, 500, { ok: false, error: err.message });
2566
+ }
2567
+ return;
2568
+ }
2569
+
2570
+ if (path === "/api/shared-workspaces/release") {
2571
+ try {
2572
+ const body = await readJsonBody(req);
2573
+ const workspaceId = body?.workspaceId || body?.id;
2574
+ if (!workspaceId) {
2575
+ jsonResponse(res, 400, { ok: false, error: "workspaceId required" });
2576
+ return;
2577
+ }
2578
+ const result = await releaseSharedWorkspace({
2579
+ workspaceId,
2580
+ owner: body?.owner,
2581
+ force: body?.force,
2582
+ reason: body?.reason,
2583
+ actor: "telegram-ui",
2584
+ });
2585
+ if (result.error) {
2586
+ jsonResponse(res, 400, { ok: false, error: result.error });
2587
+ return;
2588
+ }
2589
+ jsonResponse(res, 200, { ok: true, data: result.workspace });
2590
+ broadcastUiEvent(["workspaces"], "invalidate", {
2591
+ reason: "workspace-released",
2592
+ workspaceId,
2593
+ });
2594
+ } catch (err) {
2595
+ jsonResponse(res, 500, { ok: false, error: err.message });
2596
+ }
2597
+ return;
2598
+ }
2599
+
2600
+ if (path === "/api/shared-workspaces/renew") {
2601
+ try {
2602
+ const body = await readJsonBody(req);
2603
+ const workspaceId = body?.workspaceId || body?.id;
2604
+ if (!workspaceId) {
2605
+ jsonResponse(res, 400, { ok: false, error: "workspaceId required" });
2606
+ return;
2607
+ }
2608
+ const result = await renewSharedWorkspaceLease({
2609
+ workspaceId,
2610
+ owner: body?.owner,
2611
+ ttlMinutes: body?.ttlMinutes,
2612
+ actor: "telegram-ui",
2613
+ });
2614
+ if (result.error) {
2615
+ jsonResponse(res, 400, { ok: false, error: result.error });
2616
+ return;
2617
+ }
2618
+ jsonResponse(res, 200, {
2619
+ ok: true,
2620
+ data: result.workspace,
2621
+ lease: result.lease,
2622
+ });
2623
+ broadcastUiEvent(["workspaces"], "invalidate", {
2624
+ reason: "workspace-renewed",
2625
+ workspaceId,
2626
+ });
2627
+ } catch (err) {
2628
+ jsonResponse(res, 500, { ok: false, error: err.message });
2629
+ }
2630
+ return;
2631
+ }
2632
+
2633
+ if (path === "/api/agent-logs") {
2634
+ try {
2635
+ const file = url.searchParams.get("file");
2636
+ const query = url.searchParams.get("query") || "";
2637
+ const lines = Math.min(
2638
+ 1000,
2639
+ Math.max(20, Number(url.searchParams.get("lines") || "200")),
2640
+ );
2641
+ if (!file) {
2642
+ const files = await listAgentLogFiles(query);
2643
+ jsonResponse(res, 200, { ok: true, data: files });
2644
+ return;
2645
+ }
2646
+ const filePath = resolve(agentLogsDir, file);
2647
+ if (!filePath.startsWith(agentLogsDir)) {
2648
+ jsonResponse(res, 403, { ok: false, error: "Forbidden" });
2649
+ return;
2650
+ }
2651
+ if (!existsSync(filePath)) {
2652
+ jsonResponse(res, 404, { ok: false, error: "Log not found" });
2653
+ return;
2654
+ }
2655
+ const tail = await tailFile(filePath, lines);
2656
+ jsonResponse(res, 200, { ok: true, data: tail });
2657
+ } catch (err) {
2658
+ jsonResponse(res, 500, { ok: false, error: err.message });
2659
+ }
2660
+ return;
2661
+ }
2662
+
2663
+ if (path === "/api/agent-logs/context") {
2664
+ try {
2665
+ const query = url.searchParams.get("query") || "";
2666
+ if (!query) {
2667
+ jsonResponse(res, 400, { ok: false, error: "query required" });
2668
+ return;
2669
+ }
2670
+ const worktreeDir = resolve(repoRoot, ".cache", "worktrees");
2671
+ const dirs = await readdir(worktreeDir).catch(() => []);
2672
+ const matches = dirs.filter((d) =>
2673
+ d.toLowerCase().includes(query.toLowerCase()),
2674
+ );
2675
+ if (matches.length === 0) {
2676
+ jsonResponse(res, 200, { ok: true, data: { matches: [] } });
2677
+ return;
2678
+ }
2679
+ const wtName = matches[0];
2680
+ const wtPath = resolve(worktreeDir, wtName);
2681
+ let gitLog = "";
2682
+ let gitStatus = "";
2683
+ let diffStat = "";
2684
+ try {
2685
+ gitLog = execSync("git log --oneline -5 2>&1", {
2686
+ cwd: wtPath,
2687
+ encoding: "utf8",
2688
+ timeout: 10000,
2689
+ }).trim();
2690
+ } catch {
2691
+ gitLog = "";
2692
+ }
2693
+ try {
2694
+ gitStatus = execSync("git status --short 2>&1", {
2695
+ cwd: wtPath,
2696
+ encoding: "utf8",
2697
+ timeout: 10000,
2698
+ }).trim();
2699
+ } catch {
2700
+ gitStatus = "";
2701
+ }
2702
+ try {
2703
+ const branch = execSync("git branch --show-current 2>&1", {
2704
+ cwd: wtPath,
2705
+ encoding: "utf8",
2706
+ timeout: 5000,
2707
+ }).trim();
2708
+ diffStat = execSync(`git diff --stat main...${branch} 2>&1`, {
2709
+ cwd: wtPath,
2710
+ encoding: "utf8",
2711
+ timeout: 10000,
2712
+ }).trim();
2713
+ } catch {
2714
+ diffStat = "";
2715
+ }
2716
+ jsonResponse(res, 200, {
2717
+ ok: true,
2718
+ data: {
2719
+ name: wtName,
2720
+ path: wtPath,
2721
+ gitLog,
2722
+ gitStatus,
2723
+ diffStat,
2724
+ },
2725
+ });
2726
+ } catch (err) {
2727
+ jsonResponse(res, 500, { ok: false, error: err.message });
2728
+ }
2729
+ return;
2730
+ }
2731
+
2732
+ if (path === "/api/agents") {
2733
+ try {
2734
+ const executor = uiDeps.getInternalExecutor?.();
2735
+ const agents = [];
2736
+ if (executor) {
2737
+ const status = executor.getStatus();
2738
+ for (const slot of status.slots || []) {
2739
+ if (slot.taskId) {
2740
+ agents.push({
2741
+ id: slot.taskId,
2742
+ status: slot.status || "busy",
2743
+ taskTitle: slot.taskTitle || slot.taskId,
2744
+ branch: slot.branch || null,
2745
+ startedAt: slot.startedAt || null,
2746
+ completedCount: slot.completedCount || 0,
2747
+ });
2748
+ }
2749
+ }
2750
+ }
2751
+ jsonResponse(res, 200, { ok: true, data: agents });
2752
+ } catch (err) {
2753
+ jsonResponse(res, 200, { ok: true, data: [] });
2754
+ }
2755
+ return;
2756
+ }
2757
+
2758
+ if (path === "/api/infra") {
2759
+ try {
2760
+ const executor = uiDeps.getInternalExecutor?.();
2761
+ const status = executor?.getStatus?.() || {};
2762
+ const data = {
2763
+ executor: {
2764
+ mode: uiDeps.getExecutorMode?.() || "internal",
2765
+ maxParallel: status.maxParallel || 0,
2766
+ activeSlots: status.activeSlots || 0,
2767
+ paused: executor?.isPaused?.() || false,
2768
+ },
2769
+ system: {
2770
+ uptime: process.uptime(),
2771
+ memoryMB: Math.round(process.memoryUsage.rss() / 1024 / 1024),
2772
+ nodeVersion: process.version,
2773
+ platform: process.platform,
2774
+ },
2775
+ };
2776
+ jsonResponse(res, 200, { ok: true, data });
2777
+ } catch (err) {
2778
+ jsonResponse(res, 200, { ok: true, data: null });
2779
+ }
2780
+ return;
2781
+ }
2782
+
2783
+ if (path === "/api/agent-logs/tail") {
2784
+ try {
2785
+ const query = url.searchParams.get("query") || "";
2786
+ const lines = Math.min(
2787
+ 1000,
2788
+ Math.max(20, Number(url.searchParams.get("lines") || "100")),
2789
+ );
2790
+ const files = await listAgentLogFiles(query);
2791
+ if (!files.length) {
2792
+ jsonResponse(res, 200, { ok: true, data: null });
2793
+ return;
2794
+ }
2795
+ const latest = files[0];
2796
+ const filePath = resolve(agentLogsDir, latest.name || latest);
2797
+ if (!filePath.startsWith(agentLogsDir) || !existsSync(filePath)) {
2798
+ jsonResponse(res, 200, { ok: true, data: null });
2799
+ return;
2800
+ }
2801
+ const tail = await tailFile(filePath, lines);
2802
+ jsonResponse(res, 200, { ok: true, data: { file: latest.name || latest, content: tail } });
2803
+ } catch (err) {
2804
+ jsonResponse(res, 200, { ok: true, data: null });
2805
+ }
2806
+ return;
2807
+ }
2808
+
2809
+ if (path === "/api/agent-context") {
2810
+ try {
2811
+ const query = url.searchParams.get("query") || "";
2812
+ if (!query) {
2813
+ jsonResponse(res, 200, { ok: true, data: null });
2814
+ return;
2815
+ }
2816
+ const queryLower = query.toLowerCase();
2817
+ const worktreeDir = resolve(repoRoot, ".cache", "worktrees");
2818
+
2819
+ const worktreeMatches = [];
2820
+ let matchedWorktree = null;
2821
+ try {
2822
+ const active = await listActiveWorktrees();
2823
+ for (const wt of active || []) {
2824
+ const branch = String(wt.branch || "").toLowerCase();
2825
+ const taskKey = String(wt.taskKey || "").toLowerCase();
2826
+ const name = String(wt.name || wt.branch || "").toLowerCase();
2827
+ if (
2828
+ branch.includes(queryLower) ||
2829
+ taskKey === queryLower ||
2830
+ taskKey.includes(queryLower) ||
2831
+ name.includes(queryLower)
2832
+ ) {
2833
+ matchedWorktree = wt;
2834
+ worktreeMatches.push(wt.branch || wt.taskKey || wt.path || wt.name || "");
2835
+ break;
2836
+ }
2837
+ }
2838
+ } catch {
2839
+ /* best effort */
2840
+ }
2841
+
2842
+ let wtName = matchedWorktree?.name || "";
2843
+ let wtPath = matchedWorktree?.path || "";
2844
+
2845
+ if (!wtPath) {
2846
+ const dirs = await readdir(worktreeDir).catch(() => []);
2847
+ const directMatches = dirs.filter((d) => d.toLowerCase().includes(queryLower));
2848
+ const shortQuery = queryLower.length > 8 ? queryLower.slice(0, 8) : "";
2849
+ const shortMatches = shortQuery
2850
+ ? dirs.filter((d) => d.toLowerCase().includes(shortQuery))
2851
+ : [];
2852
+ const matches = directMatches.length ? directMatches : shortMatches;
2853
+ if (!matches.length) {
2854
+ jsonResponse(res, 200, { ok: true, data: { matches: [], context: null } });
2855
+ return;
2856
+ }
2857
+ wtName = matches[0];
2858
+ wtPath = resolve(worktreeDir, wtName);
2859
+ worktreeMatches.push(...matches);
2860
+ }
2861
+ const runWtGit = (args) => {
2862
+ try {
2863
+ return execSync(`git ${args}`, { cwd: wtPath, encoding: "utf8", timeout: 5000 }).trim();
2864
+ } catch { return ""; }
2865
+ };
2866
+ const gitLog = runWtGit("log --oneline -10");
2867
+ const gitLogDetailed = runWtGit("log --format=%h||%s||%cr -10");
2868
+ const gitStatus = runWtGit("status --porcelain");
2869
+ const gitBranch = runWtGit("rev-parse --abbrev-ref HEAD");
2870
+ const gitDiffStat = runWtGit("diff --stat");
2871
+ const gitAheadBehind = runWtGit("rev-list --left-right --count HEAD...@{upstream} 2>/dev/null");
2872
+ const changedFiles = gitStatus
2873
+ ? gitStatus
2874
+ .split("\n")
2875
+ .filter(Boolean)
2876
+ .map((line) => ({
2877
+ code: line.substring(0, 2).trim() || "?",
2878
+ file: line.substring(3).trim(),
2879
+ }))
2880
+ : [];
2881
+ const commitRows = gitLogDetailed
2882
+ ? gitLogDetailed.split("\n").filter(Boolean).map((line) => {
2883
+ const [hash, message, time] = line.split("||");
2884
+ return { hash, message, time };
2885
+ })
2886
+ : [];
2887
+ const sessionTracker = getSessionTracker();
2888
+ const sessions = sessionTracker?.listAllSessions?.() || [];
2889
+ let session =
2890
+ sessions.find((s) => String(s.id || "").toLowerCase() === queryLower) ||
2891
+ sessions.find((s) => String(s.taskId || "").toLowerCase() === queryLower);
2892
+ if (!session && matchedWorktree?.taskKey) {
2893
+ const taskKey = String(matchedWorktree.taskKey || "").toLowerCase();
2894
+ session =
2895
+ sessions.find((s) => String(s.id || "").toLowerCase() === taskKey) ||
2896
+ sessions.find((s) => String(s.taskId || "").toLowerCase() === taskKey);
2897
+ }
2898
+ if (!session && queryLower.length > 8) {
2899
+ const short = queryLower.slice(0, 8);
2900
+ session = sessions.find(
2901
+ (s) =>
2902
+ String(s.id || "").toLowerCase().includes(short) ||
2903
+ String(s.taskId || "").toLowerCase().includes(short),
2904
+ );
2905
+ }
2906
+ const fullSession =
2907
+ session && typeof sessionTracker?.getSessionMessages === "function"
2908
+ ? sessionTracker.getSessionMessages(session.id || session.taskId)
2909
+ : null;
2910
+ const actionHistory = [];
2911
+ const fileAccessMap = new Map();
2912
+ const fileAccessCounts = { read: 0, write: 0, other: 0 };
2913
+ const filePattern = /([a-zA-Z0-9_./-]+\.(?:js|mjs|cjs|ts|tsx|jsx|json|md|mdx|css|scss|less|html|yml|yaml|toml|env|lock|go|rs|py|sh|ps1|psm1|txt|sql))/g;
2914
+ const classifyActionKind = (toolName, detail) => {
2915
+ const toolLower = String(toolName || "").toLowerCase();
2916
+ const cmdLower = String(detail || "").toLowerCase();
2917
+ if (toolLower.includes("apply_patch") || toolLower.includes("write")) return "write";
2918
+ if (/\b(rg|cat|sed|ls|stat|head|tail|grep|find)\b/.test(cmdLower)) return "read";
2919
+ return "other";
2920
+ };
2921
+ const addFileAccess = (path, kind) => {
2922
+ if (!path) return;
2923
+ const entry = fileAccessMap.get(path) || { path, kinds: new Set() };
2924
+ if (!entry.kinds.has(kind)) {
2925
+ entry.kinds.add(kind);
2926
+ if (fileAccessCounts[kind] != null) fileAccessCounts[kind] += 1;
2927
+ else fileAccessCounts.other += 1;
2928
+ }
2929
+ fileAccessMap.set(path, entry);
2930
+ };
2931
+
2932
+ const messages = fullSession?.messages || [];
2933
+ const recentMessages = messages.slice(-50);
2934
+ for (const msg of recentMessages) {
2935
+ if (!msg || !msg.type) continue;
2936
+ if (msg.type === "tool_call" || msg.type === "tool_result" || msg.type === "error") {
2937
+ actionHistory.push({
2938
+ type: msg.type,
2939
+ tool: msg.meta?.toolName || (msg.type === "tool_result" ? "RESULT" : "TOOL"),
2940
+ detail: msg.content || "",
2941
+ content: msg.content || "",
2942
+ timestamp: msg.timestamp || null,
2943
+ });
2944
+ }
2945
+ if (msg.type === "tool_call" && msg.content) {
2946
+ const kind = classifyActionKind(msg.meta?.toolName, msg.content);
2947
+ const matches = msg.content.matchAll(filePattern);
2948
+ for (const match of matches) {
2949
+ const file = match?.[1];
2950
+ if (file) addFileAccess(file, kind);
2951
+ }
2952
+ }
2953
+ }
2954
+ for (const file of changedFiles) {
2955
+ if (file?.file) addFileAccess(file.file, "write");
2956
+ }
2957
+ const fileAccessSummary = fileAccessMap.size
2958
+ ? {
2959
+ files: Array.from(fileAccessMap.values()).map((entry) => ({
2960
+ path: entry.path,
2961
+ kinds: Array.from(entry.kinds),
2962
+ })),
2963
+ counts: fileAccessCounts,
2964
+ }
2965
+ : null;
2966
+ jsonResponse(res, 200, {
2967
+ ok: true,
2968
+ data: {
2969
+ matches: worktreeMatches,
2970
+ session: session || null,
2971
+ actionHistory,
2972
+ fileAccessSummary,
2973
+ context: {
2974
+ name: wtName,
2975
+ path: wtPath,
2976
+ gitLog,
2977
+ gitLogDetailed,
2978
+ gitStatus,
2979
+ gitBranch,
2980
+ gitDiffStat,
2981
+ gitAheadBehind,
2982
+ changedFiles,
2983
+ diffSummary: gitDiffStat,
2984
+ recentCommits: commitRows,
2985
+ },
2986
+ },
2987
+ });
2988
+ } catch (err) {
2989
+ jsonResponse(res, 200, { ok: true, data: null });
2990
+ }
2991
+ return;
2992
+ }
2993
+
2994
+ if (path === "/api/git/branches") {
2995
+ try {
2996
+ const raw = runGit("branch -a --sort=-committerdate", 15000);
2997
+ const lines = raw
2998
+ .split("\n")
2999
+ .map((line) => line.trim())
3000
+ .filter(Boolean);
3001
+ jsonResponse(res, 200, { ok: true, data: lines.slice(0, 40) });
3002
+ } catch (err) {
3003
+ jsonResponse(res, 500, { ok: false, error: err.message });
3004
+ }
3005
+ return;
3006
+ }
3007
+
3008
+ if (path === "/api/git/branch-detail") {
3009
+ try {
3010
+ const rawBranch = url.searchParams.get("branch") || "";
3011
+ const cleaned = rawBranch.replace(/^\*\s*/, "").trim();
3012
+ const safe = cleaned.replace(/^remotes\//, "").replace(/[^\w./-]/g, "");
3013
+ if (!safe) {
3014
+ jsonResponse(res, 400, { ok: false, error: "branch is required" });
3015
+ return;
3016
+ }
3017
+ const hasRef = (ref) => {
3018
+ try {
3019
+ execSync(`git show-ref --verify --quiet ${ref}`, {
3020
+ cwd: repoRoot,
3021
+ timeout: 5000,
3022
+ stdio: "ignore",
3023
+ });
3024
+ return true;
3025
+ } catch {
3026
+ return false;
3027
+ }
3028
+ };
3029
+ const baseRef =
3030
+ (hasRef("refs/heads/main") && "main") ||
3031
+ (hasRef("refs/remotes/origin/main") && "origin/main") ||
3032
+ (hasRef("refs/heads/master") && "master") ||
3033
+ (hasRef("refs/remotes/origin/master") && "origin/master") ||
3034
+ null;
3035
+ const diffRange = baseRef ? `${baseRef}...${safe}` : `${safe}~1..${safe}`;
3036
+ const commitsRaw = runGit(`log ${safe} --format=%h||%s||%cr -20`, 15000);
3037
+ const commits = commitsRaw
3038
+ ? commitsRaw.split("\n").filter(Boolean).map((line) => {
3039
+ const [hash, message, time] = line.split("||");
3040
+ return { hash, message, time };
3041
+ })
3042
+ : [];
3043
+ const commitListRaw = runGit(
3044
+ `log ${safe} --format=%H||%h||%an||%ae||%ad||%s --date=iso-strict -20`,
3045
+ 15000,
3046
+ );
3047
+ const commitList = commitListRaw
3048
+ ? commitListRaw.split("\n").filter(Boolean).map((line) => {
3049
+ const [hash, short, authorName, authorEmail, authorDate, subject] = line.split("||");
3050
+ return {
3051
+ hash,
3052
+ short,
3053
+ authorName,
3054
+ authorEmail,
3055
+ authorDate,
3056
+ subject,
3057
+ };
3058
+ })
3059
+ : [];
3060
+ const diffStat = runGit(`diff --stat ${diffRange}`, 15000);
3061
+ const filesRaw = runGit(`diff --name-only ${diffRange}`, 15000);
3062
+ const files = filesRaw ? filesRaw.split("\n").filter(Boolean) : [];
3063
+ const numstatRaw = runGit(`diff --numstat ${diffRange}`, 15000);
3064
+ const parseNumstat = (raw) => {
3065
+ if (!raw) return [];
3066
+ const entries = [];
3067
+ for (const line of raw.split("\n")) {
3068
+ if (!line.trim()) continue;
3069
+ const parts = line.split("\t");
3070
+ if (parts.length < 3) continue;
3071
+ const [addRaw, delRaw, ...fileParts] = parts;
3072
+ const file = fileParts.join("\t");
3073
+ if (!file) continue;
3074
+ if (addRaw === "-" && delRaw === "-") {
3075
+ entries.push({ file, additions: 0, deletions: 0, binary: true });
3076
+ } else {
3077
+ entries.push({
3078
+ file,
3079
+ additions: parseInt(addRaw, 10) || 0,
3080
+ deletions: parseInt(delRaw, 10) || 0,
3081
+ binary: false,
3082
+ });
3083
+ }
3084
+ }
3085
+ return entries;
3086
+ };
3087
+ const filesChanged = parseNumstat(numstatRaw);
3088
+ const diffSummary = filesChanged.length
3089
+ ? {
3090
+ totalFiles: filesChanged.length,
3091
+ totalAdditions: filesChanged.reduce((sum, f) => sum + (f.additions || 0), 0),
3092
+ totalDeletions: filesChanged.reduce((sum, f) => sum + (f.deletions || 0), 0),
3093
+ binaryFiles: filesChanged.reduce((sum, f) => sum + (f.binary ? 1 : 0), 0),
3094
+ }
3095
+ : null;
3096
+
3097
+ let worktree = null;
3098
+ try {
3099
+ const active = await listActiveWorktrees();
3100
+ const match = (active || []).find((wt) => {
3101
+ const branch = String(wt.branch || "").replace(/^refs\/heads\//, "");
3102
+ return branch === safe || branch === cleaned || branch.endsWith(`/${safe}`);
3103
+ });
3104
+ if (match) {
3105
+ worktree = {
3106
+ path: match.path,
3107
+ taskKey: match.taskKey || null,
3108
+ branch: match.branch || safe,
3109
+ status: match.status || null,
3110
+ };
3111
+ }
3112
+ } catch {
3113
+ /* best effort */
3114
+ }
3115
+
3116
+ let activeSlot = null;
3117
+ const executor = uiDeps.getInternalExecutor?.();
3118
+ if (executor?.getStatus) {
3119
+ const status = executor.getStatus();
3120
+ const slotMatch = (status?.slots || []).find((s) => {
3121
+ const slotBranch = String(s.branch || "").replace(/^refs\/heads\//, "");
3122
+ return slotBranch === safe || slotBranch === cleaned || slotBranch.endsWith(`/${safe}`);
3123
+ });
3124
+ if (slotMatch) {
3125
+ activeSlot = slotMatch;
3126
+ }
3127
+ }
3128
+ const workspaceTarget =
3129
+ activeSlot || worktree
3130
+ ? {
3131
+ taskId: activeSlot?.taskId || worktree?.taskKey || null,
3132
+ taskTitle: activeSlot?.taskTitle || worktree?.taskKey || safe,
3133
+ branch: worktree?.branch || safe,
3134
+ workspacePath: worktree?.path || null,
3135
+ }
3136
+ : null;
3137
+ const workspaceLink = workspaceTarget
3138
+ ? {
3139
+ label: workspaceTarget.taskTitle || workspaceTarget.branch || safe,
3140
+ taskTitle: workspaceTarget.taskTitle,
3141
+ branch: workspaceTarget.branch,
3142
+ workspacePath: workspaceTarget.workspacePath,
3143
+ target: workspaceTarget,
3144
+ }
3145
+ : null;
3146
+
3147
+ jsonResponse(res, 200, {
3148
+ ok: true,
3149
+ data: {
3150
+ branch: safe,
3151
+ base: baseRef,
3152
+ commits,
3153
+ commitList,
3154
+ diffStat,
3155
+ files,
3156
+ filesChanged,
3157
+ filesDetailed: filesChanged,
3158
+ diffSummary,
3159
+ worktree,
3160
+ activeSlot,
3161
+ workspaceTarget,
3162
+ workspaceLink,
3163
+ },
3164
+ });
3165
+ } catch (err) {
3166
+ jsonResponse(res, 500, { ok: false, error: err.message });
3167
+ }
3168
+ return;
3169
+ }
3170
+
3171
+ if (path === "/api/git/diff") {
3172
+ try {
3173
+ const diff = runGit("diff --stat HEAD", 15000);
3174
+ jsonResponse(res, 200, { ok: true, data: diff });
3175
+ } catch (err) {
3176
+ jsonResponse(res, 500, { ok: false, error: err.message });
3177
+ }
3178
+ return;
3179
+ }
3180
+
3181
+ if (path === "/api/health") {
3182
+ jsonResponse(res, 200, {
3183
+ ok: true,
3184
+ uptime: process.uptime(),
3185
+ wsClients: wsClients.size,
3186
+ lanIp: getLocalLanIp(),
3187
+ url: getTelegramUiUrl(),
3188
+ });
3189
+ return;
3190
+ }
3191
+
3192
+ if (path === "/api/config") {
3193
+ const regionEnv = (process.env.EXECUTOR_REGIONS || "").trim();
3194
+ const regions = regionEnv ? regionEnv.split(",").map((r) => r.trim()).filter(Boolean) : ["auto"];
3195
+ jsonResponse(res, 200, {
3196
+ ok: true,
3197
+ miniAppEnabled:
3198
+ !!process.env.TELEGRAM_MINIAPP_ENABLED ||
3199
+ !!process.env.TELEGRAM_UI_PORT,
3200
+ uiUrl: getTelegramUiUrl(),
3201
+ lanIp: getLocalLanIp(),
3202
+ wsEnabled: true,
3203
+ authRequired: !isAllowUnsafe(),
3204
+ sdk: process.env.EXECUTOR_SDK || "auto",
3205
+ kanbanBackend: process.env.KANBAN_BACKEND || "github",
3206
+ regions,
3207
+ });
3208
+ return;
3209
+ }
3210
+
3211
+ if (path === "/api/config/update") {
3212
+ try {
3213
+ const body = await readJsonBody(req);
3214
+ const { key, value } = body || {};
3215
+ if (!key || !value) {
3216
+ jsonResponse(res, 400, { ok: false, error: "key and value are required" });
3217
+ return;
3218
+ }
3219
+ const envMap = { sdk: "EXECUTOR_SDK", kanban: "KANBAN_BACKEND", region: "EXECUTOR_REGIONS" };
3220
+ const envKey = envMap[key];
3221
+ if (!envKey) {
3222
+ jsonResponse(res, 400, { ok: false, error: `Unknown config key: ${key}` });
3223
+ return;
3224
+ }
3225
+ process.env[envKey] = value;
3226
+ // Also send chat command for backward compat
3227
+ const cmdMap = { sdk: `/sdk ${value}`, kanban: `/kanban ${value}`, region: `/region ${value}` };
3228
+ const handler = uiDeps.handleUiCommand;
3229
+ if (typeof handler === "function") {
3230
+ try { await handler(cmdMap[key]); } catch { /* best-effort */ }
3231
+ }
3232
+ broadcastUiEvent(["executor", "overview"], "invalidate", { reason: "config-updated", key, value });
3233
+ jsonResponse(res, 200, { ok: true, key, value });
3234
+ } catch (err) {
3235
+ jsonResponse(res, 500, { ok: false, error: err.message });
3236
+ }
3237
+ return;
3238
+ }
3239
+
3240
+ if (path === "/api/settings") {
3241
+ try {
3242
+ const data = {};
3243
+ for (const key of SETTINGS_KNOWN_KEYS) {
3244
+ const val = process.env[key];
3245
+ if (SETTINGS_SENSITIVE_KEYS.has(key)) {
3246
+ data[key] = val ? "••••••" : "";
3247
+ } else {
3248
+ data[key] = val || "";
3249
+ }
3250
+ }
3251
+ const envPath = resolve(__dirname, ".env");
3252
+ const configPath = resolveConfigPath();
3253
+ const configExists = existsSync(configPath);
3254
+ const configSchema = getConfigSchema();
3255
+ jsonResponse(res, 200, {
3256
+ ok: true,
3257
+ data,
3258
+ meta: {
3259
+ envPath,
3260
+ configPath,
3261
+ configDir: dirname(configPath),
3262
+ configExists,
3263
+ configSchemaPath: CONFIG_SCHEMA_PATH,
3264
+ configSchemaLoaded: Boolean(configSchema),
3265
+ },
3266
+ });
3267
+ } catch (err) {
3268
+ jsonResponse(res, 500, { ok: false, error: err.message });
3269
+ }
3270
+ return;
3271
+ }
3272
+
3273
+ if (path === "/api/settings/update") {
3274
+ try {
3275
+ const body = await readJsonBody(req);
3276
+ const changes = body?.changes;
3277
+ if (!changes || typeof changes !== "object" || Array.isArray(changes)) {
3278
+ jsonResponse(res, 400, { ok: false, error: "changes object is required" });
3279
+ return;
3280
+ }
3281
+ // Rate limit: 2 seconds between settings updates
3282
+ const now = Date.now();
3283
+ if (now - _settingsLastUpdateTime < 2000) {
3284
+ jsonResponse(res, 429, { ok: false, error: "Settings update rate limited. Wait 2 seconds." });
3285
+ return;
3286
+ }
3287
+ const unknownKeys = Object.keys(changes).filter(k => !SETTINGS_KNOWN_SET.has(k));
3288
+ if (unknownKeys.length > 0) {
3289
+ jsonResponse(res, 400, { ok: false, error: `Unknown keys: ${unknownKeys.join(", ")}` });
3290
+ return;
3291
+ }
3292
+ const fieldErrors = {};
3293
+ for (const [key, value] of Object.entries(changes)) {
3294
+ const def = SETTINGS_SCHEMA.find((s) => s.key === key);
3295
+ if (!def) continue;
3296
+ const result = validateSetting(def, String(value ?? ""));
3297
+ if (!result.valid) {
3298
+ fieldErrors[key] = result.error || "Invalid value";
3299
+ }
3300
+ }
3301
+ const schemaFieldErrors = validateConfigSchemaChanges(changes);
3302
+ for (const [key, error] of Object.entries(schemaFieldErrors)) {
3303
+ if (!fieldErrors[key]) fieldErrors[key] = error;
3304
+ }
3305
+ if (Object.keys(fieldErrors).length > 0) {
3306
+ jsonResponse(res, 400, {
3307
+ ok: false,
3308
+ error: "Validation failed",
3309
+ fieldErrors,
3310
+ });
3311
+ return;
3312
+ }
3313
+ for (const [key, value] of Object.entries(changes)) {
3314
+ const strVal = String(value);
3315
+ if (strVal.length > 2000) {
3316
+ jsonResponse(res, 400, { ok: false, error: `Value for ${key} exceeds 2000 chars` });
3317
+ return;
3318
+ }
3319
+ if (strVal.includes('\0') || strVal.includes('\n') || strVal.includes('\r')) {
3320
+ jsonResponse(res, 400, { ok: false, error: `Value for ${key} contains illegal characters (null bytes or newlines)` });
3321
+ return;
3322
+ }
3323
+ }
3324
+ // Apply to process.env
3325
+ const strChanges = {};
3326
+ for (const [key, value] of Object.entries(changes)) {
3327
+ const strVal = String(value);
3328
+ process.env[key] = strVal;
3329
+ strChanges[key] = strVal;
3330
+ }
3331
+ // Write to .env file
3332
+ const updated = updateEnvFile(strChanges);
3333
+ const configUpdate = updateConfigFile(changes);
3334
+ const configDir = configUpdate.path ? dirname(configUpdate.path) : null;
3335
+ _settingsLastUpdateTime = now;
3336
+ broadcastUiEvent(["settings", "overview"], "invalidate", { reason: "settings-updated", keys: updated });
3337
+ jsonResponse(res, 200, {
3338
+ ok: true,
3339
+ updated,
3340
+ updatedConfig: configUpdate.updated || [],
3341
+ configPath: configUpdate.path || null,
3342
+ configDir,
3343
+ });
3344
+ } catch (err) {
3345
+ jsonResponse(res, 500, { ok: false, error: err.message });
3346
+ }
3347
+ return;
3348
+ }
3349
+
3350
+ if (path === "/api/project-summary") {
3351
+ try {
3352
+ const adapter = getKanbanAdapter();
3353
+ const projects = await adapter.listProjects();
3354
+ const project = projects?.[0] || null;
3355
+ if (project) {
3356
+ const tasks = await adapter.listTasks(project.id || project.name).catch(() => []);
3357
+ const completedCount = tasks.filter(
3358
+ (t) => t.status === "done" || t.status === "closed" || t.status === "completed",
3359
+ ).length;
3360
+ jsonResponse(res, 200, {
3361
+ ok: true,
3362
+ data: {
3363
+ id: project.id || project.name,
3364
+ name: project.name || project.title || project.id,
3365
+ description: project.description || project.body || null,
3366
+ taskCount: tasks.length,
3367
+ completedCount,
3368
+ },
3369
+ });
3370
+ } else {
3371
+ jsonResponse(res, 200, { ok: true, data: null });
3372
+ }
3373
+ } catch (err) {
3374
+ jsonResponse(res, 200, { ok: true, data: null });
3375
+ }
3376
+ return;
3377
+ }
3378
+
3379
+ if (path === "/api/project-sync/metrics") {
3380
+ try {
3381
+ const syncEngine = uiDeps.getSyncEngine?.() || null;
3382
+ jsonResponse(res, 200, {
3383
+ ok: true,
3384
+ data: {
3385
+ webhook: getProjectSyncWebhookMetrics(),
3386
+ syncEngine: syncEngine?.getStatus?.()?.metrics || null,
3387
+ },
3388
+ });
3389
+ } catch (err) {
3390
+ jsonResponse(res, 500, { ok: false, error: err.message });
3391
+ }
3392
+ return;
3393
+ }
3394
+
3395
+ if (path === "/api/command") {
3396
+ try {
3397
+ const body = await readJsonBody(req);
3398
+ const command = (body?.command || "").trim();
3399
+ if (!command) {
3400
+ jsonResponse(res, 400, { ok: false, error: "command is required" });
3401
+ return;
3402
+ }
3403
+ const ALLOWED_CMD_PREFIXES = [
3404
+ "/status",
3405
+ "/health",
3406
+ "/plan",
3407
+ "/logs",
3408
+ "/menu",
3409
+ "/tasks",
3410
+ "/start",
3411
+ "/stop",
3412
+ "/pause",
3413
+ "/resume",
3414
+ "/sdk",
3415
+ "/kanban",
3416
+ "/region",
3417
+ "/deploy",
3418
+ "/help",
3419
+ "/starttask",
3420
+ "/stoptask",
3421
+ "/retrytask",
3422
+ "/parallelism",
3423
+ "/sentinel",
3424
+ "/hooks",
3425
+ "/version",
3426
+ ];
3427
+ const cmdBase = command.split(/\s/)[0].toLowerCase();
3428
+ if (!ALLOWED_CMD_PREFIXES.some(p => cmdBase === p || cmdBase.startsWith(p + " "))) {
3429
+ jsonResponse(res, 400, { ok: false, error: `Command not allowed: ${cmdBase}` });
3430
+ return;
3431
+ }
3432
+ const handler = uiDeps.handleUiCommand;
3433
+ if (typeof handler === "function") {
3434
+ const result = await handler(command);
3435
+ jsonResponse(res, 200, { ok: true, data: result || null, command });
3436
+ } else {
3437
+ // No command handler wired — acknowledge and broadcast refresh
3438
+ jsonResponse(res, 200, {
3439
+ ok: true,
3440
+ data: null,
3441
+ command,
3442
+ message: "Command queued. Check status for results.",
3443
+ });
3444
+ }
3445
+ broadcastUiEvent(["overview", "executor", "tasks"], "invalidate", {
3446
+ reason: "command-executed",
3447
+ command,
3448
+ });
3449
+ } catch (err) {
3450
+ jsonResponse(res, 500, { ok: false, error: err.message });
3451
+ }
3452
+ return;
3453
+ }
3454
+
3455
+ if (path === "/api/tasks/retry") {
3456
+ try {
3457
+ const body = await readJsonBody(req);
3458
+ const taskId = body?.taskId || body?.id;
3459
+ if (!taskId) {
3460
+ jsonResponse(res, 400, { ok: false, error: "taskId is required" });
3461
+ return;
3462
+ }
3463
+ const executor = uiDeps.getInternalExecutor?.();
3464
+ if (!executor) {
3465
+ jsonResponse(res, 400, {
3466
+ ok: false,
3467
+ error: "Internal executor not enabled.",
3468
+ });
3469
+ return;
3470
+ }
3471
+ const adapter = getKanbanAdapter();
3472
+ const task = await adapter.getTask(taskId);
3473
+ if (!task) {
3474
+ jsonResponse(res, 404, { ok: false, error: "Task not found." });
3475
+ return;
3476
+ }
3477
+ if (typeof adapter.updateTask === "function") {
3478
+ await adapter.updateTask(taskId, { status: "todo" });
3479
+ } else if (typeof adapter.updateTaskStatus === "function") {
3480
+ await adapter.updateTaskStatus(taskId, "todo");
3481
+ }
3482
+ executor.executeTask(task).catch((error) => {
3483
+ console.warn(
3484
+ `[telegram-ui] failed to retry task ${taskId}: ${error.message}`,
3485
+ );
3486
+ });
3487
+ jsonResponse(res, 200, { ok: true, taskId });
3488
+ broadcastUiEvent(
3489
+ ["tasks", "overview", "executor", "agents"],
3490
+ "invalidate",
3491
+ { reason: "task-retried", taskId },
3492
+ );
3493
+ } catch (err) {
3494
+ jsonResponse(res, 500, { ok: false, error: err.message });
3495
+ }
3496
+ return;
3497
+ }
3498
+
3499
+ if (path === "/api/executor/dispatch") {
3500
+ try {
3501
+ const executor = uiDeps.getInternalExecutor?.();
3502
+ if (!executor) {
3503
+ jsonResponse(res, 400, {
3504
+ ok: false,
3505
+ error: "Internal executor not enabled.",
3506
+ });
3507
+ return;
3508
+ }
3509
+ const body = await readJsonBody(req);
3510
+ const taskId = (body?.taskId || "").trim();
3511
+ const prompt = (body?.prompt || "").trim();
3512
+ if (!taskId && !prompt) {
3513
+ jsonResponse(res, 400, {
3514
+ ok: false,
3515
+ error: "taskId or prompt is required",
3516
+ });
3517
+ return;
3518
+ }
3519
+ const status = executor.getStatus?.() || {};
3520
+ const freeSlots =
3521
+ (status.maxParallel || 0) - (status.activeSlots || 0);
3522
+ if (freeSlots <= 0) {
3523
+ jsonResponse(res, 409, { ok: false, error: "No free slots" });
3524
+ return;
3525
+ }
3526
+ if (taskId) {
3527
+ const adapter = getKanbanAdapter();
3528
+ const task = await adapter.getTask(taskId);
3529
+ if (!task) {
3530
+ jsonResponse(res, 404, { ok: false, error: "Task not found." });
3531
+ return;
3532
+ }
3533
+ executor.executeTask(task).catch((error) => {
3534
+ console.warn(
3535
+ `[telegram-ui] dispatch failed for ${taskId}: ${error.message}`,
3536
+ );
3537
+ });
3538
+ jsonResponse(res, 200, {
3539
+ ok: true,
3540
+ slotIndex: status.activeSlots || 0,
3541
+ taskId,
3542
+ });
3543
+ } else {
3544
+ // Ad-hoc prompt dispatch via command handler
3545
+ const handler = uiDeps.handleUiCommand;
3546
+ if (typeof handler === "function") {
3547
+ const result = await handler(`/prompt ${prompt}`);
3548
+ jsonResponse(res, 200, {
3549
+ ok: true,
3550
+ slotIndex: status.activeSlots || 0,
3551
+ data: result || null,
3552
+ });
3553
+ } else {
3554
+ jsonResponse(res, 400, {
3555
+ ok: false,
3556
+ error: "Prompt dispatch not available — no command handler.",
3557
+ });
3558
+ return;
3559
+ }
3560
+ }
3561
+ broadcastUiEvent(
3562
+ ["executor", "overview", "agents", "tasks"],
3563
+ "invalidate",
3564
+ { reason: "task-dispatched", taskId: taskId || "(ad-hoc)" },
3565
+ );
3566
+ } catch (err) {
3567
+ jsonResponse(res, 500, { ok: false, error: err.message });
3568
+ }
3569
+ return;
3570
+ }
3571
+
3572
+ if (path === "/api/executor/stop-slot") {
3573
+ try {
3574
+ const executor = uiDeps.getInternalExecutor?.();
3575
+ if (!executor) {
3576
+ jsonResponse(res, 400, {
3577
+ ok: false,
3578
+ error: "Internal executor not enabled.",
3579
+ });
3580
+ return;
3581
+ }
3582
+ const body = await readJsonBody(req);
3583
+ const slot = Number(body?.slot ?? -1);
3584
+ if (typeof executor.stopSlot === "function") {
3585
+ await executor.stopSlot(slot);
3586
+ } else if (typeof executor.cancelSlot === "function") {
3587
+ await executor.cancelSlot(slot);
3588
+ } else {
3589
+ jsonResponse(res, 400, {
3590
+ ok: false,
3591
+ error: "Executor does not support stop-slot.",
3592
+ });
3593
+ return;
3594
+ }
3595
+ jsonResponse(res, 200, { ok: true, slot });
3596
+ broadcastUiEvent(["executor", "overview", "agents"], "invalidate", {
3597
+ reason: "slot-stopped",
3598
+ slot,
3599
+ });
3600
+ } catch (err) {
3601
+ jsonResponse(res, 500, { ok: false, error: err.message });
3602
+ }
3603
+ return;
3604
+ }
3605
+
3606
+ // ── Session API endpoints ──────────────────────────────────────────────
3607
+
3608
+ if (path === "/api/sessions" && req.method === "GET") {
3609
+ try {
3610
+ const tracker = getSessionTracker();
3611
+ let sessions = tracker.listAllSessions();
3612
+ const typeFilter = url.searchParams.get("type");
3613
+ const statusFilter = url.searchParams.get("status");
3614
+ if (typeFilter) sessions = sessions.filter((s) => s.type === typeFilter);
3615
+ if (statusFilter) sessions = sessions.filter((s) => s.status === statusFilter);
3616
+ jsonResponse(res, 200, { ok: true, sessions });
3617
+ } catch (err) {
3618
+ jsonResponse(res, 500, { ok: false, error: err.message });
3619
+ }
3620
+ return;
3621
+ }
3622
+
3623
+ if (path === "/api/sessions/create" && req.method === "POST") {
3624
+ try {
3625
+ const body = await readJsonBody(req);
3626
+ const type = body?.type || "manual";
3627
+ const id = `${type}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
3628
+ const tracker = getSessionTracker();
3629
+ const session = tracker.createSession({
3630
+ id,
3631
+ type,
3632
+ metadata: { prompt: body?.prompt },
3633
+ });
3634
+ jsonResponse(res, 200, { ok: true, session: { id: session.id, type: session.type, status: session.status } });
3635
+ broadcastUiEvent(["sessions"], "invalidate", { reason: "session-created", sessionId: id });
3636
+ } catch (err) {
3637
+ jsonResponse(res, 500, { ok: false, error: err.message });
3638
+ }
3639
+ return;
3640
+ }
3641
+
3642
+ // Parameterized session routes: /api/sessions/:id[/action]
3643
+ const sessionMatch = path.match(/^\/api\/sessions\/([^/]+)(?:\/(.+))?$/);
3644
+ if (sessionMatch) {
3645
+ const sessionId = decodeURIComponent(sessionMatch[1]);
3646
+ const action = sessionMatch[2] || null;
3647
+
3648
+ if (!action && req.method === "GET") {
3649
+ try {
3650
+ const tracker = getSessionTracker();
3651
+ const session = tracker.getSessionMessages(sessionId);
3652
+ if (!session) {
3653
+ jsonResponse(res, 404, { ok: false, error: "Session not found" });
3654
+ return;
3655
+ }
3656
+ jsonResponse(res, 200, { ok: true, session });
3657
+ } catch (err) {
3658
+ jsonResponse(res, 500, { ok: false, error: err.message });
3659
+ }
3660
+ return;
3661
+ }
3662
+
3663
+ if (action === "message" && req.method === "POST") {
3664
+ try {
3665
+ const tracker = getSessionTracker();
3666
+ const session = tracker.getSessionById(sessionId);
3667
+ if (!session) {
3668
+ jsonResponse(res, 404, { ok: false, error: "Session not found" });
3669
+ return;
3670
+ }
3671
+ if (session.status === "paused" || session.status === "archived") {
3672
+ jsonResponse(res, 400, { ok: false, error: `Session is ${session.status}` });
3673
+ return;
3674
+ }
3675
+ const body = await readJsonBody(req);
3676
+ const content = body?.content;
3677
+ if (!content) {
3678
+ jsonResponse(res, 400, { ok: false, error: "content is required" });
3679
+ return;
3680
+ }
3681
+ const messageId = `msg-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
3682
+ tracker.recordEvent(sessionId, { role: "user", content, timestamp: new Date().toISOString() });
3683
+
3684
+ // Forward to primary agent if applicable
3685
+ if (session.type === "primary") {
3686
+ const exec = uiDeps.execPrimaryPrompt;
3687
+ if (exec) {
3688
+ try {
3689
+ const result = await exec(content);
3690
+ if (result) {
3691
+ tracker.recordEvent(sessionId, {
3692
+ role: "assistant",
3693
+ content: typeof result === "string" ? result : JSON.stringify(result),
3694
+ timestamp: new Date().toISOString(),
3695
+ });
3696
+ }
3697
+ } catch { /* best-effort forwarding */ }
3698
+ }
3699
+ }
3700
+
3701
+ jsonResponse(res, 200, { ok: true, messageId });
3702
+ broadcastUiEvent(["sessions"], "invalidate", { reason: "session-message", sessionId });
3703
+ } catch (err) {
3704
+ jsonResponse(res, 500, { ok: false, error: err.message });
3705
+ }
3706
+ return;
3707
+ }
3708
+
3709
+ if (action === "archive" && req.method === "POST") {
3710
+ try {
3711
+ const tracker = getSessionTracker();
3712
+ const session = tracker.getSessionById(sessionId);
3713
+ if (!session) {
3714
+ jsonResponse(res, 404, { ok: false, error: "Session not found" });
3715
+ return;
3716
+ }
3717
+ tracker.updateSessionStatus(sessionId, "archived");
3718
+ jsonResponse(res, 200, { ok: true });
3719
+ broadcastUiEvent(["sessions"], "invalidate", {
3720
+ reason: "session-archived",
3721
+ sessionId,
3722
+ });
3723
+ } catch (err) {
3724
+ jsonResponse(res, 500, { ok: false, error: err.message });
3725
+ }
3726
+ return;
3727
+ }
3728
+
3729
+ if (action === "resume" && req.method === "POST") {
3730
+ try {
3731
+ const tracker = getSessionTracker();
3732
+ const session = tracker.getSessionById(sessionId);
3733
+ if (!session) {
3734
+ jsonResponse(res, 404, { ok: false, error: "Session not found" });
3735
+ return;
3736
+ }
3737
+ tracker.updateSessionStatus(sessionId, "active");
3738
+ jsonResponse(res, 200, { ok: true });
3739
+ broadcastUiEvent(["sessions"], "invalidate", { reason: "session-resumed", sessionId });
3740
+ } catch (err) {
3741
+ jsonResponse(res, 500, { ok: false, error: err.message });
3742
+ }
3743
+ return;
3744
+ }
3745
+
3746
+ if (action === "diff" && req.method === "GET") {
3747
+ try {
3748
+ const tracker = getSessionTracker();
3749
+ const session = tracker.getSessionById(sessionId);
3750
+ if (!session) {
3751
+ jsonResponse(res, 404, { ok: false, error: "Session not found" });
3752
+ return;
3753
+ }
3754
+ const worktreePath = session.metadata?.worktreePath;
3755
+ if (!worktreePath || !existsSync(worktreePath)) {
3756
+ jsonResponse(res, 200, { ok: true, diff: { files: [], totalFiles: 0, totalAdditions: 0, totalDeletions: 0, formatted: "(no worktree)" }, summary: "(no worktree)", commits: [] });
3757
+ return;
3758
+ }
3759
+ const stats = collectDiffStats(worktreePath);
3760
+ const summary = getCompactDiffSummary(worktreePath);
3761
+ const commits = getRecentCommits(worktreePath);
3762
+ jsonResponse(res, 200, { ok: true, diff: stats, summary, commits });
3763
+ } catch (err) {
3764
+ jsonResponse(res, 500, { ok: false, error: err.message });
3765
+ }
3766
+ return;
3767
+ }
3768
+ }
3769
+
3770
+ jsonResponse(res, 404, { ok: false, error: "Unknown API endpoint" });
3771
+ }
3772
+
3773
+ async function handleStatic(req, res, url) {
3774
+ if (!requireAuth(req)) {
3775
+ textResponse(res, 401, "Unauthorized");
3776
+ return;
3777
+ }
3778
+
3779
+ const pathname = url.pathname === "/" ? "/index.html" : url.pathname;
3780
+ const filePath = resolve(uiRoot, `.${pathname}`);
3781
+
3782
+ if (!filePath.startsWith(uiRoot)) {
3783
+ textResponse(res, 403, "Forbidden");
3784
+ return;
3785
+ }
3786
+
3787
+ if (!existsSync(filePath)) {
3788
+ textResponse(res, 404, "Not Found");
3789
+ return;
3790
+ }
3791
+
3792
+ try {
3793
+ const ext = extname(filePath).toLowerCase();
3794
+ const contentType = MIME_TYPES[ext] || "application/octet-stream";
3795
+ const data = await readFile(filePath);
3796
+ res.writeHead(200, {
3797
+ "Content-Type": contentType,
3798
+ "Access-Control-Allow-Origin": "*",
3799
+ "Cache-Control": "no-store",
3800
+ });
3801
+ res.end(data);
3802
+ } catch (err) {
3803
+ textResponse(res, 500, `Failed to load ${pathname}: ${err.message}`);
3804
+ }
3805
+ }
3806
+
3807
+ export async function startTelegramUiServer(options = {}) {
3808
+ if (uiServer) return uiServer;
3809
+
3810
+ const port = Number(options.port || getDefaultPort());
3811
+ if (!port) return null;
3812
+
3813
+ injectUiDependencies(options.dependencies || {});
3814
+
3815
+ // Auto-TLS: generate a self-signed cert for HTTPS unless explicitly disabled
3816
+ let tlsOpts = null;
3817
+ if (!isTlsDisabled()) {
3818
+ tlsOpts = ensureSelfSignedCert();
3819
+ }
3820
+
3821
+ const requestHandler = async (req, res) => {
3822
+ const url = new URL(
3823
+ req.url || "/",
3824
+ `http://${req.headers.host || "localhost"}`,
3825
+ );
3826
+ const webhookPath = getGitHubWebhookPath();
3827
+
3828
+ // Token exchange: ?token=<hex> → set session cookie and redirect to clean URL
3829
+ const qToken = url.searchParams.get("token");
3830
+ if (qToken && sessionToken) {
3831
+ const provided = Buffer.from(qToken);
3832
+ const expected = Buffer.from(sessionToken);
3833
+ if (provided.length === expected.length && timingSafeEqual(provided, expected)) {
3834
+ const secure = uiServerTls ? "; Secure" : "";
3835
+ res.writeHead(302, {
3836
+ "Set-Cookie": `ve_session=${sessionToken}; HttpOnly; SameSite=Lax; Path=/; Max-Age=86400${secure}`,
3837
+ Location: url.pathname || "/",
3838
+ });
3839
+ res.end();
3840
+ return;
3841
+ }
3842
+ }
3843
+
3844
+ if (url.pathname === webhookPath) {
3845
+ await handleGitHubProjectWebhook(req, res);
3846
+ return;
3847
+ }
3848
+
3849
+ if (url.pathname.startsWith("/api/")) {
3850
+ await handleApi(req, res, url);
3851
+ return;
3852
+ }
3853
+
3854
+ // Telegram initData exchange: ?tgWebAppData=... or ?initData=... → set session cookie and redirect
3855
+ const initDataQuery =
3856
+ url.searchParams.get("tgWebAppData") ||
3857
+ url.searchParams.get("initData") ||
3858
+ "";
3859
+ if (
3860
+ initDataQuery &&
3861
+ sessionToken &&
3862
+ req.method === "GET"
3863
+ ) {
3864
+ const token = process.env.TELEGRAM_BOT_TOKEN || "";
3865
+ if (validateInitData(String(initDataQuery), token)) {
3866
+ const secure = uiServerTls ? "; Secure" : "";
3867
+ const cleanUrl = new URL(url.toString());
3868
+ cleanUrl.searchParams.delete("tgWebAppData");
3869
+ cleanUrl.searchParams.delete("initData");
3870
+ const redirectPath =
3871
+ cleanUrl.pathname + (cleanUrl.searchParams.toString() ? `?${cleanUrl.searchParams.toString()}` : "");
3872
+ res.writeHead(302, {
3873
+ "Set-Cookie": `ve_session=${sessionToken}; HttpOnly; SameSite=Lax; Path=/; Max-Age=86400${secure}`,
3874
+ Location: redirectPath || "/",
3875
+ });
3876
+ res.end();
3877
+ return;
3878
+ }
3879
+ }
3880
+ await handleStatic(req, res, url);
3881
+ };
3882
+
3883
+ if (tlsOpts) {
3884
+ uiServer = createHttpsServer(tlsOpts, requestHandler);
3885
+ uiServerTls = true;
3886
+ } else {
3887
+ uiServer = createServer(requestHandler);
3888
+ uiServerTls = false;
3889
+ }
3890
+
3891
+ wsServer = new WebSocketServer({ noServer: true });
3892
+ wsServer.on("connection", (socket) => {
3893
+ socket.__channels = new Set(["*"]);
3894
+ socket.__lastPong = Date.now();
3895
+ socket.__lastPing = null;
3896
+ socket.__missedPongs = 0;
3897
+ wsClients.add(socket);
3898
+ sendWsMessage(socket, {
3899
+ type: "hello",
3900
+ channels: ["*"],
3901
+ payload: { connected: true },
3902
+ ts: Date.now(),
3903
+ });
3904
+
3905
+ socket.on("message", (raw) => {
3906
+ try {
3907
+ const message = JSON.parse(String(raw || "{}"));
3908
+ if (message?.type === "subscribe" && Array.isArray(message.channels)) {
3909
+ const channels = message.channels
3910
+ .filter((item) => typeof item === "string" && item.trim())
3911
+ .map((item) => item.trim());
3912
+ socket.__channels = new Set(channels.length ? channels : ["*"]);
3913
+ sendWsMessage(socket, {
3914
+ type: "subscribed",
3915
+ channels: Array.from(socket.__channels),
3916
+ payload: { ok: true },
3917
+ ts: Date.now(),
3918
+ });
3919
+ } else if (message?.type === "ping" && typeof message.ts === "number") {
3920
+ // Client ping → echo back as pong
3921
+ sendWsMessage(socket, { type: "pong", ts: message.ts });
3922
+ } else if (message?.type === "pong" && typeof message.ts === "number") {
3923
+ // Client pong in response to server ping
3924
+ socket.__lastPong = Date.now();
3925
+ socket.__missedPongs = 0;
3926
+ } else if (message?.type === "subscribe-logs") {
3927
+ const logType = message.logType === "agent" ? "agent" : "system";
3928
+ const query = typeof message.query === "string" ? message.query : "";
3929
+ startLogStream(socket, logType, query);
3930
+ } else if (message?.type === "unsubscribe-logs") {
3931
+ stopLogStream(socket);
3932
+ }
3933
+ } catch {
3934
+ // Ignore malformed websocket payloads
3935
+ }
3936
+ });
3937
+
3938
+ socket.on("close", () => {
3939
+ stopLogStream(socket);
3940
+ wsClients.delete(socket);
3941
+ });
3942
+
3943
+ socket.on("error", () => {
3944
+ stopLogStream(socket);
3945
+ wsClients.delete(socket);
3946
+ });
3947
+ });
3948
+
3949
+ startWsHeartbeat();
3950
+
3951
+ uiServer.on("upgrade", (req, socket, head) => {
3952
+ const url = new URL(
3953
+ req.url || "/",
3954
+ `http://${req.headers.host || "localhost"}`,
3955
+ );
3956
+ if (url.pathname !== "/ws") {
3957
+ socket.destroy();
3958
+ return;
3959
+ }
3960
+ if (!requireWsAuth(req, url)) {
3961
+ try {
3962
+ socket.write("HTTP/1.1 401 Unauthorized\r\n\r\n");
3963
+ } catch {
3964
+ // no-op
3965
+ }
3966
+ socket.destroy();
3967
+ return;
3968
+ }
3969
+ wsServer.handleUpgrade(req, socket, head, (ws) => {
3970
+ wsServer.emit("connection", ws, req);
3971
+ });
3972
+ });
3973
+
3974
+ // Generate a session token for browser-based access (no config needed)
3975
+ sessionToken = randomBytes(32).toString("hex");
3976
+
3977
+ await new Promise((resolveReady, rejectReady) => {
3978
+ uiServer.once("error", rejectReady);
3979
+ uiServer.listen(port, options.host || DEFAULT_HOST, () => {
3980
+ resolveReady();
3981
+ });
3982
+ });
3983
+
3984
+ const publicHost = options.publicHost || process.env.TELEGRAM_UI_PUBLIC_HOST;
3985
+ const lanIp = getLocalLanIp();
3986
+ const host = publicHost || lanIp;
3987
+ const actualPort = uiServer.address().port;
3988
+ const protocol = uiServerTls
3989
+ ? "https"
3990
+ : publicHost &&
3991
+ !publicHost.startsWith("192.") &&
3992
+ !publicHost.startsWith("10.") &&
3993
+ !publicHost.startsWith("172.")
3994
+ ? "https"
3995
+ : "http";
3996
+ uiServerUrl = `${protocol}://${host}:${actualPort}`;
3997
+ console.log(`[telegram-ui] server listening on ${uiServerUrl}`);
3998
+ if (uiServerTls) {
3999
+ console.log(`[telegram-ui] TLS enabled (self-signed) — Telegram WebApp buttons will use HTTPS`);
4000
+ }
4001
+ console.log(`[telegram-ui] LAN access: ${protocol}://${lanIp}:${actualPort}`);
4002
+ console.log(`[telegram-ui] Browser access: ${protocol}://${lanIp}:${actualPort}/?token=${sessionToken}`);
4003
+
4004
+ // Check firewall rules for the UI port
4005
+ firewallState = await checkFirewall(actualPort);
4006
+ if (firewallState) {
4007
+ if (firewallState.blocked) {
4008
+ console.warn(
4009
+ `[telegram-ui] ⚠️ Port ${actualPort}/tcp appears BLOCKED by ${firewallState.firewall} for LAN access.`,
4010
+ );
4011
+ console.warn(
4012
+ `[telegram-ui] To fix, run: ${firewallState.allowCmd}`,
4013
+ );
4014
+ } else {
4015
+ console.log(`[telegram-ui] Firewall (${firewallState.firewall}): port ${actualPort}/tcp is allowed`);
4016
+ }
4017
+ }
4018
+
4019
+ // Start cloudflared tunnel for trusted TLS (Telegram Mini App requires valid cert)
4020
+ if (uiServerTls) {
4021
+ const tUrl = await startTunnel(actualPort);
4022
+ if (tUrl) {
4023
+ console.log(`[telegram-ui] Telegram Mini App URL: ${tUrl}`);
4024
+ if (firewallState?.blocked) {
4025
+ console.log(
4026
+ `[telegram-ui] ℹ️ Tunnel active — Telegram Mini App works regardless of firewall. ` +
4027
+ `LAN browser access still requires port ${actualPort}/tcp to be open.`,
4028
+ );
4029
+ }
4030
+ }
4031
+ }
4032
+
4033
+ return uiServer;
4034
+ }
4035
+
4036
+ export function stopTelegramUiServer() {
4037
+ if (!uiServer) return;
4038
+ stopTunnel();
4039
+ stopWsHeartbeat();
4040
+ for (const socket of wsClients) {
4041
+ try {
4042
+ stopLogStream(socket);
4043
+ socket.close();
4044
+ } catch {
4045
+ // best effort
4046
+ }
4047
+ }
4048
+ wsClients.clear();
4049
+ // Clean up any remaining log stream poll timers
4050
+ for (const [, streamer] of logStreamers) {
4051
+ if (streamer.pollTimer) clearInterval(streamer.pollTimer);
4052
+ }
4053
+ logStreamers.clear();
4054
+ if (wsServer) {
4055
+ try {
4056
+ wsServer.close();
4057
+ } catch {
4058
+ // best effort
4059
+ }
4060
+ }
4061
+ wsServer = null;
4062
+ try {
4063
+ uiServer.close();
4064
+ } catch {
4065
+ /* best effort */
4066
+ }
4067
+ uiServer = null;
4068
+ uiServerTls = false;
4069
+ sessionToken = "";
4070
+ resetProjectSyncWebhookMetrics();
4071
+ }
4072
+
4073
+ export { getLocalLanIp };