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/.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
|
|
@@ -155,6 +169,13 @@ TELEGRAM_MINIAPP_ENABLED=false
|
|
|
155
169
|
# Priority threshold for immediate delivery: 1=critical only, 2=critical+errors (default: 1)
|
|
156
170
|
# TELEGRAM_IMMEDIATE_PRIORITY=1
|
|
157
171
|
|
|
172
|
+
# ─── Auto-Delete Old Messages ──────────────────────────────────────────────────────────
|
|
173
|
+
# Automatically delete bot messages older than N days to keep chat tidy.
|
|
174
|
+
# Set to 0 to disable. Default: 3 days.
|
|
175
|
+
# Note: Telegram’s API may silently skip messages older than 48 h in private
|
|
176
|
+
# chats — those will just remain; no error is raised.
|
|
177
|
+
# TELEGRAM_HISTORY_RETENTION_DAYS=3
|
|
178
|
+
|
|
158
179
|
# ─── Presence & Multi-Instance Coordination ──────────────────────────────────
|
|
159
180
|
# Presence heartbeat allows discovering multiple bosun instances.
|
|
160
181
|
# Heartbeat interval in seconds (default: 60)
|
package/README.md
CHANGED
|
@@ -1,12 +1,23 @@
|
|
|
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
|
|
|
5
|
-
|
|
8
|
+
<p align="center">
|
|
9
|
+
<a href="https://bosun.virtengine.com">Website</a> · <a href="https://bosun.virtengine.com/docs/">Docs</a> · <a href="https://github.com/virtengine/bosun?tab=readme-ov-file#bosun">GitHub</a> · <a href="https://www.npmjs.com/package/bosun">npm</a> · <a href="https://github.com/virtengine/bosun/issues">Issues</a>
|
|
10
|
+
</p>
|
|
6
11
|
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
12
|
+
<p align="center">
|
|
13
|
+
<img src="site/social-banner.png" alt="bosun — AI agent supervisor" width="100%" />
|
|
14
|
+
</p>
|
|
15
|
+
|
|
16
|
+
<p align="center">
|
|
17
|
+
<a href="https://github.com/virtengine/bosun/actions/workflows/ci.yaml"><img src="https://github.com/virtengine/bosun/actions/workflows/ci.yaml/badge.svg?branch=main" alt="CI" /></a>
|
|
18
|
+
<a href="LICENSE"><img src="https://img.shields.io/badge/license-Apache%202.0-blue.svg" alt="License" /></a>
|
|
19
|
+
<a href="https://www.npmjs.com/package/bosun"><img src="https://img.shields.io/npm/v/bosun.svg" alt="npm" /></a>
|
|
20
|
+
</p>
|
|
10
21
|
|
|
11
22
|
---
|
|
12
23
|
|
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/launch.mjs
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { existsSync } from "node:fs";
|
|
1
|
+
import { existsSync, statSync } from "node:fs";
|
|
2
2
|
import { dirname, resolve } from "node:path";
|
|
3
3
|
import { fileURLToPath } from "node:url";
|
|
4
4
|
import { spawnSync, spawn } from "node:child_process";
|
|
@@ -7,6 +7,28 @@ const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
|
7
7
|
const desktopDir = resolve(__dirname);
|
|
8
8
|
const binName = process.platform === "win32" ? "electron.cmd" : "electron";
|
|
9
9
|
const electronBin = resolve(desktopDir, "node_modules", ".bin", binName);
|
|
10
|
+
const chromeSandbox = resolve(
|
|
11
|
+
desktopDir,
|
|
12
|
+
"node_modules",
|
|
13
|
+
"electron",
|
|
14
|
+
"dist",
|
|
15
|
+
"chrome-sandbox",
|
|
16
|
+
);
|
|
17
|
+
|
|
18
|
+
function shouldDisableSandbox() {
|
|
19
|
+
if (process.env.BOSUN_DESKTOP_DISABLE_SANDBOX === "1") return true;
|
|
20
|
+
if (process.platform !== "linux") return false;
|
|
21
|
+
if (!existsSync(chromeSandbox)) return true;
|
|
22
|
+
try {
|
|
23
|
+
const stats = statSync(chromeSandbox);
|
|
24
|
+
const mode = stats.mode & 0o7777;
|
|
25
|
+
const isRootOwned = stats.uid === 0;
|
|
26
|
+
const isSetuid = mode === 0o4755;
|
|
27
|
+
return !(isRootOwned && isSetuid);
|
|
28
|
+
} catch {
|
|
29
|
+
return true;
|
|
30
|
+
}
|
|
31
|
+
}
|
|
10
32
|
|
|
11
33
|
function ensureElectronInstalled() {
|
|
12
34
|
if (existsSync(electronBin)) return true;
|
|
@@ -28,11 +50,18 @@ function launch() {
|
|
|
28
50
|
process.exit(1);
|
|
29
51
|
}
|
|
30
52
|
|
|
31
|
-
const
|
|
53
|
+
const disableSandbox = shouldDisableSandbox();
|
|
54
|
+
const args = [desktopDir];
|
|
55
|
+
if (disableSandbox) {
|
|
56
|
+
args.push("--no-sandbox", "--disable-gpu-sandbox");
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const child = spawn(electronBin, args, {
|
|
32
60
|
stdio: "inherit",
|
|
33
61
|
env: {
|
|
34
62
|
...process.env,
|
|
35
63
|
BOSUN_DESKTOP: "1",
|
|
64
|
+
...(disableSandbox ? { ELECTRON_DISABLE_SANDBOX: "1" } : {}),
|
|
36
65
|
},
|
|
37
66
|
});
|
|
38
67
|
|