bosun 0.28.0 → 0.28.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/telegram-bot.mjs CHANGED
@@ -132,6 +132,18 @@ const statusBoardStatePath = resolve(
132
132
  const fwCooldownPath = resolve(repoRoot, ".cache", "ve-fw-cooldown.json");
133
133
  const FW_COOLDOWN_MS = 24 * 60 * 60 * 1000; // 24 hours
134
134
 
135
+ // ── Message History Auto-Cleanup ──────────────────────────────────────────
136
+ const msgHistoryPath = resolve(repoRoot, ".cache", "ve-message-history.json");
137
+ // Days to keep bot messages in chat. 0 = disabled. Default: 3 days.
138
+ const HISTORY_RETENTION_DAYS = (() => {
139
+ const v = Number(process.env.TELEGRAM_HISTORY_RETENTION_DAYS ?? "3");
140
+ return Number.isFinite(v) && v > 0 ? v : 0;
141
+ })();
142
+ const HISTORY_RETENTION_MS = HISTORY_RETENTION_DAYS * 24 * 60 * 60 * 1000;
143
+ const HISTORY_CLEANUP_INTERVAL_MS = 6 * 60 * 60 * 1000; // run every 6 hours
144
+ const HISTORY_INITIAL_DELAY_MS = 2 * 60 * 1000; // wait 2 min after boot
145
+ const HISTORY_MAX_TRACKED = 10_000; // safety cap
146
+
135
147
  function resolveVeKanbanPs1Path() {
136
148
  const modulePath = resolve(BosunDir, "ve-kanban.ps1");
137
149
  if (existsSync(modulePath)) return modulePath;
@@ -799,7 +811,7 @@ let agentChatId = null; // chat where agent is running
799
811
  // ── Sticky UI menu state (keep /menu accessible at bottom) ─────────────────
800
812
  const stickyMenuState = new Map();
801
813
  const stickyMenuTimers = new Map();
802
- const STICKY_MENU_BUMP_MS = 200;
814
+ const STICKY_MENU_BUMP_MS = 600;
803
815
 
804
816
  // ── Queues ──────────────────────────────────────────────────────────────────
805
817
 
@@ -1017,7 +1029,10 @@ function scheduleStickyMenuBump(chatId, lastMessageId) {
1017
1029
  if (!state?.enabled || !state?.screenId) return;
1018
1030
  if (lastMessageId && state.messageId && lastMessageId === state.messageId)
1019
1031
  return;
1020
- if (stickyMenuTimers.has(chatId)) return;
1032
+ // Debounce: cancel existing timer and schedule a fresh one so rapid
1033
+ // messages don't cause multiple bumps (reduces flicker)
1034
+ const existing = stickyMenuTimers.get(chatId);
1035
+ if (existing) clearTimeout(existing);
1021
1036
  const timer = setTimeout(() => {
1022
1037
  stickyMenuTimers.delete(chatId);
1023
1038
  bumpStickyMenu(chatId).catch(() => {});
@@ -1028,6 +1043,8 @@ function scheduleStickyMenuBump(chatId, lastMessageId) {
1028
1043
  async function bumpStickyMenu(chatId) {
1029
1044
  const state = stickyMenuState.get(chatId);
1030
1045
  if (!state?.enabled || !state?.screenId) return;
1046
+ // On bump (new messages pushed menu up), clear any inline result
1047
+ // so the menu shows clean navigation when it reappears at the bottom
1031
1048
  await showUiScreen(chatId, null, state.screenId, state.params || {}, {
1032
1049
  sticky: true,
1033
1050
  });
@@ -1112,6 +1129,7 @@ async function sendDirect(chatId, text, options = {}) {
1112
1129
  const data = await res.json();
1113
1130
  if (data.ok && data.result?.message_id) {
1114
1131
  lastMessageId = data.result.message_id;
1132
+ recordSentMessage(chatId, lastMessageId);
1115
1133
  }
1116
1134
  } catch (err) {
1117
1135
  console.warn(`[telegram-bot] send JSON parse error: ${err.message}`);
@@ -1210,6 +1228,85 @@ async function deleteDirect(chatId, messageId) {
1210
1228
  }
1211
1229
  }
1212
1230
 
1231
+ // ── Message History Helpers ───────────────────────────────────────────────────
1232
+
1233
+ /** Lazy-loaded in-memory list of sent message records. */
1234
+ let _msgHistory = null;
1235
+ let _msgHistoryDirty = false;
1236
+
1237
+ function _loadMsgHistory() {
1238
+ if (_msgHistory !== null) return;
1239
+ try {
1240
+ const raw = readFileSync(msgHistoryPath, "utf8");
1241
+ const data = JSON.parse(raw);
1242
+ _msgHistory = Array.isArray(data.messages) ? data.messages : [];
1243
+ } catch {
1244
+ _msgHistory = [];
1245
+ }
1246
+ }
1247
+
1248
+ function _saveMsgHistory() {
1249
+ if (!_msgHistory) return;
1250
+ try {
1251
+ mkdirSync(resolve(repoRoot, ".cache"), { recursive: true });
1252
+ writeFileSync(
1253
+ msgHistoryPath,
1254
+ JSON.stringify({ messages: _msgHistory }, null, 2),
1255
+ "utf8",
1256
+ );
1257
+ _msgHistoryDirty = false;
1258
+ } catch {
1259
+ /* best effort */
1260
+ }
1261
+ }
1262
+
1263
+ /**
1264
+ * Record a newly sent message ID so it can be cleaned up later.
1265
+ * No-op when HISTORY_RETENTION_MS is 0 (feature disabled).
1266
+ */
1267
+ function recordSentMessage(chatId, messageId) {
1268
+ if (!HISTORY_RETENTION_MS || !messageId) return;
1269
+ _loadMsgHistory();
1270
+ _msgHistory.push({ chat_id: chatId, message_id: messageId, sent_at: Date.now() });
1271
+ if (_msgHistory.length > HISTORY_MAX_TRACKED) {
1272
+ _msgHistory = _msgHistory.slice(-HISTORY_MAX_TRACKED);
1273
+ }
1274
+ _msgHistoryDirty = true;
1275
+ }
1276
+
1277
+ /**
1278
+ * Delete all tracked bot messages older than HISTORY_RETENTION_MS.
1279
+ * Failures are silently swallowed — Telegram may refuse deletes for messages
1280
+ * older than 48 h in private chats; we just skip those gracefully.
1281
+ */
1282
+ async function pruneMessageHistory() {
1283
+ if (!HISTORY_RETENTION_MS) return;
1284
+ _loadMsgHistory();
1285
+ if (_msgHistoryDirty) _saveMsgHistory();
1286
+
1287
+ const cutoff = Date.now() - HISTORY_RETENTION_MS;
1288
+ const toDelete = _msgHistory.filter((m) => m.sent_at < cutoff);
1289
+ if (toDelete.length === 0) return;
1290
+
1291
+ console.log(
1292
+ `[telegram-bot] auto-cleanup: deleting ${toDelete.length} message(s) older than ${HISTORY_RETENTION_DAYS}d`,
1293
+ );
1294
+
1295
+ let i = 0;
1296
+ for (const m of toDelete) {
1297
+ await deleteDirect(m.chat_id, m.message_id);
1298
+ i++;
1299
+ // Pace at ~20 deletes/s to stay well under Telegram’s 30 msg/s limit
1300
+ if (i % 20 === 0) await new Promise((r) => setTimeout(r, 1000));
1301
+ }
1302
+
1303
+ _msgHistory = _msgHistory.filter((m) => m.sent_at >= cutoff);
1304
+ _saveMsgHistory();
1305
+ console.log(
1306
+ `[telegram-bot] auto-cleanup: done. ${_msgHistory.length} messages remaining in history.`,
1307
+ );
1308
+ }
1309
+
1213
1310
  /**
1214
1311
  * Answer a Telegram callback query (required to dismiss the "loading" indicator).
1215
1312
  * @param {string} callbackQueryId - The callback_query.id from the update
@@ -2586,6 +2683,10 @@ const COMMANDS = {
2586
2683
  desc: "Show all active monitor/task/review/conflict agents",
2587
2684
  },
2588
2685
  "/logs": { handler: cmdLogs, desc: "Recent monitor logs" },
2686
+ "/telemetry": {
2687
+ handler: cmdTelemetry,
2688
+ desc: "Telemetry summary: /telemetry [errors|executors|alerts]",
2689
+ },
2589
2690
  "/agentlogs": {
2590
2691
  handler: cmdAgentLogs,
2591
2692
  desc: "Agent output for branch: /agentlogs <branch>",
@@ -3354,6 +3455,39 @@ function parseUiAction(data) {
3354
3455
  return { type, rest, raw: payload };
3355
3456
  }
3356
3457
 
3458
+ // ── Set of commands whose output can be captured and rendered inline ─────────
3459
+ const INLINE_CAPTURABLE_COMMANDS = new Set([
3460
+ "/status", "/health", "/tasks", "/logs", "/agents",
3461
+ "/branches", "/diff", "/threads", "/sdk", "/kanban",
3462
+ "/executor", "/hooks", "/sentinel", "/anomalies",
3463
+ "/worktrees", "/region", "/presence", "/coordinator",
3464
+ "/history", "/metrics", "/repos", "/container",
3465
+ "/whatsapp", "/model",
3466
+ ]);
3467
+
3468
+ /**
3469
+ * Capture the text output of a command without sending it to Telegram.
3470
+ * Returns the captured text (joined with double-newline) or null on failure.
3471
+ */
3472
+ async function captureCommandOutput(command) {
3473
+ const parts = command.split(/\s+/);
3474
+ const cmd = parts[0].toLowerCase().replace(/@\w+/, "");
3475
+ const cmdArgs = parts.slice(1).join(" ");
3476
+ const entry = COMMANDS[cmd] || COMMANDS[cmd.replace(/-/g, "_")];
3477
+ if (!entry?.handler) return null;
3478
+ _uiCaptureBuffer = [];
3479
+ try {
3480
+ await entry.handler("__ui_capture__", cmdArgs);
3481
+ } catch {
3482
+ _uiCaptureBuffer = null;
3483
+ return null;
3484
+ }
3485
+ const captured = _uiCaptureBuffer;
3486
+ _uiCaptureBuffer = null;
3487
+ const content = captured.join("\n\n").trim();
3488
+ return content || null;
3489
+ }
3490
+
3357
3491
  async function dispatchUiCommand(chatId, command) {
3358
3492
  const cmd = command.split(/\s+/)[0].toLowerCase().replace(/@\w+/, "");
3359
3493
  if (FAST_COMMANDS.has(cmd)) {
@@ -3975,7 +4109,7 @@ Object.assign(UI_SCREENS, {
3975
4109
  // Monitoring & Tools
3976
4110
  [
3977
4111
  uiButton("📁 Logs & Git", uiGoAction("logs")),
3978
- uiButton("🔌 Integrations", uiGoAction("integrations")),
4112
+ uiButton("📈 Telemetry", uiGoAction("telemetry")),
3979
4113
  uiButton("🧠 Session", uiGoAction("session")),
3980
4114
  ],
3981
4115
  // Quick Actions
@@ -4026,6 +4160,27 @@ Object.assign(UI_SCREENS, {
4026
4160
  uiButton("🎯 Coordinator", uiCmdAction("/coordinator")),
4027
4161
  uiButton("📝 Logs", uiCmdAction("/logs 50")),
4028
4162
  ],
4163
+ [
4164
+ uiButton("📈 Telemetry", uiGoAction("telemetry")),
4165
+ ],
4166
+ uiNavRow("home"),
4167
+ ]),
4168
+ },
4169
+ telemetry: {
4170
+ title: "Telemetry",
4171
+ parent: "home",
4172
+ body: () =>
4173
+ "Analytics and quality signals from recent agent runs.",
4174
+ keyboard: () =>
4175
+ buildKeyboard([
4176
+ [
4177
+ uiButton("📈 Summary", uiCmdAction("/telemetry")),
4178
+ uiButton("🧯 Errors", uiCmdAction("/telemetry errors")),
4179
+ ],
4180
+ [
4181
+ uiButton("🧪 Executors", uiCmdAction("/telemetry executors")),
4182
+ uiButton("🚨 Alerts", uiCmdAction("/telemetry alerts")),
4183
+ ],
4029
4184
  uiNavRow("home"),
4030
4185
  ]),
4031
4186
  },
@@ -5286,7 +5441,22 @@ async function showUiScreen(chatId, messageId, screenId, params = {}, opts = {})
5286
5441
  ? await screen.body(ctx)
5287
5442
  : screen.body || "";
5288
5443
  const title = screen.title ? `*${screen.title}*` : "";
5289
- const text = [title, body].filter(Boolean).join("\n\n");
5444
+
5445
+ // Support inline result rendering — show command output above the keyboard
5446
+ const inlineResult = opts.result || "";
5447
+ const textParts = [title, body];
5448
+ if (inlineResult) {
5449
+ // Truncate result to leave room for title/body/keyboard in 4096 char limit
5450
+ const maxResultLen = 3200 - title.length - body.length;
5451
+ const trimmedResult = inlineResult.length > maxResultLen
5452
+ ? inlineResult.slice(0, maxResultLen - 20) + "\n…(truncated)"
5453
+ : inlineResult;
5454
+ textParts.push("━━━━━━━━━━━━━━━━━━━━━━━━");
5455
+ textParts.push(trimmedResult);
5456
+ textParts.push("━━━━━━━━━━━━━━━━━━━━━━━━");
5457
+ }
5458
+ const text = textParts.filter(Boolean).join("\n\n");
5459
+
5290
5460
  const keyboard =
5291
5461
  typeof screen.keyboard === "function"
5292
5462
  ? await screen.keyboard(ctx)
@@ -5357,10 +5527,35 @@ async function handleUiAction({ chatId, messageId, data }) {
5357
5527
  return;
5358
5528
  }
5359
5529
  if (type === "cmd") {
5530
+ const command = raw.slice(4);
5531
+ const cmd = command.split(/\s+/)[0].toLowerCase().replace(/@\w+/, "");
5532
+
5533
+ // For capturable read-only commands, render output inline in the menu message
5534
+ // instead of sending a separate message (eliminates menu disappear/reappear)
5535
+ if (INLINE_CAPTURABLE_COMMANDS.has(cmd)) {
5536
+ try {
5537
+ const captured = await captureCommandOutput(command);
5538
+ if (captured) {
5539
+ // Determine which screen to render the result in
5540
+ const state = stickyMenuState.get(chatId);
5541
+ const parentScreen = state?.screenId || "home";
5542
+ const resolvedMessageId =
5543
+ staleSticky ? null : (isStickyMenuMessage(chatId, messageId) ? messageId : null);
5544
+ await showUiScreen(chatId, resolvedMessageId || messageId, parentScreen, state?.params || {}, {
5545
+ sticky: stickyEnabled,
5546
+ result: captured,
5547
+ });
5548
+ return;
5549
+ }
5550
+ } catch {
5551
+ // Fall through to regular dispatch on capture failure
5552
+ }
5553
+ }
5554
+
5555
+ // For action commands (restart, cleanup, etc.), dispatch normally
5360
5556
  if (staleSticky && messageId) {
5361
5557
  await deleteDirect(chatId, messageId);
5362
5558
  }
5363
- const command = raw.slice(4);
5364
5559
  await dispatchUiCommand(chatId, command);
5365
5560
  return;
5366
5561
  }
@@ -5401,6 +5596,26 @@ async function handleUiAction({ chatId, messageId, data }) {
5401
5596
  return;
5402
5597
  }
5403
5598
  if (payload.type === "cmd") {
5599
+ const tokenCmd = payload.command?.split(/\s+/)[0]?.toLowerCase()?.replace(/@\w+/, "") || "";
5600
+ // Try inline rendering for capturable commands
5601
+ if (INLINE_CAPTURABLE_COMMANDS.has(tokenCmd)) {
5602
+ try {
5603
+ const captured = await captureCommandOutput(payload.command);
5604
+ if (captured) {
5605
+ const state = stickyMenuState.get(chatId);
5606
+ const parentScreen = state?.screenId || "home";
5607
+ const resolvedMessageId =
5608
+ staleSticky ? null : (isStickyMenuMessage(chatId, messageId) ? messageId : null);
5609
+ await showUiScreen(chatId, resolvedMessageId || messageId, parentScreen, state?.params || {}, {
5610
+ sticky: stickyEnabled,
5611
+ result: captured,
5612
+ });
5613
+ return;
5614
+ }
5615
+ } catch {
5616
+ // Fall through
5617
+ }
5618
+ }
5404
5619
  if (staleSticky && messageId) {
5405
5620
  await deleteDirect(chatId, messageId);
5406
5621
  }
@@ -5803,6 +6018,155 @@ async function cmdHelpFull(chatId) {
5803
6018
  await sendReply(chatId, lines.join("\n"));
5804
6019
  }
5805
6020
 
6021
+ async function readJsonlTail(path, maxLines = 2000) {
6022
+ try {
6023
+ const raw = await readFile(path, "utf8");
6024
+ if (!raw) return [];
6025
+ const lines = raw.split(/\r?\n/).filter(Boolean);
6026
+ const tail = lines.slice(-maxLines);
6027
+ return tail
6028
+ .map((line) => {
6029
+ try {
6030
+ return JSON.parse(line);
6031
+ } catch {
6032
+ return null;
6033
+ }
6034
+ })
6035
+ .filter(Boolean);
6036
+ } catch {
6037
+ return [];
6038
+ }
6039
+ }
6040
+
6041
+ function withinDays(entry, days) {
6042
+ if (!days) return true;
6043
+ const ts = Date.parse(entry?.timestamp || "");
6044
+ if (!Number.isFinite(ts)) return true;
6045
+ return ts >= Date.now() - days * 24 * 60 * 60 * 1000;
6046
+ }
6047
+
6048
+ function summarizeTelemetry(metrics, days) {
6049
+ const filtered = metrics.filter((m) => withinDays(m, days));
6050
+ if (filtered.length === 0) return null;
6051
+ const total = filtered.length;
6052
+ const success = filtered.filter(
6053
+ (m) => m.outcome?.status === "completed" || m.metrics?.success === true,
6054
+ ).length;
6055
+ const durations = filtered.map((m) => m.metrics?.duration_ms || 0);
6056
+ const avgDuration =
6057
+ durations.length > 0
6058
+ ? Math.round(
6059
+ durations.reduce((a, b) => a + b, 0) / durations.length / 1000,
6060
+ )
6061
+ : 0;
6062
+ const totalErrors = filtered.reduce(
6063
+ (sum, m) => sum + (m.error_summary?.total_errors || m.metrics?.errors || 0),
6064
+ 0,
6065
+ );
6066
+ const executors = {};
6067
+ for (const m of filtered) {
6068
+ const exec = m.executor || "unknown";
6069
+ executors[exec] = (executors[exec] || 0) + 1;
6070
+ }
6071
+ return {
6072
+ total,
6073
+ success,
6074
+ successRate: total > 0 ? Math.round((success / total) * 100) : 0,
6075
+ avgDuration,
6076
+ totalErrors,
6077
+ executors,
6078
+ };
6079
+ }
6080
+
6081
+ async function cmdTelemetry(chatId, args = "") {
6082
+ const mode = String(args || "").trim().toLowerCase();
6083
+ const days = Number(process.env.TELEMETRY_DAYS || 7);
6084
+ const logDir = resolve(repoRoot, ".cache", "agent-work-logs");
6085
+ const metricsPath = resolve(logDir, "agent-metrics.jsonl");
6086
+ const errorsPath = resolve(logDir, "agent-errors.jsonl");
6087
+ const alertsPath = resolve(logDir, "agent-alerts.jsonl");
6088
+
6089
+ if (mode === "errors") {
6090
+ const errors = (await readJsonlTail(errorsPath, 1000)).filter((e) =>
6091
+ withinDays(e, days),
6092
+ );
6093
+ if (errors.length === 0) {
6094
+ return sendReply(chatId, "No error telemetry found.");
6095
+ }
6096
+ const byFingerprint = new Map();
6097
+ for (const e of errors) {
6098
+ const fp = e.data?.error_fingerprint || e.data?.error_message || "unknown";
6099
+ byFingerprint.set(fp, (byFingerprint.get(fp) || 0) + 1);
6100
+ }
6101
+ const top = [...byFingerprint.entries()]
6102
+ .sort((a, b) => b[1] - a[1])
6103
+ .slice(0, 8);
6104
+ const lines = [
6105
+ "🧯 Telemetry — Top Errors",
6106
+ `Window: last ${days}d`,
6107
+ "",
6108
+ ];
6109
+ for (const [fp, count] of top) {
6110
+ lines.push(`• ${fp.slice(0, 80)} (${count})`);
6111
+ }
6112
+ return sendReply(chatId, lines.join("\n"));
6113
+ }
6114
+
6115
+ if (mode === "executors") {
6116
+ const metrics = await readJsonlTail(metricsPath, 2000);
6117
+ const summary = summarizeTelemetry(metrics, days);
6118
+ if (!summary) return sendReply(chatId, "No telemetry metrics found.");
6119
+ const lines = [
6120
+ "🧪 Telemetry — Executor Mix",
6121
+ `Window: last ${days}d`,
6122
+ "",
6123
+ ];
6124
+ for (const [exec, count] of Object.entries(summary.executors)) {
6125
+ lines.push(`• ${exec}: ${count}`);
6126
+ }
6127
+ return sendReply(chatId, lines.join("\n"));
6128
+ }
6129
+
6130
+ if (mode === "alerts") {
6131
+ const alerts = (await readJsonlTail(alertsPath, 200)).filter((a) =>
6132
+ withinDays(a, days),
6133
+ );
6134
+ if (alerts.length === 0) {
6135
+ return sendReply(chatId, "No analyzer alerts found.");
6136
+ }
6137
+ const lines = [
6138
+ "🚨 Telemetry — Recent Alerts",
6139
+ `Window: last ${days}d`,
6140
+ "",
6141
+ ...alerts.slice(-10).map((a) => {
6142
+ const sev = (a.severity || "medium").toUpperCase();
6143
+ const attempt = a.attempt_id || "unknown";
6144
+ const type = a.type || "alert";
6145
+ return `• ${sev} ${type} (${attempt})`;
6146
+ }),
6147
+ ];
6148
+ return sendReply(chatId, lines.join("\n"));
6149
+ }
6150
+
6151
+ const metrics = await readJsonlTail(metricsPath, 2000);
6152
+ const summary = summarizeTelemetry(metrics, days);
6153
+ if (!summary) {
6154
+ return sendReply(chatId, "No telemetry metrics found.");
6155
+ }
6156
+ const lines = [
6157
+ "📈 Telemetry Summary",
6158
+ `Window: last ${days}d`,
6159
+ "",
6160
+ `Sessions: ${summary.total}`,
6161
+ `Success: ${summary.success}/${summary.total} (${summary.successRate}%)`,
6162
+ `Avg duration: ${summary.avgDuration}s`,
6163
+ `Total errors: ${summary.totalErrors}`,
6164
+ "",
6165
+ "Use /telemetry errors | executors | alerts for drill‑down.",
6166
+ ];
6167
+ return sendReply(chatId, lines.join("\n"));
6168
+ }
6169
+
5806
6170
  async function cmdAsk(chatId, args) {
5807
6171
  const prompt = String(args || "").trim();
5808
6172
  if (!prompt) {
@@ -10073,6 +10437,30 @@ export async function startTelegramBot() {
10073
10437
  telegramChatId,
10074
10438
  `🤖 Bosun primary agent online (${getPrimaryAgentName()}).\n\nType /menu for the control center or send any message to chat with the agent.`,
10075
10439
  );
10440
+
10441
+ // ── SECURITY: Alert when ALLOW_UNSAFE is enabled (especially with tunnel) ──
10442
+ const _isUnsafe = ["1", "true", "yes"].includes(
10443
+ String(process.env.TELEGRAM_UI_ALLOW_UNSAFE || "").toLowerCase(),
10444
+ );
10445
+ if (_isUnsafe) {
10446
+ const _tunnelMode = (process.env.TELEGRAM_UI_TUNNEL || "auto").toLowerCase();
10447
+ const _tunnelActive = _tunnelMode !== "disabled" && _tunnelMode !== "off" && _tunnelMode !== "0";
10448
+ const severity = _tunnelActive
10449
+ ? "⛔ *CRITICAL SECURITY WARNING*"
10450
+ : "⚠️ *Security Warning*";
10451
+ const tunnelNote = _tunnelActive
10452
+ ? "\n\n🔴 *Tunnel is active* — your UI is exposed to the *public internet*. Anyone who discovers the URL can control your machine, execute code, and read secrets."
10453
+ : "";
10454
+ await sendDirect(
10455
+ telegramChatId,
10456
+ `${severity}\n\n` +
10457
+ "`TELEGRAM_UI_ALLOW_UNSAFE=true` — authentication is *completely disabled* on the web UI. " +
10458
+ "Anyone with the URL can send commands to agents, modify settings, and access API keys." +
10459
+ tunnelNote +
10460
+ "\n\nTo fix: set `TELEGRAM_UI_ALLOW_UNSAFE=false` in your .env and restart.",
10461
+ { parse_mode: "Markdown" },
10462
+ );
10463
+ }
10076
10464
  }
10077
10465
 
10078
10466
  console.log("[telegram-bot] started — listening for messages");
@@ -10082,6 +10470,32 @@ export async function startTelegramBot() {
10082
10470
  console.error(`[telegram-bot] fatal poll loop error: ${err.message}`);
10083
10471
  polling = false;
10084
10472
  });
10473
+
10474
+ // ── Message history auto-cleanup ──
10475
+ if (HISTORY_RETENTION_MS) {
10476
+ // Flush in-memory buffer to disk every 5 minutes
10477
+ const flushTimer = setInterval(() => {
10478
+ if (_msgHistoryDirty) _saveMsgHistory();
10479
+ }, 5 * 60 * 1000);
10480
+ flushTimer.unref();
10481
+
10482
+ // Run first prune after HISTORY_INITIAL_DELAY_MS (2 min), then every 6 h
10483
+ setTimeout(() => {
10484
+ pruneMessageHistory().catch((err) =>
10485
+ console.warn(`[telegram-bot] message history cleanup error: ${err.message}`),
10486
+ );
10487
+ const cleanupTimer = setInterval(
10488
+ () =>
10489
+ pruneMessageHistory().catch((err) =>
10490
+ console.warn(
10491
+ `[telegram-bot] message history cleanup error: ${err.message}`,
10492
+ ),
10493
+ ),
10494
+ HISTORY_CLEANUP_INTERVAL_MS,
10495
+ );
10496
+ cleanupTimer.unref();
10497
+ }, HISTORY_INITIAL_DELAY_MS);
10498
+ }
10085
10499
  }
10086
10500
 
10087
10501
  /**
package/ui/app.js CHANGED
@@ -82,6 +82,7 @@ import { AgentsTab } from "./tabs/agents.js";
82
82
  import { InfraTab } from "./tabs/infra.js";
83
83
  import { ControlTab } from "./tabs/control.js";
84
84
  import { LogsTab } from "./tabs/logs.js";
85
+ import { TelemetryTab } from "./tabs/telemetry.js";
85
86
  import { SettingsTab } from "./tabs/settings.js";
86
87
 
87
88
  /* ── Placeholder signals for connection quality (may be provided by api.js) ── */
@@ -203,6 +204,7 @@ const TAB_COMPONENTS = {
203
204
  infra: InfraTab,
204
205
  control: ControlTab,
205
206
  logs: LogsTab,
207
+ telemetry: TelemetryTab,
206
208
  settings: SettingsTab,
207
209
  };
208
210
 
@@ -128,7 +128,7 @@ export function SkeletonCard({ height = "80px", className = "" }) {
128
128
  * Bottom-sheet modal with drag handle, title, swipe-to-dismiss, and TG BackButton integration.
129
129
  * @param {{title?: string, open?: boolean, onClose: () => void, children?: any, contentClassName?: string}} props
130
130
  */
131
- export function Modal({ title, open = true, onClose, children, contentClassName = "" }) {
131
+ export function Modal({ title, open = true, onClose, children, contentClassName = "", footer }) {
132
132
  const [visible, setVisible] = useState(false);
133
133
  const contentRef = useRef(null);
134
134
  const dragState = useRef({ startY: 0, startRect: 0, dragging: false });
@@ -179,6 +179,32 @@ export function Modal({ title, open = true, onClose, children, contentClassName
179
179
  }
180
180
  });
181
181
 
182
+ // Visual viewport (software keyboard) awareness
183
+ // Sets --keyboard-height on :root so the modal can lift above the keyboard
184
+ useEffect(() => {
185
+ if (!open) return;
186
+ const vv = window.visualViewport;
187
+ if (!vv) return;
188
+ const update = () => {
189
+ // keyboard height = amount the visual viewport has shrunk from the layout viewport
190
+ const kh = Math.max(0, window.innerHeight - vv.height - vv.offsetTop);
191
+ document.documentElement.style.setProperty("--keyboard-height", `${kh}px`);
192
+ };
193
+ vv.addEventListener("resize", update);
194
+ vv.addEventListener("scroll", update);
195
+ update();
196
+ return () => {
197
+ vv.removeEventListener("resize", update);
198
+ vv.removeEventListener("scroll", update);
199
+ document.documentElement.style.setProperty("--keyboard-height", "0px");
200
+ };
201
+ }, [open]);
202
+
203
+ // Prevent touches on the scrollable body from triggering swipe-to-dismiss
204
+ const handleBodyTouchStart = useCallback((e) => {
205
+ e.stopPropagation();
206
+ }, []);
207
+
182
208
  const handleTouchStart = useCallback((e) => {
183
209
  const el = contentRef.current;
184
210
  if (!el) return;
@@ -317,7 +343,10 @@ export function Modal({ title, open = true, onClose, children, contentClassName
317
343
  ${ICONS.close}
318
344
  </button>
319
345
  </div>
320
- ${children}
346
+ <div class="modal-body" onTouchStart=${handleBodyTouchStart}>
347
+ ${children}
348
+ </div>
349
+ ${footer ? html`<div class="modal-footer">${footer}</div>` : null}
321
350
  </div>
322
351
  </div>
323
352
  `;
package/ui/demo.html CHANGED
@@ -669,6 +669,14 @@
669
669
  return { data: STATE.status };
670
670
  if (route === '/api/executor')
671
671
  return { data: { ...STATE.status, maxParallel: STATE.maxParallel, paused: STATE.paused, executors: STATE.executors } };
672
+ if (route === '/api/telemetry/summary')
673
+ return { data: { status: 'ok', updatedAt: Date.now(), totals: { tasks: STATE.tasks.length, executors: STATE.executors.length } } };
674
+ if (route === '/api/telemetry/errors')
675
+ return { data: [] };
676
+ if (route === '/api/telemetry/executors')
677
+ return { data: STATE.executors.map((e) => ({ ...e, status: e.enabled ? 'active' : 'disabled' })) };
678
+ if (route === '/api/telemetry/alerts')
679
+ return { data: [] };
672
680
  if (route === '/api/executor/pause') {
673
681
  STATE.paused = true; addLog('info', 'executor', 'Executor paused');
674
682
  return { ok: true, paused: true };
@@ -88,6 +88,20 @@ export const ICONS = {
88
88
  <circle cx="19" cy="12" r="1.6" />
89
89
  </svg>`,
90
90
 
91
+ chart: html`<svg
92
+ viewBox="0 0 24 24"
93
+ fill="none"
94
+ stroke="currentColor"
95
+ stroke-width="2"
96
+ stroke-linecap="round"
97
+ stroke-linejoin="round"
98
+ >
99
+ <line x1="4" y1="19" x2="20" y2="19" />
100
+ <rect x="6" y="10" width="3" height="9" />
101
+ <rect x="11" y="6" width="3" height="13" />
102
+ <rect x="16" y="13" width="3" height="6" />
103
+ </svg>`,
104
+
91
105
  /* ── Status / Feedback ── */
92
106
  check: html`<svg
93
107
  viewBox="0 0 24 24"
@@ -82,5 +82,6 @@ export const TAB_CONFIG = [
82
82
  { id: "chat", label: "Chat", icon: "chat" },
83
83
  { id: "infra", label: "Infra", icon: "server" },
84
84
  { id: "logs", label: "Logs", icon: "terminal" },
85
+ { id: "telemetry", label: "Telemetry", icon: "chart" },
85
86
  { id: "settings", label: "Settings", icon: "settings" },
86
87
  ];