bosun 0.28.0 → 0.28.1
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 +15 -1
- package/README.md +4 -1
- package/agent-pool.mjs +16 -2
- package/agent-work-analyzer.mjs +419 -0
- package/claude-shell.mjs +12 -2
- package/desktop/main.mjs +81 -4
- package/monitor.mjs +151 -0
- package/package.json +2 -1
- package/setup.mjs +601 -58
- package/task-executor.mjs +296 -9
- package/telegram-bot.mjs +301 -5
- package/ui/components/shared.js +31 -2
- package/ui/modules/settings-schema.js +2 -1
- package/ui/styles/components.css +60 -8
- package/ui/tabs/tasks.js +110 -72
- package/ui-server.mjs +30 -0
- package/workspace-manager.mjs +45 -1
- package/ui/styles/workspace-switcher.css.bak +0 -693
package/.env.example
CHANGED
|
@@ -77,7 +77,21 @@ TELEGRAM_MINIAPP_ENABLED=false
|
|
|
77
77
|
# Full public URL override (takes precedence over host/port auto-detection).
|
|
78
78
|
# Use when you have a reverse proxy or tunnel with HTTPS.
|
|
79
79
|
# TELEGRAM_UI_BASE_URL=https://your-public-ui.example.com
|
|
80
|
-
#
|
|
80
|
+
# ╔══════════════════════════════════════════════════════════════════════╗
|
|
81
|
+
# ║ ⛔ DANGER — SECURITY CRITICAL ║
|
|
82
|
+
# ║ ║
|
|
83
|
+
# ║ Setting ALLOW_UNSAFE=true disables ALL authentication on the UI. ║
|
|
84
|
+
# ║ Anyone who discovers your URL can: ║
|
|
85
|
+
# ║ • Read/modify your tasks and settings ║
|
|
86
|
+
# ║ • Send commands to agents that execute code on YOUR machine ║
|
|
87
|
+
# ║ • Access secrets, API keys, and environment variables ║
|
|
88
|
+
# ║ ║
|
|
89
|
+
# ║ Combined with TELEGRAM_UI_TUNNEL=auto (Cloudflare tunnel), your UI ║
|
|
90
|
+
# ║ gets a PUBLIC internet URL — meaning ANYONE ON THE INTERNET can ║
|
|
91
|
+
# ║ find and control your machine. ║
|
|
92
|
+
# ║ ║
|
|
93
|
+
# ║ ONLY enable this for localhost-only debugging with tunnel DISABLED. ║
|
|
94
|
+
# ╚══════════════════════════════════════════════════════════════════════╝
|
|
81
95
|
# TELEGRAM_UI_ALLOW_UNSAFE=false
|
|
82
96
|
# Max age in seconds for initData auth tokens (default: 86400 = 24h)
|
|
83
97
|
# TELEGRAM_UI_AUTH_MAX_AGE_SEC=86400
|
package/README.md
CHANGED
|
@@ -1,4 +1,7 @@
|
|
|
1
|
-
|
|
1
|
+
<p align="center">
|
|
2
|
+
<img src="site/logo.png" alt="virtengine bosun ai agent" width="150" />
|
|
3
|
+
</p>
|
|
4
|
+
<h1 align="center">bosun</h1>
|
|
2
5
|
|
|
3
6
|
Bosun is a production-grade supervisor for AI coding agents. It routes tasks across executors, automates PR lifecycles, and keeps operators in control through Telegram, the Mini App dashboard, and optional WhatsApp notifications.
|
|
4
7
|
|
package/agent-pool.mjs
CHANGED
|
@@ -71,6 +71,20 @@ function envFlagEnabled(value) {
|
|
|
71
71
|
return ["1", "true", "yes", "on", "y"].includes(raw);
|
|
72
72
|
}
|
|
73
73
|
|
|
74
|
+
/**
|
|
75
|
+
* Extract a human-readable task heading from the prompt built by _buildTaskPrompt.
|
|
76
|
+
* The first line is "# TASKID — Task Title"; we return the title portion only.
|
|
77
|
+
* Falls back to the raw first line if no em-dash separator is found.
|
|
78
|
+
* @param {string} prompt
|
|
79
|
+
* @returns {string}
|
|
80
|
+
*/
|
|
81
|
+
function extractTaskHeading(prompt) {
|
|
82
|
+
const firstLine = String(prompt || "").split(/\r?\n/)[0].replace(/^#+\s*/, "").trim();
|
|
83
|
+
const dashIdx = firstLine.indexOf(" \u2014 ");
|
|
84
|
+
const title = dashIdx !== -1 ? firstLine.slice(dashIdx + 3).trim() : firstLine;
|
|
85
|
+
return title || "Execute Task";
|
|
86
|
+
}
|
|
87
|
+
|
|
74
88
|
function shouldAutoApproveCopilotPermissions() {
|
|
75
89
|
const raw = process.env.COPILOT_AUTO_APPROVE_PERMISSIONS;
|
|
76
90
|
if (raw === undefined || raw === null || String(raw).trim() === "") {
|
|
@@ -870,7 +884,7 @@ async function launchCopilotThread(prompt, cwd, timeoutMs, extra = {}) {
|
|
|
870
884
|
}
|
|
871
885
|
|
|
872
886
|
const formattedPrompt =
|
|
873
|
-
`#
|
|
887
|
+
`# ${extractTaskHeading(prompt)}\n\n${prompt}\n\n---\n` +
|
|
874
888
|
'Do NOT respond with "Ready" or ask what to do. EXECUTE this task.';
|
|
875
889
|
|
|
876
890
|
const hasSend = typeof session.send === "function";
|
|
@@ -1184,7 +1198,7 @@ async function launchClaudeThread(prompt, cwd, timeoutMs, extra = {}) {
|
|
|
1184
1198
|
const msgQueue = createMessageQueue();
|
|
1185
1199
|
|
|
1186
1200
|
const formattedPrompt =
|
|
1187
|
-
`#
|
|
1201
|
+
`# ${extractTaskHeading(prompt)}\n\n${prompt}\n\n---\n` +
|
|
1188
1202
|
'Do NOT respond with "Ready" or ask what to do. EXECUTE this task.';
|
|
1189
1203
|
|
|
1190
1204
|
msgQueue.push(makeUserMessage(formattedPrompt));
|
|
@@ -0,0 +1,419 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Agent Work Stream Analyzer
|
|
3
|
+
*
|
|
4
|
+
* Tails agent-work-stream.jsonl in real-time, detects patterns, and emits alerts
|
|
5
|
+
* for bosun to consume.
|
|
6
|
+
*
|
|
7
|
+
* Features:
|
|
8
|
+
* - Error loop detection (same error N+ times)
|
|
9
|
+
* - Tool loop detection (same tool called rapidly)
|
|
10
|
+
* - Stuck agent detection (no progress for X minutes)
|
|
11
|
+
* - Context window exhaustion prediction
|
|
12
|
+
* - Cost anomaly detection (unusually expensive sessions)
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { readFile, writeFile, appendFile, stat, watch } from "fs/promises";
|
|
16
|
+
import { createReadStream, existsSync } from "fs";
|
|
17
|
+
import { createInterface } from "readline";
|
|
18
|
+
import { resolve, dirname } from "path";
|
|
19
|
+
import { fileURLToPath } from "url";
|
|
20
|
+
|
|
21
|
+
const __filename = fileURLToPath(import.meta.url);
|
|
22
|
+
const __dirname = dirname(__filename);
|
|
23
|
+
const repoRoot = resolve(__dirname, "../..");
|
|
24
|
+
|
|
25
|
+
// ── Configuration ───────────────────────────────────────────────────────────
|
|
26
|
+
const AGENT_WORK_STREAM = resolve(
|
|
27
|
+
repoRoot,
|
|
28
|
+
".cache/agent-work-logs/agent-work-stream.jsonl",
|
|
29
|
+
);
|
|
30
|
+
const ALERTS_LOG = resolve(
|
|
31
|
+
repoRoot,
|
|
32
|
+
".cache/agent-work-logs/agent-alerts.jsonl",
|
|
33
|
+
);
|
|
34
|
+
|
|
35
|
+
const ERROR_LOOP_THRESHOLD = Number(
|
|
36
|
+
process.env.AGENT_ERROR_LOOP_THRESHOLD || "4",
|
|
37
|
+
);
|
|
38
|
+
const ERROR_LOOP_WINDOW_MS = 10 * 60 * 1000; // 10 minutes
|
|
39
|
+
|
|
40
|
+
const TOOL_LOOP_THRESHOLD = Number(
|
|
41
|
+
process.env.AGENT_TOOL_LOOP_THRESHOLD || "10",
|
|
42
|
+
);
|
|
43
|
+
const TOOL_LOOP_WINDOW_MS = 60 * 1000; // 1 minute
|
|
44
|
+
|
|
45
|
+
const STUCK_DETECTION_THRESHOLD_MS = Number(
|
|
46
|
+
process.env.AGENT_STUCK_THRESHOLD_MS || String(5 * 60 * 1000),
|
|
47
|
+
); // 5 minutes
|
|
48
|
+
|
|
49
|
+
const COST_ANOMALY_THRESHOLD_USD = Number(
|
|
50
|
+
process.env.AGENT_COST_ANOMALY_THRESHOLD || "1.0",
|
|
51
|
+
);
|
|
52
|
+
|
|
53
|
+
// ── State Tracking ──────────────────────────────────────────────────────────
|
|
54
|
+
|
|
55
|
+
// Active session state: sessionId -> { ... }
|
|
56
|
+
const activeSessions = new Map();
|
|
57
|
+
|
|
58
|
+
// Alert cooldowns: "alert_type:attempt_id" -> timestamp
|
|
59
|
+
const alertCooldowns = new Map();
|
|
60
|
+
const ALERT_COOLDOWN_MS = 5 * 60 * 1000; // 5 minutes between same alert
|
|
61
|
+
|
|
62
|
+
// ── Log Tailing ─────────────────────────────────────────────────────────────
|
|
63
|
+
|
|
64
|
+
let filePosition = 0;
|
|
65
|
+
let isRunning = false;
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Start the analyzer loop
|
|
69
|
+
*/
|
|
70
|
+
export async function startAnalyzer() {
|
|
71
|
+
if (isRunning) return;
|
|
72
|
+
isRunning = true;
|
|
73
|
+
|
|
74
|
+
console.log("[agent-work-analyzer] Starting...");
|
|
75
|
+
|
|
76
|
+
// Ensure alerts log exists
|
|
77
|
+
if (!existsSync(ALERTS_LOG)) {
|
|
78
|
+
await writeFile(ALERTS_LOG, "");
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// Initial read of existing log
|
|
82
|
+
if (existsSync(AGENT_WORK_STREAM)) {
|
|
83
|
+
filePosition = await processLogFile(filePosition);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// Watch for changes
|
|
87
|
+
console.log(`[agent-work-analyzer] Watching: ${AGENT_WORK_STREAM}`);
|
|
88
|
+
|
|
89
|
+
const watcher = watch(AGENT_WORK_STREAM, { persistent: true });
|
|
90
|
+
|
|
91
|
+
try {
|
|
92
|
+
for await (const event of watcher) {
|
|
93
|
+
if (event.eventType === "change") {
|
|
94
|
+
filePosition = await processLogFile(filePosition);
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
} catch (err) {
|
|
98
|
+
if (err.code !== "ENOENT") {
|
|
99
|
+
console.error(`[agent-work-analyzer] Watcher error: ${err.message}`);
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Stop the analyzer
|
|
106
|
+
*/
|
|
107
|
+
export function stopAnalyzer() {
|
|
108
|
+
isRunning = false;
|
|
109
|
+
console.log("[agent-work-analyzer] Stopped");
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Process log file from given position
|
|
114
|
+
* @param {number} startPosition - Byte offset to start reading from
|
|
115
|
+
* @returns {Promise<number>} New file position
|
|
116
|
+
*/
|
|
117
|
+
async function processLogFile(startPosition) {
|
|
118
|
+
try {
|
|
119
|
+
const stats = await stat(AGENT_WORK_STREAM);
|
|
120
|
+
if (stats.size <= startPosition) {
|
|
121
|
+
return startPosition; // No new data
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
const stream = createReadStream(AGENT_WORK_STREAM, {
|
|
125
|
+
start: startPosition,
|
|
126
|
+
encoding: "utf8",
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
const rl = createInterface({ input: stream });
|
|
130
|
+
let bytesRead = startPosition;
|
|
131
|
+
|
|
132
|
+
for await (const line of rl) {
|
|
133
|
+
bytesRead += Buffer.byteLength(line, "utf8") + 1; // +1 for newline
|
|
134
|
+
|
|
135
|
+
try {
|
|
136
|
+
const event = JSON.parse(line);
|
|
137
|
+
await analyzeEvent(event);
|
|
138
|
+
} catch (err) {
|
|
139
|
+
console.error(
|
|
140
|
+
`[agent-work-analyzer] Failed to parse log line: ${err.message}`,
|
|
141
|
+
);
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
return bytesRead;
|
|
146
|
+
} catch (err) {
|
|
147
|
+
if (err.code !== "ENOENT") {
|
|
148
|
+
console.error(`[agent-work-analyzer] Error reading log: ${err.message}`);
|
|
149
|
+
}
|
|
150
|
+
return startPosition;
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
// ── Event Analysis ──────────────────────────────────────────────────────────
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* Analyze a single log event
|
|
158
|
+
* @param {Object} event - Parsed JSONL event
|
|
159
|
+
*/
|
|
160
|
+
async function analyzeEvent(event) {
|
|
161
|
+
const { attempt_id, event_type, timestamp, data } = event;
|
|
162
|
+
|
|
163
|
+
// Initialize session state if needed
|
|
164
|
+
if (!activeSessions.has(attempt_id)) {
|
|
165
|
+
activeSessions.set(attempt_id, {
|
|
166
|
+
attempt_id,
|
|
167
|
+
errors: [],
|
|
168
|
+
toolCalls: [],
|
|
169
|
+
lastActivity: timestamp,
|
|
170
|
+
startedAt: timestamp,
|
|
171
|
+
taskId: event.task_id,
|
|
172
|
+
executor: event.executor,
|
|
173
|
+
});
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
const session = activeSessions.get(attempt_id);
|
|
177
|
+
session.lastActivity = timestamp;
|
|
178
|
+
|
|
179
|
+
// Route to specific analyzers
|
|
180
|
+
switch (event_type) {
|
|
181
|
+
case "error":
|
|
182
|
+
await analyzeError(session, event);
|
|
183
|
+
break;
|
|
184
|
+
case "tool_call":
|
|
185
|
+
await analyzeToolCall(session, event);
|
|
186
|
+
break;
|
|
187
|
+
case "session_start":
|
|
188
|
+
await analyzeSessionStart(session, event);
|
|
189
|
+
break;
|
|
190
|
+
case "session_end":
|
|
191
|
+
await analyzeSessionEnd(session, event);
|
|
192
|
+
activeSessions.delete(attempt_id);
|
|
193
|
+
break;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
// Continuous checks
|
|
197
|
+
await checkStuckAgent(session, event);
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
// ── Pattern Analyzers ───────────────────────────────────────────────────────
|
|
201
|
+
|
|
202
|
+
/**
|
|
203
|
+
* Analyze error events for loops
|
|
204
|
+
*/
|
|
205
|
+
async function analyzeError(session, event) {
|
|
206
|
+
const { error_fingerprint, error_message } = event.data;
|
|
207
|
+
|
|
208
|
+
session.errors.push({
|
|
209
|
+
fingerprint: error_fingerprint || "unknown",
|
|
210
|
+
message: error_message,
|
|
211
|
+
timestamp: event.timestamp,
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
// Check for error loops
|
|
215
|
+
const cutoff = Date.now() - ERROR_LOOP_WINDOW_MS;
|
|
216
|
+
const recentErrors = session.errors.filter(
|
|
217
|
+
(e) => new Date(e.timestamp).getTime() >= cutoff,
|
|
218
|
+
);
|
|
219
|
+
|
|
220
|
+
const errorCounts = {};
|
|
221
|
+
for (const err of recentErrors) {
|
|
222
|
+
errorCounts[err.fingerprint] = (errorCounts[err.fingerprint] || 0) + 1;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
// Alert if same error repeats N+ times
|
|
226
|
+
for (const [fingerprint, count] of Object.entries(errorCounts)) {
|
|
227
|
+
if (count >= ERROR_LOOP_THRESHOLD) {
|
|
228
|
+
await emitAlert({
|
|
229
|
+
type: "error_loop",
|
|
230
|
+
attempt_id: session.attempt_id,
|
|
231
|
+
task_id: session.taskId,
|
|
232
|
+
executor: session.executor,
|
|
233
|
+
error_fingerprint: fingerprint,
|
|
234
|
+
occurrences: count,
|
|
235
|
+
sample_message:
|
|
236
|
+
recentErrors.find((e) => e.fingerprint === fingerprint)?.message ||
|
|
237
|
+
"",
|
|
238
|
+
recommendation: "trigger_ai_autofix",
|
|
239
|
+
severity: "high",
|
|
240
|
+
});
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
/**
|
|
246
|
+
* Analyze tool call events for loops
|
|
247
|
+
*/
|
|
248
|
+
async function analyzeToolCall(session, event) {
|
|
249
|
+
const { tool_name } = event.data;
|
|
250
|
+
|
|
251
|
+
session.toolCalls.push({
|
|
252
|
+
tool: tool_name,
|
|
253
|
+
timestamp: event.timestamp,
|
|
254
|
+
});
|
|
255
|
+
|
|
256
|
+
// Check for tool loops
|
|
257
|
+
const cutoff = Date.now() - TOOL_LOOP_WINDOW_MS;
|
|
258
|
+
const recentCalls = session.toolCalls.filter(
|
|
259
|
+
(c) => new Date(c.timestamp).getTime() >= cutoff,
|
|
260
|
+
);
|
|
261
|
+
|
|
262
|
+
const toolCounts = {};
|
|
263
|
+
for (const call of recentCalls) {
|
|
264
|
+
toolCounts[call.tool] = (toolCounts[call.tool] || 0) + 1;
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
// Alert if same tool called N+ times rapidly
|
|
268
|
+
for (const [tool, count] of Object.entries(toolCounts)) {
|
|
269
|
+
if (count >= TOOL_LOOP_THRESHOLD) {
|
|
270
|
+
await emitAlert({
|
|
271
|
+
type: "tool_loop",
|
|
272
|
+
attempt_id: session.attempt_id,
|
|
273
|
+
task_id: session.taskId,
|
|
274
|
+
executor: session.executor,
|
|
275
|
+
tool_name: tool,
|
|
276
|
+
occurrences: count,
|
|
277
|
+
window_ms: TOOL_LOOP_WINDOW_MS,
|
|
278
|
+
recommendation: "fresh_session",
|
|
279
|
+
severity: "medium",
|
|
280
|
+
});
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
/**
|
|
286
|
+
* Analyze session start events
|
|
287
|
+
*/
|
|
288
|
+
async function analyzeSessionStart(session, event) {
|
|
289
|
+
const { prompt_type, followup_reason } = event.data;
|
|
290
|
+
|
|
291
|
+
// Track session restarts
|
|
292
|
+
if (prompt_type === "followup" || prompt_type === "retry") {
|
|
293
|
+
session.restartCount = (session.restartCount || 0) + 1;
|
|
294
|
+
|
|
295
|
+
// Alert if too many restarts
|
|
296
|
+
if (session.restartCount >= 3) {
|
|
297
|
+
await emitAlert({
|
|
298
|
+
type: "excessive_restarts",
|
|
299
|
+
attempt_id: session.attempt_id,
|
|
300
|
+
task_id: session.taskId,
|
|
301
|
+
executor: session.executor,
|
|
302
|
+
restart_count: session.restartCount,
|
|
303
|
+
last_reason: followup_reason,
|
|
304
|
+
recommendation: "manual_review",
|
|
305
|
+
severity: "medium",
|
|
306
|
+
});
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
/**
|
|
312
|
+
* Analyze session end events
|
|
313
|
+
*/
|
|
314
|
+
async function analyzeSessionEnd(session, event) {
|
|
315
|
+
const { completion_status, duration_ms, cost_usd } = event.data;
|
|
316
|
+
|
|
317
|
+
// Cost anomaly detection
|
|
318
|
+
if (cost_usd && cost_usd > COST_ANOMALY_THRESHOLD_USD) {
|
|
319
|
+
await emitAlert({
|
|
320
|
+
type: "cost_anomaly",
|
|
321
|
+
attempt_id: session.attempt_id,
|
|
322
|
+
task_id: session.taskId,
|
|
323
|
+
executor: session.executor,
|
|
324
|
+
cost_usd,
|
|
325
|
+
duration_ms,
|
|
326
|
+
threshold_usd: COST_ANOMALY_THRESHOLD_USD,
|
|
327
|
+
recommendation: "review_prompt_efficiency",
|
|
328
|
+
severity: "low",
|
|
329
|
+
});
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
// Failed session with many errors
|
|
333
|
+
if (
|
|
334
|
+
completion_status === "failed" &&
|
|
335
|
+
session.errors.length >= ERROR_LOOP_THRESHOLD
|
|
336
|
+
) {
|
|
337
|
+
await emitAlert({
|
|
338
|
+
type: "failed_session_high_errors",
|
|
339
|
+
attempt_id: session.attempt_id,
|
|
340
|
+
task_id: session.taskId,
|
|
341
|
+
executor: session.executor,
|
|
342
|
+
error_count: session.errors.length,
|
|
343
|
+
error_fingerprints: [
|
|
344
|
+
...new Set(session.errors.map((e) => e.fingerprint)),
|
|
345
|
+
],
|
|
346
|
+
recommendation: "analyze_root_cause",
|
|
347
|
+
severity: "high",
|
|
348
|
+
});
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
/**
|
|
353
|
+
* Check if agent appears stuck (no activity for X minutes)
|
|
354
|
+
*/
|
|
355
|
+
async function checkStuckAgent(session, event) {
|
|
356
|
+
const lastActivityTime = new Date(session.lastActivity).getTime();
|
|
357
|
+
const timeSinceActivity = Date.now() - lastActivityTime;
|
|
358
|
+
|
|
359
|
+
if (timeSinceActivity > STUCK_DETECTION_THRESHOLD_MS) {
|
|
360
|
+
await emitAlert({
|
|
361
|
+
type: "stuck_agent",
|
|
362
|
+
attempt_id: session.attempt_id,
|
|
363
|
+
task_id: session.taskId,
|
|
364
|
+
executor: session.executor,
|
|
365
|
+
idle_time_ms: timeSinceActivity,
|
|
366
|
+
threshold_ms: STUCK_DETECTION_THRESHOLD_MS,
|
|
367
|
+
recommendation: "check_agent_health",
|
|
368
|
+
severity: "medium",
|
|
369
|
+
});
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
// ── Alert System ────────────────────────────────────────────────────────────
|
|
374
|
+
|
|
375
|
+
/**
|
|
376
|
+
* Emit an alert to the alerts log
|
|
377
|
+
* @param {Object} alert - Alert data
|
|
378
|
+
*/
|
|
379
|
+
async function emitAlert(alert) {
|
|
380
|
+
const alertKey = `${alert.type}:${alert.attempt_id}`;
|
|
381
|
+
|
|
382
|
+
// Check cooldown
|
|
383
|
+
const lastAlert = alertCooldowns.get(alertKey);
|
|
384
|
+
if (lastAlert && Date.now() - lastAlert < ALERT_COOLDOWN_MS) {
|
|
385
|
+
return; // Skip duplicate alerts
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
alertCooldowns.set(alertKey, Date.now());
|
|
389
|
+
|
|
390
|
+
const alertEntry = {
|
|
391
|
+
timestamp: new Date().toISOString(),
|
|
392
|
+
...alert,
|
|
393
|
+
};
|
|
394
|
+
|
|
395
|
+
console.error(`[ALERT] ${alert.type}: ${alert.attempt_id}`);
|
|
396
|
+
|
|
397
|
+
// Append to alerts log
|
|
398
|
+
try {
|
|
399
|
+
await appendFile(ALERTS_LOG, JSON.stringify(alertEntry) + "\n");
|
|
400
|
+
} catch (err) {
|
|
401
|
+
console.error(`[agent-work-analyzer] Failed to write alert: ${err.message}`);
|
|
402
|
+
}
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
// ── Cleanup Old Sessions ────────────────────────────────────────────────────
|
|
406
|
+
|
|
407
|
+
setInterval(() => {
|
|
408
|
+
const cutoff = Date.now() - 60 * 60 * 1000; // 1 hour
|
|
409
|
+
|
|
410
|
+
for (const [attemptId, session] of activeSessions.entries()) {
|
|
411
|
+
const lastActivityTime = new Date(session.lastActivity).getTime();
|
|
412
|
+
if (lastActivityTime < cutoff) {
|
|
413
|
+
activeSessions.delete(attemptId);
|
|
414
|
+
}
|
|
415
|
+
}
|
|
416
|
+
}, 10 * 60 * 1000); // Cleanup every 10 minutes
|
|
417
|
+
|
|
418
|
+
// ── Exports ─────────────────────────────────────────────────────────────────
|
|
419
|
+
|
package/claude-shell.mjs
CHANGED
|
@@ -430,12 +430,22 @@ async function saveState() {
|
|
|
430
430
|
}
|
|
431
431
|
}
|
|
432
432
|
|
|
433
|
+
function extractTaskHeading(msg) {
|
|
434
|
+
// Prompt first line is "# TASKID — Task Title" (from _buildTaskPrompt).
|
|
435
|
+
// Return just the task title portion, or a short fallback.
|
|
436
|
+
const firstLine = msg.split(/\r?\n/)[0].replace(/^#+\s*/, '').trim();
|
|
437
|
+
const dashIdx = firstLine.indexOf(' \u2014 ');
|
|
438
|
+
const heading = dashIdx !== -1 ? firstLine.slice(dashIdx + 3).trim() : firstLine;
|
|
439
|
+
return heading || 'Execute Task';
|
|
440
|
+
}
|
|
441
|
+
|
|
433
442
|
function buildPrompt(userMessage, statusData) {
|
|
443
|
+
const title = extractTaskHeading(userMessage);
|
|
434
444
|
if (!statusData) {
|
|
435
|
-
return `#
|
|
445
|
+
return `# ${title}\n\n${userMessage}\n\n---\nDo NOT respond with "Ready" or ask what to do. EXECUTE this task. Read files, run commands, produce detailed output.`;
|
|
436
446
|
}
|
|
437
447
|
const statusSnippet = JSON.stringify(statusData, null, 2).slice(0, 2000);
|
|
438
|
-
return `[Orchestrator Status]\n\`\`\`json\n${statusSnippet}\n\`\`\`\n\n#
|
|
448
|
+
return `[Orchestrator Status]\n\`\`\`json\n${statusSnippet}\n\`\`\`\n\n# ${title}\n\n${userMessage}\n\n---\nDo NOT respond with "Ready" or ask what to do. EXECUTE this task. Read files, run commands, produce detailed output.`;
|
|
439
449
|
}
|
|
440
450
|
|
|
441
451
|
// ── Main Execution ─────────────────────────────────────────────────────────
|
package/desktop/main.mjs
CHANGED
|
@@ -3,6 +3,8 @@ import { dirname, join, resolve } from "node:path";
|
|
|
3
3
|
import { fileURLToPath, pathToFileURL } from "node:url";
|
|
4
4
|
import { existsSync, readFileSync } from "node:fs";
|
|
5
5
|
import { execFileSync, spawn } from "node:child_process";
|
|
6
|
+
import { request as httpRequest } from "node:http";
|
|
7
|
+
import { request as httpsRequest } from "node:https";
|
|
6
8
|
import { homedir } from "node:os";
|
|
7
9
|
|
|
8
10
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
@@ -12,6 +14,7 @@ let shuttingDown = false;
|
|
|
12
14
|
let uiServerStarted = false;
|
|
13
15
|
let uiOrigin = null;
|
|
14
16
|
let uiApi = null;
|
|
17
|
+
let runtimeConfigLoaded = false;
|
|
15
18
|
|
|
16
19
|
const DAEMON_PID_FILE = resolve(homedir(), ".cache", "bosun", "daemon.pid");
|
|
17
20
|
|
|
@@ -86,12 +89,77 @@ async function loadBosunModule(file) {
|
|
|
86
89
|
return import(pathToFileURL(modulePath).href);
|
|
87
90
|
}
|
|
88
91
|
|
|
92
|
+
async function loadRuntimeConfig() {
|
|
93
|
+
if (runtimeConfigLoaded) return;
|
|
94
|
+
try {
|
|
95
|
+
const config = await loadBosunModule("config.mjs");
|
|
96
|
+
if (typeof config?.loadConfig === "function") {
|
|
97
|
+
config.loadConfig(["node", "desktop"], { reloadEnv: true });
|
|
98
|
+
}
|
|
99
|
+
} catch (err) {
|
|
100
|
+
console.warn("[desktop] failed to load config env", err?.message || err);
|
|
101
|
+
}
|
|
102
|
+
runtimeConfigLoaded = true;
|
|
103
|
+
}
|
|
104
|
+
|
|
89
105
|
async function loadUiServerModule() {
|
|
90
106
|
if (uiApi) return uiApi;
|
|
91
107
|
uiApi = await loadBosunModule("ui-server.mjs");
|
|
92
108
|
return uiApi;
|
|
93
109
|
}
|
|
94
110
|
|
|
111
|
+
function buildDaemonUiBaseUrl() {
|
|
112
|
+
const rawPort = Number(process.env.TELEGRAM_UI_PORT || "0");
|
|
113
|
+
if (!Number.isFinite(rawPort) || rawPort <= 0) return null;
|
|
114
|
+
const tlsDisabled = parseBoolEnv(process.env.TELEGRAM_UI_TLS_DISABLE, false);
|
|
115
|
+
const protocol = tlsDisabled ? "http" : "https";
|
|
116
|
+
const host =
|
|
117
|
+
process.env.TELEGRAM_UI_DESKTOP_HOST ||
|
|
118
|
+
process.env.TELEGRAM_UI_HOST ||
|
|
119
|
+
"127.0.0.1";
|
|
120
|
+
return `${protocol}://${host}:${rawPort}`;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
async function probeUiServer(url) {
|
|
124
|
+
return new Promise((resolve) => {
|
|
125
|
+
try {
|
|
126
|
+
const isHttps = url.startsWith("https://");
|
|
127
|
+
const req = (isHttps ? httpsRequest : httpRequest)(
|
|
128
|
+
`${url}/api/status`,
|
|
129
|
+
{
|
|
130
|
+
method: "GET",
|
|
131
|
+
timeout: 1500,
|
|
132
|
+
rejectUnauthorized: false,
|
|
133
|
+
},
|
|
134
|
+
(res) => {
|
|
135
|
+
res.resume();
|
|
136
|
+
resolve(Boolean(res.statusCode && res.statusCode < 500));
|
|
137
|
+
},
|
|
138
|
+
);
|
|
139
|
+
req.on("error", () => resolve(false));
|
|
140
|
+
req.on("timeout", () => {
|
|
141
|
+
req.destroy();
|
|
142
|
+
resolve(false);
|
|
143
|
+
});
|
|
144
|
+
req.end();
|
|
145
|
+
} catch {
|
|
146
|
+
resolve(false);
|
|
147
|
+
}
|
|
148
|
+
});
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
async function resolveDaemonUiUrl() {
|
|
152
|
+
const useDaemon = parseBoolEnv(
|
|
153
|
+
process.env.BOSUN_DESKTOP_USE_DAEMON_UI,
|
|
154
|
+
true,
|
|
155
|
+
);
|
|
156
|
+
if (!useDaemon) return null;
|
|
157
|
+
const base = buildDaemonUiBaseUrl();
|
|
158
|
+
if (!base) return null;
|
|
159
|
+
const ok = await probeUiServer(base);
|
|
160
|
+
return ok ? base : null;
|
|
161
|
+
}
|
|
162
|
+
|
|
95
163
|
async function ensureDaemonRunning() {
|
|
96
164
|
const autoStart = parseBoolEnv(
|
|
97
165
|
process.env.BOSUN_DESKTOP_AUTO_START_DAEMON,
|
|
@@ -148,6 +216,13 @@ async function startUiServer() {
|
|
|
148
216
|
}
|
|
149
217
|
|
|
150
218
|
async function buildUiUrl() {
|
|
219
|
+
await loadRuntimeConfig();
|
|
220
|
+
const daemonUrl = await resolveDaemonUiUrl();
|
|
221
|
+
if (daemonUrl) {
|
|
222
|
+
uiOrigin = new URL(daemonUrl).origin;
|
|
223
|
+
return daemonUrl;
|
|
224
|
+
}
|
|
225
|
+
await startUiServer();
|
|
151
226
|
const api = await loadUiServerModule();
|
|
152
227
|
const uiServerUrl = api.getTelegramUiUrl();
|
|
153
228
|
if (!uiServerUrl) {
|
|
@@ -196,8 +271,8 @@ async function bootstrap() {
|
|
|
196
271
|
try {
|
|
197
272
|
app.setAppUserModelId("com.virtengine.bosun");
|
|
198
273
|
process.chdir(resolveBosunRoot());
|
|
274
|
+
await loadRuntimeConfig();
|
|
199
275
|
await ensureDaemonRunning();
|
|
200
|
-
await startUiServer();
|
|
201
276
|
await createMainWindow();
|
|
202
277
|
await maybeAutoUpdate();
|
|
203
278
|
} catch (error) {
|
|
@@ -231,8 +306,10 @@ async function shutdown(reason) {
|
|
|
231
306
|
}
|
|
232
307
|
|
|
233
308
|
try {
|
|
234
|
-
|
|
235
|
-
|
|
309
|
+
if (uiServerStarted) {
|
|
310
|
+
const api = await loadUiServerModule();
|
|
311
|
+
api.stopTelegramUiServer();
|
|
312
|
+
}
|
|
236
313
|
} catch (error) {
|
|
237
314
|
console.error("[desktop] failed to stop ui-server", error);
|
|
238
315
|
}
|
|
@@ -243,7 +320,7 @@ async function shutdown(reason) {
|
|
|
243
320
|
app.on("before-quit", () => {
|
|
244
321
|
shuttingDown = true;
|
|
245
322
|
try {
|
|
246
|
-
if (uiApi?.stopTelegramUiServer) {
|
|
323
|
+
if (uiServerStarted && uiApi?.stopTelegramUiServer) {
|
|
247
324
|
uiApi.stopTelegramUiServer();
|
|
248
325
|
}
|
|
249
326
|
} catch (error) {
|