bosun 0.31.4 → 0.31.6
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/error-detector.mjs +17 -0
- package/kanban-adapter.mjs +2 -2
- package/monitor.mjs +44 -32
- package/package.json +1 -1
- package/sync-engine.mjs +87 -2
- package/task-executor.mjs +26 -11
- package/telegram-bot.mjs +46 -29
- package/ui/app.js +114 -1
- package/ui/components/chat-view.js +90 -14
- package/ui/modules/api.js +54 -22
- package/ui/modules/streaming.js +8 -2
- package/ui/styles/components.css +164 -0
- package/ui/styles/sessions.css +57 -4
- package/ui/tabs/chat.js +46 -0
- package/ui/tabs/dashboard.js +103 -2
- package/ui/tabs/tasks.js +16 -23
- package/ui-server.mjs +31 -1
- package/utils.mjs +60 -0
package/error-detector.mjs
CHANGED
|
@@ -240,6 +240,22 @@ export const PATTERN_SEVERITY = {
|
|
|
240
240
|
unknown: "low",
|
|
241
241
|
};
|
|
242
242
|
|
|
243
|
+
// ── Remediation hints ──────────────────────────────────────────────────────
|
|
244
|
+
|
|
245
|
+
/**
|
|
246
|
+
* Human-readable remediation hints for each error type.
|
|
247
|
+
* Surfaced in UI and Telegram notifications to guide users.
|
|
248
|
+
*/
|
|
249
|
+
const REMEDIATION_HINTS = {
|
|
250
|
+
rate_limit: "Wait a few minutes before retrying. Consider reducing MAX_PARALLEL.",
|
|
251
|
+
oom: "Reduce MAX_PARALLEL or increase available memory. Use --max-old-space-size.",
|
|
252
|
+
oom_kill: "Process was killed by the OS. Reduce memory usage or increase system RAM.",
|
|
253
|
+
git_conflict: "Manual conflict resolution required. Run: git mergetool",
|
|
254
|
+
push_failure: "Rebase failed or push rejected. Run: git rebase --abort then try again.",
|
|
255
|
+
auth_error: "Authentication failed. Check your API tokens and credentials.",
|
|
256
|
+
api_error: "Network connectivity issue. Check your internet connection.",
|
|
257
|
+
};
|
|
258
|
+
|
|
243
259
|
// ── Helpers ─────────────────────────────────────────────────────────────────
|
|
244
260
|
|
|
245
261
|
/** Safely truncate a string for logging / details. */
|
|
@@ -364,6 +380,7 @@ export class ErrorDetector {
|
|
|
364
380
|
return {
|
|
365
381
|
...result,
|
|
366
382
|
severity: PATTERN_SEVERITY[result.pattern] ?? "low",
|
|
383
|
+
remediation: REMEDIATION_HINTS[result.pattern] || null,
|
|
367
384
|
};
|
|
368
385
|
}
|
|
369
386
|
|
package/kanban-adapter.mjs
CHANGED
|
@@ -903,7 +903,7 @@ class GitHubIssuesAdapter {
|
|
|
903
903
|
process.env.BOSUN_TASK_LABEL || "bosun";
|
|
904
904
|
this._taskScopeLabels = normalizeLabels(
|
|
905
905
|
process.env.BOSUN_TASK_LABELS ||
|
|
906
|
-
`${this._canonicalTaskLabel},codex-
|
|
906
|
+
`${this._canonicalTaskLabel},codex-monitor`,
|
|
907
907
|
);
|
|
908
908
|
this._enforceTaskLabel = parseBooleanEnv(
|
|
909
909
|
process.env.BOSUN_ENFORCE_TASK_LABEL,
|
|
@@ -915,7 +915,7 @@ class GitHubIssuesAdapter {
|
|
|
915
915
|
true,
|
|
916
916
|
);
|
|
917
917
|
this._defaultAssignee =
|
|
918
|
-
process.env.GITHUB_DEFAULT_ASSIGNEE ||
|
|
918
|
+
String(process.env.GITHUB_DEFAULT_ASSIGNEE || "").trim() || null;
|
|
919
919
|
|
|
920
920
|
this._projectMode = String(process.env.GITHUB_PROJECT_MODE || "issues")
|
|
921
921
|
.trim()
|
package/monitor.mjs
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { execSync, spawn, spawnSync } from "node:child_process";
|
|
2
|
+
import { randomUUID } from "node:crypto";
|
|
2
3
|
import {
|
|
3
4
|
existsSync,
|
|
4
5
|
mkdirSync,
|
|
@@ -7902,10 +7903,8 @@ function extractPlannerTasksFromOutput(output, maxTasks) {
|
|
|
7902
7903
|
return normalized;
|
|
7903
7904
|
}
|
|
7904
7905
|
|
|
7905
|
-
async function materializePlannerTasksToKanban(
|
|
7906
|
-
const existingOpenTasks =
|
|
7907
|
-
status: "todo",
|
|
7908
|
-
});
|
|
7906
|
+
async function materializePlannerTasksToKanban(tasks) {
|
|
7907
|
+
const existingOpenTasks = getInternalTasksByStatus("todo");
|
|
7909
7908
|
const existingTitles = new Set(
|
|
7910
7909
|
(Array.isArray(existingOpenTasks) ? existingOpenTasks : [])
|
|
7911
7910
|
.map((task) => normalizePlannerTitleForComparison(task?.title))
|
|
@@ -7925,16 +7924,26 @@ async function materializePlannerTasksToKanban(projectId, tasks) {
|
|
|
7925
7924
|
skipped.push({ title: task.title, reason: "duplicate_title" });
|
|
7926
7925
|
continue;
|
|
7927
7926
|
}
|
|
7928
|
-
const
|
|
7927
|
+
const baseBranch = task.baseBranch || task.base_branch || extractModuleBaseBranchFromTitle(task.title);
|
|
7928
|
+
const plannerMeta = {
|
|
7929
|
+
source: "task-planner",
|
|
7930
|
+
plannerMode: "codex-sdk",
|
|
7931
|
+
kind: "planned-task",
|
|
7932
|
+
externalSyncPending: true,
|
|
7933
|
+
createdAt: new Date().toISOString(),
|
|
7934
|
+
};
|
|
7935
|
+
const createdTask = addInternalTask({
|
|
7936
|
+
id: randomUUID(),
|
|
7929
7937
|
title: task.title,
|
|
7930
7938
|
description: task.description,
|
|
7931
7939
|
status: "todo",
|
|
7932
|
-
|
|
7933
|
-
|
|
7934
|
-
|
|
7935
|
-
|
|
7936
|
-
|
|
7937
|
-
|
|
7940
|
+
projectId: process.env.INTERNAL_EXECUTOR_PROJECT_ID || "internal",
|
|
7941
|
+
...(baseBranch ? { baseBranch } : {}),
|
|
7942
|
+
syncDirty: true,
|
|
7943
|
+
meta: {
|
|
7944
|
+
planner: plannerMeta,
|
|
7945
|
+
...(baseBranch ? { base_branch: baseBranch, baseBranch } : {}),
|
|
7946
|
+
},
|
|
7938
7947
|
});
|
|
7939
7948
|
if (createdTask?.id) {
|
|
7940
7949
|
created.push({ id: createdTask.id, title: task.title });
|
|
@@ -8987,13 +8996,6 @@ async function triggerTaskPlannerViaKanban(
|
|
|
8987
8996
|
details,
|
|
8988
8997
|
numTasks,
|
|
8989
8998
|
);
|
|
8990
|
-
// Get project ID using the kanban adapter
|
|
8991
|
-
const projectId = await findKanbanProjectId();
|
|
8992
|
-
if (!projectId) {
|
|
8993
|
-
throw new Error(
|
|
8994
|
-
`Cannot reach kanban backend (${getActiveKanbanBackend()}) or no project found`,
|
|
8995
|
-
);
|
|
8996
|
-
}
|
|
8997
8999
|
|
|
8998
9000
|
const desiredTitle = userPrompt
|
|
8999
9001
|
? `[${plannerTaskSizeLabel}] Plan next tasks (${reason || "backlog-empty"}) — ${userPrompt.slice(0, 60)}${userPrompt.length > 60 ? "…" : ""}`
|
|
@@ -9008,7 +9010,7 @@ async function triggerTaskPlannerViaKanban(
|
|
|
9008
9010
|
|
|
9009
9011
|
// Check for existing planner tasks to avoid duplicates
|
|
9010
9012
|
// Only block on TODO tasks whose title matches the exact format we create
|
|
9011
|
-
const existingTasks =
|
|
9013
|
+
const existingTasks = getInternalTasksByStatus("todo");
|
|
9012
9014
|
const existingPlanner = (
|
|
9013
9015
|
Array.isArray(existingTasks) ? existingTasks : []
|
|
9014
9016
|
).find((t) => {
|
|
@@ -9042,7 +9044,7 @@ async function triggerTaskPlannerViaKanban(
|
|
|
9042
9044
|
);
|
|
9043
9045
|
}
|
|
9044
9046
|
|
|
9045
|
-
const taskUrl =
|
|
9047
|
+
const taskUrl = null;
|
|
9046
9048
|
if (notify) {
|
|
9047
9049
|
const suffix = taskUrl ? `\n${taskUrl}` : "";
|
|
9048
9050
|
await sendTelegramMessage(
|
|
@@ -9061,7 +9063,7 @@ async function triggerTaskPlannerViaKanban(
|
|
|
9061
9063
|
taskId: existingPlanner.id,
|
|
9062
9064
|
taskTitle: existingPlanner.title,
|
|
9063
9065
|
taskUrl,
|
|
9064
|
-
projectId,
|
|
9066
|
+
projectId: process.env.INTERNAL_EXECUTOR_PROJECT_ID || "internal",
|
|
9065
9067
|
};
|
|
9066
9068
|
}
|
|
9067
9069
|
|
|
@@ -9071,7 +9073,24 @@ async function triggerTaskPlannerViaKanban(
|
|
|
9071
9073
|
status: "todo",
|
|
9072
9074
|
};
|
|
9073
9075
|
|
|
9074
|
-
const createdTask =
|
|
9076
|
+
const createdTask = addInternalTask({
|
|
9077
|
+
id: randomUUID(),
|
|
9078
|
+
title: taskData.title,
|
|
9079
|
+
description: taskData.description,
|
|
9080
|
+
status: "todo",
|
|
9081
|
+
projectId: process.env.INTERNAL_EXECUTOR_PROJECT_ID || "internal",
|
|
9082
|
+
syncDirty: true,
|
|
9083
|
+
meta: {
|
|
9084
|
+
planner: {
|
|
9085
|
+
source: "task-planner",
|
|
9086
|
+
plannerMode: "kanban",
|
|
9087
|
+
kind: "planner-request",
|
|
9088
|
+
triggerReason: reason || "manual",
|
|
9089
|
+
externalSyncPending: true,
|
|
9090
|
+
createdAt: new Date().toISOString(),
|
|
9091
|
+
},
|
|
9092
|
+
},
|
|
9093
|
+
});
|
|
9075
9094
|
|
|
9076
9095
|
if (createdTask && createdTask.id) {
|
|
9077
9096
|
console.log(`[monitor] task planner task created: ${createdTask.id}`);
|
|
@@ -9082,7 +9101,7 @@ async function triggerTaskPlannerViaKanban(
|
|
|
9082
9101
|
last_result: "kanban_task_created",
|
|
9083
9102
|
});
|
|
9084
9103
|
const createdId = createdTask.id;
|
|
9085
|
-
const createdUrl =
|
|
9104
|
+
const createdUrl = null;
|
|
9086
9105
|
if (notify) {
|
|
9087
9106
|
const suffix = createdUrl ? `\n${createdUrl}` : "";
|
|
9088
9107
|
await sendTelegramMessage(
|
|
@@ -9094,7 +9113,7 @@ async function triggerTaskPlannerViaKanban(
|
|
|
9094
9113
|
taskId: createdId,
|
|
9095
9114
|
taskTitle: taskData.title,
|
|
9096
9115
|
taskUrl: createdUrl,
|
|
9097
|
-
projectId,
|
|
9116
|
+
projectId: process.env.INTERNAL_EXECUTOR_PROJECT_ID || "internal",
|
|
9098
9117
|
};
|
|
9099
9118
|
}
|
|
9100
9119
|
throw new Error("Task creation failed");
|
|
@@ -9194,14 +9213,7 @@ async function triggerTaskPlannerViaCodex(
|
|
|
9194
9213
|
"utf8",
|
|
9195
9214
|
);
|
|
9196
9215
|
|
|
9197
|
-
const projectId = await findKanbanProjectId();
|
|
9198
|
-
if (!projectId) {
|
|
9199
|
-
throw new Error(
|
|
9200
|
-
`Task planner produced ${parsedTasks.length} tasks, but no kanban project is reachable for backend "${getActiveKanbanBackend()}"`,
|
|
9201
|
-
);
|
|
9202
|
-
}
|
|
9203
9216
|
const { created, skipped } = await materializePlannerTasksToKanban(
|
|
9204
|
-
projectId,
|
|
9205
9217
|
parsedTasks,
|
|
9206
9218
|
);
|
|
9207
9219
|
|
|
@@ -9228,7 +9240,7 @@ async function triggerTaskPlannerViaCodex(
|
|
|
9228
9240
|
status: "completed",
|
|
9229
9241
|
outputPath: outPath,
|
|
9230
9242
|
artifactPath,
|
|
9231
|
-
projectId,
|
|
9243
|
+
projectId: process.env.INTERNAL_EXECUTOR_PROJECT_ID || "internal",
|
|
9232
9244
|
parsedTaskCount: parsedTasks.length,
|
|
9233
9245
|
createdTaskCount: created.length,
|
|
9234
9246
|
skippedTaskCount: skipped.length,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "bosun",
|
|
3
|
-
"version": "0.31.
|
|
3
|
+
"version": "0.31.6",
|
|
4
4
|
"description": "AI-powered orchestrator supervisor — manages AI agent executors with failover, auto-restarts on failure, analyzes crashes with Codex SDK, creates PRs via Vibe-Kanban API, and sends Telegram notifications. Supports N executors with weighted distribution, multi-repo projects, and auto-setup.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "Apache 2.0",
|
package/sync-engine.mjs
CHANGED
|
@@ -16,7 +16,6 @@
|
|
|
16
16
|
import {
|
|
17
17
|
getTask,
|
|
18
18
|
getAllTasks,
|
|
19
|
-
addTask,
|
|
20
19
|
updateTask,
|
|
21
20
|
getDirtyTasks,
|
|
22
21
|
markSynced,
|
|
@@ -29,6 +28,7 @@ import {
|
|
|
29
28
|
getKanbanAdapter,
|
|
30
29
|
getKanbanBackendName,
|
|
31
30
|
listTasks,
|
|
31
|
+
createTask as createExternalTask,
|
|
32
32
|
updateTaskStatus as updateExternalStatus,
|
|
33
33
|
} from "./kanban-adapter.mjs";
|
|
34
34
|
|
|
@@ -585,6 +585,11 @@ export class SyncEngine {
|
|
|
585
585
|
baseBranchCandidate &&
|
|
586
586
|
String(baseBranchCandidate) !== String(metaBaseBranch);
|
|
587
587
|
|
|
588
|
+
const shouldCreateExternal = this.#shouldCreateExternalTask(
|
|
589
|
+
task,
|
|
590
|
+
backendName,
|
|
591
|
+
);
|
|
592
|
+
|
|
588
593
|
// Check shared state for conflicts before pushing
|
|
589
594
|
if (SHARED_STATE_ENABLED) {
|
|
590
595
|
try {
|
|
@@ -618,9 +623,48 @@ export class SyncEngine {
|
|
|
618
623
|
// Continue with push on error (graceful degradation)
|
|
619
624
|
}
|
|
620
625
|
}
|
|
626
|
+
let pushId = task.externalId || task.id;
|
|
627
|
+
|
|
628
|
+
if (shouldCreateExternal) {
|
|
629
|
+
try {
|
|
630
|
+
const created = await this.#createExternalTask(task, baseBranchCandidate);
|
|
631
|
+
const createdId = this.#extractExternalTaskId(created);
|
|
632
|
+
if (!isIdValidForBackend(createdId, backendName)) {
|
|
633
|
+
throw new Error(
|
|
634
|
+
`external create returned incompatible id for ${backendName}: ${createdId || "<empty>"}`,
|
|
635
|
+
);
|
|
636
|
+
}
|
|
637
|
+
|
|
638
|
+
const plannerMeta = {
|
|
639
|
+
...(task.meta?.planner || {}),
|
|
640
|
+
externalSyncPending: false,
|
|
641
|
+
externalCreatedAt: new Date().toISOString(),
|
|
642
|
+
externalTaskId: String(createdId),
|
|
643
|
+
};
|
|
644
|
+
updateTask(task.id, {
|
|
645
|
+
externalId: String(createdId),
|
|
646
|
+
externalBackend: backendName,
|
|
647
|
+
meta: {
|
|
648
|
+
...(task.meta || {}),
|
|
649
|
+
planner: plannerMeta,
|
|
650
|
+
},
|
|
651
|
+
});
|
|
652
|
+
|
|
653
|
+
pushId = String(createdId);
|
|
654
|
+
console.log(
|
|
655
|
+
TAG,
|
|
656
|
+
`Created external task for ${task.id} → ${pushId} (${backendName})`,
|
|
657
|
+
);
|
|
658
|
+
} catch (err) {
|
|
659
|
+
const msg = `External create failed for ${task.id}: ${err.message}`;
|
|
660
|
+
console.warn(TAG, msg);
|
|
661
|
+
result.errors.push(msg);
|
|
662
|
+
continue;
|
|
663
|
+
}
|
|
664
|
+
}
|
|
665
|
+
|
|
621
666
|
// Skip tasks whose IDs are incompatible with the active backend.
|
|
622
667
|
// e.g. VK UUID tasks cannot be pushed to GitHub Issues (needs numeric IDs).
|
|
623
|
-
const pushId = task.externalId || task.id;
|
|
624
668
|
if (!isIdValidForBackend(pushId, backendName)) {
|
|
625
669
|
// If the task originated from a different backend, silently clear dirty
|
|
626
670
|
// flag — it will be synced when that backend is active.
|
|
@@ -973,6 +1017,28 @@ export class SyncEngine {
|
|
|
973
1017
|
return await updateExternalStatus(taskId, status);
|
|
974
1018
|
}
|
|
975
1019
|
|
|
1020
|
+
async #createExternalTask(task, baseBranchCandidate = null) {
|
|
1021
|
+
const payload = {
|
|
1022
|
+
title: task.title || "Untitled task",
|
|
1023
|
+
description: task.description || "",
|
|
1024
|
+
status: "todo",
|
|
1025
|
+
assignee: task.assignee || null,
|
|
1026
|
+
priority: task.priority || null,
|
|
1027
|
+
tags: Array.isArray(task.tags) ? task.tags : [],
|
|
1028
|
+
labels: Array.isArray(task.tags) ? task.tags : [],
|
|
1029
|
+
...(baseBranchCandidate ? { baseBranch: baseBranchCandidate } : {}),
|
|
1030
|
+
};
|
|
1031
|
+
|
|
1032
|
+
if (
|
|
1033
|
+
this.#kanbanAdapter &&
|
|
1034
|
+
typeof this.#kanbanAdapter.createTask === "function"
|
|
1035
|
+
) {
|
|
1036
|
+
return await this.#kanbanAdapter.createTask(this.#projectId, payload);
|
|
1037
|
+
}
|
|
1038
|
+
|
|
1039
|
+
return await createExternalTask(this.#projectId, payload);
|
|
1040
|
+
}
|
|
1041
|
+
|
|
976
1042
|
async #updateExternalPatch(taskId, patch) {
|
|
977
1043
|
if (
|
|
978
1044
|
this.#kanbanAdapter &&
|
|
@@ -1022,6 +1088,25 @@ export class SyncEngine {
|
|
|
1022
1088
|
);
|
|
1023
1089
|
}
|
|
1024
1090
|
|
|
1091
|
+
#shouldCreateExternalTask(task, backend) {
|
|
1092
|
+
if (!task || backend === "internal") return false;
|
|
1093
|
+
if (task.externalId) return false;
|
|
1094
|
+
const plannerMeta = task.meta?.planner;
|
|
1095
|
+
if (!plannerMeta || typeof plannerMeta !== "object") return false;
|
|
1096
|
+
return plannerMeta.externalSyncPending === true;
|
|
1097
|
+
}
|
|
1098
|
+
|
|
1099
|
+
#extractExternalTaskId(created) {
|
|
1100
|
+
if (!created) return null;
|
|
1101
|
+
if (created.id != null && String(created.id).trim()) {
|
|
1102
|
+
return String(created.id).trim();
|
|
1103
|
+
}
|
|
1104
|
+
if (created.externalId != null && String(created.externalId).trim()) {
|
|
1105
|
+
return String(created.externalId).trim();
|
|
1106
|
+
}
|
|
1107
|
+
return null;
|
|
1108
|
+
}
|
|
1109
|
+
|
|
1025
1110
|
/** Simple async sleep. */
|
|
1026
1111
|
#sleep(ms) {
|
|
1027
1112
|
return new Promise((resolve) => setTimeout(resolve, ms));
|
package/task-executor.mjs
CHANGED
|
@@ -68,7 +68,7 @@ import {
|
|
|
68
68
|
collectDiffStats,
|
|
69
69
|
} from "./diff-stats.mjs";
|
|
70
70
|
import { createAnomalyDetector } from "./anomaly-detector.mjs";
|
|
71
|
-
import { normalizeDedupKey, yieldToEventLoop } from "./utils.mjs";
|
|
71
|
+
import { normalizeDedupKey, yieldToEventLoop, withRetry } from "./utils.mjs";
|
|
72
72
|
import {
|
|
73
73
|
resolveExecutorForTask,
|
|
74
74
|
executorToSdk,
|
|
@@ -3169,10 +3169,20 @@ class TaskExecutor {
|
|
|
3169
3169
|
return;
|
|
3170
3170
|
}
|
|
3171
3171
|
|
|
3172
|
-
// Fetch todo tasks
|
|
3172
|
+
// Fetch todo tasks (with transient-error retry)
|
|
3173
3173
|
let tasks;
|
|
3174
3174
|
try {
|
|
3175
|
-
tasks = await
|
|
3175
|
+
tasks = await withRetry(
|
|
3176
|
+
() => listTasks(projectId, { status: "todo" }),
|
|
3177
|
+
{
|
|
3178
|
+
maxAttempts: 3,
|
|
3179
|
+
baseMs: 2000,
|
|
3180
|
+
retryIf: (err) => {
|
|
3181
|
+
const cat = categorizeError(err);
|
|
3182
|
+
return cat === "transient" || cat === "network";
|
|
3183
|
+
},
|
|
3184
|
+
},
|
|
3185
|
+
);
|
|
3176
3186
|
this._resetListTasksBackoff();
|
|
3177
3187
|
} catch (err) {
|
|
3178
3188
|
this._noteListTasksFailure(err);
|
|
@@ -3252,14 +3262,19 @@ class TaskExecutor {
|
|
|
3252
3262
|
for (const task of toDispatch) {
|
|
3253
3263
|
// Normalize task id
|
|
3254
3264
|
task.id = task.id || task.task_id;
|
|
3255
|
-
|
|
3256
|
-
|
|
3257
|
-
|
|
3258
|
-
|
|
3259
|
-
|
|
3260
|
-
|
|
3261
|
-
|
|
3262
|
-
|
|
3265
|
+
try {
|
|
3266
|
+
// Fire and forget — executeTask handles its own lifecycle
|
|
3267
|
+
this.executeTask(task).catch((err) => {
|
|
3268
|
+
console.error(
|
|
3269
|
+
`${TAG} unhandled error in executeTask for "${task.title}": ${err.message}`,
|
|
3270
|
+
);
|
|
3271
|
+
});
|
|
3272
|
+
} catch (err) {
|
|
3273
|
+
console.warn(`${TAG} slot ${task.id} dispatch error: ${err.message}`);
|
|
3274
|
+
} finally {
|
|
3275
|
+
// ALWAYS yield, even on error, to prevent event loop starvation
|
|
3276
|
+
await yieldToEventLoop();
|
|
3277
|
+
}
|
|
3263
3278
|
}
|
|
3264
3279
|
} catch (err) {
|
|
3265
3280
|
console.error(`${TAG} poll loop error: ${err.message}`);
|
package/telegram-bot.mjs
CHANGED
|
@@ -789,6 +789,20 @@ function getBrowserUiUrl() {
|
|
|
789
789
|
const base = telegramUiUrl;
|
|
790
790
|
if (!base) return null;
|
|
791
791
|
const token = getSessionToken();
|
|
792
|
+
|
|
793
|
+
// Prefer LAN for browser opens when available (same-network path),
|
|
794
|
+
// and fall back to the configured/public URL.
|
|
795
|
+
try {
|
|
796
|
+
const parsed = new URL(base);
|
|
797
|
+
const lanIp = getLocalLanIp?.();
|
|
798
|
+
if (lanIp && parsed.port) {
|
|
799
|
+
const lanBase = `${parsed.protocol}//${lanIp}:${parsed.port}`;
|
|
800
|
+
return appendTokenToUrl(lanBase, token) || lanBase;
|
|
801
|
+
}
|
|
802
|
+
} catch {
|
|
803
|
+
// fall through to base URL
|
|
804
|
+
}
|
|
805
|
+
|
|
792
806
|
if (!token) return base;
|
|
793
807
|
return appendTokenToUrl(base, token) || base;
|
|
794
808
|
}
|
|
@@ -3042,6 +3056,7 @@ async function refreshMenuButton() {
|
|
|
3042
3056
|
|
|
3043
3057
|
const FAST_COMMANDS = new Set([
|
|
3044
3058
|
"/menu",
|
|
3059
|
+
"/background",
|
|
3045
3060
|
"/status",
|
|
3046
3061
|
"/tasks",
|
|
3047
3062
|
"/agents",
|
|
@@ -3063,39 +3078,36 @@ const FAST_COMMANDS = new Set([
|
|
|
3063
3078
|
]);
|
|
3064
3079
|
|
|
3065
3080
|
function getTelegramWebAppUrl(url) {
|
|
3066
|
-
//
|
|
3081
|
+
// Telegram Mini App must be HTTPS and publicly reachable.
|
|
3082
|
+
// Priority: explicit env URL -> tunnel URL -> provided URL.
|
|
3083
|
+
const explicit =
|
|
3084
|
+
process.env.TELEGRAM_WEBAPP_URL || process.env.TELEGRAM_UI_BASE_URL || "";
|
|
3067
3085
|
const tUrl = getTunnelUrl();
|
|
3068
|
-
|
|
3069
|
-
const normalizedTunnel = String(tUrl || "").trim().replace(/\/+$/, "");
|
|
3070
|
-
return appendTokenToUrl(normalizedTunnel, getSessionToken()) || normalizedTunnel;
|
|
3071
|
-
}
|
|
3086
|
+
const candidates = [explicit, tUrl, url];
|
|
3072
3087
|
|
|
3073
|
-
const
|
|
3074
|
-
|
|
3075
|
-
|
|
3076
|
-
|
|
3077
|
-
|
|
3078
|
-
|
|
3079
|
-
|
|
3080
|
-
if (
|
|
3081
|
-
|
|
3082
|
-
|
|
3083
|
-
|
|
3084
|
-
|
|
3085
|
-
}
|
|
3086
|
-
return null;
|
|
3087
|
-
}
|
|
3088
|
-
const normalizedUrl = parsed.toString().replace(/\/+$/, "");
|
|
3089
|
-
return appendTokenToUrl(normalizedUrl, getSessionToken()) || normalizedUrl;
|
|
3090
|
-
} catch {
|
|
3091
|
-
if (telegramWebAppUrlWarned !== normalized) {
|
|
3092
|
-
telegramWebAppUrlWarned = normalized;
|
|
3093
|
-
console.warn(
|
|
3094
|
-
`[telegram-bot] mini app URL is invalid: "${normalized}". Falling back to normal URL buttons only.`,
|
|
3095
|
-
);
|
|
3088
|
+
for (const candidate of candidates) {
|
|
3089
|
+
const normalized = String(candidate || "")
|
|
3090
|
+
.trim()
|
|
3091
|
+
.replace(/\/+$/, "");
|
|
3092
|
+
if (!normalized) continue;
|
|
3093
|
+
try {
|
|
3094
|
+
const parsed = new URL(normalized);
|
|
3095
|
+
if (parsed.protocol !== "https:") continue;
|
|
3096
|
+
const normalizedUrl = parsed.toString().replace(/\/+$/, "");
|
|
3097
|
+
return appendTokenToUrl(normalizedUrl, getSessionToken()) || normalizedUrl;
|
|
3098
|
+
} catch {
|
|
3099
|
+
// try next candidate
|
|
3096
3100
|
}
|
|
3097
|
-
return null;
|
|
3098
3101
|
}
|
|
3102
|
+
|
|
3103
|
+
const warnSource = String(explicit || tUrl || url || "").trim();
|
|
3104
|
+
if (warnSource && telegramWebAppUrlWarned !== warnSource) {
|
|
3105
|
+
telegramWebAppUrlWarned = warnSource;
|
|
3106
|
+
console.warn(
|
|
3107
|
+
`[telegram-bot] mini app URL requires a valid HTTPS URL. Got "${warnSource}". Falling back to browser URL buttons only.`,
|
|
3108
|
+
);
|
|
3109
|
+
}
|
|
3110
|
+
return null;
|
|
3099
3111
|
}
|
|
3100
3112
|
|
|
3101
3113
|
function appendTokenToUrl(inputUrl, token) {
|
|
@@ -4142,6 +4154,11 @@ Object.assign(UI_SCREENS, {
|
|
|
4142
4154
|
},
|
|
4143
4155
|
uiButton("✖", "cb:close_menu"),
|
|
4144
4156
|
]);
|
|
4157
|
+
if (telegramUiUrl) {
|
|
4158
|
+
rows.unshift([
|
|
4159
|
+
{ text: "🌐 Open in Browser", url: getBrowserUiUrl() || telegramUiUrl },
|
|
4160
|
+
]);
|
|
4161
|
+
}
|
|
4145
4162
|
} else if (telegramUiUrl) {
|
|
4146
4163
|
rows.unshift([
|
|
4147
4164
|
{ text: "🌐 Open Control Center", url: getBrowserUiUrl() || telegramUiUrl },
|