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/.env.example +22 -1
- package/README.md +16 -5
- package/agent-pool.mjs +16 -2
- package/agent-work-analyzer.mjs +419 -0
- package/claude-shell.mjs +12 -2
- package/desktop/launch.mjs +31 -2
- package/desktop/main.mjs +132 -10
- package/desktop/package.json +8 -0
- package/desktop-shortcut.mjs +20 -1
- package/monitor.mjs +151 -0
- package/package.json +3 -2
- package/rotate-agent-logs.sh +8 -7
- package/setup.mjs +759 -59
- package/task-executor.mjs +309 -12
- package/telegram-bot.mjs +419 -5
- package/ui/app.js +2 -0
- package/ui/components/shared.js +31 -2
- package/ui/demo.html +8 -0
- package/ui/modules/icons.js +14 -0
- package/ui/modules/router.js +1 -0
- package/ui/modules/settings-schema.js +11 -1
- package/ui/modules/state.js +59 -0
- package/ui/styles/components.css +60 -8
- package/ui/styles.css +88 -0
- package/ui/tabs/tasks.js +110 -72
- package/ui/tabs/telemetry.js +167 -0
- package/ui-server.mjs +158 -0
- package/workspace-manager.mjs +45 -1
- package/ui/styles/workspace-switcher.css.bak +0 -693
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 =
|
|
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
|
-
|
|
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("
|
|
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
|
-
|
|
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
|
|
package/ui/components/shared.js
CHANGED
|
@@ -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
|
-
|
|
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 };
|
package/ui/modules/icons.js
CHANGED
|
@@ -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"
|
package/ui/modules/router.js
CHANGED
|
@@ -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
|
];
|